From 56b7874f8dfcbe2cf841a5a2212955bbd9154623 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 10 Jul 2026 08:56:12 -0400 Subject: [PATCH] Self-hosted employee directory (in-app management + CSV import) Most sites have no external HR database, so add a self-hosted directory mode. - New employee_directory_mode setting: 'external' (default; read a separate HR DB, unchanged) or 'selfhosted' (app-owned table). - DirectoryEmployee model + directoryemployees table (migration 7d16). to_dict emits the same keys the external contract uses (SSO/First_Name/...), so both modes share one response shape and the frontend is unchanged. - Employee search / single / batch lookup branch on the mode. - Self-hosted-only management endpoints: list, create, update, delete, and CSV import (upsert by SSO). Guarded so they only work in self-hosted mode. - EmployeeDirectory.vue management page (Settings > Locations & Organization): table + search + pagination, add/edit/delete, CSV import (file or paste). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/api/index.js | 18 ++ frontend/src/router/routes/core.js | 6 + .../src/views/settings/EmployeeDirectory.vue | 234 ++++++++++++++++++ frontend/src/views/settings/settingsNav.js | 3 +- .../versions/7d16_directoryemployees.py | 31 +++ plugins/employees/api/routes.py | 189 ++++++++++++++ plugins/employees/models/__init__.py | 5 + .../employees/models/directory_employee.py | 33 +++ plugins/employees/plugin.py | 6 +- plugins/usb/README.md | 11 +- shopdb/core/api/settings.py | 7 + 11 files changed, 535 insertions(+), 8 deletions(-) create mode 100644 frontend/src/views/settings/EmployeeDirectory.vue create mode 100644 migrations/versions/7d16_directoryemployees.py create mode 100644 plugins/employees/models/__init__.py create mode 100644 plugins/employees/models/directory_employee.py diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index dc4ecd3..a8d7826 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -718,6 +718,24 @@ export const employeesApi = { }, lookupMultiple(ssoList) { return api.get('/employees/lookup', { params: { sso: ssoList } }) + }, + // Self-hosted directory management (directory_mode=selfhosted) + directory: { + list() { + return api.get('/employees/directory') + }, + create(data) { + return api.post('/employees/directory', data) + }, + update(sso, data) { + return api.put(`/employees/directory/${sso}`, data) + }, + remove(sso) { + return api.delete(`/employees/directory/${sso}`) + }, + importCsv(csv) { + return api.post('/employees/directory/import', { csv }) + } } } diff --git a/frontend/src/router/routes/core.js b/frontend/src/router/routes/core.js index c9d7754..643c87f 100644 --- a/frontend/src/router/routes/core.js +++ b/frontend/src/router/routes/core.js @@ -146,6 +146,12 @@ export default [ component: () => import('../../views/settings/CustomFieldsList.vue'), meta: { requiresAuth: true, requiresAdmin: true } }, + { + path: 'settings/employeedirectory', + name: 'employee-directory', + component: () => import('../../views/settings/EmployeeDirectory.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, { path: 'settings/system', name: 'system-settings', diff --git a/frontend/src/views/settings/EmployeeDirectory.vue b/frontend/src/views/settings/EmployeeDirectory.vue new file mode 100644 index 0000000..f2437a5 --- /dev/null +++ b/frontend/src/views/settings/EmployeeDirectory.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/frontend/src/views/settings/settingsNav.js b/frontend/src/views/settings/settingsNav.js index 054569c..3a516ca 100644 --- a/frontend/src/views/settings/settingsNav.js +++ b/frontend/src/views/settings/settingsNav.js @@ -1,7 +1,7 @@ // Shared settings navigation catalog. // Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the // grouping lives in one place. -import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal } from 'lucide-vue-next' +import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact } from 'lucide-vue-next' export const settingsGroups = [ { @@ -48,6 +48,7 @@ export const settingsGroups = [ { title: 'Locations & Organization', cards: [ + { to: '/settings/employeedirectory', icon: Contact, title: 'Employee Directory', description: 'Manage the self-hosted people directory (add/edit/import); read-only in external HR mode' }, { to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' }, { to: '/settings/locationtypes', icon: Tag, title: 'Location Types', description: 'Manage location types + colors' }, { to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' }, diff --git a/migrations/versions/7d16_directoryemployees.py b/migrations/versions/7d16_directoryemployees.py new file mode 100644 index 0000000..aeb2d14 --- /dev/null +++ b/migrations/versions/7d16_directoryemployees.py @@ -0,0 +1,31 @@ +"""Self-hosted employee directory table + +Revision ID: 7d16_directoryemployees +Revises: 7d15_warranties +Create Date: 2026-07-10 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d16_directoryemployees' +down_revision = '7d15_warranties' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'directoryemployees', + sa.Column('sso', sa.Integer(), primary_key=True, autoincrement=False), + sa.Column('firstname', sa.String(length=100), nullable=False), + sa.Column('lastname', sa.String(length=100), nullable=False), + sa.Column('team', sa.String(length=100), nullable=True), + sa.Column('role', sa.String(length=100), nullable=True), + sa.Column('picture', sa.String(length=255), nullable=True), + ) + + +def downgrade(): + op.drop_table('directoryemployees') diff --git a/plugins/employees/api/routes.py b/plugins/employees/api/routes.py index d013c0c..53007fd 100644 --- a/plugins/employees/api/routes.py +++ b/plugins/employees/api/routes.py @@ -7,16 +7,24 @@ displays (recognition wall), so they are not JWT-gated; keep them read-only and never return more than the directory fields below. """ +import csv +import io import logging from flask import Blueprint, request +from flask_jwt_extended import jwt_required from shopdb.api import ( + db, success_response, error_response, ErrorCodes, employee_connection, + require_role, ) +from shopdb.core.models import Setting + +from ..models import DirectoryEmployee logger = logging.getLogger(__name__) @@ -26,6 +34,22 @@ employees_bp = Blueprint('employees', __name__) _FIELDS = 'SSO, First_Name, Last_Name, Team, Role, Picture' +def _selfhosted(): + """True when the directory is the app-owned table, not an external HR DB.""" + row = Setting.query.filter_by(key='employee_directory_mode').first() + return (row.value if row and row.value else 'external').lower() == 'selfhosted' + + +def _require_selfhosted(): + """Guard for management endpoints - only valid in self-hosted mode.""" + if not _selfhosted(): + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Directory is in external mode; manage people in the source HR database.', + http_code=400) + return None + + @employees_bp.route('/search', methods=['GET']) def search_employees(): """ @@ -44,6 +68,16 @@ def search_employees(): 'Search query must be at least 2 characters' ) + if _selfhosted(): + term = f'%{query}%' + rows = (DirectoryEmployee.query + .filter(db.or_(DirectoryEmployee.firstname.ilike(term), + DirectoryEmployee.lastname.ilike(term), + db.cast(DirectoryEmployee.sso, db.String).ilike(term))) + .order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname) + .limit(limit).all()) + return success_response([e.to_dict() for e in rows]) + try: conn = employee_connection() with conn.cursor() as cur: @@ -77,6 +111,13 @@ def lookup_employee(sso): 'SSO must be numeric' ) + if _selfhosted(): + emp = DirectoryEmployee.query.get(int(sso)) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, + f'Employee with SSO {sso} not found', http_code=404) + return success_response(emp.to_dict()) + try: conn = employee_connection() with conn.cursor() as cur: @@ -121,6 +162,14 @@ def lookup_employees(): 'At least one valid SSO is required' ) + if _selfhosted(): + rows = DirectoryEmployee.query.filter( + DirectoryEmployee.sso.in_([int(s) for s in ssos])).all() + employees = [e.to_dict() for e in rows] + names = ', '.join(f"{e['First_Name'].strip()} {e['Last_Name'].strip()}" + for e in employees) + return success_response({'employees': employees, 'names': names}) + try: conn = employee_connection() with conn.cursor() as cur: @@ -148,3 +197,143 @@ def lookup_employees(): 'Employee lookup failed', http_code=500 ) + + +# ============================================================================= +# Self-hosted directory management (only when directory_mode=selfhosted) +# ============================================================================= + +@employees_bp.route('/directory', methods=['GET']) +@jwt_required(optional=True) +def list_directory(): + """Full self-hosted directory (for the management page).""" + guard = _require_selfhosted() + if guard: + return guard + rows = (DirectoryEmployee.query + .order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).all()) + return success_response([e.to_dict() for e in rows]) + + +def _employee_from_payload(data): + """Build kwargs from a payload accepting either external-style (SSO, + First_Name...) or plain (sso, firstname...) keys.""" + def pick(*keys): + for key in keys: + if data.get(key) not in (None, ''): + return data.get(key) + return None + return { + 'sso': pick('sso', 'SSO'), + 'firstname': pick('firstname', 'First_Name'), + 'lastname': pick('lastname', 'Last_Name'), + 'team': pick('team', 'Team'), + 'role': pick('role', 'Role'), + 'picture': pick('picture', 'Picture'), + } + + +@employees_bp.route('/directory', methods=['POST']) +@jwt_required() +@require_role('admin') +def create_directory_employee(): + guard = _require_selfhosted() + if guard: + return guard + fields = _employee_from_payload(request.get_json() or {}) + if not (fields['sso'] and fields['firstname'] and fields['lastname']): + return error_response(ErrorCodes.VALIDATION_ERROR, 'sso, firstname and lastname are required') + try: + sso = int(fields['sso']) + except (ValueError, TypeError): + return error_response(ErrorCodes.VALIDATION_ERROR, 'sso must be numeric') + if DirectoryEmployee.query.get(sso): + return error_response(ErrorCodes.CONFLICT, f'SSO {sso} already exists', http_code=409) + emp = DirectoryEmployee(sso=sso, firstname=fields['firstname'], lastname=fields['lastname'], + team=fields['team'], role=fields['role'], picture=fields['picture']) + db.session.add(emp) + db.session.commit() + return success_response(emp.to_dict(), message='Employee added', http_code=201) + + +@employees_bp.route('/directory/', methods=['PUT']) +@jwt_required() +@require_role('admin') +def update_directory_employee(sso): + guard = _require_selfhosted() + if guard: + return guard + emp = DirectoryEmployee.query.get(sso) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) + fields = _employee_from_payload(request.get_json() or {}) + if fields['firstname']: + emp.firstname = fields['firstname'] + if fields['lastname']: + emp.lastname = fields['lastname'] + for key in ('team', 'role', 'picture'): + if key in (request.get_json() or {}) or fields[key] is not None: + setattr(emp, key, fields[key]) + db.session.commit() + return success_response(emp.to_dict(), message='Employee updated') + + +@employees_bp.route('/directory/', methods=['DELETE']) +@jwt_required() +@require_role('admin') +def delete_directory_employee(sso): + guard = _require_selfhosted() + if guard: + return guard + emp = DirectoryEmployee.query.get(sso) + if not emp: + return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) + db.session.delete(emp) + db.session.commit() + return success_response(message='Employee removed') + + +@employees_bp.route('/directory/import', methods=['POST']) +@jwt_required() +@require_role('admin') +def import_directory(): + """Bulk upsert from CSV. Accepts headers SSO,First_Name,Last_Name,Team,Role, + Picture (case-insensitive; sso/firstname/... also accepted).""" + guard = _require_selfhosted() + if guard: + return guard + text = '' + if 'file' in request.files: + text = request.files['file'].read().decode('utf-8-sig', errors='replace') + else: + data = request.get_json(silent=True) or {} + text = data.get('csv', '') + if not text.strip(): + return error_response(ErrorCodes.VALIDATION_ERROR, 'No CSV provided') + + reader = csv.DictReader(io.StringIO(text)) + # Normalize headers to lower for tolerant matching. + added = updated = skipped = 0 + for raw in reader: + row = {(k or '').strip().lower(): (v or '').strip() for k, v in raw.items()} + sso_raw = row.get('sso') or row.get('sso ') + first = row.get('first_name') or row.get('firstname') + last = row.get('last_name') or row.get('lastname') + if not (sso_raw and sso_raw.isdigit() and first and last): + skipped += 1 + continue + sso = int(sso_raw) + team = row.get('team') or None + role = row.get('role') or None + picture = row.get('picture') or None + emp = DirectoryEmployee.query.get(sso) + if emp: + emp.firstname, emp.lastname, emp.team, emp.role, emp.picture = first, last, team, role, picture + updated += 1 + else: + db.session.add(DirectoryEmployee(sso=sso, firstname=first, lastname=last, + team=team, role=role, picture=picture)) + added += 1 + db.session.commit() + return success_response({'added': added, 'updated': updated, 'skipped': skipped}, + message=f'Import done: {added} added, {updated} updated, {skipped} skipped.') diff --git a/plugins/employees/models/__init__.py b/plugins/employees/models/__init__.py new file mode 100644 index 0000000..25343b4 --- /dev/null +++ b/plugins/employees/models/__init__.py @@ -0,0 +1,5 @@ +"""Employees plugin models.""" + +from .directory_employee import DirectoryEmployee + +__all__ = ['DirectoryEmployee'] diff --git a/plugins/employees/models/directory_employee.py b/plugins/employees/models/directory_employee.py new file mode 100644 index 0000000..ce6c059 --- /dev/null +++ b/plugins/employees/models/directory_employee.py @@ -0,0 +1,33 @@ +"""Self-hosted employee directory. + +For sites with no external HR database. When employee_directory_mode is +'selfhosted', the employee lookup APIs read this app-owned table instead of the +external directory, and the directory is managed in-app (CRUD + CSV import). + +to_dict emits the same keys the external contract returns (SSO, First_Name, +Last_Name, Team, Role, Picture) so the frontend and both modes share one shape. +""" + +from shopdb.api import db + + +class DirectoryEmployee(db.Model): + __tablename__ = 'directoryemployees' + + sso = db.Column(db.Integer, primary_key=True, autoincrement=False) + firstname = db.Column(db.String(100), nullable=False) + lastname = db.Column(db.String(100), nullable=False) + team = db.Column(db.String(100)) + role = db.Column(db.String(100)) + picture = db.Column(db.String(255)) + + def to_dict(self): + # Keys match the external employees contract the frontend consumes. + return { + 'SSO': self.sso, + 'First_Name': self.firstname, + 'Last_Name': self.lastname, + 'Team': self.team, + 'Role': self.role, + 'Picture': self.picture, + } diff --git a/plugins/employees/plugin.py b/plugins/employees/plugin.py index e298802..d9829f8 100644 --- a/plugins/employees/plugin.py +++ b/plugins/employees/plugin.py @@ -16,6 +16,7 @@ from flask import Flask, Blueprint from shopdb.plugins.base import BasePlugin, PluginMeta from .api import employees_bp +from .models import DirectoryEmployee logger = logging.getLogger(__name__) @@ -55,8 +56,9 @@ class EmployeesPlugin(BasePlugin): return employees_bp def get_models(self) -> List[Type]: - """No models - the directory is an external database.""" - return [] + """Self-hosted directory table (used when directory_mode=selfhosted). + External mode reads a separate DB via employee_connection instead.""" + return [DirectoryEmployee] def get_config_schema(self) -> List[Dict]: """Employee directory DB connection. Host/name/user are settings the diff --git a/plugins/usb/README.md b/plugins/usb/README.md index b689a04..e1b3056 100644 --- a/plugins/usb/README.md +++ b/plugins/usb/README.md @@ -9,11 +9,12 @@ The plugin's own reference tables (`usbdevicetypes`, `usbdevices`, `usbcheckouts`) live in the main app database; only the live check-in/out data is in `cmmc_usb`. -> **This schema is typically standardized across sites** - the `cmmc_usb` -> check-in/out solution is the same deployment everywhere, so the tables below -> usually match as-is and no adaptation is needed. The view recipe at the end is -> a fallback for the rare site that differs. (Contrast the employee directory, -> which genuinely varies per site.) +> **The schema is standardized across sites** - the `cmmc_usb` check-in/out +> solution is the same deployment everywhere, so the tables below match as-is +> and no schema adaptation is needed. The **database name may differ per site**, +> though - set `cmmc_usb_db_name` (default `cmmc_usb`) to match the local name. +> The view recipe at the end is only a fallback for a site that somehow differs. +> (Contrast the employee directory, which genuinely varies per site.) ## Connection diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 9c3731a..7b98058 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -263,6 +263,13 @@ def build_default_settings(): 'category': 'site', 'description': 'Set true once the first-run setup wizard has been finished' }, + { + 'key': 'employee_directory_mode', + 'value': 'external', + 'valuetype': 'string', + 'category': 'site', + 'description': "Employee directory source: 'external' (a separate HR database) or 'selfhosted' (managed in-app under Employees)" + }, { 'key': 'site_base_url', 'value': '',