Contract Signing and contract printing

This commit is contained in:
2023-03-08 21:59:10 +01:00
parent eaa79c3541
commit 5605ee9497
10 changed files with 230 additions and 46 deletions

View File

@@ -1,11 +1,12 @@
import uuid import uuid
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException, File, UploadFile
import shutil
from ..core.routes import get_crud_router from ..core.routes import get_crud_router
from .routes_draft import draft_router from .routes_draft import draft_router
from .print import print_router from .print import print_router
from .models import Contract, ContractDraft, replace_variables_in_value from .models import Contract, ContractDraft, Party, replace_variables_in_value
from .schemas import ContractCreate, ContractRead, ContractUpdate from .schemas import ContractCreate, ContractRead, ContractUpdate
from ..entity.models import Entity from ..entity.models import Entity
@@ -72,10 +73,31 @@ async def update(id: str, contract_form: ContractUpdate, user=Depends(get_curren
@contract_router.get("/signature/{signature_id}", response_description="") @contract_router.get("/signature/{signature_id}", response_description="")
async def get_signature(signature_id: str) -> ContractRead: async def get_signature(signature_id: str) -> Party:
raise HTTPException(status_code=500, detail="Not implemented") 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="") @contract_router.post("/signature/{signature_id}", response_description="")
async def affix_signature(signature_id: str, signature_form: ContractCreate) -> ContractRead: async def affix_signature(signature_id: str, signature_file: UploadFile = File(...)) -> bool:
raise HTTPException(status_code=500, detail="Not implemented") 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("media/signatures/{}.png".format(signature_id), "wb") as buffer:
shutil.copyfileobj(signature_file.file, buffer)
update_query = {"$set": {
'parties.{}.signature_affixed'.format(signature_index): True
}}
signature.signature_affixed = True
if contract.is_signed():
update_query["$set"]['status'] = 'signed'
await contract.update(update_query)
return True

View File

@@ -1,10 +1,9 @@
import uuid
import datetime import datetime
from typing import List, Literal from typing import List, Literal
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from beanie.operators import ElemMatch
from ..core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry from ..core.models import CrudDocument, RichtextSingleline, RichtextMultiline, DictionaryEntry
from ..entity.models import Entity from ..entity.models import Entity
@@ -13,6 +12,7 @@ from ..entity.models import Entity
class ContractStatus(str, Enum): class ContractStatus(str, Enum):
published = 'published' published = 'published'
signed = 'signed' signed = 'signed'
printed = 'printed'
executed = 'executed' executed = 'executed'
@@ -54,6 +54,7 @@ class Party(BaseModel):
representative: Entity = None representative: Entity = None
signature_uuid: str signature_uuid: str
signature_affixed: bool = False signature_affixed: bool = False
signature_png: str = None
class ProvisionGenuine(BaseModel): class ProvisionGenuine(BaseModel):
@@ -127,6 +128,27 @@ class Contract(CrudDocument):
hour=0, minute=0, second=0) 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): def replace_variables_in_value(variables, value: str):
for v in variables: for v in variables:

View File

@@ -1,11 +1,11 @@
import datetime import datetime
import os
import base64
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from fastapi.responses import HTMLResponse, FileResponse from fastapi.responses import HTMLResponse, FileResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from beanie.operators import ElemMatch
from weasyprint import HTML, CSS from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration from weasyprint.text.fonts import FontConfiguration
@@ -13,7 +13,7 @@ from pathlib import Path
from app.entity.models import Entity from app.entity.models import Entity
from app.template.models import ProvisionTemplate from app.template.models import ProvisionTemplate
from ..models import ContractDraft, Contract, replace_variables_in_value from ..models import ContractDraft, Contract, ContractStatus, replace_variables_in_value
async def build_model(model): async def build_model(model):
@@ -77,32 +77,55 @@ async def render_css(host, contract):
}) })
@print_router.get("/preview/draft/{draftId}", response_class=HTMLResponse) @print_router.get("/preview/draft/{draft_id}", response_class=HTMLResponse)
async def preview_draft(draftId: str) -> str: async def preview_draft(draft_id: str) -> str:
draft = await build_model(await ContractDraft.get(draftId)) draft = await build_model(await ContractDraft.get(draft_id))
return await render_print('localhost', draft) return await render_print('localhost', draft)
@print_router.get("/preview/{signature_id}", response_class=HTMLResponse) @print_router.get("/preview/{signature_id}", response_class=HTMLResponse)
async def preview_contract(signature_id: str) -> str: async def preview_contract(signature_id: str) -> str:
crit = ElemMatch(Contract.parties, {"signature_uuid": signature_id}) contract = await Contract.find_by_signature_id(signature_id)
contract = await Contract.find_one(crit) 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('localhost', contract) return await render_print('localhost', contract)
@print_router.get("/pdf", response_class=FileResponse) @print_router.get("/pdf/{contract_id}", response_class=FileResponse)
async def create_pdf() -> str: async def create_pdf(contract_id: str) -> str:
draft = await build_model(await ContractDraft.get("63e92534aafed8b509f229c4")) contract = await Contract.get(contract_id)
contract_path = "media/contracts/{}.pdf".format(contract_id)
if not os.path.isfile(contract_path):
if contract.status != ContractStatus.signed:
raise HTTPException(status_code=400, detail="Contract is not in a printable state")
for p in contract.parties:
signature_path = f'media/signatures/{p.signature_uuid}.png'
p.signature_png = retrieve_signature_png(signature_path)
# os.remove(signature_path)
font_config = FontConfiguration() font_config = FontConfiguration()
html = HTML(string=await render_print('nginx', draft)) html = HTML(string=await render_print('nginx', contract))
css = CSS(string=await render_css('nginx', draft), font_config=font_config) css = CSS(string=await render_css('nginx', contract), font_config=font_config)
html.write_pdf('out.pdf', stylesheets=[css], font_config=font_config) html.write_pdf(contract_path, stylesheets=[css], font_config=font_config)
update_query = {"$set": {
'status': 'printed'
}}
await contract.update(update_query)
return FileResponse( return FileResponse(
"out.pdf", contract_path,
media_type="application/pdf", media_type="application/pdf",
filename=draft.name) filename=contract.name)
def retrieve_signature_png(filepath):
with open(filepath, "rb") as f:
b_content = f.read()
base64_utf8_str = base64.b64encode(b_content).decode('utf-8')
ext = filepath.split('.')[-1]
return f'data:image/{ext};base64,{base64_utf8_str}'

View File

@@ -60,7 +60,14 @@
<p class="mention">(Signatures précédée de la mention « Lu et approuvé »)</p> <p class="mention">(Signatures précédée de la mention « Lu et approuvé »)</p>
<table class="signatures"> <table class="signatures">
<tr> <tr>
{% for party in contract.parties %}<td>{{ party.part|safe }}:</td>{% endfor %} {% for party in contract.parties %}
<td>
{{ party.part|safe }}:<br/>
{% if party.signature_png %}
<img src="{{ party.signature_png }}" />
{% endif %}
</td>
{% endfor %}
</tr> </tr>
</table> </table>
</div> </div>

View File

@@ -1,7 +1,7 @@
import {Component, Input, OnInit} from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { fabric } from 'fabric'; import { ActivatedRoute, ParamMap } from "@angular/router";
import {ActivatedRoute, ParamMap} from "@angular/router"; import { DomSanitizer } from "@angular/platform-browser";
import {DomSanitizer} from "@angular/platform-browser"; import { ImageUploaderCrudService } from "@common/crud/crud.service";
export class BaseContractsComponent { export class BaseContractsComponent {
@@ -24,9 +24,36 @@ export class ContractsNewComponent extends BaseContractsComponent {
} }
@Component({ @Component({
templateUrl: '../base-view/templates/card.template.html' template:`
<label>Download Link:</label>
<div class="input-group mb-12">
<span class="input-group-text"><a href="{{ this.contractPrintLink! }}" target="_blank">{{ this.contractPrintLink! }}&nbsp;</a></span>
<button type="button" class="btn btn-light" [cdkCopyToClipboard]="this.contractPrintLink!"><i-bs name="text-paragraph"/></button>
</div>
<base-card
[resource_id]="this.resource_id"
[resource]="this.resource"
[schema]="this.schema">
</base-card>
`,
}) })
export class ContractsCardComponent extends BaseContractsComponent{ export class ContractsCardComponent extends BaseContractsComponent{
resource_id: string | null = null;
contractPrintLink: 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')
this.contractPrintLink = `${location.origin}/api/v1/contract/print/pdf/${this.resource_id}`
})
}
}
} }
@Component({ @Component({
@@ -47,7 +74,17 @@ export class ContractsCardComponent extends BaseContractsComponent{
<span>Signature</span> <span>Signature</span>
</ng-template> </ng-template>
<ng-template ngbPanelContent> <ng-template ngbPanelContent>
<signature-drawer></signature-drawer> <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> </ng-template>
</ngb-panel> </ngb-panel>
</ngb-accordion> </ngb-accordion>
@@ -55,18 +92,41 @@ export class ContractsCardComponent extends BaseContractsComponent{
}) })
export class ContractsSignatureComponent implements OnInit { export class ContractsSignatureComponent implements OnInit {
signature_id: string | null = null; signature_id: string | null = null;
signature: any = {}
signatory = ""
affixed = false;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private sanitizer: DomSanitizer private sanitizer: DomSanitizer,
private crudService: ImageUploaderCrudService,
) {} ) {}
ngOnInit() {this.route.paramMap.subscribe((params: ParamMap) => { ngOnInit() {
this.signature_id = params.get('id') 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() { getPreview() {
return this.sanitizer.bypassSecurityTrustResourceUrl("/api/v1/contract/print/preview/" + this.signature_id); return this.sanitizer.bypassSecurityTrustResourceUrl("/api/v1/contract/print/preview/" + 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

@@ -7,12 +7,13 @@ import { DraftsCardComponent, DraftsListComponent, DraftsNewComponent, DraftsNew
import { FormlyModule } from "@ngx-formly/core"; import { FormlyModule } from "@ngx-formly/core";
import { FormlyBootstrapModule } from "@ngx-formly/bootstrap"; import { FormlyBootstrapModule } from "@ngx-formly/bootstrap";
import { ForeignkeyTypeComponent } from "@common/crud/types/foreignkey.type"; import { ForeignkeyTypeComponent } from "@common/crud/types/foreignkey.type";
import { CrudService } from "@common/crud/crud.service"; import { CrudService, ImageUploaderCrudService } from "@common/crud/crud.service";
import { NgbAccordionModule, NgbCollapseModule } from "@ng-bootstrap/ng-bootstrap"; import { NgbAccordionModule, NgbCollapseModule } from "@ng-bootstrap/ng-bootstrap";
import { allIcons, NgxBootstrapIconsModule } from "ngx-bootstrap-icons"; import { allIcons, NgxBootstrapIconsModule } from "ngx-bootstrap-icons";
import { ContractsCardComponent, ContractsListComponent, ContractsNewComponent, ContractsSignatureComponent } from "../contracts/contracts.component"; import { ContractsCardComponent, ContractsListComponent, ContractsNewComponent, ContractsSignatureComponent } from "../contracts/contracts.component";
import { AlphaRangeComponent, BlackBlueRangeComponent, SignatureDrawerComponent } from "./signature-drawer/signature-drawer.component"; import { AlphaRangeComponent, BlackBlueRangeComponent, SignatureDrawerComponent } from "./signature-drawer/signature-drawer.component";
import { ClipboardModule } from "@angular/cdk/clipboard";
@NgModule({ @NgModule({
@@ -29,6 +30,7 @@ import { AlphaRangeComponent, BlackBlueRangeComponent, SignatureDrawerComponent
] ]
}), }),
FormlyBootstrapModule, FormlyBootstrapModule,
ClipboardModule,
], ],
declarations: [ declarations: [
DraftsListComponent, DraftsListComponent,
@@ -43,7 +45,7 @@ import { AlphaRangeComponent, BlackBlueRangeComponent, SignatureDrawerComponent
BlackBlueRangeComponent, BlackBlueRangeComponent,
AlphaRangeComponent AlphaRangeComponent
], ],
providers: [CrudService] providers: [CrudService, ImageUploaderCrudService]
}) })
export class ContractsModule { export class ContractsModule {
} }

View File

@@ -1,8 +1,8 @@
<div class="row align-items-start"> <div class="row align-items-start">
<canvas id="signatureCanvas" class="col" width="320" height="320"></canvas> <canvas id="signatureCanvas" class="col-9" width="320" height="320"></canvas>
<div class="col-sm-2"> <div class="col-3" style="width: 90px">
<div class="card"> <div class="card">
<span class="btn btn-light btn-file"> <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)"> <i-bs name="image-fill"></i-bs><input #imageInput type="file" accept="image/png, image/gif, image/jpeg, image/bmp" (change)="addImage($event)">
@@ -13,7 +13,6 @@
<alpha-range (change)="updateAlpha($event)"></alpha-range> <alpha-range (change)="updateAlpha($event)"></alpha-range>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<button <button
type="button" type="button"
@@ -37,7 +36,11 @@
</div> </div>
</div> </div>
</div> </div>
<button class="btn btn-primary">Sign!</button> <button
type="button"
class="btn btn-primary"
(click)="sign()"
>Sign!</button>
<button <button
type="button" type="button"
class="btn btn-danger" class="btn btn-danger"

View File

@@ -8,6 +8,8 @@ import { fabric } from 'fabric';
}) })
export class SignatureDrawerComponent implements OnInit export class SignatureDrawerComponent implements OnInit
{ {
@Output() signatureDrawn$ = new EventEmitter<string>()
size = 320; size = 320;
canvas: any; canvas: any;
isEditImage = false; isEditImage = false;
@@ -26,6 +28,14 @@ export class SignatureDrawerComponent implements OnInit
'selection:updated': function() {self.handleElement()}, 'selection:updated': function() {self.handleElement()},
'selection:created': 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() { toggleDrawing() {
@@ -160,6 +170,15 @@ export class SignatureDrawerComponent implements OnInit
clear() { clear() {
this.canvas.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({ @Component({

View File

@@ -15,7 +15,7 @@ import { ArrayTypeComponent } from "./types/array.type";
import { ObjectTypeComponent } from "./types/object.type"; import { ObjectTypeComponent } from "./types/object.type";
import { DatetimeTypeComponent } from "./types/datetime.type"; import { DatetimeTypeComponent } from "./types/datetime.type";
import { DateTypeComponent } from "./types/date.type"; import { DateTypeComponent } from "./types/date.type";
import { ApiService, CrudService } from "./crud.service"; import { ApiService, CrudService, ImageUploaderCrudService } from "./crud.service";
import { CrudFormlyJsonschemaService } from "./crud-formly-jsonschema.service"; import { CrudFormlyJsonschemaService } from "./crud-formly-jsonschema.service";
import { NgbModule} from "@ng-bootstrap/ng-bootstrap"; import { NgbModule} from "@ng-bootstrap/ng-bootstrap";
import { allIcons, NgxBootstrapIconsModule } from "ngx-bootstrap-icons"; import { allIcons, NgxBootstrapIconsModule } from "ngx-bootstrap-icons";
@@ -49,6 +49,7 @@ import { ClipboardModule } from '@angular/cdk/clipboard';
JsonschemasService, JsonschemasService,
ApiService, ApiService,
CrudService, CrudService,
ImageUploaderCrudService,
CrudFormlyJsonschemaService, CrudFormlyJsonschemaService,
DictionaryService DictionaryService
], ],

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