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
|
||||
|
||||
Reference in New Issue
Block a user