diff --git a/.gitignore b/.gitignore index ad4a1f1..963a1c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Created by https://www.toptal.com/developers/gitignore/api/python # Edit at https://www.toptal.com/developers/gitignore?templates=python - +.vscode ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e334eb7 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# TODO + +## backend\src\contracts\contracts.py + +- Send contract to referer +- Extract recap +- Extract all contracts +- store total price +- store pdf file + +## Wording + +- planned -> occasionnal (planifié -> occasionnel) +- all translations + +## Tutorial for referers + +### Glossary + +Occasional +Recurrent + +## Footer + +### Legal + +### About + +### Contact + +## Migrations + +- use alembic for migration management + +## Update contract after register + +## Filter contracts in home view + +## Only show current season (if multiple form, only show the one with latest start date) diff --git a/backend/src/contracts/contracts.py b/backend/src/contracts/contracts.py index 6e195fa..312e1c0 100644 --- a/backend/src/contracts/contracts.py +++ b/backend/src/contracts/contracts.py @@ -1,65 +1,16 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Query 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 src.contracts.service as service + 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: @@ -68,41 +19,106 @@ def compute_recurrent_prices(products_quantities: list[dict], nb_shipment: int): result += compute_product_price(product, quantity, nb_shipment) return result -def compute_planned_prices(planned: list[dict]): +def compute_planned_prices(planneds: list[dict]): result = 0 - for plan in planned: - result += plan['price'] + for planned in planneds: + result += planned['price'] + return result + +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 find_dict_in_list(lst, key, value): + for i, dic in enumerate(lst): + if dic[key].id == value: + return i + return -1 + +def create_planned_dict(contract_products: list[models.ContractProduct]): + result = [] + for contract_product in contract_products: + existing_id = find_dict_in_list( + result, + 'shipment', + contract_product.shipment.id + ) + if existing_id < 0: + result.append({ + 'shipment': contract_product.shipment, + 'price': compute_product_price( + contract_product.product, + contract_product.quantity + ), + 'products': [{ + 'product': contract_product.product, + 'quantity': contract_product.quantity + }] + }) + else: + result[existing_id]['products'].append({ + 'product': contract_product.product, + 'quantity': contract_product.quantity + }) + result[existing_id]['price'] += compute_product_price( + contract_product.product, + contract_product.quantity + ) return result @router.post('/') async def create_contract( - contract: models.ContractBase, + contract: models.ContractCreate, 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 + new_contract = service.create_one(session, contract) + planned_contract_products = list(filter(lambda contract_product: contract_product.product.type == models.ProductType.PLANNED, new_contract.products)) + planneds = create_planned_dict(planned_contract_products) + recurrents = list(map(lambda x: {"product": x.product, "quantity": x.quantity}, filter(lambda contract_product: contract_product.product.type == models.ProductType.RECCURENT, new_contract.products))) + recurrent_price = compute_recurrent_prices(recurrents, len(new_contract.form.shipments)) + total_price = '{:10.2f}'.format(recurrent_price + compute_planned_prices(planneds)) + cheques = list(map(lambda x: {"name": x.name, "value": x.value}, new_contract.cheques)) # 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), + new_contract, + cheques, + planneds, + recurrents, + recurrent_price, total_price ) pdf_file = io.BytesIO(pdf_bytes) - contract_id = f'{contract.contract['firstname']}_{contract.contract['lastname']}_{form.productor.type}_{form.season}' + contract_id = f'{new_contract.firstname}_{new_contract.lastname}_{new_contract.form.productor.type}_{new_contract.form.season}' except: raise HTTPException(status_code=400, detail=PDFerrorOccured) return StreamingResponse( pdf_file, - media_type="application/pdf", + media_type='application/pdf', headers={ - "Content-Disposition": f"attachement; filename=contract_{contract_id}.pdf" + 'Content-Disposition': f'attachement; filename=contract_{contract_id}.pdf' } - ) \ No newline at end of file + ) + +@router.get('/', response_model=list[models.ContractPublic]) +def get_contracts( + forms: list[str] = Query([]), + session: Session = Depends(get_session) +): + return service.get_all(session, forms) + +@router.get('/{id}', response_model=models.ContractPublic) +def get_contract(id: int, session: Session = Depends(get_session)): + result = service.get_one(session, id) + if result is None: + raise HTTPException(status_code=404, detail=messages.notfound) + return result + +@router.delete('/{id}', response_model=models.ContractPublic) +def delete_contract(id: int, session: Session = Depends(get_session)): + result = service.delete_one(session, id) + if result is None: + raise HTTPException(status_code=404, detail=messages.notfound) + return result diff --git a/backend/src/contracts/generate_contract.py b/backend/src/contracts/generate_contract.py index 1fecdbc..f6858a6 100644 --- a/backend/src/contracts/generate_contract.py +++ b/backend/src/contracts/generate_contract.py @@ -5,37 +5,39 @@ import html from weasyprint import HTML def generate_html_contract( - form: models.Form, - contract_informations: dict, - planned: list[dict], - recurrent: list[dict], + contract: models.Contract, + cheques: list[dict], + planneds: list[dict], + reccurents: list[dict], recurrent_price: float, - total_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, + contract_name=contract.form.name, + contract_type=contract.form.productor.type, + contract_season=contract.form.season, + referer_name=contract.form.referer.name, + referer_email=contract.form.referer.email, + productor_name=contract.form.productor.name, + productor_address=contract.form.productor.address, + payment_methods_map={"cheque": "Ordre du chèque", "transfer": "virements"}, + productor_payment_methods=contract.form.productor.payment_methods, + member_name=f'{html.escape(contract.firstname)} {html.escape(contract.lastname)}', + member_email=html.escape(contract.email), + member_phone=html.escape(contract.phone), + contract_start_date=contract.form.start, + contract_end_date=contract.form.end, + planneds=planneds, + recurrents=reccurents, recurrent_price=recurrent_price, total_price=total_price, + contract_payment_method={"cheque": "chèque", "transfer": "virements"}[contract.payment_method], + cheques=cheques ) options = { 'page-size': 'Letter', diff --git a/backend/src/contracts/service.py b/backend/src/contracts/service.py new file mode 100644 index 0000000..51a07dc --- /dev/null +++ b/backend/src/contracts/service.py @@ -0,0 +1,65 @@ +from sqlmodel import Session, select +import src.models as models + +def get_all( + session: Session, + forms: list[str] +) -> list[models.ContractPublic]: + statement = select(models.Contract) + if len(forms) > 0: + statement = statement.join(models.Form).where(models.Form.name.in_(forms)) + return session.exec(statement.order_by(models.Contract.id)).all() + +def get_one(session: Session, contract_id: int) -> models.ContractPublic: + return session.get(models.Contract, contract_id) + +def create_one(session: Session, contract: models.ContractCreate) -> models.ContractPublic: + contract_create = contract.model_dump(exclude_unset=True, exclude=["products", "cheques"]) + new_contract = models.Contract(**contract_create) + + new_contract.cheques = [ + models.Cheque( + name=cheque.name, + value=cheque.value, + contract_id=new_contract.id + ) for cheque in contract.cheques + ] + + new_contract.products = [ + models.ContractProduct( + contract_id=new_contract.id, + product_id=contract_product.product_id, + shipment_id=contract_product.shipment_id, + quantity=contract_product.quantity + ) for contract_product in contract.products + ] + + session.add(new_contract) + session.commit() + session.refresh(new_contract) + return new_contract + +def update_one(session: Session, id: int, contract: models.ContractUpdate) -> models.ContractPublic: + statement = select(models.Contract).where(models.Contract.id == id) + result = session.exec(statement) + new_contract = result.first() + if not new_contract: + return None + contract_updates = contract.model_dump(exclude_unset=True) + for key, value in contract_updates.items(): + setattr(new_contract, key, value) + session.add(new_contract) + session.commit() + session.refresh(new_contract) + return new_contract + +def delete_one(session: Session, id: int) -> models.ContractPublic: + statement = select(models.Contract).where(models.Contract.id == id) + result = session.exec(statement) + contract = result.first() + if not contract: + return None + result = models.ContractPublic.model_validate(contract) + session.delete(contract) + session.commit() + return result diff --git a/backend/src/contracts/templates/layout.html b/backend/src/contracts/templates/layout.html index fa1e30f..c9e5858 100644 --- a/backend/src/contracts/templates/layout.html +++ b/backend/src/contracts/templates/layout.html @@ -1,106 +1,111 @@ - + -
-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.
++ 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. +
| Type de contrat | -{{contract_type}} | -
|---|---|
| Saison du contrat | -{{contract_season}} | -
| Type de contrat | -{{contract_type}} | -
| Référent·e | -{{referer_name}} | -
| Email référent·e | -{{referer_email}} | -
| Le/La producteur·trice | -{{productor_name}} | -
| Adresse du producteur·trice | -{{productor_address}} | -
| {{payment_methods_map[method.name]}} | -{{method.details}} | -
| L’adhérent·e | -{{member_name}} | -
| Email de l’adhérent·e | -{{member_email}} | -
| Téléphone de l'adhérent·e | -{{member_phone}} | -
| Type de contrat | +{{contract_type}} | +
| Saison du contrat | +{{contract_season}} | +
| Type de contrat | +{{contract_type}} | +
| Référent·e | +{{referer_name}} | +
| Email référent·e | +{{referer_email}} | +
| Le/La producteur·trice | +{{productor_name}} | +
| Adresse du producteur·trice | +{{productor_address}} | +
| {{payment_methods_map[method.name]}} | +{{method.details}} | +
| L’adhérent·e | +{{member_name}} | +
| Email de l’adhérent·e | +{{member_email}} | +
| Téléphone de l'adhérent·e | +{{member_phone}} | +
- 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. + 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.
-- Le/La producteur·trice s’engage à fournir un panier {{contract_type}}, 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 {{contract_start_date}} et termine le {{contract_end_date}}. + Le/La producteur·trice s’engage à fournir un panier + {{contract_type}}, 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 {{contract_start_date}} et termine le + {{contract_end_date}}.
-- 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. + 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.
-- 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. + 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.
-| Nom du produit | -Prix (€) | -Prix (€/kg) | -Poids | -Quantité | -|||||
|---|---|---|---|---|---|---|---|---|---|
| {{rec.product.name}} | -{{rec.product.price if rec.product.price else ""}} | -{{rec.product.price_kg if rec.product.price_kg else ""}} | -{{rec.product.quantity if rec.product.quantity != None else ""}} {{rec.product.quantity_unit if rec.product.quantity_unit != None else ""}} | -{{rec.quantity}}{{"g" if rec.product.unit == "1" else "kg" if rec.product.unit == "2" else "p" }} | -|||||
| Nom du produit | +Prix (€) | +Prix (€/kg) | +Poids | +Quantité | +|||||
| {{rec.product.name}} | +{{rec.product.price if rec.product.price else ""}} | +{{rec.product.price_kg if rec.product.price_kg else ""}} | ++ {{rec.product.quantity if rec.product.quantity != None else ""}} + {{rec.product.quantity_unit if rec.product.quantity_unit != None + else ""}} + | ++ {{rec.quantity}}{{"g" if rec.product.unit == "1" else "kg" if + rec.product.unit == "2" else "p" }} + | +|||||
| Total | -{{recurrent_price}}€ | +Total | +{{recurrent_price}}€ | ||||||
| Nom du produit | -Prix (€) | -Prix (€/kg) | -Poids | -Quantité | -
|---|---|---|---|---|
| {{product.product.name}} | -{{product.product.price if product.product.price else ""}} | -{{product.product.price_kg if product.product.price_kg else ""}} | -{{product.product.quantity if product.product.quantity != None else ""}} {{product.product.quantity_unit if product.product.quantity_unit != None else ""}} | -{{product.quantity}}{{"g" if product.product.unit == "1" else "kg" if product.product.unit == "2" else "p" }} | -
| Total | -{{plan.price}}€ | -|||
| Nom du produit | +Prix (€) | +Prix (€/kg) | +Poids | +Quantité | +
| {{product.product.name}} | ++ {{product.product.price if product.product.price else ""}} + | ++ {{product.product.price_kg if product.product.price_kg else ""}} + | ++ {{product.product.quantity if product.product.quantity != None + else ""}} {{product.product.quantity_unit if + product.product.quantity_unit != None else ""}} + | ++ {{product.quantity}}{{"g" if product.product.unit == "1" else + "kg" if product.product.unit == "2" else "p" }} + | +
| Total | +{{plan.price}}€ | +|||
| Signature producteur-trice | -Signature adhérent-e | -
|---|---|
| - | - |
| Cheque n°{{cheque.name}} | + {% endfor %} +|
| {{cheque.value}}€ | + {% endfor %} +
| Signature producteur-trice | +Signature adhérent-e | +
|---|---|
| + | + |