Mar 05 2026: implement reinspect comparision, bulk user import 2

This commit is contained in:
2026-03-05 09:43:31 -05:00
parent 8a91890931
commit 120134a769
3 changed files with 447 additions and 1 deletions
+283
View File
@@ -258,6 +258,289 @@ def toggle_active(customer_id):
# ── AJAX: facilities for a project (used by add-assignment form) ──────────────
# ── CSV template download ─────────────────────────────────────────────────────
@bp.route('/import/template')
@login_required
@admin_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
@admin_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)
created_assign += 1
log_action(ACTION_CREATE, 'CustomerAssignment', 0,
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'project "{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 project "{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
@admin_required
+160
View File
@@ -0,0 +1,160 @@
{% extends "base.html" %}
{% block title %}Bulk Customer Import{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2><i class="bi bi-upload text-primary me-2"></i>Bulk Customer Import</h2>
<p class="text-muted mb-0">Create multiple customer accounts and assignments from a CSV file.</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('customers.import_template') }}" class="btn btn-sm btn-outline-success">
<i class="bi bi-download me-1"></i>Download Template
</a>
<a href="{{ url_for('customers.index') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Customers
</a>
</div>
</div>
{# ── Format guide ── #}
<div class="card border-0 bg-light mb-4">
<div class="card-body py-3 px-4">
<h6 class="fw-semibold mb-2"><i class="bi bi-info-circle me-1 text-primary"></i>CSV Format</h6>
<div class="row g-3 small">
<div class="col-md-4">
<strong>Required columns</strong>
<ul class="mb-0 mt-1 ps-3">
<li><code>username</code> — unique login name</li>
<li><code>email</code> — unique email address</li>
<li><code>password</code> — min 8 characters</li>
</ul>
</div>
<div class="col-md-4">
<strong>Optional columns</strong>
<ul class="mb-0 mt-1 ps-3">
<li><code>project_name</code> — exact project name</li>
<li><code>facility_name</code> — exact facility name within project (leave blank for all)</li>
</ul>
</div>
<div class="col-md-4">
<strong>Tips</strong>
<ul class="mb-0 mt-1 ps-3">
<li>Repeat a username on multiple rows to assign them to multiple projects</li>
<li>Leave <code>facility_name</code> blank to grant access to all facilities in the project</li>
<li>Existing usernames/emails will be flagged as errors before anything is saved</li>
</ul>
</div>
</div>
</div>
</div>
{# ── Upload form ── #}
{% if not preview_rows %}
<div class="card shadow-sm">
<div class="card-header bg-primary text-white fw-semibold">
<i class="bi bi-file-earmark-arrow-up me-1"></i> Upload CSV File
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold">Select CSV File</label>
<input type="file" name="csv_file" accept=".csv" class="form-control" required>
<div class="form-text">Maximum recommended file size: 500 KB · UTF-8 encoding.</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search me-1"></i> Parse &amp; Preview
</button>
</form>
</div>
</div>
{% else %}
{# ── Preview results ── #}
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center
bg-{{ 'danger' if has_errors else 'success' }} text-white">
<span class="fw-semibold">
<i class="bi bi-{{ 'x-circle' if has_errors else 'check-circle' }} me-1"></i>
Preview — {{ preview_rows|length }} row(s) parsed
</span>
<span>
<span class="badge bg-white text-success">{{ valid_count }} valid</span>
{% set err_count = preview_rows|length - valid_count %}
{% if err_count > 0 %}
<span class="badge bg-white text-danger ms-1">{{ err_count }} error(s)</span>
{% endif %}
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" style="font-size:.85rem;">
<thead class="table-light">
<tr>
<th width="50">Row</th>
<th>Username</th>
<th>Email</th>
<th>Project</th>
<th>Facility Scope</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for row in preview_rows %}
<tr class="{{ 'table-danger' if row.status == 'error' else '' }}">
<td class="text-muted">{{ row.row }}</td>
<td>{{ row.username or '—' }}</td>
<td>{{ row.email or '—' }}</td>
<td>{{ row.project }}</td>
<td>{{ row.facility }}</td>
<td>
{% if row.status == 'ok' %}
<span class="badge bg-success">Ready</span>
{% else %}
<span class="badge bg-danger">Error</span>
<ul class="mb-0 ps-3 text-danger" style="font-size:.78rem;">
{% for e in row.errors %}<li>{{ e }}</li>{% endfor %}
</ul>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ── Action buttons ── #}
{% if has_errors %}
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>Errors found.</strong> Fix the issues above and re-upload.
Error rows will be skipped — only valid rows can be imported.
{% if valid_count > 0 %}
You may still import the {{ valid_count }} valid row(s) by clicking below.
{% endif %}
</div>
{% endif %}
<div class="d-flex gap-3 align-items-center">
{% if valid_count > 0 %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="confirmed" value="1">
<input type="hidden" name="rows_json" value="{{ rows_json }}">
<button type="submit" class="btn btn-success"
onclick="return confirm('Create {{ valid_count }} customer account(s) and their assignments?')">
<i class="bi bi-person-check me-1"></i>
Import {{ valid_count }} Valid Row{{ 's' if valid_count != 1 else '' }}
</button>
</form>
{% endif %}
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-counterclockwise me-1"></i> Upload Different File
</a>
</div>
{% endif %}
{% endblock %}
+4 -1
View File
@@ -7,7 +7,10 @@
<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">
<div class="col-auto d-flex gap-2">
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-success">
<i class="bi bi-upload"></i> Import CSV
</a>
<a href="{{ url_for('customers.create') }}" class="btn btn-primary">
<i class="bi bi-person-plus"></i> New Customer
</a>