Mar 04 2026: Implement customer's view functionalities - Phase 2

This commit is contained in:
2026-03-04 13:03:26 -05:00
parent d8475ba9c7
commit a58a0c8ded
14 changed files with 820 additions and 14 deletions
+2
View File
@@ -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
+17 -8
View File
@@ -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('/<int:facility_id>')
@@ -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('/<int:facility_id>/delete', methods=['POST'])
+224
View File
@@ -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))
+2 -2
View File
@@ -34,8 +34,8 @@
<td><strong>{{ user.username }}</strong></td>
<td>{{ user.email }}</td>
<td>
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'supervisor' %}warning{% else %}info{% endif %}">
{{ user.role|title }}
<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.replace('_',' ')|title }}
</span>
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
+5
View File
@@ -63,6 +63,11 @@
<li class="nav-item">
<a class="nav-link" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
</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">
<a class="nav-link" href="{{ url_for('templates.index') }}">Templates</a>
</li>
+2 -2
View File
@@ -5,8 +5,8 @@
<div class="row mb-3 align-items-center">
<div class="col">
<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">
{{ current_user.role|title }}
<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.replace('_',' ')|title }}
</span>
</div>
</div>
+7 -1
View File
@@ -33,7 +33,13 @@
{{ form.contact_phone(class="form-control") }}
</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="form-check">
{{ 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 %}
+56
View File
@@ -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 %}
+81
View File
@@ -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 %}
+169
View File
@@ -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
View File
@@ -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.')
])
])
# ── 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()])
+72
View File
@@ -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)
+117
View File
@@ -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')