[WIP] Download contract

This commit is contained in:
Julien Aldon
2026-02-16 17:49:15 +01:00
parent 5354a74cac
commit ab98ba81c8
10 changed files with 98 additions and 15 deletions

View File

@@ -10,15 +10,16 @@
## Wording ## Wording
- planned -> occasionnal (planifié -> occasionnel)
- all translations - all translations
## Tutorial for referers ## Draft / Publish form
- By default form is in draft mode
- Validate a form (button)
- check if productor
- check if shipments
- check products
- Publish
### Glossary
Occasional
Recurrent
## Footer ## Footer
@@ -32,8 +33,8 @@ Recurrent
- use alembic for migration management - use alembic for migration management
## Update contract after register ## Filter forms in home view
## Filter contracts in home view
## Only show current season (if multiple form, only show the one with latest start date) ## Only show current season (if multiple form, only show the one with latest start date)
## Update contract after register

View File

@@ -81,6 +81,7 @@ async def create_contract(
total_price = '{:10.2f}'.format(recurrent_price + compute_occasional_prices(occasionals)) total_price = '{:10.2f}'.format(recurrent_price + compute_occasional_prices(occasionals))
cheques = list(map(lambda x: {"name": x.name, "value": x.value}, new_contract.cheques)) cheques = list(map(lambda x: {"name": x.name, "value": x.value}, new_contract.cheques))
# TODO: send contract to referer # TODO: send contract to referer
try: try:
pdf_bytes = generate_html_contract( pdf_bytes = generate_html_contract(
new_contract, new_contract,
@@ -92,6 +93,7 @@ async def create_contract(
) )
pdf_file = io.BytesIO(pdf_bytes) pdf_file = io.BytesIO(pdf_bytes)
contract_id = f'{new_contract.firstname}_{new_contract.lastname}_{new_contract.form.productor.type}_{new_contract.form.season}' contract_id = f'{new_contract.firstname}_{new_contract.lastname}_{new_contract.form.productor.type}_{new_contract.form.season}'
service.add_contract_file(session, id, pdf_bytes)
except: except:
raise HTTPException(status_code=400, detail=PDFerrorOccured) raise HTTPException(status_code=400, detail=PDFerrorOccured)
return StreamingResponse( return StreamingResponse(
@@ -109,6 +111,23 @@ def get_contracts(
): ):
return service.get_all(session, forms) return service.get_all(session, forms)
@router.get('/{id}/file')
def get_contract_file(
id: int,
session: Session = Depends(get_session)
):
contract = service.get_one(session, id)
print(contract.file)
if contract is None:
raise HTTPException(status_code=404, detail=messages.notfound)
return StreamingResponse(
contract.file,
media_type='application/pdf',
headers={
'Content-Disposition': f'attachement; filename=contract_{contract.id}.pdf'
}
)
@router.get('/{id}', response_model=models.ContractPublic) @router.get('/{id}', response_model=models.ContractPublic)
def get_contract(id: int, session: Session = Depends(get_session)): def get_contract(id: int, session: Session = Depends(get_session)):
result = service.get_one(session, id) result = service.get_one(session, id)

View File

@@ -39,6 +39,17 @@ def create_one(session: Session, contract: models.ContractCreate) -> models.Cont
session.refresh(new_contract) session.refresh(new_contract)
return new_contract return new_contract
def add_contract_file(session: Session, id: int, file: bytes):
statement = select(models.Contract).where(models.Contract.id == id)
result = session.exec(statement)
contract = result.first()
contract.file = file
session.add(contract)
session.commit()
session.refresh(contract)
return contract
def update_one(session: Session, id: int, contract: models.ContractUpdate) -> models.ContractPublic: def update_one(session: Session, id: int, contract: models.ContractUpdate) -> models.ContractPublic:
statement = select(models.Contract).where(models.Contract.id == id) statement = select(models.Contract).where(models.Contract.id == id)
result = session.exec(statement) result = session.exec(statement)

View File

@@ -1,4 +1,4 @@
from sqlmodel import Field, SQLModel, Relationship from sqlmodel import Field, SQLModel, Relationship, Column, LargeBinary
from enum import StrEnum from enum import StrEnum
from typing import Optional from typing import Optional
import datetime import datetime
@@ -208,6 +208,7 @@ class Contract(ContractBase, table=True):
back_populates="contract", back_populates="contract",
cascade_delete=True cascade_delete=True
) )
file: bytes = Field(sa_column=Column(LargeBinary))
class ContractCreate(ContractBase): class ContractCreate(ContractBase):
products: list["ContractProductCreate"] = [] products: list["ContractProductCreate"] = []
@@ -215,12 +216,13 @@ class ContractCreate(ContractBase):
form_id: int form_id: int
class ContractUpdate(SQLModel): class ContractUpdate(SQLModel):
pass file: bytes
class ContractPublic(ContractBase): class ContractPublic(ContractBase):
id: int id: int
products: list["ContractProduct"] = [] products: list["ContractProduct"] = []
form: Form form: Form
# file: bytes
class ContractProductBase(SQLModel): class ContractProductBase(SQLModel):
product_id: int = Field( product_id: int = Field(

View File

@@ -72,6 +72,7 @@
"shipment products is necessary only for occasional products (if all products are recurrent leave empty)": "shipment products configuration is only necessary for occasional products (leave empty if all products are recurrent).", "shipment products is necessary only for occasional products (if all products are recurrent leave empty)": "shipment products configuration is only necessary for occasional products (leave empty if all products are recurrent).",
"recurrent product is for all shipments, occasional product is for a specific shipment (see shipment form)": "recurrent products are for all shipments, occasional products are for a specific shipment (see shipment form).", "recurrent product is for all shipments, occasional product is for a specific shipment (see shipment form)": "recurrent products are for all shipments, occasional products are for a specific shipment (see shipment form).",
"some contracts require a minimum value per shipment, ignore this field if it's not the case": "some contracts require a minimum value per shipment. Ignore this field if it does not apply to your contract.", "some contracts require a minimum value per shipment, ignore this field if it's not the case": "some contracts require a minimum value per shipment. Ignore this field if it does not apply to your contract.",
"contracts": "contracts",
"minimum price for this shipment should be at least": "minimum price for this shipment should be at least", "minimum price for this shipment should be at least": "minimum price for this shipment should be at least",
"there is": "there is", "there is": "there is",
"for this contract": "for this contract.", "for this contract": "for this contract.",
@@ -166,6 +167,7 @@
"of the productor": "of the producer", "of the productor": "of the producer",
"of the shipment": "of the shipment", "of the shipment": "of the shipment",
"of the contract": "of the contract", "of the contract": "of the contract",
"login with keycloak": "login with keycloak",
"there is no contract for now": "There is no contract at the moment.", "there is no contract for now": "There is no contract at the moment.",
"for transfer method contact your referer or productor": "for bank transfer, contact your referent or producer.", "for transfer method contact your referer or productor": "for bank transfer, contact your referent or producer.",
"cheque quantity": "number of cheques", "cheque quantity": "number of cheques",

View File

@@ -72,6 +72,7 @@
"shipment products is necessary only for occasional products (if all products are recurrent leave empty)": "il est nécessaire de configurer les produits pour la livraison uniquement si il y a des produits occasionnels (laisser vide si tous les produits sont récurents).", "shipment products is necessary only for occasional products (if all products are recurrent leave empty)": "il est nécessaire de configurer les produits pour la livraison uniquement si il y a des produits occasionnels (laisser vide si tous les produits sont récurents).",
"recurrent product is for all shipments, occasional product is for a specific shipment (see shipment form)": "les produits récurrents sont pour toutes les livraisons, les produits occasionnels sont pour une livraison particulière (voir formulaire de création de livraison).", "recurrent product is for all shipments, occasional product is for a specific shipment (see shipment form)": "les produits récurrents sont pour toutes les livraisons, les produits occasionnels sont pour une livraison particulière (voir formulaire de création de livraison).",
"some contracts require a minimum value per shipment, ignore this field if it's not the case": "certains contrats nécessitent une valeur minimum par livraison. Ce champ peut être ignoré sil ne sapplique pas à votre contrat.", "some contracts require a minimum value per shipment, ignore this field if it's not the case": "certains contrats nécessitent une valeur minimum par livraison. Ce champ peut être ignoré sil ne sapplique pas à votre contrat.",
"contracts": "contrats",
"minimum price for this shipment should be at least": "le prix minimum d'une livraison doit être au moins de", "minimum price for this shipment should be at least": "le prix minimum d'une livraison doit être au moins de",
"there is": "il y a", "there is": "il y a",
"for this contract": "pour ce contrat.", "for this contract": "pour ce contrat.",
@@ -166,6 +167,7 @@
"of the productor": "du producteur·trice", "of the productor": "du producteur·trice",
"of the shipment": "de la livraison", "of the shipment": "de la livraison",
"of the contract": "du contrat", "of the contract": "du contrat",
"login with keycloak": "se connecter avec keycloak",
"there is no contract for now": "Il n'y a pas de contrats pour le moment.", "there is no contract for now": "Il n'y a pas de contrats pour le moment.",
"for transfer method contact your referer or productor": "pour mettre en place le virement automatique, contactez votre référent ou le producteur.", "for transfer method contact your referer or productor": "pour mettre en place le virement automatique, contactez votre référent ou le producteur.",
"cheque quantity": "quantité de chèques (pour le paiement en plusieurs fois)", "cheque quantity": "quantité de chèques (pour le paiement en plusieurs fois)",

View File

@@ -1,8 +1,9 @@
import { ActionIcon, Table, Tooltip } from "@mantine/core"; import { ActionIcon, Table, Tooltip } from "@mantine/core";
import { type Contract } from "@/services/resources/contracts"; import { type Contract } from "@/services/resources/contracts";
import { IconX } from "@tabler/icons-react"; import { IconDownload, IconX } from "@tabler/icons-react";
import { t } from "@/config/i18n"; import { t } from "@/config/i18n";
import { useDeleteContract } from "@/services/api"; import { useDeleteContract, useGetContractFile } from "@/services/api";
import { useCallback } from "react";
export type ContractRowProps = { export type ContractRowProps = {
contract: Contract; contract: Contract;
@@ -12,7 +13,19 @@ export default function ContractRow({ contract }: ContractRowProps) {
// const [searchParams] = useSearchParams(); // const [searchParams] = useSearchParams();
const deleteMutation = useDeleteContract(); const deleteMutation = useDeleteContract();
// const navigate = useNavigate(); // const navigate = useNavigate();
const {refetch, isFetching} = useGetContractFile(contract.id)
const handleDownload = useCallback(async () => {
const { data } = await refetch();
if (!data)
return;
const url = URL.createObjectURL(data.blob);
const link = document.createElement("a");
link.href = url;
link.download = data.filename;
link.click();
URL.revokeObjectURL(url);
}, [useGetContractFile])
return ( return (
<Table.Tr key={contract.id}> <Table.Tr key={contract.id}>
<Table.Td> <Table.Td>
@@ -37,6 +50,20 @@ export default function ContractRow({ contract }: ContractRowProps) {
<IconEdit /> <IconEdit />
</ActionIcon> </ActionIcon>
</Tooltip> */} </Tooltip> */}
<Tooltip
label={t("download contract")}
>
<ActionIcon
size="sm"
mr="5"
onClick={(e) => {
e.stopPropagation();
handleDownload()
}}
>
<IconDownload/>
</ActionIcon>
</Tooltip>
<Tooltip label={t("remove contract", { capfirst: true })}> <Tooltip label={t("remove contract", { capfirst: true })}>
<ActionIcon <ActionIcon
color="red" color="red"

View File

@@ -21,7 +21,7 @@ export function Navbar() {
</Group> </Group>
<NavLink <NavLink
className={"navLink"} className={"navLink"}
aria-label={t("login with keycloak")} aria-label={t("login with keycloak", { capfirst: true })}
to={`${Config.backend_uri}/auth/login`} to={`${Config.backend_uri}/auth/login`}
> >
{t("login with keycloak", { capfirst: true })} {t("login with keycloak", { capfirst: true })}

View File

@@ -14,7 +14,7 @@ export default function Dashboard() {
defaultValue={"help"} defaultValue={"help"}
onChange={(value) => navigate(`/dashboard/${value}`)} onChange={(value) => navigate(`/dashboard/${value}`)}
> >
<Tabs.List> <Tabs.List mb="md">
<Tabs.Tab value="help">{t("help", { capfirst: true })}</Tabs.Tab> <Tabs.Tab value="help">{t("help", { capfirst: true })}</Tabs.Tab>
<Tabs.Tab value="productors">{t("productors", { capfirst: true })}</Tabs.Tab> <Tabs.Tab value="productors">{t("productors", { capfirst: true })}</Tabs.Tab>
<Tabs.Tab value="products">{t("products", { capfirst: true })}</Tabs.Tab> <Tabs.Tab value="products">{t("products", { capfirst: true })}</Tabs.Tab>

View File

@@ -586,6 +586,25 @@ export function useGetContract(
}); });
} }
export function useGetContractFile(
id?: number,
) {
return useQuery({
queryKey: ["contract"],
queryFn: () => fetch(`${Config.backend_uri}/contracts/${id}/file`)
.then(async (res) => {
const blob = await res.blob();
const disposition = res.headers.get("Content-Disposition");
const filename = disposition && disposition?.includes("filename=") ?
disposition.split("filname=")[1].replace(/"/g, "") :
`contract_${id}.pdf`
return {blob, filename};
}),
enabled: !!id,
});
}
export function useCreateContract() { export function useCreateContract() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();