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 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
+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 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'])
+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><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>
+5
View File
@@ -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>
+2 -2
View File
@@ -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>
+7 -1
View File
@@ -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 %}
+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()]) 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()])
+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')