Backend:
- New /api/clients CRUD (create, list, delete, rename)
- dropdowns.py: _load_dropdowns(client_id) — per-client file first, global fallback
- admin.py: per-client dropdown upload/preview/delete endpoints
- ai_command.py: reads sheet's client_id, builds hierarchy from client-specific file
- sheets/manager.py: client_id stored in sheet metadata; get/set_sheet_client_id helpers
- sheets.py: create sheet accepts client_id; PATCH /{id}/client endpoint
- config_runtime.py: CLIENTS_FILE, CLIENTS_DROPDOWNS_DIR, ADMIN_EMAILS list
- user_store.py: bootstrap admin from ADMIN_EMAILS (daveporter + vadymsamoilenko)
Frontend:
- New Client type; SheetMeta gains client_id
- api/clients.ts, stores/useClientStore.ts — client CRUD
- useDropdownStore: re-fetches when client changes (no stale cache)
- SheetPage: client selector in header; fetches per-client categories
- BriefUploadPage: client selector before upload
- AdminClientsPage: create/delete clients, upload per-client .xlsx, preview before apply
- Sidebar: separate admin nav links (Users / Clients / Dropdowns)
- App.tsx: /admin/clients route
Data:
- 4 clients pre-seeded (Adidas, USTUDIO, 3M Colab, Bissell) with custom hierarchy files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
148 lines
5 KiB
Python
148 lines
5 KiB
Python
"""
|
|
Admin API — user management and dropdown Excel upload.
|
|
All routes require admin role.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from ..auth.middleware import admin_required
|
|
from ..auth.user_store import list_users, set_role, set_active
|
|
from ..api.dropdowns import save_dropdowns, parse_excel_dropdowns
|
|
from ..api.clients import load_clients, get_client_by_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin')
|
|
|
|
|
|
@admin_bp.route('/users', methods=['GET'])
|
|
@admin_required
|
|
async def get_users():
|
|
return jsonify({'users': list_users()})
|
|
|
|
|
|
@admin_bp.route('/users/<user_id>', methods=['PATCH'])
|
|
@admin_required
|
|
async def update_user(user_id: str):
|
|
body = await request.get_json() or {}
|
|
|
|
user = None
|
|
if 'role' in body:
|
|
user = set_role(user_id, body['role'])
|
|
if user is None:
|
|
return jsonify({'error': 'invalid_role_or_not_found'}), 400
|
|
|
|
if 'active' in body:
|
|
user = set_active(user_id, bool(body['active']))
|
|
if user is None:
|
|
return jsonify({'error': 'not_found'}), 404
|
|
|
|
return jsonify({'success': True, 'user': user})
|
|
|
|
|
|
def _read_xlsx_file(file) -> bytes:
|
|
return file.read()
|
|
|
|
|
|
async def _parse_uploaded_xlsx(files) -> tuple[list, str | None]:
|
|
"""Returns (categories, error_message). error_message is None on success."""
|
|
file = files.get('file')
|
|
if not file:
|
|
return [], 'no_file'
|
|
if not (file.filename or '').lower().endswith('.xlsx'):
|
|
return [], 'Only .xlsx files accepted'
|
|
try:
|
|
data = _read_xlsx_file(file)
|
|
categories = parse_excel_dropdowns(data)
|
|
if not categories:
|
|
return [], 'No categories found in file'
|
|
return categories, None
|
|
except Exception as e:
|
|
logger.error(f"Dropdown parse error: {e}", exc_info=True)
|
|
return [], str(e)
|
|
|
|
|
|
@admin_bp.route('/dropdowns/upload', methods=['POST'])
|
|
@admin_required
|
|
async def upload_dropdowns():
|
|
"""Upload a new .xlsx to update global dropdown categories."""
|
|
files = await request.files
|
|
categories, err = await _parse_uploaded_xlsx(files)
|
|
if err:
|
|
return jsonify({'error': err}), 400
|
|
save_dropdowns(categories)
|
|
active_count = sum(1 for c in categories if c['status'] == 'Active')
|
|
return jsonify({'success': True, 'total': len(categories), 'active': active_count,
|
|
'archived': len(categories) - active_count})
|
|
|
|
|
|
@admin_bp.route('/dropdowns/preview', methods=['POST'])
|
|
@admin_required
|
|
async def preview_dropdowns():
|
|
"""Preview parsed categories from an uploaded file without saving."""
|
|
files = await request.files
|
|
categories, err = await _parse_uploaded_xlsx(files)
|
|
if err:
|
|
return jsonify({'error': err}), 400
|
|
return jsonify({'categories': categories, 'total': len(categories)})
|
|
|
|
|
|
# ── Per-client dropdown endpoints ─────────────────────────────────────────────
|
|
|
|
@admin_bp.route('/clients/<client_id>/dropdowns/upload', methods=['POST'])
|
|
@admin_required
|
|
async def upload_client_dropdowns(client_id: str):
|
|
"""Upload a per-client .xlsx dropdown file. Falls back to global if not set."""
|
|
if not get_client_by_id(client_id):
|
|
return jsonify({'error': 'client_not_found'}), 404
|
|
files = await request.files
|
|
categories, err = await _parse_uploaded_xlsx(files)
|
|
if err:
|
|
return jsonify({'error': err}), 400
|
|
save_dropdowns(categories, client_id=client_id)
|
|
|
|
# Mark client as having custom dropdowns
|
|
clients = load_clients()
|
|
for c in clients:
|
|
if c['id'] == client_id:
|
|
c['hasCustomDropdowns'] = True
|
|
break
|
|
from ..api.clients import _save_clients
|
|
_save_clients(clients)
|
|
|
|
active_count = sum(1 for c in categories if c['status'] == 'Active')
|
|
return jsonify({'success': True, 'total': len(categories), 'active': active_count,
|
|
'archived': len(categories) - active_count})
|
|
|
|
|
|
@admin_bp.route('/clients/<client_id>/dropdowns/preview', methods=['POST'])
|
|
@admin_required
|
|
async def preview_client_dropdowns(client_id: str):
|
|
"""Preview per-client dropdown file without saving."""
|
|
files = await request.files
|
|
categories, err = await _parse_uploaded_xlsx(files)
|
|
if err:
|
|
return jsonify({'error': err}), 400
|
|
return jsonify({'categories': categories, 'total': len(categories)})
|
|
|
|
|
|
@admin_bp.route('/clients/<client_id>/dropdowns', methods=['DELETE'])
|
|
@admin_required
|
|
async def delete_client_dropdowns(client_id: str):
|
|
"""Remove per-client dropdown override — reverts to global."""
|
|
from ..config_runtime import server_config
|
|
path = os.path.join(server_config.CLIENTS_DROPDOWNS_DIR, f"{client_id}.json")
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
clients = load_clients()
|
|
for c in clients:
|
|
if c['id'] == client_id:
|
|
c['hasCustomDropdowns'] = False
|
|
break
|
|
from ..api.clients import _save_clients
|
|
_save_clients(clients)
|
|
return jsonify({'success': True})
|