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) ──────────────
|
# ── 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>')
|
@bp.route('/facilities-for-project/<int:project_id>')
|
||||||
@login_required
|
@login_required
|
||||||
@admin_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>
|
<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>
|
<p class="text-muted mb-0">Manage portal access for all customer accounts.</p>
|
||||||
</div>
|
</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">
|
<a href="{{ url_for('customers.create') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-person-plus"></i> New Customer
|
<i class="bi bi-person-plus"></i> New Customer
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user