Mar 04 2026: Implement customer's view functionalities - Phase 2
This commit is contained in:
@@ -104,6 +104,7 @@ def create_app(config_name='default'):
|
|||||||
from app.routes import issues # Phase 3
|
from app.routes import issues # Phase 3
|
||||||
from app.routes import notifications # Notification system
|
from app.routes import notifications # Notification system
|
||||||
from app.routes import audit # Audit Trail
|
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(auth.bp)
|
||||||
app.register_blueprint(dashboard.bp)
|
app.register_blueprint(dashboard.bp)
|
||||||
@@ -114,6 +115,7 @@ def create_app(config_name='default'):
|
|||||||
app.register_blueprint(issues.bp)
|
app.register_blueprint(issues.bp)
|
||||||
app.register_blueprint(notifications.bp)
|
app.register_blueprint(notifications.bp)
|
||||||
app.register_blueprint(audit.bp)
|
app.register_blueprint(audit.bp)
|
||||||
|
app.register_blueprint(projects.bp)
|
||||||
|
|
||||||
# ── Error handler: 413 Request Entity Too Large ───────────────────────
|
# ── Error handler: 413 Request Entity Too Large ───────────────────────
|
||||||
# Nginx can return 413 before Flask sees the request; this handler covers
|
# Nginx can return 413 before Flask sees the request; this handler covers
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request
|
|||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.facility import Facility, Area
|
from app.models.facility import Facility, Area
|
||||||
|
from app.models.project import Project
|
||||||
from app.utils.forms import FacilityForm, AreaForm
|
from app.utils.forms import FacilityForm, AreaForm
|
||||||
from app.utils.decorators import supervisor_required, admin_required
|
from app.utils.decorators import supervisor_required, admin_required
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||||
@@ -19,23 +20,27 @@ def list_facilities():
|
|||||||
@supervisor_required
|
@supervisor_required
|
||||||
def create_facility():
|
def create_facility():
|
||||||
form = FacilityForm()
|
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():
|
if form.validate_on_submit():
|
||||||
|
project_id = form.project_id.data if form.project_id.data else None
|
||||||
facility = Facility(
|
facility = Facility(
|
||||||
name=form.name.data,
|
name=form.name.data,
|
||||||
address=form.address.data,
|
address=form.address.data,
|
||||||
contact_person=form.contact_person.data,
|
contact_person=form.contact_person.data,
|
||||||
contact_phone=form.contact_phone.data,
|
contact_phone=form.contact_phone.data,
|
||||||
|
project_id=project_id if project_id else None,
|
||||||
active=form.active.data
|
active=form.active.data
|
||||||
)
|
)
|
||||||
|
|
||||||
db.session.add(facility)
|
db.session.add(facility)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
log_action(ACTION_CREATE, 'Facility', facility.id, facility.name,
|
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')
|
flash(f'Facility "{facility.name}" created successfully.', 'success')
|
||||||
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
||||||
|
|
||||||
return render_template('facilities/form.html', form=form, title='Create Facility')
|
return render_template('facilities/form.html', form=form, title='Create Facility')
|
||||||
|
|
||||||
@bp.route('/<int:facility_id>')
|
@bp.route('/<int:facility_id>')
|
||||||
@@ -51,20 +56,24 @@ def view_facility(facility_id):
|
|||||||
def edit_facility(facility_id):
|
def edit_facility(facility_id):
|
||||||
facility = Facility.query.get_or_404(facility_id)
|
facility = Facility.query.get_or_404(facility_id)
|
||||||
form = FacilityForm(obj=facility)
|
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():
|
if form.validate_on_submit():
|
||||||
|
project_id = form.project_id.data if form.project_id.data else None
|
||||||
facility.name = form.name.data
|
facility.name = form.name.data
|
||||||
facility.address = form.address.data
|
facility.address = form.address.data
|
||||||
facility.contact_person = form.contact_person.data
|
facility.contact_person = form.contact_person.data
|
||||||
facility.contact_phone = form.contact_phone.data
|
facility.contact_phone = form.contact_phone.data
|
||||||
|
facility.project_id = project_id if project_id else None
|
||||||
facility.active = form.active.data
|
facility.active = form.active.data
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
log_action(ACTION_UPDATE, 'Facility', facility.id, facility.name,
|
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')
|
flash(f'Facility "{facility.name}" updated successfully.', 'success')
|
||||||
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
return redirect(url_for('facilities.view_facility', facility_id=facility.id))
|
||||||
|
|
||||||
return render_template('facilities/form.html', form=form, facility=facility, title='Edit Facility')
|
return render_template('facilities/form.html', form=form, facility=facility, title='Edit Facility')
|
||||||
|
|
||||||
@bp.route('/<int:facility_id>/delete', methods=['POST'])
|
@bp.route('/<int:facility_id>/delete', methods=['POST'])
|
||||||
|
|||||||
@@ -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('/<int:project_id>')
|
||||||
|
@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('/<int:project_id>/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('/<int:project_id>/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('/<int:project_id>/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/<int:assignment_id>/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))
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
<td><strong>{{ user.username }}</strong></td>
|
<td><strong>{{ user.username }}</strong></td>
|
||||||
<td>{{ user.email }}</td>
|
<td>{{ user.email }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'supervisor' %}warning{% else %}info{% endif %}">
|
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'supervisor' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
|
||||||
{{ user.role|title }}
|
{{ user.role.replace('_',' ')|title }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
|||||||
@@ -63,6 +63,11 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
|
<a class="nav-link" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if current_user.role in ['admin', 'supervisor', 'project_manager'] %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('projects.index') }}">Projects</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('templates.index') }}">Templates</a>
|
<a class="nav-link" href="{{ url_for('templates.index') }}">Templates</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<div class="row mb-3 align-items-center">
|
<div class="row mb-3 align-items-center">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2 class="mb-0">Welcome, {{ current_user.username }}!</h2>
|
<h2 class="mb-0">Welcome, {{ current_user.username }}!</h2>
|
||||||
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'supervisor' %}warning{% else %}info{% endif %} mt-1">
|
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'supervisor' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
|
||||||
{{ current_user.role|title }}
|
{{ current_user.role.replace('_',' ')|title }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,13 @@
|
|||||||
{{ form.contact_phone(class="form-control") }}
|
{{ form.contact_phone(class="form-control") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.project_id.label(class="form-label") }}
|
||||||
|
{{ form.project_id(class="form-select") }}
|
||||||
|
<div class="form-text">Link this facility to a project for customer portal access.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
{{ form.active(class="form-check-input") }}
|
{{ form.active(class="form-check-input") }}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 offset-md-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="bi bi-person-plus"></i> {{ title }}
|
||||||
|
</h4>
|
||||||
|
<small class="text-white-50">Project: {{ project.name }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.user_id.label(class="form-label") }}
|
||||||
|
{{ form.user_id(class="form-select") }}
|
||||||
|
{% if form.user_id.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{% for e in form.user_id.errors %}{{ e }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">Only active users with the Customer role are listed.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
{{ form.facility_id.label(class="form-label") }}
|
||||||
|
{{ form.facility_id(class="form-select") }}
|
||||||
|
<div class="form-text">
|
||||||
|
Select "All facilities in project" to grant access to every facility
|
||||||
|
within this project, or choose a specific facility to restrict access.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-person-check"></i> Assign Customer
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('projects.view', project_id=project.id) }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h4 class="mb-0">{{ title }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.name.label(class="form-label") }}
|
||||||
|
{{ form.name(class="form-control") }}
|
||||||
|
{% if form.name.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{% for e in form.name.errors %}{{ e }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.description.label(class="form-label") }}
|
||||||
|
{{ form.description(class="form-control", rows=3) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.project_manager_id.label(class="form-label") }}
|
||||||
|
{{ form.project_manager_id(class="form-select") }}
|
||||||
|
<div class="form-text">Only users with the Project Manager role are listed.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.active(class="form-check-input") }}
|
||||||
|
{{ form.active.label(class="form-check-label") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-save"></i> Save Project
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('projects.index') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Projects{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4 align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2><i class="bi bi-folder2-open"></i> Projects</h2>
|
||||||
|
</div>
|
||||||
|
{% if current_user.role in ['admin', 'supervisor'] %}
|
||||||
|
<div class="col-auto">
|
||||||
|
<a href="{{ url_for('projects.create') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> New Project
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{% for project in projects %}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<a href="{{ url_for('projects.view', project_id=project.id) }}" class="text-decoration-none">
|
||||||
|
{{ project.name }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
{% if not project.active %}
|
||||||
|
<span class="badge bg-secondary ms-2">Inactive</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success ms-2">Active</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if project.description %}
|
||||||
|
<p class="card-text text-muted small">{{ project.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-3 d-flex gap-3">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-building"></i> {{ project.facilities.count() }} facilities
|
||||||
|
</small>
|
||||||
|
{% if project.project_manager %}
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-person-badge"></i> {{ project.project_manager.username }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent d-flex gap-2">
|
||||||
|
<a href="{{ url_for('projects.view', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> View
|
||||||
|
</a>
|
||||||
|
{% if current_user.role in ['admin', 'supervisor'] %}
|
||||||
|
<a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil"></i> Edit
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.role == 'admin' %}
|
||||||
|
<form method="POST" action="{{ url_for('projects.delete', project_id=project.id) }}"
|
||||||
|
class="ms-auto d-inline"
|
||||||
|
onsubmit="return confirm('Delete project \'{{ project.name }}\'? This cannot be undone.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> No projects have been created yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ project.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4 align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2>
|
||||||
|
<i class="bi bi-folder2-open"></i> {{ project.name }}
|
||||||
|
{% if not project.active %}
|
||||||
|
<span class="badge bg-secondary ms-2 fs-6">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
{% if project.description %}
|
||||||
|
<p class="text-muted">{{ project.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-flex gap-2">
|
||||||
|
{% if current_user.role in ['admin', 'supervisor'] %}
|
||||||
|
<a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil"></i> Edit
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('projects.index') }}" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-arrow-left"></i> All Projects
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
|
||||||
|
{# ── Project Info ── #}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header bg-light fw-semibold">
|
||||||
|
<i class="bi bi-info-circle"></i> Project Details
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-5 text-muted small">Status</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
<span class="badge bg-{{ 'success' if project.active else 'secondary' }}">
|
||||||
|
{{ 'Active' if project.active else 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<dt class="col-5 text-muted small">Project Manager</dt>
|
||||||
|
<dd class="col-7 small">
|
||||||
|
{{ project.project_manager.username if project.project_manager else '—' }}
|
||||||
|
</dd>
|
||||||
|
<dt class="col-5 text-muted small">Created</dt>
|
||||||
|
<dd class="col-7 small">{{ project.created_at.strftime('%Y-%m-%d') }}</dd>
|
||||||
|
<dt class="col-5 text-muted small">Facilities</dt>
|
||||||
|
<dd class="col-7 small">{{ facilities|length }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Facilities ── #}
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-semibold"><i class="bi bi-building"></i> Facilities</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if facilities %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Address</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in facilities %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ f.name }}</strong></td>
|
||||||
|
<td class="text-muted small">{{ f.address or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'success' if f.active else 'secondary' }}">
|
||||||
|
{{ 'Active' if f.active else 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('facilities.view_facility', facility_id=f.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-3 text-muted small">No facilities linked to this project yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Customer Assignments ── #}
|
||||||
|
{% if current_user.role == 'admin' %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-semibold"><i class="bi bi-people"></i> Customer Assignments</span>
|
||||||
|
<a href="{{ url_for('projects.add_assignment', project_id=project.id) }}"
|
||||||
|
class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-person-plus"></i> Add Customer
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if assignments %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Customer Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Facility Scope</th>
|
||||||
|
<th>Assigned</th>
|
||||||
|
<th width="80"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in assignments %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ a.user.username }}</strong></td>
|
||||||
|
<td class="text-muted small">{{ a.user.email }}</td>
|
||||||
|
<td>
|
||||||
|
{% if a.facility %}
|
||||||
|
<span class="badge bg-info text-dark">{{ a.facility.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">All facilities</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">{{ a.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('projects.remove_assignment', assignment_id=a.id) }}"
|
||||||
|
onsubmit="return confirm('Remove assignment for {{ a.user.username }}?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||||
|
title="Remove assignment">
|
||||||
|
<i class="bi bi-person-dash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-3 text-muted small">
|
||||||
|
No customer users assigned yet.
|
||||||
|
<a href="{{ url_for('projects.add_assignment', project_id=project.id) }}">Add one now.</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+15
-1
@@ -94,6 +94,7 @@ class FacilityForm(FlaskForm):
|
|||||||
address = TextAreaField('Address', validators=[Optional()])
|
address = TextAreaField('Address', validators=[Optional()])
|
||||||
contact_person = StringField('Contact Person', validators=[Optional(), Length(max=100)])
|
contact_person = StringField('Contact Person', validators=[Optional(), Length(max=100)])
|
||||||
contact_phone = StringField('Contact Phone', validators=[Optional(), Length(max=20)])
|
contact_phone = StringField('Contact Phone', validators=[Optional(), Length(max=20)])
|
||||||
|
project_id = SelectField('Project', coerce=int, validators=[Optional()])
|
||||||
active = BooleanField('Active', default=True)
|
active = BooleanField('Active', default=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -174,4 +175,17 @@ class IssueUpdateForm(FlaskForm):
|
|||||||
result_photos = FileField('Result Photos', validators=[
|
result_photos = FileField('Result Photos', validators=[
|
||||||
Optional(),
|
Optional(),
|
||||||
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
|
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# ── 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()])
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user