diff --git a/app/__init__.py b/app/__init__.py index 606d448..28df65f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -104,6 +104,7 @@ def create_app(config_name='default'): from app.routes import issues # Phase 3 from app.routes import notifications # Notification system from app.routes import audit # Audit Trail + from app.routes import projects # Phase 1/2 — Project management app.register_blueprint(auth.bp) app.register_blueprint(dashboard.bp) @@ -114,6 +115,7 @@ def create_app(config_name='default'): app.register_blueprint(issues.bp) app.register_blueprint(notifications.bp) app.register_blueprint(audit.bp) + app.register_blueprint(projects.bp) # ── Error handler: 413 Request Entity Too Large ─────────────────────── # Nginx can return 413 before Flask sees the request; this handler covers diff --git a/app/routes/facilities.py b/app/routes/facilities.py index b9e2f6e..e4e0eda 100644 --- a/app/routes/facilities.py +++ b/app/routes/facilities.py @@ -2,6 +2,7 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request 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 from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE @@ -19,23 +20,27 @@ def list_facilities(): @supervisor_required 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() log_action(ACTION_CREATE, 'Facility', facility.id, facility.name, - f'contact={facility.contact_person or ""}; active={facility.active}') + 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('/') @@ -51,20 +56,24 @@ def view_facility(facility_id): def edit_facility(facility_id): facility = Facility.query.get_or_404(facility_id) 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() log_action(ACTION_UPDATE, 'Facility', facility.id, facility.name, - f'active={facility.active}') + 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']) diff --git a/app/routes/projects.py b/app/routes/projects.py new file mode 100644 index 0000000..6b34bd2 --- /dev/null +++ b/app/routes/projects.py @@ -0,0 +1,224 @@ +""" +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 +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'Project "{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 Project') + + +# ── View ────────────────────────────────────────────────────────────────────── + +@bp.route('/') +@login_required +@project_manager_required +def view(project_id): + project = Project.query.get_or_404(project_id) + 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 = Project.query.get_or_404(project_id) + 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'Project "{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 Project') + + +# ── Delete ──────────────────────────────────────────────────────────────────── + +@bp.route('//delete', methods=['POST']) +@login_required +@admin_required +def delete(project_id): + project = Project.query.get_or_404(project_id) + + 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'Project "{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 = Project.query.get_or_404(project_id) + 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 project —')] + \ + [(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 = User.query.get(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 project "{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 = CustomerAssignment.query.get_or_404(assignment_id) + project_id = assignment.project_id + project = Project.query.get_or_404(project_id) + user = User.query.get(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)) diff --git a/app/templates/auth/users.html b/app/templates/auth/users.html index 946b431..c55fd80 100644 --- a/app/templates/auth/users.html +++ b/app/templates/auth/users.html @@ -34,8 +34,8 @@ {{ user.username }} {{ user.email }} - - {{ user.role|title }} + + {{ user.role.replace('_',' ')|title }} {{ user.created_at.strftime('%Y-%m-%d') }} diff --git a/app/templates/base.html b/app/templates/base.html index ba930b4..4bbc229 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -63,6 +63,11 @@ + {% if current_user.role in ['admin', 'supervisor', 'project_manager'] %} + + {% endif %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index f95a6d4..ac795bb 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -5,8 +5,8 @@

Welcome, {{ current_user.username }}!

- - {{ current_user.role|title }} + + {{ current_user.role.replace('_',' ')|title }}
diff --git a/app/templates/facilities/form.html b/app/templates/facilities/form.html index 023ffcc..08eee4d 100644 --- a/app/templates/facilities/form.html +++ b/app/templates/facilities/form.html @@ -33,7 +33,13 @@ {{ form.contact_phone(class="form-control") }} - + +
+ {{ form.project_id.label(class="form-label") }} + {{ form.project_id(class="form-select") }} +
Link this facility to a project for customer portal access.
+
+
{{ form.active(class="form-check-input") }} diff --git a/app/templates/projects/assignment_form.html b/app/templates/projects/assignment_form.html new file mode 100644 index 0000000..5ae8e4a --- /dev/null +++ b/app/templates/projects/assignment_form.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+

+ {{ title }} +

+ Project: {{ project.name }} +
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.user_id.label(class="form-label") }} + {{ form.user_id(class="form-select") }} + {% if form.user_id.errors %} +
+ {% for e in form.user_id.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
Only active users with the Customer role are listed.
+
+ +
+ {{ form.facility_id.label(class="form-label") }} + {{ form.facility_id(class="form-select") }} +
+ Select "All facilities in project" to grant access to every facility + within this project, or choose a specific facility to restrict access. +
+
+ +
+ + + Cancel + +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/projects/form.html b/app/templates/projects/form.html new file mode 100644 index 0000000..1850981 --- /dev/null +++ b/app/templates/projects/form.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+

{{ title }}

+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label(class="form-label") }} + {{ form.name(class="form-control") }} + {% if form.name.errors %} +
+ {% for e in form.name.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.description.label(class="form-label") }} + {{ form.description(class="form-control", rows=3) }} +
+ +
+ {{ form.project_manager_id.label(class="form-label") }} + {{ form.project_manager_id(class="form-select") }} +
Only users with the Project Manager role are listed.
+
+ +
+
+ {{ form.active(class="form-check-input") }} + {{ form.active.label(class="form-check-label") }} +
+
+ +
+ + + Cancel + +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/projects/list.html b/app/templates/projects/list.html new file mode 100644 index 0000000..75d0543 --- /dev/null +++ b/app/templates/projects/list.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% block title %}Projects{% endblock %} + +{% block content %} +
+
+

Projects

+
+ {% if current_user.role in ['admin', 'supervisor'] %} + + {% endif %} +
+ +
+ {% for project in projects %} +
+
+
+
+
+ + {{ project.name }} + +
+ {% if not project.active %} + Inactive + {% else %} + Active + {% endif %} +
+ + {% if project.description %} +

{{ project.description }}

+ {% endif %} + +
+ + {{ project.facilities.count() }} facilities + + {% if project.project_manager %} + + {{ project.project_manager.username }} + + {% endif %} +
+
+ +
+
+ {% else %} +
+
+ No projects have been created yet. +
+
+ {% endfor %} +
+{% endblock %} diff --git a/app/templates/projects/view.html b/app/templates/projects/view.html new file mode 100644 index 0000000..34b3f44 --- /dev/null +++ b/app/templates/projects/view.html @@ -0,0 +1,169 @@ +{% extends "base.html" %} +{% block title %}{{ project.name }}{% endblock %} + +{% block content %} +
+
+

+ {{ project.name }} + {% if not project.active %} + Inactive + {% endif %} +

+ {% if project.description %} +

{{ project.description }}

+ {% endif %} +
+
+ {% if current_user.role in ['admin', 'supervisor'] %} + + Edit + + {% endif %} + + All Projects + +
+
+ +
+ + {# ── Project Info ── #} +
+
+
+ Project Details +
+
+
+
Status
+
+ + {{ 'Active' if project.active else 'Inactive' }} + +
+
Project Manager
+
+ {{ project.project_manager.username if project.project_manager else '—' }} +
+
Created
+
{{ project.created_at.strftime('%Y-%m-%d') }}
+
Facilities
+
{{ facilities|length }}
+
+
+
+
+ + {# ── Facilities ── #} +
+
+
+ Facilities +
+
+ {% if facilities %} +
+ + + + + + + + + + + {% for f in facilities %} + + + + + + + {% endfor %} + +
NameAddressStatus
{{ f.name }}{{ f.address or '—' }} + + {{ 'Active' if f.active else 'Inactive' }} + + + + + +
+
+ {% else %} +
No facilities linked to this project yet.
+ {% endif %} +
+
+
+ + {# ── Customer Assignments ── #} + {% if current_user.role == 'admin' %} +
+
+
+ Customer Assignments + + Add Customer + +
+
+ {% if assignments %} +
+ + + + + + + + + + + + {% for a in assignments %} + + + + + + + + {% endfor %} + +
Customer UsernameEmailFacility ScopeAssigned
{{ a.user.username }}{{ a.user.email }} + {% if a.facility %} + {{ a.facility.name }} + {% else %} + All facilities + {% endif %} + {{ a.created_at.strftime('%Y-%m-%d') }} +
+ + +
+
+
+ {% else %} +
+ No customer users assigned yet. + Add one now. +
+ {% endif %} +
+
+
+ {% endif %} + +
+{% endblock %} diff --git a/app/utils/forms.py b/app/utils/forms.py index 7ca6881..fd1de86 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -94,6 +94,7 @@ class FacilityForm(FlaskForm): address = TextAreaField('Address', validators=[Optional()]) contact_person = StringField('Contact Person', validators=[Optional(), Length(max=100)]) contact_phone = StringField('Contact Phone', validators=[Optional(), Length(max=20)]) + project_id = SelectField('Project', coerce=int, validators=[Optional()]) active = BooleanField('Active', default=True) @@ -174,4 +175,17 @@ class IssueUpdateForm(FlaskForm): result_photos = FileField('Result Photos', validators=[ Optional(), FileAllowed(['jpg','jpeg','png','gif'], 'Images only.') - ]) \ No newline at end of file + ]) + +# ── Projects ───────────────────────────────────────────────────────────────── + +class ProjectForm(FlaskForm): + name = StringField('Project Name', validators=[DataRequired(), Length(max=255)]) + description = TextAreaField('Description', validators=[Optional()]) + project_manager_id = SelectField('Project Manager', coerce=int, validators=[Optional()]) + active = BooleanField('Active', default=True) + + +class CustomerAssignmentForm(FlaskForm): + user_id = SelectField('Customer User', coerce=int, validators=[DataRequired()]) + facility_id = SelectField('Facility Scope', coerce=int, validators=[Optional()]) diff --git a/app/utils/scope.py b/app/utils/scope.py new file mode 100644 index 0000000..ca03c4d --- /dev/null +++ b/app/utils/scope.py @@ -0,0 +1,72 @@ +""" +app/utils/scope.py +------------------ +Customer-scoping utility for the Janitorial QC portal. + +Provides a single entry-point — get_customer_scope(user) — that returns the +set of facility IDs a customer is authorised to view, derived from their +CustomerAssignment rows. + +Usage (inside any route that serves customer users): + + from app.utils.scope import get_customer_scope + + facility_ids = get_customer_scope(current_user) + inspections = Inspection.query.filter( + Inspection.facility_id.in_(facility_ids) + ).all() + +For non-customer roles the function returns None, signalling that no +facility-level scoping is required (full access applies). +""" + +import logging +from app.models.project import CustomerAssignment +from app.models.facility import Facility + +logger = logging.getLogger(__name__) + + +def get_customer_scope(user) -> list[int] | None: + """Return the list of facility IDs accessible to a customer user. + + Parameters + ---------- + user : User + The currently authenticated user. + + Returns + ------- + list[int] + Facility IDs the customer may access. May be empty if no assignments + exist yet — callers should treat an empty list as "no access". + None + Returned for non-customer roles, indicating unrestricted access. + """ + if user.role != 'customer': + return None # no scoping needed for internal staff + + assignments = CustomerAssignment.query.filter_by(user_id=user.id).all() + + facility_ids = set() + + for assignment in assignments: + if assignment.facility_id: + # Scoped to a specific facility + facility_ids.add(assignment.facility_id) + else: + # Scoped to an entire project — include all facilities in that project + project_facilities = ( + Facility.query + .filter_by(project_id=assignment.project_id, active=True) + .all() + ) + for f in project_facilities: + facility_ids.add(f.id) + + logger.debug( + 'SCOPE | customer_scope | user_id=%s username=%s facility_ids=%s', + user.id, user.username, sorted(facility_ids), + ) + + return sorted(facility_ids) diff --git a/migrations/phase1_projects_roles.py b/migrations/phase1_projects_roles.py new file mode 100644 index 0000000..c189aaf --- /dev/null +++ b/migrations/phase1_projects_roles.py @@ -0,0 +1,117 @@ +"""Phase 1: Add projects table, customer_assignments table, project_id on facilities, extend user role enum + +Revision ID: phase1_projects_roles +Revises: (set this to your current DB head before running) +Create Date: 2026-03-04 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# --- IMPORTANT: set down_revision to your live DB's current head --- +revision = 'phase1_projects_roles' +down_revision = '0003_add_user_active' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + existing_tables = inspector.get_table_names() + + # ── 1. Create `projects` table ───────────────────────────────────────── + if 'projects' not in existing_tables: + op.create_table( + 'projects', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('project_manager_id', sa.Integer(), nullable=True), + sa.Column('active', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['project_manager_id'], ['users.id'], name='fk_project_manager'), + sa.PrimaryKeyConstraint('id'), + ) + + # ── 2. Create `customer_assignments` table ───────────────────────────── + if 'customer_assignments' not in existing_tables: + op.create_table( + 'customer_assignments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('facility_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['facility_id'], ['facilities.id'], + name='fk_ca_facility', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], + name='fk_ca_project', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], + name='fk_ca_user', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'project_id', 'facility_id', + name='uq_customer_assignment'), + ) + op.create_index('ix_customer_assignments_user_id', + 'customer_assignments', ['user_id']) + op.create_index('ix_customer_assignments_project_id', + 'customer_assignments', ['project_id']) + op.create_index('ix_customer_assignments_facility_id', + 'customer_assignments', ['facility_id']) + + # ── 3. Add `project_id` column to `facilities` ───────────────────────── + existing_facility_cols = [c['name'] for c in inspector.get_columns('facilities')] + if 'project_id' not in existing_facility_cols: + op.add_column( + 'facilities', + sa.Column('project_id', sa.Integer(), nullable=True) + ) + op.create_foreign_key( + 'fk_facility_project', + 'facilities', 'projects', + ['project_id'], ['id'], + ondelete='SET NULL' + ) + op.create_index('ix_facilities_project_id', 'facilities', ['project_id']) + + # ── 4. Extend `users.role` Enum with new values ──────────────────────── + # MySQL requires ALTER COLUMN to modify an ENUM. + op.alter_column( + 'users', 'role', + existing_type=mysql.ENUM('admin', 'supervisor', 'inspector'), + type_=mysql.ENUM('admin', 'supervisor', 'inspector', 'project_manager', 'customer'), + existing_nullable=False, + nullable=False, + ) + + +def downgrade(): + # ── Reverse order of operations ──────────────────────────────────────── + + # 4. Revert users.role Enum + op.alter_column( + 'users', 'role', + existing_type=mysql.ENUM('admin', 'supervisor', 'inspector', 'project_manager', 'customer'), + type_=mysql.ENUM('admin', 'supervisor', 'inspector'), + existing_nullable=False, + nullable=False, + ) + + # 3. Remove project_id from facilities + bind = op.get_bind() + inspector = sa.inspect(bind) + existing_facility_cols = [c['name'] for c in inspector.get_columns('facilities')] + if 'project_id' in existing_facility_cols: + op.drop_constraint('fk_facility_project', 'facilities', type_='foreignkey') + op.drop_index('ix_facilities_project_id', table_name='facilities') + op.drop_column('facilities', 'project_id') + + # 2. Drop customer_assignments + existing_tables = inspector.get_table_names() + if 'customer_assignments' in existing_tables: + op.drop_table('customer_assignments') + + # 1. Drop projects + if 'projects' in existing_tables: + op.drop_table('projects')