Browse Source

Release v2.0.0 PDF job reports milestone

master v2.0.0
Don Kingdon 5 days ago
parent
commit
3bf31e0699
  1. 28
      PROJECT_STATE.md
  2. 25
      README.md
  3. 2
      VERSION
  4. 111
      app/main/routes.py
  5. 10
      app/templates/cloud/zip_workspace.html

28
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)

25
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

2
VERSION

@ -1 +1 @@
v1.5.1-beta
v2.0.0

111
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_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,8 +1311,16 @@ 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
""",
"""
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()
@ -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/<int:job_id>/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/<int:job_id>/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()

10
app/templates/cloud/zip_workspace.html

@ -67,6 +67,16 @@
<input type="radio" name="format" value="targz">
TAR.GZ — good compression, faster than ZIP
</label>
<label>
<input type="radio" name="format" value="pdf">
PDF — Job Report (images only)
</label>
<label>
<input type="radio" name="format" value="pdf">
PDF — Job Report (images only)
</label>
</div>
<button class="portal-btn primary" type="submit">Create Archive</button>

Loading…
Cancel
Save