feat(pdfa): add FreeTSA timestamping, per-provider folder overrides, individual upload toggles

- Add RFC 3161 timestamping via FreeTSA (PDFA_TIMESTAMP_ENABLED, PDFA_TIMESTAMP_URL)
- Replace PDFA_UPLOAD_TO_PROVIDERS with individual PDFA_UPLOAD_ORIGINAL and PDFA_UPLOAD_PROCESSED
- Add PDFA_UPLOAD_FOLDER setting for per-provider subfolder configuration
- Add GOOGLE_DRIVE_PDFA_FOLDER_ID for Google Drive-specific folder override
- Add folder_override parameter to all 8 folder-using upload tasks
- Add folder_overrides dict parameter to send_to_all_destinations
- Add _compute_pdfa_folder_overrides() and _timestamp_file() helpers
- Expand tests to 26 (timestamping, folder overrides, individual toggles)
- Update docs/ConfigurationGuide.md and .env.demo

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-02 13:55:34 +00:00
parent eea99eb01d
commit a03b3af933
14 changed files with 584 additions and 90 deletions
+14 -3
View File
@@ -356,6 +356,17 @@ NEAR_DUPLICATE_THRESHOLD=0.85
ENABLE_PDFA_CONVERSION=false ENABLE_PDFA_CONVERSION=false
# PDF/A format variant: 1 = PDF/A-1b, 2 = PDF/A-2b (default), 3 = PDF/A-3b # PDF/A format variant: 1 = PDF/A-1b, 2 = PDF/A-2b (default), 3 = PDF/A-3b
PDFA_FORMAT=2 PDFA_FORMAT=2
# Also upload the processed PDF/A variant to all configured storage providers # Upload original-file PDF/A variant to all configured storage providers
# (files are uploaded with a '-PDFA' suffix in their filename) PDFA_UPLOAD_ORIGINAL=false
PDFA_UPLOAD_TO_PROVIDERS=false # Upload processed-file PDF/A variant to all configured storage providers
PDFA_UPLOAD_PROCESSED=false
# Subfolder name appended to each provider's folder for PDF/A uploads
# e.g. if Dropbox folder is '/Documents' this puts PDF/A files into '/Documents/pdfa'
PDFA_UPLOAD_FOLDER=pdfa
# Google Drive folder ID for PDF/A uploads (uses folder IDs, not paths)
# Leave empty to use the same folder as regular uploads
GOOGLE_DRIVE_PDFA_FOLDER_ID=
# RFC 3161 timestamping of PDF/A files (creates .tsr proof-of-existence files)
PDFA_TIMESTAMP_ENABLED=false
# Timestamp Authority URL (default: FreeTSA, a free RFC 3161 TSA)
PDFA_TIMESTAMP_URL=https://freetsa.org/tsr
+40 -4
View File
@@ -240,12 +240,48 @@ class Settings(BaseSettings):
"Valid values: '1' (PDF/A-1b), '2' (PDF/A-2b), '3' (PDF/A-3b). Default: '2'." "Valid values: '1' (PDF/A-1b), '2' (PDF/A-2b), '3' (PDF/A-3b). Default: '2'."
), ),
) )
pdfa_upload_to_providers: bool = Field( pdfa_upload_original: bool = Field(
default=False, default=False,
description=( description=(
"When enabled and PDF/A conversion is active, also upload the PDF/A variants " "Upload the original-file PDF/A variant to all configured storage providers. "
"to all configured storage providers in addition to the standard processed file. " "Files are placed in the provider's folder + PDFA_UPLOAD_FOLDER subfolder. Default: False."
"PDF/A files are uploaded with a '-PDFA' suffix in their filename. Default: False." ),
)
pdfa_upload_processed: bool = Field(
default=False,
description=(
"Upload the processed-file PDF/A variant to all configured storage providers. "
"Files are placed in the provider's folder + PDFA_UPLOAD_FOLDER subfolder. Default: False."
),
)
pdfa_upload_folder: str = Field(
default="pdfa",
description=(
"Subfolder name appended to each storage provider's configured folder for PDF/A uploads. "
"For example if Dropbox folder is '/Documents' and this is 'pdfa', PDF/A files go to "
"'/Documents/pdfa'. Set to empty string to upload into the same folder. Default: 'pdfa'."
),
)
google_drive_pdfa_folder_id: str = Field(
default="",
description=(
"Google Drive folder ID for PDF/A uploads. Since Google Drive uses IDs not paths, "
"this must be set separately. If empty, uses the standard google_drive_folder_id."
),
)
pdfa_timestamp_enabled: bool = Field(
default=False,
description=(
"Enable RFC 3161 timestamping of PDF/A files via a Timestamp Authority (TSA). "
"Creates a .tsr file alongside each PDF/A file for legal proof of existence. "
"Requires openssl binary on PATH. Default: False."
),
)
pdfa_timestamp_url: str = Field(
default="https://freetsa.org/tsr",
description=(
"URL of the RFC 3161 Timestamp Authority. Default: FreeTSA (https://freetsa.org/tsr). "
"Other options: GlobalSign, DigiStamp, or any RFC 3161-compliant TSA."
), ),
) )
+195 -9
View File
@@ -11,6 +11,11 @@ internally). Two variants are produced when enabled:
Both are saved under ``workdir/pdfa/`` and referenced in the database via Both are saved under ``workdir/pdfa/`` and referenced in the database via
``FileRecord.original_pdfa_path`` and ``FileRecord.processed_pdfa_path``. ``FileRecord.original_pdfa_path`` and ``FileRecord.processed_pdfa_path``.
When ``PDFA_TIMESTAMP_ENABLED`` is True, each PDF/A file also gets an RFC 3161
timestamp response (``.tsr``) from a configurable Timestamp Authority (default:
FreeTSA). This provides cryptographic proof of the file's existence at a given
point in time.
.. note:: .. note::
PDF/A conversion may alter font rendering (especially OCR text overlays PDF/A conversion may alter font rendering (especially OCR text overlays
@@ -23,6 +28,8 @@ import os
import shutil import shutil
import subprocess import subprocess
import requests as http_requests
from app.celery_app import celery from app.celery_app import celery
from app.config import settings from app.config import settings
from app.database import SessionLocal from app.database import SessionLocal
@@ -87,6 +94,110 @@ def _convert_pdf_to_pdfa(input_path: str, output_path: str, pdfa_format: str = "
return True return True
def _timestamp_file(file_path: str, tsa_url: str) -> str | None:
"""Create an RFC 3161 timestamp for a file using a Timestamp Authority.
Uses ``openssl ts`` to create a timestamp request (TSQ) from the file's
SHA-256 hash, submits it to the TSA via HTTP POST, and saves the timestamp
response (TSR) alongside the file.
Args:
file_path: Absolute path to the file to timestamp.
tsa_url: URL of the RFC 3161 Timestamp Authority.
Returns:
Path to the ``.tsr`` file if successful, None otherwise.
"""
openssl_bin = shutil.which("openssl")
if not openssl_bin:
logger.error("[timestamp] openssl binary not found on PATH")
return None
tsr_path = file_path + ".tsr"
tsq_path = file_path + ".tsq"
try:
# Step 1: Create timestamp request
cmd = [openssl_bin, "ts", "-query", "-data", file_path, "-sha256", "-no_nonce", "-out", tsq_path]
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30, check=False) # noqa: S603
if proc.returncode != 0:
logger.warning(f"[timestamp] openssl ts -query failed: {proc.stderr.strip()[:200]}")
return None
# Step 2: Submit TSQ to the Timestamp Authority
with open(tsq_path, "rb") as f:
tsq_data = f.read()
response = http_requests.post(
tsa_url,
data=tsq_data,
headers={"Content-Type": "application/timestamp-query"},
timeout=30,
)
if response.status_code != 200:
logger.warning(f"[timestamp] TSA returned HTTP {response.status_code} from {tsa_url}")
return None
# Step 3: Save the timestamp response
with open(tsr_path, "wb") as f:
f.write(response.content)
logger.info(f"[timestamp] RFC 3161 timestamp saved to {tsr_path}")
return tsr_path
except http_requests.RequestException as e:
logger.warning(f"[timestamp] Failed to contact TSA at {tsa_url}: {e}")
return None
except subprocess.TimeoutExpired:
logger.warning("[timestamp] openssl ts timed out")
return None
finally:
# Always clean up the TSQ file
if os.path.exists(tsq_path):
os.remove(tsq_path)
def _compute_pdfa_folder_overrides() -> dict[str, str]:
"""Compute per-provider folder overrides for PDF/A uploads.
Appends ``settings.pdfa_upload_folder`` to each provider's configured
folder. For Google Drive (which uses folder IDs), uses the dedicated
``google_drive_pdfa_folder_id`` setting.
Returns:
Dictionary mapping provider names to folder override strings.
"""
subfolder = settings.pdfa_upload_folder
overrides: dict[str, str] = {}
if not subfolder:
return overrides
# Path-based providers: append subfolder
for provider, folder_attr in [
("dropbox", "dropbox_folder"),
("nextcloud", "nextcloud_folder"),
("webdav", "webdav_folder"),
("ftp", "ftp_folder"),
("sftp", "sftp_folder"),
("onedrive", "onedrive_folder_path"),
]:
base = getattr(settings, folder_attr, "") or ""
overrides[provider] = f"{base.rstrip('/')}/{subfolder}" if base else subfolder
# S3: append subfolder to prefix
s3_prefix = getattr(settings, "s3_folder_prefix", "") or ""
overrides["s3"] = f"{s3_prefix.rstrip('/')}/{subfolder}/"
# Google Drive: use dedicated folder ID or fall back to default
gdrive_pdfa_id = settings.google_drive_pdfa_folder_id
if gdrive_pdfa_id:
overrides["google_drive"] = gdrive_pdfa_id
return overrides
@celery.task(base=BaseTaskWithRetry, bind=True) @celery.task(base=BaseTaskWithRetry, bind=True)
def convert_to_pdfa(self, file_id: int) -> dict: def convert_to_pdfa(self, file_id: int) -> dict:
"""Generate PDF/A archival copies for a processed document. """Generate PDF/A archival copies for a processed document.
@@ -95,8 +206,11 @@ def convert_to_pdfa(self, file_id: int) -> dict:
processed file (with embedded metadata). Files are saved under processed file (with embedded metadata). Files are saved under
``workdir/pdfa/original/`` and ``workdir/pdfa/processed/`` respectively. ``workdir/pdfa/original/`` and ``workdir/pdfa/processed/`` respectively.
When ``settings.pdfa_upload_to_providers`` is True, the processed PDF/A When timestamping is enabled, each PDF/A file also gets an RFC 3161
variant is also uploaded to all configured storage destinations. ``.tsr`` timestamp from the configured TSA.
Upload of each variant to storage providers is controlled independently
by ``pdfa_upload_original`` and ``pdfa_upload_processed``.
Args: Args:
file_id: ID of the FileRecord to create PDF/A copies for. file_id: ID of the FileRecord to create PDF/A copies for.
@@ -126,6 +240,8 @@ def convert_to_pdfa(self, file_id: int) -> dict:
processed_path = file_record.processed_file_path processed_path = file_record.processed_file_path
pdfa_format = settings.pdfa_format pdfa_format = settings.pdfa_format
timestamp_enabled = settings.pdfa_timestamp_enabled
timestamp_url = settings.pdfa_timestamp_url
results = {} results = {}
# --- Convert original file to PDF/A --- # --- Convert original file to PDF/A ---
@@ -155,6 +271,25 @@ def convert_to_pdfa(self, file_id: int) -> dict:
f"Original PDF/A saved: {os.path.basename(original_pdfa_path)}", f"Original PDF/A saved: {os.path.basename(original_pdfa_path)}",
file_id=file_id, file_id=file_id,
) )
# Timestamp the original PDF/A
if timestamp_enabled:
tsr = _timestamp_file(original_pdfa_path, timestamp_url)
if tsr:
log_task_progress(
task_id,
"timestamp_original_pdfa",
"success",
f"Timestamped: {os.path.basename(tsr)}",
file_id=file_id,
)
else:
log_task_progress(
task_id,
"timestamp_original_pdfa",
"failure",
"Failed to timestamp original PDF/A",
file_id=file_id,
)
else: else:
log_task_progress( log_task_progress(
task_id, task_id,
@@ -200,6 +335,25 @@ def convert_to_pdfa(self, file_id: int) -> dict:
f"Processed PDF/A saved: {os.path.basename(processed_pdfa_path)}", f"Processed PDF/A saved: {os.path.basename(processed_pdfa_path)}",
file_id=file_id, file_id=file_id,
) )
# Timestamp the processed PDF/A
if timestamp_enabled:
tsr = _timestamp_file(processed_pdfa_path, timestamp_url)
if tsr:
log_task_progress(
task_id,
"timestamp_processed_pdfa",
"success",
f"Timestamped: {os.path.basename(tsr)}",
file_id=file_id,
)
else:
log_task_progress(
task_id,
"timestamp_processed_pdfa",
"failure",
"Failed to timestamp processed PDF/A",
file_id=file_id,
)
else: else:
log_task_progress( log_task_progress(
task_id, task_id,
@@ -229,24 +383,56 @@ def convert_to_pdfa(self, file_id: int) -> dict:
db.commit() db.commit()
logger.info(f"[{task_id}] Updated database with PDF/A paths") logger.info(f"[{task_id}] Updated database with PDF/A paths")
# --- Optionally upload processed PDF/A to storage providers --- # --- Upload PDF/A variants to storage providers ---
if settings.pdfa_upload_to_providers and "processed_pdfa_path" in results: folder_overrides = _compute_pdfa_folder_overrides()
if settings.pdfa_upload_original and "original_pdfa_path" in results:
from app.tasks.send_to_all import send_to_all_destinations
logger.info(f"[{task_id}] Uploading original PDF/A to storage providers")
log_task_progress(
task_id,
"upload_original_pdfa",
"in_progress",
"Uploading original PDF/A to storage providers",
file_id=file_id,
)
send_to_all_destinations.delay(
results["original_pdfa_path"],
True,
file_id,
folder_overrides=folder_overrides if folder_overrides else None,
)
log_task_progress(
task_id,
"upload_original_pdfa",
"success",
"Original PDF/A queued for upload",
file_id=file_id,
)
if settings.pdfa_upload_processed and "processed_pdfa_path" in results:
from app.tasks.send_to_all import send_to_all_destinations from app.tasks.send_to_all import send_to_all_destinations
logger.info(f"[{task_id}] Uploading processed PDF/A to storage providers") logger.info(f"[{task_id}] Uploading processed PDF/A to storage providers")
log_task_progress( log_task_progress(
task_id, task_id,
"upload_pdfa_to_providers", "upload_processed_pdfa",
"in_progress", "in_progress",
"Uploading PDF/A variant to storage providers", "Uploading processed PDF/A to storage providers",
file_id=file_id, file_id=file_id,
) )
send_to_all_destinations.delay(results["processed_pdfa_path"], True, file_id) send_to_all_destinations.delay(
results["processed_pdfa_path"],
True,
file_id,
folder_overrides=folder_overrides if folder_overrides else None,
)
log_task_progress( log_task_progress(
task_id, task_id,
"upload_pdfa_to_providers", "upload_processed_pdfa",
"success", "success",
"PDF/A variant queued for upload", "Processed PDF/A queued for upload",
file_id=file_id, file_id=file_id,
) )
+9 -2
View File
@@ -106,7 +106,7 @@ def get_configured_services_from_validator():
@celery.task(base=BaseTaskWithRetry, bind=True) @celery.task(base=BaseTaskWithRetry, bind=True)
def send_to_all_destinations(self, file_path: str, use_validator=True, file_id: int = None): def send_to_all_destinations(self, file_path: str, use_validator=True, file_id: int = None, folder_overrides=None):
""" """
Distribute a file to all configured storage destinations. Distribute a file to all configured storage destinations.
@@ -115,6 +115,10 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id:
use_validator: Whether to use the config validator to determine enabled services use_validator: Whether to use the config validator to determine enabled services
(if False, falls back to individual checks) (if False, falls back to individual checks)
file_id: Optional file ID to associate with logs file_id: Optional file ID to associate with logs
folder_overrides: Optional dict mapping provider names to folder override strings.
When set, the override is passed to the upload task which uses it
instead of the provider's default folder. Example:
{"dropbox": "/Documents/pdfa", "s3": "docs/pdfa/"}
""" """
task_id = self.request.id task_id = self.request.id
@@ -236,7 +240,10 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id:
task_id, f"queue_{service_name}", "in_progress", f"Queueing upload to {service_name}", file_id=file_id task_id, f"queue_{service_name}", "in_progress", f"Queueing upload to {service_name}", file_id=file_id
) )
try: try:
task = service["upload_func"].delay(file_path, file_id=file_id) kwargs = {"file_id": file_id}
if folder_overrides and service_name in folder_overrides:
kwargs["folder_override"] = folder_overrides[service_name]
task = service["upload_func"].delay(file_path, **kwargs)
results[f"{service_name}_task_id"] = task.id results[f"{service_name}_task_id"] = task.id
queued_count += 1 queued_count += 1
log_task_progress( log_task_progress(
+2 -2
View File
@@ -103,7 +103,7 @@ def get_dropbox_client():
@celery.task(base=UploadTaskWithRetry, bind=True) @celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_dropbox(self, file_path: str, file_id: int = None): def upload_to_dropbox(self, file_path: str, file_id: int = None, folder_override: str = None):
""" """
Upload a file to Dropbox. Upload a file to Dropbox.
@@ -147,7 +147,7 @@ def upload_to_dropbox(self, file_path: str, file_id: int = None):
dbx = get_dropbox_client() dbx = get_dropbox_client()
# Calculate remote path based on local file structure # Calculate remote path based on local file structure
remote_base = settings.dropbox_folder or "" remote_base = folder_override if folder_override is not None else (settings.dropbox_folder or "")
remote_path = extract_remote_path(file_path, settings.workdir, remote_base) remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
# Function to check if file exists in Dropbox # Function to check if file exists in Dropbox
+5 -4
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
@celery.task(base=UploadTaskWithRetry, bind=True) @celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_ftp(self, file_path: str, file_id: int = None): def upload_to_ftp(self, file_path: str, file_id: int = None, folder_override: str = None):
""" """
Uploads a file to an FTP server in the configured folder. Uploads a file to an FTP server in the configured folder.
@@ -97,10 +97,11 @@ def upload_to_ftp(self, file_path: str, file_id: int = None):
ftp.login(user=settings.ftp_username, passwd=settings.ftp_password) ftp.login(user=settings.ftp_username, passwd=settings.ftp_password)
# Change to target directory if specified # Change to target directory if specified
if settings.ftp_folder: ftp_folder_setting = folder_override if folder_override is not None else settings.ftp_folder
if ftp_folder_setting:
try: try:
# Try to navigate to the directory, create if it doesn't exist # Try to navigate to the directory, create if it doesn't exist
ftp_folder = settings.ftp_folder ftp_folder = ftp_folder_setting
# Remove leading slash if present # Remove leading slash if present
if ftp_folder.startswith("/"): if ftp_folder.startswith("/"):
ftp_folder = ftp_folder[1:] ftp_folder = ftp_folder[1:]
@@ -138,7 +139,7 @@ def upload_to_ftp(self, file_path: str, file_id: int = None):
"status": "Completed", "status": "Completed",
"file": file_path, "file": file_path,
"ftp_host": settings.ftp_host, "ftp_host": settings.ftp_host,
"ftp_path": f"{settings.ftp_folder}/{filename}" if settings.ftp_folder else filename, "ftp_path": f"{ftp_folder_setting}/{filename}" if ftp_folder_setting else filename,
"used_tls": used_tls, "used_tls": used_tls,
} }
+6 -3
View File
@@ -153,7 +153,9 @@ def truncate_property_value(key, value, max_bytes=100):
@celery.task(base=UploadTaskWithRetry, bind=True) @celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_google_drive(self, file_path: str, include_metadata=True, file_id: int = None): def upload_to_google_drive(
self, file_path: str, include_metadata=True, file_id: int = None, folder_override: str = None
):
""" """
Uploads a file to Google Drive in the configured folder with optional metadata. Uploads a file to Google Drive in the configured folder with optional metadata.
@@ -201,8 +203,9 @@ def upload_to_google_drive(self, file_path: str, include_metadata=True, file_id:
} }
# If folder ID is specified, set parent folder # If folder ID is specified, set parent folder
if settings.google_drive_folder_id: gdrive_folder_id = folder_override if folder_override is not None else settings.google_drive_folder_id
file_metadata["parents"] = [settings.google_drive_folder_id] if gdrive_folder_id:
file_metadata["parents"] = [gdrive_folder_id]
# Add custom properties if metadata exists # Add custom properties if metadata exists
if metadata: if metadata:
+4 -2
View File
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
@celery.task(base=UploadTaskWithRetry, bind=True) @celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_nextcloud(self, file_path: str, file_id: int = None): def upload_to_nextcloud(self, file_path: str, file_id: int = None, folder_override: str = None):
""" """
Upload a file to Nextcloud WebDAV. Upload a file to Nextcloud WebDAV.
@@ -60,7 +60,9 @@ def upload_to_nextcloud(self, file_path: str, file_id: int = None):
webdav_url += "/" webdav_url += "/"
# Calculate remote path based on local file structure # Calculate remote path based on local file structure
remote_base = getattr(settings, "nextcloud_folder", "") or "" remote_base = (
folder_override if folder_override is not None else (getattr(settings, "nextcloud_folder", "") or "")
)
remote_path = extract_remote_path(file_path, settings.workdir, remote_base) remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
full_url = f"{webdav_url}/{remote_path}" full_url = f"{webdav_url}/{remote_path}"
+6 -4
View File
@@ -210,7 +210,7 @@ def upload_large_file(file_path, upload_url):
@celery.task(base=UploadTaskWithRetry, bind=True) @celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_onedrive(self, file_path: str, file_id: int = None): def upload_to_onedrive(self, file_path: str, file_id: int = None, folder_override: str = None):
""" """
Uploads a file to OneDrive in the configured folder. Uploads a file to OneDrive in the configured folder.
@@ -248,15 +248,17 @@ def upload_to_onedrive(self, file_path: str, file_id: int = None):
# Get access token # Get access token
access_token = get_onedrive_token() access_token = get_onedrive_token()
onedrive_folder = folder_override if folder_override is not None else settings.onedrive_folder_path
# Create upload session # Create upload session
upload_url = create_upload_session(filename, settings.onedrive_folder_path, access_token) upload_url = create_upload_session(filename, onedrive_folder, access_token)
# Upload the file # Upload the file
result = upload_large_file(file_path, upload_url) result = upload_large_file(file_path, upload_url)
# Log success # Log success
web_url = result.get("webUrl", "Not available") web_url = result.get("webUrl", "Not available")
logger.info(f"[{task_id}] Successfully uploaded {filename} to OneDrive at path {settings.onedrive_folder_path}") logger.info(f"[{task_id}] Successfully uploaded {filename} to OneDrive at path {onedrive_folder}")
logger.info(f"[{task_id}] File accessible at: {web_url}") logger.info(f"[{task_id}] File accessible at: {web_url}")
log_task_progress( log_task_progress(
task_id, "upload_to_onedrive", "success", f"Uploaded to OneDrive: {filename}", file_id=file_id task_id, "upload_to_onedrive", "success", f"Uploaded to OneDrive: {filename}", file_id=file_id
@@ -265,7 +267,7 @@ def upload_to_onedrive(self, file_path: str, file_id: int = None):
return { return {
"status": "Completed", "status": "Completed",
"file_path": file_path, "file_path": file_path,
"onedrive_path": f"{settings.onedrive_folder_path}/{filename}", "onedrive_path": f"{onedrive_folder}/{filename}",
"web_url": web_url, "web_url": web_url,
} }
+4 -3
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
@celery.task(base=UploadTaskWithRetry, bind=True) @celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_s3(self, file_path: str, file_id: int = None): def upload_to_s3(self, file_path: str, file_id: int = None, folder_override: str = None):
""" """
Uploads a file to Amazon S3 in the configured bucket and folder. Uploads a file to Amazon S3 in the configured bucket and folder.
@@ -61,9 +61,10 @@ def upload_to_s3(self, file_path: str, file_id: int = None):
) )
# Construct the S3 key (path within the bucket) # Construct the S3 key (path within the bucket)
if settings.s3_folder_prefix: s3_folder = folder_override if folder_override is not None else settings.s3_folder_prefix
if s3_folder:
# Ensure folder prefix ends with a slash # Ensure folder prefix ends with a slash
folder_prefix = settings.s3_folder_prefix folder_prefix = s3_folder
if not folder_prefix.endswith("/"): if not folder_prefix.endswith("/"):
folder_prefix += "/" folder_prefix += "/"
s3_key = f"{folder_prefix}{filename}" s3_key = f"{folder_prefix}{filename}"
+2 -2
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
@celery.task(base=UploadTaskWithRetry, bind=True) @celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_sftp(self, file_path: str, file_id: int = None): def upload_to_sftp(self, file_path: str, file_id: int = None, folder_override: str = None):
""" """
Upload a file to an SFTP server. Upload a file to an SFTP server.
@@ -95,7 +95,7 @@ def upload_to_sftp(self, file_path: str, file_id: int = None):
sftp = ssh.open_sftp() sftp = ssh.open_sftp()
# Calculate remote path based on local file structure # Calculate remote path based on local file structure
remote_base = settings.sftp_folder or "" remote_base = folder_override if folder_override is not None else (settings.sftp_folder or "")
remote_path = extract_remote_path(file_path, settings.workdir, remote_base) remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
# Ensure the remote path starts with a slash if the base folder does # Ensure the remote path starts with a slash if the base folder does
+2 -2
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
@celery.task(base=UploadTaskWithRetry, bind=True) @celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_webdav(self, file_path: str, file_id: int = None): def upload_to_webdav(self, file_path: str, file_id: int = None, folder_override: str = None):
""" """
Uploads a file to a WebDAV server in the configured folder. Uploads a file to a WebDAV server in the configured folder.
@@ -50,7 +50,7 @@ def upload_to_webdav(self, file_path: str, file_id: int = None):
raise ValueError(error_msg) raise ValueError(error_msg)
# Construct the full upload URL # Construct the full upload URL
webdav_folder = settings.webdav_folder or "" webdav_folder = folder_override if folder_override is not None else (settings.webdav_folder or "")
# Ensure folder doesn't have leading slash if we're joining it to the base URL # Ensure folder doesn't have leading slash if we're joining it to the base URL
if webdav_folder and webdav_folder.startswith("/"): if webdav_folder and webdav_folder.startswith("/"):
webdav_folder = webdav_folder[1:] webdav_folder = webdav_folder[1:]
+50 -4
View File
@@ -894,10 +894,15 @@ bundled in the Docker images.
> replacements. > replacements.
| Variable | Description | Default | | Variable | Description | Default |
|-----------------------------|------------------------------------------------------------------------------------------------------|---------| |-------------------------------|-------------------------------------------------------------------------------------------------------------------|----------------------------|
| `ENABLE_PDFA_CONVERSION` | Enable PDF/A archival variant generation for both original and processed files. | `false` | | `ENABLE_PDFA_CONVERSION` | Enable PDF/A archival variant generation for both original and processed files. | `false` |
| `PDFA_FORMAT` | PDF/A format variant: `1` (PDF/A-1b), `2` (PDF/A-2b), `3` (PDF/A-3b). | `2` | | `PDFA_FORMAT` | PDF/A format variant: `1` (PDF/A-1b), `2` (PDF/A-2b), `3` (PDF/A-3b). | `2` |
| `PDFA_UPLOAD_TO_PROVIDERS` | Also upload the processed PDF/A variant to all configured storage providers (with `-PDFA` suffix). | `false` | | `PDFA_UPLOAD_ORIGINAL` | Upload the original-file PDF/A variant to all configured storage providers. | `false` |
| `PDFA_UPLOAD_PROCESSED` | Upload the processed-file PDF/A variant to all configured storage providers. | `false` |
| `PDFA_UPLOAD_FOLDER` | Subfolder name appended to each provider's folder for PDF/A uploads. | `pdfa` |
| `GOOGLE_DRIVE_PDFA_FOLDER_ID`| Google Drive folder ID for PDF/A uploads (uses folder IDs, not paths). Empty = use default folder. | *(empty)* |
| `PDFA_TIMESTAMP_ENABLED` | Enable RFC 3161 timestamping of PDF/A files (creates `.tsr` proof-of-existence files). | `false` |
| `PDFA_TIMESTAMP_URL` | URL of the RFC 3161 Timestamp Authority. | `https://freetsa.org/tsr` |
### Storage Layout ### Storage Layout
@@ -909,10 +914,43 @@ workdir/
├── processed/ # Processed file with embedded metadata ├── processed/ # Processed file with embedded metadata
├── pdfa/ ├── pdfa/
│ ├── original/ # PDF/A copy of the ingested file │ ├── original/ # PDF/A copy of the ingested file
│ │ └── *.pdf.tsr # RFC 3161 timestamps (when timestamping enabled)
│ └── processed/ # PDF/A copy of the processed file (with -PDFA suffix) │ └── processed/ # PDF/A copy of the processed file (with -PDFA suffix)
│ └── *.pdf.tsr # RFC 3161 timestamps (when timestamping enabled)
└── tmp/ # Temporary processing area └── tmp/ # Temporary processing area
``` ```
### Per-Provider Folder Overrides
When uploading PDF/A files to storage providers, DocuElevate appends the
`PDFA_UPLOAD_FOLDER` value as a subfolder to each provider's configured folder.
For example:
| Provider | Regular Folder | PDF/A Upload Folder |
|--------------|-----------------------------|----------------------------------|
| Dropbox | `/Documents` | `/Documents/pdfa` |
| S3 | `docs/uploads/` | `docs/uploads/pdfa/` |
| Nextcloud | `/Files` | `/Files/pdfa` |
| OneDrive | `Documents/Uploads` | `Documents/Uploads/pdfa` |
| Google Drive | *(folder ID)* | `GOOGLE_DRIVE_PDFA_FOLDER_ID` |
Set `PDFA_UPLOAD_FOLDER` to an empty string to upload PDF/A files into the
same folder as regular uploads.
### RFC 3161 Timestamping
When `PDFA_TIMESTAMP_ENABLED=true`, each PDF/A file is timestamped using
the configured TSA (default: [FreeTSA](https://freetsa.org)). This creates
a `.tsr` file alongside each PDF/A file, providing cryptographic proof that
the document existed at a specific point in time.
Requires `openssl` on the PATH (included in Docker images).
**Other TSA options:**
- **GlobalSign** enterprise, eIDAS qualified
- **DigiStamp** high assurance, legal
- **IdenTrust** legal, free with certificate purchase
### Configuration Example ### Configuration Example
```bash ```bash
@@ -922,8 +960,16 @@ ENABLE_PDFA_CONVERSION=true
# Use PDF/A-2b format (default, recommended for most use cases) # Use PDF/A-2b format (default, recommended for most use cases)
PDFA_FORMAT=2 PDFA_FORMAT=2
# Also upload PDF/A copies to configured storage providers # Upload both original and processed PDF/A to providers
PDFA_UPLOAD_TO_PROVIDERS=true PDFA_UPLOAD_ORIGINAL=true
PDFA_UPLOAD_PROCESSED=true
# PDF/A files go into a 'pdfa' subfolder on each provider
PDFA_UPLOAD_FOLDER=pdfa
# Enable RFC 3161 timestamping via FreeTSA
PDFA_TIMESTAMP_ENABLED=true
PDFA_TIMESTAMP_URL=https://freetsa.org/tsr
``` ```
## Performance & Caching ## Performance & Caching
+242 -43
View File
@@ -8,7 +8,9 @@ import pytest
from app.tasks.convert_to_pdfa import ( from app.tasks.convert_to_pdfa import (
PDFA_ORIGINAL_SUBDIR, PDFA_ORIGINAL_SUBDIR,
PDFA_PROCESSED_SUBDIR, PDFA_PROCESSED_SUBDIR,
_compute_pdfa_folder_overrides,
_convert_pdf_to_pdfa, _convert_pdf_to_pdfa,
_timestamp_file,
convert_to_pdfa, convert_to_pdfa,
) )
@@ -29,7 +31,6 @@ class TestConvertPdfToPdfa:
mock_which.assert_called_once_with("ocrmypdf") mock_which.assert_called_once_with("ocrmypdf")
mock_run.assert_called_once() mock_run.assert_called_once()
# Verify the command arguments
cmd = mock_run.call_args[0][0] cmd = mock_run.call_args[0][0]
assert cmd[0] == "/usr/bin/ocrmypdf" assert cmd[0] == "/usr/bin/ocrmypdf"
assert "--skip-text" in cmd assert "--skip-text" in cmd
@@ -44,7 +45,6 @@ class TestConvertPdfToPdfa:
def test_ocrmypdf_not_found(self, mock_which): def test_ocrmypdf_not_found(self, mock_which):
"""Test returns False when ocrmypdf binary is not on PATH.""" """Test returns False when ocrmypdf binary is not on PATH."""
result = _convert_pdf_to_pdfa("/input.pdf", "/output.pdf") result = _convert_pdf_to_pdfa("/input.pdf", "/output.pdf")
assert result is False assert result is False
@patch("app.tasks.convert_to_pdfa.subprocess.run") @patch("app.tasks.convert_to_pdfa.subprocess.run")
@@ -52,9 +52,7 @@ class TestConvertPdfToPdfa:
def test_conversion_failure(self, mock_which, mock_run): def test_conversion_failure(self, mock_which, mock_run):
"""Test returns False when ocrmypdf exits with non-zero code.""" """Test returns False when ocrmypdf exits with non-zero code."""
mock_run.return_value = MagicMock(returncode=1, stderr="Some error occurred") mock_run.return_value = MagicMock(returncode=1, stderr="Some error occurred")
result = _convert_pdf_to_pdfa("/input.pdf", "/output.pdf") result = _convert_pdf_to_pdfa("/input.pdf", "/output.pdf")
assert result is False assert result is False
@patch("app.tasks.convert_to_pdfa.subprocess.run") @patch("app.tasks.convert_to_pdfa.subprocess.run")
@@ -62,9 +60,7 @@ class TestConvertPdfToPdfa:
def test_conversion_timeout(self, mock_which, mock_run): def test_conversion_timeout(self, mock_which, mock_run):
"""Test returns False when ocrmypdf times out.""" """Test returns False when ocrmypdf times out."""
mock_run.side_effect = subprocess.TimeoutExpired(cmd="ocrmypdf", timeout=600) mock_run.side_effect = subprocess.TimeoutExpired(cmd="ocrmypdf", timeout=600)
result = _convert_pdf_to_pdfa("/input.pdf", "/output.pdf") result = _convert_pdf_to_pdfa("/input.pdf", "/output.pdf")
assert result is False assert result is False
@patch("app.tasks.convert_to_pdfa.subprocess.run") @patch("app.tasks.convert_to_pdfa.subprocess.run")
@@ -72,7 +68,6 @@ class TestConvertPdfToPdfa:
def test_pdfa_format_variants(self, mock_which, mock_run): def test_pdfa_format_variants(self, mock_which, mock_run):
"""Test different PDF/A format variants are passed correctly.""" """Test different PDF/A format variants are passed correctly."""
mock_run.return_value = MagicMock(returncode=0, stderr="") mock_run.return_value = MagicMock(returncode=0, stderr="")
for fmt in ("1", "2", "3"): for fmt in ("1", "2", "3"):
_convert_pdf_to_pdfa("/input.pdf", "/output.pdf", fmt) _convert_pdf_to_pdfa("/input.pdf", "/output.pdf", fmt)
cmd = mock_run.call_args[0][0] cmd = mock_run.call_args[0][0]
@@ -83,16 +78,153 @@ class TestConvertPdfToPdfa:
def test_conversion_failure_empty_stderr(self, mock_which, mock_run): def test_conversion_failure_empty_stderr(self, mock_which, mock_run):
"""Test handles empty stderr on failure.""" """Test handles empty stderr on failure."""
mock_run.return_value = MagicMock(returncode=2, stderr="") mock_run.return_value = MagicMock(returncode=2, stderr="")
result = _convert_pdf_to_pdfa("/input.pdf", "/output.pdf") result = _convert_pdf_to_pdfa("/input.pdf", "/output.pdf")
assert result is False assert result is False
@pytest.mark.unit
class TestTimestampFile:
"""Tests for the _timestamp_file helper function."""
@patch("app.tasks.convert_to_pdfa.os.path.exists", return_value=False)
@patch("app.tasks.convert_to_pdfa.http_requests.post")
@patch("app.tasks.convert_to_pdfa.subprocess.run")
@patch("app.tasks.convert_to_pdfa.shutil.which", return_value="/usr/bin/openssl")
def test_successful_timestamp(self, mock_which, mock_run, mock_post, mock_exists):
"""Test successful RFC 3161 timestamping."""
mock_run.return_value = MagicMock(returncode=0)
mock_post.return_value = MagicMock(status_code=200, content=b"tsr-data")
with patch("builtins.open", MagicMock()):
result = _timestamp_file("/test.pdf", "https://freetsa.org/tsr")
assert result == "/test.pdf.tsr"
mock_which.assert_called_once_with("openssl")
mock_run.assert_called_once()
mock_post.assert_called_once()
@patch("app.tasks.convert_to_pdfa.shutil.which", return_value=None)
def test_openssl_not_found(self, mock_which):
"""Test returns None when openssl is not on PATH."""
result = _timestamp_file("/test.pdf", "https://freetsa.org/tsr")
assert result is None
@patch("app.tasks.convert_to_pdfa.os.path.exists", return_value=False)
@patch("app.tasks.convert_to_pdfa.subprocess.run")
@patch("app.tasks.convert_to_pdfa.shutil.which", return_value="/usr/bin/openssl")
def test_openssl_ts_query_fails(self, mock_which, mock_run, mock_exists):
"""Test returns None when openssl ts -query fails."""
mock_run.return_value = MagicMock(returncode=1, stderr="error")
result = _timestamp_file("/test.pdf", "https://freetsa.org/tsr")
assert result is None
@patch("app.tasks.convert_to_pdfa.os.path.exists", return_value=False)
@patch("app.tasks.convert_to_pdfa.http_requests.post")
@patch("app.tasks.convert_to_pdfa.subprocess.run")
@patch("app.tasks.convert_to_pdfa.shutil.which", return_value="/usr/bin/openssl")
def test_tsa_returns_error(self, mock_which, mock_run, mock_post, mock_exists):
"""Test returns None when TSA returns non-200 status."""
mock_run.return_value = MagicMock(returncode=0)
mock_post.return_value = MagicMock(status_code=500)
with patch("builtins.open", MagicMock()):
result = _timestamp_file("/test.pdf", "https://freetsa.org/tsr")
assert result is None
@patch("app.tasks.convert_to_pdfa.os.path.exists", return_value=False)
@patch("app.tasks.convert_to_pdfa.http_requests.post")
@patch("app.tasks.convert_to_pdfa.subprocess.run")
@patch("app.tasks.convert_to_pdfa.shutil.which", return_value="/usr/bin/openssl")
def test_tsa_network_error(self, mock_which, mock_run, mock_post, mock_exists):
"""Test returns None on network error contacting TSA."""
import requests
mock_run.return_value = MagicMock(returncode=0)
mock_post.side_effect = requests.ConnectionError("Network error")
with patch("builtins.open", MagicMock()):
result = _timestamp_file("/test.pdf", "https://freetsa.org/tsr")
assert result is None
@patch("app.tasks.convert_to_pdfa.os.path.exists", return_value=False)
@patch("app.tasks.convert_to_pdfa.subprocess.run")
@patch("app.tasks.convert_to_pdfa.shutil.which", return_value="/usr/bin/openssl")
def test_openssl_timeout(self, mock_which, mock_run, mock_exists):
"""Test returns None when openssl times out."""
mock_run.side_effect = subprocess.TimeoutExpired(cmd="openssl", timeout=30)
result = _timestamp_file("/test.pdf", "https://freetsa.org/tsr")
assert result is None
@pytest.mark.unit
class TestComputePdfaFolderOverrides:
"""Tests for _compute_pdfa_folder_overrides helper."""
@patch("app.tasks.convert_to_pdfa.settings")
def test_appends_subfolder_to_providers(self, mock_settings):
"""Test subfolder is appended to each provider's folder."""
mock_settings.pdfa_upload_folder = "pdfa"
mock_settings.dropbox_folder = "/Documents"
mock_settings.nextcloud_folder = "/Files"
mock_settings.webdav_folder = "/webdav"
mock_settings.ftp_folder = "/uploads"
mock_settings.sftp_folder = "/remote"
mock_settings.onedrive_folder_path = "Documents/Uploads"
mock_settings.s3_folder_prefix = "docs/"
mock_settings.google_drive_pdfa_folder_id = "gdrive-pdfa-folder-id"
overrides = _compute_pdfa_folder_overrides()
assert overrides["dropbox"] == "/Documents/pdfa"
assert overrides["nextcloud"] == "/Files/pdfa"
assert overrides["webdav"] == "/webdav/pdfa"
assert overrides["ftp"] == "/uploads/pdfa"
assert overrides["sftp"] == "/remote/pdfa"
assert overrides["onedrive"] == "Documents/Uploads/pdfa"
assert overrides["s3"] == "docs/pdfa/"
assert overrides["google_drive"] == "gdrive-pdfa-folder-id"
@patch("app.tasks.convert_to_pdfa.settings")
def test_empty_subfolder_returns_empty(self, mock_settings):
"""Test empty subfolder returns empty overrides dict."""
mock_settings.pdfa_upload_folder = ""
overrides = _compute_pdfa_folder_overrides()
assert overrides == {}
@patch("app.tasks.convert_to_pdfa.settings")
def test_no_gdrive_pdfa_id_excluded(self, mock_settings):
"""Test Google Drive excluded when no dedicated folder ID set."""
mock_settings.pdfa_upload_folder = "archive"
mock_settings.dropbox_folder = "/docs"
mock_settings.nextcloud_folder = ""
mock_settings.webdav_folder = ""
mock_settings.ftp_folder = ""
mock_settings.sftp_folder = ""
mock_settings.onedrive_folder_path = ""
mock_settings.s3_folder_prefix = ""
mock_settings.google_drive_pdfa_folder_id = ""
overrides = _compute_pdfa_folder_overrides()
assert overrides["dropbox"] == "/docs/archive"
assert "google_drive" not in overrides
@pytest.mark.unit @pytest.mark.unit
class TestConvertToPdfaTask: class TestConvertToPdfaTask:
"""Tests for the convert_to_pdfa Celery task.""" """Tests for the convert_to_pdfa Celery task."""
def _mock_settings(self, mock_settings):
"""Set standard mock settings for PDF/A tests."""
mock_settings.workdir = "/workdir"
mock_settings.pdfa_format = "2"
mock_settings.pdfa_upload_original = False
mock_settings.pdfa_upload_processed = False
mock_settings.pdfa_timestamp_enabled = False
mock_settings.pdfa_timestamp_url = "https://freetsa.org/tsr"
@patch("app.tasks.convert_to_pdfa.settings") @patch("app.tasks.convert_to_pdfa.settings")
@patch("app.tasks.convert_to_pdfa._convert_pdf_to_pdfa", return_value=True) @patch("app.tasks.convert_to_pdfa._convert_pdf_to_pdfa", return_value=True)
@patch("app.tasks.convert_to_pdfa.get_unique_filepath_with_counter") @patch("app.tasks.convert_to_pdfa.get_unique_filepath_with_counter")
@@ -111,17 +243,12 @@ class TestConvertToPdfaTask:
mock_settings, mock_settings,
): ):
"""Test successful PDF/A conversion of both original and processed files.""" """Test successful PDF/A conversion of both original and processed files."""
mock_settings.workdir = "/workdir" self._mock_settings(mock_settings)
mock_settings.pdfa_format = "2"
mock_settings.pdfa_upload_to_providers = False
# Mock unique path to return predictable paths
mock_unique_path.side_effect = [ mock_unique_path.side_effect = [
"/workdir/pdfa/original/test.pdf", "/workdir/pdfa/original/test.pdf",
"/workdir/pdfa/processed/test-PDFA.pdf", "/workdir/pdfa/processed/test-PDFA.pdf",
] ]
# Mock database session
mock_db = MagicMock() mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db mock_session_local.return_value.__enter__.return_value = mock_db
mock_record = MagicMock() mock_record = MagicMock()
@@ -171,10 +298,7 @@ class TestConvertToPdfaTask:
mock_settings, mock_settings,
): ):
"""Test handles failure when both conversions fail.""" """Test handles failure when both conversions fail."""
mock_settings.workdir = "/workdir" self._mock_settings(mock_settings)
mock_settings.pdfa_format = "2"
mock_settings.pdfa_upload_to_providers = False
mock_unique_path.side_effect = [ mock_unique_path.side_effect = [
"/workdir/pdfa/original/test.pdf", "/workdir/pdfa/original/test.pdf",
"/workdir/pdfa/processed/test-PDFA.pdf", "/workdir/pdfa/processed/test-PDFA.pdf",
@@ -191,18 +315,13 @@ class TestConvertToPdfaTask:
result = convert_to_pdfa.__wrapped__(file_id=1) result = convert_to_pdfa.__wrapped__(file_id=1)
assert result["status"] == "failure" assert result["status"] == "failure"
assert "original_pdfa_path" not in result
assert "processed_pdfa_path" not in result
@patch("app.tasks.convert_to_pdfa.settings") @patch("app.tasks.convert_to_pdfa.settings")
@patch("app.tasks.convert_to_pdfa.log_task_progress") @patch("app.tasks.convert_to_pdfa.log_task_progress")
@patch("app.tasks.convert_to_pdfa.SessionLocal") @patch("app.tasks.convert_to_pdfa.SessionLocal")
def test_skips_missing_files(self, mock_session_local, mock_log, mock_settings): def test_skips_missing_files(self, mock_session_local, mock_log, mock_settings):
"""Test skips conversion when original/processed files don't exist.""" """Test skips conversion when original/processed files don't exist."""
mock_settings.workdir = "/workdir" self._mock_settings(mock_settings)
mock_settings.pdfa_format = "2"
mock_settings.pdfa_upload_to_providers = False
mock_db = MagicMock() mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db mock_session_local.return_value.__enter__.return_value = mock_db
mock_record = MagicMock() mock_record = MagicMock()
@@ -212,9 +331,9 @@ class TestConvertToPdfaTask:
convert_to_pdfa.request.id = "test-task-id" convert_to_pdfa.request.id = "test-task-id"
result = convert_to_pdfa.__wrapped__(file_id=1) result = convert_to_pdfa.__wrapped__(file_id=1)
assert result["status"] == "failure" assert result["status"] == "failure"
@patch("app.tasks.convert_to_pdfa._compute_pdfa_folder_overrides", return_value={"dropbox": "/docs/pdfa"})
@patch("app.tasks.send_to_all.send_to_all_destinations") @patch("app.tasks.send_to_all.send_to_all_destinations")
@patch("app.tasks.convert_to_pdfa.settings") @patch("app.tasks.convert_to_pdfa.settings")
@patch("app.tasks.convert_to_pdfa._convert_pdf_to_pdfa", return_value=True) @patch("app.tasks.convert_to_pdfa._convert_pdf_to_pdfa", return_value=True)
@@ -223,7 +342,7 @@ class TestConvertToPdfaTask:
@patch("app.tasks.convert_to_pdfa.os.path.exists", return_value=True) @patch("app.tasks.convert_to_pdfa.os.path.exists", return_value=True)
@patch("app.tasks.convert_to_pdfa.log_task_progress") @patch("app.tasks.convert_to_pdfa.log_task_progress")
@patch("app.tasks.convert_to_pdfa.SessionLocal") @patch("app.tasks.convert_to_pdfa.SessionLocal")
def test_uploads_pdfa_when_enabled( def test_uploads_processed_pdfa_when_enabled(
self, self,
mock_session_local, mock_session_local,
mock_log, mock_log,
@@ -233,12 +352,11 @@ class TestConvertToPdfaTask:
mock_convert, mock_convert,
mock_settings, mock_settings,
mock_send_all, mock_send_all,
mock_overrides,
): ):
"""Test uploads processed PDF/A to providers when pdfa_upload_to_providers is True.""" """Test uploads processed PDF/A with folder overrides when enabled."""
mock_settings.workdir = "/workdir" self._mock_settings(mock_settings)
mock_settings.pdfa_format = "2" mock_settings.pdfa_upload_processed = True
mock_settings.pdfa_upload_to_providers = True
mock_unique_path.side_effect = [ mock_unique_path.side_effect = [
"/workdir/pdfa/original/test.pdf", "/workdir/pdfa/original/test.pdf",
"/workdir/pdfa/processed/test-PDFA.pdf", "/workdir/pdfa/processed/test-PDFA.pdf",
@@ -252,10 +370,58 @@ class TestConvertToPdfaTask:
mock_db.query.return_value.filter_by.return_value.first.return_value = mock_record mock_db.query.return_value.filter_by.return_value.first.return_value = mock_record
convert_to_pdfa.request.id = "test-task-id" convert_to_pdfa.request.id = "test-task-id"
result = convert_to_pdfa.__wrapped__(file_id=42) convert_to_pdfa.__wrapped__(file_id=42)
# Verify send_to_all_destinations was called for the PDF/A file mock_send_all.delay.assert_called_once_with(
mock_send_all.delay.assert_called_once_with("/workdir/pdfa/processed/test-PDFA.pdf", True, 42) "/workdir/pdfa/processed/test-PDFA.pdf",
True,
42,
folder_overrides={"dropbox": "/docs/pdfa"},
)
@patch("app.tasks.convert_to_pdfa._compute_pdfa_folder_overrides", return_value={"s3": "docs/pdfa/"})
@patch("app.tasks.send_to_all.send_to_all_destinations")
@patch("app.tasks.convert_to_pdfa.settings")
@patch("app.tasks.convert_to_pdfa._convert_pdf_to_pdfa", return_value=True)
@patch("app.tasks.convert_to_pdfa.get_unique_filepath_with_counter")
@patch("app.tasks.convert_to_pdfa.os.makedirs")
@patch("app.tasks.convert_to_pdfa.os.path.exists", return_value=True)
@patch("app.tasks.convert_to_pdfa.log_task_progress")
@patch("app.tasks.convert_to_pdfa.SessionLocal")
def test_uploads_original_pdfa_when_enabled(
self,
mock_session_local,
mock_log,
mock_exists,
mock_makedirs,
mock_unique_path,
mock_convert,
mock_settings,
mock_send_all,
mock_overrides,
):
"""Test uploads original PDF/A to providers when pdfa_upload_original is True."""
self._mock_settings(mock_settings)
mock_settings.pdfa_upload_original = True
mock_unique_path.side_effect = [
"/workdir/pdfa/original/test.pdf",
"/workdir/pdfa/processed/test-PDFA.pdf",
]
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_record = MagicMock()
mock_record.original_file_path = "/workdir/original/test.pdf"
mock_record.processed_file_path = "/workdir/processed/test.pdf"
mock_db.query.return_value.filter_by.return_value.first.return_value = mock_record
convert_to_pdfa.request.id = "test-task-id"
convert_to_pdfa.__wrapped__(file_id=42)
# Only original should be uploaded
mock_send_all.delay.assert_called_once()
call_args = mock_send_all.delay.call_args
assert call_args[0][0] == "/workdir/pdfa/original/test.pdf"
@patch("app.tasks.convert_to_pdfa.settings") @patch("app.tasks.convert_to_pdfa.settings")
@patch("app.tasks.convert_to_pdfa._convert_pdf_to_pdfa", return_value=True) @patch("app.tasks.convert_to_pdfa._convert_pdf_to_pdfa", return_value=True)
@@ -274,11 +440,46 @@ class TestConvertToPdfaTask:
mock_convert, mock_convert,
mock_settings, mock_settings,
): ):
"""Test does not upload PDF/A when pdfa_upload_to_providers is False.""" """Test does not upload PDF/A when both upload flags are False."""
mock_settings.workdir = "/workdir" self._mock_settings(mock_settings)
mock_settings.pdfa_format = "2" mock_unique_path.side_effect = [
mock_settings.pdfa_upload_to_providers = False "/workdir/pdfa/original/test.pdf",
"/workdir/pdfa/processed/test-PDFA.pdf",
]
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_record = MagicMock()
mock_record.original_file_path = "/workdir/original/test.pdf"
mock_record.processed_file_path = "/workdir/processed/test.pdf"
mock_db.query.return_value.filter_by.return_value.first.return_value = mock_record
convert_to_pdfa.request.id = "test-task-id"
result = convert_to_pdfa.__wrapped__(file_id=1)
assert result["status"] == "success"
@patch("app.tasks.convert_to_pdfa._timestamp_file", return_value="/workdir/pdfa/original/test.pdf.tsr")
@patch("app.tasks.convert_to_pdfa.settings")
@patch("app.tasks.convert_to_pdfa._convert_pdf_to_pdfa", return_value=True)
@patch("app.tasks.convert_to_pdfa.get_unique_filepath_with_counter")
@patch("app.tasks.convert_to_pdfa.os.makedirs")
@patch("app.tasks.convert_to_pdfa.os.path.exists", return_value=True)
@patch("app.tasks.convert_to_pdfa.log_task_progress")
@patch("app.tasks.convert_to_pdfa.SessionLocal")
def test_timestamping_when_enabled(
self,
mock_session_local,
mock_log,
mock_exists,
mock_makedirs,
mock_unique_path,
mock_convert,
mock_settings,
mock_timestamp,
):
"""Test RFC 3161 timestamping is called when enabled."""
self._mock_settings(mock_settings)
mock_settings.pdfa_timestamp_enabled = True
mock_unique_path.side_effect = [ mock_unique_path.side_effect = [
"/workdir/pdfa/original/test.pdf", "/workdir/pdfa/original/test.pdf",
"/workdir/pdfa/processed/test-PDFA.pdf", "/workdir/pdfa/processed/test-PDFA.pdf",
@@ -295,6 +496,7 @@ class TestConvertToPdfaTask:
result = convert_to_pdfa.__wrapped__(file_id=1) result = convert_to_pdfa.__wrapped__(file_id=1)
assert result["status"] == "success" assert result["status"] == "success"
assert mock_timestamp.call_count == 2
@patch("app.tasks.convert_to_pdfa.settings") @patch("app.tasks.convert_to_pdfa.settings")
@patch("app.tasks.convert_to_pdfa._convert_pdf_to_pdfa") @patch("app.tasks.convert_to_pdfa._convert_pdf_to_pdfa")
@@ -314,11 +516,8 @@ class TestConvertToPdfaTask:
mock_settings, mock_settings,
): ):
"""Test partial success when only original conversion succeeds.""" """Test partial success when only original conversion succeeds."""
mock_settings.workdir = "/workdir" self._mock_settings(mock_settings)
mock_settings.pdfa_format = "2"
mock_settings.pdfa_upload_to_providers = False
# Only original file exists
def exists_side_effect(path): def exists_side_effect(path):
return "/original/" in path return "/original/" in path