add contract pdf generation

This commit is contained in:
2026-02-14 23:59:44 +01:00
parent 7e42fbe106
commit f440cef59e
42 changed files with 1299 additions and 123 deletions

View File

@@ -13,6 +13,8 @@ import jwt
from jwt import PyJWKClient
import requests
from src.messages import tokenExpired, invalidToken
router = APIRouter(prefix="/auth")
jwk_client = PyJWKClient(JWKS_URL)
@@ -78,9 +80,9 @@ def verify_token(token: str):
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
raise HTTPException(status_code=401, detail=tokenExpired)
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
raise HTTPException(status_code=401, detail=invalidToken)
def get_current_user(

View File

@@ -1,3 +1,108 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from src.database import get_session
from sqlmodel import Session
import src.forms.service as form_service
import src.shipments.service as shipment_service
import src.products.service as product_service
from src.contracts.generate_contract import generate_html_contract
import src.models as models
from src.messages import PDFerrorOccured
import io
router = APIRouter(prefix='/contracts')
def find_dict_in_list(lst, key, value):
for i, dic in enumerate(lst):
if dic[key].id == value:
return i
return -1
def extract_products(session: Session, contract: dict):
planned = []
recurrent = []
for key in contract.keys():
key_list = key.split("-")
if "planned" in key:
shipment_id = int(key_list[1])
product_id = int(key_list[2])
shipment = shipment_service.get_one(session, shipment_id)
product = product_service.get_one(session, product_id)
existing_id = find_dict_in_list(planned, "shipment", shipment_id)
if existing_id >= 0:
planned[existing_id]["products"].append({
"product": product,
"quantity": contract[key],
})
planned[existing_id]['price'] += compute_product_price(product, contract[key])
else:
planned.append({
"shipment": shipment,
"price": compute_product_price(product, contract[key]),
"products": [{
"product": product,
"quantity": contract[key],
}]
})
if "recurrent" in key:
product_id = int(key_list[1])
product = product_service.get_one(session, product_id)
recurrent.append({
"product": product,
"quantity": contract[key]
})
return planned, recurrent
def compute_product_price(product: models.Product, quantity: int, nb_shipment: int = 1):
product_quantity_unit = 1 if product.unit == models.Unit.KILO else 1000
final_quantity = quantity if product.price else quantity / product_quantity_unit
final_price = product.price if product.price else product.price_kg
return final_price * final_quantity * nb_shipment
def compute_recurrent_prices(products_quantities: list[dict], nb_shipment: int):
result = 0
for product_quantity in products_quantities:
product = product_quantity['product']
quantity = product_quantity['quantity']
result += compute_product_price(product, quantity, nb_shipment)
return result
def compute_planned_prices(planned: list[dict]):
result = 0
for plan in planned:
result += plan['price']
return result
@router.post('/')
async def create_contract(
contract: models.ContractBase,
session: Session = Depends(get_session)
):
form = form_service.get_one(session, contract.form_id)
planned, recurrent = extract_products(session, contract.contract)
recurrent_price = compute_recurrent_prices(recurrent, len(form.shipments))
total_price = '{:10.2f}'.format(recurrent_price + compute_planned_prices(planned))
# TODO: Store contract
# TODO: send contract to referer
# TODO: Store contract informations ?
try:
pdf_bytes = generate_html_contract(
form,
contract.contract,
planned,
recurrent,
'{:10.2f}'.format(recurrent_price),
total_price
)
pdf_file = io.BytesIO(pdf_bytes)
contract_id = f'{contract.contract['firstname']}_{contract.contract['lastname']}_{form.productor.type}_{form.season}'
except:
raise HTTPException(status_code=400, detail=PDFerrorOccured)
return StreamingResponse(
pdf_file,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachement; filename=contract_{contract_id}.pdf"
}
)

View File

@@ -0,0 +1,58 @@
import jinja2
import src.models as models
import html
from weasyprint import HTML
def generate_html_contract(
form: models.Form,
contract_informations: dict,
planned: list[dict],
recurrent: list[dict],
recurrent_price: float,
total_price: float,
):
template_dir = "./src/contracts/templates"
template_loader = jinja2.FileSystemLoader(searchpath=template_dir)
template_env = jinja2.Environment(loader=template_loader, autoescape=jinja2.select_autoescape(["html", "xml"]))
template_file = "layout.html"
template = template_env.get_template(template_file)
output_text = template.render(
contract_name=form.name,
contract_type=form.productor.type,
contract_season=form.season,
referer_name=form.referer.name,
referer_email=form.referer.email,
productor_name=form.productor.name,
productor_address=form.productor.address,
payment_methods_map={"cheque": "Ordre du chèque", "transfer": "IBAN (paiement par virements)"},
productor_payment_methods=form.productor.payment_methods,
member_name=f'{html.escape(contract_informations["firstname"])} {html.escape(contract_informations["lastname"])}',
member_email=html.escape(contract_informations["email"]),
member_phone=html.escape(contract_informations["phone"]),
contract_start_date=form.start,
contract_end_date=form.end,
planned=planned,
recurrent=recurrent,
recurrent_price=recurrent_price,
total_price=total_price,
)
options = {
'page-size': 'Letter',
'margin-top': '0.5in',
'margin-right': '0.5in',
'margin-bottom': '0.5in',
'margin-left': '0.5in',
'encoding': "UTF-8",
'print-media-type': True,
"disable-javascript": True,
"disable-external-links": True,
'enable-local-file-access': False,
"disable-local-file-access": True,
"no-images": True,
}
return HTML(
string=output_text,
base_url=template_dir
).write_pdf()

View File

@@ -0,0 +1,309 @@
<!DOCTYPE html>
<html>
<head>
<title>{{contract_name}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style type="text/css">
@page {
size: A4;
margin: 1cm;
@bottom-center {
content: "Page " counter(page);
}
@top-center {
content: "AMAP Croix Luizet - Contrat";
font-size: 10px;
color: #666;
}
}
table {
border-collapse: collapse;
border: 2px solid rgb(140 140 140);
font-family: sans-serif;
font-size: 0.8rem;
letter-spacing: 1px;
}
h1, h2, h3, h4, h5 {
page-break-after: avoid;
}
h1 {
font-size: 22px;
margin-bottom: 5px;
}
h2 {
font-size: 16px;
margin-bottom: 15px;
}
h3 {
font-size: 15px;
margin-top: 30px;
margin-bottom: 10px;
}
h4 {
font-size: 14px;
margin-top: 25px;
margin-bottom: 10px;
}
h5 {
font-size: 13px;
margin-top: 20px;
margin-bottom: 5px;
}
p {
margin: 8px 0;
text-align: justify;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
margin-bottom: 20px;
font-size: 11px;
}
th,
td {
border: 1px solid #999;
padding: 6px 8px;
vertical-align: top;
}
th {
background-color: #f0f0f0;
text-align: left;
}
tbody tr:nth-child(even) {
background-color: #f7f7f7;
}
table {
page-break-inside: avoid;
}
tr {
page-break-inside: avoid;
}
.container {
width: 100%;
}
html, body {
font-family: sans-serif;
font-size: 12px;
line-height: 1.5;
color: #222;
}
body {
margin: 0;
}
.signature {
height: 80px;
}
.total-box {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid #999;
padding: 10px;
width: 200px;
margin-left: auto;
}
.total-label {
font-size: 18px;
font-weight: bold;
}
.total-price {
font-size: 18px;
font-weight: bold;
text-align: right;
}
</style>
</head>
<body>
<div class="container">
<h1>AMAP Croix Luizet</h1>
<h2>67 rue Octavie Villeurbanne - <a href="https://amapcroixluizet.eu">https://amapcroixluizet.eu</a></h2>
<h3>Contrat d'engagement solidaire</h3>
<h4>Informations contractuelles</h4>
<div class="container">
<p>Ce contrat est organisé par lAMAP CROIX-LUIZET et est régi par les statuts et le règlement intérieur de lAssociation.</p>
<table>
<tbody>
<tr>
<th>Type de contrat</th>
<td>{{contract_type}}</td>
</tr>
<tr>
<th>Saison du contrat</th>
<td>{{contract_season}}</td>
</tr>
<tr>
<th>Type de contrat</th>
<td>{{contract_type}}</td>
</tr>
<tr>
<th>Référent·e</th>
<td>{{referer_name}}</td>
</tr>
<tr>
<th>Email référent·e</th>
<td>{{referer_email}}</td>
</tr>
<tr>
<th>Le/La producteur·trice</th>
<td>{{productor_name}}</td>
</tr>
<tr>
<th>Adresse du producteur·trice</th>
<td>{{productor_address}}</td>
</tr>
{% for method in productor_payment_methods %}
<tr>
<th>{{payment_methods_map[method.name]}}</th>
<td>{{method.details}}</td>
</tr>
{% endfor %}
<tr>
<th>Ladhérent·e</th>
<td>{{member_name}}</td>
</tr>
<tr>
<th>Email de ladhérent·e</th>
<td>{{member_email}}</td>
</tr>
<tr>
<th>Téléphone de l'adhérent·e</th>
<td>{{member_phone}}</td>
</tr>
</tbody>
</table>
<p>
L'adhérent-e et le-la producteur-trice sengagent à respecter le présent contrat, les statuts et le Règlement Intérieur de «lAMAP CROIX LUIZET» et la charte des AMAP.
</p>
</div>
<div>
<h5>Engagement réciproque</h5>
<p>
Le/La producteur·trice sengage à fournir un panier <b>{{contract_type}}</b>, issu de son exploitation et de qualité en termes gustatifs. Il/Elle sengage à mener son exploitation dans un esprit de respect de la nature et de lenvironnement.
Le/La membre adhérent·e sengage à acheter 1 panier en acceptant les conséquences daléas climatiques ou autres évènements ayant un impact sur la qualité ou la quantité de produits dans le panier.
Le contrat commence le <b>{{contract_start_date}}</b> et termine le <b>{{contract_end_date}}</b>.
</p>
</div>
<div>
<h5>
<b>Modalités de livraison</b>
</h5>
<p>
Les livraisons sont effectuées exclusivement à la Maison du Citoyen, 67 rue Octavie 69100 VILLEURBANNE, les jeudis soir de 19h00 à 20h00. Toutefois en accord avec le producteur, et suivant les mesures sanitaires en vigueur, le Conseil dAdministration peut modifier exceptionnellement le lieu, le jour ou lhoraire de livraison.
</p>
</div>
<div>
<h5>
En cas dimpossibilité
</h5>
<ul>
<li>
Pour le/la producteur·trice dassurer une livraison, le Conseil dAdministration et le/la référent-e producteur·trice rechercheront, dans le respect des parties et de léthique de lAMAP une solution compensatrice.
</li>
<li>
Pour ladhérent·e de respecter le calendrier et de venir récupérer sa commande, les membres chargés de la distribution disposeront des paniers restants qui seront donnés à une association caritative ou distribués aux Amapien·ennes présent·es. Aucun panier ne sera remboursé.
</li>
</ul>
</div>
<div>
<h5>Rupture du contrat</h5>
<p>
Ce contrat peut être interrompu unilatéralement par le/la membre adhérent, si et seulement si, un/une remplaçant·e est trouvé immédiatement, de sorte que le/la producteur·trice ne soit pas pénalisé financièrement. Ce contrat peut être rompu bilatéralement à tout moment. En cas de désaccord, cest au conseil dadministration de statuer.
</p>
</div>
{% if recurrent|length > 0 %}
<div class="container">
<h4>Produits récurents (pour chaques livraisons)</h4>
<table>
<thead>
<tr>
<th>Nom du produit</th>
<th>Prix (€)</th>
<th>Prix (€/kg)</th>
<th>Poids</th>
<th>Quantité</th>
</tr>
</thead>
<tbody>
{% for rec in recurrent %}
<tr>
<td>{{rec.product.name}}</td>
<td>{{rec.product.price if rec.product.price else ""}}</td>
<td>{{rec.product.price_kg if rec.product.price_kg else ""}}</td>
<td>{{rec.product.quantity if rec.product.quantity != None else ""}} {{rec.product.quantity_unit if rec.product.quantity_unit != None else ""}}</td>
<td>{{rec.quantity}}{{"g" if rec.product.unit == "1" else "kg" if rec.product.unit == "2" else "p" }}</td>
</tr>
{% endfor %}
<tr>
<th scope="row" colspan="4">Total</th>
<td>{{recurrent_price}}€</td>
</tr>
</tbody>
</table>
</div>
{% endif %}
{% if planned|length > 0 %}
<div class="container">
<h4>Produits planifiés (par livraison)</h4>
{% for plan in planned %}
<h5>{{plan.shipment.name}} {{plan.shipment.date}}</h5>
<table>
<thead>
<tr>
<th>Nom du produit</th>
<th>Prix (€)</th>
<th>Prix (€/kg)</th>
<th>Poids</th>
<th>Quantité</th>
</tr>
</thead>
<tbody>
{% for product in plan.products %}
<tr>
<td>{{product.product.name}}</td>
<td>{{product.product.price if product.product.price else ""}}</td>
<td>{{product.product.price_kg if product.product.price_kg else ""}}</td>
<td>{{product.product.quantity if product.product.quantity != None else ""}} {{product.product.quantity_unit if product.product.quantity_unit != None else ""}}</td>
<td>{{product.quantity}}{{"g" if product.product.unit == "1" else "kg" if product.product.unit == "2" else "p" }}</td>
</tr>
{% endfor%}
<tr>
<th scope="row" colspan="4">Total</th>
<td>{{plan.price}}€</td>
</tr>
</tbody>
</table>
{% endfor %}
</div>
{% endif %}
<div class="total-box">
<div class="total-label">Prix Total :</div>
<div class="total-price">{{total_price}}€</div>
</div>
<div class="container">
<table>
<thead>
<tr>
<th>Signature producteur-trice</th>
<th>Signature adhérent-e</th>
</tr>
</thead>
<tbody>
<tr>
<td class="signature"></td>
<td class="signature"></td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -1 +1,4 @@
notfound = "Resource was not found."
notfound = "Resource was not found."
PDFerrorOccured = "An error occured during PDF generation please contact administrator"
tokenExpired = "Token expired"
invalidToken = "Invalid token"

View File

@@ -20,15 +20,30 @@ class UserUpdate(SQLModel):
class UserCreate(UserBase):
pass
class PaymentMethodBase(SQLModel):
name: str
details: str
class PaymentMethod(PaymentMethodBase, table=True):
id: int | None = Field(default=None, primary_key=True)
productor_id: int = Field(foreign_key="productor.id", ondelete="CASCADE")
productor: Optional["Productor"] = Relationship(
back_populates="payment_methods",
)
class PaymentMethodPublic(PaymentMethodBase):
id: int
productor: Optional["Productor"]
class ProductorBase(SQLModel):
name: str
address: str
payment: str
type: str
class ProductorPublic(ProductorBase):
id: int
products: list["Product"] = []
payment_methods: list["PaymentMethod"] = []
class Productor(ProductorBase, table=True):
id: int | None = Field(default=None, primary_key=True)
@@ -39,15 +54,19 @@ class Productor(ProductorBase, table=True):
"order_by": "Product.name"
},
)
payment_methods: list["PaymentMethod"] = Relationship(
back_populates="productor",
cascade_delete=True
)
class ProductorUpdate(SQLModel):
name: str | None
address: str | None
payment: str | None
payment_methods: list["PaymentMethod"] = []
type: str | None
class ProductorCreate(ProductorBase):
pass
payment_methods: list["PaymentMethod"] = []
class Unit(StrEnum):
GRAMS = "1"
@@ -102,6 +121,7 @@ class FormBase(SQLModel):
season: str
start: datetime.date
end: datetime.date
minimum_shipment_value: float | None
class FormPublic(FormBase):
id: int
@@ -128,6 +148,7 @@ class FormUpdate(SQLModel):
season: str | None
start: datetime.date | None
end: datetime.date | None
minimum_shipment_value: float | None
class FormCreate(FormBase):
pass
@@ -148,13 +169,14 @@ class TemplateCreate(TemplateBase):
pass
class ContractBase(SQLModel):
pass
form_id: int
contract: dict
class ContractPublic(ContractBase):
id: int
class Contract(ContractBase, table=True):
id: int | None = Field(default=None, primary_key=True)
# class Contract(ContractBase, table=True):
# id: int | None = Field(default=None, primary_key=True)
class ContractUpdate(SQLModel):
pass

View File

@@ -13,8 +13,16 @@ def get_one(session: Session, productor_id: int) -> models.ProductorPublic:
return session.get(models.Productor, productor_id)
def create_one(session: Session, productor: models.ProductorCreate) -> models.ProductorPublic:
productor_create = productor.model_dump(exclude_unset=True)
productor_create = productor.model_dump(exclude_unset=True, exclude="payment_methods")
new_productor = models.Productor(**productor_create)
new_productor.payment_methods = [
models.PaymentMethod(
name=pm.name,
details=pm.details
) for pm in productor.payment_methods
]
session.add(new_productor)
session.commit()
session.refresh(new_productor)
@@ -26,7 +34,20 @@ def update_one(session: Session, id: int, productor: models.ProductorUpdate) ->
new_productor = result.first()
if not new_productor:
return None
productor_updates = productor.model_dump(exclude_unset=True)
if "payment_methods" in productor_updates:
new_productor.payment_methods.clear()
for pm in productor_updates["payment_methods"]:
new_productor.payment_methods.append(
models.PaymentMethod(
name=pm["name"],
details=pm["details"],
productor_id=id
)
)
del productor_updates["payment_methods"]
for key, value in productor_updates.items():
setattr(new_productor, key, value)
session.add(new_productor)