[WIP] Download contract
This commit is contained in:
19
README.md
19
README.md
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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é s’il ne s’applique 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é s’il ne s’applique 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)",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user