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

This commit is contained in:
2026-03-04 13:35:12 -05:00
parent 59f46f2110
commit 736cb04bcb
7 changed files with 763 additions and 0 deletions
+2
View File
@@ -105,6 +105,7 @@ def create_app(config_name='default'):
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 from app.routes import projects # Phase 1/2 — Project management
from app.routes import customers # Phase 5 — Customer management
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
app.register_blueprint(dashboard.bp) app.register_blueprint(dashboard.bp)
@@ -116,6 +117,7 @@ def create_app(config_name='default'):
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) app.register_blueprint(projects.bp)
app.register_blueprint(customers.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
+268
View File
@@ -0,0 +1,268 @@
"""
app/routes/customers.py
-----------------------
Customer Management — admin-only consolidated view.
Provides a single screen to:
- List all customer-role users with their assignment summary
- Create a new customer account
- Edit an existing customer (username / email / password / active)
- Manage assignments for a customer (add / remove)
- Quick-disable / enable a customer account
- View a customer's scoped facility access at a glance
"""
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.user import User
from app.models.project import Project, CustomerAssignment
from app.models.facility import Facility
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm
from app.utils.decorators import admin_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.scope import get_customer_scope
logger = logging.getLogger(__name__)
bp = Blueprint('customers', __name__, url_prefix='/customers')
# ── List ──────────────────────────────────────────────────────────────────────
@bp.route('/')
@login_required
@admin_required
def index():
"""Consolidated customer management dashboard."""
customers = (
User.query
.filter_by(role='customer')
.order_by(User.username)
.all()
)
# Pre-compute assignment summary per customer to avoid N+1 in template
assignment_map = {} # user_id → list[CustomerAssignment]
scope_map = {} # user_id → list[int] facility IDs
for customer in customers:
assignments = CustomerAssignment.query.filter_by(user_id=customer.id).all()
assignment_map[customer.id] = assignments
scope_map[customer.id] = get_customer_scope(customer) or []
# All active projects for the assignment modal
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
return render_template(
'customers/index.html',
customers = customers,
assignment_map = assignment_map,
scope_map = scope_map,
projects = projects,
)
# ── Create customer ───────────────────────────────────────────────────────────
@bp.route('/new', methods=['GET', 'POST'])
@login_required
@admin_required
def create():
form = CustomerUserForm()
if form.validate_on_submit():
user = User(
username = form.username.data,
email = form.email.data,
role = 'customer',
active = True,
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
logger.info('CUSTOMERS | create | admin=%s new_customer=%s email=%s',
current_user.username, user.username, user.email)
log_action(ACTION_CREATE, 'User', user.id, user.username,
f'role=customer; email={user.email}; created_via=customer_mgmt')
flash(f'Customer account "{user.username}" created successfully.', 'success')
return redirect(url_for('customers.manage', customer_id=user.id))
return render_template('customers/form.html', form=form, title='Create Customer Account')
# ── Edit customer ─────────────────────────────────────────────────────────────
@bp.route('/<int:customer_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def edit(customer_id):
customer = User.query.get_or_404(customer_id)
if customer.role != 'customer':
flash('This page is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
form = CustomerUserForm(user=customer, obj=customer)
if form.validate_on_submit():
customer.username = form.username.data
customer.email = form.email.data
if form.password.data:
customer.set_password(form.password.data)
db.session.commit()
logger.info('CUSTOMERS | edit | admin=%s customer_id=%s username=%s',
current_user.username, customer.id, customer.username)
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
f'email={customer.email}; updated_via=customer_mgmt')
flash(f'Customer "{customer.username}" updated successfully.', 'success')
return redirect(url_for('customers.manage', customer_id=customer.id))
return render_template('customers/form.html', form=form, customer=customer,
title='Edit Customer Account')
# ── Customer detail / assignment management ───────────────────────────────────
@bp.route('/<int:customer_id>')
@login_required
@admin_required
def manage(customer_id):
"""Single-customer detail page: profile + all assignments."""
customer = User.query.get_or_404(customer_id)
if customer.role != 'customer':
flash('This page is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
facility_ids = get_customer_scope(customer) or []
facilities = (
Facility.query
.filter(Facility.id.in_(facility_ids), Facility.active == True)
.order_by(Facility.name)
.all()
) if facility_ids else []
# Assignment form (populated here so it can be rendered inline)
aform = CustomerAssignmentForm()
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
aform.user_id.choices = [(customer.id, customer.username)]
aform.facility_id.choices = [(0, '— All facilities in project —')]
return render_template(
'customers/manage.html',
customer = customer,
assignments = assignments,
facilities = facilities,
aform = aform,
projects = projects,
)
# ── Add assignment (from customer detail page) ────────────────────────────────
@bp.route('/<int:customer_id>/assignments/add', methods=['POST'])
@login_required
@admin_required
def add_assignment(customer_id):
customer = User.query.get_or_404(customer_id)
if customer.role != 'customer':
flash('Assignments are only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
project_id = request.form.get('project_id', type=int)
facility_id = request.form.get('facility_id', type=int) or None
if not project_id:
flash('Please select a project.', 'warning')
return redirect(url_for('customers.manage', customer_id=customer_id))
project = Project.query.get_or_404(project_id)
# Guard: duplicate assignment
existing = CustomerAssignment.query.filter_by(
user_id = customer_id,
project_id = project_id,
facility_id = facility_id,
).first()
if existing:
flash('That assignment already exists.', 'warning')
return redirect(url_for('customers.manage', customer_id=customer_id))
assignment = CustomerAssignment(
user_id = customer_id,
project_id = project_id,
facility_id = facility_id,
)
db.session.add(assignment)
db.session.commit()
scope_label = f'facility_id={facility_id}' if facility_id else 'all facilities'
logger.info('CUSTOMERS | assignment_add | admin=%s customer=%s project_id=%s scope=%s',
current_user.username, customer.username, project_id, scope_label)
log_action(ACTION_CREATE, 'CustomerAssignment', assignment.id,
f'{customer.username}{project.name}',
f'scope={scope_label}')
flash(f'Assignment added: "{customer.username}""{project.name}".', 'success')
return redirect(url_for('customers.manage', customer_id=customer_id))
# ── Remove assignment ─────────────────────────────────────────────────────────
@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)
customer_id = assignment.user_id
customer = User.query.get(customer_id)
project = Project.query.get(assignment.project_id)
username = customer.username if customer else f'user_id={customer_id}'
project_name = project.name if project else f'project_id={assignment.project_id}'
snap_id = assignment.id
db.session.delete(assignment)
db.session.commit()
logger.info('CUSTOMERS | assignment_remove | admin=%s customer=%s project=%s',
current_user.username, username, project_name)
log_action(ACTION_DELETE, 'CustomerAssignment', snap_id,
f'{username}{project_name}')
flash(f'Assignment removed for "{username}".', 'success')
return redirect(url_for('customers.manage', customer_id=customer_id))
# ── Toggle active ─────────────────────────────────────────────────────────────
@bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
@login_required
@admin_required
def toggle_active(customer_id):
customer = User.query.get_or_404(customer_id)
if customer.role != 'customer':
flash('This action is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
customer.active = not customer.active
db.session.commit()
label = 'enabled' if customer.active else 'disabled'
logger.info('CUSTOMERS | toggle_active | admin=%s customer=%s action=%s',
current_user.username, customer.username, label)
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
f'account {label} via customer_mgmt by {current_user.username}')
flash(f'Customer "{customer.username}" has been {label}.', 'success')
return redirect(request.referrer or url_for('customers.index'))
# ── AJAX: facilities for a project (used by add-assignment form) ──────────────
@bp.route('/facilities-for-project/<int:project_id>')
@login_required
@admin_required
def facilities_for_project(project_id):
from flask import jsonify
project = Project.query.get_or_404(project_id)
facilities = project.facilities.filter_by(active=True).order_by(Facility.name).all()
return jsonify([{'id': f.id, 'name': f.name} for f in facilities])
+3
View File
@@ -86,6 +86,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a> <a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('customers.index') }}">Customers</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a> <a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a>
</li> </li>
+110
View File
@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-7 offset-md-2">
{% if customer %}
<div class="card shadow-sm mb-3">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<span class="fw-semibold me-2">Account Status:</span>
<span class="badge fs-6 bg-{{ 'success' if customer.active else 'secondary' }}">
{{ 'Active' if customer.active else 'Disabled' }}
</span>
<div class="form-text mt-1">
{% if customer.active %}
Disabling prevents the customer from logging in immediately.
{% else %}
This account is currently disabled — the customer cannot log in.
{% endif %}
</div>
</div>
<form method="POST" action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }} me-1"></i>
{{ 'Disable Account' if customer.active else 'Enable Account' }}
</button>
</form>
</div>
</div>
{% endif %}
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0"><i class="bi bi-person-badge me-2"></i>{{ title }}</h4>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-6 mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control") }}
{% if form.username.errors %}
<div class="text-danger small mt-1">
{% for e in form.username.errors %}{{ e }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% if form.email.errors %}
<div class="text-danger small mt-1">
{% for e in form.email.errors %}{{ e }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control",
placeholder="Leave blank to keep current" if customer else "Min. 8 characters") }}
{% if form.password.errors %}
<div class="text-danger small mt-1">
{% for e in form.password.errors %}{{ e }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
{{ form.confirm_password.label(class="form-label") }}
{{ form.confirm_password(class="form-control") }}
{% if form.confirm_password.errors %}
<div class="text-danger small mt-1">
{% for e in form.confirm_password.errors %}{{ e }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="d-flex gap-2 mt-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>
{{ 'Save Changes' if customer else 'Create Customer' }}
</button>
{% if customer %}
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
class="btn btn-secondary">
<i class="bi bi-x-circle me-1"></i> Cancel
</a>
{% else %}
<a href="{{ url_for('customers.index') }}" class="btn btn-secondary">
<i class="bi bi-x-circle me-1"></i> Cancel
</a>
{% endif %}
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+118
View File
@@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}Customer Management{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<h2><i class="bi bi-person-badge"></i> Customer Management</h2>
<p class="text-muted mb-0">Manage portal access for all customer accounts.</p>
</div>
<div class="col-auto">
<a href="{{ url_for('customers.create') }}" class="btn btn-primary">
<i class="bi bi-person-plus"></i> New Customer
</a>
</div>
</div>
{% if customers %}
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Username</th>
<th>Email</th>
<th>Status</th>
<th>Assigned Projects</th>
<th>Accessible Facilities</th>
<th>Created</th>
<th width="160"></th>
</tr>
</thead>
<tbody>
{% for customer in customers %}
{% set assignments = assignment_map[customer.id] %}
{% set facility_ids = scope_map[customer.id] %}
<tr class="{{ 'table-secondary text-muted' if not customer.active else '' }}">
<td>
<strong>
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
class="text-decoration-none">
{{ customer.username }}
</a>
</strong>
</td>
<td class="small text-muted">{{ customer.email }}</td>
<td>
{% if customer.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Disabled</span>
{% endif %}
</td>
<td>
{% if assignments %}
{% set project_names = assignments | map(attribute='project') | map(attribute='name') | unique | list %}
{% for pname in project_names %}
<span class="badge bg-primary me-1">{{ pname }}</span>
{% endfor %}
{% else %}
<span class="text-muted small">— None —</span>
{% endif %}
</td>
<td>
{% if facility_ids %}
<span class="badge bg-info text-dark">{{ facility_ids|length }} facilit{{ 'y' if facility_ids|length == 1 else 'ies' }}</span>
{% else %}
<span class="text-muted small">— None —</span>
{% endif %}
</td>
<td class="small text-muted">{{ customer.created_at.strftime('%Y-%m-%d') }}</td>
<td class="text-end">
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
class="btn btn-sm btn-outline-primary" title="Manage">
<i class="bi bi-gear"></i>
</a>
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form method="POST"
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
title="{{ 'Disable' if customer.active else 'Enable' }}"
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }}"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ── Summary footer ── #}
<div class="mt-3 text-muted small">
{{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total
· {{ customers|selectattr('active')|list|length }} active
</div>
{% else %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-person-badge fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-3">No customer accounts have been created yet.</p>
<a href="{{ url_for('customers.create') }}" class="btn btn-primary">
<i class="bi bi-person-plus"></i> Create First Customer
</a>
</div>
</div>
{% endif %}
{% endblock %}
+228
View File
@@ -0,0 +1,228 @@
{% extends "base.html" %}
{% block title %}{{ customer.username }} — Customer Portal{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<h2>
<i class="bi bi-person-badge"></i> {{ customer.username }}
{% if not customer.active %}
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
{% else %}
<span class="badge bg-success ms-2 fs-6">Active</span>
{% endif %}
</h2>
<p class="text-muted mb-0 small">{{ customer.email }}</p>
</div>
<div class="col-auto d-flex gap-2">
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil"></i> Edit Account
</a>
<form method="POST"
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }} me-1"></i>
{{ 'Disable' if customer.active else 'Enable' }}
</button>
</form>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-left"></i> All Customers
</a>
</div>
</div>
<div class="row g-4">
{# ── Left column: account info + scoped facilities ── #}
<div class="col-md-4">
<div class="card shadow-sm mb-3">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-person-circle me-1"></i> Account Details
</div>
<div class="card-body">
<dl class="row mb-0 small">
<dt class="col-5 text-muted">Username</dt>
<dd class="col-7">{{ customer.username }}</dd>
<dt class="col-5 text-muted">Email</dt>
<dd class="col-7">{{ customer.email }}</dd>
<dt class="col-5 text-muted">Status</dt>
<dd class="col-7">
<span class="badge bg-{{ 'success' if customer.active else 'secondary' }}">
{{ 'Active' if customer.active else 'Disabled' }}
</span>
</dd>
<dt class="col-5 text-muted">Created</dt>
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
<dt class="col-5 text-muted">Assignments</dt>
<dd class="col-7">{{ assignments|length }}</dd>
<dt class="col-5 text-muted">Facilities</dt>
<dd class="col-7">{{ facilities|length }}</dd>
</dl>
</div>
</div>
{# ── Scoped facilities ── #}
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-building me-1"></i> Accessible Facilities
</div>
{% if facilities %}
<div class="card-body p-0">
<ul class="list-group list-group-flush">
{% for f in facilities %}
<li class="list-group-item d-flex justify-content-between align-items-center
py-2 px-3 small">
<span>
<i class="bi bi-building text-muted me-1"></i>{{ f.name }}
</span>
{% if f.project %}
<span class="badge bg-primary" style="font-size:.65rem;">{{ f.project.name }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% else %}
<div class="card-body text-muted small">
No facilities accessible yet — add an assignment below.
</div>
{% endif %}
</div>
</div>
{# ── Right column: assignments ── #}
<div class="col-md-8">
{# ── Current assignments table ── #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
<span><i class="bi bi-diagram-3 me-1"></i> Project Assignments</span>
</div>
{% if assignments %}
<div class="card-body p-0">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Project</th>
<th>Facility Scope</th>
<th>Assigned</th>
<th width="60"></th>
</tr>
</thead>
<tbody>
{% for a in assignments %}
<tr>
<td class="small">
<a href="{{ url_for('projects.view', project_id=a.project_id) }}"
class="text-decoration-none">
{{ a.project.name }}
</a>
</td>
<td>
{% if a.facility %}
<span class="badge bg-info text-dark small">{{ a.facility.name }}</span>
{% else %}
<span class="badge bg-secondary small">All facilities</span>
{% endif %}
</td>
<td class="text-muted small">{{ a.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<form method="POST"
action="{{ url_for('customers.remove_assignment', assignment_id=a.id) }}"
onsubmit="return confirm('Remove this assignment?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Remove">
<i class="bi bi-x-lg"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-muted small">No assignments yet.</div>
{% endif %}
</div>
{# ── Add assignment form ── #}
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-plus-circle me-1"></i> Add Assignment
</div>
<div class="card-body">
<form method="POST"
action="{{ url_for('customers.add_assignment', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row g-3 align-items-end">
<div class="col-md-5">
<label class="form-label small fw-semibold">Project</label>
<select name="project_id" id="proj-select" class="form-select form-select-sm"
required>
<option value="">— Select project —</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-5">
<label class="form-label small fw-semibold">Facility Scope</label>
<select name="facility_id" id="fac-select" class="form-select form-select-sm">
<option value="">— All facilities in project —</option>
</select>
<div class="form-text" style="font-size:.72rem;">
Leave blank to grant access to all facilities in the project.
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-plus-circle me-1"></i> Add
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
const projSelect = document.getElementById('proj-select');
const facSelect = document.getElementById('fac-select');
projSelect.addEventListener('change', function () {
const projectId = this.value;
facSelect.innerHTML = '<option value="">— All facilities in project —</option>';
if (!projectId) return;
fetch('/customers/facilities-for-project/' + projectId, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
data.forEach(function (f) {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name;
facSelect.appendChild(opt);
});
})
.catch(function () {});
});
}());
</script>
{% endblock %}
+34
View File
@@ -189,3 +189,37 @@ class ProjectForm(FlaskForm):
class CustomerAssignmentForm(FlaskForm): class CustomerAssignmentForm(FlaskForm):
user_id = SelectField('Customer User', coerce=int, validators=[DataRequired()]) user_id = SelectField('Customer User', coerce=int, validators=[DataRequired()])
facility_id = SelectField('Facility Scope', coerce=int, validators=[Optional()]) facility_id = SelectField('Facility Scope', coerce=int, validators=[Optional()])
class CustomerUserForm(FlaskForm):
"""Create / edit a customer-role user account.
Used exclusively in the Customer Management UI.
Password is required on create; optional on edit.
"""
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
password = PasswordField('Password', validators=[Optional(), Length(min=8)])
confirm_password = PasswordField('Confirm Password',
validators=[Optional(), EqualTo('password',
message='Passwords must match.')])
def __init__(self, user=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user = user # existing user instance (edit mode) or None (create mode)
def validate_username(self, field):
from app.models.user import User
existing = User.query.filter_by(username=field.data).first()
if existing and (self._user is None or existing.id != self._user.id):
raise ValidationError('Username already in use.')
def validate_email(self, field):
from app.models.user import User
existing = User.query.filter_by(email=field.data).first()
if existing and (self._user is None or existing.id != self._user.id):
raise ValidationError('Email address already in use.')
def validate_password(self, field):
"""Password is required when creating a new account."""
if self._user is None and not field.data:
raise ValidationError('Password is required for new accounts.')