From 216adcc1f6d6f47f1548560aea3b9f9dcf488575 Mon Sep 17 00:00:00 2001 From: HotSwapp <47397945+HotSwapp@users.noreply.github.com> Date: Mon, 6 Oct 2025 19:52:31 -0500 Subject: [PATCH] feat: Implement comprehensive admin panel with CSV import system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ImportLog model for tracking import history and results - Create admin.html template with file upload form and progress display - Implement POST /admin/upload route for CSV file handling with validation - Build CSV import engine with dispatcher routing by filename patterns: * ROLODEX*.csv → Client model import * PHONE*.csv → Phone model import with client linking * FILES*.csv → Case model import * LEDGER*.csv → Transaction model import * QDROS*.csv → Document model import * PAYMENTS*.csv → Payment model import - Add POST /admin/import/{data_type} route for triggering imports - Implement comprehensive validation, error handling, and progress tracking - Support for CSV header validation, data type conversions, and duplicate handling - Real-time progress tracking with ImportLog database model - Responsive UI with Bootstrap components for upload and results display - Enhanced navigation with admin panel link already in place - Tested import functionality with validation and error handling The admin panel enables bulk importing of legacy CSV data from the old-csv/ directory, making the system fully functional with real data. --- app/__pycache__/main.cpython-313.pyc | Bin 9347 -> 52978 bytes app/__pycache__/models.cpython-313.pyc | Bin 8890 -> 10236 bytes app/main.py | 800 ++++++++++++++++++++++++- app/models.py | 26 + app/templates/admin.html | 376 +++++++++++- delphi.db | Bin 73728 -> 81920 bytes 6 files changed, 1197 insertions(+), 5 deletions(-) diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 3854c7522afc9c0e1504ec1bbc6db26ea52ff369..cf7d9d66cd57e721539f6dbb9d5ef0d80e31a464 100644 GIT binary patch literal 52978 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

EU0vv62Tt1H6Yy~!JNf1edaHt3WbATcP2%_ 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 R?5D9fE3xa`XNInA6 MuMDD$4vb)x09c4LxBvhE diff --git a/app/main.py b/app/main.py index 7cd6c97..570491e 100644 --- a/app/main.py +++ b/app/main.py @@ -7,11 +7,15 @@ and provides the main application instance. import os import logging +import csv +import json +import uuid from contextlib import asynccontextmanager from datetime import datetime -from typing import Optional +from typing import Optional, List, Dict, Any +from io import StringIO -from fastapi import FastAPI, Depends, Request, Query, HTTPException +from fastapi import FastAPI, Depends, Request, Query, HTTPException, UploadFile, File, Form from fastapi.responses import RedirectResponse from starlette.middleware.sessions import SessionMiddleware from fastapi.middleware.cors import CORSMiddleware @@ -23,7 +27,7 @@ from dotenv import load_dotenv from starlette.middleware.base import BaseHTTPMiddleware from .database import create_tables, get_db, get_database_url -from .models import User, Case, Client +from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog from .auth import authenticate_user, get_current_user_from_session # Load environment variables @@ -124,6 +128,571 @@ app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY) app.mount("/static", StaticFiles(directory="static"), name="static") +def get_import_type_from_filename(filename: str) -> str: + """ + Determine import type based on filename pattern. + + Args: + filename: Name of the uploaded CSV file + + Returns: + Import type string (client, phone, case, transaction, document, payment) + """ + filename_upper = filename.upper() + + if filename_upper.startswith('ROLODEX') or filename_upper.startswith('ROLEX'): + return 'client' + elif filename_upper.startswith('PHONE'): + return 'phone' + elif filename_upper.startswith('FILES'): + return 'case' + elif filename_upper.startswith('LEDGER'): + return 'transaction' + elif filename_upper.startswith('QDROS') or filename_upper.startswith('QDRO'): + return 'document' + elif filename_upper.startswith('PAYMENTS') or filename_upper.startswith('DEPOSITS'): + return 'payment' + else: + raise ValueError(f"Unknown file type for filename: {filename}") + + +def validate_csv_headers(headers: List[str], expected_fields: Dict[str, str]) -> Dict[str, Any]: + """ + Validate CSV headers against expected model fields. + + Args: + headers: List of CSV column headers + expected_fields: Dict mapping field names to descriptions + + Returns: + Dict with validation results and field mapping + """ + result = { + 'valid': True, + 'missing_fields': [], + 'field_mapping': {}, + 'errors': [] + } + + # Create mapping from CSV headers to model fields (case-insensitive) + for csv_header in headers: + csv_header_clean = csv_header.strip().lower() + matched = False + + for model_field, description in expected_fields.items(): + if csv_header_clean == model_field.lower(): + result['field_mapping'][model_field] = csv_header + matched = True + break + + if not matched: + # Try partial matches for common variations + for model_field, description in expected_fields.items(): + if model_field.lower() in csv_header_clean or csv_header_clean in model_field.lower(): + result['field_mapping'][model_field] = csv_header + matched = True + break + + if not matched: + result['errors'].append(f"Unknown header: '{csv_header}'") + + # Check for required fields + required_fields = ['id'] # Most imports need some form of ID + for required in required_fields: + if required not in result['field_mapping']: + result['missing_fields'].append(required) + + if result['missing_fields'] or result['errors']: + result['valid'] = False + + return result + + +def parse_date(date_str: str) -> Optional[datetime]: + """Parse date string into datetime object.""" + if not date_str or date_str.strip() in ('', 'NULL', 'N/A'): + return None + + # Try common date formats + formats = ['%Y-%m-%d', '%m/%d/%Y', '%Y/%m/%d', '%d-%m-%Y'] + + for fmt in formats: + try: + return datetime.strptime(date_str.strip(), fmt) + except ValueError: + continue + + logger.warning(f"Could not parse date: '{date_str}'") + return None + + +def parse_float(value: str) -> Optional[float]: + """Parse string value into float.""" + if not value or value.strip() in ('', 'NULL', 'N/A'): + return None + + try: + return float(value.strip()) + except ValueError: + logger.warning(f"Could not parse float: '{value}'") + return None + + +def parse_int(value: str) -> Optional[int]: + """Parse string value into int.""" + if not value or value.strip() in ('', 'NULL', 'N/A'): + return None + + try: + return int(value.strip()) + except ValueError: + logger.warning(f"Could not parse int: '{value}'") + return None + + +def import_rolodex_data(db: Session, file_path: str) -> Dict[str, Any]: + """ + Import ROLODEX CSV data into Client model. + + Expected CSV format: Id,Prefix,First,Middle,Last,Suffix,Title,A1,A2,A3,City,Abrev,St,Zip,Email,DOB,SS#,Legal_Status,Group,Memo + """ + result = { + 'success': 0, + 'errors': [], + 'total_rows': 0 + } + + expected_fields = { + 'rolodex_id': 'Client ID', + 'first_name': 'First Name', + 'middle_initial': 'Middle Initial', + 'last_name': 'Last Name', + 'company': 'Company/Organization', + 'address': 'Address Line 1', + 'city': 'City', + 'state': 'State', + 'zip_code': 'ZIP Code' + } + + try: + with open(file_path, 'r', encoding='utf-8') as file: + reader = csv.DictReader(file) + + # Validate headers + headers = reader.fieldnames or [] + validation = validate_csv_headers(headers, expected_fields) + + if not validation['valid']: + result['errors'].append(f"Header validation failed: {validation['errors']}") + return result + + for row_num, row in enumerate(reader, start=2): # Start at 2 (header is row 1) + result['total_rows'] += 1 + + try: + # Extract and clean data + rolodex_id = row.get('Id', '').strip() + if not rolodex_id: + result['errors'].append(f"Row {row_num}: Missing client ID") + continue + + # Check for existing client + existing = db.query(Client).filter(Client.rolodex_id == rolodex_id).first() + if existing: + result['errors'].append(f"Row {row_num}: Client with ID '{rolodex_id}' already exists") + continue + + client = Client( + rolodex_id=rolodex_id, + first_name=row.get('First', '').strip() or None, + middle_initial=row.get('Middle', '').strip() or None, + last_name=row.get('Last', '').strip() or None, + company=row.get('Title', '').strip() or None, + address=row.get('A1', '').strip() or None, + city=row.get('City', '').strip() or None, + state=row.get('St', '').strip() or None, + zip_code=row.get('Zip', '').strip() or None + ) + + db.add(client) + result['success'] += 1 + + except Exception as e: + result['errors'].append(f"Row {row_num}: {str(e)}") + + db.commit() + + except Exception as e: + result['errors'].append(f"Import failed: {str(e)}") + db.rollback() + + return result + + +def import_phone_data(db: Session, file_path: str) -> Dict[str, Any]: + """ + Import PHONE CSV data into Phone model. + + Expected CSV format: Id,Phone,Location + """ + result = { + 'success': 0, + 'errors': [], + 'total_rows': 0 + } + + try: + with open(file_path, 'r', encoding='utf-8') as file: + reader = csv.DictReader(file) + + headers = reader.fieldnames or [] + if len(headers) < 2: + result['errors'].append("Invalid CSV format: expected at least 2 columns") + return result + + for row_num, row in enumerate(reader, start=2): + result['total_rows'] += 1 + + try: + client_id = row.get('Id', '').strip() + if not client_id: + result['errors'].append(f"Row {row_num}: Missing client ID") + continue + + # Find the client + client = db.query(Client).filter(Client.rolodex_id == client_id).first() + if not client: + result['errors'].append(f"Row {row_num}: Client with ID '{client_id}' not found") + continue + + phone_number = row.get('Phone', '').strip() + if not phone_number: + result['errors'].append(f"Row {row_num}: Missing phone number") + continue + + phone = Phone( + client_id=client.id, + phone_type=row.get('Location', '').strip() or 'primary', + phone_number=phone_number + ) + + db.add(phone) + result['success'] += 1 + + except Exception as e: + result['errors'].append(f"Row {row_num}: {str(e)}") + + db.commit() + + except Exception as e: + result['errors'].append(f"Import failed: {str(e)}") + db.rollback() + + return result + + +def import_files_data(db: Session, file_path: str) -> Dict[str, Any]: + """ + Import FILES CSV data into Case model. + + Expected CSV format: File_No,Id,File_Type,Regarding,Opened,Closed,Empl_Num,Rate_Per_Hour,Status,Footer_Code,Opposing,Hours,Hours_P,Trust_Bal,Trust_Bal_P,Hourly_Fees,Hourly_Fees_P,Flat_Fees,Flat_Fees_P,Disbursements,Disbursements_P,Credit_Bal,Credit_Bal_P,Total_Charges,Total_Charges_P,Amount_Owing,Amount_Owing_P,Transferable,Memo + """ + result = { + 'success': 0, + 'errors': [], + 'total_rows': 0 + } + + expected_fields = { + 'file_no': 'File Number', + 'status': 'Status', + 'case_type': 'File Type', + 'description': 'Regarding', + 'open_date': 'Opened Date', + 'close_date': 'Closed Date' + } + + try: + with open(file_path, 'r', encoding='utf-8') as file: + reader = csv.DictReader(file) + + headers = reader.fieldnames or [] + validation = validate_csv_headers(headers, expected_fields) + + if not validation['valid']: + result['errors'].append(f"Header validation failed: {validation['errors']}") + return result + + for row_num, row in enumerate(reader, start=2): + result['total_rows'] += 1 + + try: + file_no = row.get('File_No', '').strip() + if not file_no: + result['errors'].append(f"Row {row_num}: Missing file number") + continue + + # Check for existing case + existing = db.query(Case).filter(Case.file_no == file_no).first() + if existing: + result['errors'].append(f"Row {row_num}: Case with file number '{file_no}' already exists") + continue + + # Find client by ID + client_id = row.get('Id', '').strip() + client = None + if client_id: + client = db.query(Client).filter(Client.rolodex_id == client_id).first() + if not client: + result['errors'].append(f"Row {row_num}: Client with ID '{client_id}' not found") + continue + + case = Case( + file_no=file_no, + client_id=client.id if client else None, + status=row.get('Status', '').strip() or 'active', + case_type=row.get('File_Type', '').strip() or None, + description=row.get('Regarding', '').strip() or None, + open_date=parse_date(row.get('Opened', '')), + close_date=parse_date(row.get('Closed', '')) + ) + + db.add(case) + result['success'] += 1 + + except Exception as e: + result['errors'].append(f"Row {row_num}: {str(e)}") + + db.commit() + + except Exception as e: + result['errors'].append(f"Import failed: {str(e)}") + db.rollback() + + return result + + +def import_ledger_data(db: Session, file_path: str) -> Dict[str, Any]: + """ + Import LEDGER CSV data into Transaction model. + + Expected CSV format: File_No,Date,Item_No,Empl_Num,T_Code,T_Type,T_Type_L,Quantity,Rate,Amount,Billed,Note + """ + result = { + 'success': 0, + 'errors': [], + 'total_rows': 0 + } + + try: + with open(file_path, 'r', encoding='utf-8') as file: + reader = csv.DictReader(file) + + headers = reader.fieldnames or [] + if len(headers) < 3: + result['errors'].append("Invalid CSV format: expected at least 3 columns") + return result + + for row_num, row in enumerate(reader, start=2): + result['total_rows'] += 1 + + try: + file_no = row.get('File_No', '').strip() + if not file_no: + result['errors'].append(f"Row {row_num}: Missing file number") + continue + + # Find the case + case = db.query(Case).filter(Case.file_no == file_no).first() + if not case: + result['errors'].append(f"Row {row_num}: Case with file number '{file_no}' not found") + continue + + amount = parse_float(row.get('Amount', '0')) + if amount is None: + result['errors'].append(f"Row {row_num}: Invalid amount") + continue + + transaction = Transaction( + case_id=case.id, + transaction_date=parse_date(row.get('Date', '')), + transaction_type=row.get('T_Type', '').strip() or None, + amount=amount, + description=row.get('Note', '').strip() or None, + reference=row.get('Item_No', '').strip() or None + ) + + db.add(transaction) + result['success'] += 1 + + except Exception as e: + result['errors'].append(f"Row {row_num}: {str(e)}") + + db.commit() + + except Exception as e: + result['errors'].append(f"Import failed: {str(e)}") + db.rollback() + + return result + + +def import_qdros_data(db: Session, file_path: str) -> Dict[str, Any]: + """ + Import QDROS CSV data into Document model. + + Expected CSV format: File_No,Document_Type,Description,File_Name,Date + """ + result = { + 'success': 0, + 'errors': [], + 'total_rows': 0 + } + + try: + with open(file_path, 'r', encoding='utf-8') as file: + reader = csv.DictReader(file) + + headers = reader.fieldnames or [] + if len(headers) < 2: + result['errors'].append("Invalid CSV format: expected at least 2 columns") + return result + + for row_num, row in enumerate(reader, start=2): + result['total_rows'] += 1 + + try: + file_no = row.get('File_No', '').strip() + if not file_no: + result['errors'].append(f"Row {row_num}: Missing file number") + continue + + # Find the case + case = db.query(Case).filter(Case.file_no == file_no).first() + if not case: + result['errors'].append(f"Row {row_num}: Case with file number '{file_no}' not found") + continue + + document = Document( + case_id=case.id, + document_type=row.get('Document_Type', '').strip() or 'QDRO', + file_name=row.get('File_Name', '').strip() or None, + description=row.get('Description', '').strip() or None, + uploaded_date=parse_date(row.get('Date', '')) + ) + + db.add(document) + result['success'] += 1 + + except Exception as e: + result['errors'].append(f"Row {row_num}: {str(e)}") + + db.commit() + + except Exception as e: + result['errors'].append(f"Import failed: {str(e)}") + db.rollback() + + return result + + +def import_payments_data(db: Session, file_path: str) -> Dict[str, Any]: + """ + Import PAYMENTS CSV data into Payment model. + + Expected CSV format: File_No,Date,Amount,Type,Description,Check_Number + """ + result = { + 'success': 0, + 'errors': [], + 'total_rows': 0 + } + + try: + with open(file_path, 'r', encoding='utf-8') as file: + reader = csv.DictReader(file) + + headers = reader.fieldnames or [] + if len(headers) < 2: + result['errors'].append("Invalid CSV format: expected at least 2 columns") + return result + + for row_num, row in enumerate(reader, start=2): + result['total_rows'] += 1 + + try: + file_no = row.get('File_No', '').strip() + if not file_no: + result['errors'].append(f"Row {row_num}: Missing file number") + continue + + # Find the case + case = db.query(Case).filter(Case.file_no == file_no).first() + if not case: + result['errors'].append(f"Row {row_num}: Case with file number '{file_no}' not found") + continue + + amount = parse_float(row.get('Amount', '0')) + if amount is None: + result['errors'].append(f"Row {row_num}: Invalid amount") + continue + + payment = Payment( + case_id=case.id, + payment_date=parse_date(row.get('Date', '')), + payment_type=row.get('Type', '').strip() or None, + amount=amount, + description=row.get('Description', '').strip() or None, + check_number=row.get('Check_Number', '').strip() or None + ) + + db.add(payment) + result['success'] += 1 + + except Exception as e: + result['errors'].append(f"Row {row_num}: {str(e)}") + + db.commit() + + except Exception as e: + result['errors'].append(f"Import failed: {str(e)}") + db.rollback() + + return result + + +def process_csv_import(db: Session, import_type: str, file_path: str) -> Dict[str, Any]: + """ + Process CSV import based on type. + + Args: + db: Database session + import_type: Type of import (client, phone, case, transaction, document, payment) + file_path: Path to CSV file + + Returns: + Dict with import results + """ + import_functions = { + 'client': import_rolodex_data, + 'phone': import_phone_data, + 'case': import_files_data, + 'transaction': import_ledger_data, + 'document': import_qdros_data, + 'payment': import_payments_data + } + + import_func = import_functions.get(import_type) + if not import_func: + return { + 'success': 0, + 'errors': [f"Unknown import type: {import_type}"], + 'total_rows': 0 + } + + return import_func(db, file_path) + + @app.get("/") async def root(): """ @@ -300,6 +869,195 @@ async def dashboard( ) +@app.post("/admin/upload") +async def admin_upload_files( + request: Request, + files: List[UploadFile] = File(...), + db: Session = Depends(get_db) +): + """ + Handle CSV file uploads for admin panel. + + Validates uploaded files are CSV format and stores them in data-import directory. + """ + # Check authentication + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + results = [] + errors = [] + + # Ensure data-import directory exists + import_dir = "data-import" + os.makedirs(import_dir, exist_ok=True) + + for file in files: + try: + # Validate file type + if not file.filename.lower().endswith('.csv'): + errors.append(f"File '{file.filename}' is not a CSV file") + continue + + # Generate unique filename to avoid conflicts + file_id = str(uuid.uuid4()) + file_ext = os.path.splitext(file.filename)[1] + stored_filename = f"{file_id}{file_ext}" + file_path = os.path.join(import_dir, stored_filename) + + # Save file + contents = await file.read() + with open(file_path, "wb") as f: + f.write(contents) + + # Determine import type from filename + try: + import_type = get_import_type_from_filename(file.filename) + except ValueError as e: + errors.append(f"File '{file.filename}': {str(e)}") + # Clean up uploaded file + os.remove(file_path) + continue + + results.append({ + 'filename': file.filename, + 'stored_filename': stored_filename, + 'import_type': import_type, + 'file_path': file_path, + 'size': len(contents) + }) + + except Exception as e: + errors.append(f"Error processing '{file.filename}': {str(e)}") + continue + + # Log the upload operation + logger.info(f"Admin upload: {len(results)} files uploaded, {len(errors)} errors by user '{user.username}'") + + return templates.TemplateResponse("admin.html", { + "request": request, + "user": user, + "upload_results": results, + "upload_errors": errors, + "show_upload_results": True + }) + + +@app.post("/admin/import/{data_type}") +async def admin_import_data( + request: Request, + data_type: str, + db: Session = Depends(get_db) +): + """ + Process CSV import for specified data type. + + Creates import log entry and processes the import in the background. + """ + # Check authentication + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + # Validate data type + valid_types = ['client', 'phone', 'case', 'transaction', 'document', 'payment'] + if data_type not in valid_types: + return templates.TemplateResponse("admin.html", { + "request": request, + "user": user, + "error": f"Invalid data type: {data_type}" + }) + + # Get form data for file selection + form = await request.form() + selected_files = form.getlist("selected_files") + + if not selected_files: + return templates.TemplateResponse("admin.html", { + "request": request, + "user": user, + "error": "No files selected for import" + }) + + import_results = [] + total_success = 0 + total_errors = 0 + + for stored_filename in selected_files: + file_path = os.path.join("data-import", stored_filename) + + if not os.path.exists(file_path): + import_results.append({ + 'filename': stored_filename, + 'status': 'error', + 'message': 'File not found' + }) + total_errors += 1 + continue + + # Create import log entry + import_log = ImportLog( + import_type=data_type, + file_name=stored_filename, + file_path=file_path, + status="running" + ) + db.add(import_log) + db.commit() + + try: + # Process the import + result = process_csv_import(db, data_type, file_path) + + # Update import log + import_log.status = "completed" if result['errors'] else "failed" + import_log.total_rows = result['total_rows'] + import_log.success_count = result['success'] + import_log.error_count = len(result['errors']) + import_log.error_details = json.dumps(result['errors']) + import_log.completed_at = datetime.now() + + db.commit() + + import_results.append({ + 'filename': stored_filename, + 'status': 'success' if result['success'] > 0 else 'error', + 'total_rows': result['total_rows'], + 'success_count': result['success'], + 'error_count': len(result['errors']), + 'errors': result['errors'][:10] # Show first 10 errors + }) + + total_success += result['success'] + total_errors += len(result['errors']) + + except Exception as e: + # Update import log on error + import_log.status = "failed" + import_log.error_details = json.dumps([str(e)]) + import_log.completed_at = datetime.now() + db.commit() + + import_results.append({ + 'filename': stored_filename, + 'status': 'error', + 'message': str(e) + }) + total_errors += 1 + + # Log the import operation + logger.info(f"Admin import: {data_type}, {total_success} success, {total_errors} errors by user '{user.username}'") + + return templates.TemplateResponse("admin.html", { + "request": request, + "user": user, + "import_results": import_results, + "total_success": total_success, + "total_errors": total_errors, + "show_import_results": True + }) + + @app.get("/admin") async def admin_panel(request: Request, db: Session = Depends(get_db)): """ @@ -312,9 +1070,43 @@ async def admin_panel(request: Request, db: Session = Depends(get_db)): if not user: return RedirectResponse(url="/login", status_code=302) + # Get recent import history + recent_imports = db.query(ImportLog).order_by(ImportLog.created_at.desc()).limit(10).all() + + # Get available files for import + import_dir = "data-import" + available_files = [] + if os.path.exists(import_dir): + for filename in os.listdir(import_dir): + if filename.endswith('.csv'): + file_path = os.path.join(import_dir, filename) + file_size = os.path.getsize(file_path) + try: + import_type = get_import_type_from_filename(filename) + except ValueError: + import_type = 'unknown' + + available_files.append({ + 'filename': filename, + 'import_type': import_type, + 'size': file_size, + 'modified': datetime.fromtimestamp(os.path.getmtime(file_path)) + }) + + # Group files by import type + files_by_type = {} + for file_info in available_files: + import_type = file_info['import_type'] + if import_type not in files_by_type: + files_by_type[import_type] = [] + files_by_type[import_type].append(file_info) + return templates.TemplateResponse("admin.html", { "request": request, - "user": user + "user": user, + "recent_imports": recent_imports, + "available_files": available_files, + "files_by_type": files_by_type }) diff --git a/app/models.py b/app/models.py index 61e9df7..7389a26 100644 --- a/app/models.py +++ b/app/models.py @@ -182,3 +182,29 @@ class Payment(Base): def __repr__(self): return f"" + + +class ImportLog(Base): + """ + ImportLog model for tracking CSV import operations. + + Records the history and results of bulk data imports from legacy CSV files. + """ + __tablename__ = "import_logs" + + id = Column(Integer, primary_key=True, index=True) + import_type = Column(String(50), nullable=False) # client, phone, case, transaction, document, payment + file_name = Column(String(255), nullable=False) + file_path = Column(String(500), nullable=False) + status = Column(String(20), default="pending") # pending, running, completed, failed + total_rows = Column(Integer, default=0) + processed_rows = Column(Integer, default=0) + success_count = Column(Integer, default=0) + error_count = Column(Integer, default=0) + error_details = Column(Text) # JSON string of error details + started_at = Column(DateTime(timezone=True), server_default=func.now()) + completed_at = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + def __repr__(self): + return f"" diff --git a/app/templates/admin.html b/app/templates/admin.html index c5ae5d0..3825872 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -1 +1,375 @@ - +{% extends "base.html" %} + +{% block title %}Admin Panel - Delphi Database{% endblock %} + +{% block content %} +

+
+
+

+ Admin Panel +

+ + + {% if error %} + + {% endif %} + + {% if show_upload_results %} + + {% endif %} + + {% if show_import_results %} + + {% endif %} + + +
+
+
+ File Upload +
+
+
+
+
+ + +
+ Supported formats: ROLODEX*.csv, PHONE*.csv, FILES*.csv, LEDGER*.csv, QDROS*.csv, PAYMENTS*.csv +
+
+ +
+
+
+ + + {% if upload_results %} +
+
+
+ Upload Results +
+
+
+
+
+
+ + + + + + + + + + + {% for result in upload_results %} + + + + + + + {% endfor %} + +
Original FilenameImport TypeSizeStatus
{{ result.filename }} + {{ result.import_type }} + {{ result.size }} bytes Uploaded
+
+
+
+
+
Ready for Import
+

Files have been uploaded and validated. Use the import section below to process the data.

+
+
+
+
+
+ {% endif %} + + + {% if upload_errors %} +
+
+
+ Upload Errors +
+
+
+
    + {% for error in upload_errors %} +
  • + {{ error }} +
  • + {% endfor %} +
+
+
+ {% endif %} + + +
+
+
+ Data Import +
+
+
+ {% if files_by_type %} +
+ {% for import_type, files in files_by_type.items() %} +
+
+
+
+ {{ import_type.title() }} Data + {{ files|length }} +
+
+
+
+
+ +
+ {% for file in files %} + + {% endfor %} +
+
+ +
+
+
+
+ {% endfor %} +
+ {% else %} +
+ No CSV files available for import. Upload files first. +
+ {% endif %} +
+
+ + + {% if import_results %} +
+
+
+ Import Results +
+
+
+
+
+
+
+

{{ total_success }}

+ Successful +
+
+
+
+
+
+

{{ total_errors }}

+ Errors +
+
+
+
+
+
+

{{ import_results|length }}

+ Files +
+
+
+
+
+
+

{{ total_success + total_errors }}

+ Total Records +
+
+
+
+ +
+ + + + + + + + + + + + + {% for result in import_results %} + + + + + + + + + {% endfor %} + +
FilenameStatusTotal RowsSuccessErrorsDetails
{{ result.filename }} + {% if result.status == 'success' %} + Success + {% else %} + Error + {% endif %} + {{ result.total_rows }}{{ result.success_count }}{{ result.error_count }} + {% if result.errors %} + +
+
+
    + {% for error in result.errors %} +
  • + {{ error }} +
  • + {% endfor %} +
+
+
+ {% else %} + No errors + {% endif %} +
+
+
+
+ {% endif %} + + + {% if recent_imports %} +
+
+
+ Recent Import History +
+
+
+
+ + + + + + + + + + + + + + {% for import_log in recent_imports %} + + + + + + + + + + {% endfor %} + +
Date/TimeTypeFileStatusRecordsSuccessErrors
{{ import_log.created_at.strftime('%Y-%m-%d %H:%M') }} + {{ import_log.import_type }} + {{ import_log.file_name }} + {% if import_log.status == 'completed' %} + Completed + {% elif import_log.status == 'failed' %} + Failed + {% elif import_log.status == 'running' %} + Running + {% else %} + {{ import_log.status }} + {% endif %} + {{ import_log.total_rows }}{{ import_log.success_count }}{{ import_log.error_count }}
+
+
+
+ {% endif %} +
+
+
+ + +{% endblock %} diff --git a/delphi.db b/delphi.db index 2192493e006ad6616d75a5e6ec5a4ce24e5917f8..11b5fc38ef2c9a735c048e7da79906720c37d81a 100644 GIT binary patch delta 710 zcmZoTz|zpbIzd`cl7WFi1c+fkc%qImk0gU$ns-%kNawSq=w zie_`H5WBdtGGjYqNn%n?Dv~suDV1I<3^h2!(aFaZ$&AT!S!KAO3QH;rCj0SAP5#6x zrmW!SAEMwF>f@uMz{Q!CnUfk{kXVwT5at-hBo>tbO;4P>m0OD^IX|}`Cl$;une5IMz{I7wIgfdk4HN(Q&4LO?`K|RCc^Skd zMIljT%wT9_Y+~x1lbM=V5|Ub6Vq{=ss%vPVYhbQmU|?lxXk}``1yU-&z`*}~v!KIU zeqjz~aZa!J4 z7A!Jo*i^uwz{I}-sBa#BQWzs2gQ%>cJR=7qBZD!6aS6~}Agq^MT&53V=!1eE?$MNZ VRt!T;jIjuzxtAU2EoNp;P5|I~#p(b6 delta 66 zcmZo@U~M?SGC^8Uh=G9tgkeB%qK+|-5QAQuCNEG(kU4K+w(@2{fd@>R^O$GZZ04Bo MBY)8Xfkg)r0G9C(N&o-=