Mar 05 2026: implement reinspect comparision, bulk user import 2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 & 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 %}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user