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
## 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).

9
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.

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 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/<path:filename>/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"<b>Caption:</b> {caption}", styles["Normal"]))
if notes:
elements.append(Paragraph(f"<b>Notes:</b> {notes}", styles["Normal"]))
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 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/<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"))

8
app/templates/cloud/pdf_report_workshop.html

@ -15,6 +15,14 @@
<input name="technician" placeholder="Technician"><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>
<h3>Images</h3>

15
app/templates/cloud/zip_workspace.html

@ -76,9 +76,17 @@
</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 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;">
{% for item in staged_files %}
<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;">
<button class="portal-btn" type="submit">Move to LTS</button>
</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>
</li>
{% endfor %}

Loading…
Cancel
Save