Browse Source

v2.1.0 PDF Report Workshop with image preview and annotations

master v2.1.0
Don Kingdon 5 days ago
parent
commit
6590a04423
  1. 13
      PROJECT_STATE.md
  2. 12
      README.md
  3. 2
      VERSION
  4. 107
      app/main/routes.py
  5. 38
      app/templates/cloud/pdf_report_workshop.html
  6. 5
      app/templates/cloud/zip_workspace.html

13
PROJECT_STATE.md

@ -1,5 +1,18 @@
# OTB Cloud Project State # OTB Cloud Project State
## v2.1.0 - 20260502-005134
Feature release: PDF Report Workshop (interactive document builder).
- Added PDF Report Workshop accessible from Archive Workspace.
- Users can view staged images and add per-image captions and notes.
- Added report-level metadata (job title, customer, address, technician, date).
- Added secure staged image preview route for workshop UI.
- PDF output now supports structured job reports, not just image dumps.
---
## v2.0.0 - 20260501-063031 ## v2.0.0 - 20260501-063031
Milestone release: Archive Workspace now supports PDF Job Report generation. Milestone release: Archive Workspace now supports PDF Job Report generation.

12
README.md

@ -1,5 +1,17 @@
# OTB Cloud # OTB Cloud
## v2.1.0 - 20260502-005134
PDF Report Workshop introduced.
- Build structured job reports from staged images.
- Add captions, notes, and job metadata.
- Preview images securely inside the workshop.
- Generate client-ready PDF reports.
---
## v2.0.0 - 20260501-063031 ## v2.0.0 - 20260501-063031
Major milestone release. Major milestone release.

2
VERSION

@ -1 +1 @@
v2.0.0 v2.1.0

107
app/main/routes.py

@ -13,7 +13,7 @@ import re
import hashlib import hashlib
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from flask import Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify from flask import send_file, Blueprint, flash, redirect, render_template, request, session, url_for, current_app, send_file, jsonify
from app.db import get_db from app.db import get_db
from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256 from app.auth.utils import create_device_directories, remove_device_directories, slugify_device_name, compute_sha256
@ -2412,3 +2412,108 @@ def image_process():
}) })
return jsonify({"ok": True, "processed": processed}) return jsonify({"ok": True, "processed": processed})
@bp.route("/workspace/pdf-report/preview/<path:filename>")
@portal_session_required
def pdf_report_preview(filename: str):
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
file_path = staging_dir / filename
try:
file_path = file_path.resolve()
staging_resolved = staging_dir.resolve()
if staging_resolved not in file_path.parents and file_path != staging_resolved:
abort(403)
if not file_path.exists() or not file_path.is_file():
abort(404)
if file_path.suffix.lower() not in [".jpg", ".jpeg", ".png", ".webp"]:
abort(404)
return send_file(file_path)
except Exception:
abort(404)
@bp.route("/workspace/pdf-report")
@portal_session_required
def pdf_report_workshop():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
staging_dir.mkdir(parents=True, exist_ok=True)
staged_files = []
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"]:
staged_files.append({
"name": p.name,
"path": str(p),
})
return render_template(
"cloud/pdf_report_workshop.html",
staged_files=staged_files
)
@bp.route("/workspace/pdf-report/create", methods=["POST"])
@portal_session_required
def create_pdf_report():
tenant_root = _tenant_root()
staging_dir = tenant_root / "zip_staging"
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')}"
archive_name = re.sub(r"[^A-Za-z0-9._-]+", "_", archive_name)
pdf_path = exports_dir / f"{archive_name}.pdf"
doc = SimpleDocTemplate(str(pdf_path), pagesize=letter)
styles = getSampleStyleSheet()
elements = []
# Report metadata
elements.append(Paragraph(request.form.get("job_title", "Job Report"), 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(Spacer(1, 24))
staged = sorted([p for p in staging_dir.iterdir() if p.is_file()])
for idx, p in enumerate(staged):
if p.suffix.lower() not in [".jpg", ".jpeg", ".png", ".webp"]:
continue
caption = request.form.get(f"caption_{idx}", "")
notes = request.form.get(f"notes_{idx}", "")
elements.append(Paragraph(p.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"]))
elements.append(Spacer(1, 8))
if caption:
elements.append(Paragraph(f"<b>Caption:</b> {caption}", styles["Normal"]))
if notes:
elements.append(Paragraph(f"<b>Notes:</b> {notes}", styles["Normal"]))
elements.append(Spacer(1, 18))
elements.append(PageBreak())
doc.build(elements)
flash(f"PDF Report created: {archive_name}.pdf", "success")
return redirect(url_for("main.zip_workspace"))

38
app/templates/cloud/pdf_report_workshop.html

@ -0,0 +1,38 @@
{% extends "portal_base.html" %}
{% block title %}PDF Report Workshop{% endblock %}
{% block portal_content %}
<h1>PDF Report Workshop</h1>
<form method="post" action="/workspace/pdf-report/create">
<h3>Report Info</h3>
<input name="report_name" placeholder="Report filename"><br>
<input name="job_title" placeholder="Job Title"><br>
<input name="customer" placeholder="Customer"><br>
<input name="address" placeholder="Address"><br>
<input name="technician" placeholder="Technician"><br>
<input name="date" placeholder="Date"><br>
<hr>
<h3>Images</h3>
{% for file in staged_files %}
<div style="margin-bottom:20px;">
<img src="{{ url_for('main.pdf_report_preview', filename=file.name) }}" style="max-width:300px;max-height:220px;border-radius:10px;"><br>
<strong>{{ file.name }}</strong><br>
Caption:<br>
<input name="caption_{{ loop.index0 }}" style="width:300px;"><br>
Notes:<br>
<textarea name="notes_{{ loop.index0 }}" style="width:300px;"></textarea>
</div>
{% endfor %}
<button type="submit">Generate PDF Report</button>
</form>
{% endblock %}

5
app/templates/cloud/zip_workspace.html

@ -14,6 +14,7 @@
<div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;"> <div class="portal-toolbar" style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;"> <div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">
<a class="portal-btn" href="/workspace/pdf-report">Open PDF Report Workshop</a>
<a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a> <a class="portal-btn" href="{{ url_for('main.dashboard') }}">Back to Dashboard</a>
<a class="portal-btn" href="{{ url_for('main.lts_view') }}">View LTS</a> <a class="portal-btn" href="{{ url_for('main.lts_view') }}">View LTS</a>
</div> </div>
@ -73,10 +74,6 @@
PDF — Job Report (images only) PDF — Job Report (images only)
</label> </label>
<label>
<input type="radio" name="format" value="pdf">
PDF — Job Report (images only)
</label>
</div> </div>
<button class="portal-btn primary" type="submit">Create Archive</button> <button class="portal-btn primary" type="submit">Create Archive</button>

Loading…
Cancel
Save