diff --git a/app/routes/customers.py b/app/routes/customers.py index 0fb542a..53d0664 100644 --- a/app/routes/customers.py +++ b/app/routes/customers.py @@ -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/') @login_required @admin_required diff --git a/app/templates/customers/import.html b/app/templates/customers/import.html new file mode 100644 index 0000000..5042933 --- /dev/null +++ b/app/templates/customers/import.html @@ -0,0 +1,160 @@ +{% extends "base.html" %} +{% block title %}Bulk Customer Import{% endblock %} + +{% block content %} +
+
+

Bulk Customer Import

+

Create multiple customer accounts and assignments from a CSV file.

+
+ +
+ +{# ── Format guide ── #} +
+
+
CSV Format
+
+
+ Required columns +
    +
  • username — unique login name
  • +
  • email — unique email address
  • +
  • password — min 8 characters
  • +
+
+
+ Optional columns +
    +
  • project_name — exact project name
  • +
  • facility_name — exact facility name within project (leave blank for all)
  • +
+
+
+ Tips +
    +
  • Repeat a username on multiple rows to assign them to multiple projects
  • +
  • Leave facility_name blank to grant access to all facilities in the project
  • +
  • Existing usernames/emails will be flagged as errors before anything is saved
  • +
+
+
+
+
+ +{# ── Upload form ── #} +{% if not preview_rows %} +
+
+ Upload CSV File +
+
+
+ +
+ + +
Maximum recommended file size: 500 KB · UTF-8 encoding.
+
+ +
+
+
+ +{% else %} +{# ── Preview results ── #} +
+
+ + + Preview — {{ preview_rows|length }} row(s) parsed + + + {{ valid_count }} valid + {% set err_count = preview_rows|length - valid_count %} + {% if err_count > 0 %} + {{ err_count }} error(s) + {% endif %} + +
+ +
+
+ + + + + + + + + + + + + {% for row in preview_rows %} + + + + + + + + + {% endfor %} + +
RowUsernameEmailProjectFacility ScopeStatus
{{ row.row }}{{ row.username or '—' }}{{ row.email or '—' }}{{ row.project }}{{ row.facility }} + {% if row.status == 'ok' %} + Ready + {% else %} + Error +
    + {% for e in row.errors %}
  • {{ e }}
  • {% endfor %} +
+ {% endif %} +
+
+
+
+ +{# ── Action buttons ── #} +{% if has_errors %} +
+ + Errors found. 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 %} +
+{% endif %} + +
+ {% if valid_count > 0 %} +
+ + + + +
+ {% endif %} + + Upload Different File + +
+{% endif %} +{% endblock %} diff --git a/app/templates/customers/index.html b/app/templates/customers/index.html index e7f8eaa..c0b214e 100644 --- a/app/templates/customers/index.html +++ b/app/templates/customers/index.html @@ -7,7 +7,10 @@

Customer Management

Manage portal access for all customer accounts.

-
+