Aug 17 - Update customer roles management

This commit is contained in:
2026-08-17 14:18:10 -04:00
parent 3209a0c717
commit 5486145a24
24 changed files with 1119 additions and 191 deletions
+50 -40
View File
@@ -243,10 +243,13 @@ def request_my_data_deletion():
@login_required
@admin_required
def list_users():
# Exclude customer accounts — those are managed exclusively via /customers
# Exclude customer-side accounts — Customer Director AND Customer Inspector
# are both managed exclusively via /customers (phase51). Using
# User.CUSTOMER_ROLES rather than != 'customer' is what moves the customer
# inspectors off this page.
users = (
User.query
.filter(User.role != 'customer')
.filter(~User.role.in_(User.CUSTOMER_ROLES))
.order_by(User.created_at.desc())
.all()
)
@@ -270,6 +273,25 @@ def list_users():
inspector_contract_counts=inspector_contract_counts)
def _redirect_if_customer_account(user):
"""Send customer-side accounts back to Customer Management.
phase51 moved Customer Director + Customer Inspector wholly under
/customers. These accounts are no longer listed here, but the /auth/users
URLs are still reachable by hand — and editing one through UserForm would
fail anyway ('external_inspector' is no longer an offered role choice, so
SelectField would reject the existing value). Redirect instead of 404 so an
old bookmark lands on the page that now owns the account.
Returns a response to return, or None to continue.
"""
if user is not None and user.is_customer_account:
flash(f'{user.display_name} is a {user.role_label} account and is '
f'managed in Customer Management.', 'info')
return redirect(url_for('customers.manage', customer_id=user.id))
return None
@bp.route('/users/new', methods=['GET', 'POST'])
@login_required
@admin_required
@@ -283,20 +305,17 @@ def create_user():
if form.validate_on_submit():
role = 'inspector' if director_editing else form.role.data
# phase51 — an external inspector works for the customer or a third
# party, so we never set a password on their behalf. They are invited
# exactly like a customer: created with password_set=False (which the
# login route refuses until they finish), given a one-time token, and
# emailed a link to choose their own username and password.
invite = (role == 'external_inspector')
# phase51 — the invitation branch that used to live here moved to
# Customer Management along with the Customer Inspector role. Every
# role this form still offers is OUR OWN staff, created with an
# admin-set password. Customer-side accounts are invited (they choose
# their own username and password) via customers.create().
#
# UserForm.password is Optional() because the same form is used for
# EDIT, where blank means "keep current". On CREATE a blank password
# would otherwise store the hash of an empty string, so require one
# unless the account is being invited to choose their own.
if not invite and not form.password.data:
flash('Please set a password, or choose the External Inspector role '
'to send an invitation instead.', 'danger')
# would otherwise store the hash of an empty string, so require one.
if not form.password.data:
flash('Please set a password for the new user.', 'danger')
return render_template('auth/user_form.html', form=form, user=None,
title='Create User',
director_editing=director_editing)
@@ -306,39 +325,19 @@ def create_user():
full_name=(form.full_name.data or '').strip() or None,
email=form.email.data.strip().lower(),
role=role,
password_set=not invite,
password_set=True,
)
if invite:
# A random unguessable placeholder — password_set=False already
# blocks login, but never leave an account holding a known or
# empty-string hash.
user.set_password(secrets.token_hex(32))
else:
user.set_password(form.password.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.flush() # need user.id before minting the token
token = user.generate_set_password_token(expires_hours=72) if invite else None
db.session.commit()
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s invite=%s',
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
current_user.id, current_user.username, user.username,
user.role, invite)
user.role)
log_action(ACTION_CREATE, 'User', user.id, user.username,
f'role={user.role}; email={user.email}; invite_sent={invite}')
f'role={user.role}; email={user.email}')
if invite:
# Reuses the customer invitation email — the copy ("an account has
# been created for you… set your password") is already correct for
# any invited account. Imported inside the function to keep the
# auth ↔ customers import graph acyclic.
from app.routes.customers import _send_invite_email
_send_invite_email(user, token, base_url=request.host_url)
flash(f'External inspector {user.display_name} created. An invitation '
f'email has been sent to {user.email} with a link to set their '
f'username and password.', 'success')
else:
flash(f'User {user.username} created successfully.', 'success')
flash(f'User {user.username} created successfully.', 'success')
return redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, title='Create User',
@@ -352,6 +351,9 @@ def edit_user(user_id):
user = db.session.get(User, user_id)
if user is None:
abort(404)
moved = _redirect_if_customer_account(user)
if moved:
return moved
form = UserForm(user=user, obj=user)
# Directors may not change another user's role — that privilege is admin-only.
@@ -396,6 +398,9 @@ def resend_invite(user_id):
user = db.session.get(User, user_id)
if user is None:
abort(404)
moved = _redirect_if_customer_account(user)
if moved:
return moved
if user.password_set:
flash(f'{user.display_name} has already completed their account setup.',
'info')
@@ -424,6 +429,11 @@ def assign_inspector_contracts(user_id):
user = db.session.get(User, user_id)
if user is None or not user.is_inspector:
abort(404)
# A Customer Inspector is scoped by exactly these rows, but the page that
# owns them is now customers.manage — one editor per account, not two.
moved = _redirect_if_customer_account(user)
if moved:
return moved
from app.models.project import Project
from app.models.inspector_assignment import InspectorAssignment