@ -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 ) )
pdf_temp_files = [ ]
try :
doc . build ( elements )
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,45 +2546,61 @@ 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 %d T % H % M % S ' ) } "
report_name = f " otb-report- { datetime . now ( timezone . utc ) . strftime ( ' % Y % m %d T % 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 %d T % H % M % S ' ) } "
quality_mode = ( request . form . get ( " quality_mode " ) or " standard " ) . strip ( ) . lower ( )
if quality_mode not in [ " high " , " standard " , " compressed " ] :
quality_mode = " standard "
archive_name = re . sub ( r " [^A-Za-z0-9._-]+ " , " _ " , archive_name )
pdf_path = exports_dir / f " { report_name } .pdf "
pdf_path = exports_dir / f " { archive_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 = [ ]
# Report metadata
job_title = request . form . get ( " job_title " ) or " Job Report "
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 ( 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 , 60 0)
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 ) )
@ -2510,10 +2609,31 @@ def create_pdf_report():
if notes :
if notes :
elements . append ( Paragraph ( f " <b>Notes:</b> { notes } " , styles [ " Normal " ] ) )
elements . append ( Paragraph ( f " <b>Notes:</b> { notes } " , styles [ " Normal " ] ) )
elements . append ( Spacer ( 1 , 18 ) )
if idx != len ( image_files ) - 1 :
elements . append ( PageBreak ( ) )
elements . append ( PageBreak ( ) )
doc . build ( elements )
doc . build ( elements )
flash ( f " PDF Report created: { archive_name } .pdf " , " success " )
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
if file_path . exists ( ) and file_path . is_file ( ) :
file_path . unlink ( missing_ok = True )
flash ( f " Deleted export ' { filename } ' " , " success " )
return redirect ( url_for ( " main.zip_workspace " ) )
return redirect ( url_for ( " main.zip_workspace " ) )