From 3bf31e06991f86fd876abf9652f2cf58f0269742 Mon Sep 17 00:00:00 2001 From: Don Kingdon Date: Fri, 1 May 2026 06:30:43 +0000 Subject: [PATCH] Release v2.0.0 PDF job reports milestone --- PROJECT_STATE.md | 28 +++++- README.md | 25 +++++- VERSION | 2 +- app/main/routes.py | 119 +++++++++++++++++++++---- app/templates/cloud/zip_workspace.html | 10 +++ 5 files changed, 166 insertions(+), 18 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 32834a0..d1509fa 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,4 +1,30 @@ -## [v1.5.1-beta] - Health + stability patch +# OTB Cloud Project State + +## v2.0.0 - 20260501-063031 + +Milestone release: Archive Workspace now supports PDF Job Report generation. + +- Added PDF output option alongside ZIP, TAR, and TAR.GZ. +- PDF Job Reports are generated from staged image files. +- Job reports include title, UTC generation timestamp, image count, filenames, and scaled images. +- This turns Archive Workspace from internal export tooling into a client-facing deliverable workflow. +- Current limitation: PDF reports are image-only. + +--- + + +## v2.0.0 - 20260501-062745 + +Milestone release: Archive Workspace now supports PDF Job Report generation. + +- Added PDF output option alongside ZIP, TAR, and TAR.GZ. +- PDF Job Reports are generated from staged image files. +- Job reports include title, UTC generation timestamp, image count, filenames, and scaled images. +- This turns Archive Workspace from internal export tooling into a client-facing deliverable workflow. +- Current limitation: PDF reports are image-only. + +--- + ### Fixes - Fixed /health route crash (Path import + indentation issue) diff --git a/README.md b/README.md index 973aac0..f8db635 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,29 @@ # OTB Cloud -# OTB Cloud +## v2.0.0 - 20260501-063031 + +Major milestone release. + +- Archive Workspace can now generate client-facing PDF Job Reports from staged images. +- Existing archive outputs remain available: ZIP, TAR, TAR.GZ. +- PDF output is intended for jobsite photo reports, customer records, inspection documentation, and invoice/support attachments. +- No backup/helper patch files should be committed. + +--- + + +## v2.0.0 - 20260501-062745 + +Major milestone release. + +- Archive Workspace can now generate client-facing PDF Job Reports from staged images. +- Existing archive outputs remain available: ZIP, TAR, TAR.GZ. +- PDF output is intended for jobsite photo reports, customer records, inspection documentation, and invoice/support attachments. +- No backup/helper patch files should be committed. + +--- + + ## v1.5.1-beta - 2026-04-27 diff --git a/VERSION b/VERSION index 9a76aa7..46b105a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.5.1-beta +v2.0.0 diff --git a/app/main/routes.py b/app/main/routes.py index a5f8011..21b440b 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -4,6 +4,10 @@ from datetime import datetime, timezone import shutil import zipfile import tarfile +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 import re import hashlib @@ -873,6 +877,7 @@ def create_zip_from_workspace(): for p in staged: arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name tf.add(p, arcname=arcname) + elif archive_format == "targz": archive_filename = f"{archive_name}.tar.gz" archive_path = exports_dir / archive_filename @@ -880,6 +885,57 @@ def create_zip_from_workspace(): for p in staged: arcname = p.name.split("__", 1)[1] if "__" in p.name else p.name tf.add(p, arcname=arcname) + + elif archive_format == "pdf": + archive_filename = f"{archive_name}.pdf" + archive_path = exports_dir / archive_filename + + image_files = [] + for p in staged: + if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]: + image_files.append(p) + + if not image_files: + flash("PDF Job Report needs image files only. No supported images were staged.", "warning") + return redirect(url_for("main.zip_workspace")) + + doc = SimpleDocTemplate(str(archive_path), pagesize=letter) + styles = getSampleStyleSheet() + elements = [] + + elements.append(Paragraph("OTB Cloud Job Report", styles["Title"])) + elements.append(Spacer(1, 12)) + elements.append(Paragraph(f"Generated UTC: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')}", styles["Normal"])) + elements.append(Paragraph(f"Images included: {len(image_files)}", styles["Normal"])) + elements.append(Spacer(1, 24)) + + max_w = 500 + max_h = 620 + + for idx, img_path in enumerate(image_files, start=1): + 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 + + 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(Spacer(1, 18)) + + if idx != len(image_files): + elements.append(PageBreak()) + + except Exception as e: + elements.append(Paragraph(f"Skipped image: {display_name} ({e})", styles["Normal"])) + elements.append(Spacer(1, 12)) + + doc.build(elements) + else: archive_format = "zip" archive_filename = f"{archive_name}.zip" @@ -1219,12 +1275,22 @@ def browse_device_files(device_id: int): except Exception: page = 1 - per_page = 100 - offset = (page - 1) * per_page + per_page_param = request.args.get("per_page", "100").strip().lower() + if per_page_param == "all": + per_page = None + offset = 0 + page = 1 + else: + try: + per_page = int(per_page_param) + except Exception: + per_page = 100 + if per_page not in (100, 250, 500): + per_page = 100 + offset = (page - 1) * per_page with db.cursor() as cur: - cur.execute( - """ + base_files_sql = """ SELECT id, file_kind, @@ -1245,10 +1311,18 @@ def browse_device_files(device_id: int): AND is_deleted = 0 AND directory_path = %s ORDER BY uploaded_at DESC, id DESC - LIMIT %s OFFSET %s - """, - (session["otb_tenant_id"], device_id, current_directory, per_page, offset), - ) + """ + + if per_page is None: + cur.execute( + base_files_sql, + (session["otb_tenant_id"], device_id, current_directory), + ) + else: + cur.execute( + base_files_sql + " LIMIT %s OFFSET %s", + (session["otb_tenant_id"], device_id, current_directory, per_page, offset), + ) files = cur.fetchall() cur.execute( @@ -1355,7 +1429,7 @@ def browse_device_files(device_id: int): page=page, per_page=per_page, has_prev=page > 1, - has_next=(offset + per_page) < total_files, + has_next=(per_page is not None and (offset + per_page) < total_files), view_mode=view_mode, current_path=current_path, parent_path=parent_path, @@ -1765,7 +1839,6 @@ def video_enqueue(): @bp.route("/video-jobs") def global_video_jobs(): from app.db import get_db - from pathlib import Path tenant = session.get("tenant") or "def" db = get_db() @@ -1850,9 +1923,13 @@ def global_video_jobs(): @bp.route("/health") def cloud_health(): - from app.db import get_db from pathlib import Path + version_file = Path(current_app.root_path).parent / "VERSION" + app_version = version_file.read_text().strip() if version_file.exists() else "unknown" + + from app.db import get_db + tenant = session.get("tenant") or "def" db = get_db() @@ -1908,6 +1985,20 @@ def cloud_health(): "gpu_seconds": 0, } + with db.cursor() as cur: + cur.execute( + """ + SELECT COUNT(*) AS image_processed + FROM files + WHERE tenant_id = %s + AND file_kind = 'image_processed' + AND is_deleted = 0 + """, + (tenant_id,), + ) + image_row = cur.fetchone() or {"image_processed": 0} + image_processed = image_row["image_processed"] or 0 + def human_bytes(n): n = int(n or 0) if n < 1024: @@ -1933,6 +2024,7 @@ def cloud_health(): return render_template( "cloud/health.html", + app_version=app_version, uploaded_count=uploaded_count, uploaded_bytes=human_bytes(uploaded_bytes), lts_count=lts_count, @@ -1940,6 +2032,7 @@ def cloud_health(): archive_count=archive_count, archive_bytes=human_bytes(archive_bytes), total_used=human_bytes(total_used), + image_processed=image_processed, total_jobs=stats["total_jobs"] or 0, complete_jobs=stats["complete_jobs"] or 0, failed_jobs=stats["failed_jobs"] or 0, @@ -1950,7 +2043,6 @@ def cloud_health(): @bp.route("/video-output//view") def view_video_output(job_id): from app.db import get_db - from pathlib import Path tenant = session.get("tenant") or "def" db = get_db() @@ -1987,7 +2079,6 @@ def view_video_output(job_id): @bp.route("/video-output//send-to-lts", methods=["POST"]) def send_video_output_to_lts(job_id): from app.db import get_db - from pathlib import Path import shutil tenant = session.get("tenant") or "def" @@ -2093,7 +2184,6 @@ def download_video_output(job_id): if not job["output_relative_path"]: return "No output file for this job", 404 - from pathlib import Path full_path = Path(storage_root) / job["output_relative_path"] if not full_path.exists(): @@ -2166,7 +2256,6 @@ def video_queue_summary(): @portal_session_required def image_process(): from PIL import Image, ImageOps - from pathlib import Path from datetime import datetime db = get_db() diff --git a/app/templates/cloud/zip_workspace.html b/app/templates/cloud/zip_workspace.html index 4d6fdd7..ffc0126 100644 --- a/app/templates/cloud/zip_workspace.html +++ b/app/templates/cloud/zip_workspace.html @@ -67,6 +67,16 @@ TAR.GZ — good compression, faster than ZIP + + + +