""" app/routes/projects.py ---------------------- Project management routes. Access matrix: - List / view : admin, supervisor, project_manager - Create / edit / delete : admin, supervisor - Customer assignment management : admin """ import logging from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, Response from flask_login import login_required, current_user from app import db from app.models.project import Project, CustomerAssignment from app.models.facility import Facility from app.models.user import User from app.utils.forms import ProjectForm, CustomerAssignmentForm from app.utils.decorators import admin_required, supervisor_required, project_manager_required from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE logger = logging.getLogger(__name__) bp = Blueprint('projects', __name__, url_prefix='/projects') # ── List ────────────────────────────────────────────────────────────────────── @bp.route('/') @login_required @project_manager_required def index(): projects = Project.query.order_by(Project.name).all() return render_template('projects/list.html', projects=projects) # ── Create ──────────────────────────────────────────────────────────────────── @bp.route('/new', methods=['GET', 'POST']) @login_required @supervisor_required def create(): form = ProjectForm() # Populate project_manager choices: users with role project_manager pm_users = User.query.filter_by(role='project_manager', active=True).order_by(User.username).all() form.project_manager_id.choices = [(0, '— None —')] + [(u.id, u.username) for u in pm_users] if form.validate_on_submit(): pm_id = form.project_manager_id.data or None project = Project( name=form.name.data, description=form.description.data, project_manager_id=pm_id if pm_id else None, active=form.active.data, ) db.session.add(project) db.session.commit() logger.info('PROJECTS | create | user=%s project_id=%s name=%s', current_user.username, project.id, project.name) log_action(ACTION_CREATE, 'Project', project.id, project.name, f'pm_id={pm_id}; active={project.active}') flash(f'Contract "{project.name}" created successfully.', 'success') return redirect(url_for('projects.view', project_id=project.id)) return render_template('projects/form.html', form=form, title='Create Contract') # ── View ────────────────────────────────────────────────────────────────────── @bp.route('/') @login_required @project_manager_required def view(project_id): project = db.session.get(Project, project_id) if project is None: abort(404) facilities = project.facilities.order_by(Facility.name).all() assignments = ( CustomerAssignment.query .filter_by(project_id=project_id) .join(User, CustomerAssignment.user_id == User.id) .order_by(User.username) .all() ) return render_template( 'projects/view.html', project=project, facilities=facilities, assignments=assignments, ) # ── Edit ────────────────────────────────────────────────────────────────────── @bp.route('//edit', methods=['GET', 'POST']) @login_required @supervisor_required def edit(project_id): project = db.session.get(Project, project_id) if project is None: abort(404) form = ProjectForm(obj=project) pm_users = User.query.filter_by(role='project_manager', active=True).order_by(User.username).all() form.project_manager_id.choices = [(0, '— None —')] + [(u.id, u.username) for u in pm_users] if form.validate_on_submit(): pm_id = form.project_manager_id.data or None project.name = form.name.data project.description = form.description.data project.project_manager_id = pm_id if pm_id else None project.active = form.active.data db.session.commit() logger.info('PROJECTS | edit | user=%s project_id=%s name=%s', current_user.username, project.id, project.name) log_action(ACTION_UPDATE, 'Project', project.id, project.name, f'pm_id={project.project_manager_id}; active={project.active}') flash(f'Contract "{project.name}" updated successfully.', 'success') return redirect(url_for('projects.view', project_id=project.id)) return render_template('projects/form.html', form=form, project=project, title='Edit Contract') # ── Delete ──────────────────────────────────────────────────────────────────── @bp.route('//delete', methods=['POST']) @login_required @admin_required def delete(project_id): project = db.session.get(Project, project_id) if project is None: abort(404) if project.facilities.count() > 0: flash(f'Cannot delete "{project.name}" — it has linked facilities. ' 'Reassign or remove those facilities first.', 'danger') return redirect(url_for('projects.view', project_id=project_id)) project_name = project.name project_id_snap = project.id db.session.delete(project) db.session.commit() logger.info('PROJECTS | delete | user=%s project_id=%s name=%s', current_user.username, project_id_snap, project_name) log_action(ACTION_DELETE, 'Project', project_id_snap, project_name) flash(f'Contract "{project_name}" deleted successfully.', 'success') return redirect(url_for('projects.index')) # ── Customer Assignment — Add ───────────────────────────────────────────────── @bp.route('//assignments/add', methods=['GET', 'POST']) @login_required @admin_required def add_assignment(project_id): project = db.session.get(Project, project_id) if project is None: abort(404) form = CustomerAssignmentForm() # Customer users only customers = User.query.filter_by(role='customer', active=True).order_by(User.username).all() form.user_id.choices = [(u.id, f'{u.username} ({u.email})') for u in customers] # Facilities belonging to this project project_facilities = project.facilities.order_by(Facility.name).all() form.facility_id.choices = [(0, '— All facilities in contract —')] + \ [(f.id, f.name) for f in project_facilities] if form.validate_on_submit(): facility_id = form.facility_id.data if form.facility_id.data else None # Guard against duplicate assignments existing = CustomerAssignment.query.filter_by( user_id=form.user_id.data, project_id=project_id, facility_id=facility_id, ).first() if existing: flash('This customer assignment already exists.', 'warning') return redirect(url_for('projects.view', project_id=project_id)) assignment = CustomerAssignment( user_id=form.user_id.data, project_id=project_id, facility_id=facility_id, ) db.session.add(assignment) db.session.commit() user = db.session.get(User, form.user_id.data) scope_label = f'facility_id={facility_id}' if facility_id else 'all facilities' logger.info('PROJECTS | assignment_add | admin=%s customer=%s project_id=%s scope=%s', current_user.username, user.username, project_id, scope_label) log_action(ACTION_CREATE, 'CustomerAssignment', assignment.id, f'{user.username} → {project.name}', f'scope={scope_label}') flash(f'Customer "{user.username}" assigned to contract "{project.name}".', 'success') return redirect(url_for('projects.view', project_id=project_id)) return render_template( 'projects/assignment_form.html', form=form, project=project, title='Add Customer Assignment', ) # ── Customer Assignment — Remove ────────────────────────────────────────────── @bp.route('/assignments//remove', methods=['POST']) @login_required @admin_required def remove_assignment(assignment_id): assignment = db.session.get(CustomerAssignment, assignment_id) if assignment is None: abort(404) project_id = assignment.project_id project = db.session.get(Project, project_id) if project is None: abort(404) user = db.session.get(User, assignment.user_id) username = user.username if user else f'user_id={assignment.user_id}' assignment_id_snap = assignment.id db.session.delete(assignment) db.session.commit() logger.info('PROJECTS | assignment_remove | admin=%s customer=%s project_id=%s', current_user.username, username, project_id) log_action(ACTION_DELETE, 'CustomerAssignment', assignment_id_snap, f'{username} → {project.name}') flash(f'Assignment for "{username}" removed.', 'success') return redirect(url_for('projects.view', project_id=project_id)) # ── Bulk Import — Excel template download ───────────────────────────────────── @bp.route('/import/template') @login_required @supervisor_required def import_template(): """Download a blank .xlsx showing the expected import format.""" import io from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment wb = Workbook() # ── Sheet 1: Contracts ──────────────────────────────────────────────── ws_c = wb.active ws_c.title = 'Contracts' hdr_fill = PatternFill('solid', start_color='1F4E79') hdr_font = Font(bold=True, color='FFFFFF') hdr_align = Alignment(horizontal='center', vertical='center') contract_headers = ['contract_name', 'description', 'active'] for col, h in enumerate(contract_headers, 1): cell = ws_c.cell(row=1, column=col, value=h) cell.font = hdr_font cell.fill = hdr_fill cell.alignment = hdr_align # Example rows ws_c.append(['Acme Corp - Downtown', 'Main office complex cleaning contract', 'yes']) ws_c.append(['Acme Corp - Warehouse', '', 'yes']) ws_c.column_dimensions['A'].width = 30 ws_c.column_dimensions['B'].width = 40 ws_c.column_dimensions['C'].width = 10 # ── Sheet 2: Facilities ─────────────────────────────────────────────── ws_f = wb.create_sheet('Facilities') facility_headers = [ 'contract_name', 'facility_name', 'address', 'contact_person', 'contact_phone', 'active', ] for col, h in enumerate(facility_headers, 1): cell = ws_f.cell(row=1, column=col, value=h) cell.font = hdr_font cell.fill = hdr_fill cell.alignment = hdr_align ws_f.append(['Acme Corp - Downtown', 'Tower A', '123 Main St, Suite 100', 'Jane Smith', '555-0101', 'yes']) ws_f.append(['Acme Corp - Downtown', 'Parking Garage', '123 Main St, Level B1', '', '', 'yes']) ws_f.append(['Acme Corp - Warehouse', 'Bay 1', '456 Industrial Blvd', 'Bob Jones', '555-0202', 'yes']) ws_f.column_dimensions['A'].width = 30 ws_f.column_dimensions['B'].width = 25 ws_f.column_dimensions['C'].width = 35 ws_f.column_dimensions['D'].width = 20 ws_f.column_dimensions['E'].width = 15 ws_f.column_dimensions['F'].width = 10 buf = io.BytesIO() wb.save(buf) buf.seek(0) return Response( buf.getvalue(), mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', headers={'Content-Disposition': 'attachment; filename="contract_facility_import_template.xlsx"'}, ) # ── Bulk Import — upload → preview → confirm ────────────────────────────────── @bp.route('/import', methods=['GET', 'POST']) @login_required @supervisor_required def bulk_import(): """Two-phase Excel import for Contracts (Projects) and Facilities. Phase 1 (GET / POST with file): Parse and validate the workbook, return a preview. No DB writes. Phase 2 (POST with confirmed=1): Write all validated rows to the database. Excel format (two sheets) -------------------------- Sheet "Contracts": contract_name*, description, active Sheet "Facilities": contract_name*, facility_name*, address, contact_person, contact_phone, active * required columns. - "active" column: any of yes/true/1 → True; blank defaults to True. - Contracts that already exist (by name, case-insensitive) are reused, not duplicated. - Facilities that already exist (same name within same contract) are skipped and reported. """ import io, json from flask import session as _session # ── Phase 2: commit ─────────────────────────────────────────────────── if request.method == 'POST' and request.form.get('confirmed') == '1': rows_json = request.form.get('rows_json', '[]') try: rows = json.loads(rows_json) except Exception: flash('Import session expired. Please re-upload the file.', 'danger') return redirect(url_for('projects.bulk_import')) created_contracts = 0 reused_contracts = 0 created_facilities = 0 skipped_facilities = 0 # Cache contracts created/found in this batch contract_cache = {} # lower-name → Project for row in rows: cname = row['contract_name'] cdesc = row.get('description') or None c_active = row.get('contract_active', True) fname = row.get('facility_name') or None # Get or create contract cache_key = cname.strip().lower() project = contract_cache.get(cache_key) if project is None: project = Project.query.filter( db.func.lower(Project.name) == cache_key ).first() if project is None: project = Project( name = cname.strip(), description = cdesc, active = c_active, ) db.session.add(project) db.session.flush() contract_cache[cache_key] = project created_contracts += 1 log_action(ACTION_CREATE, 'Project', project.id, project.name, f'active={c_active}; source=bulk_import') logger.info('BULK IMPORT | contract_created | name=%s by=%s', project.name, current_user.username) else: contract_cache[cache_key] = project reused_contracts += 1 # Create facility if present in this row if fname: faddr = row.get('address') or None fcp = row.get('contact_person') or None fphone = row.get('contact_phone') or None f_active = row.get('facility_active', True) existing_fac = Facility.query.filter( Facility.project_id == project.id, db.func.lower(Facility.name) == fname.strip().lower(), ).first() if existing_fac: skipped_facilities += 1 else: facility = Facility( name = fname.strip(), address = faddr, contact_person = fcp, contact_phone = fphone, active = f_active, project_id = project.id, ) db.session.add(facility) db.session.flush() created_facilities += 1 log_action(ACTION_CREATE, 'Facility', facility.id, facility.name, f'project_id={project.id}; source=bulk_import') logger.info('BULK IMPORT | facility_created | name=%s project_id=%s by=%s', facility.name, project.id, current_user.username) db.session.commit() logger.info( 'BULK IMPORT COMMITTED | by=%s | contracts_new=%s contracts_reused=%s ' 'facilities_new=%s facilities_skipped=%s', current_user.username, created_contracts, reused_contracts, created_facilities, skipped_facilities, ) parts = [] if created_contracts: parts.append(f'{created_contracts} contract(s) created') if reused_contracts: parts.append(f'{reused_contracts} existing contract(s) reused') if created_facilities: parts.append(f'{created_facilities} facilit{"y" if created_facilities == 1 else "ies"} created') if skipped_facilities: parts.append(f'{skipped_facilities} duplicate facilit{"y" if skipped_facilities == 1 else "ies"} skipped') flash('Import complete: ' + ', '.join(parts) + '.', 'success') return redirect(url_for('projects.index')) # ── Phase 1: parse and validate ─────────────────────────────────────── preview_rows = [] errors = [] raw_valid_rows = [] if request.method == 'POST': file = request.files.get('xlsx_file') if not file or not file.filename: flash('Please select an Excel file to upload.', 'warning') return render_template('projects/import.html') if not file.filename.lower().endswith(('.xlsx', '.xlsm')): flash('Only .xlsx / .xlsm files are accepted.', 'danger') return render_template('projects/import.html') try: from openpyxl import load_workbook wb = load_workbook(filename=io.BytesIO(file.stream.read()), data_only=True) except Exception as exc: flash(f'Could not open workbook: {exc}', 'danger') return render_template('projects/import.html') # ── Parse Contracts sheet ───────────────────────────────────────── if 'Contracts' not in wb.sheetnames: flash('Workbook is missing the "Contracts" sheet. Download the template and try again.', 'danger') return render_template('projects/import.html') ws_c = wb['Contracts'] c_rows = list(ws_c.iter_rows(values_only=True)) if not c_rows: flash('"Contracts" sheet is empty.', 'danger') return render_template('projects/import.html') c_headers = [str(h).strip().lower() if h else '' for h in c_rows[0]] if 'contract_name' not in c_headers: flash('"Contracts" sheet is missing required column "contract_name".', 'danger') return render_template('projects/import.html') def col(headers, name): try: return headers.index(name) except ValueError: return None c_name_idx = col(c_headers, 'contract_name') c_desc_idx = col(c_headers, 'description') c_active_idx = col(c_headers, 'active') # name → {description, active} for valid contracts found in sheet contract_sheet = {} # lower-name → dict contract_errors = [] for i, row in enumerate(c_rows[1:], start=2): cname = str(row[c_name_idx]).strip() if row[c_name_idx] is not None else '' if not cname or cname.lower() == 'none': contract_errors.append(f'Row {i}: contract_name is required') continue raw_active = row[c_active_idx] if c_active_idx is not None else None c_active = _parse_bool(raw_active, default=True) cdesc = str(row[c_desc_idx]).strip() if (c_desc_idx is not None and row[c_desc_idx] is not None) else '' contract_sheet[cname.lower()] = { 'contract_name': cname, 'description': cdesc or None, 'contract_active': c_active, } # ── Parse Facilities sheet ──────────────────────────────────────── facility_sheet_rows = [] if 'Facilities' in wb.sheetnames: ws_f = wb['Facilities'] f_rows = list(ws_f.iter_rows(values_only=True)) if f_rows: f_headers = [str(h).strip().lower() if h else '' for h in f_rows[0]] f_cname_idx = col(f_headers, 'contract_name') f_fname_idx = col(f_headers, 'facility_name') f_addr_idx = col(f_headers, 'address') f_cp_idx = col(f_headers, 'contact_person') f_phone_idx = col(f_headers, 'contact_phone') f_active_idx = col(f_headers, 'active') if f_cname_idx is None or f_fname_idx is None: flash('"Facilities" sheet is missing required columns "contract_name" or "facility_name".', 'danger') return render_template('projects/import.html') for i, row in enumerate(f_rows[1:], start=2): cname = str(row[f_cname_idx]).strip() if row[f_cname_idx] is not None else '' fname = str(row[f_fname_idx]).strip() if row[f_fname_idx] is not None else '' if not cname or cname.lower() == 'none': continue # skip blank rows silently if not fname or fname.lower() == 'none': continue raw_active = row[f_active_idx] if f_active_idx is not None else None facility_sheet_rows.append({ 'sheet_row': i, 'contract_name': cname, 'facility_name': fname, 'address': str(row[f_addr_idx]).strip() if (f_addr_idx is not None and row[f_addr_idx]) else '', 'contact_person': str(row[f_cp_idx]).strip() if (f_cp_idx is not None and row[f_cp_idx]) else '', 'contact_phone': str(row[f_phone_idx]).strip() if (f_phone_idx is not None and row[f_phone_idx]) else '', 'facility_active': _parse_bool(raw_active, default=True), }) # ── Build preview rows ──────────────────────────────────────────── # # Strategy: one preview row per (contract) from Contracts sheet, # then one preview row per facility from Facilities sheet. # Validation: facility's contract_name must appear in Contracts sheet. existing_projects = { p.name.strip().lower(): p for p in Project.query.all() } row_num = 0 # Contract rows for lower_name, cdata in contract_sheet.items(): row_num += 1 row_errors = [] if lower_name in existing_projects: status = 'exists' note = 'Contract already exists — will be reused' else: status = 'ok' note = '' preview_rows.append({ 'row': row_num, 'sheet': 'Contracts', 'contract_name': cdata['contract_name'], 'facility_name': '', 'note': note, 'status': status, 'errors': row_errors, }) if status != 'error': raw_valid_rows.append({ 'contract_name': cdata['contract_name'], 'description': cdata['description'], 'contract_active': cdata['contract_active'], 'facility_name': None, }) # Facility rows seen_facilities = set() # (lower_contract, lower_facility) within file for frow in facility_sheet_rows: row_num += 1 row_errors = [] lower_c = frow['contract_name'].lower() lower_f = frow['facility_name'].lower() if lower_c not in contract_sheet: row_errors.append( f'Contract "{frow["contract_name"]}" not found in the Contracts sheet' ) dup_key = (lower_c, lower_f) if dup_key in seen_facilities: row_errors.append('Duplicate facility name within this contract in the file') else: seen_facilities.add(dup_key) # Check DB for existing facility with same name in same contract db_conflict = False if not row_errors: proj = existing_projects.get(lower_c) if proj: db_conflict = Facility.query.filter( Facility.project_id == proj.id, db.func.lower(Facility.name) == lower_f, ).first() is not None if db_conflict: status = 'exists' note = 'Facility already exists in this contract — will be skipped' elif row_errors: status = 'error' note = '' else: status = 'ok' note = '' preview_rows.append({ 'row': frow['sheet_row'], 'sheet': 'Facilities', 'contract_name': frow['contract_name'], 'facility_name': frow['facility_name'], 'note': note, 'status': status, 'errors': row_errors, }) if status == 'ok': raw_valid_rows.append({ 'contract_name': frow['contract_name'], 'description': None, 'contract_active': True, 'facility_name': frow['facility_name'], 'address': frow['address'] or None, 'contact_person': frow['contact_person'] or None, 'contact_phone': frow['contact_phone'] or None, 'facility_active': frow['facility_active'], }) valid_count = sum(1 for r in preview_rows if r['status'] == 'ok') has_errors = any(r['status'] == 'error' for r in preview_rows) rows_json = json.dumps(raw_valid_rows) # Contract-sheet parse errors shown as flash for ce in contract_errors: flash(ce, 'warning') return render_template( 'projects/import.html', preview_rows = preview_rows, valid_count = valid_count, has_errors = has_errors, rows_json = rows_json, ) return render_template('projects/import.html') def _parse_bool(value, default=True): """Convert Excel cell value to Python bool for 'active' columns.""" if value is None: return default s = str(value).strip().lower() if s in ('yes', 'true', '1', 'y'): return True if s in ('no', 'false', '0', 'n'): return False return default