Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bba0b34
Merge pull request #40 from itk-dev-rpa/release/0.2.0
ghbm-itk Jun 22, 2026
e9b5a26
Updated nginx config to match server setups
cableman Jun 22, 2026
ad11b72
Merge pull request #42 from itk-dev-rpa/feature/nginx-config
ghbm-itk Jun 24, 2026
16fbe8d
Made memo fields mandatory setting granual
ghbm-itk Jun 29, 2026
02a0e19
Updated changelog
ghbm-itk Jun 29, 2026
fc628b7
Updated test data
ghbm-itk Jun 29, 2026
2bc5a50
Lint
ghbm-itk Jun 29, 2026
b9f0791
Merge pull request #44 from itk-dev-rpa/feature/granular-merge-fields
ghbm-itk Jun 29, 2026
d8fe28c
Added attachment logic to document storage
ghbm-itk Jul 3, 2026
cf64ee0
Added attachment logic to shipment worker
ghbm-itk Jul 3, 2026
a73677c
Added attachment upload step to send_post
ghbm-itk Jul 3, 2026
9b771de
Added attachment download to shipment detail page
ghbm-itk Jul 3, 2026
2f8c220
Added attachment logic to api
ghbm-itk Jul 6, 2026
5608253
Removed dev comments
ghbm-itk Jul 6, 2026
43a2a9f
Lint
ghbm-itk Jul 6, 2026
5d4f0bf
Updated changelog
ghbm-itk Jul 6, 2026
42b9a1e
Moved attachment cache to shipment_worker
ghbm-itk Jul 6, 2026
2b0c13c
Added better validation of attachment suffixes
ghbm-itk Jul 6, 2026
802a043
Stabilized shipment order
ghbm-itk Jul 6, 2026
8f28d6d
Updated changelog
ghbm-itk Jul 6, 2026
58d19ac
Small text change
ghbm-itk Jul 6, 2026
325c45a
Updated changelog
ghbm-itk Jul 6, 2026
c5c9a3d
Changed attachments endpoint url
ghbm-itk Jul 7, 2026
0298abe
Added payload size safeguard
ghbm-itk Jul 7, 2026
66cd3ad
Merge pull request #46 from itk-dev-rpa/feature/attachments
ghbm-itk Jul 7, 2026
3a1700c
Bumped version
ghbm-itk Jul 7, 2026
1e50a88
Lint
ghbm-itk Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .docker/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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" '
Expand Down
7 changes: 6 additions & 1 deletion .docker/templates/default.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ 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.
- API endpoint to get attached files from a shipment.

### Changed

- Updated nginx configuration.
- Made Memo Label optional for Fjernpost.
- Made shipment order of letters stable to ensure proper caching.

## [0.2.0]

### Added
Expand Down Expand Up @@ -41,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
2 changes: 2 additions & 0 deletions docker-compose.server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand Down
24 changes: 19 additions & 5 deletions src/OpenPostbud/database/digital_post/letters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
93 changes: 93 additions & 0 deletions src/OpenPostbud/database/document_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,40 @@

from pathlib import Path
import shutil
from dataclasses import dataclass


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:
"""A dataclass representing an attachment file."""
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."""
Expand Down Expand Up @@ -49,3 +77,68 @@ 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"


def get_attachments(shipment_id: str) -> list[Attachment]:
"""Get all attachments attached to 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.lower()]
result.append(Attachment(file.name, file.read_bytes(), mime_type))

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 = int(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(), ATTACHMENT_FILE_TYPES[file_path.suffix.lower()])


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)
30 changes: 25 additions & 5 deletions src/OpenPostbud/routes/api/shipments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -29,6 +29,7 @@ class ShipmentDetail(ShipmentModel):
"""
description: str
letter_ids: list[str]
has_attachments: bool


class LetterDetail(BaseModel):
Expand All @@ -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"])
Expand All @@ -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."""

Expand All @@ -74,10 +81,23 @@ 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/{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
Expand Down
14 changes: 13 additions & 1 deletion src/OpenPostbud/routes/user/forsendelser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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)
Expand All @@ -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"))

Expand All @@ -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"):
Expand Down
Loading
Loading