Adding chtlawfirm to the api

This commit is contained in:
2025-04-10 22:43:00 +02:00
parent 72a8e7fb91
commit 93255c60a9
33 changed files with 1647 additions and 43 deletions

View File

@@ -1,4 +1,8 @@
fastapi fastapi
fastapi-filter
fastapi-pagination
fastapi-users[beanie,oauth] fastapi-users[beanie,oauth]
httpx-oauth
jinja2
uvicorn uvicorn
httpx-oauth weasyprint

View File

@@ -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", )

View File

@@ -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"], )

View File

@@ -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"]

View File

@@ -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}"
})

View File

@@ -0,0 +1,10 @@
<html>
<head>
<meta property="og:title" content="{{ title }}" />
<meta property="og:description" content="Cette page est à la destination exclusive de {{ signatory }}
Si vous n'êtes pas {{ signatory }}, veuillez fermer cette page immédiatement et surpprimer tous les liens en votre possession menant vers celle-ci.
En vous maintenant et/ou en interagissant avec cette page, vous enfreignez l'article L.229 du code pénal de l'Etat de San Andreas pour usurpation d'identité et vous vous exposez ainsi à une amende de 20 000$ ainsi qu'à des poursuites civiles.
Le cabinet Cooper, Hillman & Toshi LLC" />
<meta property="og:image" content="{{ origin_url }}/assets/logo.png" />
</head>
</html>

View File

@@ -0,0 +1,76 @@
<html>
<head>
<style>
{% include 'styles.css' %}
</style>
</head>
<body>
<div class="frontpage">
<div id="front-page-header">
<table><tr>
<td><img id="top-logo" src="{{ root_url }}/assets/logotransparent.png" alt="Cooper, Hillman & Toshi logo"></td>
<td id="office-info">Cooper, Hillman & Toshi LLP<br />6834 Innocence Boulevard<br />LOS SANTOS - SA<br /><a href="#">consulting@cht.law.com</a></td>
</tr></table>
<h1>{{ contract.title|upper }}</h1>
</div>
<div class="intro">
<h2>Introduction</h2>
<p>Le {{ contract.date.strftime('%d/%m/%Y') }} &agrave; {{ contract.location}}</p>
<p>Entre les soussign&eacute;s :</p>
{% for party in contract.parties %}
<div class="party">
{% if not loop.first %}
<p>ET</p>
{% endif %}
<p>
{% if party.entity.entity_data.type == "corporation" %}
{{ party.entity.entity_data.title }} soci&eacute;t&eacute; de {{ party.entity.entity_data.activity }} enregistr&eacute;e aupr&egrave;s du gouvernement de San Andreas et domicili&eacute;e au {{ party.entity.address }}{% if party.representative %}, repr&eacute;sent&eacute;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&eacute; le {{ party.entity.entity_data.day_of_birth.strftime('%d/%m/%Y') }} {% if party.entity.entity_data.place_of_birth %} &agrave; {{ party.entity.entity_data.place_of_birth }}{% endif %},{% endif %}
{% if party.entity.address %} r&eacute;sidant &agrave; {{ party.entity.address }}, {% endif %}
{% elif party.entity.entity_data.type == "institution" %}
{% endif %}
</p>
<p>Ci-apr&egrave;s d&eacute;nomm&eacute; <strong>{{ party.part|safe }}</strong></p>
{% if loop.first %}
<p class="part">d&apos;une part</p>
{% endif %}
</div>
{% endfor %}
<p class="part">d&apos;autre part</p>
<p>Sous la supervision l&eacute;gale de Ma&icirc;tre <strong>{{ contract.lawyer.entity_data.firstname }} {{ contract.lawyer.entity_data.lastname }}</strong></p>
<p>Il a &eacute;t&eacute; convenu l&apos;ex&eacute;cution des prestations ci-dessous, conform&eacute;ment aux conditions g&eacute;n&eacute;rales et particuli&egrave;res ci-apr&egrave;s:</p>
</div>
</div>
<div class="content">
<h2>Conditions g&eacute;n&eacute;rales & particuli&egrave;res</h2>
{% for provision in contract.provisions %}
<div class="provision">
<h3>Article {{loop.index}} - {{ provision.title|safe }}</h3>
<p>{{ provision.body|safe }}</p>
</div>
{% endfor %}
<div class="footer">
<hr/>
<p>À {{ contract.location }} le {{ contract.date.strftime('%d/%m/%Y') }}</p>
<p class="mention">(Signatures précédée de la mention « Lu et approuvé »)</p>
<table class="signatures">
<tr>
{% for party in contract.parties %}
<td>
{{ party.part|safe }}:<br/>
{% if party.signature_png %}
<img src="{{ party.signature_png }}" />
{% endif %}
</td>
{% endfor %}
</tr>
</table>
</div>
</div>
</body>
</html>

View File

@@ -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;
}

View File

@@ -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")

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

View File

@@ -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

View File

@@ -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 = ""

View File

@@ -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

View File

@@ -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

21
api/rpk-api/firm/db.py Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@
from firm.entity.routes import router as entity_router

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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

View File

@@ -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", )

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -1,19 +1,16 @@
from datetime import datetime from fastapi import APIRouter
from beanie import Document, PydanticObjectId from hub.auth import auth_router, register_router, password_router, verification_router, users_router, \
from pydantic import Field, computed_field google_oauth_router, discord_oauth_router
from hub.firm.routes import router as firm_router
hub_router = APIRouter()
class CrudDocument(Document): hub_router.include_router(register_router, tags=["Auth"], )
_id: str hub_router.include_router(auth_router, prefix="/auth", tags=["Auth"], )
created_at: datetime = Field(default=datetime.utcnow(), nullable=False, title="Créé le") hub_router.include_router(google_oauth_router, prefix="/auth/google", tags=["Auth"])
created_by: PydanticObjectId = Field() hub_router.include_router(discord_oauth_router, prefix="/auth/discord", tags=["Auth"])
updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, title="Modifié le") hub_router.include_router(verification_router, prefix="/auth/verification", tags=["Auth"], )
updated_by: PydanticObjectId = Field() hub_router.include_router(users_router, prefix="/users", tags=["Users"], )
hub_router.include_router(password_router, prefix="/users", tags=["Users"], )
@computed_field hub_router.include_router(firm_router, prefix="/users/firms", tags=["Users"], )
def label(self) -> str:
return self.compute_label()
def compute_label(self) -> str:
return ""

View File

@@ -2,7 +2,7 @@ import os
from typing import Any from typing import Any
from beanie import PydanticObjectId, Document 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 import BaseUserManager, FastAPIUsers, schemas, models
from fastapi_users.authentication import AuthenticationBackend, CookieTransport, Strategy from fastapi_users.authentication import AuthenticationBackend, CookieTransport, Strategy
from fastapi_users.authentication.strategy import AccessTokenDatabase, DatabaseStrategy from fastapi_users.authentication.strategy import AccessTokenDatabase, DatabaseStrategy
@@ -77,9 +77,12 @@ class CookieTransportOauth(CookieTransport):
cookie_transport = CookieTransportMe(cookie_name="rpkapiusersauth") cookie_transport = CookieTransportMe(cookie_name="rpkapiusersauth")
auth_backend = AuthenticationBackendMe(name="db", transport=cookie_transport, get_strategy=get_database_strategy, ) auth_backend = AuthenticationBackendMe(name="db", transport=cookie_transport, get_strategy=get_database_strategy, )
fastapi_users = FastAPIUsers[User, PydanticObjectId](get_user_manager, [auth_backend]) 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) auth_router = fastapi_users.get_auth_router(auth_backend, requires_verification=True)
register_router = fastapi_users.get_register_router(UserSchema, schemas.BaseUserCreate) register_router = fastapi_users.get_register_router(UserSchema, schemas.BaseUserCreate)
password_router = fastapi_users.get_reset_password_router() 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) 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) 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)

View File

@@ -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 ""

View File

@@ -2,7 +2,7 @@ from beanie import PydanticObjectId
from pydantic import Field, BaseModel from pydantic import Field, BaseModel
from pymongo import IndexModel from pymongo import IndexModel
from hub import CrudDocument from hub.core import CrudDocument
class Firm(CrudDocument): class Firm(CrudDocument):
name: str = Field() name: str = Field()

View File

@@ -1,10 +1,12 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from hub import hub_router
from hub.db import init_db as hub_init_db 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 firm import firm_router
from hub.firm.routes import router as firm_router from firm.db import init_db as firm_init_db
if __name__ == '__main__': if __name__ == '__main__':
import uvicorn import uvicorn
@@ -14,18 +16,12 @@ if __name__ == '__main__':
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await hub_init_db() await hub_init_db()
await firm_init_db()
# create_db_and_tables() # create_db_and_tables()
# create_admin_user() # create_admin_user()
yield yield
# do something before end # do something before end
app = FastAPI(root_path="/api/v1", lifespan=lifespan) app = FastAPI(root_path="/api/v1", lifespan=lifespan)
app.include_router(hub_router, prefix="/hub")
app.include_router(register_router, tags=["Auth"], ) app.include_router(firm_router, prefix="/firm")
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"], )

View File

@@ -18,7 +18,7 @@ export const authProvider: AuthProvider = {
scope = DISCORD_SCOPES; scope = DISCORD_SCOPES;
} }
const params = new URLSearchParams(scope); 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 response = await fetch(url, { method: "GET", },);
const body = await response.json(); const body = await response.json();
@@ -30,7 +30,7 @@ export const authProvider: AuthProvider = {
} else if (email !== undefined && password !== undefined) { } else if (email !== undefined && password !== undefined) {
const params = new URLSearchParams({"grant_type": "password", "username": email, "password": password}); const params = new URLSearchParams({"grant_type": "password", "username": email, "password": password});
const response = await fetch( const response = await fetch(
`${API_URL}/auth/login`, `${API_URL}/hub/auth/login`,
{ {
method: "POST", method: "POST",
body: params.toString(), body: params.toString(),
@@ -38,7 +38,7 @@ export const authProvider: AuthProvider = {
}, },
); );
if (response.status >= 200 && response.status < 300) { 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(); const user = await response.json();
store_user(user); store_user(user);
@@ -49,7 +49,7 @@ export const authProvider: AuthProvider = {
return { success: false }; return { success: false };
}, },
logout: async () => { 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) { if (response.status == 204 || response.status == 401) {
forget_user(); forget_user();
return { success: true }; return { success: true };
@@ -72,7 +72,7 @@ export const authProvider: AuthProvider = {
return user; 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) { if (response.status < 200 || response.status > 299) {
return null; return null;
} }
@@ -82,7 +82,7 @@ export const authProvider: AuthProvider = {
return user_data; return user_data;
}, },
register: async (params) => { register: async (params) => {
const response = await fetch(`${API_URL}/register`, { const response = await fetch(`${API_URL}/hub/register`, {
method: "POST", method: "POST",
body: JSON.stringify(params), body: JSON.stringify(params),
headers: { headers: {
@@ -103,7 +103,7 @@ export const authProvider: AuthProvider = {
}; };
}, },
forgotPassword: async (params) => { forgotPassword: async (params) => {
const response = await fetch(`${API_URL}/users/forgot-password`, { const response = await fetch(`${API_URL}/hub/users/forgot-password`, {
method: "POST", method: "POST",
body: JSON.stringify(params), body: JSON.stringify(params),
headers: { headers: {
@@ -119,7 +119,7 @@ export const authProvider: AuthProvider = {
}, },
updatePassword: async (params) => { updatePassword: async (params) => {
if (params.token !== undefined) { 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", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
password: params.password, password: params.password,