4 Commits

Author SHA1 Message Date
59cc709ed5 Full Working static multi tenant 2025-04-01 00:29:43 +02:00
50fdf22afc Initializing multitenant 2025-03-30 17:13:14 +02:00
1a247f14ce Merge branch 'master' into multi-tenant 2025-03-28 13:42:12 +01:00
706b3dc275 WIP - Adding multi tenant 2025-03-27 00:11:53 +01:00
32 changed files with 584 additions and 355 deletions

View File

@@ -1,105 +1,12 @@
import uuid
from fastapi import Depends, HTTPException, File, UploadFile
import shutil
from fastapi import APIRouter
from ..core.routes import get_crud_router
from .routes_contract import contract_router as contract_subrouter
from .routes_signature import signature_router
from .routes_draft import draft_router
from .print import print_router
from .models import Contract, ContractDraft, ContractDraftStatus, Party, replace_variables_in_value
from .schemas import ContractCreate, ContractRead, ContractUpdate
from ..entity.models import Entity
from ..template.models import ProvisionTemplate
from ..user.manager import get_current_user, get_current_superuser
contract_router = get_crud_router(Contract, ContractCreate, ContractRead, ContractUpdate)
del(contract_router.routes[0])
del(contract_router.routes[2])
del(contract_router.routes[2])
contract_router = APIRouter()
contract_router.include_router(draft_router, prefix="/draft", )
contract_router.include_router(contract_subrouter, )
contract_router.include_router(print_router, prefix="/print", )
@contract_router.post("/", response_description="Contract Successfully created")
async def create(item: ContractCreate, user=Depends(get_current_user)) -> dict:
await item.validate_foreign_key()
draft = await ContractDraft.get(item.draft_id)
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 = item.dict()
del(contract_dict['draft_id'])
contract_dict['lawyer'] = await Entity.get(user.entity_id)
contract_dict['name'] = draft.name
contract_dict['title'] = draft.title
parties = []
for p in draft.parties:
parties.append({
'entity': await Entity.get(p.entity_id),
'part': p.part,
'representative': await Entity.get(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(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
o = await Contract(**contract_dict).create()
await draft.update({"$set": {"status": ContractDraftStatus.published}})
return {"message": "Contract Successfully created", "id": o.id}
@contract_router.put("/{id}", response_description="")
async def update(id: str, contract_form: ContractUpdate, user=Depends(get_current_superuser)) -> ContractRead:
raise HTTPException(status_code=400, detail="No modification on contract")
@contract_router.get("/signature/{signature_id}", response_description="")
async def get_signature(signature_id: str) -> Party:
contract = await Contract.find_by_signature_id(signature_id)
signature = contract.get_signature(signature_id)
return signature
@contract_router.post("/signature/{signature_id}", response_description="")
async def affix_signature(signature_id: str, signature_file: UploadFile = File(...)) -> bool:
contract = await Contract.find_by_signature_id(signature_id)
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)
update_query = {"$set": {
f'parties.{signature_index}.signature_affixed': True
}}
signature.signature_affixed = True
if contract.is_signed():
update_query["$set"]['status'] = 'signed'
await contract.update(update_query)
return True
contract_router.include_router(signature_router, prefix="/signature", )

View File

@@ -1,11 +1,12 @@
import datetime
from typing import List, Literal, Optional
from enum import Enum
from uuid import UUID
from pydantic import BaseModel, Field, validator
from beanie.operators import ElemMatch
from pydantic import BaseModel, Field
from ..core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry
from ..core.filter import Filter
from ..entity.models import Entity
@@ -57,9 +58,12 @@ class Party(BaseModel):
signature_affixed: bool = False
signature_png: Optional[str] = None
class ContractProvisionType(Enum):
genuine = 'genuine'
template = 'template'
class ProvisionGenuine(BaseModel):
type: Literal['genuine'] = 'genuine'
type: Literal['genuine'] = ContractProvisionType.genuine
title: str = RichtextSingleline(props={"parametrized": True}, default="", title="Titre")
body: str = RichtextMultiline(props={"parametrized": True}, default="", title="Corps")
@@ -68,7 +72,7 @@ class ProvisionGenuine(BaseModel):
class ContractProvisionTemplateReference(BaseModel):
type: Literal['template'] = 'template'
type: Literal['template'] = ContractProvisionType.template
provision_template_id: str = Field(
foreignKey={
"reference": {
@@ -97,6 +101,9 @@ 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):
"""
@@ -130,7 +137,7 @@ class ContractDraft(CrudDocument):
class Config:
title = 'Brouillon de contrat'
async def check_is_ready(self):
async def check_is_ready(self, db):
if self.status == ContractDraftStatus.published:
return
@@ -152,18 +159,18 @@ class ContractDraft(CrudDocument):
for v in self.variables:
if not (v.key and v.value):
self.todo.append('Empty variable')
self.todo.append(f'Empty variable: {v.key}')
if self.todo:
self.status = ContractDraftStatus.in_progress
else:
self.status = ContractDraftStatus.ready
await self.update({"$set": {
"status": self.status,
"todo": self.todo
}})
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):
"""
@@ -181,19 +188,14 @@ class Contract(CrudDocument):
lawyer: Entity = Field(title="Avocat en charge")
location: str = Field(title="Lieu")
date: datetime.date = Field(title="Date")
label: Optional[str] = None
@validator("label", always=True)
def generate_label(cls, v, values, **kwargs):
if not v:
contract_label = values['title']
for p in values['parties']:
contract_label = contract_label + f" - {p.entity.label}"
def compute_label(self) -> str:
contract_label = self.title
for p in self.parties:
contract_label = f"{contract_label} - {p.entity.label}"
contract_label = contract_label + f" - {values['date'].strftime('%m/%d/%Y')}"
return contract_label
return v
contract_label = f"{contract_label} - {self.date.strftime('%m/%d/%Y')}"
return contract_label
class Settings(CrudDocument.Settings):
fulltext_search = ['name', 'title']
@@ -205,18 +207,19 @@ class Contract(CrudDocument):
}
@classmethod
def find_by_signature_id(cls, signature_id: str):
crit = ElemMatch(cls.parties, {"signature_uuid": signature_id})
return cls.find_one(crit)
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 == signature_id:
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 == signature_id:
if p.signature_uuid == str(signature_id):
return i
def is_signed(self):
@@ -225,9 +228,39 @@ class Contract(CrudDocument):
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(Filter):
name__like: Optional[str] = None
order_by: Optional[list[str]] = None
class Constants(Filter.Constants):
model = ContractDraft
search_model_fields = ["name"]
class ContractFilters(Filter):
name__like: Optional[str] = None
order_by: Optional[list[str]] = None
class Constants(Filter.Constants):
model = Contract
search_model_fields = ["name"]

View File

@@ -1,16 +1,19 @@
import datetime
import os
import base64
from uuid import UUID
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from pathlib import Path
from app.core.routes import get_tenant_db_cursor
from app.entity.models import Entity
from app.template.models import ProvisionTemplate
from ..models import ContractDraft, Contract, ContractStatus, replace_variables_in_value
@@ -77,16 +80,24 @@ async def render_css(root_url, contract):
})
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}'
@print_router.get("/preview/draft/{draft_id}", response_class=HTMLResponse)
async def preview_draft(draft_id: str, request: Request) -> str:
draft = await build_model(await ContractDraft.get(draft_id))
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)
@print_router.get("/preview/signature/{signature_id}", response_class=HTMLResponse)
async def preview_contract_by_signature(signature_id: str, request: Request) -> str:
contract = await Contract.find_by_signature_id(signature_id)
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')
@@ -95,8 +106,8 @@ async def preview_contract_by_signature(signature_id: str, request: Request) ->
@print_router.get("/preview/{contract_id}", response_class=HTMLResponse)
async def preview_contract(contract_id: str, request: Request) -> str:
contract = await Contract.get(contract_id)
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')
@@ -105,8 +116,8 @@ async def preview_contract(contract_id: str, request: Request) -> str:
@print_router.get("/pdf/{contract_id}", response_class=FileResponse)
async def create_pdf(contract_id: str) -> str:
contract = await Contract.get(contract_id)
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:
@@ -122,10 +133,8 @@ async def create_pdf(contract_id: str) -> str:
css = CSS(string=await render_css('http://nginx', contract), font_config=font_config)
html.write_pdf(contract_path, stylesheets=[css], font_config=font_config)
update_query = {"$set": {
'status': 'printed'
}}
await contract.update(update_query)
await contract.update_status(db, 'printed')
return FileResponse(
contract_path,
@@ -133,17 +142,9 @@ async def create_pdf(contract_id: str) -> str:
filename=contract.label)
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}'
@print_router.get("/opengraph/{signature_id}", response_class=HTMLResponse)
async def get_signature_opengraph(signature_id: str, request: Request) -> str:
contract = await Contract.find_by_signature_id(signature_id)
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")

View File

@@ -109,6 +109,11 @@ h2 {
text-indent: 2em;
}
.content td p {
text-indent: 0;
text-align: left;
}
.provision {
page-break-inside: avoid;
}
@@ -127,7 +132,7 @@ li {
}
.mention {
margin: 0px;
margin: 0;
font-size: 0.9em;
}

View File

@@ -0,0 +1,70 @@
import uuid
from fastapi import Depends, HTTPException
from ..core.routes import get_crud_router, get_logged_tenant_db_cursor
from .models import Contract, ContractDraft, ContractDraftStatus, replace_variables_in_value, ContractFilters
from .schemas import ContractCreate, ContractRead, ContractUpdate, ContractInit
from ..entity.models import Entity
from ..template.models import ProvisionTemplate
from ..user.manager import get_current_user
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), user=Depends(get_current_user)) -> 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, 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

@@ -1,47 +1,42 @@
from beanie import PydanticObjectId
from fastapi import HTTPException, Depends
from ..core.routes import get_crud_router
from ..core.routes import get_crud_router, get_logged_tenant_db_cursor
from ..user.manager import get_current_user
from .models import ContractDraft, ContractDraftStatus
from .models import ContractDraft, ContractDraftStatus, ContractDraftFilters
from .schemas import ContractDraftCreate, ContractDraftRead, ContractDraftUpdate
draft_router = get_crud_router(ContractDraft, ContractDraftCreate, ContractDraftRead, ContractDraftUpdate)
draft_router = get_crud_router(ContractDraft, ContractDraftCreate, ContractDraftRead, ContractDraftUpdate, ContractDraftFilters)
del(draft_router.routes[0])
del(draft_router.routes[2])
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(item: ContractDraftCreate, user=Depends(get_current_user)) -> dict:
await item.validate_foreign_key()
o = await ContractDraft(**item.dict()).create()
await o.check_is_ready()
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 {"message": "Contract Draft added successfully", "id": o.id}
return ContractDraftRead.from_model(record)
@draft_router.put("/{id}", response_description="Contract Draft record updated")
async def update(id: PydanticObjectId, req: ContractDraftUpdate, user=Depends(get_current_user)) -> ContractDraftRead:
req = {k: v for k, v in req.dict().items() if v is not None}
update_query = {"$set": {
field: value for field, value in req.items()
}}
item = await ContractDraft.get(id)
if not item:
@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 item.status == ContractDraftStatus.published:
if record.status == ContractDraftStatus.published:
raise HTTPException(
status_code=400,
detail="Contract Draft has already been published"
)
await item.update(update_query)
await item.check_is_ready()
record = await ContractDraft.update(db, record, schema)
await record.check_is_ready(db)
return ContractDraftRead(**item.dict())
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 .models import Contract, Party
from ..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

@@ -6,11 +6,11 @@ from pydantic import BaseModel, Field
from .models import ContractDraft, DraftProvision, DraftParty, Contract
from ..entity.models import Entity
from ..core.schemas import Writer
from ..core.schemas import Writer, Reader
from ..core.models import DictionaryEntry
class ContractDraftRead(ContractDraft):
class ContractDraftRead(Reader, ContractDraft):
pass
@@ -28,12 +28,12 @@ class ContractDraftCreate(Writer):
title='Variables'
)
async def validate_foreign_key(self):
return
async def validate_foreign_key(self, db):
for p in self.parties:
p.entity = await Entity.get(p.entity)
if p.entity is None:
raise ValueError
if p.entity_id:
p.entity = await Entity.get(db, p.entity_id)
if p.entity is None:
raise ValueError
class ContractDraftUpdate(ContractDraftCreate):
@@ -57,7 +57,7 @@ class PartyRead(BaseModel):
title = "Partie"
class ContractRead(Contract):
class ContractRead(Reader, Contract):
parties: List[PartyRead]
lawyer: ForeignEntityRead
@@ -70,6 +70,14 @@ class ContractCreate(Writer):
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

116
back/app/core/filter.py Normal file
View File

@@ -0,0 +1,116 @@
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, query):
if not self.ordering_values:
return query
return query.sort(*self.ordering_values)
@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:
query = query.find(filter_condition, **filter_kwargs)
return query

View File

@@ -1,21 +1,75 @@
from datetime import datetime
from datetime import datetime, UTC
from typing import Optional
from beanie import Document
from pydantic import BaseModel, Field, validator
from beanie import PydanticObjectId
from pydantic import BaseModel, Field, computed_field
class CrudDocument(Document):
_id: str
created_at: datetime = Field(default=datetime.utcnow(), nullable=False, title="Créé le")
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
@validator("label", always=True, check_fields=False)
def generate_label(cls, v, values, **kwargs):
return v
@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):
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 list(cls, db, filters):
query = filters.filter(cls._get_collection(db))
query = filters.sort(query)
return query
@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'] = {

View File

@@ -3,10 +3,13 @@ from beanie.odm.operators.find.comparison import In
from beanie.operators import And, RegEx, Eq
from fastapi import APIRouter, HTTPException, Depends
from fastapi_pagination import Page, Params, add_pagination
from fastapi_pagination.ext.beanie import paginate
from fastapi_filter import FilterDepends
from fastapi_pagination import Page, add_pagination
from fastapi_pagination.ext.motor import paginate
from ..user.manager import get_current_user, get_current_superuser
from .models import CrudDocument
from .schemas import Writer, Reader
from ..db import get_db_client
def parse_sort(sort_by):
@@ -15,8 +18,8 @@ def parse_sort(sort_by):
fields = []
for field in sort_by.split(','):
dir, col = field.split('(')
fields.append((col[:-1], 1 if dir == 'asc' else -1))
direction, column = field.split('(')
fields.append((column[:-1], 1 if direction == 'asc' else -1))
return fields
@@ -33,6 +36,7 @@ def parse_query(query: str, model):
for criterion in query.split(' AND '):
[column, operator, value] = criterion.split(' ', 2)
column = column.lower()
operand = None
if column == 'fulltext':
if not model.Settings.fulltext_search:
continue
@@ -50,68 +54,75 @@ def parse_query(query: str, model):
elif operator == 'in':
operand = In(column, value.split(','))
and_array.append(operand)
if operand:
and_array.append(operand)
if and_array:
return And(*and_array) if len(and_array) > 1 else and_array[0]
else:
return {}
#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",
#user=Depends(get_current_user)
def get_logged_tenant_db_cursor(db_client=Depends(get_db_client), user=None):
instance = "westside"
firm = "cht"
return db_client[f"tenant_{instance}_{firm}"]
def get_crud_router(model, model_create, model_read, model_update):
def get_crud_router(model: CrudDocument, model_create: Writer, model_read: Reader, model_update: Writer, model_filter):
model_name = model.__name__
router = APIRouter()
@router.post("/", response_description="{} added to the database".format(model.__name__))
async def create(item: model_create, user=Depends(get_current_user)) -> dict:
await item.validate_foreign_key()
o = await model(**item.dict()).create()
return {"message": "{} added successfully".format(model.__name__), "id": o.id}
@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.list(db, filters))
@router.get("/{id}", response_description="{} record retrieved".format(model.__name__))
async def read_id(id: PydanticObjectId, user=Depends(get_current_user)) -> model_read:
item = await model.get(id)
return model_read(**item.dict())
@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("/", response_model=Page[model_read], response_description="{} records retrieved".format(model.__name__))
async def read_list(size: int = 50, page: int = 1, sort_by: str = None, query: str = None,
user=Depends(get_current_user)) -> Page[model_read]:
sort = parse_sort(sort_by)
query = parse_query(query, model_read)
items = paginate(model.find(query), Params(**{'size': size, 'page': page}))
return await items
@router.put("/{id}", response_description="{} record updated".format(model.__name__))
async def update(id: PydanticObjectId, req: model_update, user=Depends(get_current_user)) -> model_read:
req = {k: v for k, v in req.dict().items() if v is not None}
update_query = {"$set": {
field: value for field, value in req.items()
}}
item = await model.get(id)
if not item:
@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="{} record not found!".format(model.__name__)
detail=f"{model_name} record not found!"
)
await item.update(update_query)
return model_read(**item.dict())
return model_read.from_model(record)
@router.delete("/{id}", response_description="{} record deleted from the database".format(model.__name__))
async def delete(id: PydanticObjectId, user=Depends(get_current_superuser)) -> dict:
item = await model.get(id)
if not item:
@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="{} record not found!".format(model.__name__)
detail=f"{model_name} record not found!"
)
await item.delete()
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": "{} deleted successfully".format(model.__name__)
"message": f"{model_name} deleted successfully"
}
add_pagination(router)

View File

@@ -1,12 +1,18 @@
from pydantic import BaseModel
from typing import Optional
from beanie import PydanticObjectId
from pydantic import BaseModel, Field
class Reader(BaseModel):
pass
# class Config:
# fields = {'id': '_id'}
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):
async def validate_foreign_key(self, db):
pass

View File

@@ -3,21 +3,22 @@ import motor.motor_asyncio
from beanie import init_beanie
from .user import User, AccessToken
from .entity.models import Entity
from .template.models import ContractTemplate, ProvisionTemplate
from .contract.models import ContractDraft, Contract
# from .order.models import Order
DB_PASSWORD = "IBO3eber0mdw2R9pnInLdtFykQFY2f06"
DATABASE_URL = f"mongodb://root:{DB_PASSWORD}@mongo:27017/"
client = motor.motor_asyncio.AsyncIOMotorClient(
DATABASE_URL, uuidRepresentation="standard"
)
async def init_db():
client = motor.motor_asyncio.AsyncIOMotorClient(
DATABASE_URL, uuidRepresentation="standard"
)
await init_beanie(database=client.db_name,
document_models=[User, AccessToken, Entity, ContractTemplate, ProvisionTemplate, ContractDraft,
Contract, ],
await init_beanie(database=client.core,
document_models=[User, AccessToken, ], # Entity, ContractTemplate, ProvisionTemplate, ContractDraft, Contract,
allow_index_dropping=True)
async def stop_db():
client.close()
def get_db_client():
yield client

View File

@@ -1,10 +1,11 @@
from datetime import date, datetime
from typing import List, Literal, Optional
from pydantic import Field, BaseModel, validator
from pydantic import Field, BaseModel
from beanie import Indexed
from ..core.models import CrudDocument
from ..core.filter import Filter
class EntityType(BaseModel):
@@ -30,8 +31,7 @@ class Individual(EntityType):
def label(self) -> str:
# if len(self.surnames) > 0:
# return '{} "{}" {}'.format(self.firstname, self.surnames[0], self.lastname)
return '{} {}'.format(self.firstname, self.lastname)
return f"{self.firstname} {self.lastname}"
class Config:
title = 'Particulier'
@@ -75,14 +75,12 @@ class Entity(CrudDocument):
Fiche d'un client
"""
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type')
label: str = None
address: str = Field(default="", title='Adresse')
@validator("label", always=True)
def generate_label(cls, v, values, **kwargs):
if 'entity_data' not in values:
return v
return values['entity_data'].label
def compute_label(self) -> str:
if not self.entity_data:
return ""
return self.entity_data.label
class Settings(CrudDocument.Settings):
fulltext_search = ['label']
@@ -96,6 +94,12 @@ class Entity(CrudDocument):
class Config:
title = 'Client'
@classmethod
def get_create_resource(cls):
print('coucou')
class EntityFilters(Filter):
name__like: Optional[str] = None
order_by: Optional[list[str]] = None
class Constants(Filter.Constants):
model = Entity
search_model_fields = ["name"]

View File

@@ -1,7 +1,5 @@
from ..core.routes import get_crud_router
from .models import Entity
from .models import Entity, EntityFilters
from .schemas import EntityCreate, EntityRead, EntityUpdate
router = get_crud_router(Entity, EntityCreate, EntityRead, EntityUpdate)
router = get_crud_router(Entity, EntityCreate, EntityRead, EntityUpdate, EntityFilters)

View File

@@ -1,14 +1,11 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import Field
from .models import Entity, Institution, Individual, Corporation
from ..core.schemas import Writer
from ..core.schemas import Writer, Reader
class EntityRead(Entity):
class EntityRead(Reader, Entity):
pass
class EntityCreate(Writer):
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type')
address: str = Field(default="", title='Adresse')
@@ -16,6 +13,5 @@ class EntityCreate(Writer):
class Config:
title = "Création d'un client"
class EntityUpdate(EntityCreate):
pass

View File

@@ -1,26 +1,30 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from .contract import contract_router
from .db import init_db
from .db import init_db, stop_db
from .user import user_router, get_auth_router
from .entity import entity_router
from .template import template_router
# from .order import order_router
app = FastAPI(root_path="/api/v1")
from .contract import contract_router
@app.on_event("startup")
async def on_startup():
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
await stop_db()
app = FastAPI(root_path="/api/v1", lifespan=lifespan)
app.include_router(get_auth_router(), prefix="/auth", tags=["auth"], )
app.include_router(user_router, prefix="/users", tags=["users"], )
app.include_router(entity_router, prefix="/entity", tags=["entity"], )
app.include_router(template_router, prefix="/template", tags=["template"], )
app.include_router(contract_router, prefix="/contract", tags=["contract"], )
# app.include_router(order_router, prefix="/order", tags=["order"], )
multitenant_prefix = "/{instance}/{firm}"
app.include_router(entity_router, prefix=f"{multitenant_prefix}/entity", tags=["entity"], )
app.include_router(template_router, prefix=f"{multitenant_prefix}/template", tags=["template"], )
app.include_router(contract_router, prefix=f"{multitenant_prefix}/contract", tags=["contract"], )
if __name__ == '__main__':
import uvicorn

View File

@@ -1 +0,0 @@
from .routes import router as order_router

View File

@@ -1,13 +0,0 @@
from datetime import datetime
from beanie import Document
class Order(Document):
id: str
client: str
created_at: datetime
updated_at: datetime
class Settings:
name = "order_collection"

View File

@@ -1,5 +0,0 @@
from ..core.routes import get_crud_router
from .models import Order
from .schemas import OrderCreate, OrderRead, OrderUpdate
router = get_crud_router(Order, OrderCreate, OrderRead, OrderUpdate)

View File

@@ -1,14 +0,0 @@
import uuid
from pydantic import BaseModel
class OrderRead(BaseModel):
pass
class OrderCreate(BaseModel):
login: str
class OrderUpdate(BaseModel):
pass

View File

@@ -1,9 +1,10 @@
from typing import List
from typing import List, Optional
from html import unescape
from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, Field
from ..core.models import CrudDocument, RichtextMultiline, RichtextSingleline, DictionaryEntry
from ..core.filter import Filter
class PartyTemplate(BaseModel):
@@ -47,12 +48,10 @@ class ProvisionTemplate(CrudDocument):
name: str = Field(title="Nom")
title: str = RichtextSingleline(title="Titre")
label: str = ""
body: str = RichtextMultiline(title="Corps")
@validator("label", always=True)
def generate_label(cls, v, values, **kwargs):
return "{} - \"{}\"".format(values['name'], unescape(remove_html_tags(values['title'])))
def compute_label(self) -> str:
return f"{self.name} - \"{unescape(remove_html_tags(self.title))}\""
class Settings(CrudDocument.Settings):
fulltext_search = ['name', 'title', 'body']
@@ -84,7 +83,6 @@ class ContractTemplate(CrudDocument):
"""
name: str = Field(title="Nom")
title: str = Field(title="Titre")
label: str = ""
parties: List[PartyTemplate] = Field(default=[], title="Parties")
provisions: List[ProvisionTemplateReference] = Field(
default=[],
@@ -97,12 +95,31 @@ class ContractTemplate(CrudDocument):
title="Variables"
)
@validator("label", always=True)
def generate_label(cls, v, values, **kwargs):
return "{} - \"{}\"".format(values['name'], unescape(remove_html_tags(values['title'])))
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(Filter):
name__like: Optional[str] = None
order_by: Optional[list[str]] = None
class Constants(Filter.Constants):
model = ContractTemplate
search_model_fields = ["name"]
class ProvisionTemplateFilters(Filter):
name__like: Optional[str] = None
order_by: Optional[list[str]] = None
class Constants(Filter.Constants):
model = ProvisionTemplate
search_model_fields = ["name"]

View File

@@ -1,5 +1,5 @@
from ..core.routes import get_crud_router
from .models import ContractTemplate
from .models import ContractTemplate, ContractTemplateFilters
from .schemas import ContractTemplateCreate, ContractTemplateRead, ContractTemplateUpdate
router = get_crud_router(ContractTemplate, ContractTemplateCreate, ContractTemplateRead, ContractTemplateUpdate)
router = get_crud_router(ContractTemplate, ContractTemplateCreate, ContractTemplateRead, ContractTemplateUpdate, ContractTemplateFilters)

View File

@@ -1,5 +1,5 @@
from ..core.routes import get_crud_router
from .models import ProvisionTemplate
from .models import ProvisionTemplate, ProvisionTemplateFilters
from .schemas import ProvisionTemplateCreate, ProvisionTemplateRead, ProvisionTemplateUpdate
router = get_crud_router(ProvisionTemplate, ProvisionTemplateCreate, ProvisionTemplateRead, ProvisionTemplateUpdate)
router = get_crud_router(ProvisionTemplate, ProvisionTemplateCreate, ProvisionTemplateRead, ProvisionTemplateUpdate, ProvisionTemplateFilters)

View File

@@ -2,11 +2,11 @@ from pydantic import Field
from typing import List
from .models import ContractTemplate, ProvisionTemplate, PartyTemplate, ProvisionTemplateReference, DictionaryEntry
from ..core.schemas import Writer
from ..core.schemas import Writer, Reader
from ..core.models import RichtextMultiline, RichtextSingleline
class ContractTemplateRead(ContractTemplate):
class ContractTemplateRead(Reader, ContractTemplate):
pass
@@ -34,7 +34,7 @@ class ContractTemplateUpdate(ContractTemplateCreate):
pass
class ProvisionTemplateRead(ProvisionTemplate):
class ProvisionTemplateRead(Reader, ProvisionTemplate):
pass

View File

@@ -1,7 +1,8 @@
fastapi
fastapi_users
fastapi_users_db_beanie
fastapi-pagination
fastapi_pagination
fastapi_filter
uvicorn
jinja2
weasyprint

View File

@@ -70,7 +70,7 @@ export class DraftsNewComponent extends BaseDraftsComponent implements OnInit {
this.temaplateForm.valueChanges.subscribe((values) => {
if (values.template_id !== undefined) {
this.crudService.get("template/contract", values.template_id).subscribe((templateModel) => {
delete templateModel._id;
delete templateModel.id;
delete templateModel.created_at;
delete templateModel.updated_at;
delete templateModel.label;
@@ -98,7 +98,7 @@ export class DraftsNewComponent extends BaseDraftsComponent implements OnInit {
(resourceReceived)="this.onResourceReceived($event)"
>
</base-card>
<a class="btn btn-link" href="/api/v1/contract/print/preview/draft/{{this.resource_id}}" target="_blank" i18n>Preview</a>
<a class="btn btn-link" href="/api/v1/westside/cht/contract/print/preview/draft/{{this.resource_id}}" target="_blank" i18n>Preview</a>
<ng-container *ngIf="this.isReadyForPublication;">
<formly-form [fields]="newContractFormfields" [form]="newContractForm" [model]="newContractModel"></formly-form>
<button class="btn btn-success" (click)="publish()" i18n>Publish</button>

View File

@@ -71,8 +71,8 @@ export class ContractsCardComponent extends BaseContractsComponent{
onResourceReceived(model: any): void {
this.resourceReadyToPrint = model.status != "published";
this.contractPrintLink = `${location.origin}/api/v1/contract/print/pdf/${this.resource_id}`
this.contractPreviewLink = `${location.origin}/api/v1/contract/print/preview/${this.resource_id}`
this.contractPrintLink = `${location.origin}/api/v1/westside/cht/contract/print/pdf/${this.resource_id}`
this.contractPreviewLink = `${location.origin}/api/v1/westside/cht/contract/print/preview/${this.resource_id}`
}
}
@@ -124,7 +124,7 @@ export class ContractsSignatureComponent implements OnInit {
}
getPreview() {
return this.sanitizer.bypassSecurityTrustResourceUrl("/api/v1/contract/print/preview/signature/" + this.signature_id);
return this.sanitizer.bypassSecurityTrustResourceUrl("/api/v1/westside/cht/contract/print/preview/signature/" + this.signature_id);
}
postSignature(image: string) {

View File

@@ -24,7 +24,7 @@ export class CardComponent implements OnInit {
@Input() set model(value: any) {
this._model = value;
if (Object.keys(this.form.controls).length) {
delete value._id;
delete value.id;
this.form.patchValue(value);
}
}
@@ -109,10 +109,10 @@ export class CardComponent implements OnInit {
error: (err) => this.error.emit("Error creating the entity:" + err)
});
} else {
model._id = this.resource_id;
model.id = this.resource_id;
this.crudService.update(this.resource!, model).subscribe( {
next: (model: any) => {
this.resourceUpdated.emit(model._id);
this.resourceUpdated.emit(model.id);
this.resourceReceived.emit(model);
this.model = model;
this._modelLoading$.next(false);
@@ -124,7 +124,7 @@ export class CardComponent implements OnInit {
onDelete() {
this._modelLoading$.next(true);
this.model._id = this.resource_id;
this.model.id = this.resource_id;
this.crudService.delete(this.resource!, this.model).subscribe({
next: (model: any) => {
this._modelLoading$.next(false);

View File

@@ -10,10 +10,10 @@ import {SortDirection} from "./list/sortable.directive";
export class ApiService {
constructor(protected http: HttpClient) {}
protected api_root: string = '/api/v1'
protected api_root: string = '/api/v1/westside/cht'
public getSchema() {
return this.http.get<Schema>(`${this.api_root}/openapi.json`);
return this.http.get<Schema>("/api/v1/openapi.json");
}
}
@@ -98,7 +98,7 @@ export class CrudService extends ApiService {
public update(resource: string, model: any) {
return this.http.put<{ menu: [{}] }>(
`${this.api_root}/${resource.toLowerCase()}/${model._id}`,
`${this.api_root}/${resource.toLowerCase()}/${model.id}`,
model
);
}
@@ -112,7 +112,7 @@ export class CrudService extends ApiService {
public delete(resource: string, model: any) {
return this.http.delete<{ menu: [{}] }>(
`${this.api_root}/${resource.toLowerCase()}/${model._id}`
`${this.api_root}/${resource.toLowerCase()}/${model.id}`
);
}
}

View File

@@ -33,7 +33,7 @@
<tr *ngIf="loading$ | async">
<td class="text-center" [attr.colspan]="this.displayedColumns.length" i18n>Loading...</td>
</tr>
<tr *ngFor="let row of listData$ | async" (click)="onRowClick(row._id)" (auxclick)="onRowMiddleClick(row._id);" class="table-row-link">
<tr *ngFor="let row of listData$ | async" (click)="onRowClick(row.id)" (auxclick)="onRowMiddleClick(row.id);" class="table-row-link">
<td class="text-truncate" *ngFor="let col of this.displayedColumns" style="max-width: 150px;">
<ngb-highlight [result]="getColumnValue(row, col.path)" [term]="searchTerm"></ngb-highlight>
</td>

View File

@@ -53,7 +53,7 @@ import {DictionaryService} from "./dictionary.service";
<div class="modal-body">
<crud-card *ngIf="this.hasValue()"
[resource]="this.foreignResource"
[resource_id]="this.foreignModel._id"
[resource_id]="this.foreignModel.id"
[schema]="this.foreignSchema"
[is_modal]="true"
(resourceDeleted)="onResourceDeleted($event)"
@@ -106,7 +106,7 @@ export class ForeignkeyTypeComponent extends FieldType<FieldTypeConfig> implemen
if (!v) {
this.foreignModel = {};
this.foreignLabel = "";
} else if (v != this.foreignModel._id) {
} else if (v != this.foreignModel.id) {
this.loadModel(v);
}
})
@@ -123,7 +123,7 @@ export class ForeignkeyTypeComponent extends FieldType<FieldTypeConfig> implemen
loadModel(id:string) {
this.getResource(id).pipe(
map(v => { this.setModel(v); return v; }),
map( v => this.formControl.patchValue(v._id))
map( v => this.formControl.patchValue(v.id))
).subscribe()
}
@@ -150,7 +150,7 @@ export class ForeignkeyTypeComponent extends FieldType<FieldTypeConfig> implemen
selectedItem(event: any) {
this.setModel(event.item);
this.formControl.setValue(event.item._id);
this.formControl.setValue(event.item.id);
}
setModel(model: any) {