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 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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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>{{ 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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()])
|
||||
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()])
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user