From e9b5a26cd114d148ca67578e81024048dd614d64 Mon Sep 17 00:00:00 2001 From: Jesper Kristensen Date: Mon, 22 Jun 2026 16:41:53 +0200 Subject: [PATCH 01/23] Updated nginx config to match server setups --- .docker/nginx.conf | 4 +--- .docker/templates/default.conf.template | 7 ++++++- CHANGELOG.md | 2 ++ docker-compose.server.yml | 2 ++ docker-compose.yml | 1 + 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.docker/nginx.conf b/.docker/nginx.conf index 0335e53..ec278a5 100644 --- a/.docker/nginx.conf +++ b/.docker/nginx.conf @@ -17,9 +17,7 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; - set_real_ip_from 172.16.0.0/8; - real_ip_recursive on; - real_ip_header X-Forwarded-For; + # Note: set_real_ip_from is set in the server block log_format main '$http_x_real_ip - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' diff --git a/.docker/templates/default.conf.template b/.docker/templates/default.conf.template index 8a2357f..02b9192 100644 --- a/.docker/templates/default.conf.template +++ b/.docker/templates/default.conf.template @@ -10,7 +10,12 @@ server { port_in_redirect off; - client_max_body_size 10m; + client_max_body_size ${NGINX_MAX_BODY_SIZE}; + + set_real_ip_from 172.16.0.0/16; + set_real_ip_from 192.168.39.0/24; + real_ip_recursive on; + real_ip_header X-Forwarded-For; proxy_connect_timeout 3600; proxy_send_timeout 3600; diff --git a/CHANGELOG.md b/CHANGELOG.md index 55c18b2..4310498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Updated nginx configuration. + ## [0.2.0] ### Added diff --git a/docker-compose.server.yml b/docker-compose.server.yml index efb2fb3..c6bb81d 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -20,10 +20,12 @@ services: - registration_worker volumes: - ./.docker/templates:/etc/nginx/templates:ro + - ./.docker/nginx.conf:/etc/nginx/nginx.conf:ro - ./ui:/app environment: NGINX_WEB_ROOT: /app NGINX_PORT: 8080 + NGINX_MAX_BODY_SIZE: 10m TZ: Europe/Copenhagen labels: - "traefik.enable=true" diff --git a/docker-compose.yml b/docker-compose.yml index 53b3ddc..390abdc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: environment: NGINX_WEB_ROOT: /app NGINX_PORT: 8080 + NGINX_MAX_BODY_SIZE: 10m TZ: Europe/Copenhagen labels: - "traefik.enable=true" From 16fbe8dfd2538ddffe61a3f2561ade816fd5d192 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 29 Jun 2026 09:12:51 +0200 Subject: [PATCH 02/23] Made memo fields mandatory setting granual --- .../database/digital_post/letters.py | 24 +++++++++++--- src/OpenPostbud/routes/user/send_post.py | 32 ++++++++++++------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/OpenPostbud/database/digital_post/letters.py b/src/OpenPostbud/database/digital_post/letters.py index 036d1bb..b5f74f7 100644 --- a/src/OpenPostbud/database/digital_post/letters.py +++ b/src/OpenPostbud/database/digital_post/letters.py @@ -25,16 +25,30 @@ class MemoFields(Enum): Memo functionality. a MemoField has the following members: key: The name of the field when loaded from merge data. - mandatory: If the field is mandatory or not. + 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: bool, pattern: str): + def __init__(self, key: str, mandatory_digital: bool, mandatory_physical: bool, pattern: str): self.key = key - self.mandatory = mandatory + self.mandatory_digital = mandatory_digital + self.mandatory_physical = mandatory_physical self.pattern = re.compile(pattern) - MEMO_MODTAGER = ("Memo Modtager", True, r"\d{10}|\d{8}") - MEMO_LABEL = ("Memo Label", True, r"\S.*") + 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): diff --git a/src/OpenPostbud/routes/user/send_post.py b/src/OpenPostbud/routes/user/send_post.py index 732c7ab..678184c 100644 --- a/src/OpenPostbud/routes/user/send_post.py +++ b/src/OpenPostbud/routes/user/send_post.py @@ -46,7 +46,10 @@ def __init__(self): 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) + 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("Gennemgå eksempler"): self.step3 = ExamplesStep(merge_letter=self.step2.merge_letter) @@ -55,6 +58,10 @@ def __init__(self): 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(lambda: 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.step3.set_data(fields, rows) @@ -119,8 +126,9 @@ 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]): + 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] = [] @@ -181,7 +189,7 @@ async def _on_template_upload(self, e: UploadEventArguments): self.template_fields = [] self._update_field_tables() - self._refresh_messages() + self.refresh_messages() async def _on_csv_upload(self, e: UploadEventArguments): """Read the columns from the uploaded csv, refresh the field list, @@ -195,7 +203,7 @@ async def _on_csv_upload(self, e: UploadEventArguments): self.csv_data = list(dict_reader) self._update_field_tables() self._on_csv_changed(self.csv_fields, self.csv_data) - self._refresh_messages() + self.refresh_messages() def _remove_template(self): self._template_upload.reset() @@ -203,7 +211,7 @@ def _remove_template(self): self.template_fields = [] self.template_bytes = None self._update_field_tables() - self._refresh_messages() + self.refresh_messages() def _remove_csv(self): self._csv_upload.reset() @@ -212,7 +220,7 @@ def _remove_csv(self): self.csv_data = None self._update_field_tables() self._on_csv_changed(self.csv_fields, self.csv_data) - self._refresh_messages() + self.refresh_messages() def _update_field_tables(self): """Update the csv and merge field text areas. @@ -241,7 +249,7 @@ def _update_field_tables(self): ui.label(str(f)) ui.separator() - def _refresh_messages(self): + def refresh_messages(self): """Rebuild the message area from current template + csv state. Collects template- and csv-level messages together, then shows the @@ -256,7 +264,7 @@ def _refresh_messages(self): 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)) + 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")) @@ -326,16 +334,18 @@ def go_next(): ui.button("Næste", on_click=go_next) -def _verify_csv_data(fields: list[str], csv_list: list[dict]) -> list[ValidationMessage]: +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? + - 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. @@ -359,7 +369,7 @@ def _verify_csv_data(fields: list[str], csv_list: list[dict]) -> list[Validation # Check for mandatory fields for mf in MemoFields: - if mf.mandatory and mf.key not in fields: + 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) From 02a0e199192e3507c2b0c55c520dea4c0891fd59 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 29 Jun 2026 09:12:59 +0200 Subject: [PATCH 03/23] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4310498..81a0cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + - Updated nginx configuration. +- Made Memo Label optional for Fjernpost. ## [0.2.0] From fc628b7770a33dc265b0f93f7e88d6dc0e835273 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 29 Jun 2026 09:13:10 +0200 Subject: [PATCH 04/23] Updated test data --- test_data/{Merge_data.csv => Merge_data digital.csv} | 0 test_data/Merge_data physical.csv | 6 ++++++ 2 files changed, 6 insertions(+) rename test_data/{Merge_data.csv => Merge_data digital.csv} (100%) create mode 100644 test_data/Merge_data physical.csv diff --git a/test_data/Merge_data.csv b/test_data/Merge_data digital.csv similarity index 100% rename from test_data/Merge_data.csv rename to test_data/Merge_data digital.csv diff --git a/test_data/Merge_data physical.csv b/test_data/Merge_data physical.csv new file mode 100644 index 0000000..e6240f9 --- /dev/null +++ b/test_data/Merge_data physical.csv @@ -0,0 +1,6 @@ +Memo Modtager,Name,Noun +2611740000,John,man +2611740000,Isabella,woman +2611740000,Spot,dog +2611740001,Fail,Failure +12345678,Firma,Company \ No newline at end of file From 2bc5a50f0845206a25780213275c42ee4dde41c9 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 29 Jun 2026 12:46:26 +0200 Subject: [PATCH 05/23] Lint --- src/OpenPostbud/routes/user/send_post.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenPostbud/routes/user/send_post.py b/src/OpenPostbud/routes/user/send_post.py index 678184c..6f2b134 100644 --- a/src/OpenPostbud/routes/user/send_post.py +++ b/src/OpenPostbud/routes/user/send_post.py @@ -60,7 +60,7 @@ def __init__(self): # 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(lambda: self.step2.refresh_messages()) + 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.""" From d8fe28ce4230b8337b5b1f08503d59933acdd7fb Mon Sep 17 00:00:00 2001 From: Mathias G Date: Fri, 3 Jul 2026 12:41:45 +0200 Subject: [PATCH 06/23] Added attachment logic to document storage --- src/OpenPostbud/database/document_storage.py | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/OpenPostbud/database/document_storage.py b/src/OpenPostbud/database/document_storage.py index 950f67e..71195ca 100644 --- a/src/OpenPostbud/database/document_storage.py +++ b/src/OpenPostbud/database/document_storage.py @@ -5,12 +5,40 @@ from pathlib import Path import shutil +from dataclasses import dataclass +from functools import lru_cache STORAGE_FOLDER = Path("OpenPostbud_document_storage") SHIPMENTS_FOLDER = STORAGE_FOLDER / "Shipments" LETTER_SUFFIX = ".pdf" +# Supported file types per the SF1601 documentation +ATTACHMENT_FILE_TYPES = { + '.pdf': 'application/pdf', + '.html': 'text/html', + '.txt': 'text/plain', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.rtf': 'application/msword', + '.bmp': 'image/bmp', + '.gif': 'image/gif', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.tif': 'image/tiff', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.odt': "application/vnd.oasis.opendocument.text", + '.ods': "application/vnd.oasis.opendocument.spreadsheet", +} + + +@dataclass +class Attachment: + name: str + data: bytes + mime_type: str | None = None + def _get_shipment_folder(shipment_id: str) -> Path: """Get the folder associated with the given shipment id.""" @@ -49,3 +77,41 @@ def get_letter_doc(shipment_id: str, letter_id: str) -> bytes | None: return letter_path.read_bytes() except FileNotFoundError: return None + + +def _get_attachments_folder(shipment_id: str) -> Path: + """Get the attachments folder for the given shipment.""" + return _get_shipment_folder(shipment_id) / "attachments" + + +@lru_cache(maxsize=1) +def get_attachments(shipment_id: str) -> list[Attachment]: + """Get all attachments attached to the shipment. + Must be called after add_attachments for the shipment. + """ + folder = _get_attachments_folder(shipment_id) + + if not folder.is_dir(): + return [] + + result = [] + + for file in folder.rglob("*"): + if file.is_file(): + mime_type = ATTACHMENT_FILE_TYPES[file.suffix] + result.append(Attachment(file.name, file.read_bytes(), mime_type)) + + return result + + +def add_attachments(shipment_id: str, attachments: list[Attachment]): + """Add a list of attachments to the shipment. + This should only ever be called once per shipment. + Each attachment is stored in a numbered folder to avoid name + collisions. + """ + folder = _get_attachments_folder(shipment_id) + for i, attachment in enumerate(attachments): + attachment_path = folder / str(i) / attachment.name + attachment_path.parent.mkdir(parents=True, exist_ok=True) + attachment_path.write_bytes(attachment.data) From cf64ee07a2408f400f7aa33ef9c4b491e7da215b Mon Sep 17 00:00:00 2001 From: Mathias G Date: Fri, 3 Jul 2026 12:42:00 +0200 Subject: [PATCH 07/23] Added attachment logic to shipment worker --- src/OpenPostbud/workers/shipment_worker.py | 23 ++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/OpenPostbud/workers/shipment_worker.py b/src/OpenPostbud/workers/shipment_worker.py index dca3b75..06dba6c 100644 --- a/src/OpenPostbud/workers/shipment_worker.py +++ b/src/OpenPostbud/workers/shipment_worker.py @@ -12,7 +12,7 @@ from sqlalchemy import 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 +from python_serviceplatformen.models.message import Message, MessageHeader, MessageBody, MainDocument, Sender, Recipient, File, AdditionalDocument from python_serviceplatformen.models.physical_mail import create_physical_mail from requests import Timeout, HTTPError @@ -21,6 +21,7 @@ from OpenPostbud.database.digital_post.letters import Letter, MemoFields from OpenPostbud.database.digital_post import shipments from OpenPostbud.database.common import ShipmentStatus, PostType +from OpenPostbud.database import document_storage def start_process(): @@ -130,6 +131,23 @@ def send_digital(letter: Letter, kombit_access: KombitAccess): id_type = "CPR" if len(letter.recipient_id) == 10 else "CVR" + attachments = document_storage.get_attachments(letter.shipment_id) + additional_documents = [] + + for attachment in attachments: + additional_documents.append( + AdditionalDocument( + files=[ + File( + encodingFormat=attachment.mime_type, + filename=attachment.name, + language="da", + content=base64.b64encode(attachment.data).decode() + ) + ] + ) + ) + message = Message( messageHeader=MessageHeader( messageType="DIGITALPOST", @@ -156,7 +174,8 @@ def send_digital(letter: Letter, kombit_access: KombitAccess): content=b64_doc ) ] - ) + ), + additionalDocuments=additional_documents ) ) From a73677c66b5425a514a1583577def89cd52781b0 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Fri, 3 Jul 2026 12:42:21 +0200 Subject: [PATCH 08/23] Added attachment upload step to send_post --- src/OpenPostbud/routes/user/send_post.py | 65 ++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/src/OpenPostbud/routes/user/send_post.py b/src/OpenPostbud/routes/user/send_post.py index 6f2b134..b2f8d84 100644 --- a/src/OpenPostbud/routes/user/send_post.py +++ b/src/OpenPostbud/routes/user/send_post.py @@ -12,6 +12,7 @@ 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 @@ -44,15 +45,19 @@ 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) + _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) + _stepper_navigation(stepper, validate_callback=None)#self.step2.validate) + with ui.step("Vedhæftede filer") as step: + self.step3 = AttachmentsStep() + _stepper_navigation(stepper) + step.bind_enabled_from(self.step1.post_type, 'value', backward=lambda v: v != PostType.PHYSICAL) with ui.step("Gennemgå eksempler"): - self.step3 = ExamplesStep(merge_letter=self.step2.merge_letter) + 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) @@ -64,12 +69,16 @@ def __init__(self): 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.step3.set_data(fields, rows) + self.step4.set_data(fields, rows) - def _send_post(self): + 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") @@ -83,6 +92,7 @@ def _send_post(self): 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() @@ -273,6 +283,51 @@ def refresh_messages(self): 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 75MB i alt.") + 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.""" + 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. From 9b771de9b4d1d343de8c50b1bda308e9781f98bf Mon Sep 17 00:00:00 2001 From: Mathias G Date: Fri, 3 Jul 2026 13:00:31 +0200 Subject: [PATCH 09/23] Added attachment download to shipment detail page --- src/OpenPostbud/database/document_storage.py | 34 ++++++++++++++++++-- src/OpenPostbud/routes/user/forsendelser.py | 14 +++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/OpenPostbud/database/document_storage.py b/src/OpenPostbud/database/document_storage.py index 71195ca..d43f2a7 100644 --- a/src/OpenPostbud/database/document_storage.py +++ b/src/OpenPostbud/database/document_storage.py @@ -86,9 +86,7 @@ def _get_attachments_folder(shipment_id: str) -> Path: @lru_cache(maxsize=1) def get_attachments(shipment_id: str) -> list[Attachment]: - """Get all attachments attached to the shipment. - Must be called after add_attachments for the shipment. - """ + """Get all attachments attached to the shipment.""" folder = _get_attachments_folder(shipment_id) if not folder.is_dir(): @@ -104,6 +102,36 @@ def get_attachments(shipment_id: str) -> list[Attachment]: return result +def list_attachments(shipment_id: str) -> list[tuple[str, int]]: + """Return a list of names of all attachments on the shipment. + Includes the index of the attachment to avoid name collisions. + """ + folder = _get_attachments_folder(shipment_id) + + if not folder.is_dir(): + return [] + + result = [] + + for sub_folder in folder.iterdir(): + i = sub_folder.name + file_name = next(sub_folder.iterdir()).name + result.append((file_name, i)) + + return result + + +def get_attachment(shipment_id: str, index: int) -> Attachment: + """Get the attachment file with the given index for the shipment.""" + folder = _get_attachments_folder(shipment_id) / str(index) + + if not folder.is_dir(): + raise ValueError(f"No attachment with index {index} exists for shipment {shipment_id}.") + + file_path = next(folder.iterdir()) + return Attachment(file_path.name, file_path.read_bytes()) + + def add_attachments(shipment_id: str, attachments: list[Attachment]): """Add a list of attachments to the shipment. This should only ever be called once per shipment. diff --git a/src/OpenPostbud/routes/user/forsendelser.py b/src/OpenPostbud/routes/user/forsendelser.py index 68d63e7..4552aab 100644 --- a/src/OpenPostbud/routes/user/forsendelser.py +++ b/src/OpenPostbud/routes/user/forsendelser.py @@ -6,7 +6,7 @@ from OpenPostbud.middleware import authentication from OpenPostbud.database.digital_post import letters from OpenPostbud.database.digital_post import shipments, templates -from OpenPostbud.database import db_util +from OpenPostbud.database import db_util, document_storage SHIPMENTS_COLUMNS = [ {'name': "id", 'label': "ID", 'field': "id"}, @@ -76,6 +76,8 @@ def __init__(self, shipment_id: str) -> None: template_name = templates.get_template_name(self.shipment.template_id) + attachments = document_storage.list_attachments(shipment_id) + with ui.grid(columns="auto auto"): ui.label("Navn:").classes("text-bold") ui.label(self.shipment.name) @@ -89,6 +91,12 @@ def __init__(self, shipment_id: str) -> None: ui.label("Skabelon:").classes("text-bold") ui.link(template_name).on("click", self._download_template) + if attachments: + ui.label("Vedhæftede filer:").classes("text-bold") + with ui.column(): + for attachment in attachments: + ui.link(attachment[0]).on("click", lambda i=attachment[1]: self._download_attachment(i)) + ui.label("Oprettet den:").classes("text-bold") ui.label(self.shipment.created_at.strftime("%d/%m/%Y %H:%M:%S")) @@ -110,6 +118,10 @@ def _download_template(self): template = templates.get_template(self.shipment.template_id) ui.download(template.file_data, template.file_name) + def _download_attachment(self, index): + attachment = document_storage.get_attachment(self.shipment.id, index) + ui.download(attachment.data, attachment.name) + async def _abort_shipment(self): """Abort all waiting letters for the shipment.""" if await ui_components.question_popup("Er du sikker på du vil afbryde forsendelsen?", "Afbryd forsendelse", "Annuller"): From 2f8c2206b91aafa32b9de5dc7e48818eb55fb30b Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 6 Jul 2026 12:24:51 +0200 Subject: [PATCH 10/23] Added attachment logic to api --- src/OpenPostbud/routes/api/shipments.py | 31 +++++++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/OpenPostbud/routes/api/shipments.py b/src/OpenPostbud/routes/api/shipments.py index ac6d46c..8fd204e 100644 --- a/src/OpenPostbud/routes/api/shipments.py +++ b/src/OpenPostbud/routes/api/shipments.py @@ -5,9 +5,9 @@ from fastapi import APIRouter, status from fastapi.exceptions import HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field -from OpenPostbud.database import connection +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 @@ -29,6 +29,7 @@ class ShipmentDetail(ShipmentModel): """ description: str letter_ids: list[str] + has_attachments: bool class LetterDetail(BaseModel): @@ -37,7 +38,13 @@ class LetterDetail(BaseModel): shipment_id: str recipient_id: str status: str - letter_pdf: 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"]) @@ -56,7 +63,7 @@ def get_shipments() -> list[ShipmentModel]: ] -@router.get("/shipment/{shipment_id}", tags=["Shipments"], response_model=ShipmentDetail) +@router.get("/shipment/{shipment_id}", tags=["Shipments"]) def get_shipment(shipment_id: str) -> ShipmentDetail: """Get a shipment by id.""" @@ -74,10 +81,24 @@ def get_shipment(shipment_id: str) -> ShipmentDetail: description=shipment.description, created_at=shipment.created_at, created_by=shipment.created_by, - letter_ids=letter_ids + letter_ids=letter_ids, + has_attachments=len(document_storage.list_attachments(shipment_id)) > 0 ) +@router.get("/shipment/attachments/{shipment_id}", 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 From 560825364582e2dfcf60a1f2de81315af1fa872f Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 6 Jul 2026 12:29:35 +0200 Subject: [PATCH 11/23] Removed dev comments --- src/OpenPostbud/routes/user/send_post.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/OpenPostbud/routes/user/send_post.py b/src/OpenPostbud/routes/user/send_post.py index b2f8d84..90649b3 100644 --- a/src/OpenPostbud/routes/user/send_post.py +++ b/src/OpenPostbud/routes/user/send_post.py @@ -45,16 +45,17 @@ 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) + _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=None)#self.step2.validate) + _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) From 43a2a9fc7101d8274258489fe72c1cee3007648d Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 6 Jul 2026 12:31:48 +0200 Subject: [PATCH 12/23] Lint --- src/OpenPostbud/database/document_storage.py | 1 + src/OpenPostbud/routes/api/shipments.py | 1 - src/OpenPostbud/routes/user/send_post.py | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/OpenPostbud/database/document_storage.py b/src/OpenPostbud/database/document_storage.py index d43f2a7..a73bf05 100644 --- a/src/OpenPostbud/database/document_storage.py +++ b/src/OpenPostbud/database/document_storage.py @@ -35,6 +35,7 @@ @dataclass class Attachment: + """A dataclass representing an attachment file.""" name: str data: bytes mime_type: str | None = None diff --git a/src/OpenPostbud/routes/api/shipments.py b/src/OpenPostbud/routes/api/shipments.py index 8fd204e..81839a2 100644 --- a/src/OpenPostbud/routes/api/shipments.py +++ b/src/OpenPostbud/routes/api/shipments.py @@ -98,7 +98,6 @@ def get_attachments(shipment_id: str) -> list[AttachmentModel]: 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 diff --git a/src/OpenPostbud/routes/user/send_post.py b/src/OpenPostbud/routes/user/send_post.py index 90649b3..74cab84 100644 --- a/src/OpenPostbud/routes/user/send_post.py +++ b/src/OpenPostbud/routes/user/send_post.py @@ -315,7 +315,6 @@ def _remove_attachments(self): self.remove_button.disable() self.upload.reset() - async def get_attachments(self) -> list[document_storage.Attachment]: """Return the attachments still shown in the uploader. From 5d4f0bf7b6838923820e4e43f9ec16448029e237 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 6 Jul 2026 12:32:58 +0200 Subject: [PATCH 13/23] Updated changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81a0cb7..2f030a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Option to add attached files to Digital Shipments. +- API endpoint to get attached files from a shipment. + ### Changed - Updated nginx configuration. From 42b9a1ee030e75d35c5403f3eb15a41d1ef269e4 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 6 Jul 2026 13:20:28 +0200 Subject: [PATCH 14/23] Moved attachment cache to shipment_worker --- src/OpenPostbud/workers/shipment_worker.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/OpenPostbud/workers/shipment_worker.py b/src/OpenPostbud/workers/shipment_worker.py index 06dba6c..4e72745 100644 --- a/src/OpenPostbud/workers/shipment_worker.py +++ b/src/OpenPostbud/workers/shipment_worker.py @@ -4,6 +4,7 @@ import base64 from datetime import datetime, timedelta +from functools import lru_cache import logging import time import uuid @@ -131,7 +132,7 @@ def send_digital(letter: Letter, kombit_access: KombitAccess): id_type = "CPR" if len(letter.recipient_id) == 10 else "CVR" - attachments = document_storage.get_attachments(letter.shipment_id) + attachments = _get_attachments(letter.shipment_id) additional_documents = [] for attachment in attachments: @@ -201,5 +202,11 @@ def send_physical(letter: Letter, kombit_access: KombitAccess): logging.info(f"Physical letter sent {letter.id} - {transaction_id=} - afsendelse={forsendelse.afsendelse_identifikator.value}") +@lru_cache(maxsize=1) +def _get_attachments(shipment_id: str) -> list[document_storage.Attachment]: + """Wrapper around document_storage.get_attachments to allow caching.""" + return document_storage.get_attachments(shipment_id) + + if __name__ == '__main__': start_process() From 2b0c13c4f0dd80a4c80384fc104caf2d310e0ab1 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 6 Jul 2026 13:21:09 +0200 Subject: [PATCH 15/23] Added better validation of attachment suffixes --- src/OpenPostbud/database/document_storage.py | 8 +++----- src/OpenPostbud/routes/user/send_post.py | 7 ++++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/OpenPostbud/database/document_storage.py b/src/OpenPostbud/database/document_storage.py index a73bf05..b97aa06 100644 --- a/src/OpenPostbud/database/document_storage.py +++ b/src/OpenPostbud/database/document_storage.py @@ -6,7 +6,6 @@ from pathlib import Path import shutil from dataclasses import dataclass -from functools import lru_cache STORAGE_FOLDER = Path("OpenPostbud_document_storage") @@ -85,7 +84,6 @@ def _get_attachments_folder(shipment_id: str) -> Path: return _get_shipment_folder(shipment_id) / "attachments" -@lru_cache(maxsize=1) def get_attachments(shipment_id: str) -> list[Attachment]: """Get all attachments attached to the shipment.""" folder = _get_attachments_folder(shipment_id) @@ -97,7 +95,7 @@ def get_attachments(shipment_id: str) -> list[Attachment]: for file in folder.rglob("*"): if file.is_file(): - mime_type = ATTACHMENT_FILE_TYPES[file.suffix] + mime_type = ATTACHMENT_FILE_TYPES[file.suffix.lower()] result.append(Attachment(file.name, file.read_bytes(), mime_type)) return result @@ -115,7 +113,7 @@ def list_attachments(shipment_id: str) -> list[tuple[str, int]]: result = [] for sub_folder in folder.iterdir(): - i = sub_folder.name + i = int(sub_folder.name) file_name = next(sub_folder.iterdir()).name result.append((file_name, i)) @@ -130,7 +128,7 @@ def get_attachment(shipment_id: str, index: int) -> Attachment: raise ValueError(f"No attachment with index {index} exists for shipment {shipment_id}.") file_path = next(folder.iterdir()) - return Attachment(file_path.name, file_path.read_bytes()) + return Attachment(file_path.name, file_path.read_bytes(), ATTACHMENT_FILE_TYPES[file_path.suffix.lower()]) def add_attachments(shipment_id: str, attachments: list[Attachment]): diff --git a/src/OpenPostbud/routes/user/send_post.py b/src/OpenPostbud/routes/user/send_post.py index 74cab84..2f0de4f 100644 --- a/src/OpenPostbud/routes/user/send_post.py +++ b/src/OpenPostbud/routes/user/send_post.py @@ -3,6 +3,7 @@ from csv import DictReader from collections import Counter from collections.abc import Callable +from pathlib import Path from typing import Literal, NamedTuple import asyncio @@ -303,7 +304,11 @@ def __init__(self): self.remove_button.disable() async def _on_upload(self, e: UploadEventArguments): - """Buffer each uploaded file.""" + """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() ) From 802a043c22e3bb73522c29b2242354da3d30edb4 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 6 Jul 2026 13:21:20 +0200 Subject: [PATCH 16/23] Stabilized shipment order --- src/OpenPostbud/workers/shipment_worker.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/OpenPostbud/workers/shipment_worker.py b/src/OpenPostbud/workers/shipment_worker.py index 4e72745..696d858 100644 --- a/src/OpenPostbud/workers/shipment_worker.py +++ b/src/OpenPostbud/workers/shipment_worker.py @@ -21,6 +21,7 @@ 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 @@ -66,10 +67,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) .where( Letter.status == ShipmentStatus.WAITING, datetime.now() - timedelta(seconds=config.SHIPMENT_WORKER_DELAY) > Letter.updated_at ) + .order_by(Shipment.created_at, Letter.shipment_id) .limit(1) .scalar_subquery() ) From 8f28d6dee0447ac5d920f7d1029016638c94bdec Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 6 Jul 2026 13:22:34 +0200 Subject: [PATCH 17/23] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f030a4..627b987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated nginx configuration. - Made Memo Label optional for Fjernpost. +- Made shipment order of letters stable. ## [0.2.0] From 58d19acf7062d6e93a4f75c4ef1e2b7755fcabae Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 6 Jul 2026 13:46:52 +0200 Subject: [PATCH 18/23] Small text change --- src/OpenPostbud/routes/user/send_post.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenPostbud/routes/user/send_post.py b/src/OpenPostbud/routes/user/send_post.py index 2f0de4f..e6d253b 100644 --- a/src/OpenPostbud/routes/user/send_post.py +++ b/src/OpenPostbud/routes/user/send_post.py @@ -292,7 +292,7 @@ class AttachmentsStep: 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 75MB i alt.") + ui.label("Digital Post understøtter op til 10 vedhæftede filer og op til 75MB 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] = {} From 325c45ad4a83157a6a1666b7fa4f1eea343bb4b6 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Mon, 6 Jul 2026 13:47:25 +0200 Subject: [PATCH 19/23] Updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 627b987..0c480df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated nginx configuration. - Made Memo Label optional for Fjernpost. -- Made shipment order of letters stable. +- Made shipment order of letters stable to ensure proper caching. ## [0.2.0] From c5c9a3dce5e153261ef1d71c62adad734345b2a8 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 7 Jul 2026 08:35:04 +0200 Subject: [PATCH 20/23] Changed attachments endpoint url --- src/OpenPostbud/routes/api/shipments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenPostbud/routes/api/shipments.py b/src/OpenPostbud/routes/api/shipments.py index 81839a2..7b522c8 100644 --- a/src/OpenPostbud/routes/api/shipments.py +++ b/src/OpenPostbud/routes/api/shipments.py @@ -86,7 +86,7 @@ def get_shipment(shipment_id: str) -> ShipmentDetail: ) -@router.get("/shipment/attachments/{shipment_id}", tags=["Shipments"]) +@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) From 0298abe256cb873836e691cca5bc82db594f94a0 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 7 Jul 2026 09:41:17 +0200 Subject: [PATCH 21/23] Added payload size safeguard --- src/OpenPostbud/routes/user/send_post.py | 2 +- src/OpenPostbud/workers/shipment_worker.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/OpenPostbud/routes/user/send_post.py b/src/OpenPostbud/routes/user/send_post.py index e6d253b..da8dd20 100644 --- a/src/OpenPostbud/routes/user/send_post.py +++ b/src/OpenPostbud/routes/user/send_post.py @@ -292,7 +292,7 @@ class AttachmentsStep: 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 75MB i alt inkl. brev.") + 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] = {} diff --git a/src/OpenPostbud/workers/shipment_worker.py b/src/OpenPostbud/workers/shipment_worker.py index 696d858..b241924 100644 --- a/src/OpenPostbud/workers/shipment_worker.py +++ b/src/OpenPostbud/workers/shipment_worker.py @@ -26,6 +26,11 @@ 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. @@ -136,6 +141,13 @@ 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) + + payload_size = len(document) + sum(len(attachment.data) for attachment in attachments) + if payload_size > DIGITAL_MAX_PAYLOAD_BYTES: + letter.set_status(ShipmentStatus.FAILED, message=f"Digital Post størrelse oversteg {DIGITAL_MAX_PAYLOAD_BYTES/(1024*1024)}MB") + logging.error(f"Digital letter failed. Total document size exceeded limit of {DIGITAL_MAX_PAYLOAD_BYTES}: {payload_size}") + return + additional_documents = [] for attachment in attachments: @@ -197,6 +209,11 @@ def send_physical(letter: Letter, kombit_access: KombitAccess): """ document = letter.merge_letter() + 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") + logging.error(f"Physical letter failed. Letter size exceeded limit of {PHYSICAL_MAX_PAYLOAD_BYTES}: {len(document)}") + return + forsendelse = create_physical_mail(config.PHYSICAL_MAIL_FORSENDELSE_TYPE, document) logging.info(f"Sending physical letter {letter.id}") From 3a1700c0e605253fa192dfc94508192f018ff8c2 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 7 Jul 2026 10:16:52 +0200 Subject: [PATCH 22/23] Bumped version --- CHANGELOG.md | 5 ++++- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c480df..ea2cdb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +# [0.3.0] + ### Added - Option to add attached files to Digital Shipments. @@ -52,7 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release -[Unreleased]: https://github.com/itk-dev-rpa/OpenPostbud/compare/0.2.0...HEAD +[Unreleased]: https://github.com/itk-dev-rpa/OpenPostbud/compare/0.3.0...HEAD +[0.3.0]: https://github.com/itk-dev-rpa/OpenPostbud/releases/tag/0.3.0 [0.2.0]: https://github.com/itk-dev-rpa/OpenPostbud/releases/tag/0.2.0 [0.1.0]: https://github.com/itk-dev-rpa/OpenPostbud/releases/tag/0.1.0 [0.0.1]: https://github.com/itk-dev-rpa/OpenPostbud/releases/tag/0.0.1 diff --git a/pyproject.toml b/pyproject.toml index 4f8b9b9..253ddb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "OpenPostbud" -version = "0.2.0" +version = "0.3.0" authors = [ { name="Mathias Gammelgaard", email="ghbm@aarhus.dk" }, ] From 1e50a88a539ea395f2d1bc5286dbfeb829c9c6b4 Mon Sep 17 00:00:00 2001 From: Mathias G Date: Tue, 7 Jul 2026 10:20:19 +0200 Subject: [PATCH 23/23] Lint --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea2cdb9..089899a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -# [0.3.0] +## [0.3.0] ### Added