add contract pdf generation
This commit is contained in:
@@ -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"
|
||||
}
|
||||
)
|
||||
58
backend/src/contracts/generate_contract.py
Normal file
58
backend/src/contracts/generate_contract.py
Normal 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()
|
||||
309
backend/src/contracts/templates/layout.html
Normal file
309
backend/src/contracts/templates/layout.html
Normal 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 l’AMAP CROIX-LUIZET et est régi par les statuts et le règlement intérieur de l’Association.</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>L’adhérent·e</th>
|
||||
<td>{{member_name}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Email de l’adhé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 s’engagent à respecter le présent contrat, les statuts et le Règlement Intérieur de «l’AMAP CROIX LUIZET» et la charte des AMAP.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h5>Engagement réciproque</h5>
|
||||
<p>
|
||||
Le/La producteur·trice s’engage à fournir un panier <b>{{contract_type}}</b>, issu de son exploitation et de qualité en termes gustatifs. Il/Elle s’engage à mener son exploitation dans un esprit de respect de la nature et de l’environnement.
|
||||
Le/La membre adhérent·e s’engage à acheter 1 panier en acceptant les conséquences d’alé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 d’Administration peut modifier exceptionnellement le lieu, le jour ou l’horaire de livraison.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h5>
|
||||
En cas d’impossibilité
|
||||
</h5>
|
||||
<ul>
|
||||
<li>
|
||||
Pour le/la producteur·trice d’assurer une livraison, le Conseil d’Administration et le/la référent-e producteur·trice rechercheront, dans le respect des parties et de l’éthique de l’AMAP une solution compensatrice.
|
||||
</li>
|
||||
<li>
|
||||
Pour l’adhé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, c’est au conseil d’administration 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>
|
||||
Reference in New Issue
Block a user