diff --git a/app/__init__.py b/app/__init__.py index 28df65f..5927d06 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -105,6 +105,7 @@ def create_app(config_name='default'): from app.routes import notifications # Notification system from app.routes import audit # Audit Trail 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(dashboard.bp) @@ -116,6 +117,7 @@ def create_app(config_name='default'): app.register_blueprint(notifications.bp) app.register_blueprint(audit.bp) app.register_blueprint(projects.bp) + app.register_blueprint(customers.bp) # ── Error handler: 413 Request Entity Too Large ─────────────────────── # Nginx can return 413 before Flask sees the request; this handler covers diff --git a/app/routes/customers.py b/app/routes/customers.py new file mode 100644 index 0000000..0fb542a --- /dev/null +++ b/app/routes/customers.py @@ -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('//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('/') +@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('//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//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('//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/') +@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]) diff --git a/app/templates/base.html b/app/templates/base.html index cc5d7a3..05aa009 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -86,6 +86,9 @@ + diff --git a/app/templates/customers/form.html b/app/templates/customers/form.html new file mode 100644 index 0000000..22d60c6 --- /dev/null +++ b/app/templates/customers/form.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+ + {% if customer %} +
+
+
+ Account Status: + + {{ 'Active' if customer.active else 'Disabled' }} + +
+ {% if customer.active %} + Disabling prevents the customer from logging in immediately. + {% else %} + This account is currently disabled — the customer cannot log in. + {% endif %} +
+
+
+ + +
+
+
+ {% endif %} + +
+
+

{{ title }}

+
+
+
+ {{ form.hidden_tag() }} + +
+
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control") }} + {% if form.username.errors %} +
+ {% for e in form.username.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
+
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control") }} + {% if form.email.errors %} +
+ {% for e in form.email.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
+
+ +
+
+ {{ 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 %} +
+ {% for e in form.password.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
+
+ {{ form.confirm_password.label(class="form-label") }} + {{ form.confirm_password(class="form-control") }} + {% if form.confirm_password.errors %} +
+ {% for e in form.confirm_password.errors %}{{ e }}{% endfor %} +
+ {% endif %} +
+
+ +
+ + {% if customer %} + + Cancel + + {% else %} + + Cancel + + {% endif %} +
+
+
+
+ +
+
+{% endblock %} diff --git a/app/templates/customers/index.html b/app/templates/customers/index.html new file mode 100644 index 0000000..e7f8eaa --- /dev/null +++ b/app/templates/customers/index.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} +{% block title %}Customer Management{% endblock %} + +{% block content %} +
+
+

Customer Management

+

Manage portal access for all customer accounts.

+
+ +
+ +{% if customers %} +
+
+
+ + + + + + + + + + + + + + {% for customer in customers %} + {% set assignments = assignment_map[customer.id] %} + {% set facility_ids = scope_map[customer.id] %} + + + + + + + + + + {% endfor %} + +
UsernameEmailStatusAssigned ProjectsAccessible FacilitiesCreated
+ + + {{ customer.username }} + + + {{ customer.email }} + {% if customer.active %} + Active + {% else %} + Disabled + {% endif %} + + {% if assignments %} + {% set project_names = assignments | map(attribute='project') | map(attribute='name') | unique | list %} + {% for pname in project_names %} + {{ pname }} + {% endfor %} + {% else %} + — None — + {% endif %} + + {% if facility_ids %} + {{ facility_ids|length }} facilit{{ 'y' if facility_ids|length == 1 else 'ies' }} + {% else %} + — None — + {% endif %} + {{ customer.created_at.strftime('%Y-%m-%d') }} + + + + + + +
+ + +
+
+
+
+
+ +{# ── Summary footer ── #} +
+ {{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total + · {{ customers|selectattr('active')|list|length }} active +
+ +{% else %} +
+
+ +

No customer accounts have been created yet.

+ + Create First Customer + +
+
+{% endif %} +{% endblock %} diff --git a/app/templates/customers/manage.html b/app/templates/customers/manage.html new file mode 100644 index 0000000..a5488d5 --- /dev/null +++ b/app/templates/customers/manage.html @@ -0,0 +1,228 @@ +{% extends "base.html" %} +{% block title %}{{ customer.username }} — Customer Portal{% endblock %} + +{% block content %} +
+
+

+ {{ customer.username }} + {% if not customer.active %} + Disabled + {% else %} + Active + {% endif %} +

+

{{ customer.email }}

+
+
+ + Edit Account + +
+ + +
+ + All Customers + +
+
+ +
+ + {# ── Left column: account info + scoped facilities ── #} +
+ +
+
+ Account Details +
+
+
+
Username
+
{{ customer.username }}
+
Email
+
{{ customer.email }}
+
Status
+
+ + {{ 'Active' if customer.active else 'Disabled' }} + +
+
Created
+
{{ customer.created_at.strftime('%Y-%m-%d') }}
+
Assignments
+
{{ assignments|length }}
+
Facilities
+
{{ facilities|length }}
+
+
+
+ + {# ── Scoped facilities ── #} +
+
+ Accessible Facilities +
+ {% if facilities %} +
+
    + {% for f in facilities %} +
  • + + {{ f.name }} + + {% if f.project %} + {{ f.project.name }} + {% endif %} +
  • + {% endfor %} +
+
+ {% else %} +
+ No facilities accessible yet — add an assignment below. +
+ {% endif %} +
+ +
+ + {# ── Right column: assignments ── #} +
+ + {# ── Current assignments table ── #} +
+
+ Project Assignments +
+ {% if assignments %} +
+ + + + + + + + + + + {% for a in assignments %} + + + + + + + {% endfor %} + +
ProjectFacility ScopeAssigned
+ + {{ a.project.name }} + + + {% if a.facility %} + {{ a.facility.name }} + {% else %} + All facilities + {% endif %} + {{ a.created_at.strftime('%Y-%m-%d') }} +
+ + +
+
+
+ {% else %} +
No assignments yet.
+ {% endif %} +
+ + {# ── Add assignment form ── #} +
+
+ Add Assignment +
+
+
+ + +
+
+ + +
+
+ + +
+ Leave blank to grant access to all facilities in the project. +
+
+
+ +
+
+
+
+
+ +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/utils/forms.py b/app/utils/forms.py index fd1de86..7432260 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -189,3 +189,37 @@ class ProjectForm(FlaskForm): class CustomerAssignmentForm(FlaskForm): user_id = SelectField('Customer User', coerce=int, validators=[DataRequired()]) 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.')