diff --git a/api/requirements.txt b/api/requirements.txt index 5e52b61..771ac38 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,4 +1,8 @@ fastapi +fastapi-filter +fastapi-pagination fastapi-users[beanie,oauth] +httpx-oauth +jinja2 uvicorn -httpx-oauth \ No newline at end of file +weasyprint diff --git a/api/rpk-api/firm/__init__.py b/api/rpk-api/firm/__init__.py index e69de29..4ffc770 100644 --- a/api/rpk-api/firm/__init__.py +++ b/api/rpk-api/firm/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from firm.entity import entity_router +from firm.template import template_router +from firm.contract import contract_router + + +firm_router = APIRouter(prefix="/{instance}/{firm}") + +firm_router.include_router(entity_router, prefix="/entities", tags=["Entity"], ) +firm_router.include_router(template_router, prefix="/templates", tags=["Template"], ) +firm_router.include_router(contract_router, prefix="/contracts", ) diff --git a/api/rpk-api/firm/contract/__init__.py b/api/rpk-api/firm/contract/__init__.py new file mode 100644 index 0000000..b202e76 --- /dev/null +++ b/api/rpk-api/firm/contract/__init__.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +from firm.contract.routes_contract import contract_router as contract_subrouter +from firm.contract.routes_signature import signature_router +from firm.contract.routes_draft import draft_router +from firm.contract.print import print_router, preview_router + +contract_router = APIRouter() +contract_router.include_router(draft_router, prefix="/draft", tags=["Contract Draft"], ) +contract_router.include_router(contract_subrouter, tags=["Contract"], ) +contract_router.include_router(preview_router, prefix="/preview", ) +contract_router.include_router(print_router, prefix="/print", ) +contract_router.include_router(signature_router, prefix="/signature", tags=["Signature"], ) diff --git a/api/rpk-api/firm/contract/models.py b/api/rpk-api/firm/contract/models.py new file mode 100644 index 0000000..072f760 --- /dev/null +++ b/api/rpk-api/firm/contract/models.py @@ -0,0 +1,263 @@ +import datetime +from typing import List, Literal, Optional +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, Field + +from firm.core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry +from firm.core.filter import Filter, FilterSchema +from firm.entity.models import Entity + + +class ContractStatus(str, Enum): + published = 'published' + signed = 'signed' + printed = 'printed' + executed = 'executed' + + +class ContractDraftStatus(str, Enum): + in_progress = 'in_progress' + ready = 'ready' + published = 'published' + + +class DraftParty(BaseModel): + entity_id: str = Field( + foreignKey={ + "reference": { + "resource": "entity", + "schema": "Entity", + } + }, + default="", + title="Partie" + ) + part: str = Field(title="Rôle") + representative_id: str = Field( + foreignKey={ + "reference": { + "resource": "entity", + "schema": "Entity", + } + }, + default="", + title="Représentant" + ) + + class Config: + title = 'Partie' + + +class Party(BaseModel): + entity: Entity + part: str + representative: Optional[Entity] = None + signature_uuid: str + signature_affixed: bool = False + signature_png: Optional[str] = None + +class ContractProvisionType(Enum): + genuine = 'genuine' + template = 'template' + +class ProvisionGenuine(BaseModel): + type: Literal['genuine'] = ContractProvisionType.genuine + title: str = RichtextSingleline(props={"parametrized": True}, default="", title="Titre") + body: str = RichtextMultiline(props={"parametrized": True}, default="", title="Corps") + + class Config: + title = 'Clause personalisée' + + +class ContractProvisionTemplateReference(BaseModel): + type: Literal['template'] = ContractProvisionType.template + provision_template_id: str = Field( + foreignKey={ + "reference": { + "resource": "template/provision", + "schema": "ProvisionTemplate", + "displayedFields": ['title', 'body'] + }, + }, + props={"parametrized": True}, + default="", + title="Template de clause" + ) + + class Config: + title = 'Template de clause' + + +class DraftProvision(BaseModel): + provision: ContractProvisionTemplateReference | ProvisionGenuine = Field(..., discriminator='type') + + class Config: + title = 'Clause' + + +class Provision(BaseModel): + title: str = RichtextSingleline(title="Titre") + body: str = RichtextMultiline(title="Corps") + +class ContractDraftUpdateStatus(BaseModel): + status: str = Field() + todo: List[str] = Field(default=[]) + +class ContractDraft(CrudDocument): + """ + Brouillon de contrat à remplir + """ + + name: str = Field(title="Nom") + title: str = Field(title="Titre") + parties: List[DraftParty] = Field(title="Parties") + provisions: List[DraftProvision] = Field( + props={"items-per-row": "1", "numbered": True}, + title='Clauses' + ) + variables: List[DictionaryEntry] = Field( + default=[], + format="dictionary", + title='Variables' + ) + status: ContractDraftStatus = Field(default=ContractDraftStatus.in_progress, title="Statut") + todo: List[str] = Field(default=[], title="Reste à faire") + + class Settings(CrudDocument.Settings): + fulltext_search = ['name', 'title'] + + bson_encoders = { + datetime.date: lambda dt: dt if hasattr(dt, 'hour') + else datetime.datetime(year=dt.year, month=dt.month, day=dt.day, + hour=0, minute=0, second=0) + } + + class Config: + title = 'Brouillon de contrat' + + async def check_is_ready(self, db): + if self.status == ContractDraftStatus.published: + return + + self.todo = [] + if len(self.parties) < 2: + self.todo.append('Contract must have at least two parties') + if len(self.provisions) < 1: + self.todo.append('Contract must have at least one provision') + + for p in self.parties: + if not p.entity_id: + self.todo.append('All parties must have an associated entity`') + + for p in self.provisions: + if p.provision.type == "genuine" and not (p.provision.title and p.provision.body): + self.todo.append('Empty genuine provision') + elif p.provision.type == "template" and not p.provision.provision_template_id: + self.todo.append('Empty template provision') + + for v in self.variables: + if not (v.key and v.value): + self.todo.append(f'Empty variable: {v.key}') + + if self.todo: + self.status = ContractDraftStatus.in_progress + else: + self.status = ContractDraftStatus.ready + + await self.update(db, self, ContractDraftUpdateStatus(status=self.status, todo=self.todo)) + + async def update_status(self, db, status): + update = ContractDraftUpdateStatus(status=status) + await self.update(db, self, update) + +class Contract(CrudDocument): + """ + Contrat publié. Les contrats ne peuvent pas être modifiés. + Ils peuvent seulement être signés par les parties et imprimés par l'avocat + """ + name: str = Field(title="Nom") + title: str = Field(title="Titre") + parties: List[Party] = Field(title="Parties") + provisions: List[Provision] = Field( + props={"items-per-row": "1", "numbered": True}, + title='Clauses' + ) + status: ContractStatus = Field(default=ContractStatus.published, title="Statut") + lawyer: Entity = Field(title="Avocat en charge") + location: str = Field(title="Lieu") + date: datetime.date = Field(title="Date") + + def compute_label(self) -> str: + contract_label = self.title + for p in self.parties: + contract_label = f"{contract_label} - {p.entity.label}" + + contract_label = f"{contract_label} - {self.date.strftime('%m/%d/%Y')}" + return contract_label + + class Settings(CrudDocument.Settings): + fulltext_search = ['name', 'title'] + + bson_encoders = { + datetime.date: lambda dt: dt if hasattr(dt, 'hour') + else datetime.datetime(year=dt.year, month=dt.month, day=dt.day, + hour=0, minute=0, second=0) + } + + @classmethod + async def find_by_signature_id(cls, db, signature_id: UUID): + request = {'parties': {"$elemMatch": {"signature_uuid": str(signature_id) }}} + value = await cls._get_collection(db).find_one(request) + return cls.model_validate(value) if value else None + + def get_signature(self, signature_id: str): + for p in self.parties: + if p.signature_uuid == str(signature_id): + return p + + def get_signature_index(self, signature_id: str): + for i, p in enumerate(self.parties): + if p.signature_uuid == str(signature_id): + return i + + def is_signed(self): + for p in self.parties: + if not p.signature_affixed: + return False + return True + + async def affix_signature(self, db, signature_index): + update_query = {"$set": { + f'parties.{signature_index}.signature_affixed': True + }} + + self.parties[signature_index].signature_affixed = True + if self.is_signed(): + update_query["$set"]['status'] = 'signed' + + await self._get_collection(db).update_one({"_id": self.id}, update_query) + return await self.get(db, self.id) + + +def replace_variables_in_value(variables, value: str): + for v in variables: + if v.value: + value = value.replace('%{}%'.format(v.key), v.value) + return value + + +class ContractDraftFilters(FilterSchema): + status: Optional[str] = None + + class Constants(Filter.Constants): + model = ContractDraft + search_model_fields = ["label", "status"] + +class ContractFilters(FilterSchema): + status: Optional[str] = None + + class Constants(Filter.Constants): + model = Contract + search_model_fields = ["label", "status"] diff --git a/api/rpk-api/firm/contract/print/__init__.py b/api/rpk-api/firm/contract/print/__init__.py new file mode 100644 index 0000000..8aa435d --- /dev/null +++ b/api/rpk-api/firm/contract/print/__init__.py @@ -0,0 +1,156 @@ +import datetime +import os +import base64 +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Request, Depends +from fastapi.responses import HTMLResponse, FileResponse +from fastapi.templating import Jinja2Templates + +from weasyprint import HTML, CSS +from weasyprint.text.fonts import FontConfiguration + +from pathlib import Path + +from firm.core.routes import get_tenant_db_cursor +from firm.entity.models import Entity +from firm.template.models import ProvisionTemplate +from firm.contract.models import ContractDraft, Contract, ContractStatus, replace_variables_in_value + + +async def build_model(model): + parties = [] + for p in model.parties: + party = { + "entity": await Entity.get(p.entity_id), + "part": p.part + } + if p.representative_id: + party['representative'] = await Entity.get(p.representative_id) + + parties.append(party) + + model.parties = parties + + provisions = [] + for p in model.provisions: + if p.provision.type == "template": + provision = await ProvisionTemplate.get(p.provision.provision_template_id) + else: + provision = p.provision + + provision.title = replace_variables_in_value(model.variables, provision.title) + provision.body = replace_variables_in_value(model.variables, provision.body) + provisions.append(provision) + + model.provisions = provisions + + model = model.dict() + model['location'] = "Los Santos, SA" + model['date'] = datetime.date(1970, 1, 1) + model['lawyer'] = {'entity_data': { + "firstname": "prénom avocat", + "lastname": "nom avocat", + }} + return model + + +BASE_PATH = Path(__file__).resolve().parent + + +templates = Jinja2Templates(directory=str(BASE_PATH / "templates")) + + +async def render_print(root_url, contract): + template = templates.get_template("print.html") + return template.render({ + "contract": contract, + "root_url": root_url + }) + + +async def render_css(root_url, contract): + template = templates.get_template("styles.css") + return template.render({ + "contract": contract, + "root_url": root_url + }) + + +def retrieve_signature_png(filepath): + with open(filepath, "rb") as f: + b_content = f.read() + base64_utf8_str = base64.b64encode(b_content).decode('utf-8') + ext = filepath.split('.')[-1] + return f'data:image/{ext};base64,{base64_utf8_str}' + + +preview_router = APIRouter() +@preview_router.get("/draft/{draft_id}", response_class=HTMLResponse, tags=["Contract Draft"]) +async def preview_draft(draft_id: str, db=Depends(get_tenant_db_cursor)) -> str: + draft = await build_model(await ContractDraft.get(db, draft_id)) + + return await render_print('', draft) + + +@preview_router.get("/signature/{signature_id}", response_class=HTMLResponse, tags=["Signature"]) +async def preview_contract_by_signature(signature_id: UUID, db=Depends(get_tenant_db_cursor)) -> str: + contract = await Contract.find_by_signature_id(db, signature_id) + for p in contract.parties: + if p.signature_affixed: + p.signature_png = retrieve_signature_png(f'media/signatures/{p.signature_uuid}.png') + + return await render_print('', contract) + + +@preview_router.get("/{contract_id}", response_class=HTMLResponse, tags=["Contract"]) +async def preview_contract(contract_id: str, db=Depends(get_tenant_db_cursor)) -> str: + contract = await Contract.get(db, contract_id) + for p in contract.parties: + if p.signature_affixed: + p.signature_png = retrieve_signature_png(f'media/signatures/{p.signature_uuid}.png') + + return await render_print('', contract) + + +print_router = APIRouter() +@print_router.get("/pdf/{contract_id}", response_class=FileResponse, tags=["Contract"]) +async def create_pdf(contract_id: str, db=Depends(get_tenant_db_cursor)) -> str: + contract = await Contract.get(db, contract_id) + contract_path = "media/contracts/{}.pdf".format(contract_id) + if not os.path.isfile(contract_path): + if contract.status != ContractStatus.signed: + raise HTTPException(status_code=400, detail="Contract is not in a printable state") + + for p in contract.parties: + signature_path = f'media/signatures/{p.signature_uuid}.png' + p.signature_png = retrieve_signature_png(signature_path) + # os.remove(signature_path) + + font_config = FontConfiguration() + html = HTML(string=await render_print('http://nginx', contract)) + css = CSS(string=await render_css('http://nginx', contract), font_config=font_config) + + html.write_pdf(contract_path, stylesheets=[css], font_config=font_config) + + await contract.update_status(db, 'printed') + + return FileResponse( + contract_path, + media_type="application/pdf", + filename=contract.label) + + +@print_router.get("/opengraph/{signature_id}", response_class=HTMLResponse, tags=["Signature"]) +async def get_signature_opengraph(signature_id: str, request: Request, db=Depends(get_tenant_db_cursor)) -> str: + contract = await Contract.find_by_signature_id(db, signature_id) + signature = contract.get_signature(signature_id) + template = templates.get_template("opengraph.html") + + signatory = signature.representative.label if signature.representative else signature.entity.label + + return template.render({ + "signatory": signatory, + "title": contract.label, + "origin_url": f"{request.url.scheme}://{request.url.hostname}" + }) diff --git a/api/rpk-api/firm/contract/print/templates/opengraph.html b/api/rpk-api/firm/contract/print/templates/opengraph.html new file mode 100644 index 0000000..34ce2bf --- /dev/null +++ b/api/rpk-api/firm/contract/print/templates/opengraph.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/api/rpk-api/firm/contract/print/templates/print.html b/api/rpk-api/firm/contract/print/templates/print.html new file mode 100644 index 0000000..a0340bf --- /dev/null +++ b/api/rpk-api/firm/contract/print/templates/print.html @@ -0,0 +1,76 @@ + + + + + +
+
+ + + +
Cooper, Hillman & Toshi LLP
6834 Innocence Boulevard
LOS SANTOS - SA
consulting@cht.law.com
+

{{ contract.title|upper }}

+
+
+

Introduction

+

Le {{ contract.date.strftime('%d/%m/%Y') }} à {{ contract.location}}

+

Entre les soussignés :

+ {% for party in contract.parties %} +
+ {% if not loop.first %} +

ET

+ {% endif %} +

+ {% if party.entity.entity_data.type == "corporation" %} + {{ party.entity.entity_data.title }} société de {{ party.entity.entity_data.activity }} enregistrée auprès du gouvernement de San Andreas et domiciliée au {{ party.entity.address }}{% if party.representative %}, représentée par {{ party.representative.entity_data.firstname }} {{ party.representative.entity_data.middlenames }} {{ party.representative.entity_data.lastname }}{% endif %} + {% elif party.entity.entity_data.type == "individual" %} + {{ party.entity.entity_data.firstname }} {{ party.entity.entity_data.middlenames }} {{ party.entity.entity_data.lastname }} + {% if party.entity.entity_data.day_of_birth %} né le {{ party.entity.entity_data.day_of_birth.strftime('%d/%m/%Y') }} {% if party.entity.entity_data.place_of_birth %} à {{ party.entity.entity_data.place_of_birth }}{% endif %},{% endif %} + {% if party.entity.address %} résidant à {{ party.entity.address }}, {% endif %} + {% elif party.entity.entity_data.type == "institution" %} + + {% endif %} +

+

Ci-après dénommé {{ party.part|safe }}

+ {% if loop.first %} +

d'une part

+ {% endif %} +
+ {% endfor %} +

d'autre part

+

Sous la supervision légale de Maître {{ contract.lawyer.entity_data.firstname }} {{ contract.lawyer.entity_data.lastname }}

+

Il a été convenu l'exécution des prestations ci-dessous, conformément aux conditions générales et particulières ci-après:

+
+
+
+

Conditions générales & particulières

+ + {% for provision in contract.provisions %} +
+

Article {{loop.index}} - {{ provision.title|safe }}

+

{{ provision.body|safe }}

+
+ {% endfor %} + + +
+ + diff --git a/api/rpk-api/firm/contract/print/templates/styles.css b/api/rpk-api/firm/contract/print/templates/styles.css new file mode 100644 index 0000000..8f694d0 --- /dev/null +++ b/api/rpk-api/firm/contract/print/templates/styles.css @@ -0,0 +1,147 @@ + +@font-face { + font-family: 'Century Schoolbook'; + src: url('{{ root_url }}/assets/century-schoolbook/CenturySchoolbookRegular.ttf'); +} + +@font-face { + font-family: "Century Schoolbook"; + src: url("{{ root_url }}/assets/century-schoolbook/CenturySchoolbookBold.ttf"); + font-weight: bold; +} + +@font-face { + font-family: "Century Schoolbook"; + src: url("{{ root_url }}/assets/century-schoolbook/CenturySchoolbookItalic.ttf"); + font-style: italic; +} + +@font-face { + font-family: "Century Schoolbook"; + src: url("{{ root_url }}/assets/century-schoolbook/CenturySchoolbookBoldItalic.ttf"); + font-weight: bold; + font-style: italic; +} + +@page{ + size: a4 portrait; + margin: 2cm 2cm 2cm 2cm; + counter-increment: page; + @bottom-center { + content: "© Cooper, Hillman & Toshi LLC - {{ contract.name }} - Page " counter(page) "/" counter(pages); + font-size: 0.8em; + } + background: url('{{ root_url }}/assets/watermark.png') no-repeat; + background-size:contain; +} + +@page:first { + background: none; +} + +body { + font-size:1em; + width:17cm; + font-family: 'Century Schoolbook'; +} + +#front-page-header { + page-break-inside: avoid; +} + +#front-page-header table { + width: 100%; +} + +#top-logo { + width: 5cm; + width: 5cm; + border: solid 1px black; +} + +#office-info { + text-align: right; + vertical-align: middle; +} + +h1 { + background: black; + color: white; + text-align: center; + font-size: 2.6em; + padding: 13px 0; + margin: 50px 0; + font-weight: bold; +} + +h2 { + background: lightgrey; + font-size: 1.6em; + padding: 8px 0; + font-weight: bold; +} + +.intro { + page-break-inside: avoid; +} + +.party { + page-break-inside: avoid; +} + +.part { + text-align: right; +} + +.content h2 { + page-break-before: always; +} + +.content h3 { + margin-top: 55px; + font-weight: bold; + font-size: 1.5em; + page-break-after: avoid; +} + +.content p { + page-break-inside: avoid; + text-indent: 2em; +} + +.content td p { + text-indent: 0; + text-align: left; +} + +.provision { + page-break-inside: avoid; +} + +p { + text-align: justify; +} + +li { + margin: 16px 0; +} + +.footer { + margin-top: 30px; + page-break-inside: avoid; +} + +.mention { + margin: 0; + font-size: 0.9em; +} + +.signatures { + width: 100%; +} + +.signatures td { + vertical-align: top; + text-align: center; + height: 3cm; +} diff --git a/api/rpk-api/firm/contract/routes_contract.py b/api/rpk-api/firm/contract/routes_contract.py new file mode 100644 index 0000000..24a20d0 --- /dev/null +++ b/api/rpk-api/firm/contract/routes_contract.py @@ -0,0 +1,69 @@ +import uuid +from fastapi import Depends, HTTPException + +from firm.core.routes import get_crud_router, get_logged_tenant_db_cursor + +from firm.contract.models import Contract, ContractDraft, ContractDraftStatus, replace_variables_in_value, ContractFilters +from firm.contract.schemas import ContractCreate, ContractRead, ContractUpdate, ContractInit + +from firm.entity.models import Entity +from firm.template.models import ProvisionTemplate + + +contract_router = get_crud_router(Contract, ContractCreate, ContractRead, ContractUpdate, ContractFilters) +del(contract_router.routes[4]) #delete +del(contract_router.routes[3]) #update +del(contract_router.routes[1]) #create + +@contract_router.post("/", response_description="Contract Successfully created") +async def create(schema: ContractCreate, db=Depends(get_logged_tenant_db_cursor)) -> ContractRead: + await schema.validate_foreign_key(db) + + draft = await ContractDraft.get(db, schema.draft_id) + if not draft: + raise HTTPException(status_code=404, detail=f"Contract draft not found!") + + for v in draft.variables: + if not v.key or not v.value: + raise HTTPException(status_code=400, detail="Variable {} is invalid".format(v)) + + contract_dict = schema.model_dump() + del(contract_dict['draft_id']) + + lawyer = await Entity.get(db, db.user.entity_id) + contract_dict['lawyer'] = lawyer.model_dump() + + contract_dict['name'] = draft.name + contract_dict['title'] = draft.title + parties = [] + for p in draft.parties: + parties.append({ + 'entity': await Entity.get(db, p.entity_id), + 'part': p.part, + 'representative': await Entity.get(db, p.representative_id) if p.representative_id else None, + 'signature_uuid': str(uuid.uuid4()) + }) + + contract_dict['parties'] = parties + + provisions = [] + for p in draft.provisions: + p = p.provision + provision = await ProvisionTemplate.get(db, p.provision_template_id) if p.type == "template" \ + else p + + provisions.append({ + 'title': replace_variables_in_value(draft.variables, provision.title), + 'body': replace_variables_in_value(draft.variables, provision.body) + }) + + contract_dict['provisions'] = provisions + + record = await Contract.create(db, ContractInit(**contract_dict)) + await draft.update_status(db, ContractDraftStatus.published) + + return ContractRead.from_model(record) + +@contract_router.put("/{record_id}", response_description="") +async def update(record_id: str, contract_form: ContractUpdate, db=Depends(get_logged_tenant_db_cursor)) -> ContractRead: + raise HTTPException(status_code=400, detail="No modification on contract") diff --git a/api/rpk-api/firm/contract/routes_draft.py b/api/rpk-api/firm/contract/routes_draft.py new file mode 100644 index 0000000..a9be126 --- /dev/null +++ b/api/rpk-api/firm/contract/routes_draft.py @@ -0,0 +1,41 @@ +from beanie import PydanticObjectId +from fastapi import HTTPException, Depends + +from firm.core.routes import get_crud_router, get_logged_tenant_db_cursor + +from firm.contract.models import ContractDraft, ContractDraftStatus, ContractDraftFilters +from firm.contract.schemas import ContractDraftCreate, ContractDraftRead, ContractDraftUpdate + +draft_router = get_crud_router(ContractDraft, ContractDraftCreate, ContractDraftRead, ContractDraftUpdate, ContractDraftFilters) + +del(draft_router.routes[3]) #update route +del(draft_router.routes[1]) #post route + + +@draft_router.post("/", response_description="Contract Draft added to the database") +async def create(schema: ContractDraftCreate, db=Depends(get_logged_tenant_db_cursor)) -> ContractDraftRead: + await schema.validate_foreign_key(db) + record = await ContractDraft.create(db, schema) + await record.check_is_ready(db) + + return ContractDraftRead.from_model(record) + + +@draft_router.put("/{record_id}", response_description="Contract Draft record updated") +async def update(record_id: PydanticObjectId, schema: ContractDraftUpdate, db=Depends(get_logged_tenant_db_cursor)) -> ContractDraftRead: + record = await ContractDraft.get(db, record_id) + if not record: + raise HTTPException( + status_code=404, + detail="Contract Draft record not found!" + ) + if record.status == ContractDraftStatus.published: + raise HTTPException( + status_code=400, + detail="Contract Draft has already been published" + ) + + record = await ContractDraft.update(db, record, schema) + await record.check_is_ready(db) + + return ContractDraftRead.from_model(record) diff --git a/api/rpk-api/firm/contract/routes_signature.py b/api/rpk-api/firm/contract/routes_signature.py new file mode 100644 index 0000000..46447d8 --- /dev/null +++ b/api/rpk-api/firm/contract/routes_signature.py @@ -0,0 +1,35 @@ +from fastapi import Depends, HTTPException, File, UploadFile, APIRouter +import shutil + +from uuid import UUID + +from firm.contract.models import Contract, Party +from firm.core.routes import get_tenant_db_cursor + + +signature_router = APIRouter() + +@signature_router.get("/{signature_id}", response_description="") +async def get_signature(signature_id: UUID, db=Depends(get_tenant_db_cursor)) -> Party: + contract = await Contract.find_by_signature_id(db, signature_id) + signature = contract.get_signature(signature_id) + return signature + +@signature_router.post("/{signature_id}", response_description="") +async def affix_signature(signature_id: UUID, signature_file: UploadFile = File(...), db=Depends(get_tenant_db_cursor)) -> bool: + contract = await Contract.find_by_signature_id(db, signature_id) + + if not contract: + raise HTTPException(status_code=404, detail="Contract record not found!") + + signature_index = contract.get_signature_index(signature_id) + signature = contract.parties[signature_index] + + if signature.signature_affixed: + raise HTTPException(status_code=400, detail="Signature already affixed") + + with open(f'media/signatures/{signature_id}.png', "wb") as buffer: + shutil.copyfileobj(signature_file.file, buffer) + + await contract.affix_signature(db, signature_index) + return True diff --git a/api/rpk-api/firm/contract/schemas.py b/api/rpk-api/firm/contract/schemas.py new file mode 100644 index 0000000..2666d90 --- /dev/null +++ b/api/rpk-api/firm/contract/schemas.py @@ -0,0 +1,83 @@ +import datetime +from typing import List + +from pydantic import BaseModel, Field + +from firm.contract.models import ContractDraft, DraftProvision, DraftParty, Contract + +from firm.entity.models import Entity +from firm.core.schemas import Writer, Reader +from firm.core.models import DictionaryEntry + + +class ContractDraftRead(Reader, ContractDraft): + pass + + +class ContractDraftCreate(Writer): + name: str = Field(title='Nom') + title: str = Field(title='Titre') + parties: List[DraftParty] = Field(title='Parties') + provisions: List[DraftProvision] = Field( + props={"items-per-row": "1", "numbered": True}, + title='Clauses' + ) + variables: List[DictionaryEntry] = Field( + default=[], + format="dictionary", + title='Variables' + ) + + async def validate_foreign_key(self, db): + for p in self.parties: + if p.entity_id: + p.entity = await Entity.get(db, p.entity_id) + if p.entity is None: + raise ValueError + + +class ContractDraftUpdate(ContractDraftCreate): + pass + + +class ForeignEntityRead(BaseModel): + label: str + + class Config: + title = "Avocat" + + +class PartyRead(BaseModel): + signature_affixed: bool = Field(title='Signature apposée?') + signature_uuid: str = Field(format="signature-link", title="Lien vers signature") + part: str = Field(title='Rôle') + entity: ForeignEntityRead = Field(title='Client') + + class Config: + title = "Partie" + + +class ContractRead(Reader, Contract): + parties: List[PartyRead] + lawyer: ForeignEntityRead + + class Config: + title = "Contrat" + + +class ContractCreate(Writer): + date: datetime.date + location: str + draft_id: str + +class ContractInit(BaseModel): + date: datetime.date + location: str + lawyer: dict + name: str + title: str + parties: List[dict] + provisions: List[dict] + +class ContractUpdate(BaseModel): + pass diff --git a/api/rpk-api/firm/core/__init__.py b/api/rpk-api/firm/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/rpk-api/firm/core/filter.py b/api/rpk-api/firm/core/filter.py new file mode 100644 index 0000000..e5cc03b --- /dev/null +++ b/api/rpk-api/firm/core/filter.py @@ -0,0 +1,135 @@ +from collections.abc import Callable, Mapping +from typing import Any, Optional, Union + +from pydantic import ValidationInfo, field_validator + +from fastapi_filter.base.filter import BaseFilterModel + +_odm_operator_transformer: dict[str, Callable[[Optional[str]], Optional[dict[str, Any]]]] = { + "neq": lambda value: {"$ne": value}, + "gt": lambda value: {"$gt": value}, + "gte": lambda value: {"$gte": value}, + "in": lambda value: {"$in": value}, + "isnull": lambda value: None if value else {"$ne": None}, + "lt": lambda value: {"$lt": value}, + "lte": lambda value: {"$lte": value}, + "not": lambda value: {"$ne": value}, + "ne": lambda value: {"$ne": value}, + "not_in": lambda value: {"$nin": value}, + "nin": lambda value: {"$nin": value}, + "like": lambda value: {"$regex": f".*{value}.*"}, + "ilike": lambda value: {"$regex": f".*{value}.*", "$options": "i"}, + "exists": lambda value: {"$exists": value}, +} + + +class Filter(BaseFilterModel): + """Base filter for beanie related filters. + + Example: + ```python + class MyModel: + id: PrimaryKey() + name: StringField(null=True) + count: IntField() + created_at: DatetimeField() + + class MyModelFilter(Filter): + id: Optional[int] + id__in: Optional[str] + count: Optional[int] + count__lte: Optional[int] + created_at__gt: Optional[datetime] + name__ne: Optional[str] + name__nin: Optional[list[str]] + name__isnull: Optional[bool] + ``` + """ + + def sort(self): + if not self.ordering_values: + return None + + sort = {} + for column in self.ordering_values: + direction = 1 + if column[0] in ["+", "-"]: + if column[0] == "-": + direction = -1 + column = column[1:] + + sort[column] = direction + + return sort + + @field_validator("*", mode="before") + @classmethod + def split_str( + cls: type["BaseFilterModel"], value: Optional[str], field: ValidationInfo + ) -> Optional[Union[list[str], str]]: + if ( + field.field_name is not None + and ( + field.field_name == cls.Constants.ordering_field_name + or field.field_name.endswith("__in") + or field.field_name.endswith("__nin") + ) + and isinstance(value, str) + ): + if not value: + # Empty string should return [] not [''] + return [] + return list(value.split(",")) + return value + + def _get_filter_conditions(self, nesting_depth: int = 1) -> list[tuple[Mapping[str, Any], Mapping[str, Any]]]: + filter_conditions: list[tuple[Mapping[str, Any], Mapping[str, Any]]] = [] + for field_name, value in self.filtering_fields: + field_value = getattr(self, field_name) + if isinstance(field_value, Filter): + if not field_value.model_dump(exclude_none=True, exclude_unset=True): + continue + + filter_conditions.append( + ( + {field_name: _odm_operator_transformer["neq"](None)}, + {"fetch_links": True, "nesting_depth": nesting_depth}, + ) + ) + for part, part_options in field_value._get_filter_conditions(nesting_depth=nesting_depth + 1): # noqa: SLF001 + for sub_field_name, sub_value in part.items(): + filter_conditions.append( + ( + {f"{field_name}.{sub_field_name}": sub_value}, + {"fetch_links": True, "nesting_depth": nesting_depth, **part_options}, + ) + ) + + elif "__" in field_name: + stripped_field_name, operator = field_name.split("__") + search_criteria = _odm_operator_transformer[operator](value) + filter_conditions.append(({stripped_field_name: search_criteria}, {})) + elif field_name == self.Constants.search_field_name and hasattr(self.Constants, "search_model_fields"): + search_conditions = [ + {search_field: _odm_operator_transformer["ilike"](value)} + for search_field in self.Constants.search_model_fields + ] + filter_conditions.append(({"$or": search_conditions}, {})) + else: + filter_conditions.append(({field_name: value}, {})) + + return filter_conditions + + def filter(self, query): + data = self._get_filter_conditions() + for filter_condition, filter_kwargs in data: + for field_name, value in filter_condition.items(): + if field_name in query: + query[field_name] = query[field_name] | value + else: + query[field_name] = value + return query + +class FilterSchema(Filter): + label__ilike: Optional[str] = None + order_by: Optional[list[str]] = None diff --git a/api/rpk-api/firm/core/models.py b/api/rpk-api/firm/core/models.py new file mode 100644 index 0000000..ae718a5 --- /dev/null +++ b/api/rpk-api/firm/core/models.py @@ -0,0 +1,117 @@ +from datetime import datetime, UTC +from typing import Optional + +from beanie import PydanticObjectId +from motor.motor_asyncio import AsyncIOMotorCollection +from pydantic import BaseModel, Field, computed_field + + +class CrudDocument(BaseModel): + id: Optional[PydanticObjectId] = Field(default=None) + created_at: datetime = Field(default=datetime.now(UTC), nullable=False, title="Créé le") +# created_by: str + updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, title="Modifié le") +# updated_by: str + + @property + def _id(self): + return self.id + + @computed_field + def label(self) -> str: + return self.compute_label() + + def compute_label(self) -> str: + return "" + + class Settings: + fulltext_search = [] + + @classmethod + def _collection_name(cls): + return cls.__name__ + + @classmethod + def _get_collection(cls, db) -> AsyncIOMotorCollection: + return db.get_collection(cls._collection_name()) + + @classmethod + async def create(cls, db, create_schema): + values = cls.model_validate(create_schema.model_dump()).model_dump(mode="json") + result = await cls._get_collection(db).insert_one(values) + + return await cls.get(db, result.inserted_id) + + @classmethod + def find(cls, db, filters): + return { + "collection": cls._get_collection(db), + "query_filter": filters.filter({}), + "sort": filters.sort(), + } + + @classmethod + def list(cls, db): + return cls._get_collection(db).find({}) + + @classmethod + async def get(cls, db, model_id): + value = await cls._get_collection(db).find_one({"_id": model_id}) + if not value: + return None + + value["id"] = value.pop("_id") + return cls.model_validate(value) + + @classmethod + async def update(cls, db, model, update_schema): + update_query = { + "$set": {field: value for field, value in update_schema.model_dump(mode="json").items() if field!= "id" } + } + + await cls._get_collection(db).update_one({"_id": model.id}, update_query) + return await cls.get(db, model.id) + + @classmethod + async def delete(cls, db, model): + await cls._get_collection(db).delete_one({"_id": model.id}) + + + +def text_area(*args, **kwargs): + kwargs['widget'] = { + "formlyConfig": { + "type": "textarea", + "props": { + "placeholder": "Leaving this field empty will cause formData property to be `null`", + "rows": kwargs['size'] if 'size' in kwargs else 10 + } + } + } + + return Field(*args, **kwargs) + + +def RichtextMultiline(*args, **kwargs): + if 'props' not in kwargs: + kwargs['props'] = {} + + kwargs['props']['richtext'] = True + kwargs['props']['multiline'] = True + + return Field(*args, **kwargs) + + +def RichtextSingleline(*args, **kwargs): + if 'props' not in kwargs: + kwargs['props'] = {} + + kwargs['props']['richtext'] = True + kwargs['props']['multiline'] = False + + return Field(*args, **kwargs) + + +class DictionaryEntry(BaseModel): + key: str + value: str = "" diff --git a/api/rpk-api/firm/core/routes.py b/api/rpk-api/firm/core/routes.py new file mode 100644 index 0000000..9aa5462 --- /dev/null +++ b/api/rpk-api/firm/core/routes.py @@ -0,0 +1,81 @@ +from beanie import PydanticObjectId + +from fastapi import APIRouter, HTTPException, Depends +from fastapi_filter import FilterDepends +from fastapi_pagination import Page, add_pagination +from fastapi_pagination.ext.motor import paginate + +from hub.auth import get_current_user +from firm.core.models import CrudDocument +from firm.core.schemas import Writer, Reader +from firm.db import get_db_client + + + +#instance: str="westside", firm: str="cht", +def get_tenant_db_cursor(db_client=Depends(get_db_client)): + instance = "westside" + firm = "cht" + return db_client[f"tenant_{instance}_{firm}"] + +#instance: str="westside", firm: str="cht", +def get_logged_tenant_db_cursor(db_client=Depends(get_db_client), user=Depends(get_current_user)): + instance = "westside" + firm = "cht" + db_cursor = db_client[f"tenant_{instance}_{firm}"] + db_cursor.user = user + return db_cursor + +def get_crud_router(model: CrudDocument, model_create: Writer, model_read: Reader, model_update: Writer, model_filter): + model_name = model.__name__ + router = APIRouter() + + @router.get("/", response_model=Page[model_read], response_description=f"{model_name} records retrieved") + async def read_list(filters: model_filter=FilterDepends(model_filter), db=Depends(get_logged_tenant_db_cursor)) -> Page[model_read]: + return await paginate(**model.find(db, filters)) + + @router.post("/", response_description=f"{model_name} added to the database") + async def create(schema: model_create, db=Depends(get_logged_tenant_db_cursor)) -> model_read: + await schema.validate_foreign_key(db) + record = await model.create(db, schema) + return model_read.validate_model(record) + + @router.get("/{record_id}", response_description=f"{model_name} record retrieved") + async def read_one(record_id: PydanticObjectId, db=Depends(get_logged_tenant_db_cursor)) -> model_read: + record = await model.get(db, record_id) + if not record: + raise HTTPException( + status_code=404, + detail=f"{model_name} record not found!" + ) + + return model_read.from_model(record) + + @router.put("/{record_id}", response_description=f"{model_name} record updated") + async def update(record_id: PydanticObjectId, schema: model_update, db=Depends(get_logged_tenant_db_cursor)) -> model_read: + record = await model.get(db, record_id) + if not record: + raise HTTPException( + status_code=404, + detail=f"{model_name} record not found!" + ) + + record = await model.update(db, record, schema) + return model_read.from_model(record) + + @router.delete("/{record_id}", response_description=f"{model_name} record deleted from the database") + async def delete(record_id: PydanticObjectId, db=Depends(get_logged_tenant_db_cursor)) -> dict: + record = await model.get(db, record_id) + if not record: + raise HTTPException( + status_code=404, + detail=f"{model_name} record not found!" + ) + + await model.delete(db, record) + return { + "message": f"{model_name} deleted successfully" + } + + add_pagination(router) + return router diff --git a/api/rpk-api/firm/core/schemas.py b/api/rpk-api/firm/core/schemas.py new file mode 100644 index 0000000..76c7830 --- /dev/null +++ b/api/rpk-api/firm/core/schemas.py @@ -0,0 +1,18 @@ +from typing import Optional + +from beanie import PydanticObjectId +from pydantic import BaseModel, Field + + +class Reader(BaseModel): + id: Optional[PydanticObjectId] = Field(default=None, validation_alias="_id") + + @classmethod + def from_model(cls, model): + schema = cls.model_validate(model, from_attributes=True) + return schema + + +class Writer(BaseModel): + async def validate_foreign_key(self, db): + pass diff --git a/api/rpk-api/firm/db.py b/api/rpk-api/firm/db.py new file mode 100644 index 0000000..7f199c3 --- /dev/null +++ b/api/rpk-api/firm/db.py @@ -0,0 +1,21 @@ +import os + +import motor.motor_asyncio + +MONGO_USERNAME = os.getenv("MONGO_INITDB_ROOT_USERNAME") +MONGO_PASSWORD = os.getenv("MONGO_INITDB_ROOT_PASSWORD") + +DATABASE_URL = f"mongodb://{MONGO_USERNAME}:{MONGO_PASSWORD}@mongo:27017" + +client = motor.motor_asyncio.AsyncIOMotorClient( + DATABASE_URL, uuidRepresentation="standard" +) + +async def init_db(): + pass + +async def stop_db(): + client.close() + +def get_db_client(): + yield client diff --git a/api/rpk-api/firm/entity/__init__.py b/api/rpk-api/firm/entity/__init__.py new file mode 100644 index 0000000..5c0d359 --- /dev/null +++ b/api/rpk-api/firm/entity/__init__.py @@ -0,0 +1 @@ +from firm.entity.routes import router as entity_router diff --git a/api/rpk-api/firm/entity/models.py b/api/rpk-api/firm/entity/models.py new file mode 100644 index 0000000..1c308cb --- /dev/null +++ b/api/rpk-api/firm/entity/models.py @@ -0,0 +1,101 @@ +from datetime import date, datetime +from typing import List, Literal, Optional + +from pydantic import Field, BaseModel +from beanie import Indexed + +from firm.core.models import CrudDocument +from firm.core.filter import Filter, FilterSchema + + +class EntityType(BaseModel): + @property + def label(self) -> str: + return self.title + + +class Individual(EntityType): + type: Literal['individual'] = 'individual' + firstname: Indexed(str) = Field(title='Prénom') + middlename: Indexed(str) = Field(default="", title='Autres prénoms') + lastname: Indexed(str) = Field(title='Nom de famille') + surnames: List[Indexed(str)] = Field( + default=[], + props={"items-per-row": "4", "numbered": True}, + title="Surnoms" + ) + day_of_birth: Optional[date] = Field(default=None, title='Date de naissance') + place_of_birth: Optional[str] = Field(default="", title='Lieu de naissance') + + @property + def label(self) -> str: + # if len(self.surnames) > 0: + # return '{} "{}" {}'.format(self.firstname, self.surnames[0], self.lastname) + return f"{self.firstname} {self.lastname}" + + class Config: + title = 'Particulier' + + +class Employee(BaseModel): + role: Indexed(str) = Field(title='Poste') + entity_id: str = Field(foreignKey={ + "reference": { + "resource": "entity", + "schema": "Entity", + "condition": "entity_data.type=individual" + } + }, + title='Employé' + ) + + class Config: + title = 'Fiche Employé' + + +class Corporation(EntityType): + type: Literal['corporation'] = 'corporation' + title: Indexed(str) = Field(title='Dénomination sociale') + activity: Indexed(str) = Field(title='Activité') + employees: List[Employee] = Field(default=[], title='Employés') + + class Config: + title = 'Entreprise' + + +class Institution(Corporation): + type: Literal['institution'] = 'institution' + + class Config: + title = 'Institution' + + +class Entity(CrudDocument): + """ + Fiche d'un client + """ + entity_data: Individual | Corporation | Institution = Field(..., discriminator='type') + address: str = Field(default="", title='Adresse') + + def compute_label(self) -> str: + if not self.entity_data: + return "" + return self.entity_data.label + + class Settings(CrudDocument.Settings): + fulltext_search = ['label'] + + bson_encoders = { + date: lambda dt: dt if hasattr(dt, 'hour') + else datetime(year=dt.year, month=dt.month, day=dt.day, + hour=0, minute=0, second=0) + } + + class Config: + title = 'Client' + + +class EntityFilters(FilterSchema): + class Constants(Filter.Constants): + model = Entity + search_model_fields = ["label"] diff --git a/api/rpk-api/firm/entity/routes.py b/api/rpk-api/firm/entity/routes.py new file mode 100644 index 0000000..994a69c --- /dev/null +++ b/api/rpk-api/firm/entity/routes.py @@ -0,0 +1,5 @@ +from firm.core.routes import get_crud_router +from firm.entity.models import Entity, EntityFilters +from firm.entity.schemas import EntityCreate, EntityRead, EntityUpdate + +router = get_crud_router(Entity, EntityCreate, EntityRead, EntityUpdate, EntityFilters) diff --git a/api/rpk-api/firm/entity/schemas.py b/api/rpk-api/firm/entity/schemas.py new file mode 100644 index 0000000..237710a --- /dev/null +++ b/api/rpk-api/firm/entity/schemas.py @@ -0,0 +1,17 @@ +from pydantic import Field + +from firm.entity.models import Entity, Institution, Individual, Corporation +from firm.core.schemas import Writer, Reader + +class EntityRead(Reader, Entity): + pass + +class EntityCreate(Writer): + entity_data: Individual | Corporation | Institution = Field(..., discriminator='type') + address: str = Field(default="", title='Adresse') + + class Config: + title = "Création d'un client" + +class EntityUpdate(EntityCreate): + pass diff --git a/api/rpk-api/firm/template/__init__.py b/api/rpk-api/firm/template/__init__.py new file mode 100644 index 0000000..80182ea --- /dev/null +++ b/api/rpk-api/firm/template/__init__.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +from firm.template.routes_contract import router as contract_router +from firm.template.routes_provision import router as provision_router + +template_router = APIRouter() + +template_router.include_router(provision_router, prefix="/provision", ) +template_router.include_router(contract_router, prefix="/contract", ) diff --git a/api/rpk-api/firm/template/models.py b/api/rpk-api/firm/template/models.py new file mode 100644 index 0000000..8afe951 --- /dev/null +++ b/api/rpk-api/firm/template/models.py @@ -0,0 +1,117 @@ +from typing import List +from html import unescape + +from pydantic import BaseModel, Field + +from firm.core.models import CrudDocument, RichtextMultiline, RichtextSingleline, DictionaryEntry +from firm.core.filter import Filter, FilterSchema + + +class PartyTemplate(BaseModel): + entity_id: str = Field( + foreignKey={ + "reference": { + "resource": "entity", + "schema": "Entity", + } + }, + default="", + title="Partie" + ) + part: str = Field(title="Rôle") + representative_id: str = Field( + foreignKey={ + "reference": { + "resource": "entity", + "schema": "Entity", + } + }, + default="", + title="Représentant" + ) + + class Config: + title = 'Partie' + + +def remove_html_tags(text): + """Remove html tags from a string""" + import re + clean = re.compile('<.*?>') + return re.sub(clean, '', text) + + +class ProvisionTemplate(CrudDocument): + """ + Modèle de clause à décliner + """ + + name: str = Field(title="Nom") + title: str = RichtextSingleline(title="Titre") + body: str = RichtextMultiline(title="Corps") + + def compute_label(self) -> str: + return f"{self.name} - \"{unescape(remove_html_tags(self.title))}\"" + + class Settings(CrudDocument.Settings): + fulltext_search = ['name', 'title', 'body'] + + class Config: + title = 'Template de clause' + + +class ProvisionTemplateReference(BaseModel): + provision_template_id: str = Field( + foreignKey={ + "reference": { + "resource": "template/provision", + "schema": "ProvisionTemplate", + "displayedFields": ['title', 'body'] + }, + }, + props={"parametrized": True}, + title="Template de clause" + ) + + class Config: + title = 'Clause' + + +class ContractTemplate(CrudDocument): + """ + Modèle de contrat à décliner + """ + name: str = Field(title="Nom") + title: str = Field(title="Titre") + parties: List[PartyTemplate] = Field(default=[], title="Parties") + provisions: List[ProvisionTemplateReference] = Field( + default=[], + props={"items-per-row": "1", "numbered": True}, + title="Clauses" + ) + variables: List[DictionaryEntry] = Field( + default=[], + format="dictionary", + title="Variables" + ) + + def compute_label(self) -> str: + return f"{self.name} - \"{unescape(remove_html_tags(self.title))}\"" + + class Settings(CrudDocument.Settings): + fulltext_search = ['name', 'title'] + + class Config: + title = 'Template de contrat' + + +class ContractTemplateFilters(FilterSchema): + class Constants(Filter.Constants): + model = ContractTemplate + search_model_fields = ["label"] + + +class ProvisionTemplateFilters(FilterSchema): + class Constants(Filter.Constants): + model = ProvisionTemplate + search_model_fields = ["label"] diff --git a/api/rpk-api/firm/template/routes_contract.py b/api/rpk-api/firm/template/routes_contract.py new file mode 100644 index 0000000..fd33d1d --- /dev/null +++ b/api/rpk-api/firm/template/routes_contract.py @@ -0,0 +1,5 @@ +from firm.core.routes import get_crud_router +from firm.template.models import ContractTemplate, ContractTemplateFilters +from firm.template.schemas import ContractTemplateCreate, ContractTemplateRead, ContractTemplateUpdate + +router = get_crud_router(ContractTemplate, ContractTemplateCreate, ContractTemplateRead, ContractTemplateUpdate, ContractTemplateFilters) diff --git a/api/rpk-api/firm/template/routes_provision.py b/api/rpk-api/firm/template/routes_provision.py new file mode 100644 index 0000000..804d83a --- /dev/null +++ b/api/rpk-api/firm/template/routes_provision.py @@ -0,0 +1,5 @@ +from firm.core.routes import get_crud_router +from firm.template.models import ProvisionTemplate, ProvisionTemplateFilters +from firm.template.schemas import ProvisionTemplateCreate, ProvisionTemplateRead, ProvisionTemplateUpdate + +router = get_crud_router(ProvisionTemplate, ProvisionTemplateCreate, ProvisionTemplateRead, ProvisionTemplateUpdate, ProvisionTemplateFilters) diff --git a/api/rpk-api/firm/template/schemas.py b/api/rpk-api/firm/template/schemas.py new file mode 100644 index 0000000..5d9c034 --- /dev/null +++ b/api/rpk-api/firm/template/schemas.py @@ -0,0 +1,51 @@ +from pydantic import Field +from typing import List + +from firm.template.models import ContractTemplate, ProvisionTemplate, PartyTemplate, ProvisionTemplateReference, DictionaryEntry +from firm.core.schemas import Writer, Reader +from firm.core.models import RichtextMultiline, RichtextSingleline + + +class ContractTemplateRead(Reader, ContractTemplate): + pass + + +class ContractTemplateCreate(Writer): + name: str = Field(title="Nom") + title: str = Field(title="Titre") + parties: List[PartyTemplate] = Field(default=[], title="Parties") + provisions: List[ProvisionTemplateReference] = Field( + default=[], + props={"items-per-row": "1", "numbered": True}, + title="Clauses" + ) + variables: List[DictionaryEntry] = Field( + default=[], + format="dictionary", + props={"required": False}, + title="Variables" + ) + + class Config: + title = 'Template de Contrat' + + +class ContractTemplateUpdate(ContractTemplateCreate): + pass + + +class ProvisionTemplateRead(Reader, ProvisionTemplate): + pass + + +class ProvisionTemplateCreate(Writer): + name: str = Field(title="Nom") + title: str = RichtextSingleline(title="Titre") + body: str = RichtextMultiline(title="Corps") + + class Config: + title = 'Template de Clause' + + +class ProvisionTemplateUpdate(ProvisionTemplateCreate): + pass diff --git a/api/rpk-api/hub/__init__.py b/api/rpk-api/hub/__init__.py index f58646b..88a7417 100644 --- a/api/rpk-api/hub/__init__.py +++ b/api/rpk-api/hub/__init__.py @@ -1,19 +1,16 @@ -from datetime import datetime +from fastapi import APIRouter -from beanie import Document, PydanticObjectId -from pydantic import Field, computed_field +from hub.auth import auth_router, register_router, password_router, verification_router, users_router, \ + google_oauth_router, discord_oauth_router +from hub.firm.routes import router as firm_router +hub_router = APIRouter() -class CrudDocument(Document): - _id: str - created_at: datetime = Field(default=datetime.utcnow(), nullable=False, title="Créé le") - created_by: PydanticObjectId = Field() - updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, title="Modifié le") - updated_by: PydanticObjectId = Field() - - @computed_field - def label(self) -> str: - return self.compute_label() - - def compute_label(self) -> str: - return "" +hub_router.include_router(register_router, tags=["Auth"], ) +hub_router.include_router(auth_router, prefix="/auth", tags=["Auth"], ) +hub_router.include_router(google_oauth_router, prefix="/auth/google", tags=["Auth"]) +hub_router.include_router(discord_oauth_router, prefix="/auth/discord", tags=["Auth"]) +hub_router.include_router(verification_router, prefix="/auth/verification", tags=["Auth"], ) +hub_router.include_router(users_router, prefix="/users", tags=["Users"], ) +hub_router.include_router(password_router, prefix="/users", tags=["Users"], ) +hub_router.include_router(firm_router, prefix="/users/firms", tags=["Users"], ) diff --git a/api/rpk-api/hub/auth/__init__.py b/api/rpk-api/hub/auth/__init__.py index 3bd62e3..8d8fde6 100644 --- a/api/rpk-api/hub/auth/__init__.py +++ b/api/rpk-api/hub/auth/__init__.py @@ -2,7 +2,7 @@ import os from typing import Any from beanie import PydanticObjectId, Document -from fastapi import Depends, Response, status +from fastapi import Depends, Response, status, APIRouter from fastapi_users import BaseUserManager, FastAPIUsers, schemas, models from fastapi_users.authentication import AuthenticationBackend, CookieTransport, Strategy from fastapi_users.authentication.strategy import AccessTokenDatabase, DatabaseStrategy @@ -77,9 +77,12 @@ class CookieTransportOauth(CookieTransport): cookie_transport = CookieTransportMe(cookie_name="rpkapiusersauth") auth_backend = AuthenticationBackendMe(name="db", transport=cookie_transport, get_strategy=get_database_strategy, ) - fastapi_users = FastAPIUsers[User, PydanticObjectId](get_user_manager, [auth_backend]) +get_current_user = fastapi_users.current_user(active=True) +get_current_superuser = fastapi_users.current_user(active=True, superuser=True) + + auth_router = fastapi_users.get_auth_router(auth_backend, requires_verification=True) register_router = fastapi_users.get_register_router(UserSchema, schemas.BaseUserCreate) password_router = fastapi_users.get_reset_password_router() @@ -91,6 +94,3 @@ auth_backend = AuthenticationBackend(name="db", transport=cookie_transport, get_ google_oauth_router = fastapi_users.get_oauth_router(google_oauth_client, auth_backend, SECRET, is_verified_by_default=True) discord_oauth_router = fastapi_users.get_oauth_router(discord_oauth_client, auth_backend, SECRET, is_verified_by_default=True) - -get_current_user = fastapi_users.current_user(active=True) -get_current_superuser = fastapi_users.current_user(active=True, superuser=True) diff --git a/api/rpk-api/hub/core/__init__.py b/api/rpk-api/hub/core/__init__.py new file mode 100644 index 0000000..f58646b --- /dev/null +++ b/api/rpk-api/hub/core/__init__.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from beanie import Document, PydanticObjectId +from pydantic import Field, computed_field + + +class CrudDocument(Document): + _id: str + created_at: datetime = Field(default=datetime.utcnow(), nullable=False, title="Créé le") + created_by: PydanticObjectId = Field() + updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, title="Modifié le") + updated_by: PydanticObjectId = Field() + + @computed_field + def label(self) -> str: + return self.compute_label() + + def compute_label(self) -> str: + return "" diff --git a/api/rpk-api/hub/firm/__init__.py b/api/rpk-api/hub/firm/__init__.py index 62787dd..7c6ff9c 100644 --- a/api/rpk-api/hub/firm/__init__.py +++ b/api/rpk-api/hub/firm/__init__.py @@ -2,7 +2,7 @@ from beanie import PydanticObjectId from pydantic import Field, BaseModel from pymongo import IndexModel -from hub import CrudDocument +from hub.core import CrudDocument class Firm(CrudDocument): name: str = Field() diff --git a/api/rpk-api/main.py b/api/rpk-api/main.py index 5a0db1a..5d23d25 100644 --- a/api/rpk-api/main.py +++ b/api/rpk-api/main.py @@ -1,10 +1,12 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from hub import hub_router from hub.db import init_db as hub_init_db -from hub.auth import auth_router, register_router, password_router, verification_router, users_router, \ - google_oauth_router, discord_oauth_router -from hub.firm.routes import router as firm_router + +from firm import firm_router +from firm.db import init_db as firm_init_db + if __name__ == '__main__': import uvicorn @@ -14,18 +16,12 @@ if __name__ == '__main__': @asynccontextmanager async def lifespan(app: FastAPI): await hub_init_db() + await firm_init_db() # create_db_and_tables() # create_admin_user() yield # do something before end app = FastAPI(root_path="/api/v1", lifespan=lifespan) - -app.include_router(register_router, tags=["Auth"], ) -app.include_router(auth_router, prefix="/auth", tags=["Auth"], ) -app.include_router(google_oauth_router, prefix="/auth/google", tags=["Auth"]) -app.include_router(discord_oauth_router, prefix="/auth/discord", tags=["Auth"]) -app.include_router(verification_router, prefix="/auth/verification", tags=["Auth"], ) -app.include_router(users_router, prefix="/users", tags=["Users"], ) -app.include_router(password_router, prefix="/users", tags=["Users"], ) -app.include_router(firm_router, prefix="/firms", tags=["Firms"], ) +app.include_router(hub_router, prefix="/hub") +app.include_router(firm_router, prefix="/firm") diff --git a/gui/rpk-gui/src/pages/hub/index.tsx b/gui/rpk-gui/src/pages/hub/index.tsx index 08d3fef..2901ab4 100644 --- a/gui/rpk-gui/src/pages/hub/index.tsx +++ b/gui/rpk-gui/src/pages/hub/index.tsx @@ -6,7 +6,7 @@ import { IAuthUser, IFirm } from "../../interfaces"; export const Hub = () => { const { data: user } = useGetIdentity(); - const { data: list } = useList({resource: "firms/", pagination: { mode: "off" }}, ) + const { data: list } = useList({resource: "hub/users/firms/", pagination: { mode: "off" }}, ) if (user === undefined || list === undefined) { return

Loading

} diff --git a/gui/rpk-gui/src/providers/auth-provider.tsx b/gui/rpk-gui/src/providers/auth-provider.tsx index ab05f42..3355254 100644 --- a/gui/rpk-gui/src/providers/auth-provider.tsx +++ b/gui/rpk-gui/src/providers/auth-provider.tsx @@ -18,7 +18,7 @@ export const authProvider: AuthProvider = { scope = DISCORD_SCOPES; } const params = new URLSearchParams(scope); - const url = `${API_URL}/auth/${providerName}/authorize?${params.toString()}`; + const url = `${API_URL}/hub/auth/${providerName}/authorize?${params.toString()}`; const response = await fetch(url, { method: "GET", },); const body = await response.json(); @@ -30,7 +30,7 @@ export const authProvider: AuthProvider = { } else if (email !== undefined && password !== undefined) { const params = new URLSearchParams({"grant_type": "password", "username": email, "password": password}); const response = await fetch( - `${API_URL}/auth/login`, + `${API_URL}/hub/auth/login`, { method: "POST", body: params.toString(), @@ -38,7 +38,7 @@ export const authProvider: AuthProvider = { }, ); if (response.status >= 200 && response.status < 300) { - const response = await fetch(`${API_URL}/users/me`); + const response = await fetch(`${API_URL}/hub/users/me`); const user = await response.json(); store_user(user); @@ -49,7 +49,7 @@ export const authProvider: AuthProvider = { return { success: false }; }, logout: async () => { - const response = await fetch(`${API_URL}/auth/logout`, { method: "POST" }); + const response = await fetch(`${API_URL}/hub/auth/logout`, { method: "POST" }); if (response.status == 204 || response.status == 401) { forget_user(); return { success: true }; @@ -72,7 +72,7 @@ export const authProvider: AuthProvider = { return user; } - const response = await fetch(`${API_URL}/users/me`); + const response = await fetch(`${API_URL}/hub/users/me`); if (response.status < 200 || response.status > 299) { return null; } @@ -82,7 +82,7 @@ export const authProvider: AuthProvider = { return user_data; }, register: async (params) => { - const response = await fetch(`${API_URL}/register`, { + const response = await fetch(`${API_URL}/hub/register`, { method: "POST", body: JSON.stringify(params), headers: { @@ -103,7 +103,7 @@ export const authProvider: AuthProvider = { }; }, forgotPassword: async (params) => { - const response = await fetch(`${API_URL}/users/forgot-password`, { + const response = await fetch(`${API_URL}/hub/users/forgot-password`, { method: "POST", body: JSON.stringify(params), headers: { @@ -119,7 +119,7 @@ export const authProvider: AuthProvider = { }, updatePassword: async (params) => { if (params.token !== undefined) { - const response = await fetch(`${API_URL}/users/reset-password`, { + const response = await fetch(`${API_URL}/hub/users/reset-password`, { method: "POST", body: JSON.stringify({ password: params.password,