60 Commits

Author SHA1 Message Date
ewandor
a16a122713 Correcting default date format on contract creation 2023-03-15 15:17:43 +01:00
08cb2772ea Updating default value of contract create form 2023-03-14 15:59:23 +01:00
86bcb87427 Updating date input type to allow empty dates 2023-03-14 15:59:01 +01:00
4be6591e81 Hidding birthplace when empty on contract 2023-03-14 15:55:18 +01:00
b4f81431b9 Making entity birthday optionalble 2023-03-14 15:07:00 +01:00
b1d0e115f4 Dynamic asset url depending on request host 2023-03-14 14:36:02 +01:00
8aac5376df Adding translation for Cratract draft creation form 2023-03-14 14:13:59 +01:00
918ad94861 Correcting preview link 2023-03-14 14:13:18 +01:00
b598e7a147 Infinite lifetime access_token 2023-03-13 19:03:28 +01:00
54082700e8 Merge branch 'master' of git.dorfsvald.net:ewandor/cht-lawfirm 2023-03-13 15:42:17 +01:00
4a4fca0a40 Downgrading mongo to run on leto 2023-03-13 15:42:00 +01:00
45116fbb8c Adding a title to customer create schema 2023-03-13 02:20:48 +01:00
2b784850c7 Empty login form after login 2023-03-12 21:01:53 +01:00
427ea64f8d Translations for most of contract & templates 2023-03-12 19:12:35 +01:00
868eea79bf Finishing translation of entity module 2023-03-12 13:43:49 +01:00
3390acbc82 Display column title name in crudlist 2023-03-12 13:23:49 +01:00
8319fa9fac Translating Entity resource 2023-03-12 13:07:10 +01:00
ad19a50346 Adding filter display and management in Crud front 2023-03-11 22:50:14 +01:00
f2ddc4303e Adding In filter to back 2023-03-11 22:48:27 +01:00
598a962aba Adding full text search to contract and drafts 2023-03-11 22:47:54 +01:00
1753271ac0 Preparing crud components for filters arrival 2023-03-11 22:46:54 +01:00
2cb5af904e Return textual error for permissions 2023-03-11 18:13:47 +01:00
1d3918db87 Better feedback on draft publication 2023-03-10 17:27:14 +01:00
a52435d443 Improving common list displays 2023-03-09 17:44:30 +01:00
1e3855ced5 Bumping created_at & updated_at fields to the end of schemas 2023-03-09 16:39:05 +01:00
d313f5a3d8 Adding a label to signature link 2023-03-09 16:38:28 +01:00
0f1c910919 First round of internationlization 2023-03-09 15:53:18 +01:00
0011b62a5d Login and logout menu display madolities 2023-03-08 23:48:52 +01:00
5605ee9497 Contract Signing and contract printing 2023-03-08 21:59:10 +01:00
eaa79c3541 Addign folders for signature & contract storage 2023-03-08 21:53:52 +01:00
bf536fe8f7 Adding simple permission to routes 2023-03-08 15:08:45 +01:00
ac3268e6c8 Endding entity_id to user model 2023-03-08 15:07:39 +01:00
9da0063812 Removing unused routes in main 2023-03-08 15:06:01 +01:00
b7880dc304 Adding loging logout logic 2023-03-07 14:33:29 +01:00
374f90b3ec Converting app compoenent ts to 4 spaces 2023-03-07 14:26:05 +01:00
3ecfb7fa3b Correcting authentification path in openai 2023-03-07 14:12:14 +01:00
f94fdcd141 Improving nickname display 2023-03-07 14:09:41 +01:00
0d484209f6 Correcting bug caused y Entity name collision 2023-03-07 14:09:01 +01:00
52ea9dfb63 Dynamic lawyer 2023-03-06 21:25:11 +01:00
4b00ef12aa Open preview in new tab 2023-03-06 21:22:42 +01:00
34cb9bece4 Removing unused templates 2023-03-06 21:22:19 +01:00
d43164c93d Creating a normal preview for contract drafts 2023-03-06 21:06:51 +01:00
8bb3d0f44e updating contract preview 2023-03-06 20:22:41 +01:00
9035824248 feading right data to signature form 2023-03-06 20:21:57 +01:00
ab0111c1f5 Correcting contract forms display 2023-03-06 20:21:12 +01:00
02e2685c50 Adding psecial link type 2023-03-06 20:19:40 +01:00
62bbe706ca Making array readonly-able 2023-03-06 17:59:21 +01:00
576b5970a5 Adding contract creation 2023-03-06 17:05:49 +01:00
d8c8ebdc48 broadening hidden field usage 2023-03-06 16:49:32 +01:00
ac34dd1663 Adding a newline at end of file 2023-03-06 16:48:14 +01:00
ac981d18fc Signature Widget 2023-03-05 14:43:20 +01:00
da634c59ee Moving an gular cli installation up to not redo it every time we add a library 2023-03-05 14:29:59 +01:00
4d31497b8b contract signature ready 2023-03-03 20:49:56 +01:00
5322756e5a contract draft specific path 2023-03-03 20:48:42 +01:00
9621f90e25 removing useless python class 2023-03-03 20:46:42 +01:00
5773179c0d correcting sidenav 2023-03-03 20:45:27 +01:00
35e532449b Merge branch 'feature/pdf-print' 2023-02-19 16:36:05 +01:00
6d6dc2d82b Adding contract location/date and party reprensetative 2023-02-17 19:43:56 +01:00
61b8e3fe21 Adding logo as title 2023-02-17 19:42:06 +01:00
ab6490622a Only extract parameters of non n 2023-02-17 19:41:25 +01:00
75 changed files with 3535 additions and 674 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
__pycache__/ __pycache__/
back/app/fixtures/ back/app/fixtures/
back/media/
front/app-back/ front/app-back/
front/app-back2/ front/app-back2/

View File

@@ -1,9 +1,105 @@
from fastapi import APIRouter import uuid
from fastapi import Depends, HTTPException, File, UploadFile
import shutil
from ..core.routes import get_crud_router
from .routes_draft import draft_router from .routes_draft import draft_router
from .print import print_router from .print import print_router
contract_router = APIRouter() from .models import Contract, ContractDraft, ContractDraftStatus, Party, replace_variables_in_value
from .schemas import ContractCreate, ContractRead, ContractUpdate
contract_router.include_router(draft_router, prefix="/draft", tags=["draft"], ) from ..entity.models import Entity
contract_router.include_router(print_router, prefix="/print", tags=["print"], ) 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.include_router(draft_router, prefix="/draft", )
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

View File

@@ -3,32 +3,37 @@ from typing import List, Literal
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from beanie.operators import ElemMatch
from ..core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry from ..core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry
from ..entity.models import Entity
class ContractStatus(str, Enum): class ContractStatus(str, Enum):
new = 'new' published = 'published'
signed = 'signed' signed = 'signed'
in_effect = 'in_effect' printed = 'printed'
executed = 'executed' executed = 'executed'
class ContractDraftStatus(str, Enum): class ContractDraftStatus(str, Enum):
draft = 'draft' in_progress = 'in_progress'
created = 'created' ready = 'ready'
published = 'published'
class Party(BaseModel): class DraftParty(BaseModel):
entity_id: str = Field( entity_id: str = Field(
foreignKey={ foreignKey={
"reference": { "reference": {
"resource": "entity", "resource": "entity",
"schema": "Entity", "schema": "Entity",
} }
} },
default="",
title="Partie"
) )
part: str part: str = Field(title="Rôle")
representative_id: str = Field( representative_id: str = Field(
foreignKey={ foreignKey={
"reference": { "reference": {
@@ -36,14 +41,30 @@ class Party(BaseModel):
"schema": "Entity", "schema": "Entity",
} }
}, },
default="" default="",
title="Représentant"
) )
class Config:
title = 'Partie'
class Party(BaseModel):
entity: Entity
part: str
representative: Entity = None
signature_uuid: str
signature_affixed: bool = False
signature_png: str = None
class ProvisionGenuine(BaseModel): class ProvisionGenuine(BaseModel):
type: Literal['genuine'] = 'genuine' type: Literal['genuine'] = 'genuine'
title: str = RichtextSingleline(props={"parametrized": True}) title: str = RichtextSingleline(props={"parametrized": True}, default="", title="Titre")
body: str = RichtextMultiline(props={"parametrized": True}) body: str = RichtextMultiline(props={"parametrized": True}, default="", title="Corps")
class Config:
title = 'Clause personalisée'
class ContractProvisionTemplateReference(BaseModel): class ContractProvisionTemplateReference(BaseModel):
@@ -56,23 +77,140 @@ class ContractProvisionTemplateReference(BaseModel):
"displayedFields": ['title', 'body'] "displayedFields": ['title', 'body']
}, },
}, },
props={"parametrized": True} props={"parametrized": True},
default="",
title="Template de clause"
) )
class Config:
title = 'Template de clause'
class DraftProvision(BaseModel): class DraftProvision(BaseModel):
provision: ContractProvisionTemplateReference | ProvisionGenuine = Field(..., discriminator='type') provision: ContractProvisionTemplateReference | ProvisionGenuine = Field(..., discriminator='type')
class Config:
title = 'Provision'
class Provision(BaseModel):
title: str = RichtextSingleline(title="Titre")
body: str = RichtextMultiline(title="Corps")
class ContractDraft(CrudDocument): class ContractDraft(CrudDocument):
name: str """
title: str Brouillon de contrat à remplir
parties: List[Party] """
provisions: List[DraftProvision]
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( variables: List[DictionaryEntry] = Field(
default=[], default=[],
format="dictionary", format="dictionary",
title='Variables'
) )
status: ContractDraftStatus = Field(default=ContractDraftStatus.draft) status: ContractDraftStatus = Field(default=ContractDraftStatus.in_progress, title="Statut")
location: str = "" todo: List[str] = Field(default=[], title="Reste à faire")
date: datetime.date = datetime.date(1970, 1, 1)
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):
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('Empty variable')
if self.todo:
self.status = ContractDraftStatus.in_progress
else:
self.status = ContractDraftStatus.ready
await self.update({"$set": {
"status": self.status,
"todo": self.todo
}})
class Contract(CrudDocument):
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")
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
def find_by_signature_id(cls, signature_id: str):
crit = ElemMatch(cls.parties, {"signature_uuid": signature_id})
return cls.find_one(crit)
def get_signature(self, signature_id: str):
for p in self.parties:
if p.signature_uuid == 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:
return i
def is_signed(self):
for p in self.parties:
if not p.signature_affixed:
return False
return True
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

View File

@@ -1,4 +1,8 @@
from fastapi import APIRouter import datetime
import os
import base64
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, FileResponse from fastapi.responses import HTMLResponse, FileResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -9,7 +13,7 @@ from pathlib import Path
from app.entity.models import Entity from app.entity.models import Entity
from app.template.models import ProvisionTemplate from app.template.models import ProvisionTemplate
from ..schemas import ContractDraft from ..models import ContractDraft, Contract, ContractStatus, replace_variables_in_value
async def build_model(model): async def build_model(model):
@@ -24,19 +28,28 @@ async def build_model(model):
parties.append(party) parties.append(party)
model.parties = parties model.parties = parties
provisions = [] provisions = []
for p in model.provisions: for p in model.provisions:
if p.provision.type == "template": if p.provision.type == "template":
provisions.append(await ProvisionTemplate.get(p.provision.provision_template_id)) provision = await ProvisionTemplate.get(p.provision.provision_template_id)
else: else:
provisions.append(p.provision) 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.provisions = provisions
model.location = "Toulouse" model = model.dict()
model.date = "01/01/1970" 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 return model
@@ -48,49 +61,81 @@ print_router = APIRouter()
templates = Jinja2Templates(directory=str(BASE_PATH / "templates")) templates = Jinja2Templates(directory=str(BASE_PATH / "templates"))
async def render_print(host, draft, lawyer): async def render_print(root_url, contract):
template = templates.get_template("print.html") template = templates.get_template("print.html")
return template.render({ return template.render({
"draft": draft, "contract": contract,
"lawyer": lawyer, "root_url": root_url
"static_host": host
}) })
async def render_css(host, draft): async def render_css(root_url, contract):
template = templates.get_template("styles.css") template = templates.get_template("styles.css")
return template.render({ return template.render({
"draft": draft, "contract": contract,
"static_host": host "root_url": root_url
}) })
@print_router.get("/", response_class=HTMLResponse) @print_router.get("/preview/draft/{draft_id}", response_class=HTMLResponse)
async def create() -> str: async def preview_draft(draft_id: str, request: Request) -> str:
draft = await build_model(await ContractDraft.get("63e92534aafed8b509f229c4")) draft = await build_model(await ContractDraft.get(draft_id))
lawyer = {
"firstname": "Nathaniel",
"lastname": "Toshi",
}
return await render_print('localhost', draft, lawyer) return await render_print(f'{request.url.scheme}://{request.url.hostname}', draft)
@print_router.get("/pdf", response_class=FileResponse) @print_router.get("/preview/signature/{signature_id}", response_class=HTMLResponse)
async def create_pdf() -> str: async def preview_contract_by_signature(signature_id: str, request: Request) -> str:
draft = await build_model(await ContractDraft.get("63e92534aafed8b509f229c4")) contract = await Contract.find_by_signature_id(signature_id)
lawyer = { for p in contract.parties:
"firstname": "Nathaniel", if p.signature_affixed:
"lastname": "Toshi", p.signature_png = retrieve_signature_png(f'media/signatures/{p.signature_uuid}.png')
}
return await render_print(f'{request.url.scheme}://{request.url.hostname}', contract)
@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)
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(f'{request.url.scheme}://{request.url.hostname}', contract)
@print_router.get("/pdf/{contract_id}", response_class=FileResponse)
async def create_pdf(contract_id: str) -> str:
contract = await Contract.get(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() font_config = FontConfiguration()
html = HTML(string=await render_print('nginx', draft, lawyer)) html = HTML(string=await render_print('http://nginx', contract))
css = CSS(string=await render_css('nginx', draft), font_config=font_config) css = CSS(string=await render_css('http://nginx', contract), font_config=font_config)
html.write_pdf('out.pdf', stylesheets=[css], 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)
return FileResponse( return FileResponse(
"out.pdf", contract_path,
media_type="application/pdf", media_type="application/pdf",
filename=draft.name) filename=contract.name)
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}'

View File

@@ -1,152 +0,0 @@
from weasyprint import HTML, CSS
class PdfGenerator:
"""
Generate a PDF out of a rendered template, with the possibility to integrate nicely
a header and a footer if provided.
Notes:
------
- When Weasyprint renders an html into a PDF, it goes though several intermediate steps.
Here, in this class, we deal mostly with a box representation: 1 `Document` have 1 `Page`
or more, each `Page` 1 `Box` or more. Each box can contain other box. Hence the recursive
method `get_element` for example.
For more, see:
https://weasyprint.readthedocs.io/en/stable/hacking.html#dive-into-the-source
https://weasyprint.readthedocs.io/en/stable/hacking.html#formatting-structure
- Warning: the logic of this class relies heavily on the internal Weasyprint API. This
snippet was written at the time of the release 47, it might break in the future.
- This generator draws its inspiration and, also a bit of its implementation, from this
discussion in the library github issues: https://github.com/Kozea/WeasyPrint/issues/92
"""
OVERLAY_LAYOUT = '@page {size: A4 portrait; margin: 0;}'
def __init__(self, main_html, header_html=None, footer_html=None,
base_url=None, side_margin=2, extra_vertical_margin=30):
"""
Parameters
----------
main_html: str
An HTML file (most of the time a template rendered into a string) which represents
the core of the PDF to generate.
header_html: str
An optional header html.
footer_html: str
An optional footer html.
base_url: str
An absolute url to the page which serves as a reference to Weasyprint to fetch assets,
required to get our media.
side_margin: int, interpreted in cm, by default 2cm
The margin to apply on the core of the rendered PDF (i.e. main_html).
extra_vertical_margin: int, interpreted in pixel, by default 30 pixels
An extra margin to apply between the main content and header and the footer.
The goal is to avoid having the content of `main_html` touching the header or the
footer.
"""
self.main_html = main_html
self.header_html = header_html
self.footer_html = footer_html
self.base_url = base_url
self.side_margin = side_margin
self.extra_vertical_margin = extra_vertical_margin
def _compute_overlay_element(self, element: str):
"""
Parameters
----------
element: str
Either 'header' or 'footer'
Returns
-------
element_body: BlockBox
A Weasyprint pre-rendered representation of an html element
element_height: float
The height of this element, which will be then translated in a html height
"""
html = HTML(
string=getattr(self, f'{element}_html'),
base_url=self.base_url,
)
element_doc = html.render(stylesheets=[CSS(string=self.OVERLAY_LAYOUT)])
element_page = element_doc.pages[0]
element_body = PdfGenerator.get_element(element_page._page_box.all_children(), 'body')
element_body = element_body.copy_with_children(element_body.all_children())
element_html = PdfGenerator.get_element(element_page._page_box.all_children(), element)
if element == 'header':
element_height = element_html.height
if element == 'footer':
element_height = element_page.height - element_html.position_y
return element_body, element_height
def _apply_overlay_on_main(self, main_doc, header_body=None, footer_body=None):
"""
Insert the header and the footer in the main document.
Parameters
----------
main_doc: Document
The top level representation for a PDF page in Weasyprint.
header_body: BlockBox
A representation for an html element in Weasyprint.
footer_body: BlockBox
A representation for an html element in Weasyprint.
"""
for page in main_doc.pages:
page_body = PdfGenerator.get_element(page._page_box.all_children(), 'body')
if header_body:
page_body.children += header_body.all_children()
if footer_body:
page_body.children += footer_body.all_children()
def render_pdf(self):
"""
Returns
-------
pdf: a bytes sequence
The rendered PDF.
"""
if self.header_html:
header_body, header_height = self._compute_overlay_element('header')
else:
header_body, header_height = None, 0
if self.footer_html:
footer_body, footer_height = self._compute_overlay_element('footer')
else:
footer_body, footer_height = None, 0
margins = '{header_size}px {side_margin} {footer_size}px {side_margin}'.format(
header_size=header_height + self.extra_vertical_margin,
footer_size=footer_height + self.extra_vertical_margin,
side_margin=f'{self.side_margin}cm',
)
content_print_layout = '@page {size: A4 portrait; margin: %s;}' % margins
html = HTML(
string=self.main_html,
base_url=self.base_url,
)
main_doc = html.render(stylesheets=[CSS(string=content_print_layout)])
if self.header_html or self.footer_html:
self._apply_overlay_on_main(main_doc, header_body, footer_body)
pdf = main_doc.write_pdf()
return pdf
@staticmethod
def get_element(boxes, element):
"""
Given a set of boxes representing the elements of a PDF page in a DOM-like way, find the
box which is named `element`.
Look at the notes of the class for more details on Weasyprint insides.
"""
for box in boxes:
if box.element_tag == element:
return box
return PdfGenerator.get_element(box.all_children(), element)

View File

@@ -1,30 +0,0 @@
<html>
<head>
<style>
{% include 'styles.css' %}
</style>
</head>
<body>
<div class="content">
<h2>Conditions g&eacute;n&eacute;rales & particuli&egrave;res</h2>
{% for provision in draft.provisions %}
<div class="provision">
<h3>Article {{loop.index}} - {{ provision.title|safe }}</h3>
<p>{{ provision.body|safe }}</p>
</div>
{% endfor %}
<div class="footer">
<hr/>
<p>À {{ draft.location }} le {{ draft.date }}</p>
<p class="mention">(Signatures précédée de la mention « Lu et approuvé »)</p>
<table class="signatures">
<tr>
{% for party in draft.parties %}<td>{{ party.part|safe }}:</td>{% endfor %}
</tr>
</table>
</div>
</div>
</body>
</html>

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
{% include 'styles.css' %}
</style>
</head>
<body>
<hr/>
</body>
</html>

View File

@@ -1,48 +0,0 @@
<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="http://{{ static_host }}/assets/logotransparent.png"></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>
</div>
<h1>{{ draft.title|upper }}</h1>
<div class="intro">
<h2>Introduction</h2>
<p>Le {{ draft.date }} &agrave; {{ draft.location}}</p>
<p>Entre les soussign&eacute;s :</p>
{% for party in draft.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 true %} &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>{{ lawyer.firstname }} {{ lawyer.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>
</body>
</html>

View File

@@ -8,16 +8,16 @@
<div class="frontpage"> <div class="frontpage">
<div id="front-page-header"> <div id="front-page-header">
<table><tr> <table><tr>
<td><img id="top-logo" src="http://{{ static_host }}/assets/logotransparent.png" alt="Cooper, Hillman & Toshi logo"></td> <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> <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> </tr></table>
<h1>{{ draft.title|upper }}</h1> <h1>{{ contract.title|upper }}</h1>
</div> </div>
<div class="intro"> <div class="intro">
<h2>Introduction</h2> <h2>Introduction</h2>
<p>Le {{ draft.date }} &agrave; {{ draft.location}}</p> <p>Le {{ contract.date.strftime('%d/%m/%Y') }} &agrave; {{ contract.location}}</p>
<p>Entre les soussign&eacute;s :</p> <p>Entre les soussign&eacute;s :</p>
{% for party in draft.parties %} {% for party in contract.parties %}
<div class="party"> <div class="party">
{% if not loop.first %} {% if not loop.first %}
<p>ET</p> <p>ET</p>
@@ -27,7 +27,7 @@
{{ 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 %} {{ 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" %} {% elif party.entity.entity_data.type == "individual" %}
{{ party.entity.entity_data.firstname }} {{ party.entity.entity_data.middlenames }} {{ party.entity.entity_data.lastname }} {{ 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 true %} &agrave; {{ party.entity.entity_data.place_of_birth }}{% endif %},{% endif %} {% 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 %} {% if party.entity.address %} r&eacute;sidant &agrave; {{ party.entity.address }}, {% endif %}
{% elif party.entity.entity_data.type == "institution" %} {% elif party.entity.entity_data.type == "institution" %}
@@ -40,14 +40,14 @@
</div> </div>
{% endfor %} {% endfor %}
<p class="part">d&apos;autre part</p> <p class="part">d&apos;autre part</p>
<p>Sous la supervision l&eacute;gale de Ma&icirc;tre <strong>{{ lawyer.firstname }} {{ lawyer.lastname }}</strong></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> <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> </div>
<div class="content"> <div class="content">
<h2>Conditions g&eacute;n&eacute;rales & particuli&egrave;res</h2> <h2>Conditions g&eacute;n&eacute;rales & particuli&egrave;res</h2>
{% for provision in draft.provisions %} {% for provision in contract.provisions %}
<div class="provision"> <div class="provision">
<h3>Article {{loop.index}} - {{ provision.title|safe }}</h3> <h3>Article {{loop.index}} - {{ provision.title|safe }}</h3>
<p>{{ provision.body|safe }}</p> <p>{{ provision.body|safe }}</p>
@@ -56,11 +56,18 @@
<div class="footer"> <div class="footer">
<hr/> <hr/>
<p>À {{ draft.location }} le {{ draft.date }}</p> <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> <p class="mention">(Signatures précédée de la mention « Lu et approuvé »)</p>
<table class="signatures"> <table class="signatures">
<tr> <tr>
{% for party in draft.parties %}<td>{{ party.part|safe }}:</td>{% endfor %} {% for party in contract.parties %}
<td>
{{ party.part|safe }}:<br/>
{% if party.signature_png %}
<img src="{{ party.signature_png }}" />
{% endif %}
</td>
{% endfor %}
</tr> </tr>
</table> </table>
</div> </div>

View File

@@ -1,24 +1,24 @@
@font-face { @font-face {
font-family: 'Century Schoolbook'; font-family: 'Century Schoolbook';
src: url('http://{{ static_host }}/assets/century-schoolbook/CenturySchoolbookRegular.ttf'); src: url('{{ root_url }}/assets/century-schoolbook/CenturySchoolbookRegular.ttf');
} }
@font-face { @font-face {
font-family: "Century Schoolbook"; font-family: "Century Schoolbook";
src: url("http://{{ static_host }}/assets/century-schoolbook/CenturySchoolbookBold.ttf"); src: url("{{ root_url }}/assets/century-schoolbook/CenturySchoolbookBold.ttf");
font-weight: bold; font-weight: bold;
} }
@font-face { @font-face {
font-family: "Century Schoolbook"; font-family: "Century Schoolbook";
src: url("http://{{ static_host }}/assets/century-schoolbook/CenturySchoolbookItalic.ttf"); src: url("{{ root_url }}/assets/century-schoolbook/CenturySchoolbookItalic.ttf");
font-style: italic; font-style: italic;
} }
@font-face { @font-face {
font-family: "Century Schoolbook"; font-family: "Century Schoolbook";
src: url("http://{{ static_host }}/assets/century-schoolbook/CenturySchoolbookBoldItalic.ttf"); src: url("{{ root_url }}/assets/century-schoolbook/CenturySchoolbookBoldItalic.ttf");
font-weight: bold; font-weight: bold;
font-style: italic; font-style: italic;
} }
@@ -28,10 +28,10 @@
margin: 2cm 2cm 2cm 2cm; margin: 2cm 2cm 2cm 2cm;
counter-increment: page; counter-increment: page;
@bottom-center { @bottom-center {
content: "© Cooper, Hillman & Toshi LLC - {{ draft.name }} - Page " counter(page) "/" counter(pages); content: "© Cooper, Hillman & Toshi LLC - {{ contract.name }} - Page " counter(page) "/" counter(pages);
font-size: 0.8em; font-size: 0.8em;
} }
background: url('http://{{ static_host }}/assets/watermark.png') no-repeat; background: url('{{ root_url }}/assets/watermark.png') no-repeat;
background-size:contain; background-size:contain;
} }

View File

@@ -1,7 +1,47 @@
from fastapi import APIRouter from beanie import PydanticObjectId
from fastapi import HTTPException, Depends
from ..core.routes import get_crud_router from ..core.routes import get_crud_router
from .models import ContractDraft from ..user.manager import get_current_user
from .models import ContractDraft, ContractDraftStatus
from .schemas import ContractDraftCreate, ContractDraftRead, ContractDraftUpdate from .schemas import ContractDraftCreate, ContractDraftRead, ContractDraftUpdate
draft_router = get_crud_router(ContractDraft, ContractDraftCreate, ContractDraftRead, ContractDraftUpdate) draft_router = get_crud_router(ContractDraft, ContractDraftCreate, ContractDraftRead, ContractDraftUpdate)
del(draft_router.routes[0])
del(draft_router.routes[2])
@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()
return {"message": "Contract Draft added successfully", "id": o.id}
@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:
raise HTTPException(
status_code=404,
detail="Contract Draft record not found!"
)
if item.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()
return ContractDraftRead(**item.dict())

View File

@@ -1,9 +1,9 @@
import datetime
from typing import List from typing import List
from pydantic import Field from pydantic import BaseModel, Field
from .models import ContractDraft, DraftProvision, Party from .models import ContractDraft, DraftProvision, DraftParty, Contract
from ..entity.models import Entity from ..entity.models import Entity
from ..core.schemas import Writer from ..core.schemas import Writer
@@ -15,13 +15,17 @@ class ContractDraftRead(ContractDraft):
class ContractDraftCreate(Writer): class ContractDraftCreate(Writer):
name: str name: str = Field(title='Nom')
title: str title: str = Field(title='Titre')
parties: List[Party] parties: List[DraftParty] = Field(title='Parties')
provisions: List[DraftProvision] provisions: List[DraftProvision] = Field(
props={"items-per-row": "1", "numbered": True},
title='Clauses'
)
variables: List[DictionaryEntry] = Field( variables: List[DictionaryEntry] = Field(
default=[], default=[],
format="dictionary", format="dictionary",
title='Variables'
) )
async def validate_foreign_key(self): async def validate_foreign_key(self):
@@ -34,3 +38,38 @@ class ContractDraftCreate(Writer):
class ContractDraftUpdate(ContractDraftCreate): class ContractDraftUpdate(ContractDraftCreate):
pass 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(Contract):
parties: List[PartyRead]
lawyer: ForeignEntityRead
class Config:
title = "Contrat"
class ContractCreate(Writer):
date: datetime.date
location: str
draft_id: str
class ContractUpdate(BaseModel):
pass

View File

@@ -6,8 +6,8 @@ from pydantic import BaseModel, Field, validator
class CrudDocument(Document): class CrudDocument(Document):
_id: str _id: str
created_at: datetime = Field(default=datetime.utcnow(), nullable=False) created_at: datetime = Field(default=datetime.utcnow(), nullable=False, title="Créé le")
updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, title="Modifié le")
@validator("label", always=True, check_fields=False) @validator("label", always=True, check_fields=False)
def generate_label(cls, v, values, **kwargs): def generate_label(cls, v, values, **kwargs):

View File

@@ -1,10 +1,13 @@
from beanie import PydanticObjectId from beanie import PydanticObjectId
from beanie.odm.operators.find.comparison import In
from beanie.operators import And, RegEx, Eq from beanie.operators import And, RegEx, Eq
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Depends
from fastapi_paginate import Page, Params, add_pagination from fastapi_paginate import Page, Params, add_pagination
from fastapi_paginate.ext.motor import paginate from fastapi_paginate.ext.motor import paginate
from ..user.manager import get_current_user, get_current_superuser
def parse_sort(sort_by): def parse_sort(sort_by):
if not sort_by: if not sort_by:
@@ -41,11 +44,13 @@ def parse_query(query: str, model):
elif operator == 'eq': elif operator == 'eq':
operand = Eq(column, value) operand = Eq(column, value)
elif operator == 'in':
operand = In(column, value.split(','))
and_array.append(operand) and_array.append(operand)
if and_array: if and_array:
return And(and_array) if len(and_array) > 1 else and_array[0] return And(*and_array) if len(and_array) > 1 else and_array[0]
else: else:
return {} return {}
@@ -55,18 +60,19 @@ def get_crud_router(model, model_create, model_read, model_update):
router = APIRouter() router = APIRouter()
@router.post("/", response_description="{} added to the database".format(model.__name__)) @router.post("/", response_description="{} added to the database".format(model.__name__))
async def create(item: model_create) -> dict: async def create(item: model_create, user=Depends(get_current_user)) -> dict:
await item.validate_foreign_key() await item.validate_foreign_key()
o = await model(**item.dict()).create() o = await model(**item.dict()).create()
return {"message": "{} added successfully".format(model.__name__), "id": o.id} return {"message": "{} added successfully".format(model.__name__), "id": o.id}
@router.get("/{id}", response_description="{} record retrieved".format(model.__name__)) @router.get("/{id}", response_description="{} record retrieved".format(model.__name__))
async def read_id(id: PydanticObjectId) -> model_read: async def read_id(id: PydanticObjectId, user=Depends(get_current_user)) -> model_read:
item = await model.get(id) item = await model.get(id)
return model_read(**item.dict()) return model_read(**item.dict())
@router.get("/", response_model=Page[model_read], response_description="{} records retrieved".format(model.__name__)) @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) -> Page[model_read]: 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) sort = parse_sort(sort_by)
query = parse_query(query, model_read) query = parse_query(query, model_read)
@@ -75,7 +81,7 @@ def get_crud_router(model, model_create, model_read, model_update):
return await items return await items
@router.put("/{id}", response_description="{} record updated".format(model.__name__)) @router.put("/{id}", response_description="{} record updated".format(model.__name__))
async def update(id: PydanticObjectId, req: model_update) -> model_read: 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} req = {k: v for k, v in req.dict().items() if v is not None}
update_query = {"$set": { update_query = {"$set": {
field: value for field, value in req.items() field: value for field, value in req.items()
@@ -92,7 +98,7 @@ def get_crud_router(model, model_create, model_read, model_update):
return model_read(**item.dict()) return model_read(**item.dict())
@router.delete("/{id}", response_description="{} record deleted from the database".format(model.__name__)) @router.delete("/{id}", response_description="{} record deleted from the database".format(model.__name__))
async def delete(id: PydanticObjectId) -> dict: async def delete(id: PydanticObjectId, user=Depends(get_current_superuser)) -> dict:
item = await model.get(id) item = await model.get(id)
if not item: if not item:

View File

@@ -6,7 +6,7 @@ from .user import User, AccessToken
from .entity.models import Entity from .entity.models import Entity
from .template.models import ContractTemplate, ProvisionTemplate from .template.models import ContractTemplate, ProvisionTemplate
from .order.models import Order from .order.models import Order
from .contract.models import ContractDraft from .contract.models import ContractDraft, Contract
DATABASE_URL = "mongodb://root:example@mongo:27017/" DATABASE_URL = "mongodb://root:example@mongo:27017/"
@@ -17,5 +17,6 @@ async def init_db():
) )
await init_beanie(database=client.db_name, await init_beanie(database=client.db_name,
document_models=[User, AccessToken, Entity, ContractTemplate, ProvisionTemplate, ContractDraft, ], document_models=[User, AccessToken, Entity, ContractTemplate, ProvisionTemplate, ContractDraft,
Contract, ],
allow_index_dropping=True) allow_index_dropping=True)

View File

@@ -15,13 +15,16 @@ class EntityType(BaseModel):
class Individual(EntityType): class Individual(EntityType):
type: Literal['individual'] = 'individual' type: Literal['individual'] = 'individual'
firstname: Indexed(str) firstname: Indexed(str) = Field(title='Prénom')
middlename: Indexed(str) = "" middlename: Indexed(str) = Field(default="", title='Autres prénoms')
lastname: Indexed(str) lastname: Indexed(str) = Field(title='Nom de famille')
surnames: List[Indexed(str)] = [] surnames: List[Indexed(str)] = Field(
day_of_birth: date default=[],
place_of_birth: str = "" props={"items-per-row": "4", "numbered": True},
title="Surnoms"
)
day_of_birth: date = Field(default=None, title='Date de naissance')
place_of_birth: str = Field(default="", title='Lieu de naissance')
@property @property
def label(self) -> str: def label(self) -> str:
@@ -30,36 +33,50 @@ class Individual(EntityType):
return '{} {}'.format(self.firstname, self.lastname) return '{} {}'.format(self.firstname, self.lastname)
class Config:
title = 'Particulier'
class Employee(BaseModel): class Employee(BaseModel):
role: Indexed(str) role: Indexed(str) = Field(title='Poste')
entity_id: str = Field(foreignKey={ entity_id: str = Field(foreignKey={
"reference": { "reference": {
"resource": "entity", "resource": "entity",
"schema": "Entity", "schema": "Entity",
"condition": "entity_data.type=individual" "condition": "entity_data.type=individual"
} }
}) },
title='Employé'
)
class Config:
title = 'Fiche Employé'
class Corporation(EntityType): class Corporation(EntityType):
type: Literal['corporation'] = 'corporation' type: Literal['corporation'] = 'corporation'
title: Indexed(str) title: Indexed(str) = Field(title='Dénomination sociale')
activity: Indexed(str) activity: Indexed(str) = Field(title='Activité')
employees: List[Employee] = Field(default=[]) employees: List[Employee] = Field(default=[], title='Employés')
class Config:
title = 'Entreprise'
class Institution(EntityType): class Institution(Corporation):
type: Literal['institution'] = 'institution' type: Literal['institution'] = 'institution'
title: Indexed(str)
activity: Indexed(str) class Config:
employees: List[Employee] = Field(default=[]) title = 'Institution'
class Entity(CrudDocument): class Entity(CrudDocument):
"""
Fiche d'un client
"""
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type') entity_data: Individual | Corporation | Institution = Field(..., discriminator='type')
label: str = None label: str = None
address: Optional[str] = "" address: str = Field(default="", title='Adresse')
@validator("label", always=True) @validator("label", always=True)
def generate_label(cls, v, values, **kwargs): def generate_label(cls, v, values, **kwargs):
@@ -75,3 +92,10 @@ class Entity(CrudDocument):
else datetime(year=dt.year, month=dt.month, day=dt.day, else datetime(year=dt.year, month=dt.month, day=dt.day,
hour=0, minute=0, second=0) hour=0, minute=0, second=0)
} }
class Config:
title = 'Client'
@classmethod
def get_create_resource(cls):
print('coucou')

View File

@@ -11,9 +11,11 @@ class EntityRead(Entity):
class EntityCreate(Writer): class EntityCreate(Writer):
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type') entity_data: Individual | Corporation | Institution = Field(..., discriminator='type')
address: Optional[str] = "" address: str = Field(default="", title='Adresse')
class Config:
title = "Création d'un client"
class EntityUpdate(BaseModel): class EntityUpdate(EntityCreate):
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type') pass
address: Optional[str] = ""

View File

@@ -4,8 +4,8 @@ from .contract import contract_router
from .db import init_db from .db import init_db
from .user import user_router, get_auth_router from .user import user_router, get_auth_router
from .entity import entity_router from .entity import entity_router
from .order import order_router
from .template import template_router from .template import template_router
# from .order import order_router
app = FastAPI(root_path="/api/v1") app = FastAPI(root_path="/api/v1")
@@ -15,17 +15,12 @@ async def on_startup():
await init_db() await init_db()
@app.get("/")
async def root():
return {"message": "Hello World"}
app.include_router(get_auth_router(), prefix="/auth", tags=["auth"], ) app.include_router(get_auth_router(), prefix="/auth", tags=["auth"], )
app.include_router(user_router, prefix="/users", tags=["users"], ) app.include_router(user_router, prefix="/users", tags=["users"], )
app.include_router(entity_router, prefix="/entity", tags=["entity"], ) app.include_router(entity_router, prefix="/entity", tags=["entity"], )
app.include_router(order_router, prefix="/order", tags=["order"], )
app.include_router(template_router, prefix="/template", tags=["template"], ) app.include_router(template_router, prefix="/template", tags=["template"], )
app.include_router(contract_router, prefix="/contract", tags=["contract"], ) app.include_router(contract_router, prefix="/contract", tags=["contract"], )
# app.include_router(order_router, prefix="/order", tags=["order"], )
if __name__ == '__main__': if __name__ == '__main__':
import uvicorn import uvicorn

View File

@@ -14,9 +14,10 @@ class PartyTemplate(BaseModel):
"schema": "Entity", "schema": "Entity",
} }
}, },
default="" default="",
title="Partie"
) )
part: str part: str = Field(title="Rôle")
representative_id: str = Field( representative_id: str = Field(
foreignKey={ foreignKey={
"reference": { "reference": {
@@ -24,9 +25,13 @@ class PartyTemplate(BaseModel):
"schema": "Entity", "schema": "Entity",
} }
}, },
default="" default="",
title="Représentant"
) )
class Config:
title = 'Partie'
def remove_html_tags(text): def remove_html_tags(text):
"""Remove html tags from a string""" """Remove html tags from a string"""
@@ -36,10 +41,14 @@ def remove_html_tags(text):
class ProvisionTemplate(CrudDocument): class ProvisionTemplate(CrudDocument):
name: str """
title: str = RichtextSingleline() Modèle de clause à décliner
"""
name: str = Field(title="Nom")
title: str = RichtextSingleline(title="Titre")
label: str = "" label: str = ""
body: str = RichtextMultiline() body: str = RichtextMultiline(title="Corps")
@validator("label", always=True) @validator("label", always=True)
def generate_label(cls, v, values, **kwargs): def generate_label(cls, v, values, **kwargs):
@@ -48,6 +57,9 @@ class ProvisionTemplate(CrudDocument):
class Settings(CrudDocument.Settings): class Settings(CrudDocument.Settings):
fulltext_search = ['name', 'title', 'body'] fulltext_search = ['name', 'title', 'body']
class Config:
title = 'Template de clause'
class ProvisionTemplateReference(BaseModel): class ProvisionTemplateReference(BaseModel):
provision_template_id: str = Field( provision_template_id: str = Field(
@@ -58,22 +70,31 @@ class ProvisionTemplateReference(BaseModel):
"displayedFields": ['title', 'body'] "displayedFields": ['title', 'body']
}, },
}, },
props={"parametrized": True} props={"parametrized": True},
title="Template de clause"
) )
class Config:
title = 'Clause'
class ContractTemplate(CrudDocument): class ContractTemplate(CrudDocument):
name: str """
title: str Modèle de contrat à décliner
"""
name: str = Field(title="Nom")
title: str = Field(title="Titre")
label: str = "" label: str = ""
parties: List[PartyTemplate] = [] parties: List[PartyTemplate] = Field(default=[], title="Parties")
provisions: List[ProvisionTemplateReference] = Field( provisions: List[ProvisionTemplateReference] = Field(
default=[], default=[],
props={"items-per-row": "1", "numbered": True} props={"items-per-row": "1", "numbered": True},
title="Clauses"
) )
variables: List[DictionaryEntry] = Field( variables: List[DictionaryEntry] = Field(
default=[], default=[],
format="dictionary", format="dictionary",
title="Variables"
) )
@validator("label", always=True) @validator("label", always=True)
@@ -82,3 +103,6 @@ class ContractTemplate(CrudDocument):
class Settings(CrudDocument.Settings): class Settings(CrudDocument.Settings):
fulltext_search = ['name', 'title'] fulltext_search = ['name', 'title']
class Config:
title = 'Template de contrat'

View File

@@ -11,15 +11,23 @@ class ContractTemplateRead(ContractTemplate):
class ContractTemplateCreate(Writer): class ContractTemplateCreate(Writer):
name: str name: str = Field(title="Nom")
title: str title: str = Field(title="Titre")
parties: List[PartyTemplate] = [] 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( variables: List[DictionaryEntry] = Field(
default=[], default=[],
format="dictionary", format="dictionary",
props={"required": False} props={"required": False},
title="Variables"
) )
provisions: List[ProvisionTemplateReference] = []
class Config:
title = 'Template de Contrat'
class ContractTemplateUpdate(ContractTemplateCreate): class ContractTemplateUpdate(ContractTemplateCreate):
@@ -31,9 +39,12 @@ class ProvisionTemplateRead(ProvisionTemplate):
class ProvisionTemplateCreate(Writer): class ProvisionTemplateCreate(Writer):
name: str name: str = Field(title="Nom")
title: str = RichtextSingleline() title: str = RichtextSingleline(title="Titre")
body: str = RichtextMultiline() body: str = RichtextMultiline(title="Corps")
class Config:
title = 'Template de Clause'
class ProvisionTemplateUpdate(ProvisionTemplateCreate): class ProvisionTemplateUpdate(ProvisionTemplateCreate):

View File

@@ -1,3 +1,3 @@
from .routes import router as user_router, get_auth_router from .routes import router as user_router
from .manager import get_auth_router
from .models import User, AccessToken from .models import User, AccessToken

View File

@@ -88,10 +88,10 @@ async def get_user_manager(user_db=Depends(get_user_db)):
def get_database_strategy( def get_database_strategy(
access_token_db: AccessTokenDatabase[AccessToken] = Depends(get_access_token_db), access_token_db: AccessTokenDatabase[AccessToken] = Depends(get_access_token_db),
) -> DatabaseStrategy: ) -> DatabaseStrategy:
return DatabaseStrategy(access_token_db, lifetime_seconds=3600) return DatabaseStrategy(access_token_db, lifetime_seconds=None)
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") bearer_transport = BearerTransport(tokenUrl="auth/login")
auth_backend = AuthenticationBackend( auth_backend = AuthenticationBackend(
@@ -107,6 +107,7 @@ fastapi_users = FastAPIUsers[User, uuid.UUID](
) )
get_current_user = fastapi_users.current_user(active=True) get_current_user = fastapi_users.current_user(active=True)
get_current_superuser = fastapi_users.current_user(active=True, superuser=True)
def get_auth_router(): def get_auth_router():

View File

@@ -1,6 +1,6 @@
from typing import Optional, TypeVar from typing import Optional, TypeVar
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field from pydantic import Field
from beanie import PydanticObjectId from beanie import PydanticObjectId
from fastapi_users.db import BeanieBaseUser, BeanieUserDatabase from fastapi_users.db import BeanieBaseUser, BeanieUserDatabase
@@ -15,6 +15,7 @@ class AccessToken(BeanieBaseAccessToken[PydanticObjectId]):
class User(BeanieBaseUser[PydanticObjectId]): class User(BeanieBaseUser[PydanticObjectId]):
login: str login: str
entity_id: str
created_at: datetime = Field(default=datetime.utcnow(), nullable=False) created_at: datetime = Field(default=datetime.utcnow(), nullable=False)
updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)

View File

@@ -7,15 +7,15 @@ from typing import List
from .models import User from .models import User
from .schemas import UserRead, UserUpdate, UserCreate from .schemas import UserRead, UserUpdate, UserCreate
from .manager import get_user_manager, get_current_user, get_auth_router from .manager import get_user_manager, get_current_user, get_current_superuser
router = APIRouter() router = APIRouter()
@router.post("/", response_description="User added to the database") @router.post("/", response_description="User added to the database")
async def create(user: UserCreate, user_manager=Depends(get_user_manager)) -> dict: async def create(user_form: UserCreate, user_manager=Depends(get_user_manager), user=Depends(get_current_superuser)) -> dict:
await user_manager.create(user, safe=True) await user_manager.create(user_form, safe=True)
return {"message": "User added successfully"} return {"message": "User added successfully"}
@@ -26,22 +26,22 @@ async def read_me(user=Depends(get_current_user)) -> UserRead:
@router.get("/{id}", response_description="User record retrieved") @router.get("/{id}", response_description="User record retrieved")
async def read_id(id: PydanticObjectId) -> UserRead: async def read_id(id: PydanticObjectId, user=Depends(get_current_superuser)) -> UserRead:
user = await User.get(id) user = await User.get(id)
return UserRead(**user.dict()) return UserRead(**user.dict())
@router.get("/", response_model=List[UserRead], response_description="User records retrieved") @router.get("/", response_model=List[UserRead], response_description="User records retrieved")
async def read_list() -> List[UserRead]: async def read_list(user=Depends(get_current_superuser)) -> List[UserRead]:
users = await User.find_all().to_list() users = await User.find_all().to_list()
return users return users
@router.put("/{id}", response_description="User record updated") @router.put("/{id}", response_description="User record updated")
async def update(id: PydanticObjectId, req: UserUpdate) -> UserRead: async def update(id: PydanticObjectId, user_form: UserUpdate, user=Depends(get_current_superuser)) -> UserRead:
req = {k: v for k, v in req.dict().items() if v is not None} user_form = {k: v for k, v in user_form.dict().items() if v is not None}
update_query = {"$set": { update_query = {"$set": {
field: value for field, value in req.items() field: value for field, value in user_form.items()
}} }}
user = await User.get(id) user = await User.get(id)
@@ -56,7 +56,7 @@ async def update(id: PydanticObjectId, req: UserUpdate) -> UserRead:
@router.delete("/{id}", response_description="User record deleted from the database") @router.delete("/{id}", response_description="User record deleted from the database")
async def delete(id: PydanticObjectId) -> dict: async def delete(id: PydanticObjectId, user=Depends(get_current_superuser)) -> dict:
record = await User.get(id) record = await User.get(id)
if not record: if not record:

View File

@@ -1,10 +1,6 @@
import uuid from pydantic import BaseModel
from typing import TypeVar
from pydantic import BaseModel, Field
from fastapi_users import schemas from fastapi_users import schemas
from ..core.schemas import Reader
from .models import User from .models import User
@@ -24,6 +20,7 @@ class UserCreate(UserBase):
login: str login: str
password: str password: str
email: str email: str
entity_id: str
class UserUpdate(UserBase): class UserUpdate(UserBase):

View File

View File

View File

@@ -8,6 +8,7 @@ services:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./back/app:/code/app - ./back/app:/code/app
- ./back/media:/code/media
front: front:
build: build:
@@ -27,7 +28,7 @@ services:
- "80:80" - "80:80"
mongo: mongo:
image: "mongo" image: "mongo:4.4.18"
restart: always restart: always
ports: ports:
- "27017:27017" - "27017:27017"

View File

@@ -6,11 +6,11 @@ RUN npm install -g http-server
# make the 'app' folder the current working directory # make the 'app' folder the current working directory
WORKDIR /app WORKDIR /app
RUN npm install -g @angular/cli
# copy both 'package.json' and 'package-lock.json' (if available) # copy both 'package.json' and 'package-lock.json' (if available)
COPY app/package*.json ./ COPY app/package*.json ./
# install project dependencies # install project dependencies
RUN npm install -g @angular/cli
RUN npm install RUN npm install
# copy project files and folders to the current working directory (i.e. 'app' folder) # copy project files and folders to the current working directory (i.e. 'app' folder)

View File

@@ -9,10 +9,20 @@
"root": "", "root": "",
"sourceRoot": "src", "sourceRoot": "src",
"prefix": "app", "prefix": "app",
"i18n": {
"sourceLocale": "en-US",
"locales": {
"fr": {
"translation": "src/locale/messages.fr.xlf"
}
}
},
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:browser", "builder": "@angular-devkit/build-angular:browser",
"options": { "options": {
"localize": ["fr"],
"i18nMissingTranslation": "warning",
"outputPath": "dist/app", "outputPath": "dist/app",
"index": "src/index.html", "index": "src/index.html",
"main": "src/main.ts", "main": "src/main.ts",
@@ -53,6 +63,9 @@
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true, "sourceMap": true,
"namedChunks": true "namedChunks": true
},
"fr": {
"localize": ["fr"]
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"
@@ -65,14 +78,22 @@
}, },
"development": { "development": {
"browserTarget": "app:build:development" "browserTarget": "app:build:development"
},
"fr": {
"browserTarget": "App:build:fr"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n", "builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
"options": { "options": {
"browserTarget": "app:build" "browserTarget": "app:build",
"format": "xlf2",
"outputPath": "src/locale",
"targetFiles": [
"messages.fr.xlf"
]
} }
}, },
"test": { "test": {

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^15.0.0", "@angular/animations": "^15.0.0",
"@angular/cdk": "^15.2.1",
"@angular/common": "^15.0.0", "@angular/common": "^15.0.0",
"@angular/compiler": "^15.0.0", "@angular/compiler": "^15.0.0",
"@angular/core": "^15.0.0", "@angular/core": "^15.0.0",
@@ -23,6 +24,8 @@
"@ngx-formly/core": "^6.0.0", "@ngx-formly/core": "^6.0.0",
"@popperjs/core": "^2.11.6", "@popperjs/core": "^2.11.6",
"@tinymce/tinymce-angular": "^7.0.0", "@tinymce/tinymce-angular": "^7.0.0",
"@types/fabric": "^5.3.0",
"fabric": "^5.3.0",
"ngx-bootstrap-icons": "^1.9.1", "ngx-bootstrap-icons": "^1.9.1",
"ngx-wig": "^15.1.4", "ngx-wig": "^15.1.4",
"rxjs": "~7.5.0", "rxjs": "~7.5.0",
@@ -42,6 +45,7 @@
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.0.0", "karma-jasmine-html-reporter": "~2.0.0",
"ng-extract-i18n-merge": "^2.5.1",
"typescript": "~4.8.2" "typescript": "~4.8.2"
} }
} }

View File

@@ -31,7 +31,7 @@ const routes: Routes = [
import('./views/templates/templates.module').then((m) => m.TemplatesModule) import('./views/templates/templates.module').then((m) => m.TemplatesModule)
}, },
{ {
path: 'contract-drafts', path: 'contracts',
loadChildren: () => loadChildren: () =>
import('./views/contracts/contracts.module').then((m) => m.ContractsModule) import('./views/contracts/contracts.module').then((m) => m.ContractsModule)
}, },

View File

@@ -3,7 +3,7 @@
<main style="margin-top: 0px;"> <main style="margin-top: 0px;">
<div class="container-fluid"> <div class="container-fluid">
<div class="row flex-nowrap"> <div class="row flex-nowrap">
<div class="col-auto col-md-3 col-xl-2 px-sm-2 px-0 bg-dark"> <div class="col-auto col-md-3 col-xl-2 px-sm-2 px-0 bg-dark" style="max-width: 200px;">
<sidenav class="sticky-top"></sidenav> <sidenav class="sticky-top"></sidenav>
</div> </div>
<div class="col py-3"> <div class="col py-3">
@@ -13,3 +13,4 @@
</div> </div>
</div> </div>
</main> </main>

View File

@@ -6,5 +6,7 @@ import { Component } from '@angular/core';
styleUrls: ['./app.component.css'] styleUrls: ['./app.component.css']
}) })
export class AppComponent { export class AppComponent {
title = 'app'; title = 'Cooper, Hillman & Toshi';
constructor() {}
} }

View File

@@ -2,6 +2,8 @@ import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'; import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons';
import { ReactiveFormsModule } from "@angular/forms";
import { HttpClientModule, HTTP_INTERCEPTORS } from "@angular/common/http";
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
@@ -9,20 +11,31 @@ import { AppComponent } from './app.component';
import { SidenavComponent } from "./layout/sidenav/sidenav.component"; import { SidenavComponent } from "./layout/sidenav/sidenav.component";
import { FlashmessagesComponent } from "./layout/flashmessages/flashmessages.component"; import { FlashmessagesComponent } from "./layout/flashmessages/flashmessages.component";
import { FlashmessagesService } from "./layout/flashmessages/flashmessages.service"; import { FlashmessagesService } from "./layout/flashmessages/flashmessages.service";
import { LoginComponent, LogoutComponent } from "./layout/auth/auth.component";
import { AuthService } from "./layout/auth/auth.service";
import { AuthInterceptor } from "./layout/auth/auth.interceptor"
@NgModule({ @NgModule({
declarations: [ declarations: [
AppComponent, AppComponent,
SidenavComponent, SidenavComponent,
FlashmessagesComponent FlashmessagesComponent,
LoginComponent,
LogoutComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
AppRoutingModule, AppRoutingModule,
NgbModule, NgbModule,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule,
HttpClientModule,
],
providers: [
FlashmessagesService,
AuthService,
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
], ],
providers: [FlashmessagesService],
bootstrap: [AppComponent] bootstrap: [AppComponent]
}) })
export class AppModule { } export class AppModule { }

View File

@@ -0,0 +1,126 @@
import {Component, ElementRef, EventEmitter, Output, ViewChild} from "@angular/core";
import {FormBuilder, FormGroup, Validators} from "@angular/forms";
import {Router} from "@angular/router";
import {AuthService} from "./auth.service";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'login',
template: `
<button *ngIf="!isAuthenticated"
class="nav-link px-3 w-100"
(click)="openLoginModal()"
><i-bs name="key-fill"/><span class="ms-1 d-none d-sm-inline" i18n>Login</span></button>
<ng-template #loginModal let-modal>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title" i18n>Login</h4>
<button type="button" class="btn-close" aria-label="Close"
(click)="modal.dismiss('Cross click')"></button>
</div>
<div class="modal-body">
<form [formGroup]="form">
<fieldset>
<div class="form-field">
<label i18n>Username:</label>
<input class="form-control" name="username" formControlName="username">
</div>
<div class="form-field">
<label i18n>Password:</label>
<input class="form-control" name="password" formControlName="password"
type="password">
</div>
</fieldset>
</form>
</div>
<div class="modal-footer">
<div class="form-buttons">
<button class="btn btn-primary"
(click)="this.login()" i18n>Login</button>
</div>
<div class="form-buttons">
<button class="btn btn-danger"
(click)="modal.dismiss('Cancel click')" i18n>Cancel</button>
</div>
</div>
</ng-template>
`
})
export class LoginComponent {
@Output() loginSuccess = new EventEmitter<string>()
@ViewChild('loginModal')
loginModal!: ElementRef;
form: FormGroup;
isAuthenticated: boolean = false
constructor(private fb:FormBuilder,
private authService: AuthService,
private modalService: NgbModal
) {
this.form = this.fb.group({
username: ['',Validators.required],
password: ['',Validators.required]
});
this.authService.onAuthenticationRequired$.subscribe((required: boolean) => {
if (required) {
this.openLoginModal();
}
})
this.isAuthenticated = this.authService.is_authenticated;
this.authService.onIsAuthenticated$.subscribe((isAuthenticated: boolean) => {
this.isAuthenticated = isAuthenticated;
})
}
openLoginModal() {
this.modalService.open(this.loginModal, { ariaLabelledBy: 'modal-basic-title' }).result.then(
);
}
login() {
const val = this.form.value;
if (val.username && val.password) {
this.authService.login(val.username, val.password)
.subscribe((answer: string) => {
this.form.reset();
this.modalService.dismissAll();
this.loginSuccess.emit(answer)
}
);
}
}
}
@Component({
selector: 'logout',
template: `
<button *ngIf="isAuthenticated"
class="nav-link px-3 w-100"
(click)="logout()"
><i-bs name="door-open-fill"/><span class="ms-1 d-none d-sm-inline" i18n>Logout</span></button>
`
})
export class LogoutComponent {
@Output() logoutSuccess = new EventEmitter()
isAuthenticated: boolean = false
constructor(private authService: AuthService) {
this.isAuthenticated = this.authService.is_authenticated;
this.authService.onIsAuthenticated$.subscribe((isAuthenticated: boolean) => {
this.isAuthenticated = isAuthenticated
})
}
logout() {
this.authService.logout()
.subscribe(() => {
this.logoutSuccess.emit()
}
);
}
}

View File

@@ -0,0 +1,38 @@
import { Injectable } from "@angular/core";
import { HttpErrorResponse, HttpHandler, HttpInterceptor, HttpRequest } from "@angular/common/http";
import { Observable, throwError } from 'rxjs';
import { catchError } from "rxjs/operators";
import { AuthService } from './auth.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private auth: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler) {
const authToken = this.auth.getAuthorizationToken();
let authReq: HttpRequest<any>;
if (authToken) {
authReq = req.clone({
headers: req.headers.set('Authorization', authToken)
});
} else {
authReq = req;
}
return next.handle(authReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status && error.status == 401) {
this.auth.requestAuth();
return throwError(() => $localize`Authentication required`);
}
if (error.status && error.status == 403) {
return throwError(() => $localize`Permissions too low`);
}
return throwError(() => error);
})
);
}
}

View File

@@ -0,0 +1,75 @@
import { Injectable } from "@angular/core";
import { HttpClient, HttpHeaders, HttpParams } from "@angular/common/http";
import { map } from "rxjs/operators";
import { Subject } from "rxjs";
import { FlashmessagesService } from "../flashmessages/flashmessages.service";
export interface token_answer {
access_token: string,
token_type: string
}
@Injectable()
export class AuthService {
private access_token: string | null;
private authenticationRequired = new Subject<boolean>();
onAuthenticationRequired$ = this.authenticationRequired.asObservable();
is_authenticated = false;
private isAuthenticated = new Subject<boolean>();
onIsAuthenticated$ = this.isAuthenticated.asObservable();
constructor(private http: HttpClient,
private flashMessage: FlashmessagesService) {
this.access_token = localStorage.getItem('authtoken')
this.is_authenticated = Boolean(this.access_token)
this.isAuthenticated.next(this.is_authenticated)
}
login(username:string, password:string ) {
const body = new HttpParams()
.set('username', username)
.set('password', password);
return this.http.post<token_answer>('/api/v1/auth/login', body.toString(),
{
headers: new HttpHeaders()
.set('Content-Type', 'application/x-www-form-urlencoded')
}).pipe(map( v => {
localStorage.setItem('authtoken', v.access_token);
this.access_token = v.access_token
this.flashMessage.success('Login successful. Welcome ' + username);
this.is_authenticated = true;
this.isAuthenticated.next(this.is_authenticated)
return username
} ));
}
logout() {
return this.http.post<token_answer>('/api/v1/auth/logout', '',
{
headers: new HttpHeaders()
}).pipe(map( v => {
localStorage.removeItem('authtoken');
this.access_token = null;
this.flashMessage.success('Logout successful. Goodbye');
this.is_authenticated = false;
this.isAuthenticated.next(this.is_authenticated)
} ));
}
getAuthorizationToken() {
return `Bearer ${this.access_token}`;
}
requestAuth() {
localStorage.removeItem('authtoken');
this.access_token = null;
this.authenticationRequired.next(true);
this.is_authenticated = false;
this.isAuthenticated.next(this.is_authenticated)
}
}

View File

@@ -1,12 +1,16 @@
<div class="pt-2 text-white min-vh-100 text-nowrap"> <div class="pt-2 text-white min-vh-100 text-nowrap">
<a routerLink="/" class="d-flex align-items-center pb-3 mb-md-0 me-md-auto text-white text-decoration-none"> <a routerLink="/" class="d-flex align-items-center pb-3 mb-md-0 me-md-auto text-white text-decoration-none">
<span class="fs-5 d-none d-sm-inline">Cooper, Hillman &<br/>Toshi LLP</span> <img class="fs-5 d-none d-sm-inline w-100" src="/assets/logo.png" alt="Cooper, Hillman & Toshi LLP">
</a> </a>
<ul class="nav nav-pills flex-column align-items-sm-start mb-sm-auto mb-0 w-100" id="menu"> <ul class="nav nav-pills flex-column align-items-sm-start mb-sm-auto mb-0 w-100" id="menu">
<li><login></login></li>
<ng-container *ngIf="isAuthenticated">
<li *ngFor="let item of Menu" class="nav-item w-100"> <li *ngFor="let item of Menu" class="nav-item w-100">
<a class="nav-link px-3 w-100" routerLink="{{item.link}}" [class.active]="is_current_page(item)"> <a class="nav-link px-3 w-100" routerLink="{{item.link}}" [class.active]="is_current_page(item)">
<i-bs [name]="item.icon"></i-bs><span class="ms-1 d-none d-sm-inline" [innerHTML]="item.title"></span> <i-bs [name]="item.icon"></i-bs><span class="ms-1 d-none d-sm-inline" [innerHTML]="item.title"></span>
</a> </a>
</li> </li>
</ng-container>
<li><logout></logout></li>
</ul> </ul>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { IconNamesEnum } from "ngx-bootstrap-icons"; import { IconNamesEnum } from "ngx-bootstrap-icons";
import { AuthService } from "../auth/auth.service";
@Component({ @Component({
selector: "sidenav", selector: "sidenav",
@@ -10,33 +11,48 @@ import { IconNamesEnum } from "ngx-bootstrap-icons";
export class SidenavComponent { export class SidenavComponent {
Menu = [ Menu = [
{ {
title: "Dashboard", title: $localize`Dashboard`,
link: "/dashboard", link: "/dashboard",
icon: IconNamesEnum.HouseFill icon: IconNamesEnum.HouseFill
}, },
{ {
title: "Entities", title: $localize`Entities`,
link: "/entities", link: "/entities",
icon: IconNamesEnum.PeopleFill icon: IconNamesEnum.PeopleFill
}, },
{ {
title: "Provision&nbsp;Templates", title: $localize`Provision&nbsp;Templates`,
link: "/templates/provisions", link: "/templates/provisions",
icon: IconNamesEnum.BlockquoteLeft icon: IconNamesEnum.BlockquoteLeft
}, },
{ {
title: "Contracts&nbsp;Templates", title: $localize`Contracts&nbsp;Templates`,
link: "/templates/contracts", link: "/templates/contracts",
icon: IconNamesEnum.FileCodeFill icon: IconNamesEnum.FileCodeFill
}, },
{ {
title: "Contracts&nbsp;Drafts", title: $localize`Contracts&nbsp;Drafts`,
link: "/contract-drafts", link: "/contracts/drafts",
icon: IconNamesEnum.PencilSquare icon: IconNamesEnum.PencilSquare
}, },
{
title: $localize`Contracts`,
link: "/contracts",
icon: IconNamesEnum.FileEarmarkTextFill
},
] ]
constructor(private router: Router) {} isAuthenticated: boolean = false
constructor(
private router: Router,
private authService: AuthService,) {
this.isAuthenticated = this.authService.is_authenticated;
this.authService.onIsAuthenticated$.subscribe((isAuthenticated: boolean) => {
this.isAuthenticated = isAuthenticated;
})
}
is_current_page(menu_item: any) { is_current_page(menu_item: any) {
return this.router.url.indexOf(menu_item.link) > -1; return this.router.url.indexOf(menu_item.link) > -1;

View File

@@ -4,5 +4,6 @@
[schema]="this.schema" [schema]="this.schema"
(resourceUpdated)="this.flashService.success('Entity updated')" (resourceUpdated)="this.flashService.success('Entity updated')"
(resourceDeleted)="this.flashService.success('Entity deleted')" (resourceDeleted)="this.flashService.success('Entity deleted')"
(resourceReceived)="this.onResourceReceived($event)"
(error)="this.flashService.error($event)"> (error)="this.flashService.error($event)">
</crud-card> </crud-card>

View File

@@ -1,4 +1,4 @@
import {Component, Input} from "@angular/core"; import {Component, EventEmitter, Input, Output} from "@angular/core";
import {ActivatedRoute, ParamMap} from "@angular/router"; import {ActivatedRoute, ParamMap} from "@angular/router";
import { FlashmessagesService } from "../../../layout/flashmessages/flashmessages.service"; import { FlashmessagesService } from "../../../layout/flashmessages/flashmessages.service";
@@ -11,6 +11,8 @@ export class BaseCrudCardComponent {
@Input() resource_id: string | null = null; @Input() resource_id: string | null = null;
@Input() schema: string | undefined; @Input() schema: string | undefined;
@Output() resourceReceived: EventEmitter<any> = new EventEmitter();
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
public flashService: FlashmessagesService public flashService: FlashmessagesService
@@ -23,4 +25,8 @@ export class BaseCrudCardComponent {
}) })
} }
} }
onResourceReceived(model: any) {
this.resourceReceived.emit(model);
}
} }

View File

@@ -2,6 +2,7 @@
[resource]="this.resource" [resource]="this.resource"
[schema]="this.schema" [schema]="this.schema"
[columns]="this.columns" [columns]="this.columns"
[filters]="this.filters"
(result)="this.flashService.success($event)" (result)="this.flashService.success($event)"
(error)="this.flashService.error($event)"> (error)="this.flashService.error($event)">
</crud-list> </crud-list>

View File

@@ -9,6 +9,7 @@ export class BaseCrudListComponent {
@Input() resource: string = ""; @Input() resource: string = "";
@Input() columns: string[] = []; @Input() columns: string[] = [];
@Input() schema: string | undefined; @Input() schema: string | undefined;
@Input() filters: string[] = [];
constructor( constructor(
public flashService: FlashmessagesService public flashService: FlashmessagesService

View File

@@ -1,4 +1,4 @@
<base-card <base-card
[resource]="this.resource" [resource]="this.resource"
[schema]="this.schema"> [schema]="this.schema">
</base-card>' </base-card>

View File

@@ -1,5 +1,6 @@
<base-list <base-list
[resource]="this.resource" [resource]="this.resource"
[schema]="this.schema" [schema]="this.schema"
[columns]="this.columns"> [columns]="this.columns"
[filters]="this.filters">
</base-list> </base-list>

View File

@@ -1,40 +1,78 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { DraftCardComponent, DraftListComponent, DraftNewComponent } from "./drafts.component"; import { DraftsCardComponent, DraftsListComponent, DraftsNewComponent } from "./drafts.component";
import { ContractsCardComponent, ContractsListComponent, ContractsNewComponent, ContractsSignatureComponent} from "./contracts.component";
const routes: Routes = [ const routes: Routes = [
{ {
path: '', path: '',
data: { data: {
title: 'Entities', title: 'Contracts',
}, },
children: [ children: [
{ path: '', redirectTo: 'list', pathMatch: 'full' }, { path: '', redirectTo: 'list', pathMatch: 'full' },
{ {
path: 'list', path: 'list',
component: DraftListComponent, component: ContractsListComponent,
data: { data: {
title: 'List', title: 'List',
}, },
}, },
{ {
path: 'new', path: 'new',
component: DraftNewComponent, component: ContractsNewComponent,
data: {
title: 'New',
},
},
{
path: 'signature/:id',
component: ContractsSignatureComponent,
data: {
title: 'New',
},
},
{
path: 'drafts',
data: {
title: 'Drafts',
},
children: [
{ path: '', redirectTo: 'list', pathMatch: 'full' },
{
path: 'list',
component: DraftsListComponent,
data: {
title: 'List',
},
},
{
path: 'new',
component: DraftsNewComponent,
data: { data: {
title: 'New', title: 'New',
}, },
}, },
{ {
path: ':id', path: ':id',
component: DraftCardComponent, component: DraftsCardComponent,
data: { data: {
title: 'Card', title: 'Card',
}, },
}, },
], ],
}, },
{
path: ':id',
component: ContractsCardComponent,
data: {
title: 'Card',
},
}
]
}
]; ];
@NgModule({ @NgModule({

View File

@@ -0,0 +1,152 @@
import { Component, Input, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from "@angular/router";
import { DomSanitizer } from "@angular/platform-browser";
import { ImageUploaderCrudService } from "@common/crud/crud.service";
export class BaseContractsComponent {
protected resource: string = "contract";
protected schema: string = "Contract";
}
@Component({
templateUrl: '../base-view/templates/list.template.html'
})
export class ContractsListComponent extends BaseContractsComponent {
columns = ['status', 'name', 'title', 'parties.items.entity.label', 'lawyer.label', 'date'];
filters = ['status'];
}
@Component({
templateUrl: '../base-view/templates/new.template.html'
})
export class ContractsNewComponent extends BaseContractsComponent {
@Input() value: {} = {};
}
@Component({
template:`
<ng-container *ngIf="this.resourceReadyToPrint; else previewLink">
<label i18n>Download Link:</label>
<div class="input-group mb-12">
<span class="input-group-text"><a href="{{ this.contractPrintLink! }}" target="_blank">{{ this.contractPrintLink! }}</a></span>
<button type="button" class="btn btn-light" [cdkCopyToClipboard]="this.contractPrintLink!"><i-bs name="text-paragraph"/></button>
</div>
</ng-container>
<ng-template #previewLink>
<label i18n>Preview Link:</label>
<div class="input-group mb-12">
<span class="input-group-text"><a href="{{ this.contractPreviewLink! }}" target="_blank">{{ this.contractPreviewLink! }}</a></span>
<button type="button" class="btn btn-light" [cdkCopyToClipboard]="this.contractPreviewLink!"><i-bs name="text-paragraph"/></button>
</div>
</ng-template>
<base-card
[resource_id]="this.resource_id"
[resource]="this.resource"
[schema]="this.schema"
(resourceReceived)="this.onResourceReceived($event)">
</base-card>
`,
})
export class ContractsCardComponent extends BaseContractsComponent{
resource_id: string | null = null;
resourceReadyToPrint = false;
contractPrintLink: string | null = null;
contractPreviewLink: string | null = null;
constructor(
private route: ActivatedRoute
) {
super()
}
ngOnInit(): void {
if (this.resource_id === null) {
this.route.paramMap.subscribe((params: ParamMap) => {
this.resource_id = params.get('id')
})
}
}
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}`
}
}
@Component({
template: `
<ngb-accordion #acc="ngbAccordion" activeIds="ngb-panel-1">
<ngb-panel>
<ng-template ngbPanelTitle>
<span>Preview</span>
</ng-template>
<ng-template ngbPanelContent>
<iframe width="100%"
[src]="getPreview()"
onload='javascript:(function(o){o.style.height=o.contentWindow.document.body.scrollHeight+"px";o.style.width=o.contentWindow.document.body.scrollWidth+"px";}(this));' style="height:200px;width:100%;border:none;overflow:hidden;"></iframe>
</ng-template>
</ngb-panel>
<ngb-panel>
<ng-template ngbPanelTitle>
<span>Signature</span>
</ng-template>
<ng-template ngbPanelContent>
<ng-container *ngIf="this.affixed">This Contract has already been signed by {{ this.signatory }}</ng-container>
<div class="row" *ngIf="!this.affixed">
<signature-drawer class="col-7"
(signatureDrawn$)="postSignature($event)"></signature-drawer>
<div class="col-5">
<p>Cette page est à la destination exclusive de <strong>{{ this.signatory }}</strong></p>
<p>Si vous n'êtes <strong>pas</strong> {{ this.signatory }}, veuillez <strong>fermer cette page immédiatement</strong> et surpprimer tous les liens en votre possession menant vers celle-ci.</p>
<p>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 <strong>usurpation d'identité</strong> et vous vous exposez ainsi à une amende de 20 000$ ainsi qu'à des poursuites civiles.</p>
<p>Le cabinet Cooper, Hillman & Toshi LLC</p>
</div>
</div>
</ng-template>
</ngb-panel>
</ngb-accordion>
`
})
export class ContractsSignatureComponent implements OnInit {
signature_id: string | null = null;
signature: any = {}
signatory = ""
affixed = false;
constructor(
private route: ActivatedRoute,
private sanitizer: DomSanitizer,
private crudService: ImageUploaderCrudService,
) {}
ngOnInit() {
this.route.paramMap.subscribe((params: ParamMap) => {
this.signature_id = params.get('id');
this.crudService.get('contract/signature', this.signature_id!).subscribe( (response:any) => {
this.signature = response;
this.affixed = this.signature.signature_affixed;
if (this.signature.representative) {
this.signatory = this.signature.representative.entity_data.firstname + " " + this.signature.representative.entity_data.lastname;
} else {
this.signatory = this.signature.entity.entity_data.firstname + " " + this.signature.entity.entity_data.lastname;
}
})
})
}
getPreview() {
return this.sanitizer.bypassSecurityTrustResourceUrl("/api/v1/contract/print/preview/signature/" + this.signature_id);
}
postSignature(image: string) {
this.crudService.upload('contract/signature', this.signature_id!, image).subscribe((v: any) => {
if (v) {
this.affixed = true;
}
})
}
}

View File

@@ -3,11 +3,17 @@ import { NgModule } from '@angular/core';
import { BaseViewModule } from "../base-view/base-view.module"; import { BaseViewModule } from "../base-view/base-view.module";
import { ContractsRoutingModule } from './contracts-routing.module'; import { ContractsRoutingModule } from './contracts-routing.module';
import { DraftCardComponent, DraftListComponent, DraftNewComponent, DraftNewFormComponent } from "./drafts.component"; import { DraftsCardComponent, DraftsListComponent, DraftsNewComponent, DraftsNewFormComponent } from "./drafts.component";
import { FormlyModule } from "@ngx-formly/core"; import { FormlyModule } from "@ngx-formly/core";
import { FormlyBootstrapModule } from "@ngx-formly/bootstrap"; import { FormlyBootstrapModule } from "@ngx-formly/bootstrap";
import { ForeignkeyTypeComponent } from "@common/crud/types/foreignkey.type"; import { ForeignkeyTypeComponent } from "@common/crud/types/foreignkey.type";
import { CrudService } from "@common/crud/crud.service"; import { CrudService, ImageUploaderCrudService } from "@common/crud/crud.service";
import { NgbAccordionModule, NgbCollapseModule } from "@ng-bootstrap/ng-bootstrap";
import { allIcons, NgxBootstrapIconsModule } from "ngx-bootstrap-icons";
import { ContractsCardComponent, ContractsListComponent, ContractsNewComponent, ContractsSignatureComponent } from "../contracts/contracts.component";
import { AlphaRangeComponent, BlackBlueRangeComponent, SignatureDrawerComponent } from "./signature-drawer/signature-drawer.component";
import { ClipboardModule } from "@angular/cdk/clipboard";
@NgModule({ @NgModule({
@@ -15,20 +21,31 @@ import { CrudService } from "@common/crud/crud.service";
CommonModule, CommonModule,
BaseViewModule, BaseViewModule,
ContractsRoutingModule, ContractsRoutingModule,
NgbAccordionModule,
NgbCollapseModule,
NgxBootstrapIconsModule.pick(allIcons),
FormlyModule.forRoot({ FormlyModule.forRoot({
types: [ types: [
{ name: 'foreign-key', component: ForeignkeyTypeComponent } { name: 'foreign-key', component: ForeignkeyTypeComponent }
] ]
}), }),
FormlyBootstrapModule, FormlyBootstrapModule,
ClipboardModule,
], ],
declarations: [ declarations: [
DraftListComponent, DraftsListComponent,
DraftNewComponent, DraftsNewComponent,
DraftCardComponent, DraftsCardComponent,
DraftNewFormComponent DraftsNewFormComponent,
ContractsListComponent,
ContractsNewComponent,
ContractsCardComponent,
ContractsSignatureComponent,
SignatureDrawerComponent,
BlackBlueRangeComponent,
AlphaRangeComponent
], ],
providers: [CrudService] providers: [CrudService, ImageUploaderCrudService]
}) })
export class ContractsModule { export class ContractsModule {
} }

View File

@@ -3,9 +3,12 @@ import { FormlyFieldConfig } from "@ngx-formly/core";
import { FormGroup} from "@angular/forms"; import { FormGroup} from "@angular/forms";
import { CrudFormlyJsonschemaService } from "@common/crud/crud-formly-jsonschema.service"; import { CrudFormlyJsonschemaService } from "@common/crud/crud-formly-jsonschema.service";
import { CrudService } from "@common/crud/crud.service"; import { CrudService } from "@common/crud/crud.service";
import { ActivatedRoute, ParamMap, Router } from "@angular/router";
import { formatDate } from "@angular/common";
export class BaseEntitiesComponent { export class BaseDraftsComponent {
protected resource: string = "contract/draft"; protected resource: string = "contract/draft";
protected schema: string = "ContractDraft"; protected schema: string = "ContractDraft";
} }
@@ -13,15 +16,16 @@ export class BaseEntitiesComponent {
@Component({ @Component({
templateUrl: '../base-view/templates/list.template.html' templateUrl: '../base-view/templates/list.template.html'
}) })
export class DraftListComponent extends BaseEntitiesComponent { export class DraftsListComponent extends BaseDraftsComponent {
columns = []; columns = ['status', 'name', 'title', 'parties.items.part'];
filters = ['status'];
} }
@Component({ @Component({
selector: 'draft-new-form', selector: 'draft-new-form',
template: `<base-new [resource]="this.resource" [schema]="this.schema" [model]="this.value"></base-new>` template: `<base-new [resource]="this.resource" [schema]="this.schema" [model]="this.value"></base-new>`
}) })
export class DraftNewFormComponent extends BaseEntitiesComponent { export class DraftsNewFormComponent extends BaseDraftsComponent {
@Input() value: {} = {}; @Input() value: {} = {};
} }
@@ -32,7 +36,7 @@ export class DraftNewFormComponent extends BaseEntitiesComponent {
<draft-new-form [value]="this.templateModel"></draft-new-form> <draft-new-form [value]="this.templateModel"></draft-new-form>
` `
}) })
export class DraftNewComponent extends BaseEntitiesComponent implements OnInit { export class DraftsNewComponent extends BaseDraftsComponent implements OnInit {
templateModel: {} = {}; templateModel: {} = {};
temaplateFormfields: FormlyFieldConfig[] = []; temaplateFormfields: FormlyFieldConfig[] = [];
temaplateForm: FormGroup = new FormGroup({}); temaplateForm: FormGroup = new FormGroup({});
@@ -86,7 +90,81 @@ export class DraftNewComponent extends BaseEntitiesComponent implements OnInit {
} }
@Component({ @Component({
templateUrl: '../base-view/templates/card.template.html' template: `
<base-card
[resource]="this.resource"
[schema]="this.schema"
(resourceReceived)="this.onResourceReceived($event)"
>
</base-card>
<a class="btn btn-link" href="/api/v1/contract/print/preview/draft/{{this.resource_id}}" target="_blank">Preview</a>
<ng-container *ngIf="this.isReadyForPublication;">
<formly-form [fields]="newContractFormfields" [form]="newContractForm" [model]="newContractModel"></formly-form>
<button class="btn btn-success" (click)="publish()">Publish</button>
</ng-container>
`
}) })
export class DraftCardComponent extends BaseEntitiesComponent{ export class DraftsCardComponent extends BaseDraftsComponent implements OnInit {
resource_id: string | null = null;
templateModel: {} = {};
isReadyForPublication = false;
newContractFormfields: FormlyFieldConfig[] = [];
newContractForm: FormGroup = new FormGroup({});
newContractModel: any = {
date: formatDate(new Date(), 'YYYY-MM-dd', 'EN_US', 'CET'),
location: "Los Santos, SA",
draft_id: null
}
fieldJson = {
type: "object",
required: ["date", "location", "draft_id"],
properties: {
date: {
type: "string",
format: "date",
title: "Date"
},
location: {
type: "string",
title: "Location"
},
draft_id: {
type: "string",
title: "Contract Draft",
hidden: true
},
},
}
constructor(
private route: ActivatedRoute,
private formlyJsonschema: CrudFormlyJsonschemaService,
private crudService: CrudService,
private router: Router,
) {
super();
}
ngOnInit(): void {
if (this.resource_id === null) {
this.route.paramMap.subscribe((params: ParamMap) => {
this.resource_id = params.get('id')
})
}
this.newContractModel.draft_id = this.resource_id;
// @ts-ignore
this.newContractFormfields = [this.formlyJsonschema.toFieldConfig(this.fieldJson)];
}
publish() {
this.crudService.create('contract', this.newContractModel).subscribe((response: any) => {
this.router.navigate([`../../${response.id}`], {relativeTo: this.route});
});
}
onResourceReceived(model: any): void {
this.isReadyForPublication = model.status == "ready";
}
} }

View File

@@ -0,0 +1,22 @@
canvas {
border: solid black 1px;
}
.btn-file {
position: relative;
overflow: hidden;
}
.btn-file input[type=file] {
position: absolute;
top: 0;
right: 0;
min-width: 100%;
min-height: 100%;
font-size: 100px;
text-align: right;
filter: alpha(opacity=0);
opacity: 0;
outline: none;
cursor: inherit;
display: block;
}

View File

@@ -0,0 +1,48 @@
<div class="row align-items-start">
<canvas id="signatureCanvas" class="col-9" width="320" height="320"></canvas>
<div class="col-3" style="width: 90px">
<div class="card">
<span class="btn btn-light btn-file">
<i-bs name="image-fill"></i-bs><input #imageInput type="file" accept="image/png, image/gif, image/jpeg, image/bmp" (change)="addImage($event)">
</span>
<div #collapseImage="ngbCollapse" [(ngbCollapse)]="!isEditImage">
<color-range [value]="this.currentColor" (change)="updateColor($event)"></color-range>
<alpha-range (change)="updateAlpha($event)"></alpha-range>
</div>
</div>
<div class="card">
<button
type="button"
class="btn btn-light"
(click)="this.toggleDrawing();"
[ngClass]="{active: this.isDrawing}"
><i-bs name="pencil-fill"></i-bs></button>
<div #collapseDrawing="ngbCollapse" [(ngbCollapse)]="!isDrawing">
<color-range [value]="this.currentColor" (change)="this.updateColor($event)"></color-range>
<label for="thickRange" class="form-label">Thickness</label>
<input type="range" class="form-range" #thickRange [value]="this.currentThickness" max="100" (input)="updateThickness(thickRange.value)">
<input class="form-control" type="text" #thickInput [value]="this.currentThickness" (change)="updateThickness(thickInput.value)">
</div>
</div>
<div class="card">
<button
type="button"
class="btn btn-danger"
(click)="delete()"
><i-bs name="eraser-fill"></i-bs></button>
</div>
</div>
</div>
<button
type="button"
class="btn btn-primary"
(click)="sign()"
>Sign!</button>
<button
type="button"
class="btn btn-danger"
(click)="clear()"
>Clear</button>

View File

@@ -0,0 +1,244 @@
import {Component, Output, EventEmitter, OnInit, Input} from "@angular/core";
import { fabric } from 'fabric';
@Component({
selector: 'signature-drawer',
templateUrl: './signature-drawer.component.html',
styleUrls:['./signature-drawer.component.css']
})
export class SignatureDrawerComponent implements OnInit
{
@Output() signatureDrawn$ = new EventEmitter<string>()
size = 320;
canvas: any;
isEditImage = false;
isDrawing = false;
canDelete = false;
currentColor = "rgba(0,0,0,1)";
currentAlpha = 1;
currentThickness = 4;
elements : any[] = [];
ngOnInit() {
this.canvas = new fabric.Canvas('signatureCanvas');
let self = this;
this.canvas.on({
'selection:updated': function() {self.handleElement()},
'selection:created': function() {self.handleElement()}
});
const image = localStorage.getItem('signature_image');
if (image) {
fabric.Image.fromURL(image , function(img: any) {
self.canvas.add(img);
self.canvas.renderAll();
})
}
}
toggleDrawing() {
if (this.isDrawing) {
this.isDrawing = false;
this.canvas.isDrawingMode = false;
} else {
this.canvas.discardActiveObject().renderAll();
this.isEditImage = false;
this.isDrawing = true;
this.canvas.isDrawingMode = true;
let brush = this.canvas.freeDrawingBrush;
brush.color = this.currentColor;
brush.width = this.currentThickness;
}
//this.canvas.freeDrawingBrush.width
//this.canvas.freeDrawingBrush.shadow.blur = parseInt(this.value, 10)
}
handleElement(){
let selectedObjects = this.canvas.getActiveObject();
if (selectedObjects.hasOwnProperty("filters")) {
this.isEditImage = true;
} else {
this.isEditImage = false;
}
}
updateColor(value: string) {
if (typeof value == "object") {
return;
}
this.currentColor = value;
for (let o of this.getSelectedObjects()) {
this.updateColorFilter(o);
}
let brush = this.canvas.freeDrawingBrush;
brush.color = this.currentColor;
this.canvas.renderAll();
}
updateAlpha(value: number) {
if (typeof value == "object") {
return;
}
this.currentAlpha = value;
for (let o of this.getSelectedObjects()) {
o.opacity = this.currentAlpha;
}
this.canvas.renderAll();
}
updateThickness(value: string) {
this.currentThickness = +value
let brush = this.canvas.freeDrawingBrush;
brush.width = this.currentThickness;
}
getSelectedObjects(): any[] {
let obj = this.canvas.getActiveObject();
if (obj === undefined || obj === null) {
return []
} else if (obj.hasOwnProperty("_objects")) {
return obj._objects;
} else {
return [obj];
}
}
updateColorFilter(o: any) {
if (o.hasOwnProperty("filters")) {
for (let f of o.filters) {
if (f.type == "BlendColor") {
f.color = this.currentColor;
}
}
o.applyFilters()
}
}
addImage(event: any) {
let file = event.target.files[0]
let url = URL.createObjectURL(file);
let self = this;
fabric.Image.fromURL(url, function(img: any) {
let scale;
if (img.width > img.height) {
img.scaleToWidth(self.size, true);
scale = self.size / img.width;
} else {
img.scaleToHeight(self.size, true);
scale = self.size / img.height;
}
let resizeFilter = new fabric.Image.filters.Resize(({
resizeType: 'sliceHack',
scaleX: scale,
scaleY: scale
}));
//img.filters.push(resizeFilter);
img.filters.push(new fabric.Image.filters.Grayscale());
// @ts-ignore
img.filters.push(new fabric.Image.filters.RemoveColor())
img.filters.push(new fabric.Image.filters.BlendColor({
color: self.currentColor,
mode: 'lighten',
}));
img.applyFilters();
self.canvas.add(img);
self.canvas.renderAll();
self.elements.push(img);
event.target.value = "";
});
}
delete() {
this.canvas.remove(this.canvas.getActiveObject());
}
clear() {
this.canvas.clear();
}
sign() {
const image = this.canvas.toDataURL({
format: 'png',
quality: 0.8
});
localStorage.setItem('signature_image', image);
this.signatureDrawn$.next(image)
}
}
@Component({
selector: 'color-range',
template: `
<div>
<label for="colorRange" class="form-label">Color</label>
<input type="range" class="form-range" #colorRange [value]="this.currentColorRatio" max="255" (input)="this.updateColor(colorRange.value)">
<div class="input-group">
<input class="form-control" type="text" #colorInput [value]="this.currentColorRatio" (change)="this.updateColor(colorInput.value)">
<span class="input-group-text" #colorShow [style.background-color]="this.value" style="width: 40px">&nbsp;</span>
</div>
</div>
`
})
export class BlackBlueRangeComponent implements OnInit
{
@Output() change = new EventEmitter<string>();
currentColorRatio = 255;
@Input() value = `rgba(0,0,${this.currentColorRatio},1)`;
ngOnInit() {
let rgbArr = this.value.substring(4, this.value.length-1).replace(/ /g, '').split(',');
this.currentColorRatio = +rgbArr[2];
}
updateColor(value: string) {
this.currentColorRatio = +value;
this.value = `rgb(0,0,${this.currentColorRatio})`;
this.change.emit(this.value)
}
}
@Component({
selector: 'alpha-range',
template: `
<label for="alphaRange" class="form-label">Opacity</label>
<input type="range" class="form-range" #alphaRange [value]="this.currentAlphaRatio" max="100" (input)="this.updateAlpha(alphaRange.value)">
<input class="form-control" type="text" #alphaInput [value]="this.currentAlphaRatio" (change)="this.updateAlpha(alphaInput.value)">
`
})
export class AlphaRangeComponent implements OnInit
{
@Output() change = new EventEmitter<number>();
currentAlphaRatio = 100;
@Input() value = this.currentAlphaRatio / 100;
ngOnInit() {
this.currentAlphaRatio = this.value * 100;
}
updateAlpha(value: string) {
this.currentAlphaRatio = +value;
this.value = this.currentAlphaRatio / 100;
this.change.emit(this.value)
}
}

View File

@@ -11,6 +11,7 @@ export class BaseEntitiesComponent {
}) })
export class EntityListComponent extends BaseEntitiesComponent { export class EntityListComponent extends BaseEntitiesComponent {
columns = ['label', 'address', 'entity_data.type'] columns = ['label', 'address', 'entity_data.type']
filters = [];
} }
@Component({ @Component({

View File

@@ -2,8 +2,8 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { EntitiesRoutingModule } from './entities-routing.module'; import { EntitiesRoutingModule } from './entities-routing.module';
import { EntityCardComponent, EntityListComponent, EntityNewComponent} from "./entities.component";
import { BaseViewModule } from "../base-view/base-view.module"; import { BaseViewModule } from "../base-view/base-view.module";
import { EntityCardComponent, EntityListComponent, EntityNewComponent} from "./entities.component";

View File

@@ -10,7 +10,8 @@ export class BaseContractTemplateComponent {
templateUrl: '../base-view/templates/list.template.html' templateUrl: '../base-view/templates/list.template.html'
}) })
export class ContractTemplateListComponent extends BaseContractTemplateComponent { export class ContractTemplateListComponent extends BaseContractTemplateComponent {
columns = []; columns = ['name', 'title', 'parties.items.part'];
filters = [];
} }
@Component({ @Component({

View File

@@ -10,7 +10,8 @@ export class BaseProvisionTemplateComponent {
templateUrl: '../base-view/templates/list.template.html' templateUrl: '../base-view/templates/list.template.html'
}) })
export class ProvisionTemplateListComponent extends BaseProvisionTemplateComponent{ export class ProvisionTemplateListComponent extends BaseProvisionTemplateComponent{
columns = []; columns = ['name', 'title', 'body'];
filters = [];
} }
@Component({ @Component({

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -1,36 +1,32 @@
<div> <div>
<form cForm [formGroup]="form" (ngSubmit)="onSubmit(model)"> <form cForm [formGroup]="form" (ngSubmit)="onSubmit(model)">
<span class="col col-form-label" *ngIf="formLoading$ || modelLoading$ | async">Loading...</span> <span class="col col-form-label" i18n *ngIf="formLoading$ || modelLoading$ | async">Loading...</span>
<formly-form [form]="form" [fields]="fields" [model]="model"></formly-form> <formly-form [form]="form" [fields]="fields" [model]="model"></formly-form>
<div class="d-grid gap-2 d-md-flex"> <div class="d-grid gap-2 d-md-flex">
<button class="btn btn-success btn-lg" type="submit" <button class="btn btn-success btn-lg" type="submit"
[disabled]="!form.valid && (formLoading$ || modelLoading$ | async)"> [disabled]="!form.valid && (formLoading$ || modelLoading$ | async)">
{{ this.isCreateForm() ? "Create" : "Update" }} {{ submitText }}
</button> </button>
<button class="btn btn-primary btn-lg" type="button" *ngIf="!this.isCreateForm()" <button class="btn btn-primary btn-lg" i18n type="button" *ngIf="!this.isCreateForm()"
[disabled]="!form.valid && (formLoading$ || modelLoading$ | async)" [disabled]="!form.valid && (formLoading$ || modelLoading$ | async)"
(click)="open(duplicationModal)"> (click)="open(duplicationModal)">Duplicate</button>
Duplicate <button class="btn btn-danger btn-lg" i18n type="button" *ngIf="!this.isCreateForm()"
</button>
<button class="btn btn-danger btn-lg" type="button" *ngIf="!this.isCreateForm()"
[disabled]="formLoading$ || modelLoading$ | async" [disabled]="formLoading$ || modelLoading$ | async"
(click)="open(confirmDeleteModal)"> (click)="open(confirmDeleteModal)">Delete</button>
Delete
</button>
<ng-template #confirmDeleteModal let-modal> <ng-template #confirmDeleteModal let-modal>
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">Are you sure you want to delete this {{ this.schema }}?</h4> <h4 class="modal-title" i18n>Are you sure you want to delete this {{ this.schema }}?</h4>
<button type="button" class="btn-close" aria-label="Cancel" (click)="modal.dismiss('Cancel Deletion')"></button> <button type="button" class="btn-close" i18n-aria-label aria-label="Cancel" (click)="modal.dismiss('Cancel Deletion')"></button>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-light" aria-label="Cancel" (click)="modal.dismiss('Cancel Deletion')">Cancel</button> <button type="button" class="btn btn-light" i18n i18n-aria-label aria-label="Cancel" (click)="modal.dismiss('Cancel Deletion')">Cancel</button>
<button type="button" class="btn btn-danger" (click)="modal.close('Save click')">Delete</button> <button type="button" class="btn btn-danger" i18n (click)="modal.close('Save click')">Delete</button>
</div> </div>
</ng-template> </ng-template>
<ng-template #duplicationModal let-modal> <ng-template #duplicationModal let-modal>
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">Duplicate {{ this.schema }}</h4> <h4 class="modal-title" i18n>Duplicate {{ this.schema }}</h4>
<button type="button" class="btn-close" aria-label="Cancel" (click)="modal.dismiss('Cancel Deletion')"></button> <button type="button" class="btn-close" i18n-aria-label aria-label="Cancel" (click)="modal.dismiss('Cancel Deletion')"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<crud-card [resource]="this.resource" <crud-card [resource]="this.resource"

View File

@@ -37,7 +37,7 @@ export class CardComponent implements OnInit {
@Output() resourceUpdated: EventEmitter<string> = new EventEmitter(); @Output() resourceUpdated: EventEmitter<string> = new EventEmitter();
@Output() resourceDeleted: EventEmitter<string> = new EventEmitter(); @Output() resourceDeleted: EventEmitter<string> = new EventEmitter();
@Output() error: EventEmitter<string> = new EventEmitter(); @Output() error: EventEmitter<string> = new EventEmitter();
@Output() resourceReceived: EventEmitter<any> = new EventEmitter();
form = new FormGroup({}); form = new FormGroup({});
fields: FormlyFieldConfig[] = []; fields: FormlyFieldConfig[] = [];
@@ -55,6 +55,10 @@ export class CardComponent implements OnInit {
return this._modelLoading$.asObservable(); return this._modelLoading$.asObservable();
} }
get submitText() {
return this.isCreateForm() ? $localize`Create` : $localize`Update`
}
constructor(private crudService: CrudService, constructor(private crudService: CrudService,
private formlyJsonschema: CrudFormlyJsonschemaService, private formlyJsonschema: CrudFormlyJsonschemaService,
private router: Router, private router: Router,
@@ -74,6 +78,7 @@ export class CardComponent implements OnInit {
next :(model: any) => { next :(model: any) => {
this.model = model; this.model = model;
this._modelLoading$.next(false); this._modelLoading$.next(false);
this.resourceReceived.emit(model);
}, },
error: (err) => this.error.emit("Error loading the model:" + err) error: (err) => this.error.emit("Error loading the model:" + err)
}); });
@@ -109,7 +114,8 @@ export class CardComponent implements OnInit {
next: (model: any) => { next: (model: any) => {
this.model = model; this.model = model;
this._modelLoading$.next(false); this._modelLoading$.next(false);
this.resourceUpdated.emit(model._id) this.resourceUpdated.emit(model._id);
this.resourceReceived.emit(model);
}, },
error: (err) => this.error.emit("Error updating the entity:" + err) error: (err) => this.error.emit("Error updating the entity:" + err)
}); });

View File

@@ -43,13 +43,20 @@ export class CrudFormlyJsonschemaOptions implements FormlyJsonschemaOptions {
field.type = "datetime"; field.type = "datetime";
} else if (schema.format === 'date') { } else if (schema.format === 'date') {
field.type = "date"; field.type = "date";
} else if (schema.hasOwnProperty('enum') && schema.enum.length == 1 && schema.enum[0] == schema.default ) { } else if (
(schema.hasOwnProperty('hidden') && schema.hidden)
|| (schema.hasOwnProperty('enum') && schema.enum.length == 1 && schema.enum[0] == schema.default)
) {
field.type = "hidden"; field.type = "hidden";
} else if (schema.type == "array" && schema.format == "dictionary") { } else if (schema.type == "array" && schema.format == "dictionary") {
field.type = "dictionary"; field.type = "dictionary";
} else if (schema.type == "string" && schema.hasOwnProperty('props') } else if (schema.type == "string" && schema.hasOwnProperty('props')
&& schema.props.hasOwnProperty("richtext") && schema.props.richtext) { && schema.props.hasOwnProperty("richtext") && schema.props.richtext) {
field.type = "richtext"; field.type = "richtext";
} else if (schema.type == "string" && schema.format == "signature-link") {
field.type = "signature-link";
} else if (field.type == "enum" && field.props.multiple) {
field.type = 'multicheckbox';
} }
if (schema.hasOwnProperty('props')) { if (schema.hasOwnProperty('props')) {

View File

@@ -15,7 +15,7 @@ import { ArrayTypeComponent } from "./types/array.type";
import { ObjectTypeComponent } from "./types/object.type"; import { ObjectTypeComponent } from "./types/object.type";
import { DatetimeTypeComponent } from "./types/datetime.type"; import { DatetimeTypeComponent } from "./types/datetime.type";
import { DateTypeComponent } from "./types/date.type"; import { DateTypeComponent } from "./types/date.type";
import { ApiService, CrudService } from "./crud.service"; import { ApiService, CrudService, ImageUploaderCrudService } from "./crud.service";
import { CrudFormlyJsonschemaService } from "./crud-formly-jsonschema.service"; import { CrudFormlyJsonschemaService } from "./crud-formly-jsonschema.service";
import { NgbModule} from "@ng-bootstrap/ng-bootstrap"; import { NgbModule} from "@ng-bootstrap/ng-bootstrap";
import { allIcons, NgxBootstrapIconsModule } from "ngx-bootstrap-icons"; import { allIcons, NgxBootstrapIconsModule } from "ngx-bootstrap-icons";
@@ -26,12 +26,16 @@ import { HiddenTypeComponent } from "./types/hidden.type";
import { DictionaryTypeComponent } from "./types/dictionary.type"; import { DictionaryTypeComponent } from "./types/dictionary.type";
import { DictionaryService } from "./types/dictionary.service"; import { DictionaryService } from "./types/dictionary.service";
import { RichtextTypeComponent } from "./types/richtext.type"; import { RichtextTypeComponent } from "./types/richtext.type";
import { SignatureLinkTypeComponent } from "@common/crud/types/signature-link.type";
import { ClipboardModule } from '@angular/cdk/clipboard';
import {FilterListComponent} from "@common/crud/list/filter-list.component";
@NgModule({ @NgModule({
declarations: [ declarations: [
CardComponent, CardComponent,
ListComponent, ListComponent,
FilterListComponent,
ObjectTypeComponent, ObjectTypeComponent,
DatetimeTypeComponent, DatetimeTypeComponent,
DateTypeComponent, DateTypeComponent,
@@ -40,12 +44,14 @@ import { RichtextTypeComponent } from "./types/richtext.type";
ForeignkeyTypeComponent, ForeignkeyTypeComponent,
HiddenTypeComponent, HiddenTypeComponent,
DictionaryTypeComponent, DictionaryTypeComponent,
RichtextTypeComponent RichtextTypeComponent,
SignatureLinkTypeComponent
], ],
providers: [ providers: [
JsonschemasService, JsonschemasService,
ApiService, ApiService,
CrudService, CrudService,
ImageUploaderCrudService,
CrudFormlyJsonschemaService, CrudFormlyJsonschemaService,
DictionaryService DictionaryService
], ],
@@ -68,10 +74,12 @@ import { RichtextTypeComponent } from "./types/richtext.type";
{ name: 'hidden', component: HiddenTypeComponent }, { name: 'hidden', component: HiddenTypeComponent },
{ name: 'dictionary', component: DictionaryTypeComponent }, { name: 'dictionary', component: DictionaryTypeComponent },
{ name: 'richtext', component: RichtextTypeComponent }, { name: 'richtext', component: RichtextTypeComponent },
{ name: 'signature-link', component: SignatureLinkTypeComponent },
] ]
}), }),
FormlyBootstrapModule, FormlyBootstrapModule,
EditorModule EditorModule,
ClipboardModule
], ],
exports: [ exports: [
CardComponent, CardComponent,

View File

@@ -116,3 +116,28 @@ export class CrudService extends ApiService {
); );
} }
} }
@Injectable()
export class ImageUploaderCrudService extends CrudService {
public upload(resource: string, signature_id: string, image: string) {
const formData: FormData = new FormData();
formData.append("signature_file", dataURIToBlob(image), signature_id + ".png");
return this.http.post<{ menu: [{}] }>(
`${this.api_root}/${resource.toLowerCase()}/${signature_id}`,
formData
);
}
}
function dataURIToBlob(dataURI: string) {
const splitDataURI = dataURI.split(',')
const byteString = splitDataURI[0].indexOf('base64') >= 0 ? atob(splitDataURI[1]) : decodeURI(splitDataURI[1])
const mimeString = splitDataURI[0].split(':')[1].split(';')[0]
const ia = new Uint8Array(byteString.length)
for (let i = 0; i < byteString.length; i++)
ia[i] = byteString.charCodeAt(i)
return new Blob([ia], { type: mimeString })
}

View File

@@ -94,12 +94,34 @@ export class JsonschemasService {
} }
} }
} }
this.changePropertiesOrder(resource);
observer.next(resource); observer.next(resource);
}) })
}) })
} }
changePropertiesOrder(resource: any) {
let created_at;
let updated_at;
let new_properties: any = {};
for (let prop_name in resource.properties) {
if (prop_name == 'created_at') {
created_at = resource.properties[prop_name];
} else if (prop_name == 'updated_at') {
updated_at = resource.properties[prop_name];
} else {
new_properties[prop_name] = resource.properties[prop_name];
}
}
if (created_at) {
new_properties['created_at'] = created_at;
}
if (updated_at) {
new_properties['updated_at'] = updated_at;
}
resource.properties = new_properties
}
private is_object(prop: any) { private is_object(prop: any) {
return prop.hasOwnProperty('properties') return prop.hasOwnProperty('properties')
} }
@@ -159,7 +181,7 @@ export class JsonschemasService {
} else if (this.is_union(resource)) { } else if (this.is_union(resource)) {
for (const ref of resource.oneOf!) { for (const ref of resource.oneOf!) {
// @ts-ignore // @ts-ignore
if (this.has_property(ref, property_name)) { if (this.has_descendant(ref, property_name)) {
// @ts-ignore // @ts-ignore
return this.get_descendant(ref, property_name); return this.get_descendant(ref, property_name);
} }
@@ -184,6 +206,21 @@ export class JsonschemasService {
path.substring(pointFirstPosition + 1) path.substring(pointFirstPosition + 1)
); );
} }
get_property_by_path(resource: JSONSchema7, path: string): JSONSchema7 {
const pointFirstPosition = path.indexOf('.')
if (pointFirstPosition == -1) {
return this.get_descendant(resource, path);
}
return this.get_property_by_path(
this.get_descendant(
resource,
path.substring(0, pointFirstPosition)
),
path.substring(pointFirstPosition + 1)
);
}
} }
export interface Schema { export interface Schema {

View File

@@ -0,0 +1,88 @@
import {Component, EventEmitter, Input, OnInit, Output} from "@angular/core";
import {JSONSchema7} from "json-schema";
import {JsonschemasService} from "@common/crud/jsonschemas.service"
import {FormGroup} from "@angular/forms";
import {FormlyFieldConfig} from "@ngx-formly/core";
import {CrudFormlyJsonschemaService} from "@common/crud/crud-formly-jsonschema.service";
@Component({
selector: 'crud-list-filter-list',
template: `
<formly-form [form]="form" [fields]="fields" [model]="this.searchTerms" (modelChange)="onModelChange($event)"></formly-form>
`,
})
export class FilterListComponent implements OnInit {
@Input() filters: string[] = [];
@Input() schema = "";
@Output() filterChange: EventEmitter<{[key: string]: any}> = new EventEmitter();
form = new FormGroup({});
fields: FormlyFieldConfig[] = [];
searchTerms = {}
public fieldJson = {
components: {},
type: "object",
properties: {},
}
constructor(private jsonSchemasService: JsonschemasService,
private formlyJsonschema: CrudFormlyJsonschemaService,) { }
ngOnInit() {
this.jsonSchemasService.getUpdateResource(this.schema!).subscribe({
next: (schema: any) => this.getFilterDefinition(schema),
error: (err) => console.log(err) /*this.error.emit("Error loading the schema:" + err)*/
});
}
getFilterDefinition(schema: JSONSchema7) {
const properties: {[key: string]: JSONSchema7} = {}
for (let filter of this.filters) {
if (this.jsonSchemasService.path_exists(schema, filter)) {
let prop = this.jsonSchemasService.get_property_by_path(schema, filter)
if (prop.hasOwnProperty('allOf')) {
// @ts-ignore
prop = schema.components.schemas[prop.allOf![0]['$ref'].replace('#/components/schemas/', '')];
prop.type = "array";
prop.items = {"type": "string", "enum": prop.enum};
}
if (prop.hasOwnProperty('readOnly') && prop.readOnly) {
prop.readOnly = false
}
properties[filter] = prop;
}
}
// @ts-ignore
this.fieldJson.components = schema.components;
this.fieldJson.properties = properties;
// @ts-ignore
this.fields = [this.formlyJsonschema.toFieldConfig(this.fieldJson)]
}
onModelChange(event: {[key: string]: any}) {
for (let p_name in event) {
// @ts-ignore
let p = this.fieldJson.properties[p_name]
if (p.type == "array" && !Array.isArray(p.items)) {
let value = []
for (let key in event[p_name]) {
if (event[p_name][key]) {
value.push(key)
}
}
if (value.length == 0 || value.length == p.items.enum.length) {
delete event[p_name];
} else {
event[p_name] = value;
}
}
}
this.filterChange.next(event);
}
}

View File

@@ -1,10 +1,9 @@
<form> <form>
<button class="btn btn-success btn-lg float-end" type="button" i18n (click)="onCreate()">
<button class="btn btn-success btn-lg float-end" type="button" (click)="onCreate()">
Create {{ this.schema }} Create {{ this.schema }}
</button> </button>
<div class="mb-3 row"> <div class="mb-3 row">
<label for="table-complete-search" class="col-xs-3 col-sm-auto col-form-label">Full text search:</label> <label for="table-complete-search" i18n class="col-xs-3 col-sm-auto col-form-label">Full text search:</label>
<div class="col-xs-3 col-sm-auto"> <div class="col-xs-3 col-sm-auto">
<input <input
id="table-complete-search" id="table-complete-search"
@@ -14,19 +13,22 @@
[(ngModel)]="searchTerm" [(ngModel)]="searchTerm"
/> />
</div> </div>
<span class="col col-form-label" *ngIf="loading$ | async">Loading...</span> <div class="col-xs-3 col-sm-auto">
<crud-list-filter-list [filters]="this.filters" [schema]="this.schema!" (filterChange)="onFilterChange($event)"></crud-list-filter-list>
</div>
<span class="col col-form-label" i18n *ngIf="loading$ | async">Loading...</span>
</div> </div>
<div class="table-responsive-md"> <div class="table-responsive-md">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th *ngFor="let col of this.displayedColumns" scope="col" sortable="name" (sort)="onSort($event)">{{ col }}</th> <th *ngFor="let col of this.displayedColumns" scope="col" sortable="name" (sort)="onSort($event)">{{ col.title }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let row of listData$ | async" (click)="onSelect(row._id)"> <tr *ngFor="let row of listData$ | async" (click)="onSelect(row._id)">
<td *ngFor="let col of this.displayedColumns"> <td *ngFor="let col of this.displayedColumns">
<ngb-highlight [result]="getColumnValue(row,col)" [term]="searchTerm"></ngb-highlight> <ngb-highlight [result]="getColumnValue(row, col.path)" [term]="searchTerm"></ngb-highlight>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -38,11 +40,11 @@
</ngb-pagination> </ngb-pagination>
<select class="form-select" style="width: auto" name="pageSize" [(ngModel)]="pageSize"> <select class="form-select" style="width: auto" name="pageSize" [(ngModel)]="pageSize">
<option [ngValue]="10">10 items per page</option> <option i18n [ngValue]="10">10 items per page</option>
<option [ngValue]="15">15 items per page</option> <option i18n [ngValue]="15">15 items per page</option>
<option [ngValue]="25">25 items per page</option> <option i18n [ngValue]="25">25 items per page</option>
<option [ngValue]="50">50 items per page</option> <option i18n [ngValue]="50">50 items per page</option>
<option [ngValue]="100">100 items per page</option> <option i18n [ngValue]="100">100 items per page</option>
</select> </select>
</div> </div>
</form> </form>

View File

@@ -13,6 +13,12 @@ interface State {
searchTerm: string; searchTerm: string;
sortColumn: SortColumn; sortColumn: SortColumn;
sortDirection: SortDirection; sortDirection: SortDirection;
searchFilters: {[key: string]: any}
}
interface Column {
path: string,
title: string
} }
@Component({ @Component({
@@ -23,6 +29,7 @@ interface State {
export class ListComponent implements OnInit { export class ListComponent implements OnInit {
@Input() resource: string = ""; @Input() resource: string = "";
@Input() columns: string[] = []; @Input() columns: string[] = [];
@Input() filters: string[] = [];
@Input() schema: string | undefined; @Input() schema: string | undefined;
@Output() error: EventEmitter<string> = new EventEmitter(); @Output() error: EventEmitter<string> = new EventEmitter();
@@ -30,7 +37,7 @@ export class ListComponent implements OnInit {
@ViewChildren(NgbdSortableHeader) headers: QueryList<NgbdSortableHeader> = new QueryList<NgbdSortableHeader>(); @ViewChildren(NgbdSortableHeader) headers: QueryList<NgbdSortableHeader> = new QueryList<NgbdSortableHeader>();
public displayedColumns: string[] = []; public displayedColumns: Column[] = [];
private _loading$ = new BehaviorSubject<boolean>(true); private _loading$ = new BehaviorSubject<boolean>(true);
//private _search$ = new Subject<void>(); //private _search$ = new Subject<void>();
@@ -43,6 +50,7 @@ export class ListComponent implements OnInit {
searchTerm: '', searchTerm: '',
sortColumn: '_id', sortColumn: '_id',
sortDirection: 'asc', sortDirection: 'asc',
searchFilters: {}
}; };
constructor(private service: CrudService, constructor(private service: CrudService,
@@ -62,24 +70,36 @@ export class ListComponent implements OnInit {
getColumnDefinition(schema: JSONSchema7) { getColumnDefinition(schema: JSONSchema7) {
for (let column of this.columns) { for (let column of this.columns) {
if (this.jsonSchemasService.path_exists(schema, column)) { if (this.jsonSchemasService.path_exists(schema, column)) {
this.displayedColumns.push(column); this.displayedColumns.push({
path: column,
title: this.jsonSchemasService.get_property_by_path(schema, column).title!
});
} }
} }
if (this.displayedColumns.length == 0) { if (this.displayedColumns.length == 0) {
for (let param_name in schema.properties) { for (let param_name in schema.properties) {
if (param_name != "_id") { if (param_name != "_id") {
this.displayedColumns.push(param_name); this.displayedColumns.push({
path: param_name,
title: this.jsonSchemasService.get_property_by_path(schema, param_name).title!
});
} }
} }
} }
} }
getColumnValue(row: any, col: string) { getColumnValue(row: any, col: string): string {
let parent = row; let parent = row;
for (const key of col.split('.')) { for (const key of col.split('.')) {
if (key == 'items' && Array.isArray(parent)) {
let path_parts = col.split(/items\.(.*)/s);
let subkey = path_parts[1]
return parent.map((v: any) => this.getColumnValue(v, subkey)).join(', ');
} else {
parent = parent[key]; parent = parent[key];
} }
}
return parent; return parent;
} }
@@ -87,6 +107,13 @@ export class ListComponent implements OnInit {
this._loading$.next(true); this._loading$.next(true);
let sortBy = new SortBy(this.sortColumn, this.sortDirection) let sortBy = new SortBy(this.sortColumn, this.sortDirection)
let filters = this.searchTerm ? [new Filters('fulltext', 'eq', this.searchTerm)] : []; let filters = this.searchTerm ? [new Filters('fulltext', 'eq', this.searchTerm)] : [];
for (let f in this.searchFilters) {
if (Array.isArray(this.searchFilters[f])) {
filters.push(new Filters(f, 'in', this.searchFilters[f]))
} else {
filters.push(new Filters(f, 'eq', this.searchFilters[f]))
}
}
this.service.getList(this.resource, this.page, this.pageSize, [sortBy], filters).subscribe({ this.service.getList(this.resource, this.page, this.pageSize, [sortBy], filters).subscribe({
next: (data: any) => { next: (data: any) => {
@@ -101,6 +128,10 @@ export class ListComponent implements OnInit {
}); });
} }
onFilterChange(event: any) {
this.searchFilters = event;
}
onSort({ column, direction }: any) { onSort({ column, direction }: any) {
// resetting other headers // resetting other headers
this.headers.forEach((header) => { this.headers.forEach((header) => {
@@ -145,6 +176,9 @@ export class ListComponent implements OnInit {
get searchTerm() { get searchTerm() {
return this._state.searchTerm; return this._state.searchTerm;
} }
get searchFilters() {
return this._state.searchFilters;
}
set page(page: number) { set page(page: number) {
this._set({ page }); this._set({ page });
@@ -155,6 +189,9 @@ export class ListComponent implements OnInit {
set searchTerm(searchTerm: string) { set searchTerm(searchTerm: string) {
this._set({ searchTerm }); this._set({ searchTerm });
} }
set searchFilters(searchFilters: {[key: string]: any}) {
this._set({ searchFilters });
}
set sortColumn(sortColumn: SortColumn) { set sortColumn(sortColumn: SortColumn) {
this._set({ sortColumn }); this._set({ sortColumn });
} }

View File

@@ -17,7 +17,7 @@ import { FieldArrayType } from '@ngx-formly/core';
<div *ngIf="props['numbered']" class="float-start"> <div *ngIf="props['numbered']" class="float-start">
<strong>{{ i + 1 }}</strong> <strong>{{ i + 1 }}</strong>
</div> </div>
<div class="btn-group float-end"> <div *ngIf="! this.field.props.readonly" class="btn-group float-end">
<button class="btn btn-primary btn-sm" [attr.disabled]="i == 0 ? 'disabled' : null" type="button" (click)="move(i, i-1)"><i-bs name="caret-up-fill"></i-bs></button> <button class="btn btn-primary btn-sm" [attr.disabled]="i == 0 ? 'disabled' : null" type="button" (click)="move(i, i-1)"><i-bs name="caret-up-fill"></i-bs></button>
<button class="btn btn-primary btn-sm" [attr.disabled]="i == this.field.fieldGroup!.length-1 ? 'disabled' : null" type="button" (click)="move(i, i+1)"><i-bs name="caret-down-fill"></i-bs></button> <button class="btn btn-primary btn-sm" [attr.disabled]="i == this.field.fieldGroup!.length-1 ? 'disabled' : null" type="button" (click)="move(i, i+1)"><i-bs name="caret-down-fill"></i-bs></button>
<button class="btn btn-danger btn-sm" [attr.disabled]="field.props!['removable'] === false ? 'disabled' : null" type="button" (click)="remove(i)"><i-bs name="x-octagon-fill"></i-bs></button> <button class="btn btn-danger btn-sm" [attr.disabled]="field.props!['removable'] === false ? 'disabled' : null" type="button" (click)="remove(i)"><i-bs name="x-octagon-fill"></i-bs></button>
@@ -29,7 +29,7 @@ import { FieldArrayType } from '@ngx-formly/core';
</div> </div>
</div> </div>
</div> </div>
<button class="btn btn-success col-sm-12" type="button" (click)="add()"><i-bs name="plus-square-fill"></i-bs></button> <button *ngIf="! this.field.props.readonly" class="btn btn-success col-sm-12" type="button" (click)="add()"><i-bs name="plus-square-fill"></i-bs></button>
</div> </div>
`, `,
}) })

View File

@@ -1,7 +1,6 @@
import { Component, OnInit } from '@angular/core';
import { Component, ElementRef, OnInit, ViewChild} from '@angular/core';
import { formatDate } from "@angular/common"; import { formatDate } from "@angular/common";
import { NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap'; import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
import { FieldType, FieldTypeConfig } from '@ngx-formly/core'; import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
@@ -15,12 +14,12 @@ import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
<div class="alert alert-danger" role="alert" *ngIf="showError && formControl.errors"> <div class="alert alert-danger" role="alert" *ngIf="showError && formControl.errors">
<formly-validation-message [field]="field"></formly-validation-message> <formly-validation-message [field]="field"></formly-validation-message>
</div> </div>
<div class="input-group" *ngIf="! this.field.props.readonly">
<input type="hidden" <input type="hidden"
[formControl]="formControl" [formControl]="formControl"
[formlyAttributes]="field" [formlyAttributes]="field"
[class.is-invalid]="showError"
/> />
<div class="input-group" *ngIf="! this.field.props.readonly">
<button class="btn btn-outline-secondary" (click)="d.toggle()" type="button"><i-bs name="calendar-date-fill"></i-bs></button>
<input <input
class="form-control" class="form-control"
placeholder="yyyy-mm-dd" placeholder="yyyy-mm-dd"
@@ -29,33 +28,40 @@ import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
(ngModelChange)="changeDatetime($event)" (ngModelChange)="changeDatetime($event)"
ngbDatepicker ngbDatepicker
#d="ngbDatepicker" #d="ngbDatepicker"
[class.is-invalid]="showError"
/> />
<button class="btn btn-outline-secondary" (click)="d.toggle()" type="button"><i-bs name="calendar-date-fill"></i-bs></button>
</div> </div>
<div class="input-group" *ngIf="this.field.props.readonly"> <div class="input-group" *ngIf="this.field.props.readonly">
<input class="form-control" value="{{ this.datetime.toLocaleString() }}" disabled=""/> <input class="form-control" value="{{ this.datetime ? this.datetime.toLocaleString() : '' }}" disabled=""/>
</div> </div>
`, `,
}) })
export class DateTypeComponent extends FieldType<FieldTypeConfig> implements OnInit export class DateTypeComponent extends FieldType<FieldTypeConfig> implements OnInit
{ {
public date : NgbDateStruct; public date : NgbDateStruct | null = null;
public datetime : Date = new Date(); public datetime : Date | null = null;
constructor() { constructor() {
super(); super();
this.date = this.getDateStruct(new Date());
} }
ngOnInit() { ngOnInit() {
if (this.formControl.value === undefined) { if (this.formControl.value === undefined) {
this.changeDatetime({}); this.changeDatetime({});
} else {
this.datetime = new Date(this.formControl.value);
this.date = this.getDateStruct(this.datetime);
} }
this.formControl.valueChanges.subscribe(value => { this.formControl.valueChanges.subscribe(value => {
this.datetime = new Date(value) if (value) {
this.datetime = new Date(value);
this.date = this.getDateStruct(this.datetime); this.date = this.getDateStruct(this.datetime);
} else {
this.datetime = null;
this.date = null;
}
}) })
} }
@@ -68,6 +74,10 @@ export class DateTypeComponent extends FieldType<FieldTypeConfig> implements OnI
} }
changeDatetime(event: any) { changeDatetime(event: any) {
if (this.date) {
if (!this.datetime) {
this.datetime = new Date();
}
this.datetime.setFullYear(this.date.year) this.datetime.setFullYear(this.date.year)
this.datetime.setMonth(this.date.month - 1) this.datetime.setMonth(this.date.month - 1)
this.datetime.setDate(this.date.day) this.datetime.setDate(this.date.day)
@@ -75,5 +85,10 @@ export class DateTypeComponent extends FieldType<FieldTypeConfig> implements OnI
this.formControl.setValue( this.formControl.setValue(
formatDate(this.datetime, 'YYYY-MM-dd', 'EN_US', 'CET') formatDate(this.datetime, 'YYYY-MM-dd', 'EN_US', 'CET')
) )
} else {
this.datetime = null;
this.date = null;
this.formControl.setValue('')
}
} }
} }

View File

@@ -1,5 +1,4 @@
import { Component, OnInit } from '@angular/core';
import { Component, ElementRef, OnInit, ViewChild} from '@angular/core';
import { formatDate } from "@angular/common"; import { formatDate } from "@angular/common";
import { NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap'; import { NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap';
import { FieldType, FieldTypeConfig } from '@ngx-formly/core'; import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
@@ -12,12 +11,12 @@ import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
class="form-label">{{ props.label }} class="form-label">{{ props.label }}
<span *ngIf="props.required && props['hideRequiredMarker'] !== true" aria-hidden="true">*</span> <span *ngIf="props.required && props['hideRequiredMarker'] !== true" aria-hidden="true">*</span>
</label> </label>
<div class="input-group" *ngIf="! this.field.props.readonly">
<input type="hidden" <input type="hidden"
[formControl]="formControl" [formControl]="formControl"
[formlyAttributes]="field" [formlyAttributes]="field"
[class.is-invalid]="showError"
/> />
<div class="input-group" *ngIf="! this.field.props.readonly">
<button class="btn btn-outline-secondary bi bi-calendar3" (click)="d.toggle()" type="button"></button>
<input <input
class="form-control" class="form-control"
placeholder="yyyy-mm-dd" placeholder="yyyy-mm-dd"
@@ -26,8 +25,8 @@ import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
(ngModelChange)="changeDatetime($event)" (ngModelChange)="changeDatetime($event)"
ngbDatepicker ngbDatepicker
#d="ngbDatepicker" #d="ngbDatepicker"
[class.is-invalid]="showError"
/> />
<button class="btn btn-outline-secondary bi bi-calendar3" (click)="d.toggle()" type="button"></button>
<ngb-timepicker <ngb-timepicker
(ngModelChange)="changeDatetime($event)" (ngModelChange)="changeDatetime($event)"
[(ngModel)]="time" [(ngModel)]="time"
@@ -35,15 +34,15 @@ import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
</ngb-timepicker> </ngb-timepicker>
</div> </div>
<div class="input-group" *ngIf="this.field.props.readonly"> <div class="input-group" *ngIf="this.field.props.readonly">
<input class="form-control" value="{{ this.datetime.toLocaleString() }}" disabled=""/> <input class="form-control" value="{{ this.datetime ? this.datetime.toLocaleString() : '' }}" disabled=""/>
</div> </div>
`, `,
}) })
export class DatetimeTypeComponent extends FieldType<FieldTypeConfig> implements OnInit export class DatetimeTypeComponent extends FieldType<FieldTypeConfig> implements OnInit
{ {
public time : NgbTimeStruct; public time : NgbTimeStruct | null = null;
public date : NgbDateStruct; public date : NgbDateStruct | null = null;
public datetime : Date = new Date() public datetime : Date | null = null;
constructor() { constructor() {
@@ -55,12 +54,21 @@ export class DatetimeTypeComponent extends FieldType<FieldTypeConfig> implements
ngOnInit() { ngOnInit() {
if (this.formControl.value === undefined) { if (this.formControl.value === undefined) {
this.changeDatetime({}); this.changeDatetime({});
} else {
this.datetime = new Date(this.formControl.value);
this.date = this.getDateStruct(this.datetime);
} }
this.formControl.valueChanges.subscribe(value => { this.formControl.valueChanges.subscribe(value => {
this.datetime = new Date(value) if (value) {
this.datetime = new Date(value);
this.date = this.getDateStruct(this.datetime); this.date = this.getDateStruct(this.datetime);
this.time = this.getTimeStruct(this.datetime); this.time = this.getTimeStruct(this.datetime);
} else {
this.datetime = null;
this.date = null;
this.time = null;
}
}) })
} }
@@ -81,6 +89,10 @@ export class DatetimeTypeComponent extends FieldType<FieldTypeConfig> implements
} }
changeDatetime(event: any) { changeDatetime(event: any) {
if (this.date && this.time) {
if (!this.datetime) {
this.datetime = new Date();
}
this.datetime.setFullYear(this.date.year) this.datetime.setFullYear(this.date.year)
this.datetime.setMonth(this.date.month - 1) this.datetime.setMonth(this.date.month - 1)
this.datetime.setDate(this.date.day) this.datetime.setDate(this.date.day)
@@ -91,5 +103,11 @@ export class DatetimeTypeComponent extends FieldType<FieldTypeConfig> implements
this.formControl.setValue( this.formControl.setValue(
formatDate(this.datetime, 'YYYY-MM-ddTHH:mm:ss.SSS', 'EN_US', 'CET') formatDate(this.datetime, 'YYYY-MM-ddTHH:mm:ss.SSS', 'EN_US', 'CET')
) )
} else {
this.datetime = null;
this.date = null;
this.time = null;
this.formControl.setValue('')
}
} }
} }

View File

@@ -224,9 +224,11 @@ export class ForeignkeyTypeComponent extends FieldType<FieldTypeConfig> implemen
result = result.concat(); result = result.concat();
} else if (typeof(obj[k]) == "object") { } else if (typeof(obj[k]) == "object") {
if (obj[k]) {
result = result.concat(this.extractParameters(obj[k])); result = result.concat(this.extractParameters(obj[k]));
} }
} }
}
return result return result
} }

View File

@@ -0,0 +1,24 @@
import {Component, OnInit} from "@angular/core";
import { FieldType, FieldTypeConfig } from "@ngx-formly/core";
@Component({
selector: 'app-form-signature-link-type',
template: `
<label *ngIf="props.label && props['hideLabel'] !== true" [attr.for]="id"
class="form-label">{{ props.label }}
<span *ngIf="props.required && props['hideRequiredMarker'] !== true" aria-hidden="true">*</span>
</label>
<div class="input-group mb-12">
<input class="form-control" type="text" readonly disabled="" value="{{ this.signature_url }}"/>
<button type="button" class="btn btn-light" [cdkCopyToClipboard]="this.signature_url"><i-bs name="text-paragraph"/></button>
</div>
`,
})
export class SignatureLinkTypeComponent extends FieldType<FieldTypeConfig> implements OnInit{
base_path = "/contracts/signature/"
signature_url = ""
ngOnInit() {
this.signature_url = location.origin + this.base_path + this.formControl.value
}
}

View File

@@ -0,0 +1,358 @@
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en-US" trgLang="fr">
<file original="ng.template" id="ngi18n">
<unit id="ngb.alert.close">
<segment state="initial">
<source>Close</source>
<target>Fermer</target>
</segment>
</unit>
<unit id="ngb.timepicker.HH">
<segment state="initial">
<source>HH</source>
<target>HH</target>
</segment>
</unit>
<unit id="ngb.toast.close-aria">
<segment state="initial">
<source>Close</source>
<target>Fermer</target>
</segment>
</unit>
<unit id="ngb.pagination.first">
<segment state="initial">
<source>««</source>
<target>««</target>
</segment>
</unit>
<unit id="ngb.datepicker.select-month">
<segment state="initial">
<source>Select month</source>
<target>Selectionner un mois</target>
</segment>
</unit>
<unit id="ngb.datepicker.previous-month">
<segment state="initial">
<source>Previous month</source>
<target>Mois précédent</target>
</segment>
</unit>
<unit id="ngb.progressbar.value">
<segment state="initial">
<source>
<ph id="0" equiv="INTERPOLATION"/>
</source>
<target>
<ph id="0" equiv="INTERPOLATION"/>
</target>
</segment>
</unit>
<unit id="ngb.carousel.slide-number">
<segment state="initial">
<source> Slide <ph id="0" equiv="INTERPOLATION"/> of <ph id="1" equiv="INTERPOLATION_1"/> </source>
<target> Slide <ph id="0" equiv="INTERPOLATION"/> of <ph id="1" equiv="INTERPOLATION_1"/> </target>
</segment>
</unit>
<unit id="ngb.timepicker.hours">
<segment state="initial">
<source>Hours</source>
<target>Heures</target>
</segment>
</unit>
<unit id="ngb.pagination.previous">
<segment state="initial">
<source>«</source>
<target>«</target>
</segment>
</unit>
<unit id="ngb.carousel.previous">
<segment state="initial">
<source>Previous</source>
<target>Précédent</target>
</segment>
</unit>
<unit id="ngb.timepicker.MM">
<segment state="initial">
<source>MM</source>
<target>MM</target>
</segment>
</unit>
<unit id="ngb.pagination.next">
<segment state="initial">
<source>»</source>
<target>»</target>
</segment>
</unit>
<unit id="ngb.datepicker.select-year">
<segment state="initial">
<source>Select year</source>
<target>Sélectionner une année</target>
</segment>
</unit>
<unit id="ngb.datepicker.next-month">
<segment state="initial">
<source>Next month</source>
<target>Mois suivant</target>
</segment>
</unit>
<unit id="ngb.carousel.next">
<segment state="initial">
<source>Next</source>
<target>Suivant</target>
</segment>
</unit>
<unit id="ngb.timepicker.minutes">
<segment state="initial">
<source>Minutes</source>
<target>Minutes</target>
</segment>
</unit>
<unit id="ngb.pagination.last">
<segment state="initial">
<source>»»</source>
<target>»»</target>
</segment>
</unit>
<unit id="ngb.timepicker.increment-hours">
<segment state="initial">
<source>Increment hours</source>
<target>Incrémenter les heures</target>
</segment>
</unit>
<unit id="ngb.pagination.first-aria">
<segment state="initial">
<source>First</source>
<target>Premier</target>
</segment>
</unit>
<unit id="ngb.pagination.previous-aria">
<segment state="initial">
<source>Previous</source>
<target>Précédent</target>
</segment>
</unit>
<unit id="ngb.timepicker.decrement-hours">
<segment state="initial">
<source>Decrement hours</source>
<target>Décrémenter les heures</target>
</segment>
</unit>
<unit id="ngb.pagination.next-aria">
<segment state="initial">
<source>Next</source>
<target>Suivant</target>
</segment>
</unit>
<unit id="ngb.timepicker.increment-minutes">
<segment state="initial">
<source>Increment minutes</source>
<target>Incrémenter les minutes</target>
</segment>
</unit>
<unit id="ngb.pagination.last-aria">
<segment state="initial">
<source>Last</source>
<target>Dernier</target>
</segment>
</unit>
<unit id="ngb.timepicker.decrement-minutes">
<segment state="initial">
<source>Decrement minutes</source>
<target>Décrémenter les minutes</target>
</segment>
</unit>
<unit id="ngb.timepicker.SS">
<segment state="initial">
<source>SS</source>
<target>SS</target>
</segment>
</unit>
<unit id="ngb.timepicker.seconds">
<segment state="initial">
<source>Seconds</source>
<target>Secondes</target>
</segment>
</unit>
<unit id="ngb.timepicker.increment-seconds">
<segment state="initial">
<source>Increment seconds</source>
<target>Incrémenter les secondes</target>
</segment>
</unit>
<unit id="ngb.timepicker.decrement-seconds">
<segment state="initial">
<source>Decrement seconds</source>
<target>Décrémenter les secondes</target>
</segment>
</unit>
<unit id="ngb.timepicker.PM">
<segment state="initial">
<source>
<ph id="0" equiv="INTERPOLATION"/>
</source>
<target>
<ph id="0" equiv="INTERPOLATION"/>
</target>
</segment>
</unit>
<unit id="ngb.timepicker.AM">
<segment state="initial">
<source>
<ph id="0" equiv="INTERPOLATION"/>
</source>
<target>
<ph id="0" equiv="INTERPOLATION"/>
</target>
</segment>
</unit>
<unit id="6570363013146073520">
<segment state="initial">
<source>Dashboard</source>
<target>Tableau de bord</target>
</segment>
</unit>
<unit id="4800190016750145593">
<segment state="initial">
<source>Entities</source>
<target>Clients</target>
</segment>
</unit>
<unit id="3336127935693687033">
<segment state="initial">
<source>Provision&amp;nbsp;Templates</source>
<target>Templates:&amp;nbsp;Clauses</target>
</segment>
</unit>
<unit id="8996893897509995689">
<segment state="initial">
<source>Contracts&amp;nbsp;Templates</source>
<target>Templates:&amp;nbsp;Contrats</target>
</segment>
</unit>
<unit id="4480519554689195945">
<segment state="initial">
<source>Contracts&amp;nbsp;Drafts</source>
<target>Contrats: Brouillons</target>
</segment>
</unit>
<unit id="6325430461732938793">
<segment state="initial">
<source>Contracts</source>
<target>Contrats</target>
</segment>
</unit>
<unit id="3894950702316166331">
<segment state="initial">
<source>Loading...</source>
<target>Chargement...</target>
</segment>
</unit>
<unit id="6621329748219109148">
<segment state="initial">
<source>Duplicate</source>
<target>Dupliquer</target>
</segment>
</unit>
<unit id="6594772639285650443">
<segment state="initial">
<source>Are you sure you want to delete this <ph id="0" equiv="INTERPOLATION" disp="{{ this.schema }}"/>?</source>
<target>Êtes-vous sûr de vouloir supprimer ce<ph id="0" equiv="INTERPOLATION" disp="{{ this.schema }}"/>?</target>
</segment>
</unit>
<unit id="2159130950882492111">
<segment state="initial">
<source>Cancel</source>
<target>Annuler</target>
</segment>
</unit>
<unit id="7022070615528435141">
<segment state="initial">
<source>Delete</source>
<target>Supprimer</target>
</segment>
</unit>
<unit id="2502514662075565663">
<segment state="initial">
<source>Duplicate <ph id="0" equiv="INTERPOLATION" disp="{{ this.schema }}"/></source>
<target>Dupliquer <ph id="0" equiv="INTERPOLATION" disp="{{ this.schema }}"/></target>
</segment>
</unit>
<unit id="5974043874204012120">
<segment state="initial">
<source> Create <ph id="0" equiv="INTERPOLATION" disp="{{ this.schema }}"/> </source>
<target> Créer <ph id="0" equiv="INTERPOLATION" disp="{{ this.schema }}"/> </target>
</segment>
</unit>
<unit id="8449969376421674433">
<segment state="initial">
<source>Full text search:</source>
<target>Rechercher:</target>
</segment>
</unit>
<unit id="7735090777677032335">
<segment state="initial">
<source>10 items per page</source>
<target>10 par page</target>
</segment>
</unit>
<unit id="2940591680142606604">
<segment state="initial">
<source>15 items per page</source>
<target>15 par page</target>
</segment>
</unit>
<unit id="9121589109350446305">
<segment state="initial">
<source>25 items per page</source>
<target>25 items per page</target>
</segment>
</unit>
<unit id="4791714722376172740">
<segment state="initial">
<source>50 items per page</source>
<target>50 par page</target>
</segment>
</unit>
<unit id="6810640416188824611">
<segment state="initial">
<source>100 items per page</source>
<target>100 par page</target>
</segment>
</unit>
<unit id="2454050363478003966">
<segment state="initial">
<source>Login</source>
<target>Connexion</target>
</segment>
</unit>
<unit id="140822705245800362">
<segment state="initial">
<source>Username:</source>
<target>Utilisateur:</target>
</segment>
</unit>
<unit id="6865009229971482891">
<segment state="initial">
<source>Password:</source>
<target>Mot de passe:</target>
</segment>
</unit>
<unit id="3797778920049399855">
<segment state="initial">
<source>Logout</source>
<target>Déconnexion</target>
</segment>
</unit>
<unit id="5674286808255988565">
<segment state="initial">
<source>Create</source>
<target>Créer</target>
</segment>
</unit>
<unit id="4021752662928002901">
<segment state="initial">
<source>Update</source>
<target>Mettre à jour</target>
</segment>
</unit>
</file>
</xliff>

View File

@@ -0,0 +1,296 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en-US">
<file id="ngi18n" original="ng.template">
<unit id="ngb.alert.close">
<segment>
<source>Close</source>
</segment>
</unit>
<unit id="ngb.timepicker.HH">
<segment>
<source>HH</source>
</segment>
</unit>
<unit id="ngb.toast.close-aria">
<segment>
<source>Close</source>
</segment>
</unit>
<unit id="ngb.pagination.first">
<segment>
<source>««</source>
</segment>
</unit>
<unit id="ngb.datepicker.select-month">
<segment>
<source>Select month</source>
</segment>
</unit>
<unit id="ngb.datepicker.previous-month">
<segment>
<source>Previous month</source>
</segment>
</unit>
<unit id="ngb.progressbar.value">
<segment>
<source>
<ph id="0" equiv="INTERPOLATION"/>
</source>
</segment>
</unit>
<unit id="ngb.carousel.slide-number">
<segment>
<source> Slide <ph id="0" equiv="INTERPOLATION"/> of <ph id="1" equiv="INTERPOLATION_1"/> </source>
</segment>
</unit>
<unit id="ngb.timepicker.hours">
<segment>
<source>Hours</source>
</segment>
</unit>
<unit id="ngb.pagination.previous">
<segment>
<source>«</source>
</segment>
</unit>
<unit id="ngb.carousel.previous">
<segment>
<source>Previous</source>
</segment>
</unit>
<unit id="ngb.timepicker.MM">
<segment>
<source>MM</source>
</segment>
</unit>
<unit id="ngb.pagination.next">
<segment>
<source>»</source>
</segment>
</unit>
<unit id="ngb.datepicker.select-year">
<segment>
<source>Select year</source>
</segment>
</unit>
<unit id="ngb.datepicker.next-month">
<segment>
<source>Next month</source>
</segment>
</unit>
<unit id="ngb.carousel.next">
<segment>
<source>Next</source>
</segment>
</unit>
<unit id="ngb.timepicker.minutes">
<segment>
<source>Minutes</source>
</segment>
</unit>
<unit id="ngb.pagination.last">
<segment>
<source>»»</source>
</segment>
</unit>
<unit id="ngb.timepicker.increment-hours">
<segment>
<source>Increment hours</source>
</segment>
</unit>
<unit id="ngb.pagination.first-aria">
<segment>
<source>First</source>
</segment>
</unit>
<unit id="ngb.pagination.previous-aria">
<segment>
<source>Previous</source>
</segment>
</unit>
<unit id="ngb.timepicker.decrement-hours">
<segment>
<source>Decrement hours</source>
</segment>
</unit>
<unit id="ngb.pagination.next-aria">
<segment>
<source>Next</source>
</segment>
</unit>
<unit id="ngb.timepicker.increment-minutes">
<segment>
<source>Increment minutes</source>
</segment>
</unit>
<unit id="ngb.pagination.last-aria">
<segment>
<source>Last</source>
</segment>
</unit>
<unit id="ngb.timepicker.decrement-minutes">
<segment>
<source>Decrement minutes</source>
</segment>
</unit>
<unit id="ngb.timepicker.SS">
<segment>
<source>SS</source>
</segment>
</unit>
<unit id="ngb.timepicker.seconds">
<segment>
<source>Seconds</source>
</segment>
</unit>
<unit id="ngb.timepicker.increment-seconds">
<segment>
<source>Increment seconds</source>
</segment>
</unit>
<unit id="ngb.timepicker.decrement-seconds">
<segment>
<source>Decrement seconds</source>
</segment>
</unit>
<unit id="ngb.timepicker.PM">
<segment>
<source>
<ph id="0" equiv="INTERPOLATION"/>
</source>
</segment>
</unit>
<unit id="ngb.timepicker.AM">
<segment>
<source>
<ph id="0" equiv="INTERPOLATION"/>
</source>
</segment>
</unit>
<unit id="2454050363478003966">
<segment>
<source>Login</source>
</segment>
</unit>
<unit id="140822705245800362">
<segment>
<source>Username:</source>
</segment>
</unit>
<unit id="6865009229971482891">
<segment>
<source>Password:</source>
</segment>
</unit>
<unit id="2159130950882492111">
<segment>
<source>Cancel</source>
</segment>
</unit>
<unit id="3797778920049399855">
<segment>
<source>Logout</source>
</segment>
</unit>
<unit id="6570363013146073520">
<segment>
<source>Dashboard</source>
</segment>
</unit>
<unit id="4800190016750145593">
<segment>
<source>Entities</source>
</segment>
</unit>
<unit id="3336127935693687033">
<segment>
<source>Provision&amp;nbsp;Templates</source>
</segment>
</unit>
<unit id="8996893897509995689">
<segment>
<source>Contracts&amp;nbsp;Templates</source>
</segment>
</unit>
<unit id="4480519554689195945">
<segment>
<source>Contracts&amp;nbsp;Drafts</source>
</segment>
</unit>
<unit id="6325430461732938793">
<segment>
<source>Contracts</source>
</segment>
</unit>
<unit id="3894950702316166331">
<segment>
<source>Loading...</source>
</segment>
</unit>
<unit id="6621329748219109148">
<segment>
<source>Duplicate</source>
</segment>
</unit>
<unit id="7022070615528435141">
<segment>
<source>Delete</source>
</segment>
</unit>
<unit id="6594772639285650443">
<segment>
<source>Are you sure you want to delete this <ph id="0" equiv="INTERPOLATION" disp="{{ this.schema }}"/>?</source>
</segment>
</unit>
<unit id="2502514662075565663">
<segment>
<source>Duplicate <ph id="0" equiv="INTERPOLATION" disp="{{ this.schema }}"/></source>
</segment>
</unit>
<unit id="5674286808255988565">
<segment>
<source>Create</source>
</segment>
</unit>
<unit id="4021752662928002901">
<segment>
<source>Update</source>
</segment>
</unit>
<unit id="5974043874204012120">
<segment>
<source> Create <ph id="0" equiv="INTERPOLATION" disp="{{ this.schema }}"/> </source>
</segment>
</unit>
<unit id="8449969376421674433">
<segment>
<source>Full text search:</source>
</segment>
</unit>
<unit id="7735090777677032335">
<segment>
<source>10 items per page</source>
</segment>
</unit>
<unit id="2940591680142606604">
<segment>
<source>15 items per page</source>
</segment>
</unit>
<unit id="9121589109350446305">
<segment>
<source>25 items per page</source>
</segment>
</unit>
<unit id="4791714722376172740">
<segment>
<source>50 items per page</source>
</segment>
</unit>
<unit id="6810640416188824611">
<segment>
<source>100 items per page</source>
</segment>
</unit>
</file>
</xliff>