789 lines
32 KiB
Python
789 lines
32 KiB
Python
"""
|
|
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, abort
|
|
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, CustomerInviteForm, SetPasswordForm
|
|
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
|
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
|
|
@supervisor_required
|
|
def index():
|
|
"""Consolidated customer management dashboard."""
|
|
customers = (
|
|
User.query
|
|
.filter_by(role='customer')
|
|
.order_by(User.username)
|
|
.all()
|
|
)
|
|
|
|
customer_ids = [c.id for c in customers]
|
|
|
|
# ── Single bulk query for all assignments ─────────────────────────────
|
|
# Replaces per-customer CustomerAssignment.query.filter_by(user_id=...) loop
|
|
all_assignments = (
|
|
CustomerAssignment.query
|
|
.filter(CustomerAssignment.user_id.in_(customer_ids))
|
|
.all()
|
|
) if customer_ids else []
|
|
|
|
assignment_map = {c.id: [] for c in customers}
|
|
for a in all_assignments:
|
|
assignment_map[a.user_id].append(a)
|
|
|
|
# ── Single bulk query for all active facilities in assigned projects ──
|
|
# Resolves facility scope for every customer without repeated DB round-trips.
|
|
from collections import defaultdict
|
|
assigned_project_ids = {a.project_id for a in all_assignments}
|
|
|
|
project_facilities_map = defaultdict(list) # project_id → [facility_id, ...]
|
|
if assigned_project_ids:
|
|
proj_facs = (
|
|
Facility.query
|
|
.filter(
|
|
Facility.project_id.in_(assigned_project_ids),
|
|
Facility.active == True,
|
|
)
|
|
.all()
|
|
)
|
|
for f in proj_facs:
|
|
project_facilities_map[f.project_id].append(f.id)
|
|
|
|
scope_map = {} # user_id → sorted list[int] facility IDs
|
|
for customer in customers:
|
|
ids = set()
|
|
for a in assignment_map[customer.id]:
|
|
if a.facility_id:
|
|
ids.add(a.facility_id)
|
|
else:
|
|
ids.update(project_facilities_map.get(a.project_id, []))
|
|
scope_map[customer.id] = sorted(ids)
|
|
|
|
# All active projects for the assignment modal
|
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
|
|
|
# ── Expired pending-setup invitations ─────────────────────────────────
|
|
# Surface customer accounts whose invitation token has expired but
|
|
# password_set is still False — they need a fresh invite to log in.
|
|
from app.utils.time_utils import now_eastern
|
|
expired_invitations = [
|
|
c for c in customers
|
|
if not c.password_set
|
|
and c.set_password_token_expires is not None
|
|
and c.set_password_token_expires < now_eastern()
|
|
]
|
|
|
|
return render_template(
|
|
'customers/index.html',
|
|
customers = customers,
|
|
assignment_map = assignment_map,
|
|
scope_map = scope_map,
|
|
projects = projects,
|
|
expired_invitations = expired_invitations,
|
|
)
|
|
|
|
|
|
# ── Create customer (invitation flow) ────────────────────────────────────────
|
|
|
|
@bp.route('/new', methods=['GET', 'POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def create():
|
|
"""Create a customer account via email invitation.
|
|
|
|
Admin enters Full Name and Email only. A temporary username is
|
|
auto-generated from the email address. A one-time set-password link
|
|
is emailed; the customer chooses their own username and password when
|
|
they click it. The account is activated on completion.
|
|
"""
|
|
form = CustomerInviteForm()
|
|
|
|
if form.validate_on_submit():
|
|
import re, secrets
|
|
|
|
full_name = form.full_name.data.strip()
|
|
email = form.email.data.strip().lower()
|
|
|
|
# Auto-generate a temporary username from the email local part.
|
|
# The customer replaces this with their preferred username when
|
|
# they complete the set-password flow via the emailed link.
|
|
base_uname = re.sub(r'[^a-z0-9._-]', '', email.split('@')[0])[:40] or 'customer'
|
|
username = base_uname
|
|
suffix = 1
|
|
while User.query.filter_by(username=username).first():
|
|
username = f'{base_uname}{suffix}'
|
|
suffix += 1
|
|
|
|
# Create user with a random placeholder password (password_set=False
|
|
# blocks login until the customer completes the set-password flow).
|
|
user = User(
|
|
username = username,
|
|
full_name = full_name,
|
|
email = email,
|
|
role = 'customer',
|
|
active = True,
|
|
password_set = False,
|
|
)
|
|
user.set_password(secrets.token_hex(32))
|
|
db.session.add(user)
|
|
db.session.flush()
|
|
|
|
token = user.generate_set_password_token(expires_hours=72)
|
|
db.session.commit()
|
|
|
|
logger.info('CUSTOMERS | invite | 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}; invite_sent=True')
|
|
|
|
_send_invite_email(user, token, base_url=request.host_url)
|
|
|
|
flash(
|
|
f'Customer account created for {full_name}. '
|
|
f'An invitation email has been sent to {email} with a link to set their username and password.',
|
|
'success'
|
|
)
|
|
return redirect(url_for('customers.manage', customer_id=user.id))
|
|
|
|
return render_template('customers/invite.html', form=form)
|
|
|
|
|
|
def _send_invite_email(user, token, base_url=None):
|
|
"""Send the account setup email to a newly created customer."""
|
|
from flask import current_app, render_template_string
|
|
from flask_mail import Message
|
|
from app import mail
|
|
import threading
|
|
|
|
if not current_app.config.get('MAIL_SERVER'):
|
|
logger.warning('INVITE EMAIL SKIPPED | no MAIL_SERVER | user=%s', user.username)
|
|
return
|
|
|
|
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
|
setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}'
|
|
|
|
# Per-domain branded From as a (display_name, address) tuple. The display
|
|
# NAME always tracks the host (e.g. "Gov Services QC"); the ADDRESS is
|
|
# branded only for DNS-authorized domains and otherwise stays the
|
|
# authenticated identity so the mail always delivers. See
|
|
# app/utils/mail_utils.py and CLAUDE.md rule 64.
|
|
from app.utils.mail_utils import branded_sender
|
|
sender = branded_sender(effective_base)
|
|
|
|
html_body = render_template_string("""<!DOCTYPE html>
|
|
<html>
|
|
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
|
<h2 style="color:#0d6efd;">Welcome to the Janitorial QC System</h2>
|
|
<p>Hi {{ name }},</p>
|
|
<p>An account has been created for you on the Janitorial QC (JQC) portal.
|
|
To get started, please set your password using the button below.</p>
|
|
<p>
|
|
<a href="{{ link }}"
|
|
style="background:#0d6efd;color:#fff;padding:12px 24px;
|
|
text-decoration:none;border-radius:4px;display:inline-block;font-weight:bold;">
|
|
Set My Password
|
|
</a>
|
|
</p>
|
|
<p style="font-size:13px;color:#666;">
|
|
This link expires in <strong>72 hours</strong>. If you did not expect this email,
|
|
you can safely ignore it.
|
|
</p>
|
|
<p style="font-size:13px;color:#888;">
|
|
Or copy this URL:<br>
|
|
<a href="{{ link }}" style="color:#0d6efd;">{{ link }}</a>
|
|
</p>
|
|
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
|
<p style="font-size:12px;color:#888;">Janitorial QC System — do not reply.</p>
|
|
</body>
|
|
</html>""", name=user.display_name, link=setup_link)
|
|
|
|
text_body = (
|
|
f'Hi {user.display_name},\n\n'
|
|
f'An account has been created for you on the Janitorial QC portal.\n'
|
|
f'Set your password here:\n\n{setup_link}\n\n'
|
|
f'This link expires in 72 hours.\n\nJanitorial QC System'
|
|
)
|
|
|
|
msg = Message(
|
|
subject = '[JQC] Your account is ready — please set your password',
|
|
sender = sender,
|
|
recipients = [user.email],
|
|
body = text_body,
|
|
html = html_body,
|
|
)
|
|
|
|
app = current_app._get_current_object()
|
|
|
|
def _send():
|
|
with app.app_context():
|
|
try:
|
|
mail.send(msg)
|
|
logger.info('INVITE EMAIL SENT | to=%s | user=%s', user.email, user.username)
|
|
except Exception as exc:
|
|
logger.error('INVITE EMAIL FAILED | to=%s | error=%s', user.email, exc)
|
|
|
|
threading.Thread(target=_send, daemon=True).start()
|
|
|
|
|
|
# ── Resend invitation email ───────────────────────────────────────────────────
|
|
|
|
@bp.route('/<int:customer_id>/resend-invite', methods=['POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def resend_invite(customer_id):
|
|
"""Generate a fresh token and resend the set-password invitation email."""
|
|
customer = db.session.get(User, customer_id)
|
|
if customer is None:
|
|
abort(404)
|
|
if customer.role != 'customer':
|
|
flash('This action is only for customer accounts.', 'warning')
|
|
return redirect(url_for('customers.index'))
|
|
|
|
token = customer.generate_set_password_token(expires_hours=72)
|
|
customer.password_set = False
|
|
db.session.commit()
|
|
|
|
logger.info('CUSTOMERS | resend_invite | admin=%s customer=%s',
|
|
current_user.username, customer.username)
|
|
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
|
f'invite resent by {current_user.username}')
|
|
|
|
_send_invite_email(customer, token, base_url=request.host_url)
|
|
flash(f'Invitation email resent to {customer.email}.', 'success')
|
|
return redirect(url_for('customers.manage', customer_id=customer_id))
|
|
|
|
|
|
# ── Public: set password via token ────────────────────────────────────────────
|
|
|
|
@bp.route('/set-password/<token>', methods=['GET', 'POST'])
|
|
def set_password(token):
|
|
"""Public page — customer sets their password via the emailed link."""
|
|
from app.utils.forms import SetPasswordForm
|
|
user = User.verify_set_password_token(token)
|
|
if user is None:
|
|
flash(
|
|
'This password setup link is invalid or has expired. '
|
|
'Please contact your administrator to resend the invitation.',
|
|
'danger'
|
|
)
|
|
return redirect(url_for('auth.login'))
|
|
|
|
form = SetPasswordForm()
|
|
if form.validate_on_submit():
|
|
user.username = form.username.data.strip()
|
|
user.password_set = True
|
|
user.set_password(form.password.data)
|
|
user.clear_set_password_token()
|
|
db.session.commit()
|
|
|
|
logger.info('CUSTOMERS | password_set | user=%s', user.username)
|
|
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
|
'customer chose username and password via invite link')
|
|
|
|
flash('Your account is ready. You can now log in.', 'success')
|
|
return redirect(url_for('auth.login'))
|
|
|
|
return render_template('customers/set_password.html', form=form, user=user)
|
|
|
|
|
|
# ── Edit customer ─────────────────────────────────────────────────────────────
|
|
|
|
# ── Edit customer ─────────────────────────────────────────────────────────────
|
|
|
|
@bp.route('/<int:customer_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def edit(customer_id):
|
|
customer = db.session.get(User, customer_id)
|
|
if customer is None:
|
|
abort(404)
|
|
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.full_name = form.full_name.data.strip() or None
|
|
customer.email = form.email.data.strip().lower()
|
|
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
|
|
@supervisor_required
|
|
def manage(customer_id):
|
|
"""Single-customer detail page: profile + all assignments."""
|
|
customer = db.session.get(User, customer_id)
|
|
if customer is None:
|
|
abort(404)
|
|
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 contract —')]
|
|
|
|
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
|
|
@supervisor_required
|
|
def add_assignment(customer_id):
|
|
customer = db.session.get(User, customer_id)
|
|
if customer is None:
|
|
abort(404)
|
|
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 contract.', 'warning')
|
|
return redirect(url_for('customers.manage', customer_id=customer_id))
|
|
|
|
project = db.session.get(Project, project_id)
|
|
if project is None:
|
|
abort(404)
|
|
|
|
# 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
|
|
@supervisor_required
|
|
def remove_assignment(assignment_id):
|
|
assignment = db.session.get(CustomerAssignment, assignment_id)
|
|
if assignment is None:
|
|
abort(404)
|
|
customer_id = assignment.user_id
|
|
customer = db.session.get(User, customer_id)
|
|
project = db.session.get(Project, 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
|
|
@supervisor_required
|
|
def toggle_active(customer_id):
|
|
customer = db.session.get(User, customer_id)
|
|
if customer is None:
|
|
abort(404)
|
|
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(safe_redirect_url(request.referrer, fallback=url_for('customers.index')))
|
|
|
|
|
|
# ── AJAX: facilities for a project (used by add-assignment form) ──────────────
|
|
|
|
|
|
# ── CSV template download ─────────────────────────────────────────────────────
|
|
|
|
@bp.route('/import/template')
|
|
@login_required
|
|
@supervisor_required
|
|
def import_template():
|
|
"""Download a blank CSV template showing the expected import format."""
|
|
import csv, io
|
|
from flask import Response
|
|
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf)
|
|
writer.writerow([
|
|
'username', 'email', 'password',
|
|
'project_name', 'facility_name',
|
|
])
|
|
writer.writerow([
|
|
'jane.smith', 'jane@acme.com', 'SecurePass1!',
|
|
'Acme Contract', 'Downtown Office',
|
|
])
|
|
writer.writerow([
|
|
'bob.jones', 'bob@acme.com', 'SecurePass2!',
|
|
'Acme Contract', '',
|
|
])
|
|
buf.seek(0)
|
|
return Response(
|
|
buf.getvalue(),
|
|
mimetype='text/csv',
|
|
headers={'Content-Disposition': 'attachment; filename="customer_import_template.csv"'},
|
|
)
|
|
|
|
|
|
# ── Bulk import (upload → preview → confirm) ──────────────────────────────────
|
|
|
|
@bp.route('/import', methods=['GET', 'POST'])
|
|
@login_required
|
|
@supervisor_required
|
|
def bulk_import():
|
|
"""Two-phase CSV import for customer accounts.
|
|
|
|
Phase 1 (GET / POST with file):
|
|
Parse and validate the CSV, return a preview of what will be created.
|
|
No database writes occur here.
|
|
|
|
Phase 2 (POST with confirmed=1):
|
|
Write all validated rows to the database.
|
|
|
|
CSV columns
|
|
-----------
|
|
username : required — must be unique across users
|
|
email : required — must be unique across users
|
|
password : required — min 8 characters
|
|
project_name : optional — must match an existing active Project name exactly
|
|
facility_name : optional — if given, must match an active Facility within the project
|
|
|
|
One row = one user. A user may have at most one assignment per import row;
|
|
import the same username on multiple rows to assign them to multiple projects.
|
|
Duplicate username rows after the first are treated as additional assignments.
|
|
"""
|
|
import csv, io
|
|
from flask import session as _session
|
|
|
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
|
proj_by_name = {p.name.strip().lower(): p for p in projects}
|
|
|
|
# ── Phase 2: commit confirmed rows ────────────────────────────────────
|
|
if request.method == 'POST' and request.form.get('confirmed') == '1':
|
|
import json
|
|
rows_json = request.form.get('rows_json', '[]')
|
|
try:
|
|
rows = json.loads(rows_json)
|
|
except Exception:
|
|
flash('Import session expired. Please re-upload the file.', 'danger')
|
|
return redirect(url_for('customers.bulk_import'))
|
|
|
|
created_users = 0
|
|
created_assign = 0
|
|
skipped = 0
|
|
|
|
# Track users created in this batch (username → User) so duplicate
|
|
# rows for the same username add assignments rather than re-creating.
|
|
batch_users = {}
|
|
|
|
for row in rows:
|
|
uname = row['username']
|
|
email = row['email']
|
|
pw = row['password']
|
|
proj_id = row.get('project_id')
|
|
fac_id = row.get('facility_id')
|
|
|
|
# Get or create user
|
|
user = (
|
|
batch_users.get(uname)
|
|
or User.query.filter_by(username=uname).first()
|
|
)
|
|
|
|
if user is None:
|
|
user = User(
|
|
username = uname,
|
|
email = email,
|
|
role = 'customer',
|
|
active = True,
|
|
)
|
|
user.set_password(pw)
|
|
db.session.add(user)
|
|
db.session.flush() # populate user.id before assignment
|
|
batch_users[uname] = user
|
|
created_users += 1
|
|
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
|
f'role=customer; email={email}; source=bulk_import')
|
|
logger.info('BULK IMPORT | user_created | username=%s email=%s by=%s',
|
|
uname, email, current_user.username)
|
|
|
|
# Create assignment if a project was specified
|
|
if proj_id:
|
|
existing = CustomerAssignment.query.filter_by(
|
|
user_id = user.id,
|
|
project_id = proj_id,
|
|
facility_id = fac_id or None,
|
|
).first()
|
|
if not existing:
|
|
assign = CustomerAssignment(
|
|
user_id = user.id,
|
|
project_id = proj_id,
|
|
facility_id = fac_id or None,
|
|
)
|
|
db.session.add(assign)
|
|
db.session.flush() # populate assign.id before audit log
|
|
created_assign += 1
|
|
log_action(ACTION_CREATE, 'CustomerAssignment', assign.id,
|
|
f'{uname} → project_id={proj_id}',
|
|
f'facility_id={fac_id}; source=bulk_import')
|
|
else:
|
|
skipped += 1
|
|
|
|
db.session.commit()
|
|
logger.info(
|
|
'BULK IMPORT COMMITTED | by=%s | users=%s | assignments=%s | skipped=%s',
|
|
current_user.username, created_users, created_assign, skipped,
|
|
)
|
|
flash(
|
|
f'Import complete: {created_users} user(s) created, '
|
|
f'{created_assign} assignment(s) added'
|
|
+ (f', {skipped} duplicate assignment(s) skipped.' if skipped else '.'),
|
|
'success',
|
|
)
|
|
return redirect(url_for('customers.index'))
|
|
|
|
# ── Phase 1: parse and validate ───────────────────────────────────────
|
|
preview_rows = []
|
|
errors = []
|
|
raw_valid_rows = [] # serialisable dicts passed to phase 2 via hidden field
|
|
|
|
if request.method == 'POST':
|
|
file = request.files.get('csv_file')
|
|
|
|
if not file or not file.filename:
|
|
flash('Please select a CSV file to upload.', 'warning')
|
|
return render_template('customers/import.html', projects=projects)
|
|
|
|
if not file.filename.lower().endswith('.csv'):
|
|
flash('Only .csv files are accepted.', 'danger')
|
|
return render_template('customers/import.html', projects=projects)
|
|
|
|
try:
|
|
stream = io.StringIO(file.stream.read().decode('utf-8-sig'))
|
|
reader = csv.DictReader(stream)
|
|
raw_rows = list(reader)
|
|
except Exception as exc:
|
|
flash(f'Could not parse file: {exc}', 'danger')
|
|
return render_template('customers/import.html', projects=projects)
|
|
|
|
required_cols = {'username', 'email', 'password'}
|
|
if not required_cols.issubset(set(reader.fieldnames or [])):
|
|
flash(
|
|
f'CSV is missing required columns: {required_cols - set(reader.fieldnames or [])}. '
|
|
'Download the template to see the expected format.',
|
|
'danger',
|
|
)
|
|
return render_template('customers/import.html', projects=projects)
|
|
|
|
# Track usernames seen in this file to catch intra-file duplicates
|
|
seen_usernames = {} # username → first row index (1-based)
|
|
seen_emails = {}
|
|
|
|
for i, raw in enumerate(raw_rows, start=2): # row 1 = header
|
|
row_errors = []
|
|
|
|
uname = (raw.get('username') or '').strip()
|
|
email = (raw.get('email') or '').strip()
|
|
pw = (raw.get('password') or '').strip()
|
|
pname = (raw.get('project_name') or '').strip()
|
|
fname = (raw.get('facility_name') or '').strip()
|
|
|
|
if not uname:
|
|
row_errors.append('username is required')
|
|
if not email:
|
|
row_errors.append('email is required')
|
|
if not pw:
|
|
row_errors.append('password is required')
|
|
elif len(pw) < 8:
|
|
row_errors.append('password must be at least 8 characters')
|
|
|
|
# Duplicate username within file (first occurrence creates the user;
|
|
# subsequent occurrences add assignments — that's intentional)
|
|
if uname:
|
|
if uname in seen_usernames:
|
|
# Allowed only if it's an additional assignment row
|
|
pass
|
|
else:
|
|
seen_usernames[uname] = i
|
|
# Check DB uniqueness only for new usernames
|
|
if User.query.filter_by(username=uname).first():
|
|
row_errors.append(f'username "{uname}" already exists in the system')
|
|
|
|
if email:
|
|
if email in seen_emails:
|
|
row_errors.append(f'email "{email}" appears more than once in this file')
|
|
else:
|
|
seen_emails[email] = i
|
|
if User.query.filter_by(email=email).first():
|
|
row_errors.append(f'email "{email}" already exists in the system')
|
|
|
|
# Resolve project
|
|
project = None
|
|
facility = None
|
|
proj_id = None
|
|
fac_id = None
|
|
|
|
if pname:
|
|
project = proj_by_name.get(pname.lower())
|
|
if project is None:
|
|
row_errors.append(f'contract "{pname}" not found or inactive')
|
|
else:
|
|
proj_id = project.id
|
|
if fname:
|
|
from app.models.facility import Facility
|
|
facility = Facility.query.filter(
|
|
Facility.project_id == project.id,
|
|
Facility.active == True,
|
|
db.func.lower(Facility.name) == fname.lower(),
|
|
).first()
|
|
if facility is None:
|
|
row_errors.append(
|
|
f'facility "{fname}" not found in contract "{pname}"'
|
|
)
|
|
else:
|
|
fac_id = facility.id
|
|
elif fname:
|
|
row_errors.append('facility_name requires project_name to also be set')
|
|
|
|
status = 'error' if row_errors else 'ok'
|
|
preview_rows.append({
|
|
'row': i,
|
|
'username': uname,
|
|
'email': email,
|
|
'project': project.name if project else '—',
|
|
'facility': facility.name if facility else ('All' if project else '—'),
|
|
'status': status,
|
|
'errors': row_errors,
|
|
})
|
|
|
|
if not row_errors:
|
|
raw_valid_rows.append({
|
|
'username': uname,
|
|
'email': email,
|
|
'password': pw,
|
|
'project_id': proj_id,
|
|
'facility_id': fac_id,
|
|
})
|
|
else:
|
|
errors.extend(row_errors)
|
|
|
|
import json
|
|
return render_template(
|
|
'customers/import.html',
|
|
projects = projects,
|
|
preview_rows = preview_rows,
|
|
has_errors = bool(errors),
|
|
valid_count = len(raw_valid_rows),
|
|
rows_json = json.dumps(raw_valid_rows),
|
|
)
|
|
|
|
@bp.route('/facilities-for-project/<int:project_id>')
|
|
@login_required
|
|
@supervisor_required
|
|
def facilities_for_project(project_id):
|
|
from flask import jsonify
|
|
project = db.session.get(Project, project_id)
|
|
if project is None:
|
|
abort(404)
|
|
facilities = project.facilities.filter_by(active=True).order_by(Facility.name).all()
|
|
return jsonify([{'id': f.id, 'name': f.name} for f in facilities]) |