From 86b9a7d097b72b7b2cb72a2eee39ee7d011efa3c Mon Sep 17 00:00:00 2001 From: Don Kingdon Date: Sat, 2 May 2026 05:13:53 +0000 Subject: [PATCH] v2.1.1 improve archive export cleanup controls --- PROJECT_STATE.md | 12 + README.md | 9 + VERSION | 2 +- app/main/routes.py | 222 ++++++++++++++----- app/templates/cloud/pdf_report_workshop.html | 8 + app/templates/cloud/zip_workspace.html | 15 +- 6 files changed, 215 insertions(+), 53 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 5725750..ec0d38c 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,5 +1,17 @@ # OTB Cloud Project State +## v2.1.1 - 20260502-051352 + +Patch release: Archive Workspace export cleanup improvements. + +- Added Flush Workspace button for staged Archive Workspace files. +- Fixed Download + Remove so exports are deleted after download response completes. +- Added individual Delete button for processed export files. +- Confirmed PDF Report Workshop image compression works after ImageOps import fix. + +--- + + ## v2.1.0 - 20260502-005134 Feature release: PDF Report Workshop (interactive document builder). diff --git a/README.md b/README.md index c853e56..30870c4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # OTB Cloud +## v2.1.1 - 20260502-051352 + +- Archive Workspace now supports flushing staged files. +- Processed exports can be downloaded, downloaded-and-removed, moved to LTS, or deleted individually. +- PDF Report Workshop compression/image handling confirmed working. + +--- + + ## v2.1.0 - 20260502-005134 PDF Report Workshop introduced. diff --git a/VERSION b/VERSION index 1defe53..826e142 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.1.0 +v2.1.1 diff --git a/app/main/routes.py b/app/main/routes.py index dd02af4..82c9801 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -4,16 +4,18 @@ from datetime import datetime, timezone import shutil import zipfile import tarfile +import tempfile +import os from reportlab.lib.pagesizes import letter from reportlab.platypus import SimpleDocTemplate, Image as ReportLabImage, Paragraph, Spacer, PageBreak from reportlab.lib.styles import getSampleStyleSheet -from PIL import Image as PILImage +from PIL import Image as PILImage, ImageOps from PIL import Image import re import hashlib from werkzeug.utils import secure_filename -from flask import send_file, Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify +from flask import after_this_request, send_file, Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify from app.db import get_db from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 @@ -846,6 +848,85 @@ def zip_workspace(): export_files=export_files, ) + +def _prepare_report_image_for_pdf(source_path, quality_mode="standard"): + """ + Compress a staged image into a temporary JPEG for PDF embedding. + Original uploaded/staged image is not modified. + """ + modes = { + "high": {"max_px": 2400, "quality": 82}, + "standard": {"max_px": 1600, "quality": 70}, + "compressed": {"max_px": 1200, "quality": 55}, + } + cfg = modes.get(quality_mode, modes["standard"]) + + with PILImage.open(source_path) as im: + im = ImageOps.exif_transpose(im) + + if im.mode in ("RGBA", "LA", "P"): + bg = PILImage.new("RGB", im.size, (255, 255, 255)) + if im.mode == "P": + im = im.convert("RGBA") + bg.paste(im, mask=im.split()[-1] if im.mode in ("RGBA", "LA") else None) + im = bg + else: + im = im.convert("RGB") + + max_px = cfg["max_px"] + if max(im.size) > max_px: + ratio = max_px / float(max(im.size)) + new_size = (max(1, int(im.width * ratio)), max(1, int(im.height * ratio))) + im = im.resize(new_size, PILImage.LANCZOS) + + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") + tmp.close() + im.save(tmp.name, "JPEG", quality=cfg["quality"], optimize=True, progressive=True) + + return tmp.name, im.width, im.height + + +def _pdf_scaled_dimensions(width, height, max_w=500, max_h=620): + scale = min(max_w / float(width), max_h / float(height), 1.0) + return width * scale, height * scale + + + +@bp.route("/workspace/zip/flush", methods=["POST"]) +@portal_session_required +def flush_zip_workspace(): + tenant_root = _tenant_root() + staging_dir = tenant_root / "zip_staging" + staging_dir.mkdir(parents=True, exist_ok=True) + + removed = 0 + for p in staging_dir.iterdir(): + if p.is_file(): + p.unlink(missing_ok=True) + removed += 1 + + db = get_db() + with db.cursor() as cur: + cur.execute( + """ + INSERT INTO audit_logs ( + tenant_id, user_id, actor_type, event_type, ip_address, user_agent, event_detail + ) VALUES (%s, %s, 'user', 'zip_workspace_flushed', %s, %s, %s) + """, + ( + session["otb_tenant_id"], + session["otb_user_id"], + _client_ip(), + request.headers.get("User-Agent", ""), + f"Flushed Archive Workspace; removed {removed} staged file(s)", + ), + ) + db.commit() + + flash(f"Archive Workspace flushed. Removed {removed} staged file(s).", "success") + return redirect(url_for("main.zip_workspace")) + + @bp.route("/workspace/zip/create", methods=["POST"]) @portal_session_required def create_zip_from_workspace(): @@ -916,15 +997,13 @@ def create_zip_from_workspace(): display_name = img_path.name.split("__", 1)[1] if "__" in img_path.name else img_path.name try: - with PILImage.open(img_path) as im: - w, h = im.size - scale = min(max_w / w, max_h / h, 1.0) - draw_w = w * scale - draw_h = h * scale + tmp_img, w, h = _prepare_report_image_for_pdf(img_path, "standard") + pdf_temp_files.append(tmp_img) + draw_w, draw_h = _pdf_scaled_dimensions(w, h, max_w, max_h) elements.append(Paragraph(f"{idx}. {display_name}", styles["Heading3"])) elements.append(Spacer(1, 8)) - elements.append(ReportLabImage(str(img_path), width=draw_w, height=draw_h)) + elements.append(ReportLabImage(tmp_img, width=draw_w, height=draw_h)) elements.append(Spacer(1, 18)) if idx != len(image_files): @@ -934,7 +1013,15 @@ def create_zip_from_workspace(): elements.append(Paragraph(f"Skipped image: {display_name} ({e})", styles["Normal"])) elements.append(Spacer(1, 12)) - doc.build(elements) + pdf_temp_files = [] + try: + doc.build(elements) + finally: + for tmp_path in pdf_temp_files: + try: + os.unlink(tmp_path) + except OSError: + pass else: archive_format = "zip" @@ -1712,25 +1799,21 @@ def move_export_to_lts(filename: str): @bp.route("/workspace/exports//download-remove", methods=["GET"]) @portal_session_required def download_and_remove_export(filename: str): - tenant_root = _tenant_root() - exports_dir = tenant_root / "exports" + exports_dir = _tenant_root() / "exports" file_path = exports_dir / filename - if not file_path.exists(): - flash("Archive not found.", "warning") - return redirect(url_for("main.zip_workspace")) - - response = send_file(file_path, as_attachment=True, download_name=file_path.name) + if not file_path.exists() or not file_path.is_file(): + abort(404) - @response.call_on_close - def cleanup(): + @after_this_request + def remove_file(response): try: file_path.unlink(missing_ok=True) except Exception: pass + return response - return response - + return send_file(file_path, as_attachment=True) @bp.route("/workspace/lts", methods=["GET"]) @portal_session_required @@ -2463,57 +2546,94 @@ def create_pdf_report(): exports_dir = tenant_root / "exports" exports_dir.mkdir(parents=True, exist_ok=True) - archive_name = (request.form.get("report_name") or "").strip() - if not archive_name: - archive_name = f"otb-report-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}" + report_name = (request.form.get("report_name") or "").strip() + if not report_name: + report_name = f"otb-report-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}" + + report_name = re.sub(r"[^A-Za-z0-9._-]+", "_", report_name).strip("._-") + if not report_name: + report_name = f"otb-report-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}" - archive_name = re.sub(r"[^A-Za-z0-9._-]+", "_", archive_name) - pdf_path = exports_dir / f"{archive_name}.pdf" + quality_mode = (request.form.get("quality_mode") or "standard").strip().lower() + if quality_mode not in ["high", "standard", "compressed"]: + quality_mode = "standard" + + pdf_path = exports_dir / f"{report_name}.pdf" doc = SimpleDocTemplate(str(pdf_path), pagesize=letter) styles = getSampleStyleSheet() elements = [] + pdf_temp_files = [] + + job_title = request.form.get("job_title") or "Job Report" - # Report metadata - elements.append(Paragraph(request.form.get("job_title", "Job Report"), styles["Title"])) + elements.append(Paragraph(job_title, styles["Title"])) elements.append(Spacer(1, 12)) elements.append(Paragraph(f"Customer: {request.form.get('customer', '')}", styles["Normal"])) elements.append(Paragraph(f"Address: {request.form.get('address', '')}", styles["Normal"])) elements.append(Paragraph(f"Technician: {request.form.get('technician', '')}", styles["Normal"])) elements.append(Paragraph(f"Date: {request.form.get('date', '')}", styles["Normal"])) + elements.append(Paragraph(f"PDF quality: {quality_mode}", styles["Normal"])) elements.append(Spacer(1, 24)) - staged = sorted([p for p in staging_dir.iterdir() if p.is_file()]) + image_files = [ + p for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower()) + if p.is_file() and p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"] + ] - for idx, p in enumerate(staged): - if p.suffix.lower() not in [".jpg", ".jpeg", ".png", ".webp"]: - continue + if not image_files: + flash("PDF Report Workshop needs image files. No supported images were staged.", "warning") + return redirect(url_for("main.zip_workspace")) - caption = request.form.get(f"caption_{idx}", "") - notes = request.form.get(f"notes_{idx}", "") + try: + for idx, p in enumerate(image_files): + display_name = p.name.split("__", 1)[1] if "__" in p.name else p.name + caption = request.form.get(f"caption_{idx}", "").strip() + notes = request.form.get(f"notes_{idx}", "").strip() - elements.append(Paragraph(p.name, styles["Heading3"])) - elements.append(Spacer(1, 6)) + elements.append(Paragraph(display_name, styles["Heading3"])) + elements.append(Spacer(1, 6)) - try: - with PILImage.open(p) as im: - w, h = im.size - scale = min(500 / w, 600 / h, 1.0) - elements.append(ReportLabImage(str(p), width=w*scale, height=h*scale)) - except: - elements.append(Paragraph("Image failed to load", styles["Normal"])) + try: + tmp_img, w, h = _prepare_report_image_for_pdf(p, quality_mode) + pdf_temp_files.append(tmp_img) + draw_w, draw_h = _pdf_scaled_dimensions(w, h, 500, 600) + elements.append(ReportLabImage(tmp_img, width=draw_w, height=draw_h)) + except Exception as e: + elements.append(Paragraph(f"Image failed to load: {e}", styles["Normal"])) + + elements.append(Spacer(1, 8)) - elements.append(Spacer(1, 8)) + if caption: + elements.append(Paragraph(f"Caption: {caption}", styles["Normal"])) + if notes: + elements.append(Paragraph(f"Notes: {notes}", styles["Normal"])) - if caption: - elements.append(Paragraph(f"Caption: {caption}", styles["Normal"])) - if notes: - elements.append(Paragraph(f"Notes: {notes}", styles["Normal"])) + if idx != len(image_files) - 1: + elements.append(PageBreak()) + + doc.build(elements) - elements.append(Spacer(1, 18)) - elements.append(PageBreak()) + finally: + for tmp_path in pdf_temp_files: + try: + os.unlink(tmp_path) + except OSError: + pass + + flash(f"PDF Report created: {report_name}.pdf", "success") + return redirect(url_for("main.zip_workspace")) + + + +@bp.route("/workspace/exports//delete", methods=["POST"]) +@portal_session_required +def delete_export(filename: str): + exports_dir = _tenant_root() / "exports" + file_path = exports_dir / filename - doc.build(elements) + if file_path.exists() and file_path.is_file(): + file_path.unlink(missing_ok=True) - flash(f"PDF Report created: {archive_name}.pdf", "success") + flash(f"Deleted export '{filename}'", "success") return redirect(url_for("main.zip_workspace")) diff --git a/app/templates/cloud/pdf_report_workshop.html b/app/templates/cloud/pdf_report_workshop.html index 3b770ff..cb1778f 100644 --- a/app/templates/cloud/pdf_report_workshop.html +++ b/app/templates/cloud/pdf_report_workshop.html @@ -15,6 +15,14 @@

+
+
+ +

Images

diff --git a/app/templates/cloud/zip_workspace.html b/app/templates/cloud/zip_workspace.html index 49bcdbf..f9dd15f 100644 --- a/app/templates/cloud/zip_workspace.html +++ b/app/templates/cloud/zip_workspace.html @@ -76,9 +76,17 @@ - +
+ + +
+
+ +
+ +
    {% for item in staged_files %}
  • @@ -120,6 +128,11 @@
    + +
    + +
    +
  • {% endfor %}