import logging from flask import Blueprint, render_template, redirect, url_for, flash, request, abort from flask_login import login_required, current_user from app import db from app.models.facility import Facility, Area from app.models.project import Project from app.utils.forms import FacilityForm, AreaForm from app.utils.decorators import supervisor_required, admin_required, project_manager_required from app.utils.audit import (log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT) from app.utils.scope import get_customer_scope, get_inspector_scope from app.tenancy.gates import quota_soft_check bp = Blueprint('facilities', __name__, url_prefix='/facilities') logger = logging.getLogger(__name__) @bp.route('/') @login_required def list_facilities(): if current_user.role == 'customer': cids = get_customer_scope(current_user) or [] facilities = Facility.query.filter( Facility.id.in_(cids), Facility.active == True ).order_by(Facility.name).all() elif current_user.is_inspector: fids = get_inspector_scope(current_user) or [] facilities = Facility.query.filter( Facility.id.in_(fids), Facility.active == True ).order_by(Facility.name).all() else: facilities = Facility.query.order_by(Facility.name).all() # Group facilities by Contract (Project) for the collapsible list view. # Facilities with no contract are collected under key '__none__' and # rendered last as "No Contract Assigned". from collections import OrderedDict grouped = OrderedDict() ungrouped = [] for f in facilities: if f.project: key = f.project.name grouped.setdefault(key, {'project': f.project, 'facilities': []}) grouped[key]['facilities'].append(f) else: ungrouped.append(f) grouped = OrderedDict(sorted(grouped.items())) if ungrouped: grouped['__none__'] = {'project': None, 'facilities': ungrouped} logger.info('FACILITIES | list | user=%s | total=%s | groups=%s', current_user.username, len(facilities), len(grouped)) return render_template('facilities/list.html', facilities=facilities, grouped=grouped) @bp.route('/new', methods=['GET', 'POST']) @login_required @supervisor_required @quota_soft_check('facilities') def create_facility(): form = FacilityForm() projects = Project.query.filter_by(active=True).order_by(Project.name).all() form.project_id.choices = [(0, '— None —')] + [(p.id, p.name) for p in projects] if form.validate_on_submit(): project_id = form.project_id.data if form.project_id.data else None facility = Facility( name=form.name.data, address=form.address.data, contact_person=form.contact_person.data, contact_phone=form.contact_phone.data, project_id=project_id if project_id else None, active=form.active.data ) db.session.add(facility) db.session.commit() logger.info('FACILITIES | create | user=%s | facility_id=%s name=%r', current_user.username, facility.id, facility.name) log_action(ACTION_CREATE, 'Facility', facility.id, facility.name, f'contact={facility.contact_person or ""}; project_id={facility.project_id}; active={facility.active}') flash(f'Facility "{facility.name}" created successfully.', 'success') return redirect(url_for('facilities.view_facility', facility_id=facility.id)) return render_template('facilities/form.html', form=form, title='Create Facility') @bp.route('/') @login_required def view_facility(facility_id): facility = db.session.get(Facility, facility_id) if facility is None: abort(404) if current_user.role == 'customer': cids = get_customer_scope(current_user) or [] if facility_id not in cids: flash('Access denied.', 'danger') return redirect(url_for('facilities.list_facilities')) areas = facility.areas.order_by(Area.name).all() return render_template('facilities/view.html', facility=facility, areas=areas) @bp.route('//edit', methods=['GET', 'POST']) @login_required @supervisor_required def edit_facility(facility_id): facility = db.session.get(Facility, facility_id) if facility is None: abort(404) form = FacilityForm(obj=facility) projects = Project.query.filter_by(active=True).order_by(Project.name).all() form.project_id.choices = [(0, '— None —')] + [(p.id, p.name) for p in projects] if form.validate_on_submit(): project_id = form.project_id.data if form.project_id.data else None facility.name = form.name.data facility.address = form.address.data facility.contact_person = form.contact_person.data facility.contact_phone = form.contact_phone.data facility.project_id = project_id if project_id else None facility.active = form.active.data db.session.commit() logger.info('FACILITIES | edit | user=%s | facility_id=%s name=%r', current_user.username, facility.id, facility.name) log_action(ACTION_UPDATE, 'Facility', facility.id, facility.name, f'project_id={facility.project_id}; active={facility.active}') flash(f'Facility "{facility.name}" updated successfully.', 'success') return redirect(url_for('facilities.view_facility', facility_id=facility.id)) return render_template('facilities/form.html', form=form, facility=facility, title='Edit Facility') @bp.route('//delete', methods=['POST']) @login_required @admin_required def delete_facility(facility_id): facility = db.session.get(Facility, facility_id) if facility is None: abort(404) if facility.inspections.count() > 0: flash(f'Cannot delete "{facility.name}" — it has existing inspection records.', 'danger') return redirect(url_for('facilities.view_facility', facility_id=facility.id)) facility_name = facility.name facility_id_snap = facility.id db.session.delete(facility) db.session.commit() logger.info('FACILITIES | delete | user=%s | facility_id=%s name=%r', current_user.username, facility_id_snap, facility_name) log_action(ACTION_DELETE, 'Facility', facility_id_snap, facility_name) flash(f'Facility "{facility_name}" has been permanently deleted.', 'success') return redirect(url_for('facilities.list_facilities')) # Area Management Routes @bp.route('//areas/new', methods=['GET', 'POST']) @login_required @supervisor_required def create_area(facility_id): facility = db.session.get(Facility, facility_id) if facility is None: abort(404) form = AreaForm() form.facility_id.choices = [(facility.id, facility.name)] form.facility_id.data = facility.id if form.validate_on_submit(): area = Area( name=form.name.data, area_type=form.area_type.data, facility_id=facility.id ) db.session.add(area) db.session.commit() logger.info('FACILITIES | create_area | user=%s | area_id=%s name=%r facility=%r', current_user.username, area.id, area.name, facility.name) log_action(ACTION_CREATE, 'Area', area.id, area.name, f'facility={facility.name}; type={area.area_type or ""}') flash(f'Area "{area.name}" created successfully.', 'success') return redirect(url_for('facilities.view_facility', facility_id=facility.id)) return render_template('facilities/area_form.html', form=form, facility=facility, title='Create Area') @bp.route('/areas//edit', methods=['GET', 'POST']) @login_required @supervisor_required def edit_area(area_id): area = db.session.get(Area, area_id) if area is None: abort(404) form = AreaForm(obj=area) facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() form.facility_id.choices = [(f.id, f.name) for f in facilities] if form.validate_on_submit(): area.name = form.name.data area.area_type = form.area_type.data area.facility_id = form.facility_id.data db.session.commit() logger.info('FACILITIES | edit_area | user=%s | area_id=%s name=%r', current_user.username, area.id, area.name) log_action(ACTION_UPDATE, 'Area', area.id, area.name, f'facility_id={area.facility_id}; type={area.area_type or ""}') flash(f'Area "{area.name}" updated successfully.', 'success') return redirect(url_for('facilities.view_facility', facility_id=area.facility_id)) return render_template('facilities/area_form.html', form=form, area=area, facility=area.facility, title='Edit Area') @bp.route('/areas//delete', methods=['POST']) @login_required @supervisor_required def delete_area(area_id): area = db.session.get(Area, area_id) if area is None: abort(404) facility_id = area.facility_id if area.inspections.count() > 0: flash('Cannot delete area with existing inspections.', 'danger') return redirect(url_for('facilities.view_facility', facility_id=facility_id)) issue_count = area.issues.count() if issue_count > 0: flash( f'Cannot delete area "{area.name}" — it has {issue_count} issue record(s) on file. ' f'Resolve or reassign those issues first.', 'danger' ) return redirect(url_for('facilities.view_facility', facility_id=facility_id)) area_name = area.name area_id_snap = area.id db.session.delete(area) db.session.commit() logger.info('FACILITIES | delete_area | user=%s | area_id=%s name=%r', current_user.username, area_id_snap, area_name) log_action(ACTION_DELETE, 'Area', area_id_snap, area_name) flash(f'Area "{area_name}" deleted successfully.', 'success') return redirect(url_for('facilities.view_facility', facility_id=facility_id)) # ── Facility QR codes (phase38) ─────────────────────────────────────────────── def _qr_scan_url(facility): """Absolute public scan URL, built from the current host (rule 64 pattern).""" facility.ensure_qr_token() return request.host_url.rstrip('/') + url_for('facility_qr.scan', token=facility.qr_token) def _facility_for_qr_or_403(facility_id): """Load a facility for a QR action, enforcing customer facility scope. Customers may only touch QR codes for facilities they are assigned to; all other roles have unrestricted QR access. Replaces the previous @project_manager_required gate so a customer can print (and rotate) the codes posted in their own building. """ facility = db.session.get(Facility, facility_id) if facility is None: abort(404) # QR management is not an inspector task (matches qr_print_all/qr_export_pdf). if current_user.is_inspector: abort(403) if current_user.role == 'customer': cids = get_customer_scope(current_user) or [] if facility.id not in cids: abort(403) return facility def _qr_png_bytes(url): """Return PNG bytes for a QR code encoding *url*.""" import io as _io import qrcode img = qrcode.make(url, box_size=10, border=2) buf = _io.BytesIO() img.save(buf, format='PNG') return buf.getvalue() @bp.route('//qr.png') @login_required def facility_qr_png(facility_id): """Return the facility's QR code as a PNG image. The printable card renders inline SVG; this PNG endpoint exists for the print-all grid and is the same image the PDF export embeds. """ facility = _facility_for_qr_or_403(facility_id) created = not facility.qr_token url = _qr_scan_url(facility) if created: db.session.commit() from flask import Response return Response(_qr_png_bytes(url), mimetype='image/png', headers={ 'Cache-Control': 'private, max-age=3600', }) @bp.route('//qr') @login_required def qr_card(facility_id): """Printable QR card for one facility. Generates the token on first use.""" from app.utils.qr import qr_svg facility = _facility_for_qr_or_403(facility_id) if not facility.qr_token: facility.ensure_qr_token() db.session.commit() logger.info('FACILITIES | qr_token_created | user=%s | facility_id=%s', current_user.username, facility_id) scan_url = _qr_scan_url(facility) return render_template('facilities/qr_card.html', facility=facility, scan_url=scan_url, svg=qr_svg(scan_url)) @bp.route('/qr-sheet') @login_required def qr_sheet(): """Bulk print sheet — one labeled QR card per active facility.""" from app.utils.qr import qr_svg if current_user.is_inspector: abort(403) if current_user.role == 'customer': cids = get_customer_scope(current_user) or [] facilities = (Facility.query .filter(Facility.id.in_(cids), Facility.active == True) .order_by(Facility.name).all()) if cids else [] else: facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() generated = 0 for f in facilities: if not f.qr_token: f.ensure_qr_token() generated += 1 if generated: db.session.commit() logger.info('FACILITIES | qr_tokens_created | user=%s | count=%s', current_user.username, generated) cards = [{'facility': f, 'svg': qr_svg(_qr_scan_url(f))} for f in facilities] return render_template('facilities/qr_sheet.html', cards=cards) @bp.route('//qr/regenerate', methods=['POST']) @login_required def regenerate_qr(facility_id): """Rotate the QR token — invalidates every previously printed poster. Allowed for admin/director, and for customers on their own assigned facilities. Project managers, auditors and inspectors cannot regenerate. """ import secrets facility = _facility_for_qr_or_403(facility_id) if current_user.role not in ('admin', 'director', 'customer'): abort(403) facility.qr_token = secrets.token_urlsafe(32) db.session.commit() logger.info('FACILITIES | qr_token_regenerated | user=%s | facility_id=%s', current_user.username, facility_id) log_action(ACTION_UPDATE, 'Facility', facility.id, facility.name, 'QR token regenerated — previously printed QR posters are now invalid') flash('QR code regenerated. Previously printed posters no longer work — ' 'print and post the new code.', 'success') return redirect(url_for('facilities.qr_card', facility_id=facility_id)) # ── Public Area QR codes (phase42) ──────────────────────────────────────────── # Mirrors the facility QR routes above, but scoped to a single area. Customer # scope is enforced via the area's parent facility. def _area_qr_scan_url(area): """Absolute public scan URL for an area, built from the current host.""" area.ensure_qr_token() return request.host_url.rstrip('/') + url_for('facility_qr.area_scan', token=area.qr_token) def _area_for_qr_or_403(area_id): """Load an area for a QR action, enforcing customer facility scope.""" area = db.session.get(Area, area_id) if area is None: abort(404) # QR management is not an inspector task (matches qr_print_all/qr_export_pdf). if current_user.is_inspector: abort(403) if current_user.role == 'customer': cids = get_customer_scope(current_user) or [] if area.facility_id not in cids: abort(403) return area @bp.route('/areas//qr.png') @login_required def area_qr_png(area_id): """Return the area's QR code as a PNG image.""" area = _area_for_qr_or_403(area_id) created = not area.qr_token url = _area_qr_scan_url(area) if created: db.session.commit() from flask import Response return Response(_qr_png_bytes(url), mimetype='image/png', headers={ 'Cache-Control': 'private, max-age=3600', }) @bp.route('/areas//qr') @login_required def area_qr_card(area_id): """Printable page: area name + facility + QR + public URL + instructions.""" from app.utils.qr import qr_svg area = _area_for_qr_or_403(area_id) created = not area.qr_token scan_url = _area_qr_scan_url(area) if created: db.session.commit() logger.info('FACILITIES | area_qr_token_created | user=%s | area_id=%s', current_user.username, area_id) return render_template('facilities/area_qr.html', area=area, facility=area.facility, scan_url=scan_url, svg=qr_svg(scan_url)) @bp.route('/areas//qr/regenerate', methods=['POST']) @login_required def regenerate_area_qr(area_id): """Rotate an area's QR token — invalidates every previously printed poster. Allowed for admin/director, and for customers on their own assigned facilities. Project managers, auditors and inspectors cannot regenerate. """ import secrets area = _area_for_qr_or_403(area_id) if current_user.role not in ('admin', 'director', 'customer'): abort(403) area.qr_token = secrets.token_urlsafe(32) db.session.commit() logger.info('FACILITIES | area_qr_token_regenerated | user=%s | area_id=%s', current_user.username, area_id) log_action(ACTION_UPDATE, 'Area', area.id, area.name, 'QR token regenerated — previously printed QR posters are now invalid') flash('QR code regenerated. Previously printed posters no longer work — ' 'print and post the new code.', 'warning') return redirect(url_for('facilities.area_qr_card', area_id=area.id)) # ── Bulk QR print / export (phase42) ────────────────────────────────────────── @bp.route('/qr/print-all') @login_required def qr_print_all(): """Printable / selectable sheet of the QR codes the user can see. Query params (all optional): ?contract_id= — limit to one contract; narrows the facility dropdown ?facility_id= — limit to a single facility ?include_areas=1 — also render each facility's per-area QR codes Inspectors have no QR management (403); customers are scoped to their assigned facilities; managers see all active facilities. """ if current_user.is_inspector: abort(403) contract_id = request.args.get('contract_id', type=int) facility_id = request.args.get('facility_id', type=int) include_areas = request.args.get('include_areas') in ('1', 'true', 'on') # Facilities in the viewer's scope. if current_user.role == 'customer': fids = get_customer_scope(current_user) or [] scoped = Facility.query.filter(Facility.id.in_(fids), Facility.active == True) else: scoped = Facility.query.filter(Facility.active == True) scoped_facilities = scoped.order_by(Facility.name).all() # Contract dropdown — only contracts present among the scoped facilities. contract_ids = {f.project_id for f in scoped_facilities if f.project_id} contracts = (Project.query .filter(Project.id.in_(contract_ids)) .order_by(Project.name).all()) if contract_ids else [] # Facility dropdown — narrowed by the selected contract. facility_options = [f for f in scoped_facilities if not contract_id or f.project_id == contract_id] # The rendered grid — apply the contract + facility filters. grid_facilities = facility_options if facility_id: grid_facilities = [f for f in grid_facilities if f.id == facility_id] # Ensure every rendered facility (and area, if requested) has a token so # its qr.png renders; collect areas keyed by facility id. changed = False areas_by_facility = {} for f in grid_facilities: if not f.qr_token: f.ensure_qr_token() changed = True if include_areas: fa = f.areas.order_by(Area.name).all() for a in fa: if not a.qr_token: a.ensure_qr_token() changed = True areas_by_facility[f.id] = fa if changed: db.session.commit() selected_contract = db.session.get(Project, contract_id) if contract_id else None return render_template('facilities/qr_print_all.html', facilities=grid_facilities, areas_by_facility=areas_by_facility, include_areas=include_areas, contracts=contracts, facility_options=facility_options, selected_contract=selected_contract, selected_contract_id=contract_id, selected_facility_id=facility_id) @bp.route('/qr/export-pdf', methods=['POST']) @login_required def qr_export_pdf(): """Export the selected facility + area QR codes to a single PDF. Selection arrives as repeated `facility_ids` / `area_ids` form fields. Scope is enforced per-id via the same helpers as the QR pages, so a customer can never export a code outside their assigned facilities. """ if current_user.is_inspector: abort(403) facility_ids = request.form.getlist('facility_ids', type=int) area_ids = request.form.getlist('area_ids', type=int) if not facility_ids and not area_ids: flash('Select at least one QR code to export.', 'warning') return redirect(request.referrer or url_for('facilities.qr_print_all')) items = [] for fid in facility_ids: facility = _facility_for_qr_or_403(fid) # 403 if out of scope url = _qr_scan_url(facility) items.append({ 'title': facility.name, 'subtitle': facility.project.name if facility.project else None, 'caption': 'Facility · Report a problem & view recent quality', 'png': _qr_png_bytes(url), '_sort': ((facility.name or '').lower(), 0, ''), }) for aid in area_ids: area = _area_for_qr_or_403(aid) # 403 if out of scope url = _area_qr_scan_url(area) fac_name = area.facility.name if area.facility else '' items.append({ 'title': area.name, 'subtitle': fac_name or None, 'caption': 'Area · Report a problem & view recent quality', 'png': _qr_png_bytes(url), '_sort': (fac_name.lower(), 1, (area.name or '').lower()), }) # Persist any tokens minted by ensure_qr_token() above. db.session.commit() # Group each facility with its own areas: facility card first, then areas. items.sort(key=lambda x: x['_sort']) from app.utils.pdf_export import generate_qr_codes_pdf summary = f'{len(facility_ids)} facilit' + ('y' if len(facility_ids) == 1 else 'ies') summary += f', {len(area_ids)} area' + ('' if len(area_ids) == 1 else 's') pdf_bytes = generate_qr_codes_pdf(items, filter_summary=summary) logger.info('FACILITIES | qr_export_pdf | user=%s | facilities=%s | areas=%s', current_user.username, len(facility_ids), len(area_ids)) log_action(ACTION_EXPORT, 'Facility', 0, 'QR Codes', f'exported {len(facility_ids)} facility + {len(area_ids)} area QR codes to PDF') from flask import Response return Response(pdf_bytes, mimetype='application/pdf', headers={ 'Content-Disposition': 'attachment; filename="qr_codes.pdf"', })