Browse Source

v2.1.1 improve archive export cleanup controls

master v2.1.1
Don Kingdon 4 days ago
parent
commit
86b9a7d097
  1. 12
      PROJECT_STATE.md
  2. 9
      README.md
  3. 2
      VERSION
  4. 222
      app/main/routes.py
  5. 8
      app/templates/cloud/pdf_report_workshop.html
  6. 15
      app/templates/cloud/zip_workspace.html

12
PROJECT_STATE.md

@ -1,5 +1,17 @@
# OTB Cloud Project State # 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 ## v2.1.0 - 20260502-005134
Feature release: PDF Report Workshop (interactive document builder). Feature release: PDF Report Workshop (interactive document builder).

9
README.md

@ -1,5 +1,14 @@
# OTB Cloud # 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 ## v2.1.0 - 20260502-005134
PDF Report Workshop introduced. PDF Report Workshop introduced.

2
VERSION

@ -1 +1 @@
v2.1.0 v2.1.1

222
app/main/routes.py

@ -4,16 +4,18 @@ from datetime import datetime, timezone
import shutil import shutil
import zipfile import zipfile
import tarfile import tarfile
import tempfile
import os
from reportlab.lib.pagesizes import letter from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate, Image as ReportLabImage, Paragraph, Spacer, PageBreak from reportlab.platypus import SimpleDocTemplate, Image as ReportLabImage, Paragraph, Spacer, PageBreak
from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.styles import getSampleStyleSheet
from PIL import Image as PILImage from PIL import Image as PILImage, ImageOps
from PIL import Image from PIL import Image
import re import re
import hashlib import hashlib
from werkzeug.utils import secure_filename 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.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
@ -846,6 +848,85 @@ def zip_workspace():
export_files=export_files, 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"]) @bp.route("/workspace/zip/create", methods=["POST"])
@portal_session_required @portal_session_required
def create_zip_from_workspace(): 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 display_name = img_path.name.split("__", 1)[1] if "__" in img_path.name else img_path.name
try: try:
with PILImage.open(img_path) as im: tmp_img, w, h = _prepare_report_image_for_pdf(img_path, "standard")
w, h = im.size pdf_temp_files.append(tmp_img)
scale = min(max_w / w, max_h / h, 1.0) draw_w, draw_h = _pdf_scaled_dimensions(w, h, max_w, max_h)
draw_w = w * scale
draw_h = h * scale
elements.append(Paragraph(f"{idx}. {display_name}", styles["Heading3"])) elements.append(Paragraph(f"{idx}. {display_name}", styles["Heading3"]))
elements.append(Spacer(1, 8)) 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)) elements.append(Spacer(1, 18))
if idx != len(image_files): 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(Paragraph(f"Skipped image: {display_name} ({e})", styles["Normal"]))
elements.append(Spacer(1, 12)) 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: else:
archive_format = "zip" archive_format = "zip"
@ -1712,25 +1799,21 @@ def move_export_to_lts(filename: str):
@bp.route("/workspace/exports/<path:filename>/download-remove", methods=["GET"]) @bp.route("/workspace/exports/<path:filename>/download-remove", methods=["GET"])
@portal_session_required @portal_session_required
def download_and_remove_export(filename: str): 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 file_path = exports_dir / filename
if not file_path.exists(): if not file_path.exists() or not file_path.is_file():
flash("Archive not found.", "warning") abort(404)
return redirect(url_for("main.zip_workspace"))
response = send_file(file_path, as_attachment=True, download_name=file_path.name)
@response.call_on_close @after_this_request
def cleanup(): def remove_file(response):
try: try:
file_path.unlink(missing_ok=True) file_path.unlink(missing_ok=True)
except Exception: except Exception:
pass pass
return response
return response return send_file(file_path, as_attachment=True)
@bp.route("/workspace/lts", methods=["GET"]) @bp.route("/workspace/lts", methods=["GET"])
@portal_session_required @portal_session_required
@ -2463,57 +2546,94 @@ def create_pdf_report():
exports_dir = tenant_root / "exports" exports_dir = tenant_root / "exports"
exports_dir.mkdir(parents=True, exist_ok=True) exports_dir.mkdir(parents=True, exist_ok=True)
archive_name = (request.form.get("report_name") or "").strip() report_name = (request.form.get("report_name") or "").strip()
if not archive_name: if not report_name:
archive_name = f"otb-report-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}" 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) quality_mode = (request.form.get("quality_mode") or "standard").strip().lower()
pdf_path = exports_dir / f"{archive_name}.pdf" 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) doc = SimpleDocTemplate(str(pdf_path), pagesize=letter)
styles = getSampleStyleSheet() styles = getSampleStyleSheet()
elements = [] elements = []
pdf_temp_files = []
job_title = request.form.get("job_title") or "Job Report"
# Report metadata elements.append(Paragraph(job_title, styles["Title"]))
elements.append(Paragraph(request.form.get("job_title", "Job Report"), styles["Title"]))
elements.append(Spacer(1, 12)) elements.append(Spacer(1, 12))
elements.append(Paragraph(f"Customer: {request.form.get('customer', '')}", styles["Normal"])) 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"Address: {request.form.get('address', '')}", styles["Normal"]))
elements.append(Paragraph(f"Technician: {request.form.get('technician', '')}", 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"Date: {request.form.get('date', '')}", styles["Normal"]))
elements.append(Paragraph(f"PDF quality: {quality_mode}", styles["Normal"]))
elements.append(Spacer(1, 24)) 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 not image_files:
if p.suffix.lower() not in [".jpg", ".jpeg", ".png", ".webp"]: flash("PDF Report Workshop needs image files. No supported images were staged.", "warning")
continue return redirect(url_for("main.zip_workspace"))
caption = request.form.get(f"caption_{idx}", "") try:
notes = request.form.get(f"notes_{idx}", "") 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(Paragraph(display_name, styles["Heading3"]))
elements.append(Spacer(1, 6)) elements.append(Spacer(1, 6))
try: try:
with PILImage.open(p) as im: tmp_img, w, h = _prepare_report_image_for_pdf(p, quality_mode)
w, h = im.size pdf_temp_files.append(tmp_img)
scale = min(500 / w, 600 / h, 1.0) draw_w, draw_h = _pdf_scaled_dimensions(w, h, 500, 600)
elements.append(ReportLabImage(str(p), width=w*scale, height=h*scale)) elements.append(ReportLabImage(tmp_img, width=draw_w, height=draw_h))
except: except Exception as e:
elements.append(Paragraph("Image failed to load", styles["Normal"])) 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"<b>Caption:</b> {caption}", styles["Normal"]))
if notes:
elements.append(Paragraph(f"<b>Notes:</b> {notes}", styles["Normal"]))
if caption: if idx != len(image_files) - 1:
elements.append(Paragraph(f"<b>Caption:</b> {caption}", styles["Normal"])) elements.append(PageBreak())
if notes:
elements.append(Paragraph(f"<b>Notes:</b> {notes}", styles["Normal"])) doc.build(elements)
elements.append(Spacer(1, 18)) finally:
elements.append(PageBreak()) 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/<path:filename>/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")) return redirect(url_for("main.zip_workspace"))

8
app/templates/cloud/pdf_report_workshop.html

@ -15,6 +15,14 @@
<input name="technician" placeholder="Technician"><br> <input name="technician" placeholder="Technician"><br>
<input name="date" placeholder="Date"><br> <input name="date" placeholder="Date"><br>
<label>PDF Size / Quality</label><br>
<select name="quality_mode" class="portal-input" style="max-width:320px;">
<option value="standard" selected>Standard - email friendly</option>
<option value="compressed">Compressed - smallest file</option>
<option value="high">High - larger file</option>
</select><br>
<hr> <hr>
<h3>Images</h3> <h3>Images</h3>

15
app/templates/cloud/zip_workspace.html

@ -76,9 +76,17 @@
</div> </div>
<button class="portal-btn primary" type="submit">Create Archive</button> <div style="display:flex;gap:10px;flex-wrap:wrap;">
<button class="portal-btn primary" type="submit">Create Archive</button>
<button class="portal-btn" type="submit" formaction="{{ url_for('main.flush_zip_workspace') }}" formmethod="post" onclick="return confirm('Flush all staged files from Archive Workspace? This does not delete originals.');">Flush Workspace</button>
</div>
</form> </form>
<form method="post" action="{{ url_for('main.delete_export', filename=item.name) }}" style="display:inline;">
<button class="portal-btn" type="submit" onclick="return confirm('Delete this export?');">Delete</button>
</form>
<ul style="padding-left:18px; margin:0;"> <ul style="padding-left:18px; margin:0;">
{% for item in staged_files %} {% for item in staged_files %}
<li style="margin-bottom:8px;"> <li style="margin-bottom:8px;">
@ -120,6 +128,11 @@
<form method="post" action="{{ url_for('main.move_export_to_lts', filename=item.name) }}" style="display:inline;"> <form method="post" action="{{ url_for('main.move_export_to_lts', filename=item.name) }}" style="display:inline;">
<button class="portal-btn" type="submit">Move to LTS</button> <button class="portal-btn" type="submit">Move to LTS</button>
</form> </form>
<form method="post" action="{{ url_for('main.delete_export', filename=item.name) }}" style="display:inline;">
<button class="portal-btn" type="submit" onclick="return confirm('Delete this export?');">Delete</button>
</form>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}

Loading…
Cancel
Save