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