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__/
back/app/fixtures/
back/media/
front/app-back/
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 .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"], )
contract_router.include_router(print_router, prefix="/print", tags=["print"], )
from ..entity.models import Entity
from ..template.models import ProvisionTemplate
from ..user.manager import get_current_user, get_current_superuser
contract_router = get_crud_router(Contract, ContractCreate, ContractRead, ContractUpdate)
del(contract_router.routes[0])
del(contract_router.routes[2])
del(contract_router.routes[2])
contract_router.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 pydantic import BaseModel, Field
from beanie.operators import ElemMatch
from ..core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry
from ..entity.models import Entity
class ContractStatus(str, Enum):
new = 'new'
published = 'published'
signed = 'signed'
in_effect = 'in_effect'
printed = 'printed'
executed = 'executed'
class ContractDraftStatus(str, Enum):
draft = 'draft'
created = 'created'
in_progress = 'in_progress'
ready = 'ready'
published = 'published'
class Party(BaseModel):
class DraftParty(BaseModel):
entity_id: str = Field(
foreignKey={
"reference": {
"resource": "entity",
"schema": "Entity",
}
}
},
default="",
title="Partie"
)
part: str
part: str = Field(title="Rôle")
representative_id: str = Field(
foreignKey={
"reference": {
@@ -36,14 +41,30 @@ class Party(BaseModel):
"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):
type: Literal['genuine'] = 'genuine'
title: str = RichtextSingleline(props={"parametrized": True})
body: str = RichtextMultiline(props={"parametrized": True})
title: str = RichtextSingleline(props={"parametrized": True}, default="", title="Titre")
body: str = RichtextMultiline(props={"parametrized": True}, default="", title="Corps")
class Config:
title = 'Clause personalisée'
class ContractProvisionTemplateReference(BaseModel):
@@ -56,23 +77,140 @@ class ContractProvisionTemplateReference(BaseModel):
"displayedFields": ['title', 'body']
},
},
props={"parametrized": True}
props={"parametrized": True},
default="",
title="Template de clause"
)
class Config:
title = 'Template de clause'
class DraftProvision(BaseModel):
provision: ContractProvisionTemplateReference | ProvisionGenuine = Field(..., discriminator='type')
class Config:
title = 'Provision'
class Provision(BaseModel):
title: str = RichtextSingleline(title="Titre")
body: str = RichtextMultiline(title="Corps")
class ContractDraft(CrudDocument):
name: str
title: str
parties: List[Party]
provisions: List[DraftProvision]
"""
Brouillon de contrat à remplir
"""
name: str = Field(title="Nom")
title: str = Field(title="Titre")
parties: List[DraftParty] = Field(title="Parties")
provisions: List[DraftProvision] = Field(
props={"items-per-row": "1", "numbered": True},
title='Clauses'
)
variables: List[DictionaryEntry] = Field(
default=[],
format="dictionary",
title='Variables'
)
status: ContractDraftStatus = Field(default=ContractDraftStatus.draft)
location: str = ""
date: datetime.date = datetime.date(1970, 1, 1)
status: ContractDraftStatus = Field(default=ContractDraftStatus.in_progress, title="Statut")
todo: List[str] = Field(default=[], title="Reste à faire")
class Settings(CrudDocument.Settings):
fulltext_search = ['name', 'title']
bson_encoders = {
datetime.date: lambda dt: dt if hasattr(dt, 'hour')
else datetime.datetime(year=dt.year, month=dt.month, day=dt.day,
hour=0, minute=0, second=0)
}
class Config:
title = 'Brouillon de contrat'
async def check_is_ready(self):
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.templating import Jinja2Templates
@@ -9,7 +13,7 @@ from pathlib import Path
from app.entity.models import Entity
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):
@@ -24,19 +28,28 @@ async def build_model(model):
parties.append(party)
model.parties = parties
provisions = []
for p in model.provisions:
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:
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.location = "Toulouse"
model.date = "01/01/1970"
model = model.dict()
model['location'] = "Los Santos, SA"
model['date'] = datetime.date(1970, 1, 1)
model['lawyer'] = {'entity_data': {
"firstname": "prénom avocat",
"lastname": "nom avocat",
}}
return model
@@ -48,49 +61,81 @@ print_router = APIRouter()
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")
return template.render({
"draft": draft,
"lawyer": lawyer,
"static_host": host
"contract": contract,
"root_url": root_url
})
async def render_css(host, draft):
async def render_css(root_url, contract):
template = templates.get_template("styles.css")
return template.render({
"draft": draft,
"static_host": host
"contract": contract,
"root_url": root_url
})
@print_router.get("/", response_class=HTMLResponse)
async def create() -> str:
draft = await build_model(await ContractDraft.get("63e92534aafed8b509f229c4"))
lawyer = {
"firstname": "Nathaniel",
"lastname": "Toshi",
}
@print_router.get("/preview/draft/{draft_id}", response_class=HTMLResponse)
async def preview_draft(draft_id: str, request: Request) -> str:
draft = await build_model(await ContractDraft.get(draft_id))
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)
async def create_pdf() -> str:
draft = await build_model(await ContractDraft.get("63e92534aafed8b509f229c4"))
lawyer = {
"firstname": "Nathaniel",
"lastname": "Toshi",
}
@print_router.get("/preview/signature/{signature_id}", response_class=HTMLResponse)
async def preview_contract_by_signature(signature_id: str, request: Request) -> str:
contract = await Contract.find_by_signature_id(signature_id)
for p in contract.parties:
if p.signature_affixed:
p.signature_png = retrieve_signature_png(f'media/signatures/{p.signature_uuid}.png')
font_config = FontConfiguration()
html = HTML(string=await render_print('nginx', draft, lawyer))
css = CSS(string=await render_css('nginx', draft), font_config=font_config)
return await render_print(f'{request.url.scheme}://{request.url.hostname}', contract)
html.write_pdf('out.pdf', stylesheets=[css], font_config=font_config)
@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()
html = HTML(string=await render_print('http://nginx', contract))
css = CSS(string=await render_css('http://nginx', contract), font_config=font_config)
html.write_pdf(contract_path, stylesheets=[css], font_config=font_config)
update_query = {"$set": {
'status': 'printed'
}}
await contract.update(update_query)
return FileResponse(
"out.pdf",
contract_path,
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 id="front-page-header">
<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>
</tr></table>
<h1>{{ draft.title|upper }}</h1>
<h1>{{ contract.title|upper }}</h1>
</div>
<div class="intro">
<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>
{% for party in draft.parties %}
{% for party in contract.parties %}
<div class="party">
{% if not loop.first %}
<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 %}
{% 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.entity_data.day_of_birth %} n&eacute; le {{ party.entity.entity_data.day_of_birth.strftime('%d/%m/%Y') }} {% if party.entity.entity_data.place_of_birth %} &agrave; {{ party.entity.entity_data.place_of_birth }}{% endif %},{% endif %}
{% if party.entity.address %} r&eacute;sidant &agrave; {{ party.entity.address }}, {% endif %}
{% elif party.entity.entity_data.type == "institution" %}
@@ -40,14 +40,14 @@
</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>Sous la supervision l&eacute;gale de Ma&icirc;tre <strong>{{ contract.lawyer.entity_data.firstname }} {{ contract.lawyer.entity_data.lastname }}</strong></p>
<p>Il a &eacute;t&eacute; convenu l&apos;ex&eacute;cution des prestations ci-dessous, conform&eacute;ment aux conditions g&eacute;n&eacute;rales et particuli&egrave;res ci-apr&egrave;s:</p>
</div>
</div>
<div class="content">
<h2>Conditions g&eacute;n&eacute;rales & particuli&egrave;res</h2>
{% for provision in draft.provisions %}
{% for provision in contract.provisions %}
<div class="provision">
<h3>Article {{loop.index}} - {{ provision.title|safe }}</h3>
<p>{{ provision.body|safe }}</p>
@@ -56,11 +56,18 @@
<div class="footer">
<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>
<table class="signatures">
<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>
</table>
</div>

View File

@@ -1,24 +1,24 @@
@font-face {
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-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-face {
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-face {
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-style: italic;
}
@@ -28,10 +28,10 @@
margin: 2cm 2cm 2cm 2cm;
counter-increment: page;
@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;
}
background: url('http://{{ static_host }}/assets/watermark.png') no-repeat;
background: url('{{ root_url }}/assets/watermark.png') no-repeat;
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 .models import ContractDraft
from ..user.manager import get_current_user
from .models import ContractDraft, ContractDraftStatus
from .schemas import 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 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 ..core.schemas import Writer
@@ -15,13 +15,17 @@ class ContractDraftRead(ContractDraft):
class ContractDraftCreate(Writer):
name: str
title: str
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(
default=[],
format="dictionary",
title='Variables'
)
async def validate_foreign_key(self):
@@ -34,3 +38,38 @@ class ContractDraftCreate(Writer):
class ContractDraftUpdate(ContractDraftCreate):
pass
class ForeignEntityRead(BaseModel):
label: str
class Config:
title = "Avocat"
class PartyRead(BaseModel):
signature_affixed: bool = Field(title='Signature apposée?')
signature_uuid: str = Field(format="signature-link", title="Lien vers signature")
part: str = Field(title='Rôle')
entity: ForeignEntityRead = Field(title='Client')
class Config:
title = "Partie"
class ContractRead(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):
_id: str
created_at: datetime = Field(default=datetime.utcnow(), nullable=False)
updated_at: datetime = Field(default_factory=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, title="Modifié le")
@validator("label", always=True, check_fields=False)
def generate_label(cls, v, values, **kwargs):

View File

@@ -1,10 +1,13 @@
from beanie import PydanticObjectId
from beanie.odm.operators.find.comparison import In
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.ext.motor import paginate
from ..user.manager import get_current_user, get_current_superuser
def parse_sort(sort_by):
if not sort_by:
@@ -41,11 +44,13 @@ def parse_query(query: str, model):
elif operator == 'eq':
operand = Eq(column, value)
elif operator == 'in':
operand = In(column, value.split(','))
and_array.append(operand)
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:
return {}
@@ -55,18 +60,19 @@ def get_crud_router(model, model_create, model_read, model_update):
router = APIRouter()
@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()
o = await model(**item.dict()).create()
return {"message": "{} added successfully".format(model.__name__), "id": o.id}
@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)
return model_read(**item.dict())
@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)
query = parse_query(query, model_read)
@@ -75,7 +81,7 @@ def get_crud_router(model, model_create, model_read, model_update):
return await items
@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}
update_query = {"$set": {
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())
@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)
if not item:

View File

@@ -6,7 +6,7 @@ from .user import User, AccessToken
from .entity.models import Entity
from .template.models import ContractTemplate, ProvisionTemplate
from .order.models import Order
from .contract.models import ContractDraft
from .contract.models import ContractDraft, Contract
DATABASE_URL = "mongodb://root:example@mongo:27017/"
@@ -17,5 +17,6 @@ async def init_db():
)
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)

View File

@@ -15,13 +15,16 @@ class EntityType(BaseModel):
class Individual(EntityType):
type: Literal['individual'] = 'individual'
firstname: Indexed(str)
middlename: Indexed(str) = ""
lastname: Indexed(str)
surnames: List[Indexed(str)] = []
day_of_birth: date
place_of_birth: str = ""
firstname: Indexed(str) = Field(title='Prénom')
middlename: Indexed(str) = Field(default="", title='Autres prénoms')
lastname: Indexed(str) = Field(title='Nom de famille')
surnames: List[Indexed(str)] = Field(
default=[],
props={"items-per-row": "4", "numbered": True},
title="Surnoms"
)
day_of_birth: date = Field(default=None, title='Date de naissance')
place_of_birth: str = Field(default="", title='Lieu de naissance')
@property
def label(self) -> str:
@@ -30,36 +33,50 @@ class Individual(EntityType):
return '{} {}'.format(self.firstname, self.lastname)
class Config:
title = 'Particulier'
class Employee(BaseModel):
role: Indexed(str)
role: Indexed(str) = Field(title='Poste')
entity_id: str = Field(foreignKey={
"reference": {
"resource": "entity",
"schema": "Entity",
"condition": "entity_data.type=individual"
}
})
},
title='Employé'
)
class Config:
title = 'Fiche Employé'
class Corporation(EntityType):
type: Literal['corporation'] = 'corporation'
title: Indexed(str)
activity: Indexed(str)
employees: List[Employee] = Field(default=[])
title: Indexed(str) = Field(title='Dénomination sociale')
activity: Indexed(str) = Field(title='Activité')
employees: List[Employee] = Field(default=[], title='Employés')
class Config:
title = 'Entreprise'
class Institution(EntityType):
class Institution(Corporation):
type: Literal['institution'] = 'institution'
title: Indexed(str)
activity: Indexed(str)
employees: List[Employee] = Field(default=[])
class Config:
title = 'Institution'
class Entity(CrudDocument):
"""
Fiche d'un client
"""
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type')
label: str = None
address: Optional[str] = ""
address: str = Field(default="", title='Adresse')
@validator("label", always=True)
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,
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):
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):
entity_data: Individual | Corporation | Institution = Field(..., discriminator='type')
address: Optional[str] = ""
class EntityUpdate(EntityCreate):
pass

View File

@@ -4,8 +4,8 @@ from .contract import contract_router
from .db import init_db
from .user import user_router, get_auth_router
from .entity import entity_router
from .order import order_router
from .template import template_router
# from .order import order_router
app = FastAPI(root_path="/api/v1")
@@ -15,17 +15,12 @@ async def on_startup():
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(user_router, prefix="/users", tags=["users"], )
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(contract_router, prefix="/contract", tags=["contract"], )
# app.include_router(order_router, prefix="/order", tags=["order"], )
if __name__ == '__main__':
import uvicorn

View File

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

View File

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

View File

@@ -88,10 +88,10 @@ async def get_user_manager(user_db=Depends(get_user_db)):
def get_database_strategy(
access_token_db: AccessTokenDatabase[AccessToken] = Depends(get_access_token_db),
) -> 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(
@@ -107,6 +107,7 @@ fastapi_users = FastAPIUsers[User, uuid.UUID](
)
get_current_user = fastapi_users.current_user(active=True)
get_current_superuser = fastapi_users.current_user(active=True, superuser=True)
def get_auth_router():

View File

@@ -1,6 +1,6 @@
from typing import Optional, TypeVar
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import Field
from beanie import PydanticObjectId
from fastapi_users.db import BeanieBaseUser, BeanieUserDatabase
@@ -15,6 +15,7 @@ class AccessToken(BeanieBaseAccessToken[PydanticObjectId]):
class User(BeanieBaseUser[PydanticObjectId]):
login: str
entity_id: str
created_at: datetime = Field(default=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 .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.post("/", response_description="User added to the database")
async def create(user: UserCreate, user_manager=Depends(get_user_manager)) -> dict:
await user_manager.create(user, safe=True)
async def create(user_form: UserCreate, user_manager=Depends(get_user_manager), user=Depends(get_current_superuser)) -> dict:
await user_manager.create(user_form, safe=True)
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")
async def read_id(id: PydanticObjectId) -> UserRead:
async def read_id(id: PydanticObjectId, user=Depends(get_current_superuser)) -> UserRead:
user = await User.get(id)
return UserRead(**user.dict())
@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()
return users
@router.put("/{id}", response_description="User record updated")
async def update(id: PydanticObjectId, req: UserUpdate) -> UserRead:
req = {k: v for k, v in req.dict().items() if v is not None}
async def update(id: PydanticObjectId, user_form: UserUpdate, user=Depends(get_current_superuser)) -> UserRead:
user_form = {k: v for k, v in user_form.dict().items() if v is not None}
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)
@@ -56,7 +56,7 @@ async def update(id: PydanticObjectId, req: UserUpdate) -> UserRead:
@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)
if not record:

View File

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

View File

View File

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<main style="margin-top: 0px;">
<div class="container-fluid">
<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>
</div>
<div class="col py-3">
@@ -13,3 +13,4 @@
</div>
</div>
</main>

View File

@@ -1,10 +1,12 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
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 { NgbModule } from '@ng-bootstrap/ng-bootstrap';
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 { AppComponent } from './app.component';
@@ -9,20 +11,31 @@ import { AppComponent } from './app.component';
import { SidenavComponent } from "./layout/sidenav/sidenav.component";
import { FlashmessagesComponent } from "./layout/flashmessages/flashmessages.component";
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({
declarations: [
AppComponent,
SidenavComponent,
FlashmessagesComponent
FlashmessagesComponent,
LoginComponent,
LogoutComponent
],
imports: [
BrowserModule,
AppRoutingModule,
NgbModule,
NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule,
HttpClientModule,
],
providers: [
FlashmessagesService,
AuthService,
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
],
providers: [FlashmessagesService],
bootstrap: [AppComponent]
})
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">
<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>
<ul class="nav nav-pills flex-column align-items-sm-start mb-sm-auto mb-0 w-100" id="menu">
<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)">
<i-bs [name]="item.icon"></i-bs><span class="ms-1 d-none d-sm-inline" [innerHTML]="item.title"></span>
</a>
</li>
<li><login></login></li>
<ng-container *ngIf="isAuthenticated">
<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)">
<i-bs [name]="item.icon"></i-bs><span class="ms-1 d-none d-sm-inline" [innerHTML]="item.title"></span>
</a>
</li>
</ng-container>
<li><logout></logout></li>
</ul>
</div>

View File

@@ -1,6 +1,7 @@
import { Component } from "@angular/core";
import { Router } from '@angular/router';
import { IconNamesEnum } from "ngx-bootstrap-icons";
import { AuthService } from "../auth/auth.service";
@Component({
selector: "sidenav",
@@ -10,33 +11,48 @@ import { IconNamesEnum } from "ngx-bootstrap-icons";
export class SidenavComponent {
Menu = [
{
title: "Dashboard",
title: $localize`Dashboard`,
link: "/dashboard",
icon: IconNamesEnum.HouseFill
},
{
title: "Entities",
title: $localize`Entities`,
link: "/entities",
icon: IconNamesEnum.PeopleFill
},
{
title: "Provision&nbsp;Templates",
title: $localize`Provision&nbsp;Templates`,
link: "/templates/provisions",
icon: IconNamesEnum.BlockquoteLeft
},
{
title: "Contracts&nbsp;Templates",
title: $localize`Contracts&nbsp;Templates`,
link: "/templates/contracts",
icon: IconNamesEnum.FileCodeFill
},
{
title: "Contracts&nbsp;Drafts",
link: "/contract-drafts",
title: $localize`Contracts&nbsp;Drafts`,
link: "/contracts/drafts",
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) {
return this.router.url.indexOf(menu_item.link) > -1;

View File

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

View File

@@ -1,26 +1,32 @@
import {Component, Input} from "@angular/core";
import {Component, EventEmitter, Input, Output} from "@angular/core";
import {ActivatedRoute, ParamMap} from "@angular/router";
import { FlashmessagesService } from "../../../layout/flashmessages/flashmessages.service";
@Component({
templateUrl: 'card.component.html',
selector: 'base-card',
templateUrl: 'card.component.html',
selector: 'base-card',
})
export class BaseCrudCardComponent {
@Input() resource: string | undefined;
@Input() resource_id: string | null = null;
@Input() schema: string | undefined;
@Input() resource: string | undefined;
@Input() resource_id: string | null = null;
@Input() schema: string | undefined;
constructor(
private route: ActivatedRoute,
public flashService: FlashmessagesService
) {}
@Output() resourceReceived: EventEmitter<any> = new EventEmitter();
ngOnInit(): void {
if (this.resource_id === null) {
this.route.paramMap.subscribe((params: ParamMap) => {
this.resource_id = params.get('id')
})
constructor(
private route: ActivatedRoute,
public flashService: FlashmessagesService
) {}
ngOnInit(): void {
if (this.resource_id === null) {
this.route.paramMap.subscribe((params: ParamMap) => {
this.resource_id = params.get('id')
})
}
}
onResourceReceived(model: any) {
this.resourceReceived.emit(model);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,44 +1,82 @@
import { NgModule } from '@angular/core';
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 = [
{
path: '',
data: {
title: 'Entities',
},
children: [
{ path: '', redirectTo: 'list', pathMatch: 'full' },
{
path: 'list',
component: DraftListComponent,
{
path: '',
data: {
title: 'List',
title: 'Contracts',
},
},
{
path: 'new',
component: DraftNewComponent,
data: {
title: 'New',
},
},
{
path: ':id',
component: DraftCardComponent,
data: {
title: 'Card',
},
},
],
},
children: [
{ path: '', redirectTo: 'list', pathMatch: 'full' },
{
path: 'list',
component: ContractsListComponent,
data: {
title: 'List',
},
},
{
path: 'new',
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: {
title: 'New',
},
},
{
path: ':id',
component: DraftsCardComponent,
data: {
title: 'Card',
},
},
],
},
{
path: ':id',
component: ContractsCardComponent,
data: {
title: 'Card',
},
}
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ContractsRoutingModule {}

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 { 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 { FormlyBootstrapModule } from "@ngx-formly/bootstrap";
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({
@@ -15,20 +21,31 @@ import { CrudService } from "@common/crud/crud.service";
CommonModule,
BaseViewModule,
ContractsRoutingModule,
NgbAccordionModule,
NgbCollapseModule,
NgxBootstrapIconsModule.pick(allIcons),
FormlyModule.forRoot({
types: [
{ name: 'foreign-key', component: ForeignkeyTypeComponent }
]
}),
FormlyBootstrapModule,
ClipboardModule,
],
declarations: [
DraftListComponent,
DraftNewComponent,
DraftCardComponent,
DraftNewFormComponent
DraftsListComponent,
DraftsNewComponent,
DraftsCardComponent,
DraftsNewFormComponent,
ContractsListComponent,
ContractsNewComponent,
ContractsCardComponent,
ContractsSignatureComponent,
SignatureDrawerComponent,
BlackBlueRangeComponent,
AlphaRangeComponent
],
providers: [CrudService]
providers: [CrudService, ImageUploaderCrudService]
})
export class ContractsModule {
}

View File

@@ -3,9 +3,12 @@ import { FormlyFieldConfig } from "@ngx-formly/core";
import { FormGroup} from "@angular/forms";
import { CrudFormlyJsonschemaService } from "@common/crud/crud-formly-jsonschema.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 schema: string = "ContractDraft";
}
@@ -13,15 +16,16 @@ export class BaseEntitiesComponent {
@Component({
templateUrl: '../base-view/templates/list.template.html'
})
export class DraftListComponent extends BaseEntitiesComponent {
columns = [];
export class DraftsListComponent extends BaseDraftsComponent {
columns = ['status', 'name', 'title', 'parties.items.part'];
filters = ['status'];
}
@Component({
selector: 'draft-new-form',
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: {} = {};
}
@@ -32,7 +36,7 @@ export class DraftNewFormComponent extends BaseEntitiesComponent {
<draft-new-form [value]="this.templateModel"></draft-new-form>
`
})
export class DraftNewComponent extends BaseEntitiesComponent implements OnInit {
export class DraftsNewComponent extends BaseDraftsComponent implements OnInit {
templateModel: {} = {};
temaplateFormfields: FormlyFieldConfig[] = [];
temaplateForm: FormGroup = new FormGroup({});
@@ -86,7 +90,81 @@ export class DraftNewComponent extends BaseEntitiesComponent implements OnInit {
}
@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 {
columns = ['label', 'address', 'entity_data.type']
filters = [];
}
@Component({

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

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

View File

@@ -9,15 +9,15 @@ import { CrudFormlyJsonschemaService } from "../crud-formly-jsonschema.service";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'crud-card',
templateUrl: './card.component.html',
styleUrls: ['./card.component.css']
selector: 'crud-card',
templateUrl: './card.component.html',
styleUrls: ['./card.component.css']
})
export class CardComponent implements OnInit {
@Input() resource: string | undefined;
@Input() resource_id: string | null = null;
@Input() schema: string | undefined;
@Input() is_modal: Boolean = false;
@Input() resource: string | undefined;
@Input() resource_id: string | null = null;
@Input() schema: string | undefined;
@Input() is_modal: Boolean = false;
private _model: {} = {};
@@ -37,12 +37,12 @@ export class CardComponent implements OnInit {
@Output() resourceUpdated: EventEmitter<string> = new EventEmitter();
@Output() resourceDeleted: EventEmitter<string> = new EventEmitter();
@Output() error: EventEmitter<string> = new EventEmitter();
@Output() resourceReceived: EventEmitter<any> = new EventEmitter();
form = new FormGroup({});
fields: FormlyFieldConfig[] = [];
form = new FormGroup({});
fields: FormlyFieldConfig[] = [];
schemas = JSON.parse(`{}`);
schemas = JSON.parse(`{}`);
private _formLoading$ = new BehaviorSubject<boolean>(true);
private _modelLoading$ = new BehaviorSubject<boolean>(true);
@@ -55,12 +55,16 @@ export class CardComponent implements OnInit {
return this._modelLoading$.asObservable();
}
constructor(private crudService: CrudService,
private formlyJsonschema: CrudFormlyJsonschemaService,
private router: Router,
private route: ActivatedRoute,
private modalService: NgbModal,
) { }
get submitText() {
return this.isCreateForm() ? $localize`Create` : $localize`Update`
}
constructor(private crudService: CrudService,
private formlyJsonschema: CrudFormlyJsonschemaService,
private router: Router,
private route: ActivatedRoute,
private modalService: NgbModal,
) { }
ngOnInit(): void {
this._formLoading$.next(true);
@@ -74,6 +78,7 @@ export class CardComponent implements OnInit {
next :(model: any) => {
this.model = model;
this._modelLoading$.next(false);
this.resourceReceived.emit(model);
},
error: (err) => this.error.emit("Error loading the model:" + err)
});
@@ -92,27 +97,28 @@ export class CardComponent implements OnInit {
onSubmit(model: any) {
this._modelLoading$.next(true);
if (this.isCreateForm()) {
this.crudService.create(this.resource!, model).subscribe({
next: (response: any) => {
this._modelLoading$.next(false);
if (! this.is_modal) {
this.router.navigate([`../${response.id}`], {relativeTo: this.route});
} else {
this.resourceCreated.emit(response.id)
}
},
error: (err) => this.error.emit("Error creating the entity:" + err)
});
this.crudService.create(this.resource!, model).subscribe({
next: (response: any) => {
this._modelLoading$.next(false);
if (! this.is_modal) {
this.router.navigate([`../${response.id}`], {relativeTo: this.route});
} else {
this.resourceCreated.emit(response.id)
}
},
error: (err) => this.error.emit("Error creating the entity:" + err)
});
} else {
model._id = this.resource_id;
this.crudService.update(this.resource!, model).subscribe( {
next: (model: any) => {
this.model = model;
this._modelLoading$.next(false);
this.resourceUpdated.emit(model._id)
},
error: (err) => this.error.emit("Error updating the entity:" + err)
});
this.crudService.update(this.resource!, model).subscribe( {
next: (model: any) => {
this.model = model;
this._modelLoading$.next(false);
this.resourceUpdated.emit(model._id);
this.resourceReceived.emit(model);
},
error: (err) => this.error.emit("Error updating the entity:" + err)
});
}
}
@@ -136,9 +142,9 @@ export class CardComponent implements OnInit {
this.modalService.dismissAll();
}
isCreateForm() {
return this.resource_id === null;
}
isCreateForm() {
return this.resource_id === null;
}
open(content: any) {
this.modalService.open(content, { ariaLabelledBy: 'modal-basic-title' }).result.then(

View File

@@ -43,13 +43,20 @@ export class CrudFormlyJsonschemaOptions implements FormlyJsonschemaOptions {
field.type = "datetime";
} else if (schema.format === '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";
} else if (schema.type == "array" && schema.format == "dictionary") {
field.type = "dictionary";
} else if (schema.type == "string" && schema.hasOwnProperty('props')
&& schema.props.hasOwnProperty("richtext") && schema.props.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')) {

View File

@@ -15,7 +15,7 @@ import { ArrayTypeComponent } from "./types/array.type";
import { ObjectTypeComponent } from "./types/object.type";
import { DatetimeTypeComponent } from "./types/datetime.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 { NgbModule} from "@ng-bootstrap/ng-bootstrap";
import { allIcons, NgxBootstrapIconsModule } from "ngx-bootstrap-icons";
@@ -26,12 +26,16 @@ import { HiddenTypeComponent } from "./types/hidden.type";
import { DictionaryTypeComponent } from "./types/dictionary.type";
import { DictionaryService } from "./types/dictionary.service";
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({
declarations: [
CardComponent,
ListComponent,
FilterListComponent,
ObjectTypeComponent,
DatetimeTypeComponent,
DateTypeComponent,
@@ -40,12 +44,14 @@ import { RichtextTypeComponent } from "./types/richtext.type";
ForeignkeyTypeComponent,
HiddenTypeComponent,
DictionaryTypeComponent,
RichtextTypeComponent
RichtextTypeComponent,
SignatureLinkTypeComponent
],
providers: [
JsonschemasService,
ApiService,
CrudService,
ImageUploaderCrudService,
CrudFormlyJsonschemaService,
DictionaryService
],
@@ -68,10 +74,12 @@ import { RichtextTypeComponent } from "./types/richtext.type";
{ name: 'hidden', component: HiddenTypeComponent },
{ name: 'dictionary', component: DictionaryTypeComponent },
{ name: 'richtext', component: RichtextTypeComponent },
{ name: 'signature-link', component: SignatureLinkTypeComponent },
]
}),
FormlyBootstrapModule,
EditorModule
EditorModule,
ClipboardModule
],
exports: [
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);
})
})
}
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) {
return prop.hasOwnProperty('properties')
}
@@ -159,7 +181,7 @@ export class JsonschemasService {
} else if (this.is_union(resource)) {
for (const ref of resource.oneOf!) {
// @ts-ignore
if (this.has_property(ref, property_name)) {
if (this.has_descendant(ref, property_name)) {
// @ts-ignore
return this.get_descendant(ref, property_name);
}
@@ -184,6 +206,21 @@ export class JsonschemasService {
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 {

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>
<button class="btn btn-success btn-lg float-end" type="button" (click)="onCreate()">
<button class="btn btn-success btn-lg float-end" type="button" i18n (click)="onCreate()">
Create {{ this.schema }}
</button>
<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">
<input
id="table-complete-search"
@@ -14,19 +13,22 @@
[(ngModel)]="searchTerm"
/>
</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 class="table-responsive-md">
<table class="table table-striped">
<thead>
<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>
</thead>
<tbody>
<tr *ngFor="let row of listData$ | async" (click)="onSelect(row._id)">
<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>
</tr>
</tbody>
@@ -38,11 +40,11 @@
</ngb-pagination>
<select class="form-select" style="width: auto" name="pageSize" [(ngModel)]="pageSize">
<option [ngValue]="10">10 items per page</option>
<option [ngValue]="15">15 items per page</option>
<option [ngValue]="25">25 items per page</option>
<option [ngValue]="50">50 items per page</option>
<option [ngValue]="100">100 items per page</option>
<option i18n [ngValue]="10">10 items per page</option>
<option i18n [ngValue]="15">15 items per page</option>
<option i18n [ngValue]="25">25 items per page</option>
<option i18n [ngValue]="50">50 items per page</option>
<option i18n [ngValue]="100">100 items per page</option>
</select>
</div>
</form>

View File

@@ -13,6 +13,12 @@ interface State {
searchTerm: string;
sortColumn: SortColumn;
sortDirection: SortDirection;
searchFilters: {[key: string]: any}
}
interface Column {
path: string,
title: string
}
@Component({
@@ -23,6 +29,7 @@ interface State {
export class ListComponent implements OnInit {
@Input() resource: string = "";
@Input() columns: string[] = [];
@Input() filters: string[] = [];
@Input() schema: string | undefined;
@Output() error: EventEmitter<string> = new EventEmitter();
@@ -30,7 +37,7 @@ export class ListComponent implements OnInit {
@ViewChildren(NgbdSortableHeader) headers: QueryList<NgbdSortableHeader> = new QueryList<NgbdSortableHeader>();
public displayedColumns: string[] = [];
public displayedColumns: Column[] = [];
private _loading$ = new BehaviorSubject<boolean>(true);
//private _search$ = new Subject<void>();
@@ -43,6 +50,7 @@ export class ListComponent implements OnInit {
searchTerm: '',
sortColumn: '_id',
sortDirection: 'asc',
searchFilters: {}
};
constructor(private service: CrudService,
@@ -62,23 +70,35 @@ export class ListComponent implements OnInit {
getColumnDefinition(schema: JSONSchema7) {
for (let column of this.columns) {
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) {
for (let param_name in schema.properties) {
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;
for (const key of col.split('.')) {
parent = parent[key];
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];
}
}
return parent;
}
@@ -87,6 +107,13 @@ export class ListComponent implements OnInit {
this._loading$.next(true);
let sortBy = new SortBy(this.sortColumn, this.sortDirection)
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({
next: (data: any) => {
@@ -101,6 +128,10 @@ export class ListComponent implements OnInit {
});
}
onFilterChange(event: any) {
this.searchFilters = event;
}
onSort({ column, direction }: any) {
// resetting other headers
this.headers.forEach((header) => {
@@ -145,6 +176,9 @@ export class ListComponent implements OnInit {
get searchTerm() {
return this._state.searchTerm;
}
get searchFilters() {
return this._state.searchFilters;
}
set page(page: number) {
this._set({ page });
@@ -155,6 +189,9 @@ export class ListComponent implements OnInit {
set searchTerm(searchTerm: string) {
this._set({ searchTerm });
}
set searchFilters(searchFilters: {[key: string]: any}) {
this._set({ searchFilters });
}
set sortColumn(sortColumn: SortColumn) {
this._set({ sortColumn });
}

View File

@@ -17,7 +17,7 @@ import { FieldArrayType } from '@ngx-formly/core';
<div *ngIf="props['numbered']" class="float-start">
<strong>{{ i + 1 }}</strong>
</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 == 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>
@@ -29,7 +29,7 @@ import { FieldArrayType } from '@ngx-formly/core';
</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>
`,
})

View File

@@ -1,7 +1,6 @@
import { Component, ElementRef, OnInit, ViewChild} from '@angular/core';
import { Component, OnInit } from '@angular/core';
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';
@@ -15,12 +14,12 @@ import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
<div class="alert alert-danger" role="alert" *ngIf="showError && formControl.errors">
<formly-validation-message [field]="field"></formly-validation-message>
</div>
<input type="hidden"
[formControl]="formControl"
[formlyAttributes]="field"
/>
<div class="input-group" *ngIf="! this.field.props.readonly">
<input type="hidden"
[formControl]="formControl"
[formlyAttributes]="field"
[class.is-invalid]="showError"
/>
<button class="btn btn-outline-secondary" (click)="d.toggle()" type="button"><i-bs name="calendar-date-fill"></i-bs></button>
<input
class="form-control"
placeholder="yyyy-mm-dd"
@@ -29,33 +28,40 @@ import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
(ngModelChange)="changeDatetime($event)"
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 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>
`,
})
export class DateTypeComponent extends FieldType<FieldTypeConfig> implements OnInit
{
public date : NgbDateStruct;
public datetime : Date = new Date();
public date : NgbDateStruct | null = null;
public datetime : Date | null = null;
constructor() {
super();
this.date = this.getDateStruct(new Date());
}
ngOnInit() {
if (this.formControl.value === undefined) {
this.changeDatetime({});
} else {
this.datetime = new Date(this.formControl.value);
this.date = this.getDateStruct(this.datetime);
}
this.formControl.valueChanges.subscribe(value => {
this.datetime = new Date(value)
this.date = this.getDateStruct(this.datetime);
if (value) {
this.datetime = new Date(value);
this.date = this.getDateStruct(this.datetime);
} else {
this.datetime = null;
this.date = null;
}
})
}
@@ -68,12 +74,21 @@ export class DateTypeComponent extends FieldType<FieldTypeConfig> implements OnI
}
changeDatetime(event: any) {
this.datetime.setFullYear(this.date.year)
this.datetime.setMonth(this.date.month - 1)
this.datetime.setDate(this.date.day)
if (this.date) {
if (!this.datetime) {
this.datetime = new Date();
}
this.datetime.setFullYear(this.date.year)
this.datetime.setMonth(this.date.month - 1)
this.datetime.setDate(this.date.day)
this.formControl.setValue(
formatDate(this.datetime, 'YYYY-MM-dd', 'EN_US', 'CET')
)
this.formControl.setValue(
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, ElementRef, OnInit, ViewChild} from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { formatDate } from "@angular/common";
import { NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap';
import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
@@ -12,12 +11,12 @@ import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
class="form-label">{{ props.label }}
<span *ngIf="props.required && props['hideRequiredMarker'] !== true" aria-hidden="true">*</span>
</label>
<input type="hidden"
[formControl]="formControl"
[formlyAttributes]="field"
/>
<div class="input-group" *ngIf="! this.field.props.readonly">
<input type="hidden"
[formControl]="formControl"
[formlyAttributes]="field"
[class.is-invalid]="showError"
/>
<button class="btn btn-outline-secondary bi bi-calendar3" (click)="d.toggle()" type="button"></button>
<input
class="form-control"
placeholder="yyyy-mm-dd"
@@ -26,8 +25,8 @@ import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
(ngModelChange)="changeDatetime($event)"
ngbDatepicker
#d="ngbDatepicker"
[class.is-invalid]="showError"
/>
<button class="btn btn-outline-secondary bi bi-calendar3" (click)="d.toggle()" type="button"></button>
<ngb-timepicker
(ngModelChange)="changeDatetime($event)"
[(ngModel)]="time"
@@ -35,15 +34,15 @@ import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
</ngb-timepicker>
</div>
<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>
`,
})
export class DatetimeTypeComponent extends FieldType<FieldTypeConfig> implements OnInit
{
public time : NgbTimeStruct;
public date : NgbDateStruct;
public datetime : Date = new Date()
public time : NgbTimeStruct | null = null;
public date : NgbDateStruct | null = null;
public datetime : Date | null = null;
constructor() {
@@ -55,12 +54,21 @@ export class DatetimeTypeComponent extends FieldType<FieldTypeConfig> implements
ngOnInit() {
if (this.formControl.value === undefined) {
this.changeDatetime({});
} else {
this.datetime = new Date(this.formControl.value);
this.date = this.getDateStruct(this.datetime);
}
this.formControl.valueChanges.subscribe(value => {
this.datetime = new Date(value)
this.date = this.getDateStruct(this.datetime);
this.time = this.getTimeStruct(this.datetime);
if (value) {
this.datetime = new Date(value);
this.date = this.getDateStruct(this.datetime);
this.time = this.getTimeStruct(this.datetime);
} else {
this.datetime = null;
this.date = null;
this.time = null;
}
})
}
@@ -81,15 +89,25 @@ export class DatetimeTypeComponent extends FieldType<FieldTypeConfig> implements
}
changeDatetime(event: any) {
this.datetime.setFullYear(this.date.year)
this.datetime.setMonth(this.date.month - 1)
this.datetime.setDate(this.date.day)
this.datetime.setHours(this.time.hour)
this.datetime.setMinutes(this.time.minute)
this.datetime.setSeconds(this.time.second)
if (this.date && this.time) {
if (!this.datetime) {
this.datetime = new Date();
}
this.datetime.setFullYear(this.date.year)
this.datetime.setMonth(this.date.month - 1)
this.datetime.setDate(this.date.day)
this.datetime.setHours(this.time.hour)
this.datetime.setMinutes(this.time.minute)
this.datetime.setSeconds(this.time.second)
this.formControl.setValue(
formatDate(this.datetime, 'YYYY-MM-ddTHH:mm:ss.SSS', 'EN_US', 'CET')
)
this.formControl.setValue(
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,7 +224,9 @@ export class ForeignkeyTypeComponent extends FieldType<FieldTypeConfig> implemen
result = result.concat();
} else if (typeof(obj[k]) == "object") {
result = result.concat(this.extractParameters(obj[k]));
if (obj[k]) {
result = result.concat(this.extractParameters(obj[k]));
}
}
}

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>