From b0e2f3abd400451f3bde014da2097dec02061f3d Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 27 Jul 2026 13:01:58 +0200 Subject: [PATCH 01/31] Added single letter storage --- src/OpenPostbud/database/document_storage.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/OpenPostbud/database/document_storage.py b/src/OpenPostbud/database/document_storage.py index b97aa06..8ebb33f 100644 --- a/src/OpenPostbud/database/document_storage.py +++ b/src/OpenPostbud/database/document_storage.py @@ -10,6 +10,7 @@ STORAGE_FOLDER = Path("OpenPostbud_document_storage") SHIPMENTS_FOLDER = STORAGE_FOLDER / "Shipments" +SINGLE_LETTERS_FOLDER = STORAGE_FOLDER / "Single_letters" LETTER_SUFFIX = ".pdf" # Supported file types per the SF1601 documentation @@ -40,9 +41,11 @@ class Attachment: mime_type: str | None = None -def _get_shipment_folder(shipment_id: str) -> Path: +def _get_shipment_folder(shipment_id: str | None) -> Path: """Get the folder associated with the given shipment id.""" - return SHIPMENTS_FOLDER / shipment_id + if shipment_id: + return SHIPMENTS_FOLDER / shipment_id + return SINGLE_LETTERS_FOLDER def delete_shipment_docs(shipment_id: str): @@ -52,7 +55,7 @@ def delete_shipment_docs(shipment_id: str): shutil.rmtree(folder_path) -def _get_letter_path(shipment_id: str, letter_id: str) -> Path: +def _get_letter_path(shipment_id: str | None, letter_id: str) -> Path: """Get the path to the letter's doc file.""" folder_path = _get_shipment_folder(shipment_id) return (folder_path / letter_id).with_suffix(LETTER_SUFFIX) @@ -67,7 +70,7 @@ def save_letter_doc(shipment_id: str, letter_id: str, doc_bytes: bytes): letter_path.write_bytes(doc_bytes) -def get_letter_doc(shipment_id: str, letter_id: str) -> bytes | None: +def get_letter_doc(shipment_id: str | None, letter_id: str) -> bytes | None: """Get a letter's document from the document storage if it exists. """ From f45b7b5f09cb8a9076e2bde76429ba6d50a402f7 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 27 Jul 2026 13:03:16 +0200 Subject: [PATCH 02/31] Allow null shipment in letters and added post type --- .../database/digital_post/letters.py | 376 +++++++++--------- ...003_letter_post_type_nullable_shipment.sql | 26 ++ 2 files changed, 217 insertions(+), 185 deletions(-) create mode 100644 src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql diff --git a/src/OpenPostbud/database/digital_post/letters.py b/src/OpenPostbud/database/digital_post/letters.py index b5f74f7..02e8d34 100644 --- a/src/OpenPostbud/database/digital_post/letters.py +++ b/src/OpenPostbud/database/digital_post/letters.py @@ -1,185 +1,191 @@ -"""This module contains the Letter ORM class.""" - -from __future__ import annotations - -from datetime import datetime -import json -from enum import Enum -import re - -from sqlalchemy import ForeignKey, insert, select, String, update -from sqlalchemy.orm import Mapped, mapped_column - -from OpenPostbud.database.base import Base -from OpenPostbud.database import connection -from OpenPostbud.database.data_types.encrypted_string import EncryptedString -from OpenPostbud.database.data_types.id_generator import create_id -from OpenPostbud.database.common import ShipmentStatus, PostType -from OpenPostbud.database.digital_post import templates -from OpenPostbud.database import document_storage -from OpenPostbud.utils import docx_util - - -class MemoFields(Enum): - """An enum class defining the special fields used for - Memo functionality. - a MemoField has the following members: - key: The name of the field when loaded from merge data. - mandatory_digital: Whether the field is mandatory when sending Digital Post. - mandatory_physical: Whether the field is mandatory when sending Fysisk Post. - pattern: The regex pattern for the field's value. - """ - def __init__(self, key: str, mandatory_digital: bool, mandatory_physical: bool, pattern: str): - self.key = key - self.mandatory_digital = mandatory_digital - self.mandatory_physical = mandatory_physical - self.pattern = re.compile(pattern) - - MEMO_MODTAGER = ("Memo Modtager", True, True, r"\d{10}|\d{8}") - MEMO_LABEL = ("Memo Label", True, False, r"\S.*") - - def is_mandatory_for(self, post_type: PostType) -> bool: - """Whether this field is mandatory for the given post type. - - AUTO requires the field if it is mandatory for either route, so the - letter can be sent successfully whichever route a recipient takes. - """ - if post_type == PostType.DIGITAL: - return self.mandatory_digital - if post_type == PostType.PHYSICAL: - return self.mandatory_physical - return self.mandatory_digital or self.mandatory_physical - - -class Letter(Base): - """An ORM class representing a letter.""" - __tablename__ = "Letters" - - id: Mapped[str] = mapped_column(String(12), primary_key=True, default=create_id("L-", 10)) - shipment_id: Mapped[str] = mapped_column(ForeignKey("Shipments.id", ondelete="CASCADE")) - recipient_id: Mapped[str] = mapped_column(EncryptedString()) - updated_at: Mapped[datetime] = mapped_column(default=datetime.now) - status: Mapped[ShipmentStatus] = mapped_column(default=ShipmentStatus.WAITING) - message: Mapped[str] = mapped_column(String(100), nullable=True) - field_data: Mapped[str] = mapped_column(EncryptedString()) - transaction_id: Mapped[str] = mapped_column(nullable=True) - sent_as: Mapped[PostType] = mapped_column(nullable=True) - - def to_row_dict(self) -> dict[str, str]: - """Convert to a dictionary to be shown in a table.""" - return { - "id": str(self.id), - "recipient": self.recipient_id, - "updated_at": self.updated_at.strftime("%d/%m/%Y %H:%M:%S"), - "status": self.status.value, - "message": self.message, - "sent_as": self.sent_as.value if self.sent_as else "" - } - - def merge_letter(self) -> bytes: - """Merge the letter's merge field data with its template - and convert to pdf. - - Returns: - The merged pdf letter as bytes. - """ - stored_file = document_storage.get_letter_doc(self.shipment_id, self.id) - if stored_file: - return stored_file - - template = templates.get_template_by_shipment(self.shipment_id) - - if template.file_name.endswith(".docx"): - field_data = json.loads(self.field_data) - word_file = docx_util.merge_word_file(template.file_data, field_data) - pdf_file = docx_util.convert_word_to_pdf(word_file) - document_storage.save_letter_doc(self.shipment_id, self.id, pdf_file) - return pdf_file - - return template.file_data - - def set_status(self, status: ShipmentStatus, transaction_id: str | None = None, message: str | None = None, sent_as: PostType | None = None): - """Set the status of the letter in the database. - The transaction id and sent_as are not overwritten if the given value is None. - - Args: - status: The status to set on the letter. - transaction_id: The transaction id from Digital Post. Defaults to None. - message: The message to set on the letter. Defaults to None. - sent_as: The post type the letter was actually sent as. Defaults to None. - """ - values = {} - values["status"] = status - values["updated_at"] = datetime.now() - values["message"] = message - if transaction_id: - values["transaction_id"] = transaction_id - if sent_as: - values["sent_as"] = sent_as - - with connection.get_session() as session: - q = ( - update(Letter) - .where(Letter.id == self.id) - .values(values) - ) - session.execute(q) - session.commit() - - -def add_letters(shipment_id: str, csv_data: list[dict[str, str]]): - """Add multiple new letters to the database based - on a csv file containing letter merge data. - - Args: - shipment_id: The id of the shipment the letters belong to. - csv_data: A list of dictionaries containing merge data. - """ - letter_dicts = [] - - for line in csv_data: - recipient = line[MemoFields.MEMO_MODTAGER.key] - del line[MemoFields.MEMO_MODTAGER.key] - letter_dicts.append( - { - "shipment_id": shipment_id, - "recipient_id": recipient, - "field_data": json.dumps(line) - } - ) - - with connection.get_session() as session: - session.execute(insert(Letter), letter_dicts) - session.commit() - - -def get_letters(shipment_id: str) -> tuple[Letter]: - """Get all letters belonging to a shipment.""" - with connection.get_session() as session: - query = select(Letter).where(Letter.shipment_id == shipment_id) - result = session.execute(query).scalars() - return tuple(result) - - -def abort_letters(shipment_id: str, user: str): - """Set all waiting letters in the given shipment to - aborted. Also add a message about who aborted. - - Args: - shipment_id: The id of the shipment. - user: The name of the user who aborted the shipment. - """ - with connection.get_session() as session: - query = ( - update(Letter) - .values( - status=ShipmentStatus.ABORTED, - message=f"Afbrudt af {user}" - ) - .where( - Letter.shipment_id == shipment_id, - Letter.status == ShipmentStatus.WAITING - ) - ) - session.execute(query) - session.commit() +"""This module contains the Letter ORM class.""" + +from __future__ import annotations + +from datetime import datetime +import json +from enum import Enum +import re + +from sqlalchemy import ForeignKey, insert, select, String, update +from sqlalchemy.orm import Mapped, mapped_column + +from OpenPostbud.database.base import Base +from OpenPostbud.database import connection +from OpenPostbud.database.data_types.encrypted_string import EncryptedString +from OpenPostbud.database.data_types.id_generator import create_id +from OpenPostbud.database.common import ShipmentStatus, PostType +from OpenPostbud.database.digital_post import templates +from OpenPostbud.database import document_storage +from OpenPostbud.utils import docx_util + + +class MemoFields(Enum): + """An enum class defining the special fields used for + Memo functionality. + a MemoField has the following members: + key: The name of the field when loaded from merge data. + mandatory_digital: Whether the field is mandatory when sending Digital Post. + mandatory_physical: Whether the field is mandatory when sending Fysisk Post. + pattern: The regex pattern for the field's value. + """ + def __init__(self, key: str, mandatory_digital: bool, mandatory_physical: bool, pattern: str): + self.key = key + self.mandatory_digital = mandatory_digital + self.mandatory_physical = mandatory_physical + self.pattern = re.compile(pattern) + + MEMO_MODTAGER = ("Memo Modtager", True, True, r"\d{10}|\d{8}") + MEMO_LABEL = ("Memo Label", True, False, r"\S.*") + + def is_mandatory_for(self, post_type: PostType) -> bool: + """Whether this field is mandatory for the given post type. + + AUTO requires the field if it is mandatory for either route, so the + letter can be sent successfully whichever route a recipient takes. + """ + if post_type == PostType.DIGITAL: + return self.mandatory_digital + if post_type == PostType.PHYSICAL: + return self.mandatory_physical + return self.mandatory_digital or self.mandatory_physical + + +LETTER_ID_FACTORY = create_id("L-", 10) + + +class Letter(Base): + """An ORM class representing a letter.""" + __tablename__ = "Letters" + + id: Mapped[str] = mapped_column(String(12), primary_key=True, default=LETTER_ID_FACTORY) + shipment_id: Mapped[str] = mapped_column(ForeignKey("Shipments.id", ondelete="CASCADE"), nullable=True) + recipient_id: Mapped[str] = mapped_column(EncryptedString()) + updated_at: Mapped[datetime] = mapped_column(default=datetime.now) + status: Mapped[ShipmentStatus] = mapped_column(default=ShipmentStatus.WAITING) + message: Mapped[str] = mapped_column(String(100), nullable=True) + field_data: Mapped[str] = mapped_column(EncryptedString()) + transaction_id: Mapped[str] = mapped_column(nullable=True) + post_type: Mapped[PostType] = mapped_column(default=PostType.DIGITAL) + sent_as: Mapped[PostType] = mapped_column(nullable=True) + + def to_row_dict(self) -> dict[str, str]: + """Convert to a dictionary to be shown in a table.""" + return { + "id": str(self.id), + "recipient": self.recipient_id, + "updated_at": self.updated_at.strftime("%d/%m/%Y %H:%M:%S"), + "status": self.status.value, + "message": self.message, + "sent_as": self.sent_as.value if self.sent_as else "" + } + + def merge_letter(self) -> bytes: + """Merge the letter's merge field data with its template + and convert to pdf. + + Returns: + The merged pdf letter as bytes. + """ + stored_file = document_storage.get_letter_doc(self.shipment_id, self.id) + if stored_file: + return stored_file + + template = templates.get_template_by_shipment(self.shipment_id) + + if template.file_name.endswith(".docx"): + field_data = json.loads(self.field_data) + word_file = docx_util.merge_word_file(template.file_data, field_data) + pdf_file = docx_util.convert_word_to_pdf(word_file) + document_storage.save_letter_doc(self.shipment_id, self.id, pdf_file) + return pdf_file + + return template.file_data + + def set_status(self, status: ShipmentStatus, transaction_id: str | None = None, message: str | None = None, sent_as: PostType | None = None): + """Set the status of the letter in the database. + The transaction id and sent_as are not overwritten if the given value is None. + + Args: + status: The status to set on the letter. + transaction_id: The transaction id from Digital Post. Defaults to None. + message: The message to set on the letter. Defaults to None. + sent_as: The post type the letter was actually sent as. Defaults to None. + """ + values = {} + values["status"] = status + values["updated_at"] = datetime.now() + values["message"] = message + if transaction_id: + values["transaction_id"] = transaction_id + if sent_as: + values["sent_as"] = sent_as + + with connection.get_session() as session: + q = ( + update(Letter) + .where(Letter.id == self.id) + .values(values) + ) + session.execute(q) + session.commit() + + +def add_letters(shipment_id: str, csv_data: list[dict[str, str]], post_type: PostType = PostType.DIGITAL): + """Add multiple new letters to the database based + on a csv file containing letter merge data. + + Args: + shipment_id: The id of the shipment the letters belong to. + csv_data: A list of dictionaries containing merge data. + post_type: How the letters should be sent. + """ + letter_dicts = [] + + for line in csv_data: + recipient = line[MemoFields.MEMO_MODTAGER.key] + del line[MemoFields.MEMO_MODTAGER.key] + letter_dicts.append( + { + "shipment_id": shipment_id, + "recipient_id": recipient, + "field_data": json.dumps(line), + "post_type": post_type + } + ) + + with connection.get_session() as session: + session.execute(insert(Letter), letter_dicts) + session.commit() + + +def get_letters(shipment_id: str) -> tuple[Letter]: + """Get all letters belonging to a shipment.""" + with connection.get_session() as session: + query = select(Letter).where(Letter.shipment_id == shipment_id) + result = session.execute(query).scalars() + return tuple(result) + + +def abort_letters(shipment_id: str, user: str): + """Set all waiting letters in the given shipment to + aborted. Also add a message about who aborted. + + Args: + shipment_id: The id of the shipment. + user: The name of the user who aborted the shipment. + """ + with connection.get_session() as session: + query = ( + update(Letter) + .values( + status=ShipmentStatus.ABORTED, + message=f"Afbrudt af {user}" + ) + .where( + Letter.shipment_id == shipment_id, + Letter.status == ShipmentStatus.WAITING + ) + ) + session.execute(query) + session.commit() diff --git a/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql b/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql new file mode 100644 index 0000000..b8a3d9c --- /dev/null +++ b/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql @@ -0,0 +1,26 @@ +CREATE TABLE "Letters_new" ( + id VARCHAR(12) NOT NULL, + shipment_id VARCHAR(12), + recipient_id BINARY NOT NULL, + updated_at DATETIME NOT NULL, + status VARCHAR(9) NOT NULL, + message VARCHAR(100), + field_data BINARY NOT NULL, + transaction_id VARCHAR, + sent_as VARCHAR(8), + post_type VARCHAR(8) NOT NULL DEFAULT 'DIGITAL', + PRIMARY KEY (id), + FOREIGN KEY(shipment_id) REFERENCES "Shipments" (id) ON DELETE CASCADE +) + + +INSERT INTO "Letters_new" (id, shipment_id, recipient_id, updated_at, status, message, field_data, transaction_id, sent_as, post_type) +SELECT id, shipment_id, recipient_id, updated_at, status, message, field_data, transaction_id, sent_as, + COALESCE((SELECT post_type FROM "Shipments" WHERE "Shipments".id = "Letters".shipment_id), 'DIGITAL') +FROM "Letters" + + +DROP TABLE "Letters" + + +ALTER TABLE "Letters_new" RENAME TO "Letters" From 34d3d1095fc6bd984e0159a9adc643d442d55f91 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 27 Jul 2026 13:03:25 +0200 Subject: [PATCH 03/31] Docstring fix --- .../database/digital_post/shipments.py | 200 +++++++++--------- 1 file changed, 100 insertions(+), 100 deletions(-) diff --git a/src/OpenPostbud/database/digital_post/shipments.py b/src/OpenPostbud/database/digital_post/shipments.py index d70277c..da52a73 100644 --- a/src/OpenPostbud/database/digital_post/shipments.py +++ b/src/OpenPostbud/database/digital_post/shipments.py @@ -1,100 +1,100 @@ -"""This module is contains for the Shipment ORM class.""" - -from datetime import datetime, timedelta -import logging - -from sqlalchemy import String, ForeignKey, select -from sqlalchemy.orm import Mapped, mapped_column - -from OpenPostbud import config -from OpenPostbud.database.base import Base -from OpenPostbud.database import connection -from OpenPostbud.database.common import PostType -from OpenPostbud.database.data_types.id_generator import create_id -from OpenPostbud.database import document_storage - - -class Shipment(Base): - """An ORM class representing a Shipment.""" - __tablename__ = "Shipments" - - id: Mapped[str] = mapped_column(String(12), primary_key=True, default=create_id("S-", 10)) - name: Mapped[str] = mapped_column(String(50)) - description: Mapped[str] = mapped_column(String(200)) - template_id: Mapped[int] = mapped_column(ForeignKey("Templates.id")) - created_at: Mapped[datetime] = mapped_column(default=datetime.now) - created_by: Mapped[str] = mapped_column(String(50)) - post_type: Mapped[PostType] = mapped_column(default=PostType.DIGITAL) - - def to_row_dict(self): - """Convert to a dictionary to be shown in a table.""" - return { - "id": str(self.id), - "name": self.name, - "post_type": self.post_type.value, - "created_at": self.created_at.strftime("%d/%m/%Y %H:%M:%S"), - "created_by": self.created_by, - } - - def get_deletion_date(self) -> datetime: - """Get the deletion date of the shipment.""" - return self.created_at + timedelta(days=config.SHIPMENT_LIFETIME_DAYS) - - -def add_shipment(name: str, description: str, created_by: str, template_id: int, post_type: PostType = PostType.DIGITAL) -> int: - """Add a new Shipment to the database. - - Args: - name: The name of the shipment. - description: The description of the shipment. - created_by: The name of the user who created the shipment. - template_id: The id of the template connected to the shipment. - post_type: How the shipment should be sent. Defaults to Digital Post. - - Returns: - The id of the new shipment. - """ - shipment = Shipment( - name=name, - description=description, - template_id=template_id, - created_by=created_by, - post_type=post_type - ) - - with connection.get_session() as session: - session.add(shipment) - session.commit() - return shipment.id - - -def get_shipments() -> tuple[Shipment]: - """Get all shipments from the database.""" - with connection.get_session() as session: - result = session.execute(select(Shipment).order_by(Shipment.created_at.desc())).scalars() - return tuple(result) - - -def get_shipment(shipment_id: str) -> Shipment | None: - """Get a single shipment from the database.""" - with connection.get_session() as session: - return session.get(Shipment, shipment_id) - - -def delete_old_shipments(): - """Delete shipments that are older than SHIPMENT_LIFETIME_DAYS. - Letters are also deleted by database cascade. - """ - logging.info("Cleaning up old shipments.") - - with connection.get_session() as session: - query = select(Shipment).where((datetime.today() - timedelta(days=config.SHIPMENT_LIFETIME_DAYS)) > Shipment.created_at) - shipments = list(session.execute(query).scalars()) - - for shipment in shipments: - document_storage.delete_shipment_docs(shipment.id) - session.delete(shipment) - - session.commit() - - logging.info(f"Deleted {len(shipments)} old shipments.") +"""This module is contains for the Shipment ORM class.""" + +from datetime import datetime, timedelta +import logging + +from sqlalchemy import String, ForeignKey, select +from sqlalchemy.orm import Mapped, mapped_column + +from OpenPostbud import config +from OpenPostbud.database.base import Base +from OpenPostbud.database import connection +from OpenPostbud.database.common import PostType +from OpenPostbud.database.data_types.id_generator import create_id +from OpenPostbud.database import document_storage + + +class Shipment(Base): + """An ORM class representing a Shipment.""" + __tablename__ = "Shipments" + + id: Mapped[str] = mapped_column(String(12), primary_key=True, default=create_id("S-", 10)) + name: Mapped[str] = mapped_column(String(50)) + description: Mapped[str] = mapped_column(String(200)) + template_id: Mapped[int] = mapped_column(ForeignKey("Templates.id")) + created_at: Mapped[datetime] = mapped_column(default=datetime.now) + created_by: Mapped[str] = mapped_column(String(50)) + post_type: Mapped[PostType] = mapped_column(default=PostType.DIGITAL) + + def to_row_dict(self): + """Convert to a dictionary to be shown in a table.""" + return { + "id": str(self.id), + "name": self.name, + "post_type": self.post_type.value, + "created_at": self.created_at.strftime("%d/%m/%Y %H:%M:%S"), + "created_by": self.created_by, + } + + def get_deletion_date(self) -> datetime: + """Get the deletion date of the shipment.""" + return self.created_at + timedelta(days=config.SHIPMENT_LIFETIME_DAYS) + + +def add_shipment(name: str, description: str, created_by: str, template_id: int, post_type: PostType = PostType.DIGITAL) -> str: + """Add a new Shipment to the database. + + Args: + name: The name of the shipment. + description: The description of the shipment. + created_by: The name of the user who created the shipment. + template_id: The id of the template connected to the shipment. + post_type: How the shipment should be sent. Defaults to Digital Post. + + Returns: + The id of the new shipment. + """ + shipment = Shipment( + name=name, + description=description, + template_id=template_id, + created_by=created_by, + post_type=post_type + ) + + with connection.get_session() as session: + session.add(shipment) + session.commit() + return shipment.id + + +def get_shipments() -> tuple[Shipment]: + """Get all shipments from the database.""" + with connection.get_session() as session: + result = session.execute(select(Shipment).order_by(Shipment.created_at.desc())).scalars() + return tuple(result) + + +def get_shipment(shipment_id: str) -> Shipment | None: + """Get a single shipment from the database.""" + with connection.get_session() as session: + return session.get(Shipment, shipment_id) + + +def delete_old_shipments(): + """Delete shipments that are older than SHIPMENT_LIFETIME_DAYS. + Letters are also deleted by database cascade. + """ + logging.info("Cleaning up old shipments.") + + with connection.get_session() as session: + query = select(Shipment).where((datetime.today() - timedelta(days=config.SHIPMENT_LIFETIME_DAYS)) > Shipment.created_at) + shipments = list(session.execute(query).scalars()) + + for shipment in shipments: + document_storage.delete_shipment_docs(shipment.id) + session.delete(shipment) + + session.commit() + + logging.info(f"Deleted {len(shipments)} old shipments.") From 493de39dfb4c2eec0ee909b26324266f8ed230c3 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 27 Jul 2026 13:04:03 +0200 Subject: [PATCH 04/31] Added send_letter endpoint and moved get_letter --- src/OpenPostbud/routes/api/letters.py | 88 ++++++++++ src/OpenPostbud/routes/api/router.py | 3 +- src/OpenPostbud/routes/api/shipments.py | 209 ++++++++++-------------- 3 files changed, 179 insertions(+), 121 deletions(-) create mode 100644 src/OpenPostbud/routes/api/letters.py diff --git a/src/OpenPostbud/routes/api/letters.py b/src/OpenPostbud/routes/api/letters.py new file mode 100644 index 0000000..d406095 --- /dev/null +++ b/src/OpenPostbud/routes/api/letters.py @@ -0,0 +1,88 @@ +"""This module defines routes for the shipments api.""" + +from datetime import datetime +import base64 +import json + +from fastapi import APIRouter, status +from fastapi.exceptions import HTTPException +from pydantic import BaseModel, Field + +from OpenPostbud.database import connection, document_storage +from OpenPostbud.database.common import PostType +from OpenPostbud.database.digital_post import letters +from OpenPostbud.database import connection + + +router = APIRouter() + + +class LetterDetail(BaseModel): + """A pydantic model representing a letter response.""" + id: str + shipment_id: str | None + recipient_id: str + status: str + status_time: datetime + sent_as: PostType | None + letter_pdf: str | None = Field(description="Base64-encoded file contents.") + + +class SendLetterModel(BaseModel): + """A pydantic model representing a shipment response.""" + recipient_id: str + memo_label: str | None + post_type: PostType + letter_document: str = Field(description="Base64-encoded file contents.") + + +class SendLetterResponse(BaseModel): + id: str + + +@router.post("/send_letter", tags=["Letters"]) +def send_letter(letter: SendLetterModel) -> SendLetterResponse: + """Send a single letter without a shipment.""" + + new_letter = letters.Letter( + id=letters.LETTER_ID_FACTORY(), + shipment_id=None, + recipient_id=letter.recipient_id, + field_data=json.dumps({letters.MemoFields.MEMO_LABEL.key: letter.memo_label}), + post_type=letter.post_type + ) + + document_storage.save_letter_doc(None, new_letter.id, base64.b64decode(letter.letter_document)) + + with connection.get_session() as session: + session.add(new_letter) + session.commit() + + return SendLetterResponse(id=new_letter.id) + + +@router.get("/letter/{letter_id}", tags=["Letters"]) +def get_letter(letter_id: str, get_pdf: bool = True) -> LetterDetail: + """Get a letter by id. Merges and returns the final letter as a pdf + in base 64.""" + with connection.get_session() as session: + letter = session.get(letters.Letter, letter_id) + + if not letter: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "No letter exists with the given id") + + if get_pdf: + pdf = letter.merge_letter() + pdf_64 = base64.b64encode(pdf).decode() + else: + pdf_64 = None + + return LetterDetail( + id=letter.id, + shipment_id=letter.shipment_id, + recipient_id=letter.recipient_id, + status=letter.status, + status_time=letter.updated_at, + letter_pdf=pdf_64, + sent_as=letter.sent_as + ) diff --git a/src/OpenPostbud/routes/api/router.py b/src/OpenPostbud/routes/api/router.py index a0bc59f..e085ebc 100644 --- a/src/OpenPostbud/routes/api/router.py +++ b/src/OpenPostbud/routes/api/router.py @@ -8,7 +8,7 @@ import jwt -from OpenPostbud.routes.api import shipments +from OpenPostbud.routes.api import shipments, letters from OpenPostbud import config @@ -29,6 +29,7 @@ def check_bearer_token(credentials: HTTPAuthorizationCredentials = Depends(secur # Router object for all api routes router = APIRouter(prefix="/api", dependencies=[Security(check_bearer_token)]) router.include_router(shipments.router) +router.include_router(letters.router) @router.get("/hello", tags=["Test"], description="Used to test correct connection to the api.") diff --git a/src/OpenPostbud/routes/api/shipments.py b/src/OpenPostbud/routes/api/shipments.py index 7b522c8..d3727c5 100644 --- a/src/OpenPostbud/routes/api/shipments.py +++ b/src/OpenPostbud/routes/api/shipments.py @@ -1,120 +1,89 @@ -"""This module defines routes for the shipments api.""" - -from datetime import datetime -import base64 - -from fastapi import APIRouter, status -from fastapi.exceptions import HTTPException -from pydantic import BaseModel, Field - -from OpenPostbud.database import connection, document_storage -from OpenPostbud.database.digital_post import shipments as shipments_db -from OpenPostbud.database.digital_post import letters as letters_db - - -router = APIRouter() - - -class ShipmentModel(BaseModel): - """A pydantic model representing a shipment response.""" - id: str - name: str - created_at: datetime - created_by: str - - -class ShipmentDetail(ShipmentModel): - """A pydantic model representing a shipment response - including a list of letter ids and description. - """ - description: str - letter_ids: list[str] - has_attachments: bool - - -class LetterDetail(BaseModel): - """A pydantic model representing a letter response.""" - id: str - shipment_id: str - recipient_id: str - status: str - letter_pdf: str = Field(description="Base64-encoded file contents.") - - -class AttachmentModel(BaseModel): - """A pydantic model representing an attachment response.""" - file_name: str - file_data: str = Field(description="Base64-encoded file contents.") - - -@router.get("/shipments", tags=["Shipments"]) -def get_shipments() -> list[ShipmentModel]: - """Get all shipments and return as a list.""" - shipments = shipments_db.get_shipments() - - return [ - ShipmentModel( - id=s.id, - name=s.name, - created_at=s.created_at, - created_by=s.created_by - ) - for s in shipments - ] - - -@router.get("/shipment/{shipment_id}", tags=["Shipments"]) -def get_shipment(shipment_id: str) -> ShipmentDetail: - """Get a shipment by id.""" - - shipment = shipments_db.get_shipment(shipment_id) - - if not shipment: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "No shipment exists with the given id") - - letters = letters_db.get_letters(shipment.id) - letter_ids = [letter.id for letter in letters] - - return ShipmentDetail( - id=shipment.id, - name=shipment.name, - description=shipment.description, - created_at=shipment.created_at, - created_by=shipment.created_by, - letter_ids=letter_ids, - has_attachments=len(document_storage.list_attachments(shipment_id)) > 0 - ) - - -@router.get("/shipment/{shipment_id}/attachments", tags=["Shipments"]) -def get_attachments(shipment_id: str) -> list[AttachmentModel]: - """Get all attachments for the given shipment.""" - shipment = shipments_db.get_shipment(shipment_id) - - if not shipment: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "No shipment exists with the given id") - - attachments = document_storage.get_attachments(shipment_id) - return [AttachmentModel(file_name=a.name, file_data=base64.b64encode(a.data).decode()) for a in attachments] - - -@router.get("/letter/{letter_id}", tags=["Letters"]) -def get_letter(letter_id: str) -> LetterDetail: - """Get a letter by id. Merges and returns the final letter as a pdf - in base 64.""" - with connection.get_session() as session: - letter = session.get(letters_db.Letter, letter_id) - - if not letter: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "No letter exists with the given id") - - pdf = letter.merge_letter() - pdf_64 = base64.b64encode(pdf).decode() - - return LetterDetail( - id=letter.id, - shipment_id=letter.shipment_id, - recipient_id=letter.recipient_id, - status=letter.status, - letter_pdf=pdf_64 - ) +"""This module defines routes for the shipments api.""" + +from datetime import datetime +import base64 + +from fastapi import APIRouter, status +from fastapi.exceptions import HTTPException +from pydantic import BaseModel, Field + +from OpenPostbud.database import connection, document_storage +from OpenPostbud.database.digital_post import shipments as shipments_db +from OpenPostbud.database.digital_post import letters as letters_db + + +router = APIRouter() + + +class ShipmentModel(BaseModel): + """A pydantic model representing a shipment response.""" + id: str + name: str + created_at: datetime + created_by: str + + +class ShipmentDetail(ShipmentModel): + """A pydantic model representing a shipment response + including a list of letter ids and description. + """ + description: str + letter_ids: list[str] + has_attachments: bool + + +class AttachmentModel(BaseModel): + """A pydantic model representing an attachment response.""" + file_name: str + file_data: str = Field(description="Base64-encoded file contents.") + + +@router.get("/shipments", tags=["Shipments"]) +def get_shipments() -> list[ShipmentModel]: + """Get all shipments and return as a list.""" + shipments = shipments_db.get_shipments() + + return [ + ShipmentModel( + id=s.id, + name=s.name, + created_at=s.created_at, + created_by=s.created_by + ) + for s in shipments + ] + + +@router.get("/shipment/{shipment_id}", tags=["Shipments"]) +def get_shipment(shipment_id: str) -> ShipmentDetail: + """Get a shipment by id.""" + + shipment = shipments_db.get_shipment(shipment_id) + + if not shipment: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "No shipment exists with the given id") + + letters = letters_db.get_letters(shipment.id) + letter_ids = [letter.id for letter in letters] + + return ShipmentDetail( + id=shipment.id, + name=shipment.name, + description=shipment.description, + created_at=shipment.created_at, + created_by=shipment.created_by, + letter_ids=letter_ids, + has_attachments=len(document_storage.list_attachments(shipment_id)) > 0 + ) + + +@router.get("/shipment/{shipment_id}/attachments", tags=["Shipments"]) +def get_attachments(shipment_id: str) -> list[AttachmentModel]: + """Get all attachments for the given shipment.""" + shipment = shipments_db.get_shipment(shipment_id) + + if not shipment: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "No shipment exists with the given id") + + attachments = document_storage.get_attachments(shipment_id) + return [AttachmentModel(file_name=a.name, file_data=base64.b64encode(a.data).decode()) for a in attachments] From 40f17eab909923a6d6bb33ea1166e7e825e53a22 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 27 Jul 2026 13:04:20 +0200 Subject: [PATCH 05/31] Added letter post_type to send_post --- src/OpenPostbud/routes/user/send_post.py | 896 +++++++++++------------ 1 file changed, 448 insertions(+), 448 deletions(-) diff --git a/src/OpenPostbud/routes/user/send_post.py b/src/OpenPostbud/routes/user/send_post.py index da8dd20..094f809 100644 --- a/src/OpenPostbud/routes/user/send_post.py +++ b/src/OpenPostbud/routes/user/send_post.py @@ -1,448 +1,448 @@ -"""This module contains the 'send_post' page.""" - -from csv import DictReader -from collections import Counter -from collections.abc import Callable -from pathlib import Path -from typing import Literal, NamedTuple -import asyncio - -from nicegui import ui, APIRouter, app -from nicegui import run as nicegui_run -from nicegui.events import UploadEventArguments -from jinja2.exceptions import TemplateSyntaxError - -from OpenPostbud import ui_components -from OpenPostbud.database import document_storage -from OpenPostbud.middleware import authentication -from OpenPostbud.database.digital_post import letters, shipments, templates -from OpenPostbud.database.digital_post.letters import MemoFields -from OpenPostbud.database.common import PostType -from OpenPostbud.utils import docx_util - - -router = APIRouter() - - -class ValidationMessage(NamedTuple): - """A message produced by csv validation, ready to be shown in a MessageArea.""" - text: str - type_: Literal["positive", "warning", "negative"] - - -@router.page("/send_digital_post", name="Send Post") -def page(): - """Show the 'send_post page.""" - ui_components.header() - ui.label("Ny Forsendelse").classes("text-4xl") - ui.label("På denne side kan du oprette en ny forsendelse af Digital Post eller Fysisk Post.") - - SendPostPage() - - -class SendPostPage: - """A class representing the 'send_post' page.""" - def __init__(self): - with ui.stepper().props("vertical flat done-color=green") as stepper: - with ui.step("Beskrivelse"): - self.step1 = MetadataStep() - _stepper_navigation(stepper, prev_button=False, validate_callback=self.step1.validate) - with ui.step("Skabelon og data"): - self.step2 = FileUploadStep( - on_csv_changed=self._on_csv_data_changed, - get_post_type=lambda: self.step1.post_type.value, - ) - _stepper_navigation(stepper, validate_callback=self.step2.validate) - with ui.step("Vedhæftede filer") as step: - self.step3 = AttachmentsStep() - _stepper_navigation(stepper) - # Disable entire step if selected post type is physical - step.bind_enabled_from(self.step1.post_type, 'value', backward=lambda v: v != PostType.PHYSICAL) - with ui.step("Gennemgå eksempler"): - self.step4 = ExamplesStep(merge_letter=self.step2.merge_letter) - _stepper_navigation(stepper) - with ui.step("Send post"): - ui.button("Send Post", on_click=self._send_post) - _stepper_navigation(stepper, next_button=False) - - # Re-run csv validation when the post type changes, since the set of - # mandatory fields depends on it. - self.step1.post_type.on_value_change(self.step2.refresh_messages) - - def _on_csv_data_changed(self, fields: list[str], rows: list[dict[str, str]] | None): - """Forward csv changes from step 2 to step 3.""" - self.step4.set_data(fields, rows) - - async def _send_post(self): - """Add the shipment and letters to the database and navigate - to the detail page of the shipment. - """ - # Read the attachments before the spinner dialog steals focus, since it - # relies on a round-trip to the client. - attachments = await self.step3.get_attachments() - - with ui.dialog(value=True) as dialog: - dialog.props("persistent") - ui.spinner(size="5em") - - try: - template_id = templates.add_template(self.step2.template_name, self.step2.template_bytes) - shipment_id = shipments.add_shipment( - self.step1.shipment_name.value, - self.step1.shipment_desc.value, - authentication.get_current_user(), - template_id, - self.step1.post_type.value) - letters.add_letters(shipment_id, self.step2.csv_data) - document_storage.add_attachments(shipment_id, attachments) - ui.navigate.to(app.url_path_for("Shipment Detail", shipment_id=shipment_id)) - finally: - dialog.close() - - -class MetadataStep: - """A class representing the first step in the Send Post flow. - Here the user enters a name and description for the shipment. - """ - def __init__(self): - ui.label("Angiv et navn og beskrivelse af forsendelsen, så den kan genkendes senere.") - ui.label("Navn og beskrivelse påvirker ikke forsendelsens indhold.") - self.shipment_name = ui.input( - "Forsendelse navn", - validation={"Maks 50 tegn": lambda v: len(v) <= 50, "Skal udfyldes": lambda v: len(v) != 0}, - ).classes("w-full") - self.shipment_desc = ui.textarea( - "Forsendelse beskrivelse", - validation={"Maks 200 tegn": lambda v: len(v) <= 200, "Skal udfyldes": lambda v: len(v) != 0}, - ).classes("w-full") - - ui.label("Vælg hvordan forsendelsen skal sendes.") - self.post_type = ui.radio({pt: pt.value for pt in PostType}, value=PostType.DIGITAL) - physical_hint = ui.label( - "Bemærk: Ved Fysisk Post skal modtagerens adresse fremgå af brevet, så den kan ses i kuvertens rude." - ).classes("text-secondary") - physical_hint.bind_visibility_from(self.post_type, "value", backward=lambda v: v != PostType.DIGITAL) - - def validate(self) -> bool: - """Validator function for step 1.""" - name_ok = self.shipment_name.validate() - desc_ok = self.shipment_desc.validate() - if not (name_ok and desc_ok): - ui.notify("Udfyld venligst alle felter", type='warning') - return False - return True - - -# pylint: disable-next=too-many-instance-attributes -class FileUploadStep: - """A class representing the second step in the Send Post flow. - Here the user uploads a template and merge data. - """ - def __init__(self, on_csv_changed: Callable[[list[str], list[dict[str, str]] | None], None], get_post_type: Callable[[], PostType]): - self._on_csv_changed = on_csv_changed - self._get_post_type = get_post_type - self.template_name: str | None = None - self.template_bytes: bytes | None = None - self.template_fields: list[str] = [] - self.csv_data: list[dict[str, str]] | None = None - self.csv_fields: list[str] = [] - - with ui.grid(columns=2): - ui.label("Upload skabelon (.docx, .pdf)").classes("text-bold") - ui.label("Upload flettedata (.csv)").classes("text-bold") - - self._template_upload = ui.upload(on_upload=self._on_template_upload, max_files=1, auto_upload=True).props("accept=.docx,.pdf") - self._csv_upload = ui.upload(on_upload=self._on_csv_upload, max_files=1, auto_upload=True).props("accept=.csv") - self._template_upload.on("removed", self._remove_template) - self._csv_upload.on("removed", self._remove_csv) - - self.template_reset_button = ui_components.DisableButton("Nulstil skabelon", on_click=self._remove_template) - self.template_reset_button.disable() - self.csv_reset_button = ui_components.DisableButton("Nulstil flettedata", on_click=self._remove_csv) - self.csv_reset_button.disable() - - ui.label("Flettefelter i skabelon") - ui.label("Datakolonner i csv") - - self.template_fields_area = ui.scroll_area().classes("border border-gray-300") - self.csv_fields_area = ui.scroll_area().classes("border border-gray-300") - - self.message_area = ui_components.MessageArea().classes("border border-gray-300") - - def validate(self) -> bool: - """Validate that both template and merge data has been uploaded.""" - if not self.template_bytes: - ui.notify("Skabelon mangler", type="warning") - if not self.csv_data: - ui.notify("Flettedata mangler", type="warning") - return bool(self.template_bytes and self.csv_data) - - def merge_letter(self, merge_data: dict[str, str]) -> bytes: - """Use the template and merge data to create an example letter.""" - if self.template_name.endswith(".docx"): - word_file = docx_util.merge_word_file(self.template_bytes, merge_data) - return docx_util.convert_word_to_pdf(word_file) - return self.template_bytes - - async def _on_template_upload(self, e: UploadEventArguments): - """Read the merge fields from the uploaded template and refresh the field list.""" - self.template_reset_button.enable() - self.template_name = e.file.name - self.template_bytes = await e.file.read() - - if self.template_name.endswith(".docx"): - try: - self.template_fields = docx_util.get_merge_fields(self.template_bytes) - except TemplateSyntaxError as error: - ui.notify(f"Syntaksfejl i skabelon: {error}", type="negative", timeout=0, actions=[{"label": "Luk", "color": "white"}]) - self._remove_template() - raise - else: - self.template_fields = [] - - self._update_field_tables() - self.refresh_messages() - - async def _on_csv_upload(self, e: UploadEventArguments): - """Read the columns from the uploaded csv, refresh the field list, - push the data to step 3, and run validation. - """ - self.csv_reset_button.enable() - file_content = await e.file.text(encoding="utf-8-sig") - - dict_reader = DictReader(file_content.splitlines()) - self.csv_fields = sorted(list(dict_reader.fieldnames)) - self.csv_data = list(dict_reader) - self._update_field_tables() - self._on_csv_changed(self.csv_fields, self.csv_data) - self.refresh_messages() - - def _remove_template(self): - self._template_upload.reset() - self.template_reset_button.disable() - self.template_fields = [] - self.template_bytes = None - self._update_field_tables() - self.refresh_messages() - - def _remove_csv(self): - self._csv_upload.reset() - self.csv_reset_button.disable() - self.csv_fields = [] - self.csv_data = None - self._update_field_tables() - self._on_csv_changed(self.csv_fields, self.csv_data) - self.refresh_messages() - - def _update_field_tables(self): - """Update the csv and merge field text areas. - Color code merge fields according to whether they appear - in the merge data. - """ - self.template_fields_area.clear() - with self.template_fields_area: - for f in self.template_fields: - with ui.row(align_items='center'): - if f in self.csv_fields: - ui.icon("check_circle", color='positive', size="1rem") - else: - ui.icon("cancel", color='negative', size="1rem") - ui.label(f) - ui.separator() - - self.csv_fields_area.clear() - with self.csv_fields_area: - for f in self.csv_fields: - if any(f == mf.key for mf in MemoFields): - with ui.row(align_items='center'): - ui.icon("settings", color='secondary') - ui.label(str(f)).classes("text-secondary") - else: - ui.label(str(f)) - ui.separator() - - def refresh_messages(self): - """Rebuild the message area from current template + csv state. - - Collects template- and csv-level messages together, then shows the - "all good" message only when data has been uploaded and no other - messages were produced. - """ - self.message_area.clear() - messages: list[ValidationMessage] = [] - - for f in self.template_fields: - if f not in self.csv_fields: - messages.append(ValidationMessage(f"'{f}' mangler i flettedata", "warning")) - - if self.csv_data is not None: - messages.extend(_verify_csv_data(self.csv_fields, self.csv_data, self._get_post_type())) - - if not messages and self.template_bytes and self.csv_data: - messages.append(ValidationMessage("Alles gut", "positive")) - - for msg in messages: - self.message_area.add_message(msg.text, type_=msg.type_) - - -class AttachmentsStep: - """A class representing the attachments step in the Send Post flow. - Here the user can upload extra files to be sent alongside the letter. - """ - def __init__(self): - ui.label("Her kan du vedhæfte ekstra filer til forsendelsen.") - ui.label("Vedhæftede filer sendes, som de er, og flettes derfor ikke.") - ui.label("Digital Post understøtter op til 10 vedhæftede filer og op til 74MB i alt inkl. brev.") - ui.label("Bemærk at vedhæftede filer kun understøttes i Digital Post.") - - self._attachments: dict[tuple[str, int], document_storage.Attachment] = {} - - with ui.grid(columns=1): - file_types = f"accept={','.join(document_storage.ATTACHMENT_FILE_TYPES.keys())}" - self.upload = ui.upload(multiple=True, max_files=10, auto_upload=True, on_upload=self._on_upload, on_rejected=lambda: ui.notify("Upload afvist", type="warning")).props(file_types) - self.remove_button = ui_components.DisableButton("Nulstil vedhæftninger", on_click=self._remove_attachments) - self.remove_button.disable() - - async def _on_upload(self, e: UploadEventArguments): - """Buffer each uploaded file, skipping unsupported file types.""" - suffix = Path(e.file.name).suffix.lower() - if suffix not in document_storage.ATTACHMENT_FILE_TYPES: - ui.notify(f"Filtypen '{suffix}' understøttes ikke: {e.file.name}", type="negative") - return - self._attachments[(e.file.name, e.file.size())] = document_storage.Attachment( - e.file.name, await e.file.read() - ) - self.remove_button.enable() - - def _remove_attachments(self): - """Remove all already uploaded attachments.""" - self._attachments = {} - self.remove_button.disable() - self.upload.reset() - - async def get_attachments(self) -> list[document_storage.Attachment]: - """Return the attachments still shown in the uploader. - - The buffer is reconciled against the uploader's current file list, so - files the user removed in the browser are excluded. - """ - names = await ui.run_javascript( - f"return getElement({self.upload.id}).$refs.qRef.files.map(f => [f.name, f.size])" - ) - names = [tuple(n) for n in names] - return [self._attachments[name] for name in names if name in self._attachments] - - -class ExamplesStep: - """A class representing the third step in the Send Post flow. - Here the user can verify the uploaded data and download sample letters. - """ - def __init__(self, merge_letter: Callable[[dict[str, str]], bytes]): - self._merge_letter = merge_letter - ui.label("Her kan du hente og gennemgå eksempler på breve med den givne data.") - self.example_table = ui.table(rows=[], title="Breve", column_defaults={"align": "left"}, pagination=5) - self.example_table.add_slot( - "body-cell-example_button", - r""" - - - - """ - ) - self.example_table.on("example_button_click", self._on_example_click) - - def set_data(self, fields: list[str], rows: list[dict[str, str]] | None): - """Update the example table columns and rows.""" - columns = [{"name": n, "label": n, "field": n} for n in fields] - columns.append({"name": "example_button", "label": "", "field": "example_button"}) - self.example_table.columns = columns - self.example_table.rows = rows - - async def _on_example_click(self, event): - with ui.dialog(value=True) as dialog: - dialog.props("persistent") - ui.spinner(size="5em") - - try: - letter = await asyncio.wait_for( - nicegui_run.io_bound(lambda: self._merge_letter(event.args)), - timeout=10, - ) - ui.download(letter, "Eksempel.pdf") - except asyncio.TimeoutError: - ui.notify("Download fejlede", type="warning") - finally: - dialog.close() - - -def _stepper_navigation(stepper: ui.stepper, prev_button: bool = True, next_button: bool = True, validate_callback: Callable[[], bool] | None = None): - """Add 'previous' and 'next' buttons to the stepper. - - Args: - stepper: The stepper object to add buttons to. - prev_button: Whether to add a 'previous' button. Defaults to True. - next_button: Whether to add a 'next' button. Defaults to True. - validate_callback: A function to do validation before going to the next step. Defaults to None. - """ - with ui.stepper_navigation(): - if prev_button: - ui.button("Forrige", on_click=stepper.previous).props("flat") - if next_button: - def go_next(): - if validate_callback is None or validate_callback(): - stepper.next() - ui.button("Næste", on_click=go_next) - - -def _verify_csv_data(fields: list[str], csv_list: list[dict], post_type: PostType) -> list[ValidationMessage]: - """Verify the input against these rules: - - Does the data contain any rows? - - Are there any duplicate receivers? - - Are all mandatory fields present? (depends on the post type) - - Does field pattern match for all lines? (max 3 reported) - - Args: - fields: The column names in the csv. - csv_list: The input list as a csv dictionary from DictReader. - post_type: The post type the shipment will be sent as, which - determines which fields are mandatory. - - Returns: - A list of validation messages, empty if no problems are found. - """ - messages: list[ValidationMessage] = [] - - # Check that there is any data at all - if not csv_list: - messages.append(ValidationMessage("Flettedata indeholder ingen rækker", "negative")) - return messages - - # Check for duplicate receivers - if MemoFields.MEMO_MODTAGER.key in fields: - counter = Counter(line[MemoFields.MEMO_MODTAGER.key] for line in csv_list) - duplicates = [f"{k}: {v}" for k, v in counter.items() if v > 1] - if duplicates: - messages.append(ValidationMessage( - f"Duplikater fundet i '{MemoFields.MEMO_MODTAGER.key}': " + " - ".join(duplicates), - "warning", - )) - - # Check for mandatory fields - for mf in MemoFields: - if mf.is_mandatory_for(post_type) and mf.key not in fields: - messages.append(ValidationMessage(f"'{mf.key}' ikke fundet i data", "negative")) - - # Check for pattern mismatches (show 3 errors max) - pattern_errors = 0 - for i, row in enumerate(csv_list): - for mf in MemoFields: - if mf.key in row and not mf.pattern.fullmatch(row[mf.key]): - messages.append(ValidationMessage( - f"Fejl på linje {i}: Kolonne: '{mf.key}' - Mønster: '{mf.pattern.pattern}'", - "negative", - )) - pattern_errors += 1 - if pattern_errors >= 3: - return messages - - return messages +"""This module contains the 'send_post' page.""" + +from csv import DictReader +from collections import Counter +from collections.abc import Callable +from pathlib import Path +from typing import Literal, NamedTuple +import asyncio + +from nicegui import ui, APIRouter, app +from nicegui import run as nicegui_run +from nicegui.events import UploadEventArguments +from jinja2.exceptions import TemplateSyntaxError + +from OpenPostbud import ui_components +from OpenPostbud.database import document_storage +from OpenPostbud.middleware import authentication +from OpenPostbud.database.digital_post import letters, shipments, templates +from OpenPostbud.database.digital_post.letters import MemoFields +from OpenPostbud.database.common import PostType +from OpenPostbud.utils import docx_util + + +router = APIRouter() + + +class ValidationMessage(NamedTuple): + """A message produced by csv validation, ready to be shown in a MessageArea.""" + text: str + type_: Literal["positive", "warning", "negative"] + + +@router.page("/send_digital_post", name="Send Post") +def page(): + """Show the 'send_post page.""" + ui_components.header() + ui.label("Ny Forsendelse").classes("text-4xl") + ui.label("På denne side kan du oprette en ny forsendelse af Digital Post eller Fysisk Post.") + + SendPostPage() + + +class SendPostPage: + """A class representing the 'send_post' page.""" + def __init__(self): + with ui.stepper().props("vertical flat done-color=green") as stepper: + with ui.step("Beskrivelse"): + self.step1 = MetadataStep() + _stepper_navigation(stepper, prev_button=False, validate_callback=self.step1.validate) + with ui.step("Skabelon og data"): + self.step2 = FileUploadStep( + on_csv_changed=self._on_csv_data_changed, + get_post_type=lambda: self.step1.post_type.value, + ) + _stepper_navigation(stepper, validate_callback=self.step2.validate) + with ui.step("Vedhæftede filer") as step: + self.step3 = AttachmentsStep() + _stepper_navigation(stepper) + # Disable entire step if selected post type is physical + step.bind_enabled_from(self.step1.post_type, 'value', backward=lambda v: v != PostType.PHYSICAL) + with ui.step("Gennemgå eksempler"): + self.step4 = ExamplesStep(merge_letter=self.step2.merge_letter) + _stepper_navigation(stepper) + with ui.step("Send post"): + ui.button("Send Post", on_click=self._send_post) + _stepper_navigation(stepper, next_button=False) + + # Re-run csv validation when the post type changes, since the set of + # mandatory fields depends on it. + self.step1.post_type.on_value_change(self.step2.refresh_messages) + + def _on_csv_data_changed(self, fields: list[str], rows: list[dict[str, str]] | None): + """Forward csv changes from step 2 to step 3.""" + self.step4.set_data(fields, rows) + + async def _send_post(self): + """Add the shipment and letters to the database and navigate + to the detail page of the shipment. + """ + # Read the attachments before the spinner dialog steals focus, since it + # relies on a round-trip to the client. + attachments = await self.step3.get_attachments() + + with ui.dialog(value=True) as dialog: + dialog.props("persistent") + ui.spinner(size="5em") + + try: + template_id = templates.add_template(self.step2.template_name, self.step2.template_bytes) + shipment_id = shipments.add_shipment( + self.step1.shipment_name.value, + self.step1.shipment_desc.value, + authentication.get_current_user(), + template_id, + self.step1.post_type.value) + letters.add_letters(shipment_id, self.step2.csv_data, self.step1.post_type.value) + document_storage.add_attachments(shipment_id, attachments) + ui.navigate.to(app.url_path_for("Shipment Detail", shipment_id=shipment_id)) + finally: + dialog.close() + + +class MetadataStep: + """A class representing the first step in the Send Post flow. + Here the user enters a name and description for the shipment. + """ + def __init__(self): + ui.label("Angiv et navn og beskrivelse af forsendelsen, så den kan genkendes senere.") + ui.label("Navn og beskrivelse påvirker ikke forsendelsens indhold.") + self.shipment_name = ui.input( + "Forsendelse navn", + validation={"Maks 50 tegn": lambda v: len(v) <= 50, "Skal udfyldes": lambda v: len(v) != 0}, + ).classes("w-full") + self.shipment_desc = ui.textarea( + "Forsendelse beskrivelse", + validation={"Maks 200 tegn": lambda v: len(v) <= 200, "Skal udfyldes": lambda v: len(v) != 0}, + ).classes("w-full") + + ui.label("Vælg hvordan forsendelsen skal sendes.") + self.post_type = ui.radio({pt: pt.value for pt in PostType}, value=PostType.DIGITAL) + physical_hint = ui.label( + "Bemærk: Ved Fysisk Post skal modtagerens adresse fremgå af brevet, så den kan ses i kuvertens rude." + ).classes("text-secondary") + physical_hint.bind_visibility_from(self.post_type, "value", backward=lambda v: v != PostType.DIGITAL) + + def validate(self) -> bool: + """Validator function for step 1.""" + name_ok = self.shipment_name.validate() + desc_ok = self.shipment_desc.validate() + if not (name_ok and desc_ok): + ui.notify("Udfyld venligst alle felter", type='warning') + return False + return True + + +# pylint: disable-next=too-many-instance-attributes +class FileUploadStep: + """A class representing the second step in the Send Post flow. + Here the user uploads a template and merge data. + """ + def __init__(self, on_csv_changed: Callable[[list[str], list[dict[str, str]] | None], None], get_post_type: Callable[[], PostType]): + self._on_csv_changed = on_csv_changed + self._get_post_type = get_post_type + self.template_name: str | None = None + self.template_bytes: bytes | None = None + self.template_fields: list[str] = [] + self.csv_data: list[dict[str, str]] | None = None + self.csv_fields: list[str] = [] + + with ui.grid(columns=2): + ui.label("Upload skabelon (.docx, .pdf)").classes("text-bold") + ui.label("Upload flettedata (.csv)").classes("text-bold") + + self._template_upload = ui.upload(on_upload=self._on_template_upload, max_files=1, auto_upload=True).props("accept=.docx,.pdf") + self._csv_upload = ui.upload(on_upload=self._on_csv_upload, max_files=1, auto_upload=True).props("accept=.csv") + self._template_upload.on("removed", self._remove_template) + self._csv_upload.on("removed", self._remove_csv) + + self.template_reset_button = ui_components.DisableButton("Nulstil skabelon", on_click=self._remove_template) + self.template_reset_button.disable() + self.csv_reset_button = ui_components.DisableButton("Nulstil flettedata", on_click=self._remove_csv) + self.csv_reset_button.disable() + + ui.label("Flettefelter i skabelon") + ui.label("Datakolonner i csv") + + self.template_fields_area = ui.scroll_area().classes("border border-gray-300") + self.csv_fields_area = ui.scroll_area().classes("border border-gray-300") + + self.message_area = ui_components.MessageArea().classes("border border-gray-300") + + def validate(self) -> bool: + """Validate that both template and merge data has been uploaded.""" + if not self.template_bytes: + ui.notify("Skabelon mangler", type="warning") + if not self.csv_data: + ui.notify("Flettedata mangler", type="warning") + return bool(self.template_bytes and self.csv_data) + + def merge_letter(self, merge_data: dict[str, str]) -> bytes: + """Use the template and merge data to create an example letter.""" + if self.template_name.endswith(".docx"): + word_file = docx_util.merge_word_file(self.template_bytes, merge_data) + return docx_util.convert_word_to_pdf(word_file) + return self.template_bytes + + async def _on_template_upload(self, e: UploadEventArguments): + """Read the merge fields from the uploaded template and refresh the field list.""" + self.template_reset_button.enable() + self.template_name = e.file.name + self.template_bytes = await e.file.read() + + if self.template_name.endswith(".docx"): + try: + self.template_fields = docx_util.get_merge_fields(self.template_bytes) + except TemplateSyntaxError as error: + ui.notify(f"Syntaksfejl i skabelon: {error}", type="negative", timeout=0, actions=[{"label": "Luk", "color": "white"}]) + self._remove_template() + raise + else: + self.template_fields = [] + + self._update_field_tables() + self.refresh_messages() + + async def _on_csv_upload(self, e: UploadEventArguments): + """Read the columns from the uploaded csv, refresh the field list, + push the data to step 3, and run validation. + """ + self.csv_reset_button.enable() + file_content = await e.file.text(encoding="utf-8-sig") + + dict_reader = DictReader(file_content.splitlines()) + self.csv_fields = sorted(list(dict_reader.fieldnames)) + self.csv_data = list(dict_reader) + self._update_field_tables() + self._on_csv_changed(self.csv_fields, self.csv_data) + self.refresh_messages() + + def _remove_template(self): + self._template_upload.reset() + self.template_reset_button.disable() + self.template_fields = [] + self.template_bytes = None + self._update_field_tables() + self.refresh_messages() + + def _remove_csv(self): + self._csv_upload.reset() + self.csv_reset_button.disable() + self.csv_fields = [] + self.csv_data = None + self._update_field_tables() + self._on_csv_changed(self.csv_fields, self.csv_data) + self.refresh_messages() + + def _update_field_tables(self): + """Update the csv and merge field text areas. + Color code merge fields according to whether they appear + in the merge data. + """ + self.template_fields_area.clear() + with self.template_fields_area: + for f in self.template_fields: + with ui.row(align_items='center'): + if f in self.csv_fields: + ui.icon("check_circle", color='positive', size="1rem") + else: + ui.icon("cancel", color='negative', size="1rem") + ui.label(f) + ui.separator() + + self.csv_fields_area.clear() + with self.csv_fields_area: + for f in self.csv_fields: + if any(f == mf.key for mf in MemoFields): + with ui.row(align_items='center'): + ui.icon("settings", color='secondary') + ui.label(str(f)).classes("text-secondary") + else: + ui.label(str(f)) + ui.separator() + + def refresh_messages(self): + """Rebuild the message area from current template + csv state. + + Collects template- and csv-level messages together, then shows the + "all good" message only when data has been uploaded and no other + messages were produced. + """ + self.message_area.clear() + messages: list[ValidationMessage] = [] + + for f in self.template_fields: + if f not in self.csv_fields: + messages.append(ValidationMessage(f"'{f}' mangler i flettedata", "warning")) + + if self.csv_data is not None: + messages.extend(_verify_csv_data(self.csv_fields, self.csv_data, self._get_post_type())) + + if not messages and self.template_bytes and self.csv_data: + messages.append(ValidationMessage("Alles gut", "positive")) + + for msg in messages: + self.message_area.add_message(msg.text, type_=msg.type_) + + +class AttachmentsStep: + """A class representing the attachments step in the Send Post flow. + Here the user can upload extra files to be sent alongside the letter. + """ + def __init__(self): + ui.label("Her kan du vedhæfte ekstra filer til forsendelsen.") + ui.label("Vedhæftede filer sendes, som de er, og flettes derfor ikke.") + ui.label("Digital Post understøtter op til 10 vedhæftede filer og op til 74MB i alt inkl. brev.") + ui.label("Bemærk at vedhæftede filer kun understøttes i Digital Post.") + + self._attachments: dict[tuple[str, int], document_storage.Attachment] = {} + + with ui.grid(columns=1): + file_types = f"accept={','.join(document_storage.ATTACHMENT_FILE_TYPES.keys())}" + self.upload = ui.upload(multiple=True, max_files=10, auto_upload=True, on_upload=self._on_upload, on_rejected=lambda: ui.notify("Upload afvist", type="warning")).props(file_types) + self.remove_button = ui_components.DisableButton("Nulstil vedhæftninger", on_click=self._remove_attachments) + self.remove_button.disable() + + async def _on_upload(self, e: UploadEventArguments): + """Buffer each uploaded file, skipping unsupported file types.""" + suffix = Path(e.file.name).suffix.lower() + if suffix not in document_storage.ATTACHMENT_FILE_TYPES: + ui.notify(f"Filtypen '{suffix}' understøttes ikke: {e.file.name}", type="negative") + return + self._attachments[(e.file.name, e.file.size())] = document_storage.Attachment( + e.file.name, await e.file.read() + ) + self.remove_button.enable() + + def _remove_attachments(self): + """Remove all already uploaded attachments.""" + self._attachments = {} + self.remove_button.disable() + self.upload.reset() + + async def get_attachments(self) -> list[document_storage.Attachment]: + """Return the attachments still shown in the uploader. + + The buffer is reconciled against the uploader's current file list, so + files the user removed in the browser are excluded. + """ + names = await ui.run_javascript( + f"return getElement({self.upload.id}).$refs.qRef.files.map(f => [f.name, f.size])" + ) + names = [tuple(n) for n in names] + return [self._attachments[name] for name in names if name in self._attachments] + + +class ExamplesStep: + """A class representing the third step in the Send Post flow. + Here the user can verify the uploaded data and download sample letters. + """ + def __init__(self, merge_letter: Callable[[dict[str, str]], bytes]): + self._merge_letter = merge_letter + ui.label("Her kan du hente og gennemgå eksempler på breve med den givne data.") + self.example_table = ui.table(rows=[], title="Breve", column_defaults={"align": "left"}, pagination=5) + self.example_table.add_slot( + "body-cell-example_button", + r""" + + + + """ + ) + self.example_table.on("example_button_click", self._on_example_click) + + def set_data(self, fields: list[str], rows: list[dict[str, str]] | None): + """Update the example table columns and rows.""" + columns = [{"name": n, "label": n, "field": n} for n in fields] + columns.append({"name": "example_button", "label": "", "field": "example_button"}) + self.example_table.columns = columns + self.example_table.rows = rows + + async def _on_example_click(self, event): + with ui.dialog(value=True) as dialog: + dialog.props("persistent") + ui.spinner(size="5em") + + try: + letter = await asyncio.wait_for( + nicegui_run.io_bound(lambda: self._merge_letter(event.args)), + timeout=10, + ) + ui.download(letter, "Eksempel.pdf") + except asyncio.TimeoutError: + ui.notify("Download fejlede", type="warning") + finally: + dialog.close() + + +def _stepper_navigation(stepper: ui.stepper, prev_button: bool = True, next_button: bool = True, validate_callback: Callable[[], bool] | None = None): + """Add 'previous' and 'next' buttons to the stepper. + + Args: + stepper: The stepper object to add buttons to. + prev_button: Whether to add a 'previous' button. Defaults to True. + next_button: Whether to add a 'next' button. Defaults to True. + validate_callback: A function to do validation before going to the next step. Defaults to None. + """ + with ui.stepper_navigation(): + if prev_button: + ui.button("Forrige", on_click=stepper.previous).props("flat") + if next_button: + def go_next(): + if validate_callback is None or validate_callback(): + stepper.next() + ui.button("Næste", on_click=go_next) + + +def _verify_csv_data(fields: list[str], csv_list: list[dict], post_type: PostType) -> list[ValidationMessage]: + """Verify the input against these rules: + - Does the data contain any rows? + - Are there any duplicate receivers? + - Are all mandatory fields present? (depends on the post type) + - Does field pattern match for all lines? (max 3 reported) + + Args: + fields: The column names in the csv. + csv_list: The input list as a csv dictionary from DictReader. + post_type: The post type the shipment will be sent as, which + determines which fields are mandatory. + + Returns: + A list of validation messages, empty if no problems are found. + """ + messages: list[ValidationMessage] = [] + + # Check that there is any data at all + if not csv_list: + messages.append(ValidationMessage("Flettedata indeholder ingen rækker", "negative")) + return messages + + # Check for duplicate receivers + if MemoFields.MEMO_MODTAGER.key in fields: + counter = Counter(line[MemoFields.MEMO_MODTAGER.key] for line in csv_list) + duplicates = [f"{k}: {v}" for k, v in counter.items() if v > 1] + if duplicates: + messages.append(ValidationMessage( + f"Duplikater fundet i '{MemoFields.MEMO_MODTAGER.key}': " + " - ".join(duplicates), + "warning", + )) + + # Check for mandatory fields + for mf in MemoFields: + if mf.is_mandatory_for(post_type) and mf.key not in fields: + messages.append(ValidationMessage(f"'{mf.key}' ikke fundet i data", "negative")) + + # Check for pattern mismatches (show 3 errors max) + pattern_errors = 0 + for i, row in enumerate(csv_list): + for mf in MemoFields: + if mf.key in row and not mf.pattern.fullmatch(row[mf.key]): + messages.append(ValidationMessage( + f"Fejl på linje {i}: Kolonne: '{mf.key}' - Mønster: '{mf.pattern.pattern}'", + "negative", + )) + pattern_errors += 1 + if pattern_errors >= 3: + return messages + + return messages From 10117a4384de29d37de60795d9d30c4d4955fa9b Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 27 Jul 2026 13:05:00 +0200 Subject: [PATCH 06/31] Updated shipment_worker --- src/OpenPostbud/workers/shipment_worker.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/OpenPostbud/workers/shipment_worker.py b/src/OpenPostbud/workers/shipment_worker.py index b241924..50b2339 100644 --- a/src/OpenPostbud/workers/shipment_worker.py +++ b/src/OpenPostbud/workers/shipment_worker.py @@ -20,7 +20,6 @@ from OpenPostbud import config from OpenPostbud.database import connection from OpenPostbud.database.digital_post.letters import Letter, MemoFields -from OpenPostbud.database.digital_post import shipments from OpenPostbud.database.digital_post.shipments import Shipment from OpenPostbud.database.common import ShipmentStatus, PostType from OpenPostbud.database import document_storage @@ -72,12 +71,12 @@ def get_waiting_letter() -> Letter | None: with connection.get_session() as session: sub_q = ( select(Letter.id) - .join(Shipment, Letter.shipment_id == Shipment.id) + .outerjoin(Shipment, Letter.shipment_id == Shipment.id) .where( Letter.status == ShipmentStatus.WAITING, datetime.now() - timedelta(seconds=config.SHIPMENT_WORKER_DELAY) > Letter.updated_at ) - .order_by(Shipment.created_at, Letter.shipment_id) + .order_by(Shipment.created_at.nulls_first(), Letter.updated_at, Letter.shipment_id) .limit(1) .scalar_subquery() ) @@ -101,22 +100,20 @@ def get_waiting_letter() -> Letter | None: def send_letter(letter: Letter, kombit_access: KombitAccess): - """Send a letter according to its shipment's post type. + """Send a letter according to its post type. - Digital Post and Auto shipments first check if the recipient is registered - for Digital Post. Digital shipments fail if the recipient isn't registered, - while Auto shipments fall back to physical mail. Physical shipments are + Digital Post and Auto letters first check if the recipient is registered + for Digital Post. Digital letters fail if the recipient isn't registered, + while Auto letters fall back to physical mail. Physical letters are always sent as physical mail. """ - shipment = shipments.get_shipment(letter.shipment_id) - - if shipment.post_type == PostType.PHYSICAL: + if letter.post_type == PostType.PHYSICAL: send_physical(letter, kombit_access) return is_registered = digital_post.is_registered(letter.recipient_id, 'digitalpost', kombit_access) - if shipment.post_type == PostType.DIGITAL: + if letter.post_type == PostType.DIGITAL: if not is_registered: letter.set_status(ShipmentStatus.FAILED, message="Ikke tilmeldt Digital Post") logging.info(f"Letter not sent. The recipient is not registered for Digital Post. {letter.id}") @@ -140,7 +137,7 @@ def send_digital(letter: Letter, kombit_access: KombitAccess): id_type = "CPR" if len(letter.recipient_id) == 10 else "CVR" - attachments = _get_attachments(letter.shipment_id) + attachments = _get_attachments(letter.shipment_id) if letter.shipment_id else [] payload_size = len(document) + sum(len(attachment.data) for attachment in attachments) if payload_size > DIGITAL_MAX_PAYLOAD_BYTES: From 6b6bd78942b5dd7321786a5bd71d2b76247402ed Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 11:16:41 +0200 Subject: [PATCH 07/31] Made add_letters fetch data from shipment in db --- src/OpenPostbud/database/digital_post/letters.py | 10 +++++++--- src/OpenPostbud/routes/user/send_post.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/OpenPostbud/database/digital_post/letters.py b/src/OpenPostbud/database/digital_post/letters.py index 02e8d34..cbf5188 100644 --- a/src/OpenPostbud/database/digital_post/letters.py +++ b/src/OpenPostbud/database/digital_post/letters.py @@ -131,15 +131,18 @@ def set_status(self, status: ShipmentStatus, transaction_id: str | None = None, session.commit() -def add_letters(shipment_id: str, csv_data: list[dict[str, str]], post_type: PostType = PostType.DIGITAL): +def add_letters(shipment_id: str, csv_data: list[dict[str, str]]): """Add multiple new letters to the database based on a csv file containing letter merge data. + The letters inherit their post type and creator from the shipment. + Args: shipment_id: The id of the shipment the letters belong to. csv_data: A list of dictionaries containing merge data. - post_type: How the letters should be sent. """ + shipment = shipments.get_shipment(shipment_id) + letter_dicts = [] for line in csv_data: @@ -150,7 +153,8 @@ def add_letters(shipment_id: str, csv_data: list[dict[str, str]], post_type: Pos "shipment_id": shipment_id, "recipient_id": recipient, "field_data": json.dumps(line), - "post_type": post_type + "post_type": shipment.post_type, + "created_by": shipment.created_by } ) diff --git a/src/OpenPostbud/routes/user/send_post.py b/src/OpenPostbud/routes/user/send_post.py index 094f809..2fa98be 100644 --- a/src/OpenPostbud/routes/user/send_post.py +++ b/src/OpenPostbud/routes/user/send_post.py @@ -93,7 +93,7 @@ async def _send_post(self): authentication.get_current_user(), template_id, self.step1.post_type.value) - letters.add_letters(shipment_id, self.step2.csv_data, self.step1.post_type.value) + letters.add_letters(shipment_id, self.step2.csv_data) document_storage.add_attachments(shipment_id, attachments) ui.navigate.to(app.url_path_for("Shipment Detail", shipment_id=shipment_id)) finally: From 23c31fc88e64f1b9e33addc275ae57a9b2c07c2c Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 11:16:57 +0200 Subject: [PATCH 08/31] Added generic info popup component --- src/OpenPostbud/ui_components.py | 417 ++++++++++++++++--------------- 1 file changed, 217 insertions(+), 200 deletions(-) diff --git a/src/OpenPostbud/ui_components.py b/src/OpenPostbud/ui_components.py index 7839fc2..f038533 100644 --- a/src/OpenPostbud/ui_components.py +++ b/src/OpenPostbud/ui_components.py @@ -1,200 +1,217 @@ -"""This module contains reusable UI components.""" - -from typing import Literal -import csv -from io import StringIO - -from nicegui import ui, app - -from OpenPostbud.middleware import authentication -from OpenPostbud import config - - -def header(): - """Show a NiceGUI header with links to other pages.""" - theme() - - with ui.header(): - logo = ui.label("📯 OpenPostbud 📯").classes("text-3xl text-bold cursor-pointer") - logo.on("click", lambda: ui.navigate.to(app.url_path_for("Front Page"))) # pylint: disable=no-member - - ui.link("Forside", app.url_path_for("Front Page")).classes(replace='text-lg text-white') - ui.separator().props("vertical color=white size=2px") - ui.link("Digital Post", app.url_path_for("Shipment Overview")).classes(replace='text-lg text-white') - ui.separator().props("vertical color=white size=2px") - ui.link("NemSMS", app.url_path_for("NemSMS Overview")).classes(replace='text-lg text-white') - ui.separator().props("vertical color=white size=2px") - ui.link("Tjek Tilmelding", app.url_path_for("Registration Overview")).classes(replace='text-lg text-white') - - if authentication.is_admin(): - ui.separator().props("vertical color=white size=2px") - ui.link("API Brugere", app.url_path_for("API Users")).classes(replace='text-lg text-white') - - ui.space() - ui.label(authentication.get_current_user()).classes('text-lg text-white') - ui.label(str(authentication.get_current_user_roles())).classes('text-lg text-white') - ui.separator().props("vertical color=white size=2px") - ui.label(config.OPENPOSTBUD_VERSION).classes('text-lg text-white') - ui.button("Log Ud", on_click=authentication.logout, color="white").classes("text-primary") - - -def theme(): - """Set the theme for the current page.""" - ui.colors(primary="#cc0000") - ui.input.default_props("filled") - ui.textarea.default_props("filled") - - -def obscure_id_column(table: ui.table, column_name: str): - """Obscure the last 4 digits of a CPR or CVR value in a Nicegui table. - Adds a 'show/hide' button next to the value in the table. - - A 10-digit value (CPR) is shown as dddddd-XXXX; an 8-digit value (CVR) - is shown as ddddXXXX. Other lengths are shown unchanged. - - Args: - table: The table object. - column_name: The name of the column to obscure. - """ - table.add_slot(f"body-cell-{column_name}", r''' - - - {{ props.value.substring(0, 6) }}-{{ props.expand ? props.value.substring(6) : 'XXXX' }} - - - {{ props.value.substring(0, 4) }}{{ props.expand ? props.value.substring(4) : 'XXXX' }} - - - {{ props.value }} - - - - ''') - - -async def question_popup(question: str, option1: str, option2: str, color1: str = 'primary', color2: str = 'primary') -> bool: - """Show an awaitable popup with a question and two buttons with the given options. - Example: - result = await question_popup("Do you like candy", "YES!", "Not really") - - Args: - question: The question to display. - option1: The text on button 1. - option2: The text on button 2. - color1: The color of button 1. - color2: The color of button 2. - - Returns: - bool: True if button 1 is clicked, or False if button 2 is clicked. - """ - with ui.dialog(value=True).props('persistent') as dialog, ui.card(): - ui.label(question).classes("text-lg") - with ui.row(): - ui.button(option1, on_click=lambda e: dialog.submit(True), color=color1) - ui.button(option2, on_click=lambda e: dialog.submit(False), color=color2) - - return await dialog - - -async def text_input_popup(prompt: str, input_label: str) -> str: - """Show an awaitable popup that asks for a single text input. - - Args: - prompt: The text to show on the dialog. - input_label: The label text on the input element. - - Returns: - The text from the text input or an empty string if the dialog is closed. - """ - with ui.dialog(value=True).props('persistent') as dialog, ui.card(): - ui.label(prompt).classes("text-lg") - text_input = ui.input(input_label) - with ui.row(): - ui.button("OK", on_click=lambda e: dialog.submit(text_input.value)) - ui.button("Luk", on_click=lambda e: dialog.submit("")) - - return await dialog - - -class DisableButton(ui.button): - """An extension of ui.button that turns grey when disabled.""" - def _handle_enabled_change(self, enabled: bool) -> None: - """Called when the element is enabled or disabled. - - :param enabled: The new state. - """ - if enabled: - self.props("color=primary") - else: - self.props("color=grey") - self._props['disable'] = not enabled - self.update() - - -class MessageArea(ui.scroll_area): - """A ui component for displaying messages in line with other content.""" - def add_message(self, text: str, type_: Literal["positive", "warning", "negative"]): - """Add a new message to the message area. - - Args: - text: The text of the message. - type_: The type of the message which determines the color and icon. - """ - with self, ui.card() as card: - with ui.row(align_items="center"): - match(type_): - case "positive": - card.classes("w-full bg-positive") - ui.icon("check_circle", color="white", size="1.8em") - ui.label(text).classes("text-white") - case "warning": - card.classes("w-full bg-warning") - ui.icon("priority_high", color="black", size="1.8em") - ui.label(text) - case "negative": - card.classes("w-full bg-negative") - ui.icon("warning", color="white", size="1.8em") - ui.label(text).classes("text-white") - self.update() - - -class SearchTable(ui.table): - """An extension of ui.table that has a search field in the top slot.""" - def __init__(self, *, rows, columns=None, column_defaults=None, row_key='id', title=None, selection=None, pagination=None, on_select=None, on_pagination_change=None, # pylint: disable=too-many-arguments - search_field: bool, download_button: bool): - super().__init__(rows=rows, columns=columns, column_defaults=column_defaults, row_key=row_key, selection=selection, pagination=pagination, on_select=on_select, on_pagination_change=on_pagination_change) - with self.add_slot("top"): - ui.label(title).classes("q-table__title") - ui.space() - if download_button: - ui.button("Download liste", on_click=self._download_list).classes("mr-5") - if search_field: - search_input = ui.input("Søg").props("clearable") - self.bind_filter_from(search_input, "value") - - def _download_list(self): - """A callback function for downloading the table data as a csv file.""" - field_names = [col["field"] for col in self.columns] - field_labels = {col["field"]: col["label"] for col in self.columns} - - f = StringIO() - writer = csv.DictWriter(f, fieldnames=field_names) - writer.writerow(field_labels) - writer.writerows(self.rows) - - ui.download(f.getvalue().encode(), "Liste.csv") - - -class MultilineLabel(): - """A utility class for creating multiple labels - for multiline text.""" - labels: list[ui.label] - - def __init__(self, text: str): - self.labels = [] - - with ui.column().style("gap: 0;"): - for line in text.splitlines(): - self.labels.append(ui.label(line)) +"""This module contains reusable UI components.""" + +from typing import Literal +import csv +from io import StringIO + +from nicegui import ui, app + +from OpenPostbud.middleware import authentication +from OpenPostbud import config + + +def header(): + """Show a NiceGUI header with links to other pages.""" + theme() + + with ui.header(): + logo = ui.label("📯 OpenPostbud 📯").classes("text-3xl text-bold cursor-pointer") + logo.on("click", lambda: ui.navigate.to(app.url_path_for("Front Page"))) # pylint: disable=no-member + + ui.link("Forside", app.url_path_for("Front Page")).classes(replace='text-lg text-white') + ui.separator().props("vertical color=white size=2px") + ui.link("Digital Post", app.url_path_for("Shipment Overview")).classes(replace='text-lg text-white') + ui.separator().props("vertical color=white size=2px") + ui.link("NemSMS", app.url_path_for("NemSMS Overview")).classes(replace='text-lg text-white') + ui.separator().props("vertical color=white size=2px") + ui.link("Tjek Tilmelding", app.url_path_for("Registration Overview")).classes(replace='text-lg text-white') + + if authentication.is_admin(): + ui.separator().props("vertical color=white size=2px") + ui.link("API Brugere", app.url_path_for("API Users")).classes(replace='text-lg text-white') + + ui.space() + ui.label(authentication.get_current_user()).classes('text-lg text-white') + ui.label(str(authentication.get_current_user_roles())).classes('text-lg text-white') + ui.separator().props("vertical color=white size=2px") + ui.label(config.OPENPOSTBUD_VERSION).classes('text-lg text-white') + ui.button("Log Ud", on_click=authentication.logout, color="white").classes("text-primary") + + +def theme(): + """Set the theme for the current page.""" + ui.colors(primary="#cc0000") + ui.input.default_props("filled") + ui.textarea.default_props("filled") + + +def obscure_id_column(table: ui.table, column_name: str): + """Obscure the last 4 digits of a CPR or CVR value in a Nicegui table. + Adds a 'show/hide' button next to the value in the table. + + A 10-digit value (CPR) is shown as dddddd-XXXX; an 8-digit value (CVR) + is shown as ddddXXXX. Other lengths are shown unchanged. + + Args: + table: The table object. + column_name: The name of the column to obscure. + """ + table.add_slot(f"body-cell-{column_name}", r''' + + + {{ props.value.substring(0, 6) }}-{{ props.expand ? props.value.substring(6) : 'XXXX' }} + + + {{ props.value.substring(0, 4) }}{{ props.expand ? props.value.substring(4) : 'XXXX' }} + + + {{ props.value }} + + + + ''') + + +async def question_popup(question: str, option1: str, option2: str, color1: str = 'primary', color2: str = 'primary') -> bool: + """Show an awaitable popup with a question and two buttons with the given options. + Example: + result = await question_popup("Do you like candy", "YES!", "Not really") + + Args: + question: The question to display. + option1: The text on button 1. + option2: The text on button 2. + color1: The color of button 1. + color2: The color of button 2. + + Returns: + bool: True if button 1 is clicked, or False if button 2 is clicked. + """ + with ui.dialog(value=True).props('persistent') as dialog, ui.card(): + ui.label(question).classes("text-lg") + with ui.row(): + ui.button(option1, on_click=lambda e: dialog.submit(True), color=color1) + ui.button(option2, on_click=lambda e: dialog.submit(False), color=color2) + + return await dialog + + +async def text_input_popup(prompt: str, input_label: str) -> str: + """Show an awaitable popup that asks for a single text input. + + Args: + prompt: The text to show on the dialog. + input_label: The label text on the input element. + + Returns: + The text from the text input or an empty string if the dialog is closed. + """ + with ui.dialog(value=True).props('persistent') as dialog, ui.card(): + ui.label(prompt).classes("text-lg") + text_input = ui.input(input_label) + with ui.row(): + ui.button("OK", on_click=lambda e: dialog.submit(text_input.value)) + ui.button("Luk", on_click=lambda e: dialog.submit("")) + + return await dialog + + +async def info_popup(information: str): + """Show an awaitable popup that asks for a single text input. + + Args: + prompt: The text to show on the dialog. + input_label: The label text on the input element. + + Returns: + The text from the text input or an empty string if the dialog is closed. + """ + with ui.dialog(value=True).props('persistent') as dialog, ui.card(): + ui.label(information).classes("text-lg") + ui.button("Luk", on_click=lambda e: dialog.submit("")) + + return await dialog + + +class DisableButton(ui.button): + """An extension of ui.button that turns grey when disabled.""" + def _handle_enabled_change(self, enabled: bool) -> None: + """Called when the element is enabled or disabled. + + :param enabled: The new state. + """ + if enabled: + self.props("color=primary") + else: + self.props("color=grey") + self._props['disable'] = not enabled + self.update() + + +class MessageArea(ui.scroll_area): + """A ui component for displaying messages in line with other content.""" + def add_message(self, text: str, type_: Literal["positive", "warning", "negative"]): + """Add a new message to the message area. + + Args: + text: The text of the message. + type_: The type of the message which determines the color and icon. + """ + with self, ui.card() as card: + with ui.row(align_items="center"): + match(type_): + case "positive": + card.classes("w-full bg-positive") + ui.icon("check_circle", color="white", size="1.8em") + ui.label(text).classes("text-white") + case "warning": + card.classes("w-full bg-warning") + ui.icon("priority_high", color="black", size="1.8em") + ui.label(text) + case "negative": + card.classes("w-full bg-negative") + ui.icon("warning", color="white", size="1.8em") + ui.label(text).classes("text-white") + self.update() + + +class SearchTable(ui.table): + """An extension of ui.table that has a search field in the top slot.""" + def __init__(self, *, rows, columns=None, column_defaults=None, row_key='id', title=None, selection=None, pagination=None, on_select=None, on_pagination_change=None, # pylint: disable=too-many-arguments + search_field: bool, download_button: bool): + super().__init__(rows=rows, columns=columns, column_defaults=column_defaults, row_key=row_key, selection=selection, pagination=pagination, on_select=on_select, on_pagination_change=on_pagination_change) + with self.add_slot("top"): + ui.label(title).classes("q-table__title") + ui.space() + if download_button: + ui.button("Download liste", on_click=self._download_list).classes("mr-5") + if search_field: + search_input = ui.input("Søg").props("clearable") + self.bind_filter_from(search_input, "value") + + def _download_list(self): + """A callback function for downloading the table data as a csv file.""" + field_names = [col["field"] for col in self.columns] + field_labels = {col["field"]: col["label"] for col in self.columns} + + f = StringIO() + writer = csv.DictWriter(f, fieldnames=field_names) + writer.writerow(field_labels) + writer.writerows(self.rows) + + ui.download(f.getvalue().encode(), "Liste.csv") + + +class MultilineLabel(): + """A utility class for creating multiple labels + for multiline text.""" + labels: list[ui.label] + + def __init__(self, text: str): + self.labels = [] + + with ui.column().style("gap: 0;"): + for line in text.splitlines(): + self.labels.append(ui.label(line)) From 9de28fb6b81ee7dc89381ff60a893ad915005276 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 11:17:27 +0200 Subject: [PATCH 09/31] Removed unneeded return --- src/OpenPostbud/ui_components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenPostbud/ui_components.py b/src/OpenPostbud/ui_components.py index f038533..62f20f0 100644 --- a/src/OpenPostbud/ui_components.py +++ b/src/OpenPostbud/ui_components.py @@ -132,7 +132,7 @@ async def info_popup(information: str): ui.label(information).classes("text-lg") ui.button("Luk", on_click=lambda e: dialog.submit("")) - return await dialog + await dialog class DisableButton(ui.button): From e28642f8aa27d8b9f3cd414c23d8035abb379ed9 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 11:18:13 +0200 Subject: [PATCH 10/31] Added created_by to letters table --- src/OpenPostbud/database/digital_post/letters.py | 1 + .../sql/003_letter_post_type_nullable_shipment.sql | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/OpenPostbud/database/digital_post/letters.py b/src/OpenPostbud/database/digital_post/letters.py index cbf5188..de0e535 100644 --- a/src/OpenPostbud/database/digital_post/letters.py +++ b/src/OpenPostbud/database/digital_post/letters.py @@ -67,6 +67,7 @@ class Letter(Base): field_data: Mapped[str] = mapped_column(EncryptedString()) transaction_id: Mapped[str] = mapped_column(nullable=True) post_type: Mapped[PostType] = mapped_column(default=PostType.DIGITAL) + created_by: Mapped[str] = mapped_column(String(50)) sent_as: Mapped[PostType] = mapped_column(nullable=True) def to_row_dict(self) -> dict[str, str]: diff --git a/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql b/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql index b8a3d9c..b6e379c 100644 --- a/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql +++ b/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql @@ -9,14 +9,16 @@ CREATE TABLE "Letters_new" ( transaction_id VARCHAR, sent_as VARCHAR(8), post_type VARCHAR(8) NOT NULL DEFAULT 'DIGITAL', + created_by VARCHAR(50) NOT NULL DEFAULT 'unknown', PRIMARY KEY (id), FOREIGN KEY(shipment_id) REFERENCES "Shipments" (id) ON DELETE CASCADE ) -INSERT INTO "Letters_new" (id, shipment_id, recipient_id, updated_at, status, message, field_data, transaction_id, sent_as, post_type) +INSERT INTO "Letters_new" (id, shipment_id, recipient_id, updated_at, status, message, field_data, transaction_id, sent_as, post_type, created_by) SELECT id, shipment_id, recipient_id, updated_at, status, message, field_data, transaction_id, sent_as, - COALESCE((SELECT post_type FROM "Shipments" WHERE "Shipments".id = "Letters".shipment_id), 'DIGITAL') + COALESCE((SELECT post_type FROM "Shipments" WHERE "Shipments".id = "Letters".shipment_id), 'DIGITAL'), + COALESCE((SELECT created_by FROM "Shipments" WHERE "Shipments".id = "Letters".shipment_id), 'unknown') FROM "Letters" From ed15e105446b923cf3a242a5dc37a1468738fbc4 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 11:18:40 +0200 Subject: [PATCH 11/31] Added delete single letters --- .../database/digital_post/letters.py | 29 ++++++++++++++++++- src/OpenPostbud/database/document_storage.py | 6 ++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/OpenPostbud/database/digital_post/letters.py b/src/OpenPostbud/database/digital_post/letters.py index de0e535..1009d9d 100644 --- a/src/OpenPostbud/database/digital_post/letters.py +++ b/src/OpenPostbud/database/digital_post/letters.py @@ -2,20 +2,23 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta import json from enum import Enum +import logging import re from sqlalchemy import ForeignKey, insert, select, String, update from sqlalchemy.orm import Mapped, mapped_column +from OpenPostbud import config from OpenPostbud.database.base import Base from OpenPostbud.database import connection from OpenPostbud.database.data_types.encrypted_string import EncryptedString from OpenPostbud.database.data_types.id_generator import create_id from OpenPostbud.database.common import ShipmentStatus, PostType from OpenPostbud.database.digital_post import templates +from OpenPostbud.database.digital_post import shipments from OpenPostbud.database import document_storage from OpenPostbud.utils import docx_util @@ -172,6 +175,13 @@ def get_letters(shipment_id: str) -> tuple[Letter]: return tuple(result) +def get_letters_by_user(user_id: str) -> tuple[Letter]: + with connection.get_session() as session: + query = select(Letter).where(Letter.created_by == user_id) + result = session.execute(query).scalars() + return tuple(result) + + def abort_letters(shipment_id: str, user: str): """Set all waiting letters in the given shipment to aborted. Also add a message about who aborted. @@ -194,3 +204,20 @@ def abort_letters(shipment_id: str, user: str): ) session.execute(query) session.commit() + + +def delete_old_single_letters(): + """Delete single letters that are older than SHIPMENT_LIFETIME_DAYS.""" + logging.info("Cleaning up old single letters.") + + with connection.get_session() as session: + query = select(Letter).where((datetime.today() - timedelta(days=config.SHIPMENT_LIFETIME_DAYS)) > Letter.updated_at) + letters = list(session.execute(query).scalars()) + + for letter in letters: + document_storage.delete_single_letter_doc(letter.id) + session.delete(letter) + + session.commit() + + logging.info(f"Deleted {len(letters)} old single letters.") diff --git a/src/OpenPostbud/database/document_storage.py b/src/OpenPostbud/database/document_storage.py index 8ebb33f..c44aa40 100644 --- a/src/OpenPostbud/database/document_storage.py +++ b/src/OpenPostbud/database/document_storage.py @@ -55,6 +55,12 @@ def delete_shipment_docs(shipment_id: str): shutil.rmtree(folder_path) +def delete_single_letter_doc(letter_id: str): + """Delete the stored document for a single letter without a shipment.""" + letter_path = _get_letter_path(shipment_id=None, letter_id=letter_id) + letter_path.unlink(missing_ok=True) + + def _get_letter_path(shipment_id: str | None, letter_id: str) -> Path: """Get the path to the letter's doc file.""" folder_path = _get_shipment_folder(shipment_id) From 43518771a97f19b5f2f8c001411e2bfca8c282cc Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 11:18:55 +0200 Subject: [PATCH 12/31] Added delete single letters to cli --- src/OpenPostbud/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/OpenPostbud/__main__.py b/src/OpenPostbud/__main__.py index e03dfb4..87f3b18 100644 --- a/src/OpenPostbud/__main__.py +++ b/src/OpenPostbud/__main__.py @@ -3,7 +3,7 @@ import argparse from OpenPostbud.database.check_registration import registration_job -from OpenPostbud.database.digital_post import shipments +from OpenPostbud.database.digital_post import shipments, letters from OpenPostbud.database.nemsms import nemsms_shipments from OpenPostbud.middleware import authentication from OpenPostbud.database import connection @@ -20,6 +20,7 @@ def database_cleanup(*_): shipments.delete_old_shipments() registration_job.delete_old_registration_jobs() nemsms_shipments.delete_old_shipments() + letters.delete_old_single_letters() def create_database(*_): From 1d8ed66d043b8f11f48bd94bbd2f56887e6ffe32 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 11:19:26 +0200 Subject: [PATCH 13/31] Added api user db functions --- src/OpenPostbud/database/api_users.py | 227 ++++++++++++++------------ 1 file changed, 121 insertions(+), 106 deletions(-) diff --git a/src/OpenPostbud/database/api_users.py b/src/OpenPostbud/database/api_users.py index 0908383..f62e488 100644 --- a/src/OpenPostbud/database/api_users.py +++ b/src/OpenPostbud/database/api_users.py @@ -1,106 +1,121 @@ -"""This module handles the creation and verification of api users.""" - -from __future__ import annotations - -from datetime import datetime -import secrets -import re - -from passlib.hash import pbkdf2_sha256 -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy import select - -from OpenPostbud.database.base import Base -from OpenPostbud.database import connection - - -class ApiUser(Base): - """An ORM class representing an api user.""" - __tablename__ = "ApiUsers" - - id: Mapped[str] = mapped_column(primary_key=True) - name: Mapped[str] - key_hash: Mapped[str] - created_at: Mapped[datetime] = mapped_column(default=datetime.now) - active: Mapped[bool] = mapped_column(default=True) - - def to_row_dict(self) -> dict[str, str]: - """Convert to a dictionary to be shown in a table.""" - return { - "id": self.id, - "name": self.name, - "created_at": self.created_at.strftime("%d/%m/%Y %H:%M:%S"), - "active": {True: "Aktiv", False: "Inaktiv"}[self.active] - } - - -def get_api_users() -> tuple[ApiUser]: - """Get all api users in the database.""" - with connection.get_session() as session: - result = session.execute(select(ApiUser)).scalars() - return tuple(result) - - -def create_api_user(name: str) -> str: - """Add a new api user to the database with the given name. - A random api key is generated for the new user. - - Args: - name: The name of the new api user. - - Returns: - The complete api key to be used in api calls. - """ - id = secrets.token_urlsafe(8) - key = secrets.token_urlsafe(32) - - user = ApiUser( - id=id, - name=name, - key_hash=pbkdf2_sha256.hash(key) - ) - - with connection.get_session() as session: - session.add(user) - session.commit() - - return f"{id}.{key}" - - -def delete_api_user(user_id: str): - """Delete the api user with the given id.""" - with connection.get_session() as session: - user = session.get(ApiUser, user_id) - if user: - session.delete(user) - session.commit() - return True - return False - - -def verify_api_key(api_key: str) -> ApiUser | None: - """Verify an api key against the database. - - Args: - api_key: The api key to verify - - Returns: - Returns the api user if the key is valid. - """ - # The api key is assumed to be of the form "id.key" - if not re.fullmatch(r"[\w-]+\.[\w-]+", api_key): - return False - - id, key = api_key.split(".") - - with connection.get_session() as session: - user = session.get(ApiUser, id) - - if user and user.active and pbkdf2_sha256.verify(key, user.key_hash): - return user - - return None - - -if __name__ == "__main__": - print(create_api_user("Test Api User")) +"""This module handles the creation and verification of api users.""" + +from __future__ import annotations + +from datetime import datetime +import secrets +import re + +from passlib.hash import pbkdf2_sha256 +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import select + +from OpenPostbud.database.base import Base +from OpenPostbud.database import connection + + +class ApiUser(Base): + """An ORM class representing an api user.""" + __tablename__ = "ApiUsers" + + id: Mapped[str] = mapped_column(primary_key=True) + name: Mapped[str] + key_hash: Mapped[str] + created_at: Mapped[datetime] = mapped_column(default=datetime.now) + active: Mapped[bool] = mapped_column(default=True) + + def to_row_dict(self) -> dict[str, str]: + """Convert to a dictionary to be shown in a table.""" + return { + "id": self.id, + "name": self.name, + "created_at": self.created_at.strftime("%d/%m/%Y %H:%M:%S"), + "active": {True: "Aktiv", False: "Inaktiv"}[self.active] + } + + +def get_api_users() -> tuple[ApiUser]: + """Get all api users in the database.""" + with connection.get_session() as session: + result = session.execute(select(ApiUser)).scalars() + return tuple(result) + + +def get_api_user(id: str) -> ApiUser: + """Get a single api user from the database.""" + with connection.get_session() as session: + return session.get(ApiUser, id) + + +def create_api_user(name: str) -> str: + """Add a new api user to the database with the given name. + A random api key is generated for the new user. + + Args: + name: The name of the new api user. + + Returns: + The complete api key to be used in api calls. + """ + id = secrets.token_urlsafe(8) + key = secrets.token_urlsafe(32) + + user = ApiUser( + id=id, + name=name, + key_hash=pbkdf2_sha256.hash(key) + ) + + with connection.get_session() as session: + session.add(user) + session.commit() + + return f"{id}.{key}" + + +def delete_api_user(user_id: str): + """Delete the api user with the given id.""" + with connection.get_session() as session: + user = session.get(ApiUser, user_id) + if user: + session.delete(user) + session.commit() + return True + return False + + +def deactivate_api_user(user_id: str): + """Deactivate the api user with the given id.""" + with connection.get_session() as session: + user = session.get(ApiUser, user_id) + if user: + user.active = False + session.commit() + + +def verify_api_key(api_key: str) -> ApiUser | None: + """Verify an api key against the database. + + Args: + api_key: The api key to verify + + Returns: + Returns the api user if the key is valid. + """ + # The api key is assumed to be of the form "id.key" + if not re.fullmatch(r"[\w-]+\.[\w-]+", api_key): + return None + + id, key = api_key.split(".") + + with connection.get_session() as session: + user = session.get(ApiUser, id) + + if user and user.active and pbkdf2_sha256.verify(key, user.key_hash): + return user + + return None + + +if __name__ == "__main__": + print(create_api_user("Test Api User")) From 976e11de1d5762058dae12120ce1f368396ed5bb Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 11:19:51 +0200 Subject: [PATCH 14/31] Changed api user ui --- src/OpenPostbud/routes/admin/api_users.py | 219 ++++++++++++++-------- 1 file changed, 138 insertions(+), 81 deletions(-) diff --git a/src/OpenPostbud/routes/admin/api_users.py b/src/OpenPostbud/routes/admin/api_users.py index a8f7e27..19bb9ec 100644 --- a/src/OpenPostbud/routes/admin/api_users.py +++ b/src/OpenPostbud/routes/admin/api_users.py @@ -1,81 +1,138 @@ -"""This module is responsible for the admin page for api users.""" - -from nicegui import ui, APIRouter -from nicegui.events import ClickEventArguments - -from OpenPostbud import ui_components -from OpenPostbud.database import api_users - -router = APIRouter() - -USER_COLUMNS = [ - {'name': "name", 'label': "Navn", 'field': "name"}, - {'name': "id", 'label': "ID", 'field': "id"}, - {'name': "active", 'label': "Status", 'field': "active"}, - {'name': "created_at", 'label': "Oprettet", 'field': "created_at"} -] - -COLUMN_DEFAULTS = {'align': 'left', 'sortable': True, 'style': 'padding-right: 5rem'} - - -@router.page("/api-users", name="API Users") -def api_users_page(): - """Show the api users page.""" - ui_components.header() - ApiUserPage() - - -class ApiUserPage: - """A class representing the api user page.""" - def __init__(self): - ui.label("Velkommen til Api brugere!").classes("text-4xl") - ui.button("Ny api bruger", on_click=self._add_api_user) - - self.table = ui.table(rows=[], columns=USER_COLUMNS, column_defaults=COLUMN_DEFAULTS) - self.table.on("rowClick", self._row_click) - self._update_table() - - def _update_table(self): - """Update the api user table with the newest data from the database.""" - rows = [user.to_row_dict() for user in api_users.get_api_users()] - self.table.rows = rows - - def _row_click(self, event): - """Open a dialog for the clicked api user row.""" - with ui.dialog(value=True) as dialog, ui.card(): - row = event.args[1] - ui.label(f"{row['name']} - {row['id']}").classes("text-xl") - with ui.row(): - ui.button("Slet", on_click=lambda e: self._delete_user(row['id'], dialog)) - ui.button("Luk", on_click=dialog.close) - - async def _add_api_user(self): - """Show a popup prompt for a new api user name and create the user with the given name.""" - name = await ui_components.text_input_popup("Indtast navn på ny API bruger", "Navn") - if not name: - return - api_key = api_users.create_api_user(name) - self._update_table() - - def copy_button_click(event: ClickEventArguments): - ui.clipboard.write(api_key) - event.sender.props("icon=check_circle label=Kopieret") - - with ui.dialog(value=True).props('persistent') as dialog, ui.card(): - ui.label("Kopier nedenstående api-nøgle. Den kan ikke vises igen.").classes("text-bold") - ui.label(api_key) - with ui.row(): - ui.button("Kopier", on_click=copy_button_click) - ui.button("Luk", on_click=dialog.close).props("flat") - - async def _delete_user(self, user_id: str, dialog: ui.dialog): - """Show a confirmation popup and delete the user with the given id.""" - if not await ui_components.question_popup(f"Vil du slette api bruger {user_id}?", "Ja", "Nej"): - return - - if api_users.delete_api_user(user_id): - ui.notify(f"Api bruger slettet: {user_id}", type='positive') - self._update_table() - dialog.close() - else: - ui.notify(f"Bruger ikke fundet: {user_id}", type='negative') +"""This module is responsible for the admin page for api users.""" + +from nicegui import app, ui, APIRouter +from nicegui.events import ClickEventArguments +from fastapi import HTTPException + +from OpenPostbud import ui_components +from OpenPostbud.database import api_users +from OpenPostbud.database.digital_post import letters + +router = APIRouter() + +USER_COLUMNS = [ + {'name': "name", 'label': "Navn", 'field': "name"}, + {'name': "id", 'label': "ID", 'field': "id"}, + {'name': "active", 'label': "Status", 'field': "active"}, + {'name': "created_at", 'label': "Oprettet", 'field': "created_at"} +] + +LETTERS_COLUMNS = [ + {'name': "id", 'label': "ID", 'field': "id"}, + {'name': "recipient", 'label': "Modtager", 'field': "recipient"}, + {'name': "status", 'label': "Status", 'field': "status"}, + {'name': "sent_as", 'label': "Sendt som", 'field': "sent_as"}, + {'name': "updated_at", 'label': "Status Opdateret", 'field': "updated_at"}, + {'name': "Message", 'label': "Besked", 'field': "message"} +] + +COLUMN_DEFAULTS = {'align': 'left', 'sortable': True, 'style': 'padding-right: 5rem'} + + +@router.page("/api-users", name="API Users") +def api_users_page(): + """Show the api users page.""" + ui_components.header() + ApiUsersPage() + + +class ApiUsersPage: + """A class representing the api user page.""" + def __init__(self): + ui.label("Velkommen til Api brugere!").classes("text-4xl") + ui.button("Ny api bruger", on_click=self._add_api_user) + + self.table = ui.table(rows=[], columns=USER_COLUMNS, column_defaults=COLUMN_DEFAULTS) + self.table.on("rowClick", self._row_click) + self._update_table() + + def _update_table(self): + """Update the api user table with the newest data from the database.""" + rows = [user.to_row_dict() for user in api_users.get_api_users()] + self.table.rows = rows + + def _row_click(self, event): + """Open a dialog for the clicked api user row.""" + ui.navigate.to(app.url_path_for("API User Detail", api_user_id=event.args[1]['id'])) + + async def _add_api_user(self): + """Show a popup prompt for a new api user name and create the user with the given name.""" + name = await ui_components.text_input_popup("Indtast navn på ny API bruger", "Navn") + if not name: + return + api_key = api_users.create_api_user(name) + self._update_table() + + def copy_button_click(event: ClickEventArguments): + ui.clipboard.write(api_key) + event.sender.props("icon=check_circle label=Kopieret") + + with ui.dialog(value=True).props('persistent') as dialog, ui.card(): + ui.label("Kopier nedenstående api-nøgle. Den kan ikke vises igen.").classes("text-bold") + ui.label(api_key) + with ui.row(): + ui.button("Kopier", on_click=copy_button_click) + ui.button("Luk", on_click=dialog.close).props("flat") + + +@router.page("/api-users/{api_user_id}", name="API User Detail") +def api_user_detail_page(api_user_id: str): + ui_components.header() + ApiUserDetailPage(api_user_id) + + +class ApiUserDetailPage: + def __init__(self, api_user_id: str): + self.user = api_users.get_api_user(api_user_id) + if not self.user: + raise HTTPException(404, f"Ingen api bruger med id {api_user_id} fundet.") + + self._show_header() + + with ui.row(): + self.disable_button = ui_components.DisableButton("Deaktiver", on_click=self._deactivate_user) + if not self.user.active: + self.disable_button.disable() + ui.button("Slet", on_click=self._delete_user) + + self._show_letters_table() + + def _show_letters_table(self): + """Show the letters table.""" + letter_rows = [letter.to_row_dict() for letter in letters.get_letters_by_user(self.user.id)] + self.letter_table = ui_components.SearchTable(title="Breve", rows=letter_rows, columns=LETTERS_COLUMNS, column_defaults=COLUMN_DEFAULTS, pagination=50, download_button=True, search_field=True) + ui_components.obscure_id_column(self.letter_table, "recipient") + + async def _deactivate_user(self): + """Show a confirmation popup and deactivate the user with the given id.""" + if not await ui_components.question_popup(f"Vil du deaktivere api bruger {self.user.id}?", "Ja", "Nej"): + return + + api_users.deactivate_api_user(self.user.id) + ui.notify(f"Api bruger deaktiveret: {self.user.id}", type='positive') + self.disable_button.disable() + self._show_header.refresh() + + async def _delete_user(self): + """Show a confirmation popup and delete the user with the given id. + An api user can only be deleted if it doesn't have any letters attached. + """ + if letters.get_letters_by_user(self.user.id): + await ui_components.info_popup("Du kan ikke slette en api bruger med eksisterende breve tilknyttet.") + return + + if not await ui_components.question_popup(f"Vil du slette api bruger {self.user.id}?", "Ja", "Nej"): + return + + api_users.delete_api_user(self.user.id) + ui.navigate.to(app.url_path_for("API Users")) + + @ui.refreshable + def _show_header(self): + self.user = api_users.get_api_user(self.user.id) + with ui.row(): + ui.label(f"Api bruger {self.user.name} - {self.user.id}").classes("text-4xl") + if self.user.active: + ui.chip("Aktiv", color="positive", text_color="white").classes("text-lg") + else: + ui.chip("Inaktiv", text_color="white").classes("text-lg") \ No newline at end of file From 51b99aaa047da07e9a1b973dbb30044f4f3ec684 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 11:20:32 +0200 Subject: [PATCH 15/31] Moved bearer token dep and added sub to send_letter endpoint --- src/OpenPostbud/routes/api/dependencies.py | 23 ++++++++++++++++++++++ src/OpenPostbud/routes/api/letters.py | 9 ++++++--- src/OpenPostbud/routes/api/router.py | 20 +------------------ 3 files changed, 30 insertions(+), 22 deletions(-) create mode 100644 src/OpenPostbud/routes/api/dependencies.py diff --git a/src/OpenPostbud/routes/api/dependencies.py b/src/OpenPostbud/routes/api/dependencies.py new file mode 100644 index 0000000..8255daf --- /dev/null +++ b/src/OpenPostbud/routes/api/dependencies.py @@ -0,0 +1,23 @@ +"""Shared FastAPI dependencies for the api routes.""" + +from fastapi import Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.exceptions import HTTPException + +import jwt + +from OpenPostbud import config + + +security_scheme = HTTPBearer() + + +def check_bearer_token(credentials: HTTPAuthorizationCredentials = Depends(security_scheme)) -> dict[str, str]: + """Check the validity of an incoming bearer JWT token.""" + try: + payload = jwt.decode(credentials.credentials, config.API_JWT_SECRET, algorithms=["HS256"]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(401, "Token expired") # pylint: disable=raise-missing-from + except jwt.InvalidTokenError: + raise HTTPException(401, "Invalid token") # pylint: disable=raise-missing-from diff --git a/src/OpenPostbud/routes/api/letters.py b/src/OpenPostbud/routes/api/letters.py index d406095..65fce6b 100644 --- a/src/OpenPostbud/routes/api/letters.py +++ b/src/OpenPostbud/routes/api/letters.py @@ -1,10 +1,11 @@ """This module defines routes for the shipments api.""" from datetime import datetime +from typing import Annotated import base64 import json -from fastapi import APIRouter, status +from fastapi import APIRouter, Depends, status from fastapi.exceptions import HTTPException from pydantic import BaseModel, Field @@ -12,6 +13,7 @@ from OpenPostbud.database.common import PostType from OpenPostbud.database.digital_post import letters from OpenPostbud.database import connection +from OpenPostbud.routes.api.dependencies import check_bearer_token router = APIRouter() @@ -41,7 +43,7 @@ class SendLetterResponse(BaseModel): @router.post("/send_letter", tags=["Letters"]) -def send_letter(letter: SendLetterModel) -> SendLetterResponse: +def send_letter(letter: SendLetterModel, token: Annotated[dict[str, str], Depends(check_bearer_token)]) -> SendLetterResponse: """Send a single letter without a shipment.""" new_letter = letters.Letter( @@ -49,7 +51,8 @@ def send_letter(letter: SendLetterModel) -> SendLetterResponse: shipment_id=None, recipient_id=letter.recipient_id, field_data=json.dumps({letters.MemoFields.MEMO_LABEL.key: letter.memo_label}), - post_type=letter.post_type + post_type=letter.post_type, + created_by=token["sub"] ) document_storage.save_letter_doc(None, new_letter.id, base64.b64decode(letter.letter_document)) diff --git a/src/OpenPostbud/routes/api/router.py b/src/OpenPostbud/routes/api/router.py index e085ebc..9032160 100644 --- a/src/OpenPostbud/routes/api/router.py +++ b/src/OpenPostbud/routes/api/router.py @@ -3,27 +3,9 @@ from typing import Annotated from fastapi import APIRouter, Depends, Security -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from fastapi.exceptions import HTTPException - -import jwt from OpenPostbud.routes.api import shipments, letters -from OpenPostbud import config - - -security_scheme = HTTPBearer() - - -def check_bearer_token(credentials: HTTPAuthorizationCredentials = Depends(security_scheme)) -> dict[str, str]: - """Check the validity of an incoming bearer JWT token.""" - try: - payload = jwt.decode(credentials.credentials, config.API_JWT_SECRET, algorithms=["HS256"]) - return payload - except jwt.ExpiredSignatureError: - raise HTTPException(401, "Token expired") # pylint: disable=raise-missing-from - except jwt.InvalidTokenError: - raise HTTPException(401, "Invalid token") # pylint: disable=raise-missing-from +from OpenPostbud.routes.api.dependencies import check_bearer_token # Router object for all api routes From c2d9b543f853e31adf740dc1d1f3069b7da0a5e7 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 12:31:57 +0200 Subject: [PATCH 16/31] Lint --- src/OpenPostbud/database/digital_post/letters.py | 1 + src/OpenPostbud/routes/admin/api_users.py | 6 ++++-- src/OpenPostbud/routes/api/letters.py | 6 +++--- src/OpenPostbud/routes/api/shipments.py | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/OpenPostbud/database/digital_post/letters.py b/src/OpenPostbud/database/digital_post/letters.py index 1009d9d..69231b8 100644 --- a/src/OpenPostbud/database/digital_post/letters.py +++ b/src/OpenPostbud/database/digital_post/letters.py @@ -176,6 +176,7 @@ def get_letters(shipment_id: str) -> tuple[Letter]: def get_letters_by_user(user_id: str) -> tuple[Letter]: + """Get all letters created by the given user.""" with connection.get_session() as session: query = select(Letter).where(Letter.created_by == user_id) result = session.execute(query).scalars() diff --git a/src/OpenPostbud/routes/admin/api_users.py b/src/OpenPostbud/routes/admin/api_users.py index 19bb9ec..79e8186 100644 --- a/src/OpenPostbud/routes/admin/api_users.py +++ b/src/OpenPostbud/routes/admin/api_users.py @@ -37,7 +37,7 @@ def api_users_page(): class ApiUsersPage: - """A class representing the api user page.""" + """A class representing the api users page.""" def __init__(self): ui.label("Velkommen til Api brugere!").classes("text-4xl") ui.button("Ny api bruger", on_click=self._add_api_user) @@ -77,11 +77,13 @@ def copy_button_click(event: ClickEventArguments): @router.page("/api-users/{api_user_id}", name="API User Detail") def api_user_detail_page(api_user_id: str): + """Show the api user detail page.""" ui_components.header() ApiUserDetailPage(api_user_id) class ApiUserDetailPage: + """A class representing the api user detail page.""" def __init__(self, api_user_id: str): self.user = api_users.get_api_user(api_user_id) if not self.user: @@ -135,4 +137,4 @@ def _show_header(self): if self.user.active: ui.chip("Aktiv", color="positive", text_color="white").classes("text-lg") else: - ui.chip("Inaktiv", text_color="white").classes("text-lg") \ No newline at end of file + ui.chip("Inaktiv", text_color="white").classes("text-lg") diff --git a/src/OpenPostbud/routes/api/letters.py b/src/OpenPostbud/routes/api/letters.py index 65fce6b..3be5272 100644 --- a/src/OpenPostbud/routes/api/letters.py +++ b/src/OpenPostbud/routes/api/letters.py @@ -12,7 +12,6 @@ from OpenPostbud.database import connection, document_storage from OpenPostbud.database.common import PostType from OpenPostbud.database.digital_post import letters -from OpenPostbud.database import connection from OpenPostbud.routes.api.dependencies import check_bearer_token @@ -20,7 +19,7 @@ class LetterDetail(BaseModel): - """A pydantic model representing a letter response.""" + """A model representing a letter response.""" id: str shipment_id: str | None recipient_id: str @@ -31,7 +30,7 @@ class LetterDetail(BaseModel): class SendLetterModel(BaseModel): - """A pydantic model representing a shipment response.""" + """A model representing a shipment response.""" recipient_id: str memo_label: str | None post_type: PostType @@ -39,6 +38,7 @@ class SendLetterModel(BaseModel): class SendLetterResponse(BaseModel): + """A model describing a response from the send_letter endpoint.""" id: str diff --git a/src/OpenPostbud/routes/api/shipments.py b/src/OpenPostbud/routes/api/shipments.py index d3727c5..cdfc2cf 100644 --- a/src/OpenPostbud/routes/api/shipments.py +++ b/src/OpenPostbud/routes/api/shipments.py @@ -7,7 +7,7 @@ from fastapi.exceptions import HTTPException from pydantic import BaseModel, Field -from OpenPostbud.database import connection, document_storage +from OpenPostbud.database import document_storage from OpenPostbud.database.digital_post import shipments as shipments_db from OpenPostbud.database.digital_post import letters as letters_db From dd0a0a9db07b9f6840b3387c90ab1fdeb7400b67 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 13:03:14 +0200 Subject: [PATCH 17/31] Fixed letter deletion query --- src/OpenPostbud/database/digital_post/letters.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/OpenPostbud/database/digital_post/letters.py b/src/OpenPostbud/database/digital_post/letters.py index 69231b8..aa0c2df 100644 --- a/src/OpenPostbud/database/digital_post/letters.py +++ b/src/OpenPostbud/database/digital_post/letters.py @@ -212,7 +212,10 @@ def delete_old_single_letters(): logging.info("Cleaning up old single letters.") with connection.get_session() as session: - query = select(Letter).where((datetime.today() - timedelta(days=config.SHIPMENT_LIFETIME_DAYS)) > Letter.updated_at) + query = select(Letter).where( + (datetime.today() - timedelta(days=config.SHIPMENT_LIFETIME_DAYS)) > Letter.updated_at, + Letter.shipment_id.is_(None) + ) letters = list(session.execute(query).scalars()) for letter in letters: From 6b8833c6021664683ca825678920a3f33799a42c Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 13:08:16 +0200 Subject: [PATCH 18/31] Normalize line endings to LF Eight files had been committed with CRLF line endings, which made their diffs unreviewable (send_post.py showed 896 changed lines with no actual content change). Renormalized with `git add --renormalize`; this commit changes line endings only. Root cause was core.autocrlf=false locally, which commits worktree bytes verbatim. Now set to `input` globally so CRLF is normalized on commit while nothing is converted on checkout. Co-Authored-By: Claude Opus 5 (1M context) --- src/OpenPostbud/database/api_users.py | 242 ++--- .../database/digital_post/letters.py | 454 ++++----- .../database/digital_post/shipments.py | 200 ++-- src/OpenPostbud/routes/admin/api_users.py | 280 +++--- src/OpenPostbud/routes/api/letters.py | 182 ++-- src/OpenPostbud/routes/api/shipments.py | 178 ++-- src/OpenPostbud/routes/user/send_post.py | 896 +++++++++--------- src/OpenPostbud/ui_components.py | 434 ++++----- 8 files changed, 1433 insertions(+), 1433 deletions(-) diff --git a/src/OpenPostbud/database/api_users.py b/src/OpenPostbud/database/api_users.py index f62e488..8b2220a 100644 --- a/src/OpenPostbud/database/api_users.py +++ b/src/OpenPostbud/database/api_users.py @@ -1,121 +1,121 @@ -"""This module handles the creation and verification of api users.""" - -from __future__ import annotations - -from datetime import datetime -import secrets -import re - -from passlib.hash import pbkdf2_sha256 -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy import select - -from OpenPostbud.database.base import Base -from OpenPostbud.database import connection - - -class ApiUser(Base): - """An ORM class representing an api user.""" - __tablename__ = "ApiUsers" - - id: Mapped[str] = mapped_column(primary_key=True) - name: Mapped[str] - key_hash: Mapped[str] - created_at: Mapped[datetime] = mapped_column(default=datetime.now) - active: Mapped[bool] = mapped_column(default=True) - - def to_row_dict(self) -> dict[str, str]: - """Convert to a dictionary to be shown in a table.""" - return { - "id": self.id, - "name": self.name, - "created_at": self.created_at.strftime("%d/%m/%Y %H:%M:%S"), - "active": {True: "Aktiv", False: "Inaktiv"}[self.active] - } - - -def get_api_users() -> tuple[ApiUser]: - """Get all api users in the database.""" - with connection.get_session() as session: - result = session.execute(select(ApiUser)).scalars() - return tuple(result) - - -def get_api_user(id: str) -> ApiUser: - """Get a single api user from the database.""" - with connection.get_session() as session: - return session.get(ApiUser, id) - - -def create_api_user(name: str) -> str: - """Add a new api user to the database with the given name. - A random api key is generated for the new user. - - Args: - name: The name of the new api user. - - Returns: - The complete api key to be used in api calls. - """ - id = secrets.token_urlsafe(8) - key = secrets.token_urlsafe(32) - - user = ApiUser( - id=id, - name=name, - key_hash=pbkdf2_sha256.hash(key) - ) - - with connection.get_session() as session: - session.add(user) - session.commit() - - return f"{id}.{key}" - - -def delete_api_user(user_id: str): - """Delete the api user with the given id.""" - with connection.get_session() as session: - user = session.get(ApiUser, user_id) - if user: - session.delete(user) - session.commit() - return True - return False - - -def deactivate_api_user(user_id: str): - """Deactivate the api user with the given id.""" - with connection.get_session() as session: - user = session.get(ApiUser, user_id) - if user: - user.active = False - session.commit() - - -def verify_api_key(api_key: str) -> ApiUser | None: - """Verify an api key against the database. - - Args: - api_key: The api key to verify - - Returns: - Returns the api user if the key is valid. - """ - # The api key is assumed to be of the form "id.key" - if not re.fullmatch(r"[\w-]+\.[\w-]+", api_key): - return None - - id, key = api_key.split(".") - - with connection.get_session() as session: - user = session.get(ApiUser, id) - - if user and user.active and pbkdf2_sha256.verify(key, user.key_hash): - return user - - return None - - -if __name__ == "__main__": - print(create_api_user("Test Api User")) +"""This module handles the creation and verification of api users.""" + +from __future__ import annotations + +from datetime import datetime +import secrets +import re + +from passlib.hash import pbkdf2_sha256 +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import select + +from OpenPostbud.database.base import Base +from OpenPostbud.database import connection + + +class ApiUser(Base): + """An ORM class representing an api user.""" + __tablename__ = "ApiUsers" + + id: Mapped[str] = mapped_column(primary_key=True) + name: Mapped[str] + key_hash: Mapped[str] + created_at: Mapped[datetime] = mapped_column(default=datetime.now) + active: Mapped[bool] = mapped_column(default=True) + + def to_row_dict(self) -> dict[str, str]: + """Convert to a dictionary to be shown in a table.""" + return { + "id": self.id, + "name": self.name, + "created_at": self.created_at.strftime("%d/%m/%Y %H:%M:%S"), + "active": {True: "Aktiv", False: "Inaktiv"}[self.active] + } + + +def get_api_users() -> tuple[ApiUser]: + """Get all api users in the database.""" + with connection.get_session() as session: + result = session.execute(select(ApiUser)).scalars() + return tuple(result) + + +def get_api_user(id: str) -> ApiUser: + """Get a single api user from the database.""" + with connection.get_session() as session: + return session.get(ApiUser, id) + + +def create_api_user(name: str) -> str: + """Add a new api user to the database with the given name. + A random api key is generated for the new user. + + Args: + name: The name of the new api user. + + Returns: + The complete api key to be used in api calls. + """ + id = secrets.token_urlsafe(8) + key = secrets.token_urlsafe(32) + + user = ApiUser( + id=id, + name=name, + key_hash=pbkdf2_sha256.hash(key) + ) + + with connection.get_session() as session: + session.add(user) + session.commit() + + return f"{id}.{key}" + + +def delete_api_user(user_id: str): + """Delete the api user with the given id.""" + with connection.get_session() as session: + user = session.get(ApiUser, user_id) + if user: + session.delete(user) + session.commit() + return True + return False + + +def deactivate_api_user(user_id: str): + """Deactivate the api user with the given id.""" + with connection.get_session() as session: + user = session.get(ApiUser, user_id) + if user: + user.active = False + session.commit() + + +def verify_api_key(api_key: str) -> ApiUser | None: + """Verify an api key against the database. + + Args: + api_key: The api key to verify + + Returns: + Returns the api user if the key is valid. + """ + # The api key is assumed to be of the form "id.key" + if not re.fullmatch(r"[\w-]+\.[\w-]+", api_key): + return None + + id, key = api_key.split(".") + + with connection.get_session() as session: + user = session.get(ApiUser, id) + + if user and user.active and pbkdf2_sha256.verify(key, user.key_hash): + return user + + return None + + +if __name__ == "__main__": + print(create_api_user("Test Api User")) diff --git a/src/OpenPostbud/database/digital_post/letters.py b/src/OpenPostbud/database/digital_post/letters.py index aa0c2df..2683c6d 100644 --- a/src/OpenPostbud/database/digital_post/letters.py +++ b/src/OpenPostbud/database/digital_post/letters.py @@ -1,227 +1,227 @@ -"""This module contains the Letter ORM class.""" - -from __future__ import annotations - -from datetime import datetime, timedelta -import json -from enum import Enum -import logging -import re - -from sqlalchemy import ForeignKey, insert, select, String, update -from sqlalchemy.orm import Mapped, mapped_column - -from OpenPostbud import config -from OpenPostbud.database.base import Base -from OpenPostbud.database import connection -from OpenPostbud.database.data_types.encrypted_string import EncryptedString -from OpenPostbud.database.data_types.id_generator import create_id -from OpenPostbud.database.common import ShipmentStatus, PostType -from OpenPostbud.database.digital_post import templates -from OpenPostbud.database.digital_post import shipments -from OpenPostbud.database import document_storage -from OpenPostbud.utils import docx_util - - -class MemoFields(Enum): - """An enum class defining the special fields used for - Memo functionality. - a MemoField has the following members: - key: The name of the field when loaded from merge data. - mandatory_digital: Whether the field is mandatory when sending Digital Post. - mandatory_physical: Whether the field is mandatory when sending Fysisk Post. - pattern: The regex pattern for the field's value. - """ - def __init__(self, key: str, mandatory_digital: bool, mandatory_physical: bool, pattern: str): - self.key = key - self.mandatory_digital = mandatory_digital - self.mandatory_physical = mandatory_physical - self.pattern = re.compile(pattern) - - MEMO_MODTAGER = ("Memo Modtager", True, True, r"\d{10}|\d{8}") - MEMO_LABEL = ("Memo Label", True, False, r"\S.*") - - def is_mandatory_for(self, post_type: PostType) -> bool: - """Whether this field is mandatory for the given post type. - - AUTO requires the field if it is mandatory for either route, so the - letter can be sent successfully whichever route a recipient takes. - """ - if post_type == PostType.DIGITAL: - return self.mandatory_digital - if post_type == PostType.PHYSICAL: - return self.mandatory_physical - return self.mandatory_digital or self.mandatory_physical - - -LETTER_ID_FACTORY = create_id("L-", 10) - - -class Letter(Base): - """An ORM class representing a letter.""" - __tablename__ = "Letters" - - id: Mapped[str] = mapped_column(String(12), primary_key=True, default=LETTER_ID_FACTORY) - shipment_id: Mapped[str] = mapped_column(ForeignKey("Shipments.id", ondelete="CASCADE"), nullable=True) - recipient_id: Mapped[str] = mapped_column(EncryptedString()) - updated_at: Mapped[datetime] = mapped_column(default=datetime.now) - status: Mapped[ShipmentStatus] = mapped_column(default=ShipmentStatus.WAITING) - message: Mapped[str] = mapped_column(String(100), nullable=True) - field_data: Mapped[str] = mapped_column(EncryptedString()) - transaction_id: Mapped[str] = mapped_column(nullable=True) - post_type: Mapped[PostType] = mapped_column(default=PostType.DIGITAL) - created_by: Mapped[str] = mapped_column(String(50)) - sent_as: Mapped[PostType] = mapped_column(nullable=True) - - def to_row_dict(self) -> dict[str, str]: - """Convert to a dictionary to be shown in a table.""" - return { - "id": str(self.id), - "recipient": self.recipient_id, - "updated_at": self.updated_at.strftime("%d/%m/%Y %H:%M:%S"), - "status": self.status.value, - "message": self.message, - "sent_as": self.sent_as.value if self.sent_as else "" - } - - def merge_letter(self) -> bytes: - """Merge the letter's merge field data with its template - and convert to pdf. - - Returns: - The merged pdf letter as bytes. - """ - stored_file = document_storage.get_letter_doc(self.shipment_id, self.id) - if stored_file: - return stored_file - - template = templates.get_template_by_shipment(self.shipment_id) - - if template.file_name.endswith(".docx"): - field_data = json.loads(self.field_data) - word_file = docx_util.merge_word_file(template.file_data, field_data) - pdf_file = docx_util.convert_word_to_pdf(word_file) - document_storage.save_letter_doc(self.shipment_id, self.id, pdf_file) - return pdf_file - - return template.file_data - - def set_status(self, status: ShipmentStatus, transaction_id: str | None = None, message: str | None = None, sent_as: PostType | None = None): - """Set the status of the letter in the database. - The transaction id and sent_as are not overwritten if the given value is None. - - Args: - status: The status to set on the letter. - transaction_id: The transaction id from Digital Post. Defaults to None. - message: The message to set on the letter. Defaults to None. - sent_as: The post type the letter was actually sent as. Defaults to None. - """ - values = {} - values["status"] = status - values["updated_at"] = datetime.now() - values["message"] = message - if transaction_id: - values["transaction_id"] = transaction_id - if sent_as: - values["sent_as"] = sent_as - - with connection.get_session() as session: - q = ( - update(Letter) - .where(Letter.id == self.id) - .values(values) - ) - session.execute(q) - session.commit() - - -def add_letters(shipment_id: str, csv_data: list[dict[str, str]]): - """Add multiple new letters to the database based - on a csv file containing letter merge data. - - The letters inherit their post type and creator from the shipment. - - Args: - shipment_id: The id of the shipment the letters belong to. - csv_data: A list of dictionaries containing merge data. - """ - shipment = shipments.get_shipment(shipment_id) - - letter_dicts = [] - - for line in csv_data: - recipient = line[MemoFields.MEMO_MODTAGER.key] - del line[MemoFields.MEMO_MODTAGER.key] - letter_dicts.append( - { - "shipment_id": shipment_id, - "recipient_id": recipient, - "field_data": json.dumps(line), - "post_type": shipment.post_type, - "created_by": shipment.created_by - } - ) - - with connection.get_session() as session: - session.execute(insert(Letter), letter_dicts) - session.commit() - - -def get_letters(shipment_id: str) -> tuple[Letter]: - """Get all letters belonging to a shipment.""" - with connection.get_session() as session: - query = select(Letter).where(Letter.shipment_id == shipment_id) - result = session.execute(query).scalars() - return tuple(result) - - -def get_letters_by_user(user_id: str) -> tuple[Letter]: - """Get all letters created by the given user.""" - with connection.get_session() as session: - query = select(Letter).where(Letter.created_by == user_id) - result = session.execute(query).scalars() - return tuple(result) - - -def abort_letters(shipment_id: str, user: str): - """Set all waiting letters in the given shipment to - aborted. Also add a message about who aborted. - - Args: - shipment_id: The id of the shipment. - user: The name of the user who aborted the shipment. - """ - with connection.get_session() as session: - query = ( - update(Letter) - .values( - status=ShipmentStatus.ABORTED, - message=f"Afbrudt af {user}" - ) - .where( - Letter.shipment_id == shipment_id, - Letter.status == ShipmentStatus.WAITING - ) - ) - session.execute(query) - session.commit() - - -def delete_old_single_letters(): - """Delete single letters that are older than SHIPMENT_LIFETIME_DAYS.""" - logging.info("Cleaning up old single letters.") - - with connection.get_session() as session: - query = select(Letter).where( - (datetime.today() - timedelta(days=config.SHIPMENT_LIFETIME_DAYS)) > Letter.updated_at, - Letter.shipment_id.is_(None) - ) - letters = list(session.execute(query).scalars()) - - for letter in letters: - document_storage.delete_single_letter_doc(letter.id) - session.delete(letter) - - session.commit() - - logging.info(f"Deleted {len(letters)} old single letters.") +"""This module contains the Letter ORM class.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import json +from enum import Enum +import logging +import re + +from sqlalchemy import ForeignKey, insert, select, String, update +from sqlalchemy.orm import Mapped, mapped_column + +from OpenPostbud import config +from OpenPostbud.database.base import Base +from OpenPostbud.database import connection +from OpenPostbud.database.data_types.encrypted_string import EncryptedString +from OpenPostbud.database.data_types.id_generator import create_id +from OpenPostbud.database.common import ShipmentStatus, PostType +from OpenPostbud.database.digital_post import templates +from OpenPostbud.database.digital_post import shipments +from OpenPostbud.database import document_storage +from OpenPostbud.utils import docx_util + + +class MemoFields(Enum): + """An enum class defining the special fields used for + Memo functionality. + a MemoField has the following members: + key: The name of the field when loaded from merge data. + mandatory_digital: Whether the field is mandatory when sending Digital Post. + mandatory_physical: Whether the field is mandatory when sending Fysisk Post. + pattern: The regex pattern for the field's value. + """ + def __init__(self, key: str, mandatory_digital: bool, mandatory_physical: bool, pattern: str): + self.key = key + self.mandatory_digital = mandatory_digital + self.mandatory_physical = mandatory_physical + self.pattern = re.compile(pattern) + + MEMO_MODTAGER = ("Memo Modtager", True, True, r"\d{10}|\d{8}") + MEMO_LABEL = ("Memo Label", True, False, r"\S.*") + + def is_mandatory_for(self, post_type: PostType) -> bool: + """Whether this field is mandatory for the given post type. + + AUTO requires the field if it is mandatory for either route, so the + letter can be sent successfully whichever route a recipient takes. + """ + if post_type == PostType.DIGITAL: + return self.mandatory_digital + if post_type == PostType.PHYSICAL: + return self.mandatory_physical + return self.mandatory_digital or self.mandatory_physical + + +LETTER_ID_FACTORY = create_id("L-", 10) + + +class Letter(Base): + """An ORM class representing a letter.""" + __tablename__ = "Letters" + + id: Mapped[str] = mapped_column(String(12), primary_key=True, default=LETTER_ID_FACTORY) + shipment_id: Mapped[str] = mapped_column(ForeignKey("Shipments.id", ondelete="CASCADE"), nullable=True) + recipient_id: Mapped[str] = mapped_column(EncryptedString()) + updated_at: Mapped[datetime] = mapped_column(default=datetime.now) + status: Mapped[ShipmentStatus] = mapped_column(default=ShipmentStatus.WAITING) + message: Mapped[str] = mapped_column(String(100), nullable=True) + field_data: Mapped[str] = mapped_column(EncryptedString()) + transaction_id: Mapped[str] = mapped_column(nullable=True) + post_type: Mapped[PostType] = mapped_column(default=PostType.DIGITAL) + created_by: Mapped[str] = mapped_column(String(50)) + sent_as: Mapped[PostType] = mapped_column(nullable=True) + + def to_row_dict(self) -> dict[str, str]: + """Convert to a dictionary to be shown in a table.""" + return { + "id": str(self.id), + "recipient": self.recipient_id, + "updated_at": self.updated_at.strftime("%d/%m/%Y %H:%M:%S"), + "status": self.status.value, + "message": self.message, + "sent_as": self.sent_as.value if self.sent_as else "" + } + + def merge_letter(self) -> bytes: + """Merge the letter's merge field data with its template + and convert to pdf. + + Returns: + The merged pdf letter as bytes. + """ + stored_file = document_storage.get_letter_doc(self.shipment_id, self.id) + if stored_file: + return stored_file + + template = templates.get_template_by_shipment(self.shipment_id) + + if template.file_name.endswith(".docx"): + field_data = json.loads(self.field_data) + word_file = docx_util.merge_word_file(template.file_data, field_data) + pdf_file = docx_util.convert_word_to_pdf(word_file) + document_storage.save_letter_doc(self.shipment_id, self.id, pdf_file) + return pdf_file + + return template.file_data + + def set_status(self, status: ShipmentStatus, transaction_id: str | None = None, message: str | None = None, sent_as: PostType | None = None): + """Set the status of the letter in the database. + The transaction id and sent_as are not overwritten if the given value is None. + + Args: + status: The status to set on the letter. + transaction_id: The transaction id from Digital Post. Defaults to None. + message: The message to set on the letter. Defaults to None. + sent_as: The post type the letter was actually sent as. Defaults to None. + """ + values = {} + values["status"] = status + values["updated_at"] = datetime.now() + values["message"] = message + if transaction_id: + values["transaction_id"] = transaction_id + if sent_as: + values["sent_as"] = sent_as + + with connection.get_session() as session: + q = ( + update(Letter) + .where(Letter.id == self.id) + .values(values) + ) + session.execute(q) + session.commit() + + +def add_letters(shipment_id: str, csv_data: list[dict[str, str]]): + """Add multiple new letters to the database based + on a csv file containing letter merge data. + + The letters inherit their post type and creator from the shipment. + + Args: + shipment_id: The id of the shipment the letters belong to. + csv_data: A list of dictionaries containing merge data. + """ + shipment = shipments.get_shipment(shipment_id) + + letter_dicts = [] + + for line in csv_data: + recipient = line[MemoFields.MEMO_MODTAGER.key] + del line[MemoFields.MEMO_MODTAGER.key] + letter_dicts.append( + { + "shipment_id": shipment_id, + "recipient_id": recipient, + "field_data": json.dumps(line), + "post_type": shipment.post_type, + "created_by": shipment.created_by + } + ) + + with connection.get_session() as session: + session.execute(insert(Letter), letter_dicts) + session.commit() + + +def get_letters(shipment_id: str) -> tuple[Letter]: + """Get all letters belonging to a shipment.""" + with connection.get_session() as session: + query = select(Letter).where(Letter.shipment_id == shipment_id) + result = session.execute(query).scalars() + return tuple(result) + + +def get_letters_by_user(user_id: str) -> tuple[Letter]: + """Get all letters created by the given user.""" + with connection.get_session() as session: + query = select(Letter).where(Letter.created_by == user_id) + result = session.execute(query).scalars() + return tuple(result) + + +def abort_letters(shipment_id: str, user: str): + """Set all waiting letters in the given shipment to + aborted. Also add a message about who aborted. + + Args: + shipment_id: The id of the shipment. + user: The name of the user who aborted the shipment. + """ + with connection.get_session() as session: + query = ( + update(Letter) + .values( + status=ShipmentStatus.ABORTED, + message=f"Afbrudt af {user}" + ) + .where( + Letter.shipment_id == shipment_id, + Letter.status == ShipmentStatus.WAITING + ) + ) + session.execute(query) + session.commit() + + +def delete_old_single_letters(): + """Delete single letters that are older than SHIPMENT_LIFETIME_DAYS.""" + logging.info("Cleaning up old single letters.") + + with connection.get_session() as session: + query = select(Letter).where( + (datetime.today() - timedelta(days=config.SHIPMENT_LIFETIME_DAYS)) > Letter.updated_at, + Letter.shipment_id.is_(None) + ) + letters = list(session.execute(query).scalars()) + + for letter in letters: + document_storage.delete_single_letter_doc(letter.id) + session.delete(letter) + + session.commit() + + logging.info(f"Deleted {len(letters)} old single letters.") diff --git a/src/OpenPostbud/database/digital_post/shipments.py b/src/OpenPostbud/database/digital_post/shipments.py index da52a73..d7944be 100644 --- a/src/OpenPostbud/database/digital_post/shipments.py +++ b/src/OpenPostbud/database/digital_post/shipments.py @@ -1,100 +1,100 @@ -"""This module is contains for the Shipment ORM class.""" - -from datetime import datetime, timedelta -import logging - -from sqlalchemy import String, ForeignKey, select -from sqlalchemy.orm import Mapped, mapped_column - -from OpenPostbud import config -from OpenPostbud.database.base import Base -from OpenPostbud.database import connection -from OpenPostbud.database.common import PostType -from OpenPostbud.database.data_types.id_generator import create_id -from OpenPostbud.database import document_storage - - -class Shipment(Base): - """An ORM class representing a Shipment.""" - __tablename__ = "Shipments" - - id: Mapped[str] = mapped_column(String(12), primary_key=True, default=create_id("S-", 10)) - name: Mapped[str] = mapped_column(String(50)) - description: Mapped[str] = mapped_column(String(200)) - template_id: Mapped[int] = mapped_column(ForeignKey("Templates.id")) - created_at: Mapped[datetime] = mapped_column(default=datetime.now) - created_by: Mapped[str] = mapped_column(String(50)) - post_type: Mapped[PostType] = mapped_column(default=PostType.DIGITAL) - - def to_row_dict(self): - """Convert to a dictionary to be shown in a table.""" - return { - "id": str(self.id), - "name": self.name, - "post_type": self.post_type.value, - "created_at": self.created_at.strftime("%d/%m/%Y %H:%M:%S"), - "created_by": self.created_by, - } - - def get_deletion_date(self) -> datetime: - """Get the deletion date of the shipment.""" - return self.created_at + timedelta(days=config.SHIPMENT_LIFETIME_DAYS) - - -def add_shipment(name: str, description: str, created_by: str, template_id: int, post_type: PostType = PostType.DIGITAL) -> str: - """Add a new Shipment to the database. - - Args: - name: The name of the shipment. - description: The description of the shipment. - created_by: The name of the user who created the shipment. - template_id: The id of the template connected to the shipment. - post_type: How the shipment should be sent. Defaults to Digital Post. - - Returns: - The id of the new shipment. - """ - shipment = Shipment( - name=name, - description=description, - template_id=template_id, - created_by=created_by, - post_type=post_type - ) - - with connection.get_session() as session: - session.add(shipment) - session.commit() - return shipment.id - - -def get_shipments() -> tuple[Shipment]: - """Get all shipments from the database.""" - with connection.get_session() as session: - result = session.execute(select(Shipment).order_by(Shipment.created_at.desc())).scalars() - return tuple(result) - - -def get_shipment(shipment_id: str) -> Shipment | None: - """Get a single shipment from the database.""" - with connection.get_session() as session: - return session.get(Shipment, shipment_id) - - -def delete_old_shipments(): - """Delete shipments that are older than SHIPMENT_LIFETIME_DAYS. - Letters are also deleted by database cascade. - """ - logging.info("Cleaning up old shipments.") - - with connection.get_session() as session: - query = select(Shipment).where((datetime.today() - timedelta(days=config.SHIPMENT_LIFETIME_DAYS)) > Shipment.created_at) - shipments = list(session.execute(query).scalars()) - - for shipment in shipments: - document_storage.delete_shipment_docs(shipment.id) - session.delete(shipment) - - session.commit() - - logging.info(f"Deleted {len(shipments)} old shipments.") +"""This module is contains for the Shipment ORM class.""" + +from datetime import datetime, timedelta +import logging + +from sqlalchemy import String, ForeignKey, select +from sqlalchemy.orm import Mapped, mapped_column + +from OpenPostbud import config +from OpenPostbud.database.base import Base +from OpenPostbud.database import connection +from OpenPostbud.database.common import PostType +from OpenPostbud.database.data_types.id_generator import create_id +from OpenPostbud.database import document_storage + + +class Shipment(Base): + """An ORM class representing a Shipment.""" + __tablename__ = "Shipments" + + id: Mapped[str] = mapped_column(String(12), primary_key=True, default=create_id("S-", 10)) + name: Mapped[str] = mapped_column(String(50)) + description: Mapped[str] = mapped_column(String(200)) + template_id: Mapped[int] = mapped_column(ForeignKey("Templates.id")) + created_at: Mapped[datetime] = mapped_column(default=datetime.now) + created_by: Mapped[str] = mapped_column(String(50)) + post_type: Mapped[PostType] = mapped_column(default=PostType.DIGITAL) + + def to_row_dict(self): + """Convert to a dictionary to be shown in a table.""" + return { + "id": str(self.id), + "name": self.name, + "post_type": self.post_type.value, + "created_at": self.created_at.strftime("%d/%m/%Y %H:%M:%S"), + "created_by": self.created_by, + } + + def get_deletion_date(self) -> datetime: + """Get the deletion date of the shipment.""" + return self.created_at + timedelta(days=config.SHIPMENT_LIFETIME_DAYS) + + +def add_shipment(name: str, description: str, created_by: str, template_id: int, post_type: PostType = PostType.DIGITAL) -> str: + """Add a new Shipment to the database. + + Args: + name: The name of the shipment. + description: The description of the shipment. + created_by: The name of the user who created the shipment. + template_id: The id of the template connected to the shipment. + post_type: How the shipment should be sent. Defaults to Digital Post. + + Returns: + The id of the new shipment. + """ + shipment = Shipment( + name=name, + description=description, + template_id=template_id, + created_by=created_by, + post_type=post_type + ) + + with connection.get_session() as session: + session.add(shipment) + session.commit() + return shipment.id + + +def get_shipments() -> tuple[Shipment]: + """Get all shipments from the database.""" + with connection.get_session() as session: + result = session.execute(select(Shipment).order_by(Shipment.created_at.desc())).scalars() + return tuple(result) + + +def get_shipment(shipment_id: str) -> Shipment | None: + """Get a single shipment from the database.""" + with connection.get_session() as session: + return session.get(Shipment, shipment_id) + + +def delete_old_shipments(): + """Delete shipments that are older than SHIPMENT_LIFETIME_DAYS. + Letters are also deleted by database cascade. + """ + logging.info("Cleaning up old shipments.") + + with connection.get_session() as session: + query = select(Shipment).where((datetime.today() - timedelta(days=config.SHIPMENT_LIFETIME_DAYS)) > Shipment.created_at) + shipments = list(session.execute(query).scalars()) + + for shipment in shipments: + document_storage.delete_shipment_docs(shipment.id) + session.delete(shipment) + + session.commit() + + logging.info(f"Deleted {len(shipments)} old shipments.") diff --git a/src/OpenPostbud/routes/admin/api_users.py b/src/OpenPostbud/routes/admin/api_users.py index 79e8186..e9f4f1a 100644 --- a/src/OpenPostbud/routes/admin/api_users.py +++ b/src/OpenPostbud/routes/admin/api_users.py @@ -1,140 +1,140 @@ -"""This module is responsible for the admin page for api users.""" - -from nicegui import app, ui, APIRouter -from nicegui.events import ClickEventArguments -from fastapi import HTTPException - -from OpenPostbud import ui_components -from OpenPostbud.database import api_users -from OpenPostbud.database.digital_post import letters - -router = APIRouter() - -USER_COLUMNS = [ - {'name': "name", 'label': "Navn", 'field': "name"}, - {'name': "id", 'label': "ID", 'field': "id"}, - {'name': "active", 'label': "Status", 'field': "active"}, - {'name': "created_at", 'label': "Oprettet", 'field': "created_at"} -] - -LETTERS_COLUMNS = [ - {'name': "id", 'label': "ID", 'field': "id"}, - {'name': "recipient", 'label': "Modtager", 'field': "recipient"}, - {'name': "status", 'label': "Status", 'field': "status"}, - {'name': "sent_as", 'label': "Sendt som", 'field': "sent_as"}, - {'name': "updated_at", 'label': "Status Opdateret", 'field': "updated_at"}, - {'name': "Message", 'label': "Besked", 'field': "message"} -] - -COLUMN_DEFAULTS = {'align': 'left', 'sortable': True, 'style': 'padding-right: 5rem'} - - -@router.page("/api-users", name="API Users") -def api_users_page(): - """Show the api users page.""" - ui_components.header() - ApiUsersPage() - - -class ApiUsersPage: - """A class representing the api users page.""" - def __init__(self): - ui.label("Velkommen til Api brugere!").classes("text-4xl") - ui.button("Ny api bruger", on_click=self._add_api_user) - - self.table = ui.table(rows=[], columns=USER_COLUMNS, column_defaults=COLUMN_DEFAULTS) - self.table.on("rowClick", self._row_click) - self._update_table() - - def _update_table(self): - """Update the api user table with the newest data from the database.""" - rows = [user.to_row_dict() for user in api_users.get_api_users()] - self.table.rows = rows - - def _row_click(self, event): - """Open a dialog for the clicked api user row.""" - ui.navigate.to(app.url_path_for("API User Detail", api_user_id=event.args[1]['id'])) - - async def _add_api_user(self): - """Show a popup prompt for a new api user name and create the user with the given name.""" - name = await ui_components.text_input_popup("Indtast navn på ny API bruger", "Navn") - if not name: - return - api_key = api_users.create_api_user(name) - self._update_table() - - def copy_button_click(event: ClickEventArguments): - ui.clipboard.write(api_key) - event.sender.props("icon=check_circle label=Kopieret") - - with ui.dialog(value=True).props('persistent') as dialog, ui.card(): - ui.label("Kopier nedenstående api-nøgle. Den kan ikke vises igen.").classes("text-bold") - ui.label(api_key) - with ui.row(): - ui.button("Kopier", on_click=copy_button_click) - ui.button("Luk", on_click=dialog.close).props("flat") - - -@router.page("/api-users/{api_user_id}", name="API User Detail") -def api_user_detail_page(api_user_id: str): - """Show the api user detail page.""" - ui_components.header() - ApiUserDetailPage(api_user_id) - - -class ApiUserDetailPage: - """A class representing the api user detail page.""" - def __init__(self, api_user_id: str): - self.user = api_users.get_api_user(api_user_id) - if not self.user: - raise HTTPException(404, f"Ingen api bruger med id {api_user_id} fundet.") - - self._show_header() - - with ui.row(): - self.disable_button = ui_components.DisableButton("Deaktiver", on_click=self._deactivate_user) - if not self.user.active: - self.disable_button.disable() - ui.button("Slet", on_click=self._delete_user) - - self._show_letters_table() - - def _show_letters_table(self): - """Show the letters table.""" - letter_rows = [letter.to_row_dict() for letter in letters.get_letters_by_user(self.user.id)] - self.letter_table = ui_components.SearchTable(title="Breve", rows=letter_rows, columns=LETTERS_COLUMNS, column_defaults=COLUMN_DEFAULTS, pagination=50, download_button=True, search_field=True) - ui_components.obscure_id_column(self.letter_table, "recipient") - - async def _deactivate_user(self): - """Show a confirmation popup and deactivate the user with the given id.""" - if not await ui_components.question_popup(f"Vil du deaktivere api bruger {self.user.id}?", "Ja", "Nej"): - return - - api_users.deactivate_api_user(self.user.id) - ui.notify(f"Api bruger deaktiveret: {self.user.id}", type='positive') - self.disable_button.disable() - self._show_header.refresh() - - async def _delete_user(self): - """Show a confirmation popup and delete the user with the given id. - An api user can only be deleted if it doesn't have any letters attached. - """ - if letters.get_letters_by_user(self.user.id): - await ui_components.info_popup("Du kan ikke slette en api bruger med eksisterende breve tilknyttet.") - return - - if not await ui_components.question_popup(f"Vil du slette api bruger {self.user.id}?", "Ja", "Nej"): - return - - api_users.delete_api_user(self.user.id) - ui.navigate.to(app.url_path_for("API Users")) - - @ui.refreshable - def _show_header(self): - self.user = api_users.get_api_user(self.user.id) - with ui.row(): - ui.label(f"Api bruger {self.user.name} - {self.user.id}").classes("text-4xl") - if self.user.active: - ui.chip("Aktiv", color="positive", text_color="white").classes("text-lg") - else: - ui.chip("Inaktiv", text_color="white").classes("text-lg") +"""This module is responsible for the admin page for api users.""" + +from nicegui import app, ui, APIRouter +from nicegui.events import ClickEventArguments +from fastapi import HTTPException + +from OpenPostbud import ui_components +from OpenPostbud.database import api_users +from OpenPostbud.database.digital_post import letters + +router = APIRouter() + +USER_COLUMNS = [ + {'name': "name", 'label': "Navn", 'field': "name"}, + {'name': "id", 'label': "ID", 'field': "id"}, + {'name': "active", 'label': "Status", 'field': "active"}, + {'name': "created_at", 'label': "Oprettet", 'field': "created_at"} +] + +LETTERS_COLUMNS = [ + {'name': "id", 'label': "ID", 'field': "id"}, + {'name': "recipient", 'label': "Modtager", 'field': "recipient"}, + {'name': "status", 'label': "Status", 'field': "status"}, + {'name': "sent_as", 'label': "Sendt som", 'field': "sent_as"}, + {'name': "updated_at", 'label': "Status Opdateret", 'field': "updated_at"}, + {'name': "Message", 'label': "Besked", 'field': "message"} +] + +COLUMN_DEFAULTS = {'align': 'left', 'sortable': True, 'style': 'padding-right: 5rem'} + + +@router.page("/api-users", name="API Users") +def api_users_page(): + """Show the api users page.""" + ui_components.header() + ApiUsersPage() + + +class ApiUsersPage: + """A class representing the api users page.""" + def __init__(self): + ui.label("Velkommen til Api brugere!").classes("text-4xl") + ui.button("Ny api bruger", on_click=self._add_api_user) + + self.table = ui.table(rows=[], columns=USER_COLUMNS, column_defaults=COLUMN_DEFAULTS) + self.table.on("rowClick", self._row_click) + self._update_table() + + def _update_table(self): + """Update the api user table with the newest data from the database.""" + rows = [user.to_row_dict() for user in api_users.get_api_users()] + self.table.rows = rows + + def _row_click(self, event): + """Open a dialog for the clicked api user row.""" + ui.navigate.to(app.url_path_for("API User Detail", api_user_id=event.args[1]['id'])) + + async def _add_api_user(self): + """Show a popup prompt for a new api user name and create the user with the given name.""" + name = await ui_components.text_input_popup("Indtast navn på ny API bruger", "Navn") + if not name: + return + api_key = api_users.create_api_user(name) + self._update_table() + + def copy_button_click(event: ClickEventArguments): + ui.clipboard.write(api_key) + event.sender.props("icon=check_circle label=Kopieret") + + with ui.dialog(value=True).props('persistent') as dialog, ui.card(): + ui.label("Kopier nedenstående api-nøgle. Den kan ikke vises igen.").classes("text-bold") + ui.label(api_key) + with ui.row(): + ui.button("Kopier", on_click=copy_button_click) + ui.button("Luk", on_click=dialog.close).props("flat") + + +@router.page("/api-users/{api_user_id}", name="API User Detail") +def api_user_detail_page(api_user_id: str): + """Show the api user detail page.""" + ui_components.header() + ApiUserDetailPage(api_user_id) + + +class ApiUserDetailPage: + """A class representing the api user detail page.""" + def __init__(self, api_user_id: str): + self.user = api_users.get_api_user(api_user_id) + if not self.user: + raise HTTPException(404, f"Ingen api bruger med id {api_user_id} fundet.") + + self._show_header() + + with ui.row(): + self.disable_button = ui_components.DisableButton("Deaktiver", on_click=self._deactivate_user) + if not self.user.active: + self.disable_button.disable() + ui.button("Slet", on_click=self._delete_user) + + self._show_letters_table() + + def _show_letters_table(self): + """Show the letters table.""" + letter_rows = [letter.to_row_dict() for letter in letters.get_letters_by_user(self.user.id)] + self.letter_table = ui_components.SearchTable(title="Breve", rows=letter_rows, columns=LETTERS_COLUMNS, column_defaults=COLUMN_DEFAULTS, pagination=50, download_button=True, search_field=True) + ui_components.obscure_id_column(self.letter_table, "recipient") + + async def _deactivate_user(self): + """Show a confirmation popup and deactivate the user with the given id.""" + if not await ui_components.question_popup(f"Vil du deaktivere api bruger {self.user.id}?", "Ja", "Nej"): + return + + api_users.deactivate_api_user(self.user.id) + ui.notify(f"Api bruger deaktiveret: {self.user.id}", type='positive') + self.disable_button.disable() + self._show_header.refresh() + + async def _delete_user(self): + """Show a confirmation popup and delete the user with the given id. + An api user can only be deleted if it doesn't have any letters attached. + """ + if letters.get_letters_by_user(self.user.id): + await ui_components.info_popup("Du kan ikke slette en api bruger med eksisterende breve tilknyttet.") + return + + if not await ui_components.question_popup(f"Vil du slette api bruger {self.user.id}?", "Ja", "Nej"): + return + + api_users.delete_api_user(self.user.id) + ui.navigate.to(app.url_path_for("API Users")) + + @ui.refreshable + def _show_header(self): + self.user = api_users.get_api_user(self.user.id) + with ui.row(): + ui.label(f"Api bruger {self.user.name} - {self.user.id}").classes("text-4xl") + if self.user.active: + ui.chip("Aktiv", color="positive", text_color="white").classes("text-lg") + else: + ui.chip("Inaktiv", text_color="white").classes("text-lg") diff --git a/src/OpenPostbud/routes/api/letters.py b/src/OpenPostbud/routes/api/letters.py index 3be5272..4158fc3 100644 --- a/src/OpenPostbud/routes/api/letters.py +++ b/src/OpenPostbud/routes/api/letters.py @@ -1,91 +1,91 @@ -"""This module defines routes for the shipments api.""" - -from datetime import datetime -from typing import Annotated -import base64 -import json - -from fastapi import APIRouter, Depends, status -from fastapi.exceptions import HTTPException -from pydantic import BaseModel, Field - -from OpenPostbud.database import connection, document_storage -from OpenPostbud.database.common import PostType -from OpenPostbud.database.digital_post import letters -from OpenPostbud.routes.api.dependencies import check_bearer_token - - -router = APIRouter() - - -class LetterDetail(BaseModel): - """A model representing a letter response.""" - id: str - shipment_id: str | None - recipient_id: str - status: str - status_time: datetime - sent_as: PostType | None - letter_pdf: str | None = Field(description="Base64-encoded file contents.") - - -class SendLetterModel(BaseModel): - """A model representing a shipment response.""" - recipient_id: str - memo_label: str | None - post_type: PostType - letter_document: str = Field(description="Base64-encoded file contents.") - - -class SendLetterResponse(BaseModel): - """A model describing a response from the send_letter endpoint.""" - id: str - - -@router.post("/send_letter", tags=["Letters"]) -def send_letter(letter: SendLetterModel, token: Annotated[dict[str, str], Depends(check_bearer_token)]) -> SendLetterResponse: - """Send a single letter without a shipment.""" - - new_letter = letters.Letter( - id=letters.LETTER_ID_FACTORY(), - shipment_id=None, - recipient_id=letter.recipient_id, - field_data=json.dumps({letters.MemoFields.MEMO_LABEL.key: letter.memo_label}), - post_type=letter.post_type, - created_by=token["sub"] - ) - - document_storage.save_letter_doc(None, new_letter.id, base64.b64decode(letter.letter_document)) - - with connection.get_session() as session: - session.add(new_letter) - session.commit() - - return SendLetterResponse(id=new_letter.id) - - -@router.get("/letter/{letter_id}", tags=["Letters"]) -def get_letter(letter_id: str, get_pdf: bool = True) -> LetterDetail: - """Get a letter by id. Merges and returns the final letter as a pdf - in base 64.""" - with connection.get_session() as session: - letter = session.get(letters.Letter, letter_id) - - if not letter: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "No letter exists with the given id") - - if get_pdf: - pdf = letter.merge_letter() - pdf_64 = base64.b64encode(pdf).decode() - else: - pdf_64 = None - - return LetterDetail( - id=letter.id, - shipment_id=letter.shipment_id, - recipient_id=letter.recipient_id, - status=letter.status, - status_time=letter.updated_at, - letter_pdf=pdf_64, - sent_as=letter.sent_as - ) +"""This module defines routes for the shipments api.""" + +from datetime import datetime +from typing import Annotated +import base64 +import json + +from fastapi import APIRouter, Depends, status +from fastapi.exceptions import HTTPException +from pydantic import BaseModel, Field + +from OpenPostbud.database import connection, document_storage +from OpenPostbud.database.common import PostType +from OpenPostbud.database.digital_post import letters +from OpenPostbud.routes.api.dependencies import check_bearer_token + + +router = APIRouter() + + +class LetterDetail(BaseModel): + """A model representing a letter response.""" + id: str + shipment_id: str | None + recipient_id: str + status: str + status_time: datetime + sent_as: PostType | None + letter_pdf: str | None = Field(description="Base64-encoded file contents.") + + +class SendLetterModel(BaseModel): + """A model representing a shipment response.""" + recipient_id: str + memo_label: str | None + post_type: PostType + letter_document: str = Field(description="Base64-encoded file contents.") + + +class SendLetterResponse(BaseModel): + """A model describing a response from the send_letter endpoint.""" + id: str + + +@router.post("/send_letter", tags=["Letters"]) +def send_letter(letter: SendLetterModel, token: Annotated[dict[str, str], Depends(check_bearer_token)]) -> SendLetterResponse: + """Send a single letter without a shipment.""" + + new_letter = letters.Letter( + id=letters.LETTER_ID_FACTORY(), + shipment_id=None, + recipient_id=letter.recipient_id, + field_data=json.dumps({letters.MemoFields.MEMO_LABEL.key: letter.memo_label}), + post_type=letter.post_type, + created_by=token["sub"] + ) + + document_storage.save_letter_doc(None, new_letter.id, base64.b64decode(letter.letter_document)) + + with connection.get_session() as session: + session.add(new_letter) + session.commit() + + return SendLetterResponse(id=new_letter.id) + + +@router.get("/letter/{letter_id}", tags=["Letters"]) +def get_letter(letter_id: str, get_pdf: bool = True) -> LetterDetail: + """Get a letter by id. Merges and returns the final letter as a pdf + in base 64.""" + with connection.get_session() as session: + letter = session.get(letters.Letter, letter_id) + + if not letter: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "No letter exists with the given id") + + if get_pdf: + pdf = letter.merge_letter() + pdf_64 = base64.b64encode(pdf).decode() + else: + pdf_64 = None + + return LetterDetail( + id=letter.id, + shipment_id=letter.shipment_id, + recipient_id=letter.recipient_id, + status=letter.status, + status_time=letter.updated_at, + letter_pdf=pdf_64, + sent_as=letter.sent_as + ) diff --git a/src/OpenPostbud/routes/api/shipments.py b/src/OpenPostbud/routes/api/shipments.py index cdfc2cf..6af51be 100644 --- a/src/OpenPostbud/routes/api/shipments.py +++ b/src/OpenPostbud/routes/api/shipments.py @@ -1,89 +1,89 @@ -"""This module defines routes for the shipments api.""" - -from datetime import datetime -import base64 - -from fastapi import APIRouter, status -from fastapi.exceptions import HTTPException -from pydantic import BaseModel, Field - -from OpenPostbud.database import document_storage -from OpenPostbud.database.digital_post import shipments as shipments_db -from OpenPostbud.database.digital_post import letters as letters_db - - -router = APIRouter() - - -class ShipmentModel(BaseModel): - """A pydantic model representing a shipment response.""" - id: str - name: str - created_at: datetime - created_by: str - - -class ShipmentDetail(ShipmentModel): - """A pydantic model representing a shipment response - including a list of letter ids and description. - """ - description: str - letter_ids: list[str] - has_attachments: bool - - -class AttachmentModel(BaseModel): - """A pydantic model representing an attachment response.""" - file_name: str - file_data: str = Field(description="Base64-encoded file contents.") - - -@router.get("/shipments", tags=["Shipments"]) -def get_shipments() -> list[ShipmentModel]: - """Get all shipments and return as a list.""" - shipments = shipments_db.get_shipments() - - return [ - ShipmentModel( - id=s.id, - name=s.name, - created_at=s.created_at, - created_by=s.created_by - ) - for s in shipments - ] - - -@router.get("/shipment/{shipment_id}", tags=["Shipments"]) -def get_shipment(shipment_id: str) -> ShipmentDetail: - """Get a shipment by id.""" - - shipment = shipments_db.get_shipment(shipment_id) - - if not shipment: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "No shipment exists with the given id") - - letters = letters_db.get_letters(shipment.id) - letter_ids = [letter.id for letter in letters] - - return ShipmentDetail( - id=shipment.id, - name=shipment.name, - description=shipment.description, - created_at=shipment.created_at, - created_by=shipment.created_by, - letter_ids=letter_ids, - has_attachments=len(document_storage.list_attachments(shipment_id)) > 0 - ) - - -@router.get("/shipment/{shipment_id}/attachments", tags=["Shipments"]) -def get_attachments(shipment_id: str) -> list[AttachmentModel]: - """Get all attachments for the given shipment.""" - shipment = shipments_db.get_shipment(shipment_id) - - if not shipment: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "No shipment exists with the given id") - - attachments = document_storage.get_attachments(shipment_id) - return [AttachmentModel(file_name=a.name, file_data=base64.b64encode(a.data).decode()) for a in attachments] +"""This module defines routes for the shipments api.""" + +from datetime import datetime +import base64 + +from fastapi import APIRouter, status +from fastapi.exceptions import HTTPException +from pydantic import BaseModel, Field + +from OpenPostbud.database import document_storage +from OpenPostbud.database.digital_post import shipments as shipments_db +from OpenPostbud.database.digital_post import letters as letters_db + + +router = APIRouter() + + +class ShipmentModel(BaseModel): + """A pydantic model representing a shipment response.""" + id: str + name: str + created_at: datetime + created_by: str + + +class ShipmentDetail(ShipmentModel): + """A pydantic model representing a shipment response + including a list of letter ids and description. + """ + description: str + letter_ids: list[str] + has_attachments: bool + + +class AttachmentModel(BaseModel): + """A pydantic model representing an attachment response.""" + file_name: str + file_data: str = Field(description="Base64-encoded file contents.") + + +@router.get("/shipments", tags=["Shipments"]) +def get_shipments() -> list[ShipmentModel]: + """Get all shipments and return as a list.""" + shipments = shipments_db.get_shipments() + + return [ + ShipmentModel( + id=s.id, + name=s.name, + created_at=s.created_at, + created_by=s.created_by + ) + for s in shipments + ] + + +@router.get("/shipment/{shipment_id}", tags=["Shipments"]) +def get_shipment(shipment_id: str) -> ShipmentDetail: + """Get a shipment by id.""" + + shipment = shipments_db.get_shipment(shipment_id) + + if not shipment: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "No shipment exists with the given id") + + letters = letters_db.get_letters(shipment.id) + letter_ids = [letter.id for letter in letters] + + return ShipmentDetail( + id=shipment.id, + name=shipment.name, + description=shipment.description, + created_at=shipment.created_at, + created_by=shipment.created_by, + letter_ids=letter_ids, + has_attachments=len(document_storage.list_attachments(shipment_id)) > 0 + ) + + +@router.get("/shipment/{shipment_id}/attachments", tags=["Shipments"]) +def get_attachments(shipment_id: str) -> list[AttachmentModel]: + """Get all attachments for the given shipment.""" + shipment = shipments_db.get_shipment(shipment_id) + + if not shipment: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "No shipment exists with the given id") + + attachments = document_storage.get_attachments(shipment_id) + return [AttachmentModel(file_name=a.name, file_data=base64.b64encode(a.data).decode()) for a in attachments] diff --git a/src/OpenPostbud/routes/user/send_post.py b/src/OpenPostbud/routes/user/send_post.py index 2fa98be..da8dd20 100644 --- a/src/OpenPostbud/routes/user/send_post.py +++ b/src/OpenPostbud/routes/user/send_post.py @@ -1,448 +1,448 @@ -"""This module contains the 'send_post' page.""" - -from csv import DictReader -from collections import Counter -from collections.abc import Callable -from pathlib import Path -from typing import Literal, NamedTuple -import asyncio - -from nicegui import ui, APIRouter, app -from nicegui import run as nicegui_run -from nicegui.events import UploadEventArguments -from jinja2.exceptions import TemplateSyntaxError - -from OpenPostbud import ui_components -from OpenPostbud.database import document_storage -from OpenPostbud.middleware import authentication -from OpenPostbud.database.digital_post import letters, shipments, templates -from OpenPostbud.database.digital_post.letters import MemoFields -from OpenPostbud.database.common import PostType -from OpenPostbud.utils import docx_util - - -router = APIRouter() - - -class ValidationMessage(NamedTuple): - """A message produced by csv validation, ready to be shown in a MessageArea.""" - text: str - type_: Literal["positive", "warning", "negative"] - - -@router.page("/send_digital_post", name="Send Post") -def page(): - """Show the 'send_post page.""" - ui_components.header() - ui.label("Ny Forsendelse").classes("text-4xl") - ui.label("På denne side kan du oprette en ny forsendelse af Digital Post eller Fysisk Post.") - - SendPostPage() - - -class SendPostPage: - """A class representing the 'send_post' page.""" - def __init__(self): - with ui.stepper().props("vertical flat done-color=green") as stepper: - with ui.step("Beskrivelse"): - self.step1 = MetadataStep() - _stepper_navigation(stepper, prev_button=False, validate_callback=self.step1.validate) - with ui.step("Skabelon og data"): - self.step2 = FileUploadStep( - on_csv_changed=self._on_csv_data_changed, - get_post_type=lambda: self.step1.post_type.value, - ) - _stepper_navigation(stepper, validate_callback=self.step2.validate) - with ui.step("Vedhæftede filer") as step: - self.step3 = AttachmentsStep() - _stepper_navigation(stepper) - # Disable entire step if selected post type is physical - step.bind_enabled_from(self.step1.post_type, 'value', backward=lambda v: v != PostType.PHYSICAL) - with ui.step("Gennemgå eksempler"): - self.step4 = ExamplesStep(merge_letter=self.step2.merge_letter) - _stepper_navigation(stepper) - with ui.step("Send post"): - ui.button("Send Post", on_click=self._send_post) - _stepper_navigation(stepper, next_button=False) - - # Re-run csv validation when the post type changes, since the set of - # mandatory fields depends on it. - self.step1.post_type.on_value_change(self.step2.refresh_messages) - - def _on_csv_data_changed(self, fields: list[str], rows: list[dict[str, str]] | None): - """Forward csv changes from step 2 to step 3.""" - self.step4.set_data(fields, rows) - - async def _send_post(self): - """Add the shipment and letters to the database and navigate - to the detail page of the shipment. - """ - # Read the attachments before the spinner dialog steals focus, since it - # relies on a round-trip to the client. - attachments = await self.step3.get_attachments() - - with ui.dialog(value=True) as dialog: - dialog.props("persistent") - ui.spinner(size="5em") - - try: - template_id = templates.add_template(self.step2.template_name, self.step2.template_bytes) - shipment_id = shipments.add_shipment( - self.step1.shipment_name.value, - self.step1.shipment_desc.value, - authentication.get_current_user(), - template_id, - self.step1.post_type.value) - letters.add_letters(shipment_id, self.step2.csv_data) - document_storage.add_attachments(shipment_id, attachments) - ui.navigate.to(app.url_path_for("Shipment Detail", shipment_id=shipment_id)) - finally: - dialog.close() - - -class MetadataStep: - """A class representing the first step in the Send Post flow. - Here the user enters a name and description for the shipment. - """ - def __init__(self): - ui.label("Angiv et navn og beskrivelse af forsendelsen, så den kan genkendes senere.") - ui.label("Navn og beskrivelse påvirker ikke forsendelsens indhold.") - self.shipment_name = ui.input( - "Forsendelse navn", - validation={"Maks 50 tegn": lambda v: len(v) <= 50, "Skal udfyldes": lambda v: len(v) != 0}, - ).classes("w-full") - self.shipment_desc = ui.textarea( - "Forsendelse beskrivelse", - validation={"Maks 200 tegn": lambda v: len(v) <= 200, "Skal udfyldes": lambda v: len(v) != 0}, - ).classes("w-full") - - ui.label("Vælg hvordan forsendelsen skal sendes.") - self.post_type = ui.radio({pt: pt.value for pt in PostType}, value=PostType.DIGITAL) - physical_hint = ui.label( - "Bemærk: Ved Fysisk Post skal modtagerens adresse fremgå af brevet, så den kan ses i kuvertens rude." - ).classes("text-secondary") - physical_hint.bind_visibility_from(self.post_type, "value", backward=lambda v: v != PostType.DIGITAL) - - def validate(self) -> bool: - """Validator function for step 1.""" - name_ok = self.shipment_name.validate() - desc_ok = self.shipment_desc.validate() - if not (name_ok and desc_ok): - ui.notify("Udfyld venligst alle felter", type='warning') - return False - return True - - -# pylint: disable-next=too-many-instance-attributes -class FileUploadStep: - """A class representing the second step in the Send Post flow. - Here the user uploads a template and merge data. - """ - def __init__(self, on_csv_changed: Callable[[list[str], list[dict[str, str]] | None], None], get_post_type: Callable[[], PostType]): - self._on_csv_changed = on_csv_changed - self._get_post_type = get_post_type - self.template_name: str | None = None - self.template_bytes: bytes | None = None - self.template_fields: list[str] = [] - self.csv_data: list[dict[str, str]] | None = None - self.csv_fields: list[str] = [] - - with ui.grid(columns=2): - ui.label("Upload skabelon (.docx, .pdf)").classes("text-bold") - ui.label("Upload flettedata (.csv)").classes("text-bold") - - self._template_upload = ui.upload(on_upload=self._on_template_upload, max_files=1, auto_upload=True).props("accept=.docx,.pdf") - self._csv_upload = ui.upload(on_upload=self._on_csv_upload, max_files=1, auto_upload=True).props("accept=.csv") - self._template_upload.on("removed", self._remove_template) - self._csv_upload.on("removed", self._remove_csv) - - self.template_reset_button = ui_components.DisableButton("Nulstil skabelon", on_click=self._remove_template) - self.template_reset_button.disable() - self.csv_reset_button = ui_components.DisableButton("Nulstil flettedata", on_click=self._remove_csv) - self.csv_reset_button.disable() - - ui.label("Flettefelter i skabelon") - ui.label("Datakolonner i csv") - - self.template_fields_area = ui.scroll_area().classes("border border-gray-300") - self.csv_fields_area = ui.scroll_area().classes("border border-gray-300") - - self.message_area = ui_components.MessageArea().classes("border border-gray-300") - - def validate(self) -> bool: - """Validate that both template and merge data has been uploaded.""" - if not self.template_bytes: - ui.notify("Skabelon mangler", type="warning") - if not self.csv_data: - ui.notify("Flettedata mangler", type="warning") - return bool(self.template_bytes and self.csv_data) - - def merge_letter(self, merge_data: dict[str, str]) -> bytes: - """Use the template and merge data to create an example letter.""" - if self.template_name.endswith(".docx"): - word_file = docx_util.merge_word_file(self.template_bytes, merge_data) - return docx_util.convert_word_to_pdf(word_file) - return self.template_bytes - - async def _on_template_upload(self, e: UploadEventArguments): - """Read the merge fields from the uploaded template and refresh the field list.""" - self.template_reset_button.enable() - self.template_name = e.file.name - self.template_bytes = await e.file.read() - - if self.template_name.endswith(".docx"): - try: - self.template_fields = docx_util.get_merge_fields(self.template_bytes) - except TemplateSyntaxError as error: - ui.notify(f"Syntaksfejl i skabelon: {error}", type="negative", timeout=0, actions=[{"label": "Luk", "color": "white"}]) - self._remove_template() - raise - else: - self.template_fields = [] - - self._update_field_tables() - self.refresh_messages() - - async def _on_csv_upload(self, e: UploadEventArguments): - """Read the columns from the uploaded csv, refresh the field list, - push the data to step 3, and run validation. - """ - self.csv_reset_button.enable() - file_content = await e.file.text(encoding="utf-8-sig") - - dict_reader = DictReader(file_content.splitlines()) - self.csv_fields = sorted(list(dict_reader.fieldnames)) - self.csv_data = list(dict_reader) - self._update_field_tables() - self._on_csv_changed(self.csv_fields, self.csv_data) - self.refresh_messages() - - def _remove_template(self): - self._template_upload.reset() - self.template_reset_button.disable() - self.template_fields = [] - self.template_bytes = None - self._update_field_tables() - self.refresh_messages() - - def _remove_csv(self): - self._csv_upload.reset() - self.csv_reset_button.disable() - self.csv_fields = [] - self.csv_data = None - self._update_field_tables() - self._on_csv_changed(self.csv_fields, self.csv_data) - self.refresh_messages() - - def _update_field_tables(self): - """Update the csv and merge field text areas. - Color code merge fields according to whether they appear - in the merge data. - """ - self.template_fields_area.clear() - with self.template_fields_area: - for f in self.template_fields: - with ui.row(align_items='center'): - if f in self.csv_fields: - ui.icon("check_circle", color='positive', size="1rem") - else: - ui.icon("cancel", color='negative', size="1rem") - ui.label(f) - ui.separator() - - self.csv_fields_area.clear() - with self.csv_fields_area: - for f in self.csv_fields: - if any(f == mf.key for mf in MemoFields): - with ui.row(align_items='center'): - ui.icon("settings", color='secondary') - ui.label(str(f)).classes("text-secondary") - else: - ui.label(str(f)) - ui.separator() - - def refresh_messages(self): - """Rebuild the message area from current template + csv state. - - Collects template- and csv-level messages together, then shows the - "all good" message only when data has been uploaded and no other - messages were produced. - """ - self.message_area.clear() - messages: list[ValidationMessage] = [] - - for f in self.template_fields: - if f not in self.csv_fields: - messages.append(ValidationMessage(f"'{f}' mangler i flettedata", "warning")) - - if self.csv_data is not None: - messages.extend(_verify_csv_data(self.csv_fields, self.csv_data, self._get_post_type())) - - if not messages and self.template_bytes and self.csv_data: - messages.append(ValidationMessage("Alles gut", "positive")) - - for msg in messages: - self.message_area.add_message(msg.text, type_=msg.type_) - - -class AttachmentsStep: - """A class representing the attachments step in the Send Post flow. - Here the user can upload extra files to be sent alongside the letter. - """ - def __init__(self): - ui.label("Her kan du vedhæfte ekstra filer til forsendelsen.") - ui.label("Vedhæftede filer sendes, som de er, og flettes derfor ikke.") - ui.label("Digital Post understøtter op til 10 vedhæftede filer og op til 74MB i alt inkl. brev.") - ui.label("Bemærk at vedhæftede filer kun understøttes i Digital Post.") - - self._attachments: dict[tuple[str, int], document_storage.Attachment] = {} - - with ui.grid(columns=1): - file_types = f"accept={','.join(document_storage.ATTACHMENT_FILE_TYPES.keys())}" - self.upload = ui.upload(multiple=True, max_files=10, auto_upload=True, on_upload=self._on_upload, on_rejected=lambda: ui.notify("Upload afvist", type="warning")).props(file_types) - self.remove_button = ui_components.DisableButton("Nulstil vedhæftninger", on_click=self._remove_attachments) - self.remove_button.disable() - - async def _on_upload(self, e: UploadEventArguments): - """Buffer each uploaded file, skipping unsupported file types.""" - suffix = Path(e.file.name).suffix.lower() - if suffix not in document_storage.ATTACHMENT_FILE_TYPES: - ui.notify(f"Filtypen '{suffix}' understøttes ikke: {e.file.name}", type="negative") - return - self._attachments[(e.file.name, e.file.size())] = document_storage.Attachment( - e.file.name, await e.file.read() - ) - self.remove_button.enable() - - def _remove_attachments(self): - """Remove all already uploaded attachments.""" - self._attachments = {} - self.remove_button.disable() - self.upload.reset() - - async def get_attachments(self) -> list[document_storage.Attachment]: - """Return the attachments still shown in the uploader. - - The buffer is reconciled against the uploader's current file list, so - files the user removed in the browser are excluded. - """ - names = await ui.run_javascript( - f"return getElement({self.upload.id}).$refs.qRef.files.map(f => [f.name, f.size])" - ) - names = [tuple(n) for n in names] - return [self._attachments[name] for name in names if name in self._attachments] - - -class ExamplesStep: - """A class representing the third step in the Send Post flow. - Here the user can verify the uploaded data and download sample letters. - """ - def __init__(self, merge_letter: Callable[[dict[str, str]], bytes]): - self._merge_letter = merge_letter - ui.label("Her kan du hente og gennemgå eksempler på breve med den givne data.") - self.example_table = ui.table(rows=[], title="Breve", column_defaults={"align": "left"}, pagination=5) - self.example_table.add_slot( - "body-cell-example_button", - r""" - - - - """ - ) - self.example_table.on("example_button_click", self._on_example_click) - - def set_data(self, fields: list[str], rows: list[dict[str, str]] | None): - """Update the example table columns and rows.""" - columns = [{"name": n, "label": n, "field": n} for n in fields] - columns.append({"name": "example_button", "label": "", "field": "example_button"}) - self.example_table.columns = columns - self.example_table.rows = rows - - async def _on_example_click(self, event): - with ui.dialog(value=True) as dialog: - dialog.props("persistent") - ui.spinner(size="5em") - - try: - letter = await asyncio.wait_for( - nicegui_run.io_bound(lambda: self._merge_letter(event.args)), - timeout=10, - ) - ui.download(letter, "Eksempel.pdf") - except asyncio.TimeoutError: - ui.notify("Download fejlede", type="warning") - finally: - dialog.close() - - -def _stepper_navigation(stepper: ui.stepper, prev_button: bool = True, next_button: bool = True, validate_callback: Callable[[], bool] | None = None): - """Add 'previous' and 'next' buttons to the stepper. - - Args: - stepper: The stepper object to add buttons to. - prev_button: Whether to add a 'previous' button. Defaults to True. - next_button: Whether to add a 'next' button. Defaults to True. - validate_callback: A function to do validation before going to the next step. Defaults to None. - """ - with ui.stepper_navigation(): - if prev_button: - ui.button("Forrige", on_click=stepper.previous).props("flat") - if next_button: - def go_next(): - if validate_callback is None or validate_callback(): - stepper.next() - ui.button("Næste", on_click=go_next) - - -def _verify_csv_data(fields: list[str], csv_list: list[dict], post_type: PostType) -> list[ValidationMessage]: - """Verify the input against these rules: - - Does the data contain any rows? - - Are there any duplicate receivers? - - Are all mandatory fields present? (depends on the post type) - - Does field pattern match for all lines? (max 3 reported) - - Args: - fields: The column names in the csv. - csv_list: The input list as a csv dictionary from DictReader. - post_type: The post type the shipment will be sent as, which - determines which fields are mandatory. - - Returns: - A list of validation messages, empty if no problems are found. - """ - messages: list[ValidationMessage] = [] - - # Check that there is any data at all - if not csv_list: - messages.append(ValidationMessage("Flettedata indeholder ingen rækker", "negative")) - return messages - - # Check for duplicate receivers - if MemoFields.MEMO_MODTAGER.key in fields: - counter = Counter(line[MemoFields.MEMO_MODTAGER.key] for line in csv_list) - duplicates = [f"{k}: {v}" for k, v in counter.items() if v > 1] - if duplicates: - messages.append(ValidationMessage( - f"Duplikater fundet i '{MemoFields.MEMO_MODTAGER.key}': " + " - ".join(duplicates), - "warning", - )) - - # Check for mandatory fields - for mf in MemoFields: - if mf.is_mandatory_for(post_type) and mf.key not in fields: - messages.append(ValidationMessage(f"'{mf.key}' ikke fundet i data", "negative")) - - # Check for pattern mismatches (show 3 errors max) - pattern_errors = 0 - for i, row in enumerate(csv_list): - for mf in MemoFields: - if mf.key in row and not mf.pattern.fullmatch(row[mf.key]): - messages.append(ValidationMessage( - f"Fejl på linje {i}: Kolonne: '{mf.key}' - Mønster: '{mf.pattern.pattern}'", - "negative", - )) - pattern_errors += 1 - if pattern_errors >= 3: - return messages - - return messages +"""This module contains the 'send_post' page.""" + +from csv import DictReader +from collections import Counter +from collections.abc import Callable +from pathlib import Path +from typing import Literal, NamedTuple +import asyncio + +from nicegui import ui, APIRouter, app +from nicegui import run as nicegui_run +from nicegui.events import UploadEventArguments +from jinja2.exceptions import TemplateSyntaxError + +from OpenPostbud import ui_components +from OpenPostbud.database import document_storage +from OpenPostbud.middleware import authentication +from OpenPostbud.database.digital_post import letters, shipments, templates +from OpenPostbud.database.digital_post.letters import MemoFields +from OpenPostbud.database.common import PostType +from OpenPostbud.utils import docx_util + + +router = APIRouter() + + +class ValidationMessage(NamedTuple): + """A message produced by csv validation, ready to be shown in a MessageArea.""" + text: str + type_: Literal["positive", "warning", "negative"] + + +@router.page("/send_digital_post", name="Send Post") +def page(): + """Show the 'send_post page.""" + ui_components.header() + ui.label("Ny Forsendelse").classes("text-4xl") + ui.label("På denne side kan du oprette en ny forsendelse af Digital Post eller Fysisk Post.") + + SendPostPage() + + +class SendPostPage: + """A class representing the 'send_post' page.""" + def __init__(self): + with ui.stepper().props("vertical flat done-color=green") as stepper: + with ui.step("Beskrivelse"): + self.step1 = MetadataStep() + _stepper_navigation(stepper, prev_button=False, validate_callback=self.step1.validate) + with ui.step("Skabelon og data"): + self.step2 = FileUploadStep( + on_csv_changed=self._on_csv_data_changed, + get_post_type=lambda: self.step1.post_type.value, + ) + _stepper_navigation(stepper, validate_callback=self.step2.validate) + with ui.step("Vedhæftede filer") as step: + self.step3 = AttachmentsStep() + _stepper_navigation(stepper) + # Disable entire step if selected post type is physical + step.bind_enabled_from(self.step1.post_type, 'value', backward=lambda v: v != PostType.PHYSICAL) + with ui.step("Gennemgå eksempler"): + self.step4 = ExamplesStep(merge_letter=self.step2.merge_letter) + _stepper_navigation(stepper) + with ui.step("Send post"): + ui.button("Send Post", on_click=self._send_post) + _stepper_navigation(stepper, next_button=False) + + # Re-run csv validation when the post type changes, since the set of + # mandatory fields depends on it. + self.step1.post_type.on_value_change(self.step2.refresh_messages) + + def _on_csv_data_changed(self, fields: list[str], rows: list[dict[str, str]] | None): + """Forward csv changes from step 2 to step 3.""" + self.step4.set_data(fields, rows) + + async def _send_post(self): + """Add the shipment and letters to the database and navigate + to the detail page of the shipment. + """ + # Read the attachments before the spinner dialog steals focus, since it + # relies on a round-trip to the client. + attachments = await self.step3.get_attachments() + + with ui.dialog(value=True) as dialog: + dialog.props("persistent") + ui.spinner(size="5em") + + try: + template_id = templates.add_template(self.step2.template_name, self.step2.template_bytes) + shipment_id = shipments.add_shipment( + self.step1.shipment_name.value, + self.step1.shipment_desc.value, + authentication.get_current_user(), + template_id, + self.step1.post_type.value) + letters.add_letters(shipment_id, self.step2.csv_data) + document_storage.add_attachments(shipment_id, attachments) + ui.navigate.to(app.url_path_for("Shipment Detail", shipment_id=shipment_id)) + finally: + dialog.close() + + +class MetadataStep: + """A class representing the first step in the Send Post flow. + Here the user enters a name and description for the shipment. + """ + def __init__(self): + ui.label("Angiv et navn og beskrivelse af forsendelsen, så den kan genkendes senere.") + ui.label("Navn og beskrivelse påvirker ikke forsendelsens indhold.") + self.shipment_name = ui.input( + "Forsendelse navn", + validation={"Maks 50 tegn": lambda v: len(v) <= 50, "Skal udfyldes": lambda v: len(v) != 0}, + ).classes("w-full") + self.shipment_desc = ui.textarea( + "Forsendelse beskrivelse", + validation={"Maks 200 tegn": lambda v: len(v) <= 200, "Skal udfyldes": lambda v: len(v) != 0}, + ).classes("w-full") + + ui.label("Vælg hvordan forsendelsen skal sendes.") + self.post_type = ui.radio({pt: pt.value for pt in PostType}, value=PostType.DIGITAL) + physical_hint = ui.label( + "Bemærk: Ved Fysisk Post skal modtagerens adresse fremgå af brevet, så den kan ses i kuvertens rude." + ).classes("text-secondary") + physical_hint.bind_visibility_from(self.post_type, "value", backward=lambda v: v != PostType.DIGITAL) + + def validate(self) -> bool: + """Validator function for step 1.""" + name_ok = self.shipment_name.validate() + desc_ok = self.shipment_desc.validate() + if not (name_ok and desc_ok): + ui.notify("Udfyld venligst alle felter", type='warning') + return False + return True + + +# pylint: disable-next=too-many-instance-attributes +class FileUploadStep: + """A class representing the second step in the Send Post flow. + Here the user uploads a template and merge data. + """ + def __init__(self, on_csv_changed: Callable[[list[str], list[dict[str, str]] | None], None], get_post_type: Callable[[], PostType]): + self._on_csv_changed = on_csv_changed + self._get_post_type = get_post_type + self.template_name: str | None = None + self.template_bytes: bytes | None = None + self.template_fields: list[str] = [] + self.csv_data: list[dict[str, str]] | None = None + self.csv_fields: list[str] = [] + + with ui.grid(columns=2): + ui.label("Upload skabelon (.docx, .pdf)").classes("text-bold") + ui.label("Upload flettedata (.csv)").classes("text-bold") + + self._template_upload = ui.upload(on_upload=self._on_template_upload, max_files=1, auto_upload=True).props("accept=.docx,.pdf") + self._csv_upload = ui.upload(on_upload=self._on_csv_upload, max_files=1, auto_upload=True).props("accept=.csv") + self._template_upload.on("removed", self._remove_template) + self._csv_upload.on("removed", self._remove_csv) + + self.template_reset_button = ui_components.DisableButton("Nulstil skabelon", on_click=self._remove_template) + self.template_reset_button.disable() + self.csv_reset_button = ui_components.DisableButton("Nulstil flettedata", on_click=self._remove_csv) + self.csv_reset_button.disable() + + ui.label("Flettefelter i skabelon") + ui.label("Datakolonner i csv") + + self.template_fields_area = ui.scroll_area().classes("border border-gray-300") + self.csv_fields_area = ui.scroll_area().classes("border border-gray-300") + + self.message_area = ui_components.MessageArea().classes("border border-gray-300") + + def validate(self) -> bool: + """Validate that both template and merge data has been uploaded.""" + if not self.template_bytes: + ui.notify("Skabelon mangler", type="warning") + if not self.csv_data: + ui.notify("Flettedata mangler", type="warning") + return bool(self.template_bytes and self.csv_data) + + def merge_letter(self, merge_data: dict[str, str]) -> bytes: + """Use the template and merge data to create an example letter.""" + if self.template_name.endswith(".docx"): + word_file = docx_util.merge_word_file(self.template_bytes, merge_data) + return docx_util.convert_word_to_pdf(word_file) + return self.template_bytes + + async def _on_template_upload(self, e: UploadEventArguments): + """Read the merge fields from the uploaded template and refresh the field list.""" + self.template_reset_button.enable() + self.template_name = e.file.name + self.template_bytes = await e.file.read() + + if self.template_name.endswith(".docx"): + try: + self.template_fields = docx_util.get_merge_fields(self.template_bytes) + except TemplateSyntaxError as error: + ui.notify(f"Syntaksfejl i skabelon: {error}", type="negative", timeout=0, actions=[{"label": "Luk", "color": "white"}]) + self._remove_template() + raise + else: + self.template_fields = [] + + self._update_field_tables() + self.refresh_messages() + + async def _on_csv_upload(self, e: UploadEventArguments): + """Read the columns from the uploaded csv, refresh the field list, + push the data to step 3, and run validation. + """ + self.csv_reset_button.enable() + file_content = await e.file.text(encoding="utf-8-sig") + + dict_reader = DictReader(file_content.splitlines()) + self.csv_fields = sorted(list(dict_reader.fieldnames)) + self.csv_data = list(dict_reader) + self._update_field_tables() + self._on_csv_changed(self.csv_fields, self.csv_data) + self.refresh_messages() + + def _remove_template(self): + self._template_upload.reset() + self.template_reset_button.disable() + self.template_fields = [] + self.template_bytes = None + self._update_field_tables() + self.refresh_messages() + + def _remove_csv(self): + self._csv_upload.reset() + self.csv_reset_button.disable() + self.csv_fields = [] + self.csv_data = None + self._update_field_tables() + self._on_csv_changed(self.csv_fields, self.csv_data) + self.refresh_messages() + + def _update_field_tables(self): + """Update the csv and merge field text areas. + Color code merge fields according to whether they appear + in the merge data. + """ + self.template_fields_area.clear() + with self.template_fields_area: + for f in self.template_fields: + with ui.row(align_items='center'): + if f in self.csv_fields: + ui.icon("check_circle", color='positive', size="1rem") + else: + ui.icon("cancel", color='negative', size="1rem") + ui.label(f) + ui.separator() + + self.csv_fields_area.clear() + with self.csv_fields_area: + for f in self.csv_fields: + if any(f == mf.key for mf in MemoFields): + with ui.row(align_items='center'): + ui.icon("settings", color='secondary') + ui.label(str(f)).classes("text-secondary") + else: + ui.label(str(f)) + ui.separator() + + def refresh_messages(self): + """Rebuild the message area from current template + csv state. + + Collects template- and csv-level messages together, then shows the + "all good" message only when data has been uploaded and no other + messages were produced. + """ + self.message_area.clear() + messages: list[ValidationMessage] = [] + + for f in self.template_fields: + if f not in self.csv_fields: + messages.append(ValidationMessage(f"'{f}' mangler i flettedata", "warning")) + + if self.csv_data is not None: + messages.extend(_verify_csv_data(self.csv_fields, self.csv_data, self._get_post_type())) + + if not messages and self.template_bytes and self.csv_data: + messages.append(ValidationMessage("Alles gut", "positive")) + + for msg in messages: + self.message_area.add_message(msg.text, type_=msg.type_) + + +class AttachmentsStep: + """A class representing the attachments step in the Send Post flow. + Here the user can upload extra files to be sent alongside the letter. + """ + def __init__(self): + ui.label("Her kan du vedhæfte ekstra filer til forsendelsen.") + ui.label("Vedhæftede filer sendes, som de er, og flettes derfor ikke.") + ui.label("Digital Post understøtter op til 10 vedhæftede filer og op til 74MB i alt inkl. brev.") + ui.label("Bemærk at vedhæftede filer kun understøttes i Digital Post.") + + self._attachments: dict[tuple[str, int], document_storage.Attachment] = {} + + with ui.grid(columns=1): + file_types = f"accept={','.join(document_storage.ATTACHMENT_FILE_TYPES.keys())}" + self.upload = ui.upload(multiple=True, max_files=10, auto_upload=True, on_upload=self._on_upload, on_rejected=lambda: ui.notify("Upload afvist", type="warning")).props(file_types) + self.remove_button = ui_components.DisableButton("Nulstil vedhæftninger", on_click=self._remove_attachments) + self.remove_button.disable() + + async def _on_upload(self, e: UploadEventArguments): + """Buffer each uploaded file, skipping unsupported file types.""" + suffix = Path(e.file.name).suffix.lower() + if suffix not in document_storage.ATTACHMENT_FILE_TYPES: + ui.notify(f"Filtypen '{suffix}' understøttes ikke: {e.file.name}", type="negative") + return + self._attachments[(e.file.name, e.file.size())] = document_storage.Attachment( + e.file.name, await e.file.read() + ) + self.remove_button.enable() + + def _remove_attachments(self): + """Remove all already uploaded attachments.""" + self._attachments = {} + self.remove_button.disable() + self.upload.reset() + + async def get_attachments(self) -> list[document_storage.Attachment]: + """Return the attachments still shown in the uploader. + + The buffer is reconciled against the uploader's current file list, so + files the user removed in the browser are excluded. + """ + names = await ui.run_javascript( + f"return getElement({self.upload.id}).$refs.qRef.files.map(f => [f.name, f.size])" + ) + names = [tuple(n) for n in names] + return [self._attachments[name] for name in names if name in self._attachments] + + +class ExamplesStep: + """A class representing the third step in the Send Post flow. + Here the user can verify the uploaded data and download sample letters. + """ + def __init__(self, merge_letter: Callable[[dict[str, str]], bytes]): + self._merge_letter = merge_letter + ui.label("Her kan du hente og gennemgå eksempler på breve med den givne data.") + self.example_table = ui.table(rows=[], title="Breve", column_defaults={"align": "left"}, pagination=5) + self.example_table.add_slot( + "body-cell-example_button", + r""" + + + + """ + ) + self.example_table.on("example_button_click", self._on_example_click) + + def set_data(self, fields: list[str], rows: list[dict[str, str]] | None): + """Update the example table columns and rows.""" + columns = [{"name": n, "label": n, "field": n} for n in fields] + columns.append({"name": "example_button", "label": "", "field": "example_button"}) + self.example_table.columns = columns + self.example_table.rows = rows + + async def _on_example_click(self, event): + with ui.dialog(value=True) as dialog: + dialog.props("persistent") + ui.spinner(size="5em") + + try: + letter = await asyncio.wait_for( + nicegui_run.io_bound(lambda: self._merge_letter(event.args)), + timeout=10, + ) + ui.download(letter, "Eksempel.pdf") + except asyncio.TimeoutError: + ui.notify("Download fejlede", type="warning") + finally: + dialog.close() + + +def _stepper_navigation(stepper: ui.stepper, prev_button: bool = True, next_button: bool = True, validate_callback: Callable[[], bool] | None = None): + """Add 'previous' and 'next' buttons to the stepper. + + Args: + stepper: The stepper object to add buttons to. + prev_button: Whether to add a 'previous' button. Defaults to True. + next_button: Whether to add a 'next' button. Defaults to True. + validate_callback: A function to do validation before going to the next step. Defaults to None. + """ + with ui.stepper_navigation(): + if prev_button: + ui.button("Forrige", on_click=stepper.previous).props("flat") + if next_button: + def go_next(): + if validate_callback is None or validate_callback(): + stepper.next() + ui.button("Næste", on_click=go_next) + + +def _verify_csv_data(fields: list[str], csv_list: list[dict], post_type: PostType) -> list[ValidationMessage]: + """Verify the input against these rules: + - Does the data contain any rows? + - Are there any duplicate receivers? + - Are all mandatory fields present? (depends on the post type) + - Does field pattern match for all lines? (max 3 reported) + + Args: + fields: The column names in the csv. + csv_list: The input list as a csv dictionary from DictReader. + post_type: The post type the shipment will be sent as, which + determines which fields are mandatory. + + Returns: + A list of validation messages, empty if no problems are found. + """ + messages: list[ValidationMessage] = [] + + # Check that there is any data at all + if not csv_list: + messages.append(ValidationMessage("Flettedata indeholder ingen rækker", "negative")) + return messages + + # Check for duplicate receivers + if MemoFields.MEMO_MODTAGER.key in fields: + counter = Counter(line[MemoFields.MEMO_MODTAGER.key] for line in csv_list) + duplicates = [f"{k}: {v}" for k, v in counter.items() if v > 1] + if duplicates: + messages.append(ValidationMessage( + f"Duplikater fundet i '{MemoFields.MEMO_MODTAGER.key}': " + " - ".join(duplicates), + "warning", + )) + + # Check for mandatory fields + for mf in MemoFields: + if mf.is_mandatory_for(post_type) and mf.key not in fields: + messages.append(ValidationMessage(f"'{mf.key}' ikke fundet i data", "negative")) + + # Check for pattern mismatches (show 3 errors max) + pattern_errors = 0 + for i, row in enumerate(csv_list): + for mf in MemoFields: + if mf.key in row and not mf.pattern.fullmatch(row[mf.key]): + messages.append(ValidationMessage( + f"Fejl på linje {i}: Kolonne: '{mf.key}' - Mønster: '{mf.pattern.pattern}'", + "negative", + )) + pattern_errors += 1 + if pattern_errors >= 3: + return messages + + return messages diff --git a/src/OpenPostbud/ui_components.py b/src/OpenPostbud/ui_components.py index 62f20f0..104543a 100644 --- a/src/OpenPostbud/ui_components.py +++ b/src/OpenPostbud/ui_components.py @@ -1,217 +1,217 @@ -"""This module contains reusable UI components.""" - -from typing import Literal -import csv -from io import StringIO - -from nicegui import ui, app - -from OpenPostbud.middleware import authentication -from OpenPostbud import config - - -def header(): - """Show a NiceGUI header with links to other pages.""" - theme() - - with ui.header(): - logo = ui.label("📯 OpenPostbud 📯").classes("text-3xl text-bold cursor-pointer") - logo.on("click", lambda: ui.navigate.to(app.url_path_for("Front Page"))) # pylint: disable=no-member - - ui.link("Forside", app.url_path_for("Front Page")).classes(replace='text-lg text-white') - ui.separator().props("vertical color=white size=2px") - ui.link("Digital Post", app.url_path_for("Shipment Overview")).classes(replace='text-lg text-white') - ui.separator().props("vertical color=white size=2px") - ui.link("NemSMS", app.url_path_for("NemSMS Overview")).classes(replace='text-lg text-white') - ui.separator().props("vertical color=white size=2px") - ui.link("Tjek Tilmelding", app.url_path_for("Registration Overview")).classes(replace='text-lg text-white') - - if authentication.is_admin(): - ui.separator().props("vertical color=white size=2px") - ui.link("API Brugere", app.url_path_for("API Users")).classes(replace='text-lg text-white') - - ui.space() - ui.label(authentication.get_current_user()).classes('text-lg text-white') - ui.label(str(authentication.get_current_user_roles())).classes('text-lg text-white') - ui.separator().props("vertical color=white size=2px") - ui.label(config.OPENPOSTBUD_VERSION).classes('text-lg text-white') - ui.button("Log Ud", on_click=authentication.logout, color="white").classes("text-primary") - - -def theme(): - """Set the theme for the current page.""" - ui.colors(primary="#cc0000") - ui.input.default_props("filled") - ui.textarea.default_props("filled") - - -def obscure_id_column(table: ui.table, column_name: str): - """Obscure the last 4 digits of a CPR or CVR value in a Nicegui table. - Adds a 'show/hide' button next to the value in the table. - - A 10-digit value (CPR) is shown as dddddd-XXXX; an 8-digit value (CVR) - is shown as ddddXXXX. Other lengths are shown unchanged. - - Args: - table: The table object. - column_name: The name of the column to obscure. - """ - table.add_slot(f"body-cell-{column_name}", r''' - - - {{ props.value.substring(0, 6) }}-{{ props.expand ? props.value.substring(6) : 'XXXX' }} - - - {{ props.value.substring(0, 4) }}{{ props.expand ? props.value.substring(4) : 'XXXX' }} - - - {{ props.value }} - - - - ''') - - -async def question_popup(question: str, option1: str, option2: str, color1: str = 'primary', color2: str = 'primary') -> bool: - """Show an awaitable popup with a question and two buttons with the given options. - Example: - result = await question_popup("Do you like candy", "YES!", "Not really") - - Args: - question: The question to display. - option1: The text on button 1. - option2: The text on button 2. - color1: The color of button 1. - color2: The color of button 2. - - Returns: - bool: True if button 1 is clicked, or False if button 2 is clicked. - """ - with ui.dialog(value=True).props('persistent') as dialog, ui.card(): - ui.label(question).classes("text-lg") - with ui.row(): - ui.button(option1, on_click=lambda e: dialog.submit(True), color=color1) - ui.button(option2, on_click=lambda e: dialog.submit(False), color=color2) - - return await dialog - - -async def text_input_popup(prompt: str, input_label: str) -> str: - """Show an awaitable popup that asks for a single text input. - - Args: - prompt: The text to show on the dialog. - input_label: The label text on the input element. - - Returns: - The text from the text input or an empty string if the dialog is closed. - """ - with ui.dialog(value=True).props('persistent') as dialog, ui.card(): - ui.label(prompt).classes("text-lg") - text_input = ui.input(input_label) - with ui.row(): - ui.button("OK", on_click=lambda e: dialog.submit(text_input.value)) - ui.button("Luk", on_click=lambda e: dialog.submit("")) - - return await dialog - - -async def info_popup(information: str): - """Show an awaitable popup that asks for a single text input. - - Args: - prompt: The text to show on the dialog. - input_label: The label text on the input element. - - Returns: - The text from the text input or an empty string if the dialog is closed. - """ - with ui.dialog(value=True).props('persistent') as dialog, ui.card(): - ui.label(information).classes("text-lg") - ui.button("Luk", on_click=lambda e: dialog.submit("")) - - await dialog - - -class DisableButton(ui.button): - """An extension of ui.button that turns grey when disabled.""" - def _handle_enabled_change(self, enabled: bool) -> None: - """Called when the element is enabled or disabled. - - :param enabled: The new state. - """ - if enabled: - self.props("color=primary") - else: - self.props("color=grey") - self._props['disable'] = not enabled - self.update() - - -class MessageArea(ui.scroll_area): - """A ui component for displaying messages in line with other content.""" - def add_message(self, text: str, type_: Literal["positive", "warning", "negative"]): - """Add a new message to the message area. - - Args: - text: The text of the message. - type_: The type of the message which determines the color and icon. - """ - with self, ui.card() as card: - with ui.row(align_items="center"): - match(type_): - case "positive": - card.classes("w-full bg-positive") - ui.icon("check_circle", color="white", size="1.8em") - ui.label(text).classes("text-white") - case "warning": - card.classes("w-full bg-warning") - ui.icon("priority_high", color="black", size="1.8em") - ui.label(text) - case "negative": - card.classes("w-full bg-negative") - ui.icon("warning", color="white", size="1.8em") - ui.label(text).classes("text-white") - self.update() - - -class SearchTable(ui.table): - """An extension of ui.table that has a search field in the top slot.""" - def __init__(self, *, rows, columns=None, column_defaults=None, row_key='id', title=None, selection=None, pagination=None, on_select=None, on_pagination_change=None, # pylint: disable=too-many-arguments - search_field: bool, download_button: bool): - super().__init__(rows=rows, columns=columns, column_defaults=column_defaults, row_key=row_key, selection=selection, pagination=pagination, on_select=on_select, on_pagination_change=on_pagination_change) - with self.add_slot("top"): - ui.label(title).classes("q-table__title") - ui.space() - if download_button: - ui.button("Download liste", on_click=self._download_list).classes("mr-5") - if search_field: - search_input = ui.input("Søg").props("clearable") - self.bind_filter_from(search_input, "value") - - def _download_list(self): - """A callback function for downloading the table data as a csv file.""" - field_names = [col["field"] for col in self.columns] - field_labels = {col["field"]: col["label"] for col in self.columns} - - f = StringIO() - writer = csv.DictWriter(f, fieldnames=field_names) - writer.writerow(field_labels) - writer.writerows(self.rows) - - ui.download(f.getvalue().encode(), "Liste.csv") - - -class MultilineLabel(): - """A utility class for creating multiple labels - for multiline text.""" - labels: list[ui.label] - - def __init__(self, text: str): - self.labels = [] - - with ui.column().style("gap: 0;"): - for line in text.splitlines(): - self.labels.append(ui.label(line)) +"""This module contains reusable UI components.""" + +from typing import Literal +import csv +from io import StringIO + +from nicegui import ui, app + +from OpenPostbud.middleware import authentication +from OpenPostbud import config + + +def header(): + """Show a NiceGUI header with links to other pages.""" + theme() + + with ui.header(): + logo = ui.label("📯 OpenPostbud 📯").classes("text-3xl text-bold cursor-pointer") + logo.on("click", lambda: ui.navigate.to(app.url_path_for("Front Page"))) # pylint: disable=no-member + + ui.link("Forside", app.url_path_for("Front Page")).classes(replace='text-lg text-white') + ui.separator().props("vertical color=white size=2px") + ui.link("Digital Post", app.url_path_for("Shipment Overview")).classes(replace='text-lg text-white') + ui.separator().props("vertical color=white size=2px") + ui.link("NemSMS", app.url_path_for("NemSMS Overview")).classes(replace='text-lg text-white') + ui.separator().props("vertical color=white size=2px") + ui.link("Tjek Tilmelding", app.url_path_for("Registration Overview")).classes(replace='text-lg text-white') + + if authentication.is_admin(): + ui.separator().props("vertical color=white size=2px") + ui.link("API Brugere", app.url_path_for("API Users")).classes(replace='text-lg text-white') + + ui.space() + ui.label(authentication.get_current_user()).classes('text-lg text-white') + ui.label(str(authentication.get_current_user_roles())).classes('text-lg text-white') + ui.separator().props("vertical color=white size=2px") + ui.label(config.OPENPOSTBUD_VERSION).classes('text-lg text-white') + ui.button("Log Ud", on_click=authentication.logout, color="white").classes("text-primary") + + +def theme(): + """Set the theme for the current page.""" + ui.colors(primary="#cc0000") + ui.input.default_props("filled") + ui.textarea.default_props("filled") + + +def obscure_id_column(table: ui.table, column_name: str): + """Obscure the last 4 digits of a CPR or CVR value in a Nicegui table. + Adds a 'show/hide' button next to the value in the table. + + A 10-digit value (CPR) is shown as dddddd-XXXX; an 8-digit value (CVR) + is shown as ddddXXXX. Other lengths are shown unchanged. + + Args: + table: The table object. + column_name: The name of the column to obscure. + """ + table.add_slot(f"body-cell-{column_name}", r''' + + + {{ props.value.substring(0, 6) }}-{{ props.expand ? props.value.substring(6) : 'XXXX' }} + + + {{ props.value.substring(0, 4) }}{{ props.expand ? props.value.substring(4) : 'XXXX' }} + + + {{ props.value }} + + + + ''') + + +async def question_popup(question: str, option1: str, option2: str, color1: str = 'primary', color2: str = 'primary') -> bool: + """Show an awaitable popup with a question and two buttons with the given options. + Example: + result = await question_popup("Do you like candy", "YES!", "Not really") + + Args: + question: The question to display. + option1: The text on button 1. + option2: The text on button 2. + color1: The color of button 1. + color2: The color of button 2. + + Returns: + bool: True if button 1 is clicked, or False if button 2 is clicked. + """ + with ui.dialog(value=True).props('persistent') as dialog, ui.card(): + ui.label(question).classes("text-lg") + with ui.row(): + ui.button(option1, on_click=lambda e: dialog.submit(True), color=color1) + ui.button(option2, on_click=lambda e: dialog.submit(False), color=color2) + + return await dialog + + +async def text_input_popup(prompt: str, input_label: str) -> str: + """Show an awaitable popup that asks for a single text input. + + Args: + prompt: The text to show on the dialog. + input_label: The label text on the input element. + + Returns: + The text from the text input or an empty string if the dialog is closed. + """ + with ui.dialog(value=True).props('persistent') as dialog, ui.card(): + ui.label(prompt).classes("text-lg") + text_input = ui.input(input_label) + with ui.row(): + ui.button("OK", on_click=lambda e: dialog.submit(text_input.value)) + ui.button("Luk", on_click=lambda e: dialog.submit("")) + + return await dialog + + +async def info_popup(information: str): + """Show an awaitable popup that asks for a single text input. + + Args: + prompt: The text to show on the dialog. + input_label: The label text on the input element. + + Returns: + The text from the text input or an empty string if the dialog is closed. + """ + with ui.dialog(value=True).props('persistent') as dialog, ui.card(): + ui.label(information).classes("text-lg") + ui.button("Luk", on_click=lambda e: dialog.submit("")) + + await dialog + + +class DisableButton(ui.button): + """An extension of ui.button that turns grey when disabled.""" + def _handle_enabled_change(self, enabled: bool) -> None: + """Called when the element is enabled or disabled. + + :param enabled: The new state. + """ + if enabled: + self.props("color=primary") + else: + self.props("color=grey") + self._props['disable'] = not enabled + self.update() + + +class MessageArea(ui.scroll_area): + """A ui component for displaying messages in line with other content.""" + def add_message(self, text: str, type_: Literal["positive", "warning", "negative"]): + """Add a new message to the message area. + + Args: + text: The text of the message. + type_: The type of the message which determines the color and icon. + """ + with self, ui.card() as card: + with ui.row(align_items="center"): + match(type_): + case "positive": + card.classes("w-full bg-positive") + ui.icon("check_circle", color="white", size="1.8em") + ui.label(text).classes("text-white") + case "warning": + card.classes("w-full bg-warning") + ui.icon("priority_high", color="black", size="1.8em") + ui.label(text) + case "negative": + card.classes("w-full bg-negative") + ui.icon("warning", color="white", size="1.8em") + ui.label(text).classes("text-white") + self.update() + + +class SearchTable(ui.table): + """An extension of ui.table that has a search field in the top slot.""" + def __init__(self, *, rows, columns=None, column_defaults=None, row_key='id', title=None, selection=None, pagination=None, on_select=None, on_pagination_change=None, # pylint: disable=too-many-arguments + search_field: bool, download_button: bool): + super().__init__(rows=rows, columns=columns, column_defaults=column_defaults, row_key=row_key, selection=selection, pagination=pagination, on_select=on_select, on_pagination_change=on_pagination_change) + with self.add_slot("top"): + ui.label(title).classes("q-table__title") + ui.space() + if download_button: + ui.button("Download liste", on_click=self._download_list).classes("mr-5") + if search_field: + search_input = ui.input("Søg").props("clearable") + self.bind_filter_from(search_input, "value") + + def _download_list(self): + """A callback function for downloading the table data as a csv file.""" + field_names = [col["field"] for col in self.columns] + field_labels = {col["field"]: col["label"] for col in self.columns} + + f = StringIO() + writer = csv.DictWriter(f, fieldnames=field_names) + writer.writerow(field_labels) + writer.writerows(self.rows) + + ui.download(f.getvalue().encode(), "Liste.csv") + + +class MultilineLabel(): + """A utility class for creating multiple labels + for multiline text.""" + labels: list[ui.label] + + def __init__(self, text: str): + self.labels = [] + + with ui.column().style("gap: 0;"): + for line in text.splitlines(): + self.labels.append(ui.label(line)) From fcda9373200fbb27732ba3b375aaa64d22289c52 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 13:35:50 +0200 Subject: [PATCH 19/31] Fixed docstring --- src/OpenPostbud/ui_components.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/OpenPostbud/ui_components.py b/src/OpenPostbud/ui_components.py index 104543a..7ad22d8 100644 --- a/src/OpenPostbud/ui_components.py +++ b/src/OpenPostbud/ui_components.py @@ -119,15 +119,7 @@ async def text_input_popup(prompt: str, input_label: str) -> str: async def info_popup(information: str): - """Show an awaitable popup that asks for a single text input. - - Args: - prompt: The text to show on the dialog. - input_label: The label text on the input element. - - Returns: - The text from the text input or an empty string if the dialog is closed. - """ + """Show an awaitable popup that shows information.""" with ui.dialog(value=True).props('persistent') as dialog, ui.card(): ui.label(information).classes("text-lg") ui.button("Luk", on_click=lambda e: dialog.submit("")) From 04e5a0044e115363add7bcec218d4c411e77447f Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 13:36:08 +0200 Subject: [PATCH 20/31] removed default on created_by --- .../migrations/sql/003_letter_post_type_nullable_shipment.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql b/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql index b6e379c..5aca754 100644 --- a/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql +++ b/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql @@ -9,7 +9,7 @@ CREATE TABLE "Letters_new" ( transaction_id VARCHAR, sent_as VARCHAR(8), post_type VARCHAR(8) NOT NULL DEFAULT 'DIGITAL', - created_by VARCHAR(50) NOT NULL DEFAULT 'unknown', + created_by VARCHAR(50) NOT NULL, PRIMARY KEY (id), FOREIGN KEY(shipment_id) REFERENCES "Shipments" (id) ON DELETE CASCADE ) From cad27abab2e49b499eb220829ceb45f093f48c72 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 28 Jul 2026 13:36:23 +0200 Subject: [PATCH 21/31] Added checks for send_letter input --- src/OpenPostbud/routes/api/letters.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/OpenPostbud/routes/api/letters.py b/src/OpenPostbud/routes/api/letters.py index 4158fc3..b57f8d1 100644 --- a/src/OpenPostbud/routes/api/letters.py +++ b/src/OpenPostbud/routes/api/letters.py @@ -46,6 +46,16 @@ class SendLetterResponse(BaseModel): def send_letter(letter: SendLetterModel, token: Annotated[dict[str, str], Depends(check_bearer_token)]) -> SendLetterResponse: """Send a single letter without a shipment.""" + if letters.MemoFields.MEMO_LABEL.is_mandatory_for(letter.post_type) and letter.memo_label is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Memo label should be set on digital letters.") + + if letter.memo_label is not None and not letters.MemoFields.MEMO_LABEL.pattern.fullmatch(letter.memo_label): + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Memo label doesn't match the required pattern: {letters.MemoFields.MEMO_LABEL.pattern.pattern}") + + + if not letters.MemoFields.MEMO_MODTAGER.pattern.fullmatch(letter.recipient_id): + raise HTTPException(400, f"Recipient doesn't match the required pattern: {letters.MemoFields.MEMO_MODTAGER.pattern.pattern}") + new_letter = letters.Letter( id=letters.LETTER_ID_FACTORY(), shipment_id=None, From 82c3069f46e7a02234e4ec603fdc1979fef7c679 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Wed, 29 Jul 2026 09:46:57 +0200 Subject: [PATCH 22/31] Refactor --- src/OpenPostbud/database/digital_post/letters.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/OpenPostbud/database/digital_post/letters.py b/src/OpenPostbud/database/digital_post/letters.py index 2683c6d..14a67c3 100644 --- a/src/OpenPostbud/database/digital_post/letters.py +++ b/src/OpenPostbud/database/digital_post/letters.py @@ -54,14 +54,14 @@ def is_mandatory_for(self, post_type: PostType) -> bool: return self.mandatory_digital or self.mandatory_physical -LETTER_ID_FACTORY = create_id("L-", 10) +create_letter_id = create_id("L-", 10) class Letter(Base): """An ORM class representing a letter.""" __tablename__ = "Letters" - id: Mapped[str] = mapped_column(String(12), primary_key=True, default=LETTER_ID_FACTORY) + id: Mapped[str] = mapped_column(String(12), primary_key=True, default=create_letter_id) shipment_id: Mapped[str] = mapped_column(ForeignKey("Shipments.id", ondelete="CASCADE"), nullable=True) recipient_id: Mapped[str] = mapped_column(EncryptedString()) updated_at: Mapped[datetime] = mapped_column(default=datetime.now) @@ -144,19 +144,24 @@ def add_letters(shipment_id: str, csv_data: list[dict[str, str]]): Args: shipment_id: The id of the shipment the letters belong to. csv_data: A list of dictionaries containing merge data. + + Raises: + ValueError: If no shipment with the given id exists. """ shipment = shipments.get_shipment(shipment_id) + if not shipment: + raise ValueError("No shipment found with the given id.") letter_dicts = [] for line in csv_data: - recipient = line[MemoFields.MEMO_MODTAGER.key] - del line[MemoFields.MEMO_MODTAGER.key] + field_data = dict(line) + recipient = field_data.pop(MemoFields.MEMO_MODTAGER.key) letter_dicts.append( { "shipment_id": shipment_id, "recipient_id": recipient, - "field_data": json.dumps(line), + "field_data": json.dumps(field_data), "post_type": shipment.post_type, "created_by": shipment.created_by } From 77b464a0fa7f728e8f985c8f078c7ce7c9652fa6 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Wed, 29 Jul 2026 09:47:22 +0200 Subject: [PATCH 23/31] Added data checks --- src/OpenPostbud/routes/api/letters.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/OpenPostbud/routes/api/letters.py b/src/OpenPostbud/routes/api/letters.py index b57f8d1..64d96b3 100644 --- a/src/OpenPostbud/routes/api/letters.py +++ b/src/OpenPostbud/routes/api/letters.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import Annotated import base64 +import binascii import json from fastapi import APIRouter, Depends, status @@ -52,12 +53,16 @@ def send_letter(letter: SendLetterModel, token: Annotated[dict[str, str], Depend if letter.memo_label is not None and not letters.MemoFields.MEMO_LABEL.pattern.fullmatch(letter.memo_label): raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Memo label doesn't match the required pattern: {letters.MemoFields.MEMO_LABEL.pattern.pattern}") - if not letters.MemoFields.MEMO_MODTAGER.pattern.fullmatch(letter.recipient_id): - raise HTTPException(400, f"Recipient doesn't match the required pattern: {letters.MemoFields.MEMO_MODTAGER.pattern.pattern}") + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Recipient doesn't match the required pattern: {letters.MemoFields.MEMO_MODTAGER.pattern.pattern}") + + try: + letter_document = base64.b64decode(letter.letter_document, validate=True) + except binascii.Error as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Letter document isn't valid base64.") from exc new_letter = letters.Letter( - id=letters.LETTER_ID_FACTORY(), + id=letters.create_letter_id(), shipment_id=None, recipient_id=letter.recipient_id, field_data=json.dumps({letters.MemoFields.MEMO_LABEL.key: letter.memo_label}), @@ -65,11 +70,16 @@ def send_letter(letter: SendLetterModel, token: Annotated[dict[str, str], Depend created_by=token["sub"] ) - document_storage.save_letter_doc(None, new_letter.id, base64.b64decode(letter.letter_document)) + document_storage.save_letter_doc(None, new_letter.id, letter_document) - with connection.get_session() as session: - session.add(new_letter) - session.commit() + try: + with connection.get_session() as session: + session.add(new_letter) + session.commit() + except Exception: + # Prevent orphan files + document_storage.delete_single_letter_doc(new_letter.id) + raise return SendLetterResponse(id=new_letter.id) From d4ad0fadf7638ca7d15532a4131e2d74c1bc1afb Mon Sep 17 00:00:00 2001 From: Mathias G Date: Wed, 29 Jul 2026 09:47:35 +0200 Subject: [PATCH 24/31] Removed default value --- .../migrations/sql/003_letter_post_type_nullable_shipment.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql b/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql index 5aca754..eca2804 100644 --- a/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql +++ b/src/OpenPostbud/database/migrations/sql/003_letter_post_type_nullable_shipment.sql @@ -8,7 +8,7 @@ CREATE TABLE "Letters_new" ( field_data BINARY NOT NULL, transaction_id VARCHAR, sent_as VARCHAR(8), - post_type VARCHAR(8) NOT NULL DEFAULT 'DIGITAL', + post_type VARCHAR(8) NOT NULL, created_by VARCHAR(50) NOT NULL, PRIMARY KEY (id), FOREIGN KEY(shipment_id) REFERENCES "Shipments" (id) ON DELETE CASCADE From 18504b7eea0dcb0b4b2491dae2c2fed9925b6a5d Mon Sep 17 00:00:00 2001 From: Mathias G Date: Wed, 29 Jul 2026 09:47:47 +0200 Subject: [PATCH 25/31] Bumped OP version --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 9b6a38b..5fc68ee 100644 --- a/uv.lock +++ b/uv.lock @@ -1028,7 +1028,7 @@ wheels = [ [[package]] name = "openpostbud" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 3d6791b408f4a3c481a3042f20fc9e074548141b Mon Sep 17 00:00:00 2001 From: Mathias G Date: Wed, 29 Jul 2026 09:50:40 +0200 Subject: [PATCH 26/31] Removed outdated schema doc --- SCHEMAS.md | 42 ------------------------------------------ 1 file changed, 42 deletions(-) delete mode 100644 SCHEMAS.md diff --git a/SCHEMAS.md b/SCHEMAS.md deleted file mode 100644 index db12742..0000000 --- a/SCHEMAS.md +++ /dev/null @@ -1,42 +0,0 @@ -# Database Schemas - -## Shipments - -Shipments that has been created in the ui. - -| Column | Type | Note | -| ----------- | -------- | ------------- | -| id | int | PK | -| name | str | | -| description | str | | -| template_id | int | FK(templates) | -| created_at | datetime | | -| created_by | str | | -| status | str | | -| type | str | NemSMS/DP | -| | | | - -## Letters - -Descriptions of each letter inside shipments. - -| Column | Type | Note | -| ------------ | -------- | ------------- | -| id | int | PK | -| shipment_id | int | FK(shipments) | -| recipient_id | int | | -| updated_at | datetime | | -| status | str | | -| field_data | str | json | -| | | | - -## Templates - -Docx templates used to generate letters. - -| Column | Type | Note | -| ----------- | ---- | ---- | -| id | int | PK | -| file_name | str | | -| file_data | blob | | -| field_names | str | | From c7eaa4f5e169a2b690e7c10c995c9ad407db8149 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Wed, 29 Jul 2026 09:58:21 +0200 Subject: [PATCH 27/31] Updated changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 089899a..5872e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- API endpoint for sending single letters without a shipment. +- Admin page for viewing api letters and deactivating api users. +- Added get_pdf param to get_letter api endpoint to allow not downloading letter files. + +### Changed + +- Added created_by and post_type to letters db table to support single letters. +- Made shipment_id nullable in letters db table. +- Api users can not be deleted if any letters are attached to them. + ## [0.3.0] ### Added From 2d9e772bebd46bdc9031a83b93df254a178e11d3 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Thu, 30 Jul 2026 09:11:02 +0200 Subject: [PATCH 28/31] Cleaned up single letter functionality --- .../database/digital_post/letters.py | 89 +++++++++++++++++-- src/OpenPostbud/database/document_storage.py | 72 +++++++++------ src/OpenPostbud/routes/api/letters.py | 48 +++++----- src/OpenPostbud/workers/shipment_worker.py | 11 +-- 4 files changed, 156 insertions(+), 64 deletions(-) diff --git a/src/OpenPostbud/database/digital_post/letters.py b/src/OpenPostbud/database/digital_post/letters.py index 14a67c3..d693482 100644 --- a/src/OpenPostbud/database/digital_post/letters.py +++ b/src/OpenPostbud/database/digital_post/letters.py @@ -56,6 +56,24 @@ def is_mandatory_for(self, post_type: PostType) -> bool: create_letter_id = create_id("L-", 10) +# Maximum size of a payload before base 64 encoding accepted by the receiving APIs. +DIGITAL_MAX_PAYLOAD_BYTES = 74 * 1024 * 1024 +PHYSICAL_MAX_PAYLOAD_BYTES = 7 * 1024 * 1024 + +# The magic bytes at the start of every pdf file. +PDF_MAGIC_BYTES = b"%PDF-" + + +def max_payload_bytes(post_type: PostType) -> int: + """The maximum payload size allowed for the given post type. + + AUTO uses the smaller of the two limits, since the letter must be + sendable whichever route the recipient takes. + """ + if post_type == PostType.DIGITAL: + return DIGITAL_MAX_PAYLOAD_BYTES + return PHYSICAL_MAX_PAYLOAD_BYTES + class Letter(Base): """An ORM class representing a letter.""" @@ -84,16 +102,32 @@ def to_row_dict(self) -> dict[str, str]: "sent_as": self.sent_as.value if self.sent_as else "" } - def merge_letter(self) -> bytes: - """Merge the letter's merge field data with its template - and convert to pdf. + def get_document(self) -> bytes: + """Get the letter's final document as a pdf. + + A single letter has its document supplied when it's created, so the + document is read from the document storage as is. + + A letter belonging to a shipment is merged from the shipment's + template and the letter's merge field data. The result is cached in + the document storage so the merge only happens once. Returns: - The merged pdf letter as bytes. + The letter's pdf as bytes. + + Raises: + FileNotFoundError: If a single letter's document is missing from + the document storage. """ - stored_file = document_storage.get_letter_doc(self.shipment_id, self.id) - if stored_file: - return stored_file + if self.shipment_id is None: + document = document_storage.get_single_letter_doc(self.id) + if document is None: + raise FileNotFoundError(f"The document of single letter {self.id} is missing from the document storage.") + return document + + cached_file = document_storage.get_letter_doc(self.shipment_id, self.id) + if cached_file: + return cached_file template = templates.get_template_by_shipment(self.shipment_id) @@ -172,6 +206,47 @@ def add_letters(shipment_id: str, csv_data: list[dict[str, str]]): session.commit() +def add_single_letter(recipient_id: str, memo_label: str | None, post_type: PostType, created_by: str, document: bytes) -> str: + """Add a single letter without a shipment to the database. + + The letter's document is supplied instead of being merged from a + template. It's written to the document storage before the letter row is + committed, so the shipment worker never picks up a letter whose document + is missing. If the row can't be committed the document is deleted again + to avoid orphan files. + + Args: + recipient_id: The cpr or cvr number of the recipient. + memo_label: The label shown to the recipient in Digital Post. + post_type: How the letter should be sent. + created_by: The id of the api user creating the letter. + document: The letter's document as a pdf. + + Returns: + The id of the new letter. + """ + letter = Letter( + id=create_letter_id(), + shipment_id=None, + recipient_id=recipient_id, + field_data=json.dumps({MemoFields.MEMO_LABEL.key: memo_label}), + post_type=post_type, + created_by=created_by + ) + + document_storage.save_single_letter_doc(letter.id, document) + + try: + with connection.get_session() as session: + session.add(letter) + session.commit() + except Exception: + document_storage.delete_single_letter_doc(letter.id) + raise + + return letter.id + + def get_letters(shipment_id: str) -> tuple[Letter]: """Get all letters belonging to a shipment.""" with connection.get_session() as session: diff --git a/src/OpenPostbud/database/document_storage.py b/src/OpenPostbud/database/document_storage.py index c44aa40..4770162 100644 --- a/src/OpenPostbud/database/document_storage.py +++ b/src/OpenPostbud/database/document_storage.py @@ -41,11 +41,33 @@ class Attachment: mime_type: str | None = None -def _get_shipment_folder(shipment_id: str | None) -> Path: +def _get_shipment_folder(shipment_id: str) -> Path: """Get the folder associated with the given shipment id.""" - if shipment_id: - return SHIPMENTS_FOLDER / shipment_id - return SINGLE_LETTERS_FOLDER + return SHIPMENTS_FOLDER / shipment_id + + +def _get_letter_path(shipment_id: str, letter_id: str) -> Path: + """Get the path to the doc file of a letter belonging to a shipment.""" + return (_get_shipment_folder(shipment_id) / letter_id).with_suffix(LETTER_SUFFIX) + + +def _get_single_letter_path(letter_id: str) -> Path: + """Get the path to the doc file of a single letter without a shipment.""" + return (SINGLE_LETTERS_FOLDER / letter_id).with_suffix(LETTER_SUFFIX) + + +def _write_doc(doc_path: Path, doc_bytes: bytes): + """Write a document to the given path, creating the folder if needed.""" + doc_path.parent.mkdir(parents=True, exist_ok=True) + doc_path.write_bytes(doc_bytes) + + +def _read_doc(doc_path: Path) -> bytes | None: + """Read the document at the given path if it exists.""" + try: + return doc_path.read_bytes() + except FileNotFoundError: + return None def delete_shipment_docs(shipment_id: str): @@ -55,37 +77,35 @@ def delete_shipment_docs(shipment_id: str): shutil.rmtree(folder_path) -def delete_single_letter_doc(letter_id: str): - """Delete the stored document for a single letter without a shipment.""" - letter_path = _get_letter_path(shipment_id=None, letter_id=letter_id) - letter_path.unlink(missing_ok=True) +def save_letter_doc(shipment_id: str, letter_id: str, doc_bytes: bytes): + """Cache the merged document of a letter belonging to a shipment. + It's assumed the document is a pdf file. + """ + _write_doc(_get_letter_path(shipment_id, letter_id), doc_bytes) -def _get_letter_path(shipment_id: str | None, letter_id: str) -> Path: - """Get the path to the letter's doc file.""" - folder_path = _get_shipment_folder(shipment_id) - return (folder_path / letter_id).with_suffix(LETTER_SUFFIX) +def get_letter_doc(shipment_id: str, letter_id: str) -> bytes | None: + """Get the cached merged document of a letter belonging to a shipment, + if it has been merged before. + """ + return _read_doc(_get_letter_path(shipment_id, letter_id)) -def save_letter_doc(shipment_id: str, letter_id: str, doc_bytes: bytes): - """Save a letter's document to the document storage. +def save_single_letter_doc(letter_id: str, doc_bytes: bytes): + """Save the document of a single letter without a shipment. It's assumed the document is a pdf file. """ - letter_path = _get_letter_path(shipment_id, letter_id) - letter_path.parent.mkdir(parents=True, exist_ok=True) - letter_path.write_bytes(doc_bytes) + _write_doc(_get_single_letter_path(letter_id), doc_bytes) -def get_letter_doc(shipment_id: str | None, letter_id: str) -> bytes | None: - """Get a letter's document from the document storage - if it exists. - """ - letter_path = _get_letter_path(shipment_id, letter_id) +def get_single_letter_doc(letter_id: str) -> bytes | None: + """Get the document of a single letter without a shipment, if it exists.""" + return _read_doc(_get_single_letter_path(letter_id)) - try: - return letter_path.read_bytes() - except FileNotFoundError: - return None + +def delete_single_letter_doc(letter_id: str): + """Delete the stored document for a single letter without a shipment.""" + _get_single_letter_path(letter_id).unlink(missing_ok=True) def _get_attachments_folder(shipment_id: str) -> Path: diff --git a/src/OpenPostbud/routes/api/letters.py b/src/OpenPostbud/routes/api/letters.py index 64d96b3..5b1fef2 100644 --- a/src/OpenPostbud/routes/api/letters.py +++ b/src/OpenPostbud/routes/api/letters.py @@ -1,16 +1,15 @@ -"""This module defines routes for the shipments api.""" +"""This module defines routes for the letters api.""" from datetime import datetime from typing import Annotated import base64 import binascii -import json from fastapi import APIRouter, Depends, status from fastapi.exceptions import HTTPException from pydantic import BaseModel, Field -from OpenPostbud.database import connection, document_storage +from OpenPostbud.database import connection from OpenPostbud.database.common import PostType from OpenPostbud.database.digital_post import letters from OpenPostbud.routes.api.dependencies import check_bearer_token @@ -31,11 +30,11 @@ class LetterDetail(BaseModel): class SendLetterModel(BaseModel): - """A model representing a shipment response.""" + """A model representing a single letter to be sent.""" recipient_id: str memo_label: str | None post_type: PostType - letter_document: str = Field(description="Base64-encoded file contents.") + letter_document: str = Field(description="Base64-encoded pdf file contents.") class SendLetterResponse(BaseModel): @@ -61,27 +60,27 @@ def send_letter(letter: SendLetterModel, token: Annotated[dict[str, str], Depend except binascii.Error as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Letter document isn't valid base64.") from exc - new_letter = letters.Letter( - id=letters.create_letter_id(), - shipment_id=None, + if not letter_document.startswith(letters.PDF_MAGIC_BYTES): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Letter document isn't a pdf file.") + + # Reject oversized documents here rather than letting the letter fail in + # the shipment worker after it has been accepted. + max_bytes = letters.max_payload_bytes(letter.post_type) + if len(letter_document) > max_bytes: + raise HTTPException( + status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + f"Letter document exceeds the maximum size of {max_bytes} bytes for post type {letter.post_type.name}." + ) + + letter_id = letters.add_single_letter( recipient_id=letter.recipient_id, - field_data=json.dumps({letters.MemoFields.MEMO_LABEL.key: letter.memo_label}), + memo_label=letter.memo_label, post_type=letter.post_type, - created_by=token["sub"] + created_by=token["sub"], + document=letter_document ) - document_storage.save_letter_doc(None, new_letter.id, letter_document) - - try: - with connection.get_session() as session: - session.add(new_letter) - session.commit() - except Exception: - # Prevent orphan files - document_storage.delete_single_letter_doc(new_letter.id) - raise - - return SendLetterResponse(id=new_letter.id) + return SendLetterResponse(id=letter_id) @router.get("/letter/{letter_id}", tags=["Letters"]) @@ -95,7 +94,10 @@ def get_letter(letter_id: str, get_pdf: bool = True) -> LetterDetail: raise HTTPException(status.HTTP_400_BAD_REQUEST, "No letter exists with the given id") if get_pdf: - pdf = letter.merge_letter() + try: + pdf = letter.get_document() + except FileNotFoundError as exc: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "The letter's document is no longer available.") from exc pdf_64 = base64.b64encode(pdf).decode() else: pdf_64 = None diff --git a/src/OpenPostbud/workers/shipment_worker.py b/src/OpenPostbud/workers/shipment_worker.py index 50b2339..ab525ef 100644 --- a/src/OpenPostbud/workers/shipment_worker.py +++ b/src/OpenPostbud/workers/shipment_worker.py @@ -19,17 +19,12 @@ from OpenPostbud import config from OpenPostbud.database import connection -from OpenPostbud.database.digital_post.letters import Letter, MemoFields +from OpenPostbud.database.digital_post.letters import Letter, MemoFields, DIGITAL_MAX_PAYLOAD_BYTES, PHYSICAL_MAX_PAYLOAD_BYTES from OpenPostbud.database.digital_post.shipments import Shipment from OpenPostbud.database.common import ShipmentStatus, PostType from OpenPostbud.database import document_storage -# Maximum size of files before base 64 encoding accepted by the receiving APIs. -DIGITAL_MAX_PAYLOAD_BYTES = 74 * 1024 * 1024 -PHYSICAL_MAX_PAYLOAD_BYTES = 7 * 1024 * 1024 - - def start_process(): """The entry point of the worker process. @@ -128,7 +123,7 @@ def send_letter(letter: Letter, kombit_access: KombitAccess): def send_digital(letter: Letter, kombit_access: KombitAccess): """Send a letter as Digital Post.""" - document = letter.merge_letter() + document = letter.get_document() b64_doc = base64.b64encode(document).decode() label = json.loads(letter.field_data)[MemoFields.MEMO_LABEL.key] @@ -204,7 +199,7 @@ def send_physical(letter: Letter, kombit_access: KombitAccess): The recipient address must be present in the letter itself so it shows through the window of the envelope. The recipient id is therefore not sent. """ - document = letter.merge_letter() + document = letter.get_document() if len(document) > PHYSICAL_MAX_PAYLOAD_BYTES: letter.set_status(ShipmentStatus.FAILED, message=f"Fjernpost størrelse oversteg {PHYSICAL_MAX_PAYLOAD_BYTES/(1024*1024)}MB") From ca3131f7e2fbf8ab3bcdefdfc5b5aaf3c103e09c Mon Sep 17 00:00:00 2001 From: Mathias G Date: Thu, 30 Jul 2026 11:08:07 +0200 Subject: [PATCH 29/31] Added sleep on timeout and fixed query ordering --- src/OpenPostbud/workers/nemsms_worker.py | 8 ++++++-- src/OpenPostbud/workers/shipment_worker.py | 12 ++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/OpenPostbud/workers/nemsms_worker.py b/src/OpenPostbud/workers/nemsms_worker.py index 72c1c98..e8bbe37 100644 --- a/src/OpenPostbud/workers/nemsms_worker.py +++ b/src/OpenPostbud/workers/nemsms_worker.py @@ -16,6 +16,7 @@ from OpenPostbud import config from OpenPostbud.database import connection from OpenPostbud.database.nemsms.nemsms_messages import NemSMSMessage +from OpenPostbud.database.nemsms.nemsms_shipments import NemSMSShipment from OpenPostbud.database.nemsms import nemsms_shipments from OpenPostbud.database.common import ShipmentStatus @@ -38,7 +39,8 @@ def start_process(): send_message(message, kombit_access) except Timeout: message.set_status(ShipmentStatus.WAITING, message="Timeout. Prøver igen.") - logging.error(f"Sending message {message.id} timed out.") + logging.error(f"Sending message {message.id} timed out. Sleeping for {config.SHIPMENT_WORKER_SLEEP_TIME} seconds.") + time.sleep(config.SHIPMENT_WORKER_SLEEP_TIME) except Exception as e: # pylint: disable=broad-exception-caught message.set_status(ShipmentStatus.FAILED, message="Systemfejl: e.__class__.__name__") logging.error(f"Sending message {message.id} failed: {e}") @@ -57,10 +59,12 @@ def get_waiting_message() -> NemSMSMessage | None: with connection.get_session() as session: sub_q = ( select(NemSMSMessage.id) + .join(NemSMSShipment, NemSMSMessage.shipment_id == NemSMSShipment.id) .where( NemSMSMessage.status == ShipmentStatus.WAITING, - datetime.now() - timedelta(seconds=config.SHIPMENT_WORKER_DELAY) > NemSMSMessage.updated_at + datetime.now() - timedelta(seconds=config.SHIPMENT_WORKER_DELAY) > NemSMSShipment.created_at ) + .order_by(NemSMSShipment.created_at, NemSMSMessage.shipment_id, NemSMSMessage.updated_at) .limit(1) .scalar_subquery() ) diff --git a/src/OpenPostbud/workers/shipment_worker.py b/src/OpenPostbud/workers/shipment_worker.py index ab525ef..98d94de 100644 --- a/src/OpenPostbud/workers/shipment_worker.py +++ b/src/OpenPostbud/workers/shipment_worker.py @@ -10,7 +10,7 @@ import uuid import json -from sqlalchemy import select, update +from sqlalchemy import or_, select, update from python_serviceplatformen.authentication import KombitAccess from python_serviceplatformen import digital_post from python_serviceplatformen.models.message import Message, MessageHeader, MessageBody, MainDocument, Sender, Recipient, File, AdditionalDocument @@ -43,7 +43,8 @@ def start_process(): send_letter(letter, kombit_access) except Timeout: letter.set_status(ShipmentStatus.WAITING, message="Timeout. Prøver igen.") - logging.error(f"Sending letter {letter.id} timed out.") + logging.error(f"Sending letter {letter.id} timed out. Sleeping for {config.SHIPMENT_WORKER_SLEEP_TIME} seconds.") + time.sleep(config.SHIPMENT_WORKER_SLEEP_TIME) except HTTPError as e: response_body = e.response.text if e.response is not None else "" letter.set_status(ShipmentStatus.FAILED, message=f"Systemfejl: {e.__class__.__name__}") @@ -69,9 +70,12 @@ def get_waiting_letter() -> Letter | None: .outerjoin(Shipment, Letter.shipment_id == Shipment.id) .where( Letter.status == ShipmentStatus.WAITING, - datetime.now() - timedelta(seconds=config.SHIPMENT_WORKER_DELAY) > Letter.updated_at + or_( + datetime.now() - timedelta(seconds=config.SHIPMENT_WORKER_DELAY) > Shipment.created_at, + Letter.shipment_id.is_(None) + ) ) - .order_by(Shipment.created_at.nulls_first(), Letter.updated_at, Letter.shipment_id) + .order_by(Shipment.created_at.nulls_first(), Letter.shipment_id, Letter.updated_at) .limit(1) .scalar_subquery() ) From 0f05d814470a2436f4cbc7ed8a1425b6026664f5 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Thu, 30 Jul 2026 12:59:16 +0200 Subject: [PATCH 30/31] Added indexes in db --- CHANGELOG.md | 1 + .../migrations/sql/004_add_indexes.sql | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/OpenPostbud/database/migrations/sql/004_add_indexes.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 5872e20..fb0b5af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added created_by and post_type to letters db table to support single letters. - Made shipment_id nullable in letters db table. - Api users can not be deleted if any letters are attached to them. +- Added indexes to the letters, nemsms messages and registration tasks db tables. ## [0.3.0] diff --git a/src/OpenPostbud/database/migrations/sql/004_add_indexes.sql b/src/OpenPostbud/database/migrations/sql/004_add_indexes.sql new file mode 100644 index 0000000..5a1af54 --- /dev/null +++ b/src/OpenPostbud/database/migrations/sql/004_add_indexes.sql @@ -0,0 +1,22 @@ +CREATE INDEX letters_shipment_id ON "Letters"(shipment_id) + + +CREATE INDEX letters_transaction_id ON "Letters"(transaction_id) + + +CREATE INDEX letters_status ON "Letters"(status) + + +CREATE INDEX nemsms_messages_shipment_id ON "NemSMS_Messages"(shipment_id) + + +CREATE INDEX nemsms_messages_transaction_id ON "NemSMS_Messages"(transaction_id) + + +CREATE INDEX nemsms_messages_status ON "NemSMS_Messages"(status) + + +CREATE INDEX registration_tasks_job_id ON "RegistrationTasks"(job_id) + + +CREATE INDEX registration_tasks_status ON "RegistrationTasks"(status) \ No newline at end of file From 9619288e969d806feb6f2cee11c4439f40134b05 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Thu, 13 Aug 2026 13:31:13 +0200 Subject: [PATCH 31/31] Fixed typehint --- src/OpenPostbud/database/api_users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenPostbud/database/api_users.py b/src/OpenPostbud/database/api_users.py index 8b2220a..14f2390 100644 --- a/src/OpenPostbud/database/api_users.py +++ b/src/OpenPostbud/database/api_users.py @@ -41,7 +41,7 @@ def get_api_users() -> tuple[ApiUser]: return tuple(result) -def get_api_user(id: str) -> ApiUser: +def get_api_user(id: str) -> ApiUser | None: """Get a single api user from the database.""" with connection.get_session() as session: return session.get(ApiUser, id)