diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 00000000..ca4e06c9 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,215 @@ +# Changes Summary - Dynamic Application System + +## Overview +The application system has been completely redesigned to be fully dynamic and configurable. All application types, fields, statuses, and workflows are now defined in the database rather than hardcoded. + +## Major Changes + +### 1. Database Architecture + +#### New Tables +- `application_types` - Defines application type templates +- `application_fields` - Field definitions for each type +- `application_type_statuses` - Status definitions per type +- `status_transitions` - Workflow transition rules +- `dynamic_applications` - Application instances +- `application_history_v2` - Complete audit trail +- `application_attachments_v2` - File attachments +- `application_transition_logs` - Status change tracking +- `application_approvals` - Approval decisions + +#### Removed Tables +- `applications` (old fixed structure) +- `form_templates` (replaced by application_types) +- `field_mappings` (integrated into application_fields) + +### 2. Core Features + +#### Dynamic Field System +- 18+ field types (text, date, amount, etc.) +- Conditional display rules +- Custom validation per field +- Section grouping +- Default values and placeholders + +#### Flexible Workflow +- Custom statuses with colors/icons +- Configurable transitions between statuses +- Multiple trigger types: + - User approval (with role requirements) + - Applicant actions + - Time-based triggers + - Condition-based triggers + - Automatic transitions + +#### Enhanced Cost Management +- Up to 100 cost positions (previously 24) +- Up to 100 comparison offers (previously 24) +- Categories and notes per position +- Automatic total calculation + +### 3. API Changes + +#### New Endpoints + +**Application Types:** +- `GET /api/application-types` - List all types +- `GET /api/application-types/{id}` - Get specific type +- `POST /api/application-types` - Create new type (admin) +- `PUT /api/application-types/{id}` - Update type (admin) +- `DELETE /api/application-types/{id}` - Delete/deactivate type +- `POST /api/application-types/{id}/pdf-template` - Upload PDF + +**Dynamic Applications:** +- `GET /api/applications` - List with advanced filtering +- `GET /api/applications/{id}` - Get with access key support +- `POST /api/applications` - Create with type selection +- `PUT /api/applications/{id}` - Update with validation +- `POST /api/applications/{id}/submit` - Submit for review +- `POST /api/applications/{id}/transition` - Status change +- `POST /api/applications/{id}/approve` - Approval actions +- `GET /api/applications/{id}/history` - Audit trail +- `POST /api/applications/{id}/generate-pdf` - PDF generation + +#### Removed Endpoints +- All QSM/VSM specific endpoints +- Fixed form template endpoints +- Legacy PDF processing endpoints + +### 4. Models & Types + +#### New TypeScript Types (`frontend/src/types/dynamic.ts`) +- `ApplicationType` - Type definition +- `FieldDefinition` - Field configuration +- `StatusDefinition` - Status configuration +- `TransitionDefinition` - Workflow rules +- `DynamicApplication` - Application instance +- `CostPosition` - Cost item structure +- `ComparisonOffer` - Vendor offer structure + +#### New Python Models (`backend/src/models/application_type.py`) +- `ApplicationType` - Type ORM model +- `ApplicationField` - Field ORM model +- `ApplicationTypeStatus` - Status ORM model +- `StatusTransition` - Transition ORM model +- `DynamicApplication` - Application ORM model +- Supporting models for history, attachments, approvals + +### 5. Services + +#### New Services +- `NotificationService` - Email notifications with templates +- `PDFService` - Dynamic PDF generation +- `AuthService` - Enhanced authentication with roles + +#### Enhanced Services +- Field validation with type-specific rules +- PDF template mapping and filling +- Workflow engine for transitions +- Audit logging for all changes + +### 6. Frontend Updates + +#### New Components (to be implemented) +- Dynamic field renderer +- Visual workflow designer +- Application type builder +- Status badge with colors +- Cost position manager (100 items) +- Comparison offer manager (100 items) + +#### API Client (`frontend/src/api/dynamicClient.ts`) +- Full TypeScript support +- Automatic token refresh +- Public access support +- Error handling +- File upload support + +### 7. Migration + +#### Data Migration (`backend/scripts/migrate_to_dynamic.py`) +- Creates QSM and VSM as dynamic types +- Migrates existing applications +- Preserves all data and relationships +- Maintains audit trail + +#### Migration Steps +1. Run database migration to create new tables +2. Execute migration script to create default types +3. Verify data integrity +4. Update frontend to use new endpoints +5. Remove old code and tables + +### 8. Configuration + +#### Environment Variables +```env +# New/Updated +MAX_COST_POSITIONS=100 +MAX_COMPARISON_OFFERS=100 +PDF_TEMPLATE_STORAGE=database +DYNAMIC_FIELD_VALIDATION=true +WORKFLOW_ENGINE_ENABLED=true +AUDIT_LOGGING_LEVEL=detailed +``` + +### 9. Benefits + +#### For Administrators +- Create new application types without coding +- Visual workflow designer +- Flexible field configuration +- PDF template management +- Role-based access control + +#### For Users +- Consistent interface across all types +- Better validation and help text +- Public access with keys +- Enhanced cost management +- Real-time status tracking + +#### For Developers +- No hardcoded logic +- Extensible field types +- Clean separation of concerns +- Full TypeScript support +- Comprehensive audit trail + +### 10. Breaking Changes + +#### Backend +- All application endpoints changed +- Database schema completely redesigned +- Old models removed +- API response format changed + +#### Frontend +- New type system required +- API client rewritten +- Component props changed +- State management updated + +### 11. Upgrade Path + +1. **Backup** all existing data +2. **Deploy** new backend with migrations +3. **Run** migration script +4. **Update** frontend to new API +5. **Test** thoroughly +6. **Remove** old code and tables + +### 12. Future Enhancements + +- Form templates and presets +- Batch operations +- Advanced reporting +- Mobile app support +- Webhook integrations +- Custom field types via plugins +- Multi-language support +- Advanced PDF templates with conditionals + +## Summary + +This update transforms the application system from a fixed, hardcoded structure to a fully dynamic, database-driven system. While this is a major breaking change, it provides unlimited flexibility for future requirements without code changes. \ No newline at end of file diff --git a/DYNAMIC_SYSTEM_ARCHITECTURE.md b/DYNAMIC_SYSTEM_ARCHITECTURE.md new file mode 100644 index 00000000..33b19588 --- /dev/null +++ b/DYNAMIC_SYSTEM_ARCHITECTURE.md @@ -0,0 +1,374 @@ +# Dynamic Application System Architecture + +## Overview + +This document describes the new fully dynamic application system that replaces the previous fixed QSM/VSM structure. The system now allows administrators to define any type of application with custom fields, statuses, and workflows. + +## Core Concepts + +### 1. Application Types + +Application types are fully configurable templates that define: +- **Fields**: Dynamic field definitions with types, validation, and display rules +- **Statuses**: Custom status workflow with transitions +- **PDF Templates**: Optional PDF template with field mapping +- **Access Control**: Role-based access restrictions +- **Limits**: Maximum cost positions and comparison offers + +### 2. Fields + +Fields are the building blocks of applications with the following types: +- `text_short`: Short text input (max 255 chars) +- `text_long`: Long text/textarea +- `options`: Single selection from predefined options +- `yesno`: Boolean yes/no field +- `mail`: Email address with validation +- `date`: Date picker +- `datetime`: Date and time picker +- `amount`: Numeric amount field +- `currency_eur`: EUR currency field with formatting +- `number`: General numeric field +- `file`: File upload +- `signature`: Digital signature field +- `phone`: Phone number with validation +- `url`: URL with validation +- `checkbox`: Single checkbox +- `radio`: Radio button group +- `select`: Dropdown selection +- `multiselect`: Multiple selection + +Each field supports: +- **Validation Rules**: min/max values, patterns, required status +- **Display Conditions**: Show/hide based on other field values +- **Default Values**: Pre-filled values +- **Placeholders & Help Text**: User guidance + +### 3. Status System + +Statuses define the workflow states: +- **Editability**: Whether the application can be edited +- **Visual Style**: Color and icon for UI +- **Notifications**: Email templates for status changes +- **Transitions**: Rules for moving between statuses + +### 4. Transitions + +Transitions define how applications move between statuses: + +**Trigger Types:** +- `user_approval`: Requires N users with specific role to approve +- `applicant_action`: Button/action by the applicant +- `deadline_expired`: Automatic when a deadline passes +- `time_elapsed`: After a specific time period +- `condition_met`: When field conditions are satisfied +- `automatic`: Immediate automatic transition + +## Database Schema + +### Core Tables + +1. **application_types** + - Defines application type templates + - Stores PDF template as BLOB + - Contains field mapping configuration + +2. **application_fields** + - Field definitions for each type + - Validation rules as JSON + - Display conditions as JSON + +3. **application_type_statuses** + - Status definitions per type + - Visual configuration (color, icon) + - Notification templates + +4. **status_transitions** + - Transition rules between statuses + - Trigger configuration + - Conditions and actions + +5. **dynamic_applications** + - Actual application instances + - Common fields (email, title, names) + - Dynamic field_data as JSON + - Cost positions and comparison offers as JSON + +6. **application_history_v2** + - Complete audit trail + - Field-level change tracking + - User and IP tracking + +7. **application_approvals** + - Approval decisions by role + - Comments and timestamps + +## API Endpoints + +### Application Types Management + +``` +GET /api/application-types - List all types +GET /api/application-types/{id} - Get specific type +POST /api/application-types - Create new type (admin) +PUT /api/application-types/{id} - Update type (admin) +DELETE /api/application-types/{id} - Delete type (admin) +POST /api/application-types/{id}/pdf-template - Upload PDF template +``` + +### Dynamic Applications + +``` +GET /api/applications - List applications +GET /api/applications/{id} - Get application details +POST /api/applications - Create new application +PUT /api/applications/{id} - Update application +POST /api/applications/{id}/submit - Submit for review +POST /api/applications/{id}/transition - Change status (admin) +POST /api/applications/{id}/approve - Approve/reject +GET /api/applications/{id}/history - Get history +POST /api/applications/{id}/generate-pdf - Generate PDF +``` + +## Common Fields + +The following fields are always present (not dynamic): + +1. **Email**: Applicant's email address +2. **Status**: Current workflow status +3. **Type**: Application type reference +4. **Title**: Application title/subject +5. **First Name**: Applicant's first name +6. **Last Name**: Applicant's last name +7. **Timestamps**: Created, submitted, status changed, completed +8. **Cost Positions**: Up to 100 items with description, amount, category +9. **Comparison Offers**: Up to 100 vendor offers + +## Frontend Components + +### Dynamic Field Renderer + +The frontend includes a dynamic field rendering system that: +- Renders fields based on type +- Applies validation rules +- Handles display conditions +- Manages field dependencies + +### Status Workflow UI + +Visual workflow display showing: +- Current status with color/icon +- Available actions +- Transition history +- Approval tracking + +### Admin Interface + +Application type builder with: +- Drag-and-drop field designer +- Visual workflow editor +- PDF template mapper +- Role management + +## Migration from Old System + +### Data Migration Steps + +1. **Create new tables** - Run migration script +2. **Define standard types** - Create QSM/VSM as dynamic types +3. **Map existing data** - Convert old applications to new format +4. **Update references** - Point to new tables +5. **Remove old tables** - Clean up after verification + +### Field Mapping + +Old QSM/VSM fields map to dynamic fields: + +```json +{ + "project.name": "project_name", + "applicant.name.first": "first_name", + "applicant.name.last": "last_name", + "applicant.contact.email": "email", + "project.costs": "cost_positions", + "project.totals.requestedAmountEur": "total_amount" +} +``` + +## Security & Access Control + +### Role-Based Access + +- **Admin**: Full access to type management and all applications +- **Budget Reviewer**: Review and approve budget-related applications +- **Finance Reviewer**: Financial review and approval +- **AStA Member**: Voting rights on applications +- **Applicant**: Create and edit own applications + +### Public Access + +Applications can be accessed via: +- **Authenticated**: Full access based on role +- **Access Key**: Limited access with unique key +- **Public Link**: Read-only access if configured + +## Configuration + +### Environment Variables + +```env +# Database +DATABASE_URL=mysql://user:pass@localhost/stupa_db + +# Email +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=noreply@example.com +SMTP_PASSWORD=secret +FROM_EMAIL=noreply@example.com +FROM_NAME=Application System + +# Security +JWT_SECRET_KEY=your-secret-key +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# Storage +PDF_OUTPUT_DIR=./uploads/pdfs +ATTACHMENT_DIR=./uploads/attachments +MAX_UPLOAD_SIZE=10485760 + +# Frontend +BASE_URL=https://applications.example.com +``` + +### Application Type Example + +```json +{ + "type_id": "travel_grant", + "name": "Travel Grant Application", + "description": "Apply for travel funding", + "fields": [ + { + "field_id": "destination", + "field_type": "text_short", + "name": "Destination", + "is_required": true, + "validation_rules": { + "max_length": 100 + } + }, + { + "field_id": "purpose", + "field_type": "text_long", + "name": "Purpose of Travel", + "is_required": true, + "validation_rules": { + "min_length": 50, + "max_length": 500 + } + }, + { + "field_id": "travel_date", + "field_type": "date", + "name": "Travel Date", + "is_required": true, + "validation_rules": { + "min_date": "2024-01-01" + } + }, + { + "field_id": "amount_requested", + "field_type": "currency_eur", + "name": "Amount Requested", + "is_required": true, + "validation_rules": { + "min": 0, + "max": 5000 + } + } + ], + "statuses": [ + { + "status_id": "draft", + "name": "Draft", + "is_editable": true, + "color": "#6B7280", + "is_initial": true + }, + { + "status_id": "submitted", + "name": "Submitted", + "is_editable": false, + "color": "#3B82F6", + "send_notification": true + }, + { + "status_id": "approved", + "name": "Approved", + "is_editable": false, + "color": "#10B981", + "is_final": true + }, + { + "status_id": "rejected", + "name": "Rejected", + "is_editable": false, + "color": "#EF4444", + "is_final": true + } + ], + "transitions": [ + { + "from_status_id": "draft", + "to_status_id": "submitted", + "name": "Submit Application", + "trigger_type": "applicant_action" + }, + { + "from_status_id": "submitted", + "to_status_id": "approved", + "name": "Approve", + "trigger_type": "user_approval", + "trigger_config": { + "role": "admin", + "required_approvals": 1 + } + }, + { + "from_status_id": "submitted", + "to_status_id": "rejected", + "name": "Reject", + "trigger_type": "user_approval", + "trigger_config": { + "role": "admin", + "required_approvals": 1 + } + } + ] +} +``` + +## Advantages of Dynamic System + +1. **Flexibility**: Create any type of application without code changes +2. **Maintainability**: All configuration in database, no hardcoded logic +3. **Scalability**: Same infrastructure handles all application types +4. **User Experience**: Consistent interface across all applications +5. **Compliance**: Built-in audit trail and approval workflows +6. **Integration**: PDF generation works with any template +7. **Future-Proof**: Easy to add new field types and features + +## Performance Considerations + +- **JSON Fields**: Indexed for fast searching +- **Caching**: Application types cached in memory +- **Lazy Loading**: Field data loaded on demand +- **Batch Operations**: Support for bulk status changes +- **Async Processing**: PDF generation in background + +## Backup and Recovery + +- **Daily Backups**: Automated database backups +- **Version History**: All changes tracked in history tables +- **Soft Deletes**: Applications marked as deleted, not removed +- **Export/Import**: JSON format for data portability \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 988c0c96..295075f8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,6 +10,7 @@ RUN apt-get update && apt-get install -y \ pkg-config \ wget \ curl \ + netcat-openbsd \ # PDF processing tools poppler-utils \ # Clean up @@ -38,14 +39,19 @@ RUN pip install --no-cache-dir \ # Copy application code COPY src/ ./src/ -COPY assets/ ./assets/ +# Copy entrypoint script +COPY docker-entrypoint.sh /app/ +RUN chmod +x /app/docker-entrypoint.sh +# Copy assets if they exist (currently no assets needed after removing LaTeX) +# COPY assets/ ./assets/ # Create necessary directories RUN mkdir -p /app/uploads \ /app/templates \ /app/attachments \ /app/pdf_forms \ - /app/logs + /app/logs \ + /app/assets # Set permissions RUN chmod -R 755 /app @@ -58,4 +64,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ EXPOSE 8000 # Run the application -CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 00000000..dcae2edf --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +echo "Starting STUPA PDF API Backend..." + +# Wait for database to be ready +echo "Waiting for database..." +while ! nc -z ${MYSQL_HOST:-db} ${MYSQL_PORT:-3306}; do + sleep 1 +done +echo "Database is ready!" + +# Run database initialization +echo "Initializing database..." +python -m src.startup || { + echo "Warning: Database initialization failed or already initialized" +} + +# Run migrations if alembic is available +if [ -f "alembic.ini" ]; then + echo "Running database migrations..." + alembic upgrade head || { + echo "Warning: Migrations failed or not configured" + } +fi + +# Start the application +echo "Starting application server..." +exec uvicorn src.main:app \ + --host 0.0.0.0 \ + --port ${PORT:-8000} \ + --workers ${WORKERS:-1} \ + --reload-dir /app/src \ + ${UVICORN_ARGS} diff --git a/backend/requirements.txt b/backend/requirements.txt index 2b5e6f10..3b210c24 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -25,6 +25,7 @@ requests-oauthlib==1.3.1 # PDF Processing pypdf==3.17.4 +PyPDF2==3.0.1 PyMuPDF==1.23.16 reportlab==4.0.8 pillow==10.2.0 @@ -37,7 +38,6 @@ email-validator==2.1.0.post1 # Caching & Sessions redis==5.0.1 -python-redis==0.6.0 # Template Processing jinja2==3.1.3 @@ -60,7 +60,6 @@ openapi-schema-pydantic==1.2.4 pytest==7.4.4 pytest-asyncio==0.23.3 pytest-cov==4.1.0 -httpx-mock==0.4.0 faker==22.0.0 # Development Tools @@ -74,13 +73,10 @@ python-json-logger==2.0.7 sentry-sdk[fastapi]==1.39.2 # Data Validation & Serialization -marshmallow==3.20.2 pyyaml==6.0.1 # Background Tasks (optional) celery==5.3.6 -kombu==5.3.5 -flower==2.0.1 # Rate Limiting slowapi==0.1.9 diff --git a/backend/scripts/migrate_to_dynamic.py b/backend/scripts/migrate_to_dynamic.py new file mode 100644 index 00000000..62297ed6 --- /dev/null +++ b/backend/scripts/migrate_to_dynamic.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +""" +Migration script to convert old application system to dynamic application system + +This script: +1. Creates default QSM and VSM application types +2. Migrates existing applications to the new dynamic format +3. Preserves all data and relationships +""" + +import os +import sys +import json +import logging +from datetime import datetime +from typing import Dict, Any, List, Optional + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from src.models.application_type import ( + ApplicationType, ApplicationField, ApplicationTypeStatus, + StatusTransition, DynamicApplication, ApplicationHistory, + FieldType, TransitionTriggerType +) +from src.models.base import Base +from src.config.database import get_database_url + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +def create_qsm_application_type(session) -> ApplicationType: + """Create QSM application type with all fields""" + logger.info("Creating QSM application type...") + + qsm_type = ApplicationType( + type_id="qsm", + name="QSM - Qualitätssicherungsmittel", + description="Antrag für Qualitätssicherungsmittel zur Verbesserung der Lehre", + is_active=True, + is_public=True, + max_cost_positions=100, + max_comparison_offers=100 + ) + session.add(qsm_type) + session.flush() + + # Define QSM fields + qsm_fields = [ + # Institution fields + {"field_id": "institution_type", "type": FieldType.SELECT, "name": "Art der Institution", + "options": ["Fachschaft", "STUPA-Referat", "Studentische Hochschulgruppe", "Fakultät", "Hochschuleinrichtung"], + "required": True, "order": 10}, + {"field_id": "institution_name", "type": FieldType.TEXT_SHORT, "name": "Name der Institution", + "required": True, "order": 11}, + + # Applicant fields + {"field_id": "applicant_type", "type": FieldType.SELECT, "name": "Antragsteller", + "options": ["Person", "Institution"], "required": True, "order": 20}, + {"field_id": "course", "type": FieldType.SELECT, "name": "Studiengang", + "options": ["INF", "ESB", "LS", "TEC", "TEX", "NXT"], "order": 21}, + {"field_id": "role", "type": FieldType.SELECT, "name": "Rolle", + "options": ["Student", "Professor", "Mitarbeiter", "AStA", "Referatsleitung", "Fachschaftsvorstand"], + "order": 22}, + {"field_id": "phone", "type": FieldType.PHONE, "name": "Telefonnummer", "order": 23}, + + # Project fields + {"field_id": "project_description", "type": FieldType.TEXT_LONG, "name": "Projektbeschreibung", + "required": True, "order": 30}, + {"field_id": "project_start", "type": FieldType.DATE, "name": "Projektbeginn", + "required": True, "order": 31}, + {"field_id": "project_end", "type": FieldType.DATE, "name": "Projektende", "order": 32}, + {"field_id": "participants", "type": FieldType.NUMBER, "name": "Anzahl Teilnehmer", "order": 33}, + + # Participation + {"field_id": "faculty_inf", "type": FieldType.CHECKBOX, "name": "Fakultät INF", "order": 40}, + {"field_id": "faculty_esb", "type": FieldType.CHECKBOX, "name": "Fakultät ESB", "order": 41}, + {"field_id": "faculty_ls", "type": FieldType.CHECKBOX, "name": "Fakultät LS", "order": 42}, + {"field_id": "faculty_tec", "type": FieldType.CHECKBOX, "name": "Fakultät TEC", "order": 43}, + {"field_id": "faculty_tex", "type": FieldType.CHECKBOX, "name": "Fakultät TEX", "order": 44}, + {"field_id": "faculty_nxt", "type": FieldType.CHECKBOX, "name": "Fakultät NxT", "order": 45}, + {"field_id": "faculty_open", "type": FieldType.CHECKBOX, "name": "Fakultätsübergreifend", "order": 46}, + + # Financing + {"field_id": "qsm_code", "type": FieldType.SELECT, "name": "QSM-Code", + "options": [ + "vwv-3-2-1-1: Finanzierung zusätzlicher Lehr- und Seminarangebote", + "vwv-3-2-1-2: Fachspezifische Studienprojekte", + "vwv-3-2-1-3: Hochschuldidaktische Fort- und Weiterbildungsmaßnahmen", + "vwv-3-2-2-1: Verbesserung/Ausbau von Serviceeinrichtungen", + "vwv-3-2-2-2: Lehr- und Lernmaterialien", + "vwv-3-2-2-3: Durchführung von Exkursionen", + "vwv-3-2-2-4: Infrastrukturelle Begleit- und Anpassungsmaßnahmen", + "vwv-3-2-3-1: Verbesserung der Beratungsangebote", + "vwv-3-2-3-2: Studium Generale und fachübergreifende Lehrangebote", + "vwv-3-2-3-3: Sonstige Maßnahmen im Interesse der Studierendenschaft" + ], + "required": True, "order": 50}, + {"field_id": "qsm_stellenfinanzierungen", "type": FieldType.CHECKBOX, + "name": "Stellenfinanzierungen", "order": 51}, + {"field_id": "qsm_studierende", "type": FieldType.CHECKBOX, + "name": "Für Studierende", "order": 52}, + {"field_id": "qsm_individuell", "type": FieldType.CHECKBOX, + "name": "Individuelle Maßnahme", "order": 53}, + {"field_id": "qsm_exkursion_genehmigt", "type": FieldType.CHECKBOX, + "name": "Exkursion genehmigt", "order": 54}, + {"field_id": "qsm_exkursion_bezuschusst", "type": FieldType.CHECKBOX, + "name": "Exkursion bezuschusst", "order": 55}, + + # Attachments + {"field_id": "comparative_offers", "type": FieldType.CHECKBOX, + "name": "Vergleichsangebote vorhanden", "order": 60}, + {"field_id": "fakultaet_attachment", "type": FieldType.CHECKBOX, + "name": "Fakultätsbeschluss angehängt", "order": 61}, + ] + + for field_def in qsm_fields: + field = ApplicationField( + application_type_id=qsm_type.id, + field_id=field_def["field_id"], + field_type=field_def["type"], + name=field_def["name"], + field_order=field_def.get("order", 0), + is_required=field_def.get("required", False), + options=field_def.get("options"), + validation_rules=field_def.get("validation", {}) + ) + session.add(field) + + return qsm_type + + +def create_vsm_application_type(session) -> ApplicationType: + """Create VSM application type with all fields""" + logger.info("Creating VSM application type...") + + vsm_type = ApplicationType( + type_id="vsm", + name="VSM - Verfasste Studierendenschaft", + description="Antrag für Mittel der Verfassten Studierendenschaft", + is_active=True, + is_public=True, + max_cost_positions=100, + max_comparison_offers=100 + ) + session.add(vsm_type) + session.flush() + + # Define VSM fields (similar to QSM but with VSM-specific financing) + vsm_fields = [ + # Institution fields + {"field_id": "institution_type", "type": FieldType.SELECT, "name": "Art der Institution", + "options": ["Fachschaft", "STUPA-Referat", "Studentische Hochschulgruppe"], + "required": True, "order": 10}, + {"field_id": "institution_name", "type": FieldType.TEXT_SHORT, "name": "Name der Institution", + "required": True, "order": 11}, + + # Applicant fields (same as QSM) + {"field_id": "applicant_type", "type": FieldType.SELECT, "name": "Antragsteller", + "options": ["Person", "Institution"], "required": True, "order": 20}, + {"field_id": "course", "type": FieldType.SELECT, "name": "Studiengang", + "options": ["INF", "ESB", "LS", "TEC", "TEX", "NXT"], "order": 21}, + {"field_id": "role", "type": FieldType.SELECT, "name": "Rolle", + "options": ["Student", "AStA", "Referatsleitung", "Fachschaftsvorstand"], + "order": 22}, + {"field_id": "phone", "type": FieldType.PHONE, "name": "Telefonnummer", "order": 23}, + + # Project fields (same as QSM) + {"field_id": "project_description", "type": FieldType.TEXT_LONG, "name": "Projektbeschreibung", + "required": True, "order": 30}, + {"field_id": "project_start", "type": FieldType.DATE, "name": "Projektbeginn", + "required": True, "order": 31}, + {"field_id": "project_end", "type": FieldType.DATE, "name": "Projektende", "order": 32}, + {"field_id": "participants", "type": FieldType.NUMBER, "name": "Anzahl Teilnehmer", "order": 33}, + + # VSM-specific financing + {"field_id": "vsm_code", "type": FieldType.SELECT, "name": "VSM-Code", + "options": [ + "lhg-01: Hochschulpolitische, fachliche, soziale, wirtschaftliche und kulturelle Belange", + "lhg-02: Mitwirkung an den Aufgaben der Hochschulen", + "lhg-03: Politische Bildung", + "lhg-04: Förderung der Chancengleichheit", + "lhg-05: Förderung der Integration ausländischer Studierender", + "lhg-06: Förderung der sportlichen Aktivitäten", + "lhg-07: Pflege der überregionalen Studierendenbeziehungen" + ], + "required": True, "order": 50}, + {"field_id": "vsm_aufgaben", "type": FieldType.CHECKBOX, + "name": "Aufgaben der Studierendenschaft", "order": 51}, + {"field_id": "vsm_individuell", "type": FieldType.CHECKBOX, + "name": "Individuelle Maßnahme", "order": 52}, + + # Attachments + {"field_id": "comparative_offers", "type": FieldType.CHECKBOX, + "name": "Vergleichsangebote vorhanden", "order": 60}, + ] + + for field_def in vsm_fields: + field = ApplicationField( + application_type_id=vsm_type.id, + field_id=field_def["field_id"], + field_type=field_def["type"], + name=field_def["name"], + field_order=field_def.get("order", 0), + is_required=field_def.get("required", False), + options=field_def.get("options"), + validation_rules=field_def.get("validation", {}) + ) + session.add(field) + + return vsm_type + + +def create_statuses_and_transitions(session, app_type: ApplicationType): + """Create standard statuses and transitions for an application type""" + logger.info(f"Creating statuses and transitions for {app_type.name}...") + + # Define standard statuses + statuses = [ + {"id": "draft", "name": "Entwurf", "editable": True, "color": "#6B7280", + "initial": True, "final": False}, + {"id": "submitted", "name": "Beantragt", "editable": False, "color": "#3B82F6", + "initial": False, "final": False, "notification": True}, + {"id": "processing_locked", "name": "Bearbeitung gesperrt", "editable": False, + "color": "#F59E0B", "initial": False, "final": False}, + {"id": "under_review", "name": "Zu prüfen", "editable": False, "color": "#8B5CF6", + "initial": False, "final": False}, + {"id": "voting", "name": "Zur Abstimmung", "editable": False, "color": "#EC4899", + "initial": False, "final": False}, + {"id": "approved", "name": "Genehmigt", "editable": False, "color": "#10B981", + "initial": False, "final": True, "notification": True}, + {"id": "rejected", "name": "Abgelehnt", "editable": False, "color": "#EF4444", + "initial": False, "final": True, "notification": True}, + {"id": "cancelled", "name": "Zurückgezogen", "editable": False, "color": "#9CA3AF", + "initial": False, "final": True, "cancelled": True}, + ] + + status_objects = {} + for i, status_def in enumerate(statuses): + status = ApplicationTypeStatus( + application_type_id=app_type.id, + status_id=status_def["id"], + name=status_def["name"], + is_editable=status_def["editable"], + color=status_def["color"], + display_order=i * 10, + is_initial=status_def.get("initial", False), + is_final=status_def.get("final", False), + is_cancelled=status_def.get("cancelled", False), + send_notification=status_def.get("notification", False) + ) + session.add(status) + session.flush() + status_objects[status_def["id"]] = status + + # Define transitions + transitions = [ + # From Draft + {"from": "draft", "to": "submitted", "name": "Antrag einreichen", + "trigger": TransitionTriggerType.APPLICANT_ACTION}, + + # From Submitted + {"from": "submitted", "to": "processing_locked", "name": "Bearbeitung sperren", + "trigger": TransitionTriggerType.USER_APPROVAL, "role": "admin"}, + {"from": "submitted", "to": "under_review", "name": "Zur Prüfung freigeben", + "trigger": TransitionTriggerType.USER_APPROVAL, "role": "admin"}, + {"from": "submitted", "to": "cancelled", "name": "Zurückziehen", + "trigger": TransitionTriggerType.APPLICANT_ACTION}, + + # From Processing Locked + {"from": "processing_locked", "to": "under_review", "name": "Bearbeitung entsperren", + "trigger": TransitionTriggerType.USER_APPROVAL, "role": "admin"}, + + # From Under Review + {"from": "under_review", "to": "voting", "name": "Zur Abstimmung freigeben", + "trigger": TransitionTriggerType.USER_APPROVAL, "role": "budget_reviewer"}, + {"from": "under_review", "to": "rejected", "name": "Ablehnen", + "trigger": TransitionTriggerType.USER_APPROVAL, "role": "budget_reviewer"}, + + # From Voting + {"from": "voting", "to": "approved", "name": "Genehmigen", + "trigger": TransitionTriggerType.USER_APPROVAL, "role": "asta_member", + "required": 3}, # Requires 3 AStA members to approve + {"from": "voting", "to": "rejected", "name": "Ablehnen", + "trigger": TransitionTriggerType.USER_APPROVAL, "role": "asta_member", + "required": 3}, # Requires 3 AStA members to reject + ] + + for trans_def in transitions: + config = {"role": trans_def.get("role", "admin")} + if "required" in trans_def: + config["required_approvals"] = trans_def["required"] + + transition = StatusTransition( + from_status_id=status_objects[trans_def["from"]].id, + to_status_id=status_objects[trans_def["to"]].id, + name=trans_def["name"], + trigger_type=trans_def["trigger"], + trigger_config=config, + is_active=True + ) + session.add(transition) + + +def migrate_old_application(session, old_app: Dict[str, Any], app_type: ApplicationType) -> DynamicApplication: + """Migrate an old application to the new dynamic format""" + + # Extract data from old format + payload = old_app.get("payload", {}) + pa = payload.get("pa", {}) + applicant = pa.get("applicant", {}) + project = pa.get("project", {}) + + # Map old status to new status + status_map = { + "DRAFT": "draft", + "BEANTRAGT": "submitted", + "BEARBEITUNG_GESPERRT": "processing_locked", + "ZU_PRUEFEN": "under_review", + "ZUR_ABSTIMMUNG": "voting", + "GENEHMIGT": "approved", + "ABGELEHNT": "rejected", + "CANCELLED": "cancelled" + } + + # Build field data + field_data = {} + + # Institution fields + institution = applicant.get("institution", {}) + field_data["institution_type"] = institution.get("type", "") + field_data["institution_name"] = institution.get("name", "") + + # Applicant fields + field_data["applicant_type"] = applicant.get("type", "person") + name = applicant.get("name", {}) + contact = applicant.get("contact", {}) + field_data["course"] = applicant.get("course", "") + field_data["role"] = applicant.get("role", "") + field_data["phone"] = contact.get("phone", "") + + # Project fields + field_data["project_description"] = project.get("description", "") + dates = project.get("dates", {}) + field_data["project_start"] = dates.get("start", "") + field_data["project_end"] = dates.get("end", "") + field_data["participants"] = project.get("participants", 0) + + # Participation + participation = project.get("participation", {}) + faculties = participation.get("faculties", {}) + field_data["faculty_inf"] = faculties.get("inf", False) + field_data["faculty_esb"] = faculties.get("esb", False) + field_data["faculty_ls"] = faculties.get("ls", False) + field_data["faculty_tec"] = faculties.get("tec", False) + field_data["faculty_tex"] = faculties.get("tex", False) + field_data["faculty_nxt"] = faculties.get("nxt", False) + field_data["faculty_open"] = faculties.get("open", False) + + # Financing + financing = project.get("financing", {}) + if app_type.type_id == "qsm": + qsm = financing.get("qsm", {}) + field_data["qsm_code"] = qsm.get("code", "") + flags = qsm.get("flags", {}) + field_data["qsm_stellenfinanzierungen"] = flags.get("stellenfinanzierungen", False) + field_data["qsm_studierende"] = flags.get("studierende", False) + field_data["qsm_individuell"] = flags.get("individuell", False) + field_data["qsm_exkursion_genehmigt"] = flags.get("exkursionGenehmigt", False) + field_data["qsm_exkursion_bezuschusst"] = flags.get("exkursionBezuschusst", False) + else: # VSM + vsm = financing.get("vsm", {}) + field_data["vsm_code"] = vsm.get("code", "") + flags = vsm.get("flags", {}) + field_data["vsm_aufgaben"] = flags.get("aufgaben", False) + field_data["vsm_individuell"] = flags.get("individuell", False) + + # Attachments + attachments = pa.get("attachments", {}) + field_data["comparative_offers"] = attachments.get("comparativeOffers", False) + if app_type.type_id == "qsm": + field_data["fakultaet_attachment"] = attachments.get("fakultaet", False) + + # Cost positions + costs = project.get("costs", []) + cost_positions = [] + for cost in costs: + cost_positions.append({ + "description": cost.get("name", ""), + "amount": cost.get("amountEur", 0), + "category": "", + "notes": "" + }) + + # Create new dynamic application + new_app = DynamicApplication( + application_id=old_app["pa_id"], + application_key=old_app["pa_key"], + application_type_id=app_type.id, + user_id=old_app.get("user_id"), + email=contact.get("email", ""), + status_id=status_map.get(old_app.get("status", "DRAFT"), "draft"), + title=project.get("name", ""), + first_name=name.get("first", ""), + last_name=name.get("last", ""), + field_data=field_data, + cost_positions=cost_positions, + total_amount=project.get("totals", {}).get("requestedAmountEur", 0), + submitted_at=old_app.get("submitted_at"), + created_at=old_app.get("created_at", datetime.utcnow()), + updated_at=old_app.get("updated_at", datetime.utcnow()) + ) + + return new_app + + +def main(): + """Main migration function""" + logger.info("Starting migration to dynamic application system...") + + # Create database connection + engine = create_engine(get_database_url()) + Session = sessionmaker(bind=engine) + session = Session() + + try: + # Step 1: Create application types + qsm_type = create_qsm_application_type(session) + vsm_type = create_vsm_application_type(session) + session.commit() + logger.info("Application types created successfully") + + # Step 2: Create statuses and transitions + create_statuses_and_transitions(session, qsm_type) + create_statuses_and_transitions(session, vsm_type) + session.commit() + logger.info("Statuses and transitions created successfully") + + # Step 3: Migrate existing applications + logger.info("Migrating existing applications...") + + # Query old applications (if table exists) + try: + result = session.execute(text("SELECT * FROM applications")) + old_applications = result.fetchall() + + migrated_count = 0 + for old_app_row in old_applications: + old_app = dict(old_app_row._mapping) + + # Determine type based on variant + variant = old_app.get("variant", "QSM") + app_type = qsm_type if variant == "QSM" else vsm_type + + # Migrate application + new_app = migrate_old_application(session, old_app, app_type) + session.add(new_app) + + # Create history entry + history = ApplicationHistory( + application_id=new_app.id, + action="migrated", + comment=f"Migrated from old {variant} application", + created_at=datetime.utcnow() + ) + session.add(history) + + migrated_count += 1 + + if migrated_count % 100 == 0: + session.commit() + logger.info(f"Migrated {migrated_count} applications...") + + session.commit() + logger.info(f"Successfully migrated {migrated_count} applications") + + except Exception as e: + logger.warning(f"Could not migrate old applications: {e}") + logger.info("This is normal if running on a fresh database") + + logger.info("Migration completed successfully!") + + except Exception as e: + logger.error(f"Migration failed: {e}") + session.rollback() + raise + + finally: + session.close() + + +if __name__ == "__main__": + main() diff --git a/backend/src/api/application_types.py b/backend/src/api/application_types.py new file mode 100644 index 00000000..964283c5 --- /dev/null +++ b/backend/src/api/application_types.py @@ -0,0 +1,611 @@ +""" +API routes for dynamic application type management +""" + +from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Form +from sqlalchemy.orm import Session +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, Field +import json + +from ..config.database import get_db +from ..models.application_type import ( + ApplicationType, ApplicationField, ApplicationTypeStatus, + StatusTransition, FieldType, TransitionTriggerType +) +from ..models.user import User +from ..services.auth_service import get_current_user, require_admin +from ..utils.pdf_utils import validate_pdf_template, extract_pdf_fields + + +router = APIRouter(prefix="/application-types", tags=["Application Types"]) + + +# Pydantic models +class FieldDefinition(BaseModel): + field_id: str + field_type: str + name: str + label: Optional[str] = None + description: Optional[str] = None + field_order: int = 0 + is_required: bool = False + is_readonly: bool = False + is_hidden: bool = False + options: Optional[List[str]] = None + default_value: Optional[str] = None + validation_rules: Optional[Dict[str, Any]] = None + display_conditions: Optional[Dict[str, Any]] = None + placeholder: Optional[str] = None + section: Optional[str] = None + + +class StatusDefinition(BaseModel): + status_id: str + name: str + description: Optional[str] = None + is_editable: bool = True + color: Optional[str] = None + icon: Optional[str] = None + display_order: int = 0 + is_initial: bool = False + is_final: bool = False + is_cancelled: bool = False + send_notification: bool = False + notification_template: Optional[str] = None + + +class TransitionDefinition(BaseModel): + from_status_id: str + to_status_id: str + name: str + trigger_type: str + trigger_config: Dict[str, Any] = Field(default_factory=dict) + conditions: Optional[Dict[str, Any]] = None + actions: Optional[List[Dict[str, Any]]] = None + priority: int = 0 + is_active: bool = True + + +class ApplicationTypeCreate(BaseModel): + type_id: str + name: str + description: Optional[str] = None + fields: List[FieldDefinition] + statuses: List[StatusDefinition] + transitions: List[TransitionDefinition] + pdf_field_mapping: Dict[str, str] = Field(default_factory=dict) + is_active: bool = True + is_public: bool = True + allowed_roles: Optional[List[str]] = None + max_cost_positions: int = 100 + max_comparison_offers: int = 100 + + +class ApplicationTypeUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + is_public: Optional[bool] = None + allowed_roles: Optional[List[str]] = None + max_cost_positions: Optional[int] = None + max_comparison_offers: Optional[int] = None + + +class ApplicationTypeResponse(BaseModel): + id: int + type_id: str + name: str + description: Optional[str] + is_active: bool + is_public: bool + allowed_roles: List[str] + max_cost_positions: int + max_comparison_offers: int + version: str + usage_count: int + pdf_template_filename: Optional[str] + fields: List[FieldDefinition] + statuses: List[StatusDefinition] + transitions: List[TransitionDefinition] + created_at: str + updated_at: str + + class Config: + from_attributes = True + + +@router.get("/", response_model=List[ApplicationTypeResponse]) +async def get_application_types( + include_inactive: bool = False, + current_user: Optional[User] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get all application types""" + query = db.query(ApplicationType) + + if not include_inactive: + query = query.filter(ApplicationType.is_active == True) + + # Non-admin users only see public types or types they have access to + if current_user and not current_user.has_role("admin"): + query = query.filter( + (ApplicationType.is_public == True) | + (ApplicationType.allowed_roles.contains([role.name for role in current_user.roles])) + ) + elif not current_user: + # Anonymous users only see public types + query = query.filter(ApplicationType.is_public == True) + + types = query.all() + + result = [] + for app_type in types: + type_dict = { + "id": app_type.id, + "type_id": app_type.type_id, + "name": app_type.name, + "description": app_type.description, + "is_active": app_type.is_active, + "is_public": app_type.is_public, + "allowed_roles": app_type.allowed_roles or [], + "max_cost_positions": app_type.max_cost_positions, + "max_comparison_offers": app_type.max_comparison_offers, + "version": app_type.version, + "usage_count": app_type.usage_count, + "pdf_template_filename": app_type.pdf_template_filename, + "created_at": app_type.created_at.isoformat(), + "updated_at": app_type.updated_at.isoformat(), + "fields": [], + "statuses": [], + "transitions": [] + } + + # Add fields + for field in app_type.fields: + type_dict["fields"].append({ + "field_id": field.field_id, + "field_type": field.field_type.value, + "name": field.name, + "label": field.label, + "description": field.description, + "field_order": field.field_order, + "is_required": field.is_required, + "is_readonly": field.is_readonly, + "is_hidden": field.is_hidden, + "options": field.options, + "default_value": field.default_value, + "validation_rules": field.validation_rules, + "display_conditions": field.display_conditions, + "placeholder": field.placeholder, + "section": field.section + }) + + # Add statuses and transitions + status_map = {} + for status in app_type.statuses: + status_dict = { + "status_id": status.status_id, + "name": status.name, + "description": status.description, + "is_editable": status.is_editable, + "color": status.color, + "icon": status.icon, + "display_order": status.display_order, + "is_initial": status.is_initial, + "is_final": status.is_final, + "is_cancelled": status.is_cancelled, + "send_notification": status.send_notification, + "notification_template": status.notification_template + } + type_dict["statuses"].append(status_dict) + status_map[status.id] = status.status_id + + # Add transitions + for status in app_type.statuses: + for transition in status.transitions_from: + type_dict["transitions"].append({ + "from_status_id": status_map.get(transition.from_status_id), + "to_status_id": status_map.get(transition.to_status_id), + "name": transition.name, + "trigger_type": transition.trigger_type.value, + "trigger_config": transition.trigger_config, + "conditions": transition.conditions, + "actions": transition.actions, + "priority": transition.priority, + "is_active": transition.is_active + }) + + result.append(ApplicationTypeResponse(**type_dict)) + + return result + + +@router.get("/{type_id}", response_model=ApplicationTypeResponse) +async def get_application_type( + type_id: str, + current_user: Optional[User] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get a specific application type""" + app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first() + + if not app_type: + raise HTTPException(status_code=404, detail="Application type not found") + + # Check access + if not app_type.is_public: + if not current_user: + raise HTTPException(status_code=403, detail="Access denied") + if not current_user.has_role("admin"): + if app_type.allowed_roles and not current_user.has_any_role(app_type.allowed_roles): + raise HTTPException(status_code=403, detail="Access denied") + + # Build response + type_dict = { + "id": app_type.id, + "type_id": app_type.type_id, + "name": app_type.name, + "description": app_type.description, + "is_active": app_type.is_active, + "is_public": app_type.is_public, + "allowed_roles": app_type.allowed_roles or [], + "max_cost_positions": app_type.max_cost_positions, + "max_comparison_offers": app_type.max_comparison_offers, + "version": app_type.version, + "usage_count": app_type.usage_count, + "pdf_template_filename": app_type.pdf_template_filename, + "created_at": app_type.created_at.isoformat(), + "updated_at": app_type.updated_at.isoformat(), + "fields": [], + "statuses": [], + "transitions": [] + } + + # Add fields + for field in app_type.fields: + type_dict["fields"].append({ + "field_id": field.field_id, + "field_type": field.field_type.value, + "name": field.name, + "label": field.label, + "description": field.description, + "field_order": field.field_order, + "is_required": field.is_required, + "is_readonly": field.is_readonly, + "is_hidden": field.is_hidden, + "options": field.options, + "default_value": field.default_value, + "validation_rules": field.validation_rules, + "display_conditions": field.display_conditions, + "placeholder": field.placeholder, + "section": field.section + }) + + # Add statuses and transitions + status_map = {} + for status in app_type.statuses: + status_dict = { + "status_id": status.status_id, + "name": status.name, + "description": status.description, + "is_editable": status.is_editable, + "color": status.color, + "icon": status.icon, + "display_order": status.display_order, + "is_initial": status.is_initial, + "is_final": status.is_final, + "is_cancelled": status.is_cancelled, + "send_notification": status.send_notification, + "notification_template": status.notification_template + } + type_dict["statuses"].append(status_dict) + status_map[status.id] = status.status_id + + # Add transitions + for status in app_type.statuses: + for transition in status.transitions_from: + type_dict["transitions"].append({ + "from_status_id": status_map.get(transition.from_status_id), + "to_status_id": status_map.get(transition.to_status_id), + "name": transition.name, + "trigger_type": transition.trigger_type.value, + "trigger_config": transition.trigger_config, + "conditions": transition.conditions, + "actions": transition.actions, + "priority": transition.priority, + "is_active": transition.is_active + }) + + return ApplicationTypeResponse(**type_dict) + + +@router.post("/", response_model=ApplicationTypeResponse) +async def create_application_type( + type_data: str = Form(...), + pdf_template: Optional[UploadFile] = File(None), + current_user: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """Create a new application type (admin only)""" + try: + data = json.loads(type_data) + type_create = ApplicationTypeCreate(**data) + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}") + + # Check if type_id already exists + existing = db.query(ApplicationType).filter(ApplicationType.type_id == type_create.type_id).first() + if existing: + raise HTTPException(status_code=400, detail="Application type with this ID already exists") + + # Create application type + app_type = ApplicationType( + type_id=type_create.type_id, + name=type_create.name, + description=type_create.description, + pdf_field_mapping=type_create.pdf_field_mapping, + is_active=type_create.is_active, + is_public=type_create.is_public, + allowed_roles=type_create.allowed_roles, + max_cost_positions=type_create.max_cost_positions, + max_comparison_offers=type_create.max_comparison_offers + ) + + # Handle PDF template upload + if pdf_template: + pdf_content = await pdf_template.read() + app_type.pdf_template = pdf_content + app_type.pdf_template_filename = pdf_template.filename + + # Extract and validate PDF fields + try: + pdf_fields = extract_pdf_fields(pdf_content) + # Validate mapping + for pdf_field in type_create.pdf_field_mapping.keys(): + if pdf_field not in pdf_fields: + raise ValueError(f"PDF field '{pdf_field}' not found in template") + except Exception as e: + raise HTTPException(status_code=400, detail=f"PDF validation failed: {str(e)}") + + db.add(app_type) + db.flush() + + # Create fields + for field_def in type_create.fields: + field = ApplicationField( + application_type_id=app_type.id, + field_id=field_def.field_id, + field_type=FieldType(field_def.field_type), + name=field_def.name, + label=field_def.label, + description=field_def.description, + field_order=field_def.field_order, + is_required=field_def.is_required, + is_readonly=field_def.is_readonly, + is_hidden=field_def.is_hidden, + options=field_def.options, + default_value=field_def.default_value, + validation_rules=field_def.validation_rules, + display_conditions=field_def.display_conditions, + placeholder=field_def.placeholder, + section=field_def.section + ) + db.add(field) + + # Create statuses + status_map = {} + for status_def in type_create.statuses: + status = ApplicationTypeStatus( + application_type_id=app_type.id, + status_id=status_def.status_id, + name=status_def.name, + description=status_def.description, + is_editable=status_def.is_editable, + color=status_def.color, + icon=status_def.icon, + display_order=status_def.display_order, + is_initial=status_def.is_initial, + is_final=status_def.is_final, + is_cancelled=status_def.is_cancelled, + send_notification=status_def.send_notification, + notification_template=status_def.notification_template + ) + db.add(status) + db.flush() + status_map[status_def.status_id] = status + + # Create transitions + for trans_def in type_create.transitions: + from_status = status_map.get(trans_def.from_status_id) + to_status = status_map.get(trans_def.to_status_id) + + if not from_status or not to_status: + raise HTTPException(status_code=400, detail=f"Invalid status in transition: {trans_def.from_status_id} -> {trans_def.to_status_id}") + + transition = StatusTransition( + from_status_id=from_status.id, + to_status_id=to_status.id, + name=trans_def.name, + trigger_type=TransitionTriggerType(trans_def.trigger_type), + trigger_config=trans_def.trigger_config, + conditions=trans_def.conditions, + actions=trans_def.actions, + priority=trans_def.priority, + is_active=trans_def.is_active + ) + db.add(transition) + + db.commit() + db.refresh(app_type) + + # Return created type + return await get_application_type(app_type.type_id, current_user, db) + + +@router.put("/{type_id}", response_model=ApplicationTypeResponse) +async def update_application_type( + type_id: str, + update_data: ApplicationTypeUpdate, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """Update an application type (admin only)""" + app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first() + + if not app_type: + raise HTTPException(status_code=404, detail="Application type not found") + + # Update fields + if update_data.name is not None: + app_type.name = update_data.name + if update_data.description is not None: + app_type.description = update_data.description + if update_data.is_active is not None: + app_type.is_active = update_data.is_active + if update_data.is_public is not None: + app_type.is_public = update_data.is_public + if update_data.allowed_roles is not None: + app_type.allowed_roles = update_data.allowed_roles + if update_data.max_cost_positions is not None: + app_type.max_cost_positions = update_data.max_cost_positions + if update_data.max_comparison_offers is not None: + app_type.max_comparison_offers = update_data.max_comparison_offers + + db.commit() + db.refresh(app_type) + + return await get_application_type(app_type.type_id, current_user, db) + + +@router.post("/{type_id}/pdf-template") +async def upload_pdf_template( + type_id: str, + pdf_template: UploadFile = File(...), + current_user: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """Upload or update PDF template for an application type""" + app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first() + + if not app_type: + raise HTTPException(status_code=404, detail="Application type not found") + + # Read and validate PDF + pdf_content = await pdf_template.read() + + try: + pdf_fields = extract_pdf_fields(pdf_content) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid PDF template: {str(e)}") + + # Update template + app_type.pdf_template = pdf_content + app_type.pdf_template_filename = pdf_template.filename + + db.commit() + + return { + "message": "PDF template uploaded successfully", + "filename": pdf_template.filename, + "fields": pdf_fields + } + + +@router.delete("/{type_id}") +async def delete_application_type( + type_id: str, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """Delete an application type (admin only)""" + app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first() + + if not app_type: + raise HTTPException(status_code=404, detail="Application type not found") + + # Check if type has been used + if app_type.usage_count > 0: + # Instead of deleting, deactivate it + app_type.is_active = False + db.commit() + return {"message": "Application type deactivated (has existing applications)"} + + db.delete(app_type) + db.commit() + + return {"message": "Application type deleted successfully"} + + +@router.post("/{type_id}/fields") +async def add_field_to_type( + type_id: str, + field: FieldDefinition, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """Add a field to an application type""" + app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first() + + if not app_type: + raise HTTPException(status_code=404, detail="Application type not found") + + # Check if field_id already exists + existing = db.query(ApplicationField).filter( + ApplicationField.application_type_id == app_type.id, + ApplicationField.field_id == field.field_id + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Field with this ID already exists") + + new_field = ApplicationField( + application_type_id=app_type.id, + field_id=field.field_id, + field_type=FieldType(field.field_type), + name=field.name, + label=field.label, + description=field.description, + field_order=field.field_order, + is_required=field.is_required, + is_readonly=field.is_readonly, + is_hidden=field.is_hidden, + options=field.options, + default_value=field.default_value, + validation_rules=field.validation_rules, + display_conditions=field.display_conditions, + placeholder=field.placeholder, + section=field.section + ) + + db.add(new_field) + db.commit() + + return {"message": "Field added successfully"} + + +@router.delete("/{type_id}/fields/{field_id}") +async def remove_field_from_type( + type_id: str, + field_id: str, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """Remove a field from an application type""" + app_type = db.query(ApplicationType).filter(ApplicationType.type_id == type_id).first() + + if not app_type: + raise HTTPException(status_code=404, detail="Application type not found") + + field = db.query(ApplicationField).filter( + ApplicationField.application_type_id == app_type.id, + ApplicationField.field_id == field_id + ).first() + + if not field: + raise HTTPException(status_code=404, detail="Field not found") + + db.delete(field) + db.commit() + + return {"message": "Field removed successfully"} diff --git a/backend/src/api/dynamic_applications.py b/backend/src/api/dynamic_applications.py new file mode 100644 index 00000000..23887c5b --- /dev/null +++ b/backend/src/api/dynamic_applications.py @@ -0,0 +1,831 @@ +""" +API routes for dynamic application management +""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import or_, and_, desc +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, Field, validator +from datetime import datetime, timedelta +import secrets +import hashlib + +from ..config.database import get_db +from ..models.application_type import ( + ApplicationType, ApplicationField, ApplicationTypeStatus, + DynamicApplication, ApplicationHistory, ApplicationAttachment, + ApplicationTransitionLog, ApplicationApproval, TransitionTriggerType +) +from ..models.user import User +from ..services.auth_service import get_current_user, get_optional_user +from ..services.notification_service import send_notification +from ..services.pdf_service import generate_pdf_for_application +from ..utils.validators import validate_field_value + + +router = APIRouter(prefix="/applications", tags=["Dynamic Applications"]) + + +# Pydantic models +class CostPosition(BaseModel): + description: str + amount: float + category: Optional[str] = None + notes: Optional[str] = None + + +class ComparisonOffer(BaseModel): + vendor: str + description: str + amount: float + selected: bool = False + notes: Optional[str] = None + + +class ApplicationCreate(BaseModel): + application_type_id: str + title: str + field_data: Dict[str, Any] = Field(default_factory=dict) + cost_positions: Optional[List[CostPosition]] = None + comparison_offers: Optional[List[ComparisonOffer]] = None + + @validator('cost_positions') + def validate_cost_positions(cls, v, values): + if v and len(v) > 100: + raise ValueError("Maximum 100 cost positions allowed") + return v + + @validator('comparison_offers') + def validate_comparison_offers(cls, v, values): + if v and len(v) > 100: + raise ValueError("Maximum 100 comparison offers allowed") + return v + + +class ApplicationUpdate(BaseModel): + title: Optional[str] = None + field_data: Optional[Dict[str, Any]] = None + cost_positions: Optional[List[CostPosition]] = None + comparison_offers: Optional[List[ComparisonOffer]] = None + + +class ApplicationResponse(BaseModel): + id: int + application_id: str + application_type_id: int + type_name: str + email: str + status_id: str + status_name: str + title: str + first_name: Optional[str] + last_name: Optional[str] + total_amount: float + field_data: Dict[str, Any] + cost_positions: List[Dict[str, Any]] + comparison_offers: List[Dict[str, Any]] + submitted_at: Optional[datetime] + status_changed_at: Optional[datetime] + created_at: datetime + updated_at: datetime + can_edit: bool + available_actions: List[str] + + class Config: + from_attributes = True + + +class ApplicationListResponse(BaseModel): + id: int + application_id: str + type_name: str + title: str + email: str + status_id: str + status_name: str + total_amount: float + submitted_at: Optional[datetime] + created_at: datetime + + class Config: + from_attributes = True + + +class StatusTransitionRequest(BaseModel): + new_status_id: str + comment: Optional[str] = None + trigger_data: Optional[Dict[str, Any]] = None + + +class ApprovalRequest(BaseModel): + decision: str # approve, reject, abstain + comment: Optional[str] = None + + +@router.get("/", response_model=List[ApplicationListResponse]) +async def list_applications( + type_id: Optional[str] = None, + status_id: Optional[str] = None, + email: Optional[str] = None, + search: Optional[str] = None, + submitted_after: Optional[datetime] = None, + submitted_before: Optional[datetime] = None, + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + current_user: Optional[User] = Depends(get_optional_user), + db: Session = Depends(get_db) +): + """List applications with filtering""" + query = db.query(DynamicApplication).join(ApplicationType) + + # Filter by type + if type_id: + query = query.filter(ApplicationType.type_id == type_id) + + # Filter by status + if status_id: + query = query.filter(DynamicApplication.status_id == status_id) + + # Filter by email (for users to see their own applications) + if email: + query = query.filter(DynamicApplication.email == email) + elif current_user and not current_user.has_role("admin"): + # Non-admin users only see their own applications + query = query.filter(DynamicApplication.email == current_user.email) + + # Search + if search: + search_term = f"%{search}%" + query = query.filter( + or_( + DynamicApplication.title.ilike(search_term), + DynamicApplication.email.ilike(search_term), + DynamicApplication.search_text.ilike(search_term) + ) + ) + + # Date filters + if submitted_after: + query = query.filter(DynamicApplication.submitted_at >= submitted_after) + if submitted_before: + query = query.filter(DynamicApplication.submitted_at <= submitted_before) + + # Order and paginate + query = query.order_by(desc(DynamicApplication.created_at)) + applications = query.offset(offset).limit(limit).all() + + # Build response + result = [] + for app in applications: + # Get status name + status = db.query(ApplicationTypeStatus).filter( + ApplicationTypeStatus.application_type_id == app.application_type_id, + ApplicationTypeStatus.status_id == app.status_id + ).first() + + result.append({ + "id": app.id, + "application_id": app.application_id, + "type_name": app.application_type.name, + "title": app.title, + "email": app.email, + "status_id": app.status_id, + "status_name": status.name if status else app.status_id, + "total_amount": app.total_amount, + "submitted_at": app.submitted_at, + "created_at": app.created_at + }) + + return result + + +@router.get("/{application_id}", response_model=ApplicationResponse) +async def get_application( + application_id: str, + access_key: Optional[str] = None, + current_user: Optional[User] = Depends(get_optional_user), + db: Session = Depends(get_db) +): + """Get application details""" + app = db.query(DynamicApplication).filter( + DynamicApplication.application_id == application_id + ).first() + + if not app: + raise HTTPException(status_code=404, detail="Application not found") + + # Check access + has_access = False + if current_user: + if current_user.has_role("admin"): + has_access = True + elif app.email == current_user.email: + has_access = True + elif access_key: + # Verify access key + key_hash = hashlib.sha256(access_key.encode()).hexdigest() + if app.application_key == key_hash: + has_access = True + + if not has_access: + raise HTTPException(status_code=403, detail="Access denied") + + # Get status details + status = db.query(ApplicationTypeStatus).filter( + ApplicationTypeStatus.application_type_id == app.application_type_id, + ApplicationTypeStatus.status_id == app.status_id + ).first() + + # Determine if editable + can_edit = False + if status and status.is_editable: + if current_user and (current_user.has_role("admin") or app.email == current_user.email): + can_edit = True + elif access_key: + can_edit = True + + # Get available actions + available_actions = [] + if current_user: + # Check for available transitions + transitions = db.query(StatusTransition).filter( + StatusTransition.from_status_id == status.id, + StatusTransition.is_active == True + ).all() + + for trans in transitions: + if trans.trigger_type == TransitionTriggerType.APPLICANT_ACTION: + if app.email == current_user.email: + available_actions.append(trans.name) + elif trans.trigger_type == TransitionTriggerType.USER_APPROVAL: + config = trans.trigger_config or {} + required_role = config.get("role") + if required_role and current_user.has_role(required_role): + available_actions.append(trans.name) + + return ApplicationResponse( + id=app.id, + application_id=app.application_id, + application_type_id=app.application_type_id, + type_name=app.application_type.name, + email=app.email, + status_id=app.status_id, + status_name=status.name if status else app.status_id, + title=app.title, + first_name=app.first_name, + last_name=app.last_name, + total_amount=app.total_amount, + field_data=app.field_data or {}, + cost_positions=app.cost_positions or [], + comparison_offers=app.comparison_offers or [], + submitted_at=app.submitted_at, + status_changed_at=app.status_changed_at, + created_at=app.created_at, + updated_at=app.updated_at, + can_edit=can_edit, + available_actions=available_actions + ) + + +@router.post("/", response_model=Dict[str, Any]) +async def create_application( + application_data: ApplicationCreate, + background_tasks: BackgroundTasks, + current_user: Optional[User] = Depends(get_optional_user), + db: Session = Depends(get_db) +): + """Create a new application""" + # Get application type + app_type = db.query(ApplicationType).filter( + ApplicationType.type_id == application_data.application_type_id, + ApplicationType.is_active == True + ).first() + + if not app_type: + raise HTTPException(status_code=404, detail="Application type not found or inactive") + + # Check access to type + if not app_type.is_public: + if not current_user: + raise HTTPException(status_code=403, detail="Authentication required") + if app_type.allowed_roles and not current_user.has_any_role(app_type.allowed_roles): + raise HTTPException(status_code=403, detail="Not authorized for this application type") + + # Validate fields + for field in app_type.fields: + if field.is_required and field.field_id not in application_data.field_data: + raise HTTPException(status_code=400, detail=f"Required field missing: {field.name}") + + if field.field_id in application_data.field_data: + value = application_data.field_data[field.field_id] + try: + validate_field_value(value, field) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Get initial status + initial_status = db.query(ApplicationTypeStatus).filter( + ApplicationTypeStatus.application_type_id == app_type.id, + ApplicationTypeStatus.is_initial == True + ).first() + + if not initial_status: + # Fallback to first status + initial_status = db.query(ApplicationTypeStatus).filter( + ApplicationTypeStatus.application_type_id == app_type.id + ).order_by(ApplicationTypeStatus.display_order).first() + + if not initial_status: + raise HTTPException(status_code=500, detail="No status defined for application type") + + # Generate application ID and access key + app_id = secrets.token_urlsafe(16) + access_key = secrets.token_urlsafe(32) + key_hash = hashlib.sha256(access_key.encode()).hexdigest() + + # Create application + application = DynamicApplication( + application_id=app_id, + application_key=key_hash, + application_type_id=app_type.id, + user_id=current_user.id if current_user else None, + email=current_user.email if current_user else application_data.field_data.get("email", ""), + status_id=initial_status.status_id, + title=application_data.title, + first_name=current_user.given_name if current_user else application_data.field_data.get("first_name"), + last_name=current_user.family_name if current_user else application_data.field_data.get("last_name"), + field_data=application_data.field_data, + cost_positions=[cp.dict() for cp in application_data.cost_positions] if application_data.cost_positions else [], + comparison_offers=[co.dict() for co in application_data.comparison_offers] if application_data.comparison_offers else [] + ) + + # Calculate total amount + application.calculate_total_amount() + + # Update search text + application.update_search_text() + + db.add(application) + db.flush() + + # Create history entry + history = ApplicationHistory( + application_id=application.id, + user_id=current_user.id if current_user else None, + action="created", + comment="Application created" + ) + db.add(history) + + # Update usage count + app_type.usage_count += 1 + + db.commit() + db.refresh(application) + + # Send notification + if initial_status.send_notification: + background_tasks.add_task( + send_notification, + application.email, + "Application Created", + initial_status.notification_template or f"Your application {app_id} has been created." + ) + + return { + "application_id": app_id, + "access_key": access_key, + "access_url": f"/applications/{app_id}?key={access_key}", + "status": initial_status.status_id + } + + +@router.put("/{application_id}", response_model=ApplicationResponse) +async def update_application( + application_id: str, + update_data: ApplicationUpdate, + access_key: Optional[str] = None, + current_user: Optional[User] = Depends(get_optional_user), + db: Session = Depends(get_db) +): + """Update an application""" + app = db.query(DynamicApplication).filter( + DynamicApplication.application_id == application_id + ).first() + + if not app: + raise HTTPException(status_code=404, detail="Application not found") + + # Check access and editability + has_access = False + if current_user: + if current_user.has_role("admin"): + has_access = True + elif app.email == current_user.email: + has_access = True + elif access_key: + key_hash = hashlib.sha256(access_key.encode()).hexdigest() + if app.application_key == key_hash: + has_access = True + + if not has_access: + raise HTTPException(status_code=403, detail="Access denied") + + # Check if status allows editing + status = db.query(ApplicationTypeStatus).filter( + ApplicationTypeStatus.application_type_id == app.application_type_id, + ApplicationTypeStatus.status_id == app.status_id + ).first() + + if not status or not status.is_editable: + raise HTTPException(status_code=400, detail="Application cannot be edited in current status") + + # Track changes + changes = {} + + # Update fields + if update_data.title is not None: + changes["title"] = {"old": app.title, "new": update_data.title} + app.title = update_data.title + + if update_data.field_data is not None: + # Validate new field data + app_type = app.application_type + for field in app_type.fields: + if field.is_required and field.field_id not in update_data.field_data: + raise HTTPException(status_code=400, detail=f"Required field missing: {field.name}") + + if field.field_id in update_data.field_data: + value = update_data.field_data[field.field_id] + try: + validate_field_value(value, field) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + changes["field_data"] = {"old": app.field_data, "new": update_data.field_data} + app.field_data = update_data.field_data + + if update_data.cost_positions is not None: + cost_data = [cp.dict() for cp in update_data.cost_positions] + changes["cost_positions"] = {"old": app.cost_positions, "new": cost_data} + app.cost_positions = cost_data + app.calculate_total_amount() + + if update_data.comparison_offers is not None: + offer_data = [co.dict() for co in update_data.comparison_offers] + changes["comparison_offers"] = {"old": app.comparison_offers, "new": offer_data} + app.comparison_offers = offer_data + + # Update search text + app.update_search_text() + + # Create history entry + if changes: + history = ApplicationHistory( + application_id=app.id, + user_id=current_user.id if current_user else None, + action="updated", + field_changes=changes, + comment="Application updated" + ) + db.add(history) + + db.commit() + db.refresh(app) + + return await get_application(application_id, access_key, current_user, db) + + +@router.post("/{application_id}/submit") +async def submit_application( + application_id: str, + access_key: Optional[str] = None, + background_tasks: BackgroundTasks = None, + current_user: Optional[User] = Depends(get_optional_user), + db: Session = Depends(get_db) +): + """Submit an application for review""" + app = db.query(DynamicApplication).filter( + DynamicApplication.application_id == application_id + ).first() + + if not app: + raise HTTPException(status_code=404, detail="Application not found") + + # Check access + has_access = False + if current_user and app.email == current_user.email: + has_access = True + elif access_key: + key_hash = hashlib.sha256(access_key.encode()).hexdigest() + if app.application_key == key_hash: + has_access = True + + if not has_access: + raise HTTPException(status_code=403, detail="Access denied") + + # Check if already submitted + if app.submitted_at: + raise HTTPException(status_code=400, detail="Application already submitted") + + # Find submit transition + current_status = db.query(ApplicationTypeStatus).filter( + ApplicationTypeStatus.application_type_id == app.application_type_id, + ApplicationTypeStatus.status_id == app.status_id + ).first() + + if not current_status: + raise HTTPException(status_code=500, detail="Current status not found") + + # Find transition for submit action + from ..models.application_type import StatusTransition + transition = db.query(StatusTransition).filter( + StatusTransition.from_status_id == current_status.id, + StatusTransition.trigger_type == TransitionTriggerType.APPLICANT_ACTION, + StatusTransition.is_active == True + ).first() + + if not transition: + raise HTTPException(status_code=400, detail="Submit action not available in current status") + + # Get target status + target_status = db.query(ApplicationTypeStatus).filter( + ApplicationTypeStatus.id == transition.to_status_id + ).first() + + if not target_status: + raise HTTPException(status_code=500, detail="Target status not found") + + # Update application + app.submitted_at = datetime.utcnow() + app.status_id = target_status.status_id + app.status_changed_at = datetime.utcnow() + + # Log transition + transition_log = ApplicationTransitionLog( + application_id=app.id, + from_status=current_status.status_id, + to_status=target_status.status_id, + transition_name=transition.name, + trigger_type=TransitionTriggerType.APPLICANT_ACTION.value, + triggered_by=current_user.id if current_user else None + ) + db.add(transition_log) + + # Create history entry + history = ApplicationHistory( + application_id=app.id, + user_id=current_user.id if current_user else None, + action="submitted", + comment="Application submitted for review" + ) + db.add(history) + + db.commit() + + # Send notification + if target_status.send_notification and background_tasks: + background_tasks.add_task( + send_notification, + app.email, + "Application Submitted", + target_status.notification_template or f"Your application {app.application_id} has been submitted." + ) + + return { + "message": "Application submitted successfully", + "new_status": target_status.status_id + } + + +@router.post("/{application_id}/transition") +async def transition_application_status( + application_id: str, + transition_request: StatusTransitionRequest, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Transition application to a new status""" + app = db.query(DynamicApplication).filter( + DynamicApplication.application_id == application_id + ).first() + + if not app: + raise HTTPException(status_code=404, detail="Application not found") + + # Check admin access + if not current_user.has_role("admin"): + raise HTTPException(status_code=403, detail="Admin access required") + + # Get current and target status + current_status = db.query(ApplicationTypeStatus).filter( + ApplicationTypeStatus.application_type_id == app.application_type_id, + ApplicationTypeStatus.status_id == app.status_id + ).first() + + target_status = db.query(ApplicationTypeStatus).filter( + ApplicationTypeStatus.application_type_id == app.application_type_id, + ApplicationTypeStatus.status_id == transition_request.new_status_id + ).first() + + if not current_status or not target_status: + raise HTTPException(status_code=400, detail="Invalid status") + + # Check if transition is valid + from ..models.application_type import StatusTransition + transition = db.query(StatusTransition).filter( + StatusTransition.from_status_id == current_status.id, + StatusTransition.to_status_id == target_status.id, + StatusTransition.is_active == True + ).first() + + if not transition: + raise HTTPException(status_code=400, detail="Invalid status transition") + + # Update application + app.status_id = target_status.status_id + app.status_changed_at = datetime.utcnow() + + if target_status.is_final: + app.completed_at = datetime.utcnow() + + # Log transition + transition_log = ApplicationTransitionLog( + application_id=app.id, + from_status=current_status.status_id, + to_status=target_status.status_id, + transition_name=transition.name, + trigger_type=transition.trigger_type.value, + triggered_by=current_user.id, + trigger_data=transition_request.trigger_data + ) + db.add(transition_log) + + # Create history entry + history = ApplicationHistory( + application_id=app.id, + user_id=current_user.id, + action="status_changed", + comment=transition_request.comment or f"Status changed from {current_status.name} to {target_status.name}" + ) + db.add(history) + + db.commit() + + # Send notification + if target_status.send_notification: + background_tasks.add_task( + send_notification, + app.email, + f"Application Status Changed: {target_status.name}", + target_status.notification_template or f"Your application {app.application_id} status has been updated to {target_status.name}." + ) + + return { + "message": "Status changed successfully", + "new_status": target_status.status_id, + "new_status_name": target_status.name + } + + +@router.post("/{application_id}/approve") +async def approve_application( + application_id: str, + approval: ApprovalRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Approve or reject an application""" + app = db.query(DynamicApplication).filter( + DynamicApplication.application_id == application_id + ).first() + + if not app: + raise HTTPException(status_code=404, detail="Application not found") + + # Determine user's role for this approval + approval_role = None + if current_user.has_role("budget_reviewer"): + approval_role = "budget_reviewer" + elif current_user.has_role("finance_reviewer"): + approval_role = "finance_reviewer" + elif current_user.has_role("asta_member"): + approval_role = "asta_member" + else: + raise HTTPException(status_code=403, detail="No approval permission") + + # Check if already approved by this user + existing = db.query(ApplicationApproval).filter( + ApplicationApproval.application_id == app.id, + ApplicationApproval.user_id == current_user.id, + ApplicationApproval.role == approval_role + ).first() + + if existing: + # Update existing approval + existing.decision = approval.decision + existing.comment = approval.comment + existing.updated_at = datetime.utcnow() + else: + # Create new approval + new_approval = ApplicationApproval( + application_id=app.id, + user_id=current_user.id, + role=approval_role, + decision=approval.decision, + comment=approval.comment, + status_at_approval=app.status_id + ) + db.add(new_approval) + + # Create history entry + history = ApplicationHistory( + application_id=app.id, + user_id=current_user.id, + action=f"{approval_role}_{approval.decision}", + comment=approval.comment or f"{approval_role} {approval.decision}" + ) + db.add(history) + + # Check if this triggers a status transition + # (This would be implemented based on transition rules) + + db.commit() + + return { + "message": f"Approval recorded: {approval.decision}", + "role": approval_role, + "decision": approval.decision + } + + +@router.get("/{application_id}/history") +async def get_application_history( + application_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get application history""" + app = db.query(DynamicApplication).filter( + DynamicApplication.application_id == application_id + ).first() + + if not app: + raise HTTPException(status_code=404, detail="Application not found") + + # Check access + if not current_user.has_role("admin") and app.email != current_user.email: + raise HTTPException(status_code=403, detail="Access denied") + + history = db.query(ApplicationHistory).filter( + ApplicationHistory.application_id == app.id + ).order_by(desc(ApplicationHistory.created_at)).all() + + result = [] + for entry in history: + result.append({ + "id": entry.id, + "action": entry.action, + "comment": entry.comment, + "field_changes": entry.field_changes, + "user_id": entry.user_id, + "created_at": entry.created_at + }) + + return result + + +@router.post("/{application_id}/generate-pdf") +async def generate_application_pdf( + application_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Generate PDF for an application""" + app = db.query(DynamicApplication).filter( + DynamicApplication.application_id == application_id + ).first() + + if not app: + raise HTTPException(status_code=404, detail="Application not found") + + # Check access + if not current_user.has_role("admin") and app.email != current_user.email: + raise HTTPException(status_code=403, detail="Access denied") + + # Generate PDF + try: + pdf_path = generate_pdf_for_application(app, db) + app.pdf_generated = True + app.pdf_generated_at = datetime.utcnow() + app.pdf_file_path = pdf_path + db.commit() + + return { + "message": "PDF generated successfully", + "pdf_path": pdf_path + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}") diff --git a/backend/src/api/middleware/__init__.py b/backend/src/api/middleware/__init__.py new file mode 100644 index 00000000..94cce7c8 --- /dev/null +++ b/backend/src/api/middleware/__init__.py @@ -0,0 +1,11 @@ +"""API middleware modules.""" + +from .error_handler import ErrorHandlerMiddleware +from .logging import LoggingMiddleware +from .rate_limit import RateLimitMiddleware + +__all__ = [ + "ErrorHandlerMiddleware", + "LoggingMiddleware", + "RateLimitMiddleware", +] diff --git a/backend/src/api/middleware/error_handler.py b/backend/src/api/middleware/error_handler.py new file mode 100644 index 00000000..ae5fb0a4 --- /dev/null +++ b/backend/src/api/middleware/error_handler.py @@ -0,0 +1,220 @@ +"""Error handling middleware for API exceptions.""" + +import logging +import traceback +from typing import Optional +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from fastapi import status +from fastapi.exceptions import RequestValidationError, HTTPException +from pydantic import ValidationError +from sqlalchemy.exc import SQLAlchemyError + +logger = logging.getLogger(__name__) + + +class ErrorHandlerMiddleware(BaseHTTPMiddleware): + """Middleware for handling API errors and exceptions.""" + + def __init__(self, app): + """ + Initialize error handler middleware. + + Args: + app: The FastAPI application + """ + super().__init__(app) + + def _get_request_id(self, request: Request) -> str: + """ + Get request ID from request state. + + Args: + request: The incoming request + + Returns: + Request ID or 'unknown' + """ + return getattr(request.state, 'request_id', 'unknown') + + def _format_error_response( + self, + request: Request, + status_code: int, + error_type: str, + message: str, + details: Optional[dict] = None + ) -> JSONResponse: + """ + Format error response. + + Args: + request: The incoming request + status_code: HTTP status code + error_type: Type of error + message: Error message + details: Additional error details + + Returns: + JSONResponse with error information + """ + error_response = { + "error": { + "type": error_type, + "message": message, + "path": request.url.path, + "method": request.method, + "request_id": self._get_request_id(request) + } + } + + if details: + error_response["error"]["details"] = details + + return JSONResponse( + status_code=status_code, + content=error_response, + headers={ + "X-Request-ID": self._get_request_id(request), + "X-Error-Type": error_type + } + ) + + async def dispatch(self, request: Request, call_next): + """ + Process the request and handle any errors. + + Args: + request: The incoming request + call_next: The next middleware or endpoint + + Returns: + The response + """ + try: + response = await call_next(request) + return response + + except HTTPException as e: + # Handle FastAPI HTTP exceptions + logger.warning( + f"HTTP exception: {e.status_code} - {e.detail}", + extra={ + "request_id": self._get_request_id(request), + "status_code": e.status_code, + "path": request.url.path + } + ) + return self._format_error_response( + request=request, + status_code=e.status_code, + error_type="http_error", + message=str(e.detail), + details={"status_code": e.status_code} + ) + + except RequestValidationError as e: + # Handle validation errors + logger.warning( + f"Validation error: {str(e)}", + extra={ + "request_id": self._get_request_id(request), + "path": request.url.path, + "errors": e.errors() + } + ) + return self._format_error_response( + request=request, + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + error_type="validation_error", + message="Request validation failed", + details={"validation_errors": e.errors()} + ) + + except ValidationError as e: + # Handle Pydantic validation errors + logger.warning( + f"Pydantic validation error: {str(e)}", + extra={ + "request_id": self._get_request_id(request), + "path": request.url.path, + "errors": e.errors() + } + ) + return self._format_error_response( + request=request, + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + error_type="validation_error", + message="Data validation failed", + details={"validation_errors": e.errors()} + ) + + except SQLAlchemyError as e: + # Handle database errors + logger.error( + f"Database error: {str(e)}", + extra={ + "request_id": self._get_request_id(request), + "path": request.url.path, + "error": str(e) + }, + exc_info=True + ) + return self._format_error_response( + request=request, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + error_type="database_error", + message="A database error occurred", + details={"error": "Database operation failed"} + ) + + except ValueError as e: + # Handle value errors + logger.error( + f"Value error: {str(e)}", + extra={ + "request_id": self._get_request_id(request), + "path": request.url.path, + "error": str(e) + } + ) + return self._format_error_response( + request=request, + status_code=status.HTTP_400_BAD_REQUEST, + error_type="value_error", + message=str(e) + ) + + except Exception as e: + # Handle all other exceptions + error_id = self._get_request_id(request) + logger.error( + f"Unexpected error: {str(e)}", + extra={ + "request_id": error_id, + "path": request.url.path, + "error": str(e), + "traceback": traceback.format_exc() + }, + exc_info=True + ) + + # Determine if we should show detailed error (dev mode) + show_details = False # Set to True in development + + error_message = "An unexpected error occurred" + error_details = {"error_id": error_id} + + if show_details: + error_message = str(e) + error_details["exception"] = type(e).__name__ + error_details["traceback"] = traceback.format_exc().split('\n') + + return self._format_error_response( + request=request, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + error_type="internal_error", + message=error_message, + details=error_details + ) diff --git a/backend/src/api/middleware/logging.py b/backend/src/api/middleware/logging.py new file mode 100644 index 00000000..83a5d635 --- /dev/null +++ b/backend/src/api/middleware/logging.py @@ -0,0 +1,206 @@ +"""Logging middleware for API request/response tracking.""" + +import time +import json +import logging +from typing import Optional +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +import uuid + +logger = logging.getLogger(__name__) + + +class LoggingMiddleware(BaseHTTPMiddleware): + """Middleware for logging API requests and responses.""" + + def __init__(self, app): + """ + Initialize logging middleware. + + Args: + app: The FastAPI application + """ + super().__init__(app) + self.skip_paths = { + "/health", + "/ready", + "/docs", + "/redoc", + "/openapi.json", + "/favicon.ico" + } + + def _get_client_info(self, request: Request) -> dict: + """ + Extract client information from request. + + Args: + request: The incoming request + + Returns: + Dictionary containing client information + """ + client_info = {} + + # Get client IP + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + client_info["ip"] = forwarded_for.split(",")[0].strip() + elif real_ip := request.headers.get("X-Real-IP"): + client_info["ip"] = real_ip + elif request.client: + client_info["ip"] = request.client.host + else: + client_info["ip"] = "unknown" + + # Get user agent + client_info["user_agent"] = request.headers.get("User-Agent", "unknown") + + return client_info + + def _should_log_body(self, content_type: Optional[str]) -> bool: + """ + Determine if request/response body should be logged. + + Args: + content_type: The content type header value + + Returns: + Boolean indicating if body should be logged + """ + if not content_type: + return False + + loggable_types = [ + "application/json", + "application/x-www-form-urlencoded", + "text/plain", + "text/html", + "text/xml", + "application/xml" + ] + + return any(t in content_type.lower() for t in loggable_types) + + async def _get_request_body(self, request: Request) -> Optional[str]: + """ + Safely get request body for logging. + + Args: + request: The incoming request + + Returns: + Request body as string or None + """ + try: + # Check content type + content_type = request.headers.get("Content-Type", "") + + if not self._should_log_body(content_type): + return None + + # Don't log large bodies + content_length = request.headers.get("Content-Length") + if content_length and int(content_length) > 10000: # 10KB limit + return "[Body too large to log]" + + # Get body + body = await request.body() + if body: + if "application/json" in content_type: + # Try to parse as JSON for better formatting + try: + return json.dumps(json.loads(body), indent=2) + except: + return body.decode("utf-8", errors="ignore") + else: + return body.decode("utf-8", errors="ignore") + + return None + except Exception as e: + logger.debug(f"Could not get request body: {e}") + return None + + async def dispatch(self, request: Request, call_next): + """ + Process the request and log details. + + Args: + request: The incoming request + call_next: The next middleware or endpoint + + Returns: + The response + """ + # Skip logging for certain paths + if request.url.path in self.skip_paths: + return await call_next(request) + + # Generate request ID + request_id = str(uuid.uuid4())[:8] + + # Start timing + start_time = time.time() + + # Get client info + client_info = self._get_client_info(request) + + # Log request + logger.info( + f"[{request_id}] Request: {request.method} {request.url.path}", + extra={ + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "query_params": dict(request.query_params), + "client": client_info + } + ) + + # Store request ID in request state for use in endpoints + request.state.request_id = request_id + + # Process request + try: + response = await call_next(request) + except Exception as e: + # Log exception + process_time = time.time() - start_time + logger.error( + f"[{request_id}] Request failed after {process_time:.3f}s: {str(e)}", + extra={ + "request_id": request_id, + "process_time": process_time, + "error": str(e) + }, + exc_info=True + ) + raise + + # Calculate process time + process_time = time.time() - start_time + + # Add request ID to response headers + response.headers["X-Request-ID"] = request_id + response.headers["X-Process-Time"] = f"{process_time:.3f}" + + # Log response + log_level = logging.INFO + if response.status_code >= 500: + log_level = logging.ERROR + elif response.status_code >= 400: + log_level = logging.WARNING + + logger.log( + log_level, + f"[{request_id}] Response: {response.status_code} in {process_time:.3f}s", + extra={ + "request_id": request_id, + "status_code": response.status_code, + "process_time": process_time + } + ) + + return response diff --git a/backend/src/api/middleware/rate_limit.py b/backend/src/api/middleware/rate_limit.py new file mode 100644 index 00000000..0219c5fb --- /dev/null +++ b/backend/src/api/middleware/rate_limit.py @@ -0,0 +1,165 @@ +"""Rate limiting middleware for API endpoints.""" + +import time +from typing import Dict, Optional +from collections import defaultdict +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from fastapi import status + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Middleware for rate limiting API requests.""" + + def __init__(self, app, settings=None): + """ + Initialize rate limit middleware. + + Args: + app: The FastAPI application + settings: Rate limit settings object + """ + super().__init__(app) + self.settings = settings + + # Store request counts per IP + self.request_counts: Dict[str, Dict[str, float]] = defaultdict(dict) + + # Default settings if not provided + self.requests_per_minute = 60 + self.requests_per_hour = 1000 + + if settings: + self.requests_per_minute = getattr(settings, 'requests_per_minute', 60) + self.requests_per_hour = getattr(settings, 'requests_per_hour', 1000) + + def _get_client_ip(self, request: Request) -> str: + """ + Get the client IP address from the request. + + Args: + request: The incoming request + + Returns: + The client IP address + """ + # Try to get real IP from headers (for proxy scenarios) + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + return forwarded_for.split(",")[0].strip() + + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip + + # Fallback to client host + if request.client: + return request.client.host + + return "unknown" + + def _is_rate_limited(self, client_ip: str) -> tuple[bool, Optional[str]]: + """ + Check if the client has exceeded rate limits. + + Args: + client_ip: The client IP address + + Returns: + Tuple of (is_limited, reason) + """ + current_time = time.time() + client_data = self.request_counts[client_ip] + + # Clean up old entries + minute_ago = current_time - 60 + hour_ago = current_time - 3600 + + # Remove entries older than an hour + client_data = { + timestamp: count + for timestamp, count in client_data.items() + if float(timestamp) > hour_ago + } + + # Count requests in the last minute + minute_requests = sum( + count for timestamp, count in client_data.items() + if float(timestamp) > minute_ago + ) + + # Count requests in the last hour + hour_requests = sum(client_data.values()) + + # Check minute limit + if minute_requests >= self.requests_per_minute: + return True, f"Rate limit exceeded: {self.requests_per_minute} requests per minute" + + # Check hour limit + if hour_requests >= self.requests_per_hour: + return True, f"Rate limit exceeded: {self.requests_per_hour} requests per hour" + + # Update request count + timestamp_key = str(current_time) + client_data[timestamp_key] = client_data.get(timestamp_key, 0) + 1 + self.request_counts[client_ip] = client_data + + return False, None + + async def dispatch(self, request: Request, call_next): + """ + Process the request and apply rate limiting. + + Args: + request: The incoming request + call_next: The next middleware or endpoint + + Returns: + The response + """ + # Skip rate limiting for health check endpoints + if request.url.path in ["/health", "/ready", "/docs", "/redoc", "/openapi.json"]: + return await call_next(request) + + # Get client IP + client_ip = self._get_client_ip(request) + + # Check rate limit + is_limited, reason = self._is_rate_limited(client_ip) + + if is_limited: + return JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content={ + "detail": reason, + "type": "rate_limit_exceeded" + }, + headers={ + "Retry-After": "60", # Suggest retry after 60 seconds + "X-RateLimit-Limit": str(self.requests_per_minute), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": str(int(time.time()) + 60) + } + ) + + # Process the request + response = await call_next(request) + + # Add rate limit headers to successful responses + if hasattr(self, 'request_counts') and client_ip in self.request_counts: + current_time = time.time() + minute_ago = current_time - 60 + + minute_requests = sum( + count for timestamp, count in self.request_counts[client_ip].items() + if float(timestamp) > minute_ago + ) + + remaining = max(0, self.requests_per_minute - minute_requests) + + response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute) + response.headers["X-RateLimit-Remaining"] = str(remaining) + response.headers["X-RateLimit-Reset"] = str(int(current_time) + 60) + + return response diff --git a/backend/src/api/routes/__init__.py b/backend/src/api/routes/__init__.py new file mode 100644 index 00000000..a655dc5d --- /dev/null +++ b/backend/src/api/routes/__init__.py @@ -0,0 +1,80 @@ +""" +API Routes Module + +This module exports all available API routers. +""" + +from fastapi import APIRouter + +# Create placeholder routers for now +application_router = APIRouter(tags=["applications"]) +attachment_router = APIRouter(tags=["attachments"]) +pdf_router = APIRouter(tags=["pdf"]) +auth_router = APIRouter(tags=["authentication"]) +health_router = APIRouter(tags=["health"]) + +# Import actual routes when available +try: + from ..v1.auth import router as auth_v1_router + auth_router = auth_v1_router +except ImportError: + pass + +try: + from .applications import router as app_router + application_router = app_router +except ImportError: + pass + +try: + from .attachments import router as attach_router + attachment_router = attach_router +except ImportError: + pass + +try: + from .pdf import router as pdf_route + pdf_router = pdf_route +except ImportError: + pass + +try: + from .health import router as health_route + health_router = health_route +except ImportError: + pass + +# Health check endpoints +@health_router.get("/") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy", "service": "api"} + +@health_router.get("/ready") +async def readiness_check(): + """Readiness check endpoint""" + return {"status": "ready", "service": "api"} + +# Placeholder endpoints for missing routes +@application_router.get("/") +async def list_applications(): + """List applications""" + return {"applications": []} + +@attachment_router.get("/") +async def list_attachments(): + """List attachments""" + return {"attachments": []} + +@pdf_router.get("/") +async def pdf_info(): + """PDF service info""" + return {"service": "pdf", "version": "1.0.0"} + +__all__ = [ + "application_router", + "attachment_router", + "pdf_router", + "auth_router", + "health_router" +] diff --git a/backend/src/config/database.py b/backend/src/config/database.py new file mode 100644 index 00000000..26f64d04 --- /dev/null +++ b/backend/src/config/database.py @@ -0,0 +1,245 @@ +""" +Database Configuration Module + +This module provides database configuration and connection utilities. +""" + +import os +from typing import Optional, Generator +from urllib.parse import quote_plus + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.pool import QueuePool + +from .settings import get_settings + + +def get_database_url() -> str: + """ + Get the database connection URL. + + Returns: + Database connection URL string + """ + # Always prefer environment variables for database connection + # This ensures Docker environment settings take precedence + db_type = os.getenv("DB_TYPE", "mysql") + + if db_type == "sqlite": + db_path = os.getenv("SQLITE_PATH", "./app.db") + return f"sqlite:///{db_path}" + + # MySQL/MariaDB connection + host = os.getenv("MYSQL_HOST", "localhost") + port = os.getenv("MYSQL_PORT", "3306") + database = os.getenv("MYSQL_DB", "stupa") + user = os.getenv("MYSQL_USER", "stupa") + password = os.getenv("MYSQL_PASSWORD", "secret") + + # URL encode the password to handle special characters + password_encoded = quote_plus(password) + + return f"mysql+pymysql://{user}:{password_encoded}@{host}:{port}/{database}?charset=utf8mb4" + + +def get_engine(url: Optional[str] = None): + """ + Create and return a SQLAlchemy engine. + + Args: + url: Optional database URL. If not provided, will use get_database_url() + + Returns: + SQLAlchemy Engine instance + """ + if url is None: + url = get_database_url() + + # Engine configuration + engine_config = { + "pool_size": int(os.getenv("DB_POOL_SIZE", "10")), + "max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "20")), + "pool_pre_ping": os.getenv("DB_POOL_PRE_PING", "true").lower() == "true", + "pool_recycle": int(os.getenv("DB_POOL_RECYCLE", "3600")), + "echo": os.getenv("DB_ECHO", "false").lower() == "true", + } + + # Create engine with connection pooling + engine = create_engine( + url, + poolclass=QueuePool, + **engine_config + ) + + return engine + + +# Global engine and session factory +_engine = None +_session_factory = None + + +def get_session_factory() -> sessionmaker: + """ + Get or create a session factory. + + Returns: + SQLAlchemy sessionmaker instance + """ + global _session_factory, _engine + + if _session_factory is None: + if _engine is None: + _engine = get_engine() + + _session_factory = sessionmaker( + bind=_engine, + autocommit=False, + autoflush=False, + expire_on_commit=False + ) + + return _session_factory + + +def get_db() -> Generator[Session, None, None]: + """ + Dependency injection for FastAPI to get database session. + + Yields: + Database session + """ + session_factory = get_session_factory() + db = session_factory() + try: + yield db + finally: + db.close() + + +def init_database(): + """ + Initialize the database (create tables). + This should be called on application startup. + """ + from models.base import Base + from models.application_type import ( + ApplicationType, + ApplicationField, + ApplicationTypeStatus, + StatusTransition, + DynamicApplication, + ApplicationHistory, + ApplicationAttachment, + ApplicationTransitionLog, + ApplicationApproval + ) + from models.user import User, Role, user_roles + + engine = get_engine() + + # Create all tables + Base.metadata.create_all(bind=engine) + + # Initialize default data if needed + init_default_data(engine) + + +def init_default_data(engine): + """ + Initialize default data in the database. + + Args: + engine: SQLAlchemy engine instance + """ + from sqlalchemy.orm import Session + from models.user import Role + + with Session(engine) as session: + # Check if default roles exist + admin_role = session.query(Role).filter_by(name="admin").first() + + if not admin_role: + # Create default roles + default_roles = [ + Role( + name="admin", + display_name="Administrator", + description="Full system access", + is_admin=True, + is_system=True, + permissions=["*"] + ), + Role( + name="budget_reviewer", + display_name="Haushaltsbeauftragte", + description="Budget review permissions", + can_review_budget=True, + is_system=True, + permissions=["applications.review", "applications.view"] + ), + Role( + name="finance_reviewer", + display_name="Finanzreferent", + description="Finance review permissions", + can_review_finance=True, + is_system=True, + permissions=["applications.review", "applications.view", "applications.finance"] + ), + Role( + name="asta_member", + display_name="AStA Member", + description="AStA voting member", + can_vote=True, + is_system=True, + permissions=["applications.vote", "applications.view"] + ), + Role( + name="applicant", + display_name="Applicant", + description="Can create and manage own applications", + is_system=True, + permissions=["applications.create", "applications.own.view", "applications.own.edit"] + ) + ] + + for role in default_roles: + session.add(role) + + session.commit() + print("Default roles created successfully") + + +def drop_database(): + """ + Drop all database tables. + WARNING: This will delete all data! + """ + from ..models.base import Base + + engine = get_engine() + Base.metadata.drop_all(bind=engine) + print("All database tables dropped") + + +def reset_database(): + """ + Reset the database (drop and recreate all tables). + WARNING: This will delete all data! + """ + drop_database() + init_database() + print("Database reset complete") + + +# Export for backwards compatibility +__all__ = [ + "get_database_url", + "get_engine", + "get_session_factory", + "get_db", + "init_database", + "drop_database", + "reset_database" +] diff --git a/backend/src/config/settings.py b/backend/src/config/settings.py index f0dc5e65..7aa86637 100644 --- a/backend/src/config/settings.py +++ b/backend/src/config/settings.py @@ -26,6 +26,18 @@ class DatabaseSettings(BaseSettings): pool_pre_ping: bool = Field(default=True, env="DB_POOL_PRE_PING") echo: bool = Field(default=False, env="DB_ECHO") + def __init__(self, **kwargs): + """Initialize DatabaseSettings and log environment variables""" + import os + import logging + logger = logging.getLogger(__name__) + logger.info(f"DatabaseSettings init - MYSQL_HOST from env: {os.getenv('MYSQL_HOST', 'NOT SET')}") + logger.info(f"DatabaseSettings init - MYSQL_PORT from env: {os.getenv('MYSQL_PORT', 'NOT SET')}") + logger.info(f"DatabaseSettings init - MYSQL_DB from env: {os.getenv('MYSQL_DB', 'NOT SET')}") + logger.info(f"DatabaseSettings init - MYSQL_USER from env: {os.getenv('MYSQL_USER', 'NOT SET')}") + super().__init__(**kwargs) + logger.info(f"DatabaseSettings after init - host: {self.host}, port: {self.port}, database: {self.database}") + @property def dsn(self) -> str: """Generate database connection string""" @@ -239,6 +251,19 @@ class Settings(BaseSettings): workflow: WorkflowSettings = Field(default_factory=WorkflowSettings) app: ApplicationSettings = Field(default_factory=ApplicationSettings) + def __init__(self, **kwargs): + """Initialize Settings with proper environment variable loading for nested models""" + super().__init__(**kwargs) + # Reinitialize nested settings to ensure they load environment variables + self.database = DatabaseSettings() + self.security = SecuritySettings() + self.oidc = OIDCSettings() + self.email = EmailSettings() + self.rate_limit = RateLimitSettings() + self.storage = StorageSettings() + self.workflow = WorkflowSettings() + self.app = ApplicationSettings() + # Dynamic configuration support config_file: Optional[Path] = Field(default=None, env="CONFIG_FILE") config_overrides: Dict[str, Any] = Field(default_factory=dict) diff --git a/backend/src/core/container.py b/backend/src/core/container.py index d75673bb..6a2e7bc3 100644 --- a/backend/src/core/container.py +++ b/backend/src/core/container.py @@ -368,34 +368,30 @@ def set_container(container: Container): _container = container -@lru_cache() def create_container(settings: Optional[Settings] = None) -> Container: """Create and configure a new container instance""" container = Container(settings) - # Register default repositories - from ..repositories.application import ApplicationRepository - from ..repositories.attachment import AttachmentRepository + # Note: Repositories and services will be registered as needed + # The dynamic system doesn't require pre-registered repositories - container.register_repository("application_repository", ApplicationRepository) - container.register_repository("attachment_repository", AttachmentRepository) + # Register core services that might still be needed + try: + from ..services.pdf_service import PDFService + container.register_service("pdf_service", PDFService, singleton=True) + except ImportError: + pass - # Register default services - from ..services.application import ApplicationService - from ..services.pdf import PDFService - from ..services.auth import AuthService + try: + from ..services.auth_service import AuthService + container.register_service("auth_service", AuthService, singleton=True) + except ImportError: + pass - container.register_service( - "application_service", - ApplicationService, - dependencies={ - "repository": "application_repository", - "pdf_service": "pdf_service" - }, - singleton=True - ) - - container.register_service("pdf_service", PDFService, singleton=True) - container.register_service("auth_service", AuthService, singleton=True) + try: + from ..services.notification_service import NotificationService + container.register_service("notification_service", NotificationService, singleton=True) + except ImportError: + pass return container diff --git a/backend/src/core/database.py b/backend/src/core/database.py index 42bbaa66..b1803f98 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -5,7 +5,7 @@ This module provides database initialization, connection management, and migration support for the application. """ -from typing import Optional, Generator, Any +from typing import Optional, Generator, Any, Dict from contextlib import contextmanager import logging @@ -16,15 +16,20 @@ from sqlalchemy.pool import QueuePool from sqlalchemy.exc import SQLAlchemyError from ..config.settings import Settings, get_settings +from ..config.database import get_database_url from ..models.base import Base -from ..models.application import ( - Application, +from ..models.application_type import ( + ApplicationType, + ApplicationField, + ApplicationTypeStatus, + StatusTransition, + DynamicApplication, + ApplicationHistory, ApplicationAttachment, - Attachment, - ComparisonOffer, - CostPositionJustification, - Counter + ApplicationTransitionLog, + ApplicationApproval ) +from ..models.user import User, Role, Session as UserSession logger = logging.getLogger(__name__) @@ -73,7 +78,7 @@ class DatabaseManager: def _create_engine(self) -> Engine: """Create SQLAlchemy engine with configuration""" engine = create_engine( - self.settings.database.dsn, + get_database_url(), poolclass=QueuePool, pool_size=self.settings.database.pool_size, max_overflow=self.settings.database.max_overflow, @@ -167,31 +172,47 @@ class DatabaseManager: def _init_default_data(self): """Initialize default data in the database""" with self.session_scope() as session: - # Initialize counters - counters = [ + # Initialize default roles if not present + default_roles = [ { - "key": "application_id", - "value": 0, - "prefix": "PA", - "format_string": "{prefix}{value:06d}" + "name": "admin", + "display_name": "Administrator", + "description": "Full system access", + "is_admin": True, + "is_system": True }, { - "key": "attachment_id", - "value": 0, - "prefix": "ATT", - "format_string": "{prefix}{value:08d}" + "name": "budget_reviewer", + "display_name": "Haushaltsbeauftragte", + "description": "Budget review permissions", + "can_review_budget": True, + "is_system": True + }, + { + "name": "finance_reviewer", + "display_name": "Finanzreferent", + "description": "Finance review permissions", + "can_review_finance": True, + "is_system": True + }, + { + "name": "asta_member", + "display_name": "AStA Member", + "description": "AStA voting member", + "can_vote": True, + "is_system": True } ] - for counter_data in counters: - existing = session.query(Counter).filter_by( - key=counter_data["key"] + for role_data in default_roles: + existing = session.query(Role).filter_by( + name=role_data["name"] ).first() if not existing: - counter = Counter(**counter_data) - session.add(counter) - logger.info(f"Created counter: {counter_data['key']}") + role = Role(**role_data) + session.add(role) + logger.info(f"Created role: {role_data['name']}") def verify_connection(self) -> bool: """ @@ -375,12 +396,18 @@ class DatabaseHealthCheck: manager = get_db_manager() required_tables = [ - "applications", - "attachments", - "application_attachments", - "comparison_offers", - "cost_position_justifications", - "counters" + "application_types", + "application_fields", + "application_type_statuses", + "status_transitions", + "dynamic_applications", + "application_history_v2", + "application_attachments_v2", + "application_transition_logs", + "application_approvals", + "users", + "roles", + "user_roles" ] with manager.session_scope() as session: diff --git a/backend/src/main.py b/backend/src/main.py index 10e74776..bad7987f 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -21,15 +21,13 @@ from .api.middleware.rate_limit import RateLimitMiddleware from .api.middleware.logging import LoggingMiddleware from .api.middleware.error_handler import ErrorHandlerMiddleware from .api.routes import ( - application_router, attachment_router, pdf_router, auth_router, health_router ) -from .providers.pdf_qsm import QSMProvider -from .providers.pdf_vsm import VSMProvider -from .services.pdf import PDFService +from .api.application_types import router as application_types_router +from .api.dynamic_applications import router as dynamic_applications_router # Configure logging logging.basicConfig( @@ -57,10 +55,8 @@ async def lifespan(app: FastAPI): # Initialize dependency injection container container = create_container(settings) - # Register PDF providers - pdf_service = container.get_service("pdf_service") - pdf_service.register_provider(QSMProvider(settings)) - pdf_service.register_provider(VSMProvider(settings)) + # PDF service will be initialized if needed + # Dynamic system doesn't require pre-registered providers # Store container in app state app.state.container = container @@ -123,6 +119,7 @@ def create_app() -> FastAPI: ) # Include routers + # Note: nginx strips /api/ prefix when proxying, so we don't add it here app.include_router( health_router, prefix="/health", @@ -131,28 +128,32 @@ def create_app() -> FastAPI: app.include_router( auth_router, - prefix=f"{settings.app.api_prefix}/auth", + prefix="/auth", tags=["authentication"] ) - app.include_router( - application_router, - prefix=f"{settings.app.api_prefix}/applications", - tags=["applications"] - ) - app.include_router( attachment_router, - prefix=f"{settings.app.api_prefix}/attachments", + prefix="/attachments", tags=["attachments"] ) app.include_router( pdf_router, - prefix=f"{settings.app.api_prefix}/pdf", + prefix="/pdf", tags=["pdf"] ) + app.include_router( + application_types_router, + tags=["application-types"] + ) + + app.include_router( + dynamic_applications_router, + tags=["dynamic-applications"] + ) + # Root endpoint @app.get("/", tags=["root"]) async def root() -> Dict[str, Any]: diff --git a/backend/src/migrations/002_add_dynamic_application_system.py b/backend/src/migrations/002_add_dynamic_application_system.py new file mode 100644 index 00000000..eae2be9b --- /dev/null +++ b/backend/src/migrations/002_add_dynamic_application_system.py @@ -0,0 +1,248 @@ +""" +Add dynamic application system tables + +This migration creates all tables needed for the fully dynamic application system. +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +from datetime import datetime + +# Revision identifiers +revision = 'add_dynamic_application_system' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + """Create tables for dynamic application system""" + + # Create application_types table + op.create_table( + 'application_types', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('type_id', sa.String(100), nullable=False, comment='Unique identifier for application type'), + sa.Column('name', sa.String(255), nullable=False, comment='Display name'), + sa.Column('description', sa.Text(), nullable=True, comment='Markdown description'), + sa.Column('pdf_template', sa.LargeBinary(), nullable=True, comment='PDF template blob'), + sa.Column('pdf_template_filename', sa.String(255), nullable=True, comment='Original PDF template filename'), + sa.Column('pdf_field_mapping', sa.JSON(), nullable=False, default={}, comment='Mapping from PDF field names to field IDs'), + sa.Column('is_active', sa.Boolean(), default=True, nullable=False, comment='Whether this type is currently active'), + sa.Column('is_public', sa.Boolean(), default=True, nullable=False, comment='Whether this type is publicly available'), + sa.Column('allowed_roles', sa.JSON(), nullable=True, comment='List of roles allowed to create this type'), + sa.Column('max_cost_positions', sa.Integer(), default=100, nullable=False, comment='Maximum number of cost positions'), + sa.Column('max_comparison_offers', sa.Integer(), default=100, nullable=False, comment='Maximum number of comparison offers'), + sa.Column('version', sa.String(20), default='1.0.0', nullable=False, comment='Version number'), + sa.Column('parent_type_id', sa.Integer(), sa.ForeignKey('application_types.id'), nullable=True, comment='Parent type for versioning'), + sa.Column('usage_count', sa.Integer(), default=0, nullable=False, comment='Number of applications created with this type'), + sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow), + sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('type_id'), + sa.Index('idx_apptype_active_public', 'is_active', 'is_public') + ) + + # Create application_fields table + op.create_table( + 'application_fields', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('application_type_id', sa.Integer(), sa.ForeignKey('application_types.id', ondelete='CASCADE'), nullable=False), + sa.Column('field_id', sa.String(100), nullable=False, comment='Unique field identifier within type'), + sa.Column('field_type', sa.Enum( + 'TEXT_SHORT', 'TEXT_LONG', 'OPTIONS', 'YESNO', 'MAIL', 'DATE', 'DATETIME', + 'AMOUNT', 'CURRENCY_EUR', 'NUMBER', 'FILE', 'SIGNATURE', 'PHONE', 'URL', + 'CHECKBOX', 'RADIO', 'SELECT', 'MULTISELECT', + name='fieldtype' + ), nullable=False, comment='Field data type'), + sa.Column('name', sa.String(255), nullable=False, comment='Field display name'), + sa.Column('label', sa.String(500), nullable=True, comment='Field label for forms'), + sa.Column('description', sa.Text(), nullable=True, comment='Field help text'), + sa.Column('field_order', sa.Integer(), default=0, nullable=False, comment='Display order'), + sa.Column('is_required', sa.Boolean(), default=False, nullable=False, comment='Whether field is required'), + sa.Column('is_readonly', sa.Boolean(), default=False, nullable=False, comment='Whether field is read-only'), + sa.Column('is_hidden', sa.Boolean(), default=False, nullable=False, comment='Whether field is hidden'), + sa.Column('options', sa.JSON(), nullable=True, comment='List of options for selection fields'), + sa.Column('default_value', sa.Text(), nullable=True, comment='Default field value'), + sa.Column('validation_rules', sa.JSON(), nullable=True, comment='Validation rules (min, max, pattern, etc.)'), + sa.Column('display_conditions', sa.JSON(), nullable=True, comment='Conditions for displaying field'), + sa.Column('placeholder', sa.String(500), nullable=True, comment='Input placeholder text'), + sa.Column('section', sa.String(100), nullable=True, comment='Section identifier for grouping'), + sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow), + sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('application_type_id', 'field_id', name='uq_type_field'), + sa.Index('idx_field_type_order', 'application_type_id', 'field_order') + ) + + # Create application_type_statuses table + op.create_table( + 'application_type_statuses', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('application_type_id', sa.Integer(), sa.ForeignKey('application_types.id', ondelete='CASCADE'), nullable=False), + sa.Column('status_id', sa.String(50), nullable=False, comment='Status identifier'), + sa.Column('name', sa.String(100), nullable=False, comment='Status display name'), + sa.Column('description', sa.Text(), nullable=True, comment='Status description'), + sa.Column('is_editable', sa.Boolean(), default=True, nullable=False, comment='Whether application is editable in this status'), + sa.Column('color', sa.String(7), nullable=True, comment='RGB color code (e.g., #FF5733)'), + sa.Column('icon', sa.String(50), nullable=True, comment='Icon identifier'), + sa.Column('display_order', sa.Integer(), default=0, nullable=False, comment='Display order'), + sa.Column('is_initial', sa.Boolean(), default=False, nullable=False, comment='Whether this is the initial status'), + sa.Column('is_final', sa.Boolean(), default=False, nullable=False, comment='Whether this is a final status'), + sa.Column('is_cancelled', sa.Boolean(), default=False, nullable=False, comment='Whether this represents a cancelled state'), + sa.Column('send_notification', sa.Boolean(), default=False, nullable=False, comment='Send notification when entering this status'), + sa.Column('notification_template', sa.Text(), nullable=True, comment='Notification template'), + sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow), + sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('application_type_id', 'status_id', name='uq_type_status'), + sa.Index('idx_status_type_order', 'application_type_id', 'display_order') + ) + + # Create status_transitions table + op.create_table( + 'status_transitions', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('from_status_id', sa.Integer(), sa.ForeignKey('application_type_statuses.id', ondelete='CASCADE'), nullable=False), + sa.Column('to_status_id', sa.Integer(), sa.ForeignKey('application_type_statuses.id', ondelete='CASCADE'), nullable=False), + sa.Column('name', sa.String(100), nullable=False, comment='Transition name'), + sa.Column('trigger_type', sa.Enum( + 'USER_APPROVAL', 'APPLICANT_ACTION', 'DEADLINE_EXPIRED', + 'TIME_ELAPSED', 'CONDITION_MET', 'AUTOMATIC', + name='transitiontriggertype' + ), nullable=False, comment='Type of trigger'), + sa.Column('trigger_config', sa.JSON(), nullable=False, default={}, comment='Trigger-specific configuration'), + sa.Column('conditions', sa.JSON(), nullable=True, comment='Additional conditions for transition'), + sa.Column('actions', sa.JSON(), nullable=True, comment='Actions to execute on transition'), + sa.Column('priority', sa.Integer(), default=0, nullable=False, comment='Priority (higher = executed first)'), + sa.Column('is_active', sa.Boolean(), default=True, nullable=False, comment='Whether transition is active'), + sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow), + sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('from_status_id', 'to_status_id', 'name', name='uq_transition'), + sa.Index('idx_transition_from_to', 'from_status_id', 'to_status_id') + ) + + # Create dynamic_applications table + op.create_table( + 'dynamic_applications', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('application_id', sa.String(64), nullable=False, comment='Public application ID'), + sa.Column('application_key', sa.String(255), nullable=False, comment='Application access key (hashed)'), + sa.Column('application_type_id', sa.Integer(), sa.ForeignKey('application_types.id'), nullable=False), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True), + sa.Column('email', sa.String(255), nullable=False, comment='Applicant email'), + sa.Column('status_id', sa.String(50), nullable=False, comment='Current status ID'), + sa.Column('title', sa.String(500), nullable=False, comment='Application title'), + sa.Column('first_name', sa.String(100), nullable=True), + sa.Column('last_name', sa.String(100), nullable=True), + sa.Column('status_changed_at', sa.DateTime(), nullable=True, comment='When status was last changed'), + sa.Column('submitted_at', sa.DateTime(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('field_data', sa.JSON(), nullable=False, default={}, comment='Dynamic field values'), + sa.Column('cost_positions', sa.JSON(), nullable=True, comment='List of cost positions (up to 100)'), + sa.Column('comparison_offers', sa.JSON(), nullable=True, comment='List of comparison offers (up to 100)'), + sa.Column('total_amount', sa.Float(), default=0.0, nullable=False, comment='Calculated total amount'), + sa.Column('pdf_generated', sa.Boolean(), default=False, nullable=False), + sa.Column('pdf_generated_at', sa.DateTime(), nullable=True), + sa.Column('pdf_file_path', sa.String(500), nullable=True), + sa.Column('metadata', sa.JSON(), nullable=True, comment='Additional metadata'), + sa.Column('search_text', sa.Text(), nullable=True, comment='Concatenated searchable text'), + sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow), + sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('application_id'), + sa.Index('idx_dynapp_type_status', 'application_type_id', 'status_id'), + sa.Index('idx_dynapp_email_type', 'email', 'application_type_id'), + sa.Index('idx_dynapp_submitted', 'submitted_at', 'status_id') + ) + + # Create application_history_v2 table + op.create_table( + 'application_history_v2', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('application_id', sa.Integer(), sa.ForeignKey('dynamic_applications.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True), + sa.Column('action', sa.String(100), nullable=False, comment='Action performed'), + sa.Column('field_changes', sa.JSON(), nullable=True, comment='Changed fields with old/new values'), + sa.Column('comment', sa.Text(), nullable=True), + sa.Column('ip_address', sa.String(45), nullable=True), + sa.Column('user_agent', sa.String(500), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow), + sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow), + sa.PrimaryKeyConstraint('id'), + sa.Index('idx_history_app', 'application_id') + ) + + # Create application_attachments_v2 table + op.create_table( + 'application_attachments_v2', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('application_id', sa.Integer(), sa.ForeignKey('dynamic_applications.id', ondelete='CASCADE'), nullable=False), + sa.Column('field_id', sa.String(100), nullable=True, comment='Associated field ID'), + sa.Column('file_name', sa.String(255), nullable=False), + sa.Column('file_path', sa.String(500), nullable=False), + sa.Column('file_size', sa.Integer(), nullable=False), + sa.Column('file_type', sa.String(100), nullable=True), + sa.Column('file_hash', sa.String(64), nullable=True), + sa.Column('uploaded_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow), + sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow), + sa.PrimaryKeyConstraint('id'), + sa.Index('idx_attachment_app', 'application_id') + ) + + # Create application_transition_logs table + op.create_table( + 'application_transition_logs', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('application_id', sa.Integer(), sa.ForeignKey('dynamic_applications.id', ondelete='CASCADE'), nullable=False), + sa.Column('from_status', sa.String(50), nullable=True), + sa.Column('to_status', sa.String(50), nullable=False), + sa.Column('transition_name', sa.String(100), nullable=True), + sa.Column('trigger_type', sa.String(50), nullable=True), + sa.Column('triggered_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=True), + sa.Column('trigger_data', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow), + sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow), + sa.PrimaryKeyConstraint('id'), + sa.Index('idx_translog_app', 'application_id') + ) + + # Create application_approvals table + op.create_table( + 'application_approvals', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('application_id', sa.Integer(), sa.ForeignKey('dynamic_applications.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('role', sa.String(50), nullable=False, comment='Role of approver'), + sa.Column('decision', sa.String(20), nullable=False, comment='approve, reject, abstain'), + sa.Column('comment', sa.Text(), nullable=True), + sa.Column('status_at_approval', sa.String(50), nullable=True, comment='Status when approval was given'), + sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow), + sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('application_id', 'user_id', 'role', name='uq_app_user_role_approval'), + sa.Index('idx_approval_app', 'application_id'), + sa.Index('idx_approval_user', 'user_id') + ) + + +def downgrade(): + """Drop all dynamic application system tables""" + + # Drop tables in reverse order due to foreign key constraints + op.drop_table('application_approvals') + op.drop_table('application_transition_logs') + op.drop_table('application_attachments_v2') + op.drop_table('application_history_v2') + op.drop_table('dynamic_applications') + op.drop_table('status_transitions') + op.drop_table('application_type_statuses') + op.drop_table('application_fields') + op.drop_table('application_types') + + # Drop enums + op.execute('DROP TYPE IF EXISTS fieldtype') + op.execute('DROP TYPE IF EXISTS transitiontriggertype') diff --git a/backend/src/models/application.py b/backend/src/models/application.py deleted file mode 100644 index 9ca8778c..00000000 --- a/backend/src/models/application.py +++ /dev/null @@ -1,620 +0,0 @@ -""" -Application Database Models - -This module defines the database models for the application system. -""" - -from sqlalchemy import ( - Column, Integer, String, Text, DateTime, JSON, Boolean, - ForeignKey, UniqueConstraint, Index, Float, Enum as SQLEnum -) -from sqlalchemy.orm import relationship, backref -from sqlalchemy.dialects.mysql import LONGTEXT -import enum -from typing import Optional, Dict, Any -from datetime import datetime - -from .base import ExtendedBaseModel, BaseModel, TimestampMixin - - -class ApplicationStatus(enum.Enum): - """Application status enumeration""" - DRAFT = "draft" - BEANTRAGT = "beantragt" # Submitted - BEARBEITUNG_GESPERRT = "bearbeitung_gesperrt" # Processing locked - ZU_PRUEFEN = "zu_pruefen" # To be reviewed - ZUR_ABSTIMMUNG = "zur_abstimmung" # For voting - GENEHMIGT = "genehmigt" # Approved - ABGELEHNT = "abgelehnt" # Rejected - CANCELLED = "cancelled" - - -class ApplicationType(enum.Enum): - """Application type enumeration""" - QSM = "QSM" - VSM = "VSM" - - -class InstitutionType(enum.Enum): - """Institution type enumeration""" - STUDENT_FS = "stud-fs" - STUDENT_RF = "stud-rf" - STUDENT_HG = "stud-hg" - FACULTY = "faculty" - HS_INSTITUTION = "hs-institution" - - -class Application(ExtendedBaseModel): - """Main application model""" - - __tablename__ = "applications" - - # Core fields - pa_id = Column( - String(64), - unique=True, - nullable=False, - index=True, - comment="Public application ID" - ) - pa_key = Column( - String(255), - nullable=False, - comment="Application access key (hashed)" - ) - - # User relationship - user_id = Column( - Integer, - ForeignKey('users.id'), - nullable=True, - index=True, - comment="User who created the application" - ) - - # Template relationship - template_id = Column( - Integer, - ForeignKey('form_templates.id'), - nullable=True, - index=True, - comment="Form template used" - ) - - variant = Column( - SQLEnum(ApplicationType), - nullable=False, - default=ApplicationType.QSM, - comment="Application variant (QSM or VSM)" - ) - status = Column( - SQLEnum(ApplicationStatus), - nullable=False, - default=ApplicationStatus.DRAFT, - index=True, - comment="Application status" - ) - - # JSON payload containing all form data - payload = Column( - JSON, - nullable=False, - default=dict, - comment="Complete application payload" - ) - - # Searchable extracted fields for quick queries - institution_name = Column(String(255), index=True) - institution_type = Column(SQLEnum(InstitutionType), index=True) - applicant_first_name = Column(String(255), index=True) - applicant_last_name = Column(String(255), index=True) - applicant_email = Column(String(255), index=True) - project_name = Column(String(500), index=True) - project_start_date = Column(String(50)) - project_end_date = Column(String(50)) - total_amount = Column(Float, default=0.0) - - -class ApplicationVote(ExtendedBaseModel): - """Application voting model""" - - __tablename__ = "application_votes" - - application_id = Column( - Integer, - ForeignKey('applications.id', ondelete='CASCADE'), - nullable=False, - index=True - ) - - user_id = Column( - Integer, - ForeignKey('users.id', ondelete='CASCADE'), - nullable=False, - index=True - ) - - vote = Column( - String(20), - nullable=False, - comment="Vote: for, against, abstain" - ) - - comment = Column( - Text, - nullable=True, - comment="Optional vote comment" - ) - - voted_at = Column( - DateTime, - nullable=False, - default=datetime.utcnow - ) - - # Relationships - application = relationship("Application", back_populates="votes") - user = relationship("User") - - # Unique constraint - __table_args__ = ( - UniqueConstraint('application_id', 'user_id', name='uq_application_user_vote'), - ) - - -class ApplicationHistory(ExtendedBaseModel): - """Application history tracking""" - - __tablename__ = "application_history" - - application_id = Column( - Integer, - ForeignKey('applications.id', ondelete='CASCADE'), - nullable=False, - index=True - ) - - user_id = Column( - Integer, - ForeignKey('users.id'), - nullable=True, - index=True - ) - - action = Column( - String(100), - nullable=False, - comment="Action performed" - ) - - old_status = Column( - String(50), - nullable=True, - comment="Previous status" - ) - - new_status = Column( - String(50), - nullable=True, - comment="New status" - ) - - changes = Column( - JSON, - nullable=True, - default=dict, - comment="Field changes" - ) - - comment = Column( - Text, - nullable=True, - comment="History comment" - ) - - timestamp = Column( - DateTime, - nullable=False, - default=datetime.utcnow, - index=True - ) - - # Relationships - application = relationship("Application", back_populates="history") - user = relationship("User") - - -class ApplicationAttachment(ExtendedBaseModel): - """Application attachment model""" - - __tablename__ = "application_attachments" - - application_id = Column( - Integer, - ForeignKey('applications.id', ondelete='CASCADE'), - nullable=False, - index=True - ) - - file_name = Column( - String(255), - nullable=False, - comment="Original file name" - ) - - file_path = Column( - String(500), - nullable=False, - comment="Storage path" - ) - - file_size = Column( - Integer, - nullable=False, - comment="File size in bytes" - ) - - file_type = Column( - String(100), - nullable=True, - comment="MIME type" - ) - - file_hash = Column( - String(64), - nullable=True, - comment="File SHA256 hash" - ) - - uploaded_by = Column( - Integer, - ForeignKey('users.id'), - nullable=True - ) - - uploaded_at = Column( - DateTime, - nullable=False, - default=datetime.utcnow - ) - - # Relationships - application = relationship("Application", back_populates="attachments") - uploader = relationship("User") - - # Metadata - submitted_at = Column(DateTime, nullable=True) - reviewed_at = Column(DateTime, nullable=True) - completed_at = Column(DateTime, nullable=True) - - # Workflow fields - locked_at = Column(DateTime, nullable=True, comment="When processing was locked") - locked_by = Column(Integer, ForeignKey('users.id'), nullable=True, comment="Who locked the processing") - - # Budget review - budget_reviewed_by = Column(Integer, ForeignKey('users.id'), nullable=True, comment="Haushaltsbeauftragte") - budget_reviewed_at = Column(DateTime, nullable=True) - budget_review_status = Column(String(50), nullable=True) # approved, rejected, pending - budget_review_comment = Column(Text, nullable=True) - - # Finance review - finance_reviewed_by = Column(Integer, ForeignKey('users.id'), nullable=True, comment="Finanzreferent") - finance_reviewed_at = Column(DateTime, nullable=True) - finance_review_status = Column(String(50), nullable=True) # approved, rejected, pending - finance_review_comment = Column(Text, nullable=True) - - # Voting - voting_opened_at = Column(DateTime, nullable=True) - voting_closed_at = Column(DateTime, nullable=True) - voting_result = Column(String(50), nullable=True) # approved, rejected - votes_for = Column(Integer, default=0) - votes_against = Column(Integer, default=0) - votes_abstain = Column(Integer, default=0) - - # Relationships - user = relationship("User", foreign_keys=[user_id], back_populates="applications") - template = relationship("FormTemplate", back_populates="applications") - locker = relationship("User", foreign_keys=[locked_by]) - budget_reviewer = relationship("User", foreign_keys=[budget_reviewed_by]) - finance_reviewer = relationship("User", foreign_keys=[finance_reviewed_by]) - votes = relationship("ApplicationVote", back_populates="application", cascade="all, delete-orphan") - attachments = relationship("ApplicationAttachment", back_populates="application", cascade="all, delete-orphan") - history = relationship("ApplicationHistory", back_populates="application", cascade="all, delete-orphan") - reviewed_by = Column(String(255), nullable=True) - - # PDF storage - pdf_data = Column( - LONGTEXT, - nullable=True, - comment="Base64 encoded PDF data" - ) - pdf_generated_at = Column(DateTime, nullable=True) - - # Relationships - attachments = relationship( - "ApplicationAttachment", - back_populates="application", - cascade="all, delete-orphan", - lazy="dynamic" - ) - comparison_offers = relationship( - "ComparisonOffer", - back_populates="application", - cascade="all, delete-orphan", - lazy="dynamic" - ) - cost_justifications = relationship( - "CostPositionJustification", - back_populates="application", - cascade="all, delete-orphan", - lazy="dynamic" - ) - - # Indexes - __table_args__ = ( - Index("idx_app_status_created", "status", "created_at"), - Index("idx_app_email_status", "applicant_email", "status"), - Index("idx_app_institution", "institution_type", "institution_name"), - Index("idx_app_dates", "project_start_date", "project_end_date"), - ) - - def update_from_payload(self): - """Update searchable fields from payload""" - if not self.payload: - return - - pa = self.payload.get("pa", {}) - - # Extract applicant info - applicant = pa.get("applicant", {}) - self.applicant_first_name = applicant.get("name", {}).get("first") - self.applicant_last_name = applicant.get("name", {}).get("last") - self.applicant_email = applicant.get("contact", {}).get("email") - - # Extract institution info - institution = applicant.get("institution", {}) - self.institution_name = institution.get("name") - inst_type = institution.get("type") - if inst_type and inst_type != "-": - try: - self.institution_type = InstitutionType(inst_type) - except ValueError: - pass - - # Extract project info - project = pa.get("project", {}) - self.project_name = project.get("name") - - dates = project.get("dates", {}) - self.project_start_date = dates.get("start") - self.project_end_date = dates.get("end") - - # Calculate total amount - costs = project.get("costs", []) - total = 0.0 - for cost in costs: - amount = cost.get("amountEur", 0) - if amount: - total += float(amount) - self.total_amount = total - - def to_dict(self, exclude: Optional[set] = None, include_pdf: bool = False) -> Dict[str, Any]: - """Convert to dictionary with optional PDF exclusion""" - exclude = exclude or set() - if not include_pdf: - exclude.add("pdf_data") - - data = super().to_dict(exclude=exclude) - - # Convert enums to strings - if "status" in data and data["status"]: - data["status"] = data["status"].value if hasattr(data["status"], "value") else data["status"] - if "variant" in data and data["variant"]: - data["variant"] = data["variant"].value if hasattr(data["variant"], "value") else data["variant"] - if "institution_type" in data and data["institution_type"]: - data["institution_type"] = data["institution_type"].value if hasattr(data["institution_type"], "value") else data["institution_type"] - - return data - - -class Attachment(BaseModel, TimestampMixin): - """Base attachment model""" - - __tablename__ = "attachments" - - id = Column(Integer, primary_key=True) - filename = Column(String(255), nullable=False) - content_type = Column(String(100), nullable=False) - size = Column(Integer, nullable=False) - checksum = Column(String(64), nullable=True) - storage_type = Column( - String(50), - default="database", - comment="Storage type: database or filesystem" - ) - storage_path = Column( - String(500), - nullable=True, - comment="Path if stored in filesystem" - ) - data = Column( - LONGTEXT, - nullable=True, - comment="Base64 encoded data if stored in database" - ) - - # Indexes - __table_args__ = ( - Index("idx_attachment_checksum", "checksum"), - ) - - -class ApplicationAttachment(BaseModel): - """Junction table for application attachments with additional metadata""" - - __tablename__ = "application_attachments" - - id = Column(Integer, primary_key=True) - application_id = Column( - Integer, - ForeignKey("applications.id", ondelete="CASCADE"), - nullable=False, - index=True - ) - attachment_id = Column( - Integer, - ForeignKey("attachments.id", ondelete="CASCADE"), - nullable=False, - index=True - ) - category = Column( - String(50), - nullable=True, - comment="Attachment category (e.g., invoice, receipt, etc.)" - ) - description = Column(Text, nullable=True) - uploaded_at = Column(DateTime, default=datetime.utcnow) - uploaded_by = Column(String(255), nullable=True) - - # Relationships - application = relationship("Application", back_populates="attachments") - attachment = relationship("Attachment", backref="applications", cascade="all, delete") - - # Constraints - __table_args__ = ( - UniqueConstraint("application_id", "attachment_id", name="uq_app_attachment"), - Index("idx_app_attach_category", "application_id", "category"), - ) - - -class ComparisonOffer(ExtendedBaseModel): - """Comparison offers for cost positions""" - - __tablename__ = "comparison_offers" - - application_id = Column( - Integer, - ForeignKey("applications.id", ondelete="CASCADE"), - nullable=False, - index=True - ) - cost_position_idx = Column( - Integer, - nullable=False, - comment="Index of cost position in application" - ) - supplier_name = Column(String(255), nullable=False) - amount_eur = Column(Float, nullable=False) - description = Column(Text, nullable=True) - is_preferred = Column( - Boolean, - default=False, - comment="Whether this is the preferred offer" - ) - attachment_id = Column( - Integer, - ForeignKey("attachments.id", ondelete="SET NULL"), - nullable=True - ) - - # Relationships - application = relationship("Application", back_populates="comparison_offers") - attachment = relationship("Attachment", backref="comparison_offers") - - # Indexes - __table_args__ = ( - Index("idx_comp_offer_app_pos", "application_id", "cost_position_idx"), - Index("idx_comp_offer_preferred", "application_id", "is_preferred"), - ) - - def to_dict(self, exclude: Optional[set] = None) -> Dict[str, Any]: - """Convert to dictionary""" - data = super().to_dict(exclude=exclude) - - # Include attachment info if available - if self.attachment: - data["attachment_info"] = { - "id": self.attachment.id, - "filename": self.attachment.filename, - "size": self.attachment.size, - "content_type": self.attachment.content_type - } - - return data - - -class CostPositionJustification(ExtendedBaseModel): - """Justifications for cost positions""" - - __tablename__ = "cost_position_justifications" - - application_id = Column( - Integer, - ForeignKey("applications.id", ondelete="CASCADE"), - nullable=False, - index=True - ) - cost_position_idx = Column( - Integer, - nullable=False, - comment="Index of cost position in application" - ) - justification = Column( - Text, - nullable=False, - comment="Justification text" - ) - justification_type = Column( - String(50), - default="standard", - comment="Type of justification" - ) - - # Relationships - application = relationship("Application", back_populates="cost_justifications") - - # Constraints - __table_args__ = ( - UniqueConstraint( - "application_id", "cost_position_idx", - name="uq_app_cost_justification" - ), - Index("idx_cost_just_type", "justification_type"), - ) - - -class Counter(BaseModel): - """Counter for generating sequential IDs""" - - __tablename__ = "counters" - - key = Column(String(50), unique=True, nullable=False) - value = Column(Integer, default=0, nullable=False) - prefix = Column(String(20), nullable=True) - suffix = Column(String(20), nullable=True) - format_string = Column( - String(100), - default="{prefix}{value:06d}{suffix}", - comment="Python format string for ID generation" - ) - - @classmethod - def get_next_value(cls, session, key: str, increment: int = 1) -> int: - """Get next counter value with atomic increment""" - counter = session.query(cls).filter_by(key=key).with_for_update().first() - if not counter: - counter = cls(key=key, value=0) - session.add(counter) - - counter.value += increment - session.flush() - return counter.value - - def format_id(self, value: Optional[int] = None) -> str: - """Format counter value as ID string""" - val = value if value is not None else self.value - return self.format_string.format( - prefix=self.prefix or "", - suffix=self.suffix or "", - value=val - ) diff --git a/backend/src/models/application_type.py b/backend/src/models/application_type.py new file mode 100644 index 00000000..a0a51fcf --- /dev/null +++ b/backend/src/models/application_type.py @@ -0,0 +1,955 @@ +""" +Dynamic Application Type Models + +This module defines the database models for fully dynamic application types. +""" + +from sqlalchemy import ( + Column, Integer, String, Text, DateTime, JSON, Boolean, + ForeignKey, UniqueConstraint, Index, Float, LargeBinary, + Enum as SQLEnum +) +from sqlalchemy.orm import relationship, backref +from sqlalchemy.dialects.mysql import LONGTEXT +import enum +from typing import Optional, Dict, Any, List +from datetime import datetime + +from .base import ExtendedBaseModel, BaseModel, TimestampMixin + + +class FieldType(enum.Enum): + """Field type enumeration""" + TEXT_SHORT = "text_short" + TEXT_LONG = "text_long" + OPTIONS = "options" + YESNO = "yesno" + MAIL = "mail" + DATE = "date" + DATETIME = "datetime" + AMOUNT = "amount" + CURRENCY_EUR = "currency_eur" + NUMBER = "number" + FILE = "file" + SIGNATURE = "signature" + PHONE = "phone" + URL = "url" + CHECKBOX = "checkbox" + RADIO = "radio" + SELECT = "select" + MULTISELECT = "multiselect" + + +class TransitionTriggerType(enum.Enum): + """Transition trigger type""" + USER_APPROVAL = "user_approval" # N users with role X approve/reject + APPLICANT_ACTION = "applicant_action" # Button clicked by applicant + DEADLINE_EXPIRED = "deadline_expired" # Date deadline passed + TIME_ELAPSED = "time_elapsed" # Timespan elapsed + CONDITION_MET = "condition_met" # Field condition met + AUTOMATIC = "automatic" # Automatic transition + + +class ApplicationType(ExtendedBaseModel): + """Dynamic application type definition""" + + __tablename__ = "application_types" + + # Core fields + type_id = Column( + String(100), + unique=True, + nullable=False, + index=True, + comment="Unique identifier for application type" + ) + + name = Column( + String(255), + nullable=False, + comment="Display name" + ) + + description = Column( + Text, + nullable=True, + comment="Markdown description" + ) + + # PDF Template + pdf_template = Column( + LargeBinary, + nullable=True, + comment="PDF template blob" + ) + + pdf_template_filename = Column( + String(255), + nullable=True, + comment="Original PDF template filename" + ) + + # Field mapping (PDF field name -> field ID) + pdf_field_mapping = Column( + JSON, + nullable=False, + default=dict, + comment="Mapping from PDF field names to field IDs" + ) + + # Configuration + is_active = Column( + Boolean, + default=True, + index=True, + comment="Whether this type is currently active" + ) + + is_public = Column( + Boolean, + default=True, + comment="Whether this type is publicly available" + ) + + # Access control + allowed_roles = Column( + JSON, + nullable=True, + default=list, + comment="List of roles allowed to create this type" + ) + + # Cost configuration + max_cost_positions = Column( + Integer, + default=100, + comment="Maximum number of cost positions" + ) + + max_comparison_offers = Column( + Integer, + default=100, + comment="Maximum number of comparison offers" + ) + + # Versioning + version = Column( + String(20), + default="1.0.0", + comment="Version number" + ) + + parent_type_id = Column( + Integer, + ForeignKey('application_types.id'), + nullable=True, + comment="Parent type for versioning" + ) + + # Statistics + usage_count = Column( + Integer, + default=0, + comment="Number of applications created with this type" + ) + + # Relationships + fields = relationship( + "ApplicationField", + back_populates="application_type", + cascade="all, delete-orphan", + order_by="ApplicationField.field_order" + ) + + statuses = relationship( + "ApplicationTypeStatus", + back_populates="application_type", + cascade="all, delete-orphan" + ) + + applications = relationship( + "DynamicApplication", + back_populates="application_type" + ) + + parent_type = relationship( + "ApplicationType", + remote_side="ApplicationType.id", + backref=backref("versions", lazy="dynamic") + ) + + # Indexes + __table_args__ = ( + Index('idx_apptype_active_public', 'is_active', 'is_public'), + ) + + +class ApplicationField(ExtendedBaseModel): + """Field definition for application types""" + + __tablename__ = "application_fields" + + application_type_id = Column( + Integer, + ForeignKey('application_types.id', ondelete='CASCADE'), + nullable=False, + index=True + ) + + field_id = Column( + String(100), + nullable=False, + comment="Unique field identifier within type" + ) + + field_type = Column( + SQLEnum(FieldType), + nullable=False, + comment="Field data type" + ) + + name = Column( + String(255), + nullable=False, + comment="Field display name" + ) + + label = Column( + String(500), + nullable=True, + comment="Field label for forms" + ) + + description = Column( + Text, + nullable=True, + comment="Field help text" + ) + + field_order = Column( + Integer, + default=0, + comment="Display order" + ) + + # Field configuration + is_required = Column( + Boolean, + default=False, + comment="Whether field is required" + ) + + is_readonly = Column( + Boolean, + default=False, + comment="Whether field is read-only" + ) + + is_hidden = Column( + Boolean, + default=False, + comment="Whether field is hidden" + ) + + # Options for select/radio/checkbox fields + options = Column( + JSON, + nullable=True, + default=list, + comment="List of options for selection fields" + ) + + # Default value + default_value = Column( + Text, + nullable=True, + comment="Default field value" + ) + + # Validation rules + validation_rules = Column( + JSON, + nullable=True, + default=dict, + comment="Validation rules (min, max, pattern, etc.)" + ) + + # Display conditions + display_conditions = Column( + JSON, + nullable=True, + default=dict, + comment="Conditions for displaying field" + ) + + # Placeholder + placeholder = Column( + String(500), + nullable=True, + comment="Input placeholder text" + ) + + # Section grouping + section = Column( + String(100), + nullable=True, + comment="Section identifier for grouping" + ) + + # Relationships + application_type = relationship( + "ApplicationType", + back_populates="fields" + ) + + # Unique constraint + __table_args__ = ( + UniqueConstraint('application_type_id', 'field_id', name='uq_type_field'), + Index('idx_field_type_order', 'application_type_id', 'field_order'), + ) + + +class ApplicationTypeStatus(ExtendedBaseModel): + """Status definition for application types""" + + __tablename__ = "application_type_statuses" + + application_type_id = Column( + Integer, + ForeignKey('application_types.id', ondelete='CASCADE'), + nullable=False, + index=True + ) + + status_id = Column( + String(50), + nullable=False, + comment="Status identifier" + ) + + name = Column( + String(100), + nullable=False, + comment="Status display name" + ) + + description = Column( + Text, + nullable=True, + comment="Status description" + ) + + # Configuration + is_editable = Column( + Boolean, + default=True, + comment="Whether application is editable in this status" + ) + + color = Column( + String(7), + nullable=True, + comment="RGB color code (e.g., #FF5733)" + ) + + icon = Column( + String(50), + nullable=True, + comment="Icon identifier" + ) + + # Order for display + display_order = Column( + Integer, + default=0, + comment="Display order" + ) + + # Status flags + is_initial = Column( + Boolean, + default=False, + comment="Whether this is the initial status" + ) + + is_final = Column( + Boolean, + default=False, + comment="Whether this is a final status" + ) + + is_cancelled = Column( + Boolean, + default=False, + comment="Whether this represents a cancelled state" + ) + + # Notification configuration + send_notification = Column( + Boolean, + default=False, + comment="Send notification when entering this status" + ) + + notification_template = Column( + Text, + nullable=True, + comment="Notification template" + ) + + # Relationships + application_type = relationship( + "ApplicationType", + back_populates="statuses" + ) + + transitions_from = relationship( + "StatusTransition", + foreign_keys="StatusTransition.from_status_id", + back_populates="from_status", + cascade="all, delete-orphan" + ) + + transitions_to = relationship( + "StatusTransition", + foreign_keys="StatusTransition.to_status_id", + back_populates="to_status" + ) + + # Unique constraint + __table_args__ = ( + UniqueConstraint('application_type_id', 'status_id', name='uq_type_status'), + Index('idx_status_type_order', 'application_type_id', 'display_order'), + ) + + +class StatusTransition(ExtendedBaseModel): + """Status transition rules""" + + __tablename__ = "status_transitions" + + from_status_id = Column( + Integer, + ForeignKey('application_type_statuses.id', ondelete='CASCADE'), + nullable=False, + index=True + ) + + to_status_id = Column( + Integer, + ForeignKey('application_type_statuses.id', ondelete='CASCADE'), + nullable=False, + index=True + ) + + name = Column( + String(100), + nullable=False, + comment="Transition name" + ) + + trigger_type = Column( + SQLEnum(TransitionTriggerType), + nullable=False, + comment="Type of trigger" + ) + + # Trigger configuration + trigger_config = Column( + JSON, + nullable=False, + default=dict, + comment="Trigger-specific configuration" + ) + + # Conditions + conditions = Column( + JSON, + nullable=True, + default=dict, + comment="Additional conditions for transition" + ) + + # Actions + actions = Column( + JSON, + nullable=True, + default=list, + comment="Actions to execute on transition" + ) + + # Priority for multiple possible transitions + priority = Column( + Integer, + default=0, + comment="Priority (higher = executed first)" + ) + + is_active = Column( + Boolean, + default=True, + comment="Whether transition is active" + ) + + # Relationships + from_status = relationship( + "ApplicationTypeStatus", + foreign_keys=[from_status_id], + back_populates="transitions_from" + ) + + to_status = relationship( + "ApplicationTypeStatus", + foreign_keys=[to_status_id], + back_populates="transitions_to" + ) + + # Unique constraint + __table_args__ = ( + UniqueConstraint('from_status_id', 'to_status_id', 'name', name='uq_transition'), + Index('idx_transition_from_to', 'from_status_id', 'to_status_id'), + ) + + +class DynamicApplication(ExtendedBaseModel): + """Dynamic application instance""" + + __tablename__ = "dynamic_applications" + + # Identification + application_id = Column( + String(64), + unique=True, + nullable=False, + index=True, + comment="Public application ID" + ) + + application_key = Column( + String(255), + nullable=False, + comment="Application access key (hashed)" + ) + + # Type reference + application_type_id = Column( + Integer, + ForeignKey('application_types.id'), + nullable=False, + index=True + ) + + # User reference + user_id = Column( + Integer, + ForeignKey('users.id'), + nullable=True, + index=True + ) + + # Common fields (always present) + email = Column( + String(255), + nullable=False, + index=True, + comment="Applicant email" + ) + + status_id = Column( + String(50), + nullable=False, + index=True, + comment="Current status ID" + ) + + title = Column( + String(500), + nullable=False, + comment="Application title" + ) + + first_name = Column( + String(100), + nullable=True, + index=True + ) + + last_name = Column( + String(100), + nullable=True, + index=True + ) + + # Timestamps + status_changed_at = Column( + DateTime, + nullable=True, + comment="When status was last changed" + ) + + submitted_at = Column( + DateTime, + nullable=True, + index=True + ) + + completed_at = Column( + DateTime, + nullable=True + ) + + # Dynamic field data + field_data = Column( + JSON, + nullable=False, + default=dict, + comment="Dynamic field values" + ) + + # Cost positions (extended) + cost_positions = Column( + JSON, + nullable=True, + default=list, + comment="List of cost positions (up to 100)" + ) + + comparison_offers = Column( + JSON, + nullable=True, + default=list, + comment="List of comparison offers (up to 100)" + ) + + total_amount = Column( + Float, + default=0.0, + index=True, + comment="Calculated total amount" + ) + + # PDF generation + pdf_generated = Column( + Boolean, + default=False + ) + + pdf_generated_at = Column( + DateTime, + nullable=True + ) + + pdf_file_path = Column( + String(500), + nullable=True + ) + + # Metadata + application_metadata = Column( + JSON, + nullable=True, + default=dict, + comment="Additional metadata" + ) + + # Search optimization + search_text = Column( + Text, + nullable=True, + comment="Concatenated searchable text" + ) + + # Relationships + application_type = relationship( + "ApplicationType", + back_populates="applications" + ) + + user = relationship( + "User", + back_populates="dynamic_applications" + ) + + history = relationship( + "ApplicationHistory", + back_populates="application", + cascade="all, delete-orphan" + ) + + attachments = relationship( + "ApplicationAttachment", + back_populates="application", + cascade="all, delete-orphan" + ) + + transitions = relationship( + "ApplicationTransitionLog", + back_populates="application", + cascade="all, delete-orphan" + ) + + approvals = relationship( + "ApplicationApproval", + back_populates="application", + cascade="all, delete-orphan" + ) + + # Indexes + __table_args__ = ( + Index('idx_dynapp_type_status', 'application_type_id', 'status_id'), + Index('idx_dynapp_email_type', 'email', 'application_type_id'), + Index('idx_dynapp_submitted', 'submitted_at', 'status_id'), + ) + + def update_search_text(self): + """Update searchable text from field data""" + parts = [ + self.title or '', + self.email or '', + self.first_name or '', + self.last_name or '', + ] + + # Add field data + if self.field_data: + for key, value in self.field_data.items(): + if isinstance(value, str): + parts.append(value) + elif isinstance(value, (list, dict)): + parts.append(str(value)) + + self.search_text = ' '.join(filter(None, parts)) + + def calculate_total_amount(self): + """Calculate total from cost positions""" + total = 0.0 + if self.cost_positions: + for pos in self.cost_positions: + if isinstance(pos, dict) and 'amount' in pos: + try: + total += float(pos['amount']) + except (ValueError, TypeError): + pass + self.total_amount = total + + +class ApplicationHistory(ExtendedBaseModel): + """Application history tracking""" + + __tablename__ = "application_history_v2" + + application_id = Column( + Integer, + ForeignKey('dynamic_applications.id', ondelete='CASCADE'), + nullable=False, + index=True + ) + + user_id = Column( + Integer, + ForeignKey('users.id'), + nullable=True + ) + + action = Column( + String(100), + nullable=False, + comment="Action performed" + ) + + field_changes = Column( + JSON, + nullable=True, + default=dict, + comment="Changed fields with old/new values" + ) + + comment = Column( + Text, + nullable=True + ) + + ip_address = Column( + String(45), + nullable=True + ) + + user_agent = Column( + String(500), + nullable=True + ) + + # Relationships + application = relationship( + "DynamicApplication", + back_populates="history" + ) + + user = relationship("User") + + +class ApplicationAttachment(ExtendedBaseModel): + """Application attachments""" + + __tablename__ = "application_attachments_v2" + + application_id = Column( + Integer, + ForeignKey('dynamic_applications.id', ondelete='CASCADE'), + nullable=False, + index=True + ) + + field_id = Column( + String(100), + nullable=True, + comment="Associated field ID" + ) + + file_name = Column( + String(255), + nullable=False + ) + + file_path = Column( + String(500), + nullable=False + ) + + file_size = Column( + Integer, + nullable=False + ) + + file_type = Column( + String(100), + nullable=True + ) + + file_hash = Column( + String(64), + nullable=True + ) + + uploaded_by = Column( + Integer, + ForeignKey('users.id'), + nullable=True + ) + + # Relationships + application = relationship( + "DynamicApplication", + back_populates="attachments" + ) + + uploader = relationship("User") + + +class ApplicationTransitionLog(ExtendedBaseModel): + """Log of status transitions""" + + __tablename__ = "application_transition_logs" + + application_id = Column( + Integer, + ForeignKey('dynamic_applications.id', ondelete='CASCADE'), + nullable=False, + index=True + ) + + from_status = Column( + String(50), + nullable=True + ) + + to_status = Column( + String(50), + nullable=False + ) + + transition_name = Column( + String(100), + nullable=True + ) + + trigger_type = Column( + String(50), + nullable=True + ) + + triggered_by = Column( + Integer, + ForeignKey('users.id'), + nullable=True + ) + + trigger_data = Column( + JSON, + nullable=True, + default=dict + ) + + # Relationships + application = relationship( + "DynamicApplication", + back_populates="transitions" + ) + + user = relationship("User") + + +class ApplicationApproval(ExtendedBaseModel): + """Approval tracking for applications""" + + __tablename__ = "application_approvals" + + application_id = Column( + Integer, + ForeignKey('dynamic_applications.id', ondelete='CASCADE'), + nullable=False, + index=True + ) + + user_id = Column( + Integer, + ForeignKey('users.id'), + nullable=False, + index=True + ) + + role = Column( + String(50), + nullable=False, + comment="Role of approver" + ) + + decision = Column( + String(20), + nullable=False, + comment="approve, reject, abstain" + ) + + comment = Column( + Text, + nullable=True + ) + + status_at_approval = Column( + String(50), + nullable=True, + comment="Status when approval was given" + ) + + # Relationships + application = relationship( + "DynamicApplication", + back_populates="approvals" + ) + + user = relationship("User") + + # Unique constraint + __table_args__ = ( + UniqueConstraint('application_id', 'user_id', 'role', name='uq_app_user_role_approval'), + ) diff --git a/backend/src/models/form_template.py b/backend/src/models/form_template.py deleted file mode 100644 index 3fbf121f..00000000 --- a/backend/src/models/form_template.py +++ /dev/null @@ -1,458 +0,0 @@ -""" -Form Template Database Models - -This module defines the database models for form templates and PDF field mappings. -""" - -from sqlalchemy import ( - Column, Integer, String, Text, DateTime, JSON, Boolean, - ForeignKey, UniqueConstraint, Index, Enum as SQLEnum -) -from sqlalchemy.orm import relationship, backref -import enum -from typing import Optional, Dict, Any, List -from datetime import datetime - -from .base import ExtendedBaseModel, BaseModel, TimestampMixin - - -class FormType(enum.Enum): - """Form type enumeration""" - QSM = "QSM" - VSM = "VSM" - CUSTOM = "CUSTOM" - - -class FieldType(enum.Enum): - """Field type enumeration""" - TEXT = "text" - NUMBER = "number" - DATE = "date" - EMAIL = "email" - PHONE = "phone" - CHECKBOX = "checkbox" - RADIO = "radio" - SELECT = "select" - TEXTAREA = "textarea" - FILE = "file" - SIGNATURE = "signature" - CURRENCY = "currency" - - -class FormTemplate(ExtendedBaseModel): - """Form template model for configurable PDF forms""" - - __tablename__ = "form_templates" - - # Core fields - name = Column( - String(255), - unique=True, - nullable=False, - index=True, - comment="Template name" - ) - - display_name = Column( - String(255), - nullable=False, - comment="Display name for UI" - ) - - description = Column( - Text, - nullable=True, - comment="Template description" - ) - - form_type = Column( - SQLEnum(FormType), - nullable=False, - default=FormType.CUSTOM, - index=True, - comment="Form type" - ) - - # PDF template file - pdf_file_path = Column( - String(500), - nullable=True, - comment="Path to uploaded PDF template file" - ) - - pdf_file_name = Column( - String(255), - nullable=True, - comment="Original PDF file name" - ) - - pdf_file_size = Column( - Integer, - nullable=True, - comment="PDF file size in bytes" - ) - - pdf_file_hash = Column( - String(64), - nullable=True, - comment="PDF file SHA256 hash" - ) - - # Form configuration - is_active = Column( - Boolean, - default=True, - index=True, - comment="Whether template is active" - ) - - is_public = Column( - Boolean, - default=True, - comment="Whether template is publicly accessible" - ) - - requires_verification = Column( - Boolean, - default=True, - comment="Whether user verification is required" - ) - - # Access control - allowed_roles = Column( - JSON, - nullable=True, - default=list, - comment="List of role names allowed to use this template" - ) - - # Form designer configuration - form_design = Column( - JSON, - nullable=True, - default=dict, - comment="Visual form designer configuration" - ) - - # Workflow configuration - workflow_config = Column( - JSON, - nullable=True, - default=dict, - comment="Workflow configuration for approval process" - ) - - # Statistics - usage_count = Column( - Integer, - default=0, - comment="Number of times template has been used" - ) - - # Version management - version = Column( - String(20), - default="1.0.0", - comment="Template version" - ) - - parent_template_id = Column( - Integer, - ForeignKey('form_templates.id'), - nullable=True, - comment="Parent template for versioning" - ) - - # Relationships - field_mappings = relationship( - "FieldMapping", - back_populates="template", - cascade="all, delete-orphan", - order_by="FieldMapping.field_order" - ) - - applications = relationship( - "Application", - back_populates="template" - ) - - parent_template = relationship( - "FormTemplate", - remote_side="FormTemplate.id", - backref=backref("versions", lazy="dynamic") - ) - - # Indexes - __table_args__ = ( - Index('idx_template_active_public', 'is_active', 'is_public'), - Index('idx_template_type_active', 'form_type', 'is_active'), - ) - - def to_dict(self, include_mappings: bool = False) -> Dict[str, Any]: - """Convert to dictionary representation""" - data = { - "id": self.id, - "name": self.name, - "display_name": self.display_name, - "description": self.description, - "form_type": self.form_type.value if self.form_type else None, - "is_active": self.is_active, - "is_public": self.is_public, - "requires_verification": self.requires_verification, - "allowed_roles": self.allowed_roles or [], - "version": self.version, - "usage_count": self.usage_count, - "pdf_file_name": self.pdf_file_name, - "pdf_file_size": self.pdf_file_size, - "created_at": self.created_at.isoformat() if self.created_at else None, - "updated_at": self.updated_at.isoformat() if self.updated_at else None, - } - - if include_mappings: - data["field_mappings"] = [ - mapping.to_dict() for mapping in self.field_mappings - ] - - return data - - -class FieldMapping(ExtendedBaseModel): - """Field mapping model for PDF form fields""" - - __tablename__ = "field_mappings" - - template_id = Column( - Integer, - ForeignKey('form_templates.id', ondelete='CASCADE'), - nullable=False, - index=True - ) - - # PDF field information - pdf_field_name = Column( - String(255), - nullable=False, - comment="Field name in PDF" - ) - - pdf_field_type = Column( - String(50), - nullable=True, - comment="Original PDF field type" - ) - - # Mapping configuration - field_key = Column( - String(255), - nullable=False, - comment="Internal field key for data storage" - ) - - field_label = Column( - String(255), - nullable=False, - comment="Display label for field" - ) - - field_type = Column( - SQLEnum(FieldType), - nullable=False, - default=FieldType.TEXT, - comment="Mapped field type" - ) - - field_order = Column( - Integer, - default=0, - comment="Field display order" - ) - - # Field configuration - is_required = Column( - Boolean, - default=False, - comment="Whether field is required" - ) - - is_readonly = Column( - Boolean, - default=False, - comment="Whether field is read-only" - ) - - is_hidden = Column( - Boolean, - default=False, - comment="Whether field is hidden" - ) - - # Special field flags - is_email_field = Column( - Boolean, - default=False, - comment="Whether this is the email field (auto-filled from user)" - ) - - is_name_field = Column( - Boolean, - default=False, - comment="Whether this is a name field (auto-filled from OIDC)" - ) - - # Validation rules - validation_rules = Column( - JSON, - nullable=True, - default=dict, - comment="Field validation rules" - ) - - # Options for select/radio fields - field_options = Column( - JSON, - nullable=True, - default=list, - comment="Options for select/radio/checkbox fields" - ) - - # Default value - default_value = Column( - Text, - nullable=True, - comment="Default field value" - ) - - # Placeholder and help text - placeholder = Column( - String(500), - nullable=True, - comment="Field placeholder text" - ) - - help_text = Column( - Text, - nullable=True, - comment="Field help text" - ) - - # Conditional display - display_conditions = Column( - JSON, - nullable=True, - default=dict, - comment="Conditions for displaying field" - ) - - # Data transformation - transform_rules = Column( - JSON, - nullable=True, - default=dict, - comment="Rules for transforming field data" - ) - - # Relationships - template = relationship( - "FormTemplate", - back_populates="field_mappings" - ) - - # Indexes - __table_args__ = ( - UniqueConstraint('template_id', 'pdf_field_name', name='uq_template_pdf_field'), - UniqueConstraint('template_id', 'field_key', name='uq_template_field_key'), - Index('idx_field_template_order', 'template_id', 'field_order'), - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary representation""" - return { - "id": self.id, - "pdf_field_name": self.pdf_field_name, - "pdf_field_type": self.pdf_field_type, - "field_key": self.field_key, - "field_label": self.field_label, - "field_type": self.field_type.value if self.field_type else None, - "field_order": self.field_order, - "is_required": self.is_required, - "is_readonly": self.is_readonly, - "is_hidden": self.is_hidden, - "is_email_field": self.is_email_field, - "is_name_field": self.is_name_field, - "validation_rules": self.validation_rules or {}, - "field_options": self.field_options or [], - "default_value": self.default_value, - "placeholder": self.placeholder, - "help_text": self.help_text, - "display_conditions": self.display_conditions or {}, - "transform_rules": self.transform_rules or {}, - } - - -class FormDesign(ExtendedBaseModel): - """Visual form designer configuration""" - - __tablename__ = "form_designs" - - template_id = Column( - Integer, - ForeignKey('form_templates.id', ondelete='CASCADE'), - unique=True, - nullable=False, - index=True - ) - - # Designer layout - layout_type = Column( - String(50), - default="single-column", - comment="Layout type (single-column, two-column, custom)" - ) - - # Sections configuration - sections = Column( - JSON, - nullable=False, - default=list, - comment="Form sections configuration" - ) - - # Styling - theme = Column( - JSON, - nullable=True, - default=dict, - comment="Theme configuration" - ) - - custom_css = Column( - Text, - nullable=True, - comment="Custom CSS styles" - ) - - # Components configuration - components = Column( - JSON, - nullable=True, - default=list, - comment="Custom components configuration" - ) - - # Relationships - template = relationship( - "FormTemplate", - backref=backref("design", uselist=False, cascade="all, delete-orphan") - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary representation""" - return { - "id": self.id, - "template_id": self.template_id, - "layout_type": self.layout_type, - "sections": self.sections or [], - "theme": self.theme or {}, - "custom_css": self.custom_css, - "components": self.components or [], - } diff --git a/backend/src/models/user.py b/backend/src/models/user.py index a7454b72..c468e9c0 100644 --- a/backend/src/models/user.py +++ b/backend/src/models/user.py @@ -11,7 +11,7 @@ from sqlalchemy import ( from sqlalchemy.orm import relationship, backref import enum from typing import Optional, Dict, Any, List -from datetime import datetime +from datetime import datetime, timedelta from .base import ExtendedBaseModel, BaseModel, TimestampMixin @@ -123,8 +123,8 @@ class User(ExtendedBaseModel): lazy="joined" ) - applications = relationship( - "Application", + dynamic_applications = relationship( + "DynamicApplication", back_populates="user", cascade="all, delete-orphan" ) diff --git a/backend/src/providers/pdf_qsm.py b/backend/src/providers/pdf_qsm.py deleted file mode 100644 index d4941a77..00000000 --- a/backend/src/providers/pdf_qsm.py +++ /dev/null @@ -1,400 +0,0 @@ -""" -QSM PDF Variant Provider - -This module provides the PDF variant provider for QSM (Qualitätssicherungsmittel) forms. -""" - -from typing import Dict, Any, List, Optional -from pathlib import Path -import re -import logging - -from ..services.pdf import PDFVariantProvider -from ..config.settings import Settings, get_settings - -logger = logging.getLogger(__name__) - - -class QSMProvider(PDFVariantProvider): - """Provider for QSM PDF variant""" - - def __init__(self, settings: Optional[Settings] = None): - """Initialize QSM provider""" - self.settings = settings or get_settings() - self._field_mapping = self._initialize_field_mapping() - - def get_variant_name(self) -> str: - """Get the name of this variant""" - return "QSM" - - def get_template_path(self) -> Path: - """Get the path to the PDF template for this variant""" - return self.settings.pdf.qsm_template - - def get_variant_indicators(self) -> List[str]: - """Get list of field names that indicate this variant""" - return [ - "pa-qsm-financing", - "pa-qsm-vwv-3-2-1-1", - "pa-qsm-vwv-3-2-1-2" - ] - - def _initialize_field_mapping(self) -> Dict[str, Any]: - """Initialize field mapping configuration""" - return { - # Meta fields - "pa-id": { - "target": "pa.meta.id", - "type": "str", - "required": True - }, - "pa-key": { - "target": "pa.meta.key", - "type": "str", - "required": True - }, - - # Applicant fields - "pa-institution-type": { - "target": "pa.applicant.institution.type", - "type": "enum", - "values": { - "stud-fs": "Fachschaft", - "stud-rf": "STUPA-Referat", - "stud-hg": "Studentische Hochschulgruppe", - "faculty": "Fakultät", - "hs-institution": "Hochschuleinrichtung" - } - }, - "pa-institution": { - "target": "pa.applicant.institution.name", - "type": "str", - "required": True - }, - "pa-first-name": { - "target": "pa.applicant.name.first", - "type": "str", - "required": True - }, - "pa-last-name": { - "target": "pa.applicant.name.last", - "type": "str", - "required": True - }, - "pa-email": { - "target": "pa.applicant.contact.email", - "type": "str", - "required": True - }, - "pa-phone": { - "target": "pa.applicant.contact.phone", - "type": "str" - }, - "pa-course": { - "target": "pa.applicant.course", - "type": "enum", - "values": ["INF", "ESB", "LS", "TEC", "TEX", "NXT"] - }, - "pa-role": { - "target": "pa.applicant.role", - "type": "enum", - "values": [ - "Student", "Professor", "Mitarbeiter", - "ASTA", "Referatsleitung", "Fachschaftsvorstand" - ] - }, - - # Project fields - "pa-project-name": { - "target": "pa.project.name", - "type": "str", - "required": True - }, - "pa-project-description": { - "target": "pa.project.description", - "type": "str", - "required": True - }, - "pa-start-date": { - "target": "pa.project.dates.start", - "type": "str", - "required": True - }, - "pa-end-date": { - "target": "pa.project.dates.end", - "type": "str" - }, - "pa-participants": { - "target": "pa.project.participants", - "type": "int" - }, - - # QSM-specific fields - "pa-qsm-financing": { - "target": "pa.project.financing.qsm.code", - "type": "enum", - "values": { - "vwv-3-2-1-1": "Finanzierung zusätzlicher Lehr- und Seminarangebote", - "vwv-3-2-1-2": "Fachspezifische Studienprojekte", - "vwv-3-2-1-3": "Hochschuldidaktische Fort- und Weiterbildungsmaßnahmen", - "vwv-3-2-1-4": "Studentische Tutorien und Arbeitsgruppen", - "vwv-3-2-1-5": "Exkursionen", - "vwv-3-2-1-6": "Sonstige Maßnahmen" - }, - "required": True - } - } - - def get_field_mapping(self) -> Dict[str, Any]: - """Get the field mapping configuration for this variant""" - return self._field_mapping - - def parse_pdf_fields(self, pdf_fields: Dict[str, Any]) -> Dict[str, Any]: - """Parse PDF form fields into a structured payload""" - payload = { - "pa": { - "meta": {}, - "applicant": { - "name": {}, - "contact": {}, - "institution": {} - }, - "project": { - "dates": {}, - "costs": [], - "participation": {"faculties": {}}, - "financing": {"qsm": {}} - } - } - } - - # Process each field according to mapping - for field_name, field_value in pdf_fields.items(): - if field_value is None or field_value == "": - continue - - # Handle cost fields with wildcards - cost_match = re.match(r"pa-cost-(\d+)-(name|amount-euro)", field_name) - if cost_match: - idx = int(cost_match.group(1)) - 1 - field_type = cost_match.group(2) - - # Ensure costs array is large enough - while len(payload["pa"]["project"]["costs"]) <= idx: - payload["pa"]["project"]["costs"].append({}) - - if field_type == "name": - payload["pa"]["project"]["costs"][idx]["name"] = field_value - elif field_type == "amount-euro": - payload["pa"]["project"]["costs"][idx]["amountEur"] = self._parse_amount(field_value) - continue - - # Handle participation checkboxes - if field_name.startswith("pa-participating-faculties-"): - faculty = field_name.replace("pa-participating-faculties-", "") - payload["pa"]["project"]["participation"]["faculties"][faculty] = self._parse_bool(field_value) - continue - - # Handle QSM-specific checkboxes - if field_name.startswith("pa-qsm-vwv-"): - key = field_name.replace("pa-qsm-", "") - if "flags" not in payload["pa"]["project"]["financing"]["qsm"]: - payload["pa"]["project"]["financing"]["qsm"]["flags"] = {} - payload["pa"]["project"]["financing"]["qsm"]["flags"][key] = self._parse_bool(field_value) - continue - - # Process regular mapped fields - if field_name in self._field_mapping: - mapping = self._field_mapping[field_name] - target_path = mapping["target"] - field_type = mapping.get("type", "str") - - # Transform value based on type - transformed_value = self.transform_value(field_value, field_type) - - # Handle enum values - if field_type == "enum" and "values" in mapping: - if isinstance(mapping["values"], dict): - # Map to display value if available - transformed_value = mapping["values"].get(field_value, field_value) - - # Set value in payload using target path - self._set_nested_value(payload, target_path, transformed_value) - - # Clean up empty costs - if payload["pa"]["project"]["costs"]: - payload["pa"]["project"]["costs"] = [ - cost for cost in payload["pa"]["project"]["costs"] - if cost.get("name") or cost.get("amountEur") - ] - - return payload - - def map_payload_to_fields(self, payload: Dict[str, Any]) -> Dict[str, Any]: - """Map a structured payload to PDF form fields""" - pdf_fields = {} - - # Process regular fields - for field_name, mapping in self._field_mapping.items(): - target_path = mapping["target"] - value = self._get_nested_value(payload, target_path) - - if value is not None: - field_type = mapping.get("type", "str") - - # Handle enum reverse mapping - if field_type == "enum" and "values" in mapping and isinstance(mapping["values"], dict): - # Find key by value - for key, display_value in mapping["values"].items(): - if display_value == value or key == value: - value = key - break - - # Format value for PDF - if field_type == "float": - value = self._format_amount(value) - elif field_type == "bool": - value = "Ja" if value else "Nein" - - pdf_fields[field_name] = str(value) if value is not None else "" - - # Process costs array - pa = payload.get("pa", {}) - project = pa.get("project", {}) - costs = project.get("costs", []) - - for i, cost in enumerate(costs[:24], 1): # Limit to 24 cost positions - if "name" in cost: - pdf_fields[f"pa-cost-{i}-name"] = cost["name"] - if "amountEur" in cost: - pdf_fields[f"pa-cost-{i}-amount-euro"] = self._format_amount(cost["amountEur"]) - - # Process participation faculties - participation = project.get("participation", {}) - faculties = participation.get("faculties", {}) - - for faculty, is_participating in faculties.items(): - pdf_fields[f"pa-participating-faculties-{faculty}"] = "Ja" if is_participating else "Nein" - - # Process QSM flags - financing = project.get("financing", {}) - qsm = financing.get("qsm", {}) - flags = qsm.get("flags", {}) - - for flag_key, flag_value in flags.items(): - pdf_fields[f"pa-qsm-{flag_key}"] = "Ja" if flag_value else "Nein" - - return pdf_fields - - def validate_payload(self, payload: Dict[str, Any]) -> List[str]: - """Validate a payload for this variant""" - errors = [] - - # Check required fields - for field_name, mapping in self._field_mapping.items(): - if mapping.get("required", False): - target_path = mapping["target"] - value = self._get_nested_value(payload, target_path) - - if value is None or (isinstance(value, str) and not value.strip()): - errors.append(f"Required field missing: {target_path}") - - # Validate QSM-specific requirements - pa = payload.get("pa", {}) - project = pa.get("project", {}) - - # Check if financing code is valid - financing = project.get("financing", {}) - qsm = financing.get("qsm", {}) - financing_code = qsm.get("code") - - if not financing_code: - errors.append("QSM financing code is required") - elif financing_code not in self._field_mapping["pa-qsm-financing"]["values"]: - errors.append(f"Invalid QSM financing code: {financing_code}") - - # Validate costs - costs = project.get("costs", []) - if not costs: - errors.append("At least one cost position is required") - else: - for i, cost in enumerate(costs): - if not cost.get("name"): - errors.append(f"Cost position {i+1}: name is required") - if cost.get("amountEur") is not None: - try: - amount = float(cost["amountEur"]) - if amount < 0: - errors.append(f"Cost position {i+1}: amount cannot be negative") - except (TypeError, ValueError): - errors.append(f"Cost position {i+1}: invalid amount") - - return errors - - def detect_variant(self, pdf_fields: Dict[str, Any]) -> bool: - """Check if the given PDF fields match this variant""" - # Check for QSM-specific fields - qsm_indicators = ["pa-qsm-financing"] - - for indicator in qsm_indicators: - if indicator in pdf_fields and pdf_fields[indicator]: - return True - - # Check for QSM flags - for field_name in pdf_fields: - if field_name.startswith("pa-qsm-vwv-"): - return True - - return False - - # Helper methods - - def _set_nested_value(self, obj: Dict[str, Any], path: str, value: Any): - """Set a value in a nested dictionary using dot notation""" - keys = path.split(".") - current = obj - - for key in keys[:-1]: - if key not in current: - current[key] = {} - current = current[key] - - current[keys[-1]] = value - - def _get_nested_value(self, obj: Dict[str, Any], path: str) -> Any: - """Get a value from a nested dictionary using dot notation""" - keys = path.split(".") - current = obj - - for key in keys: - if isinstance(current, dict) and key in current: - current = current[key] - else: - return None - - return current - - def _parse_amount(self, value: str) -> float: - """Parse amount from German format (1.234,56) to float""" - if not value: - return 0.0 - - # Remove thousands separator and replace comma with dot - cleaned = value.replace(".", "").replace(",", ".") - - try: - return float(cleaned) - except (TypeError, ValueError): - return 0.0 - - def _format_amount(self, value: float) -> str: - """Format amount to German format (1.234,56)""" - return f"{value:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") - - def _parse_bool(self, value: Any) -> bool: - """Parse boolean value from various formats""" - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.lower() in ["true", "yes", "1", "on", "ja", "/Yes"] - return bool(value) diff --git a/backend/src/repositories/role.py b/backend/src/repositories/role.py new file mode 100644 index 00000000..d6089755 --- /dev/null +++ b/backend/src/repositories/role.py @@ -0,0 +1,245 @@ +""" +Role Repository + +This module provides data access methods for role management. +""" + +from typing import Optional, List, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import func + +from ..models.user import Role +from .base import BaseRepository + + +class RoleRepository(BaseRepository[Role]): + """Repository for role data access""" + + def __init__(self, db: Session): + super().__init__(Role, db) + + def get_by_name(self, name: str) -> Optional[Role]: + """Get role by name""" + return self.db.query(Role).filter( + func.lower(Role.name) == func.lower(name) + ).first() + + def get_by_oidc_claim(self, claim: str) -> Optional[Role]: + """Get role by OIDC claim value""" + return self.db.query(Role).filter( + Role.oidc_role_claim == claim + ).first() + + def get_system_roles(self) -> List[Role]: + """Get all system roles""" + return self.db.query(Role).filter( + Role.is_system == True + ).all() + + def get_admin_roles(self) -> List[Role]: + """Get all admin roles""" + return self.db.query(Role).filter( + Role.is_admin == True + ).all() + + def get_reviewer_roles(self) -> List[Role]: + """Get all reviewer roles""" + return self.db.query(Role).filter( + (Role.can_review_budget == True) | + (Role.can_review_finance == True) + ).all() + + def get_voting_roles(self) -> List[Role]: + """Get all roles that can vote""" + return self.db.query(Role).filter( + Role.can_vote == True + ).all() + + def get_oidc_role_mappings(self) -> Dict[str, Role]: + """Get mapping of OIDC claims to roles""" + roles = self.db.query(Role).filter( + Role.oidc_role_claim.isnot(None) + ).all() + + return { + role.oidc_role_claim: role + for role in roles + } + + def search_roles( + self, + query: Optional[str] = None, + is_system: Optional[bool] = None, + is_admin: Optional[bool] = None, + can_review: Optional[bool] = None, + can_vote: Optional[bool] = None, + limit: int = 100, + offset: int = 0 + ) -> List[Role]: + """Search roles with filters""" + q = self.db.query(Role) + + if query: + search_term = f"%{query}%" + q = q.filter( + (Role.name.ilike(search_term)) | + (Role.display_name.ilike(search_term)) | + (Role.description.ilike(search_term)) + ) + + if is_system is not None: + q = q.filter(Role.is_system == is_system) + + if is_admin is not None: + q = q.filter(Role.is_admin == is_admin) + + if can_review is not None: + q = q.filter( + (Role.can_review_budget == can_review) | + (Role.can_review_finance == can_review) + ) + + if can_vote is not None: + q = q.filter(Role.can_vote == can_vote) + + return q.order_by(Role.priority.desc(), Role.name).limit(limit).offset(offset).all() + + def create_role( + self, + name: str, + display_name: str, + description: Optional[str] = None, + permissions: Optional[List[str]] = None, + is_system: bool = False, + is_admin: bool = False, + can_review_budget: bool = False, + can_review_finance: bool = False, + can_vote: bool = False, + oidc_role_claim: Optional[str] = None, + priority: int = 0 + ) -> Role: + """Create a new role""" + role = Role( + name=name, + display_name=display_name, + description=description, + permissions=permissions or [], + is_system=is_system, + is_admin=is_admin, + can_review_budget=can_review_budget, + can_review_finance=can_review_finance, + can_vote=can_vote, + oidc_role_claim=oidc_role_claim, + priority=priority + ) + + self.db.add(role) + self.db.commit() + self.db.refresh(role) + return role + + def update_permissions(self, role_id: int, permissions: List[str]) -> Optional[Role]: + """Update role permissions""" + role = self.get_by_id(role_id) + if role and not role.is_system: + role.permissions = permissions + self.db.commit() + self.db.refresh(role) + return role + + def update_oidc_mapping(self, role_id: int, oidc_claim: Optional[str]) -> Optional[Role]: + """Update OIDC claim mapping for a role""" + role = self.get_by_id(role_id) + if role: + role.oidc_role_claim = oidc_claim + self.db.commit() + self.db.refresh(role) + return role + + def get_role_by_priority(self, roles: List[str]) -> Optional[Role]: + """Get the highest priority role from a list of role names""" + if not roles: + return None + + return self.db.query(Role).filter( + Role.name.in_(roles) + ).order_by(Role.priority.desc()).first() + + def count_users_by_role(self) -> Dict[str, int]: + """Get count of users per role""" + from sqlalchemy import select, func + from ..models.user import User, user_roles + + result = self.db.execute( + select( + Role.name, + func.count(user_roles.c.user_id).label('user_count') + ) + .select_from(Role) + .outerjoin(user_roles, Role.id == user_roles.c.role_id) + .group_by(Role.id, Role.name) + ).all() + + return {row.name: row.user_count for row in result} + + def has_permission(self, role_id: int, permission: str) -> bool: + """Check if a role has a specific permission""" + role = self.get_by_id(role_id) + if not role: + return False + + return role.has_permission(permission) + + def get_default_user_role(self) -> Optional[Role]: + """Get the default role for new users""" + return self.get_by_name("user") + + def get_or_create_default_roles(self) -> Dict[str, Role]: + """Get or create default system roles""" + default_roles = { + "admin": { + "display_name": "Administrator", + "description": "Full system access", + "is_system": True, + "is_admin": True, + "permissions": ["*"], + "priority": 100 + }, + "user": { + "display_name": "User", + "description": "Basic user access", + "is_system": True, + "permissions": ["read:own", "write:own", "submit:application"], + "priority": 0 + }, + "haushaltsbeauftragte": { + "display_name": "Haushaltsbeauftragte(r)", + "description": "Budget reviewer", + "can_review_budget": True, + "permissions": ["review:budget", "read:applications", "comment:applications"], + "priority": 50 + }, + "finanzreferent": { + "display_name": "Finanzreferent", + "description": "Finance reviewer", + "can_review_finance": True, + "permissions": ["review:finance", "read:applications", "comment:applications"], + "priority": 50 + }, + "asta": { + "display_name": "AStA Member", + "description": "Can vote on applications", + "can_vote": True, + "permissions": ["vote:applications", "read:applications", "comment:applications"], + "priority": 40 + } + } + + created_roles = {} + for name, config in default_roles.items(): + role = self.get_by_name(name) + if not role: + role = self.create_role(name=name, **config) + created_roles[name] = role + + return created_roles diff --git a/backend/src/repositories/user.py b/backend/src/repositories/user.py new file mode 100644 index 00000000..59c16574 --- /dev/null +++ b/backend/src/repositories/user.py @@ -0,0 +1,216 @@ +""" +User Repository + +This module provides data access methods for user management. +""" + +from typing import Optional, List, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import or_, and_, func + +from ..models.user import User, Role, AuthProvider, VerificationStatus +from .base import BaseRepository + + +class UserRepository(BaseRepository[User]): + """Repository for user data access""" + + def __init__(self, db: Session): + super().__init__(User, db) + + def get_by_email(self, email: str) -> Optional[User]: + """Get user by email address""" + return self.db.query(User).filter( + func.lower(User.email) == func.lower(email) + ).first() + + def get_by_oidc_sub(self, sub: str, issuer: str) -> Optional[User]: + """Get user by OIDC subject identifier""" + return self.db.query(User).filter( + User.oidc_sub == sub, + User.oidc_issuer == issuer + ).first() + + def get_by_verification_token(self, token_hash: str) -> Optional[User]: + """Get user by email verification token""" + return self.db.query(User).filter( + User.email_verification_token == token_hash + ).first() + + def get_by_auth_provider(self, provider: AuthProvider) -> List[User]: + """Get all users using a specific auth provider""" + return self.db.query(User).filter( + User.auth_provider == provider + ).all() + + def search_users( + self, + query: Optional[str] = None, + verification_status: Optional[VerificationStatus] = None, + auth_provider: Optional[AuthProvider] = None, + has_role: Optional[str] = None, + limit: int = 100, + offset: int = 0 + ) -> List[User]: + """Search users with filters""" + q = self.db.query(User) + + if query: + search_term = f"%{query}%" + q = q.filter( + or_( + User.email.ilike(search_term), + User.given_name.ilike(search_term), + User.family_name.ilike(search_term), + User.display_name.ilike(search_term), + User.preferred_username.ilike(search_term) + ) + ) + + if verification_status: + q = q.filter(User.verification_status == verification_status) + + if auth_provider: + q = q.filter(User.auth_provider == auth_provider) + + if has_role: + q = q.join(User.roles).filter(Role.name == has_role) + + return q.limit(limit).offset(offset).all() + + def count_by_verification_status(self) -> Dict[str, int]: + """Get count of users by verification status""" + counts = self.db.query( + User.verification_status, + func.count(User.id) + ).group_by(User.verification_status).all() + + return { + status.value if status else 'unknown': count + for status, count in counts + } + + def get_users_with_role(self, role_name: str) -> List[User]: + """Get all users with a specific role""" + return self.db.query(User).join(User.roles).filter( + Role.name == role_name + ).all() + + def get_admin_users(self) -> List[User]: + """Get all admin users""" + return self.db.query(User).join(User.roles).filter( + Role.is_admin == True + ).all() + + def get_reviewers(self, review_type: str) -> List[User]: + """Get users who can review applications""" + if review_type == "budget": + return self.db.query(User).join(User.roles).filter( + Role.can_review_budget == True + ).all() + elif review_type == "finance": + return self.db.query(User).join(User.roles).filter( + Role.can_review_finance == True + ).all() + else: + return [] + + def get_voters(self) -> List[User]: + """Get users who can vote on applications""" + return self.db.query(User).join(User.roles).filter( + Role.can_vote == True + ).all() + + def update_last_login(self, user_id: int) -> Optional[User]: + """Update user's last login timestamp""" + from datetime import datetime + + user = self.get_by_id(user_id) + if user: + user.last_login_at = datetime.utcnow() + self.db.commit() + self.db.refresh(user) + return user + + def update_last_activity(self, user_id: int) -> Optional[User]: + """Update user's last activity timestamp""" + from datetime import datetime + + user = self.get_by_id(user_id) + if user: + user.last_activity_at = datetime.utcnow() + self.db.commit() + self.db.refresh(user) + return user + + def verify_email(self, user_id: int) -> Optional[User]: + """Mark user's email as verified""" + from datetime import datetime + + user = self.get_by_id(user_id) + if user: + user.email_verified = True + user.email_verified_at = datetime.utcnow() + user.email_verification_token = None + + # Update verification status + if user.auth_provider == AuthProvider.EMAIL: + user.verification_status = VerificationStatus.EMAIL_VERIFIED + elif user.auth_provider == AuthProvider.OIDC: + user.verification_status = VerificationStatus.FULLY_VERIFIED + + self.db.commit() + self.db.refresh(user) + return user + + def add_role(self, user_id: int, role: Role) -> Optional[User]: + """Add a role to a user""" + user = self.get_by_id(user_id) + if user and role not in user.roles: + user.roles.append(role) + self.db.commit() + self.db.refresh(user) + return user + + def remove_role(self, user_id: int, role: Role) -> Optional[User]: + """Remove a role from a user""" + user = self.get_by_id(user_id) + if user and role in user.roles: + user.roles.remove(role) + self.db.commit() + self.db.refresh(user) + return user + + def set_roles(self, user_id: int, roles: List[Role]) -> Optional[User]: + """Set user's roles (replaces existing)""" + user = self.get_by_id(user_id) + if user: + user.roles = roles + self.db.commit() + self.db.refresh(user) + return user + + def get_inactive_users(self, days: int = 30) -> List[User]: + """Get users who haven't been active for specified days""" + from datetime import datetime, timedelta + + cutoff_date = datetime.utcnow() - timedelta(days=days) + return self.db.query(User).filter( + or_( + User.last_activity_at < cutoff_date, + and_( + User.last_activity_at.is_(None), + User.last_login_at < cutoff_date + ) + ) + ).all() + + def get_unverified_users(self, days_old: int = 7) -> List[User]: + """Get unverified users older than specified days""" + from datetime import datetime, timedelta + + cutoff_date = datetime.utcnow() - timedelta(days=days_old) + return self.db.query(User).filter( + User.verification_status == VerificationStatus.UNVERIFIED, + User.created_at < cutoff_date + ).all() diff --git a/backend/src/services/auth_service.py b/backend/src/services/auth_service.py new file mode 100644 index 00000000..df761a99 --- /dev/null +++ b/backend/src/services/auth_service.py @@ -0,0 +1,259 @@ +""" +Authentication service with dependency injections for FastAPI +""" + +from typing import Optional +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from jose import JWTError, jwt +from datetime import datetime, timedelta +import os + +from ..config.database import get_db +from ..models.user import User + +# Security +security = HTTPBearer(auto_error=False) + +# JWT Configuration +SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30")) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """ + Create a JWT access token + + Args: + data: Data to encode in the token + expires_delta: Optional custom expiration time + + Returns: + Encoded JWT token + """ + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_access_token(token: str) -> dict: + """ + Decode and verify a JWT access token + + Args: + token: JWT token to decode + + Returns: + Decoded token payload + + Raises: + HTTPException: If token is invalid or expired + """ + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + """ + Get the current authenticated user from JWT token + + Args: + credentials: HTTP Bearer token credentials + db: Database session + + Returns: + Current authenticated user + + Raises: + HTTPException: If authentication fails + """ + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + + try: + payload = decode_access_token(token) + user_id: int = payload.get("sub") + + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user + + +async def get_optional_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: Session = Depends(get_db) +) -> Optional[User]: + """ + Get the current user if authenticated, otherwise return None + + Args: + credentials: Optional HTTP Bearer token credentials + db: Database session + + Returns: + Current authenticated user or None + """ + if not credentials: + return None + + try: + return await get_current_user(credentials, db) + except HTTPException: + return None + + +async def require_admin( + current_user: User = Depends(get_current_user) +) -> User: + """ + Require the current user to have admin role + + Args: + current_user: Current authenticated user + + Returns: + Current user if admin + + Raises: + HTTPException: If user is not admin + """ + if not current_user.has_role("admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required" + ) + return current_user + + +async def require_roles(roles: list): + """ + Create a dependency that requires specific roles + + Args: + roles: List of role names that are allowed + + Returns: + Dependency function + """ + async def role_checker(current_user: User = Depends(get_current_user)) -> User: + if not current_user.has_any_role(roles): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"One of these roles required: {', '.join(roles)}" + ) + return current_user + + return role_checker + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a plain password against a hashed password + + Args: + plain_password: Plain text password + hashed_password: Hashed password to compare against + + Returns: + True if password matches + """ + from passlib.context import CryptContext + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """ + Hash a password for storage + + Args: + password: Plain text password + + Returns: + Hashed password + """ + from passlib.context import CryptContext + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + return pwd_context.hash(password) + + +# Helper functions for specific role checks +async def require_budget_reviewer( + current_user: User = Depends(get_current_user) +) -> User: + """Require budget reviewer role""" + if not current_user.has_role("budget_reviewer") and not current_user.has_role("admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Budget reviewer access required" + ) + return current_user + + +async def require_finance_reviewer( + current_user: User = Depends(get_current_user) +) -> User: + """Require finance reviewer role""" + if not current_user.has_role("finance_reviewer") and not current_user.has_role("admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Finance reviewer access required" + ) + return current_user + + +async def require_asta_member( + current_user: User = Depends(get_current_user) +) -> User: + """Require AStA member role""" + if not current_user.has_role("asta_member") and not current_user.has_role("admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="AStA member access required" + ) + return current_user diff --git a/backend/src/services/notification_service.py b/backend/src/services/notification_service.py new file mode 100644 index 00000000..408d84e2 --- /dev/null +++ b/backend/src/services/notification_service.py @@ -0,0 +1,409 @@ +""" +Notification service for sending emails and notifications +""" + +import smtplib +import os +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders +from typing import List, Optional, Dict, Any +import logging +from jinja2 import Template +from datetime import datetime +import asyncio +import aiosmtplib +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class NotificationService: + """Service for sending notifications""" + + def __init__(self): + self.smtp_host = os.getenv("SMTP_HOST", "localhost") + self.smtp_port = int(os.getenv("SMTP_PORT", "587")) + self.smtp_user = os.getenv("SMTP_USER", "") + self.smtp_password = os.getenv("SMTP_PASSWORD", "") + self.smtp_use_tls = os.getenv("SMTP_USE_TLS", "true").lower() == "true" + self.smtp_use_ssl = os.getenv("SMTP_USE_SSL", "false").lower() == "true" + self.from_email = os.getenv("FROM_EMAIL", "noreply@example.com") + self.from_name = os.getenv("FROM_NAME", "Application System") + self.reply_to_email = os.getenv("REPLY_TO_EMAIL", "") + self.base_url = os.getenv("BASE_URL", "http://localhost:3000") + + def _render_template(self, template: str, context: Dict[str, Any]) -> str: + """ + Render a template with context + + Args: + template: Template string (can include Jinja2 syntax) + context: Context dictionary for template rendering + + Returns: + Rendered template string + """ + try: + jinja_template = Template(template) + return jinja_template.render(**context) + except Exception as e: + logger.error(f"Failed to render template: {str(e)}") + return template + + def _create_message( + self, + to_email: str, + subject: str, + body: str, + html_body: Optional[str] = None, + attachments: Optional[List[tuple]] = None, + cc: Optional[List[str]] = None, + bcc: Optional[List[str]] = None + ) -> MIMEMultipart: + """ + Create an email message + + Args: + to_email: Recipient email address + subject: Email subject + body: Plain text body + html_body: Optional HTML body + attachments: Optional list of (filename, content) tuples + cc: Optional list of CC recipients + bcc: Optional list of BCC recipients + + Returns: + MIMEMultipart message object + """ + msg = MIMEMultipart('alternative') + msg['From'] = f"{self.from_name} <{self.from_email}>" + msg['To'] = to_email + msg['Subject'] = subject + + if self.reply_to_email: + msg['Reply-To'] = self.reply_to_email + + if cc: + msg['Cc'] = ', '.join(cc) + + if bcc: + msg['Bcc'] = ', '.join(bcc) + + # Add plain text part + msg.attach(MIMEText(body, 'plain')) + + # Add HTML part if provided + if html_body: + msg.attach(MIMEText(html_body, 'html')) + + # Add attachments + if attachments: + for filename, content in attachments: + part = MIMEBase('application', 'octet-stream') + part.set_payload(content) + encoders.encode_base64(part) + part.add_header( + 'Content-Disposition', + f'attachment; filename= {filename}' + ) + msg.attach(part) + + return msg + + def send_email( + self, + to_email: str, + subject: str, + body: str, + html_body: Optional[str] = None, + attachments: Optional[List[tuple]] = None, + cc: Optional[List[str]] = None, + bcc: Optional[List[str]] = None, + context: Optional[Dict[str, Any]] = None + ) -> bool: + """ + Send an email synchronously + + Args: + to_email: Recipient email address + subject: Email subject + body: Plain text body + html_body: Optional HTML body + attachments: Optional list of (filename, content) tuples + cc: Optional list of CC recipients + bcc: Optional list of BCC recipients + context: Optional context for template rendering + + Returns: + True if email was sent successfully + """ + try: + # Render templates if context is provided + if context: + subject = self._render_template(subject, context) + body = self._render_template(body, context) + if html_body: + html_body = self._render_template(html_body, context) + + msg = self._create_message( + to_email, subject, body, html_body, + attachments, cc, bcc + ) + + # Send email + if self.smtp_use_ssl: + server = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port) + else: + server = smtplib.SMTP(self.smtp_host, self.smtp_port) + if self.smtp_use_tls: + server.starttls() + + if self.smtp_user and self.smtp_password: + server.login(self.smtp_user, self.smtp_password) + + all_recipients = [to_email] + if cc: + all_recipients.extend(cc) + if bcc: + all_recipients.extend(bcc) + + server.send_message(msg, self.from_email, all_recipients) + server.quit() + + logger.info(f"Email sent successfully to {to_email}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {to_email}: {str(e)}") + return False + + async def send_email_async( + self, + to_email: str, + subject: str, + body: str, + html_body: Optional[str] = None, + attachments: Optional[List[tuple]] = None, + cc: Optional[List[str]] = None, + bcc: Optional[List[str]] = None, + context: Optional[Dict[str, Any]] = None + ) -> bool: + """ + Send an email asynchronously + + Args: + to_email: Recipient email address + subject: Email subject + body: Plain text body + html_body: Optional HTML body + attachments: Optional list of (filename, content) tuples + cc: Optional list of CC recipients + bcc: Optional list of BCC recipients + context: Optional context for template rendering + + Returns: + True if email was sent successfully + """ + try: + # Render templates if context is provided + if context: + subject = self._render_template(subject, context) + body = self._render_template(body, context) + if html_body: + html_body = self._render_template(html_body, context) + + msg = self._create_message( + to_email, subject, body, html_body, + attachments, cc, bcc + ) + + # Send email asynchronously + await aiosmtplib.send( + msg, + hostname=self.smtp_host, + port=self.smtp_port, + username=self.smtp_user if self.smtp_user else None, + password=self.smtp_password if self.smtp_password else None, + use_tls=self.smtp_use_tls, + start_tls=self.smtp_use_tls and not self.smtp_use_ssl + ) + + logger.info(f"Email sent successfully to {to_email}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {to_email}: {str(e)}") + return False + + def send_application_created( + self, + to_email: str, + application_id: str, + access_key: str, + application_title: str, + status: str + ): + """ + Send application created notification + + Args: + to_email: Recipient email + application_id: Application ID + access_key: Access key for the application + application_title: Application title + status: Current status + """ + subject = "Application Created Successfully" + + body = f""" +Dear Applicant, + +Your application "{application_title}" has been created successfully. + +Application ID: {application_id} +Status: {status} + +You can access your application at: +{self.base_url}/applications/{application_id}?key={access_key} + +Please save this link for future reference. You will need it to access and update your application. + +Best regards, +{self.from_name} + """ + + html_body = f""" + + + + + + +
+
+

Application Created Successfully

+
+ +

Dear Applicant,

+ +

Your application "{application_title}" has been created successfully.

+ +
+

Application ID: {application_id}

+

Status: {status}

+
+ +

You can access your application at:

+

+ + View Application + +

+ +

Important: Please save this link for future reference. You will need it to access and update your application.

+ +

Best regards,
{self.from_name}

+
+ + + """ + + return self.send_email(to_email, subject, body, html_body) + + def send_status_change( + self, + to_email: str, + application_id: str, + application_title: str, + old_status: str, + new_status: str, + comment: Optional[str] = None + ): + """ + Send status change notification + + Args: + to_email: Recipient email + application_id: Application ID + application_title: Application title + old_status: Previous status + new_status: New status + comment: Optional comment + """ + subject = f"Application Status Changed: {new_status}" + + body = f""" +Dear Applicant, + +The status of your application "{application_title}" has been changed. + +Application ID: {application_id} +Previous Status: {old_status} +New Status: {new_status} +{f'Comment: {comment}' if comment else ''} + +You can view your application at: +{self.base_url}/applications/{application_id} + +Best regards, +{self.from_name} + """ + + return self.send_email(to_email, subject, body) + + +# Singleton instance +notification_service = NotificationService() + + +# Convenience functions for background tasks +async def send_notification( + to_email: str, + subject: str, + body: str, + html_body: Optional[str] = None, + attachments: Optional[List[tuple]] = None +): + """ + Send a notification email (for use in background tasks) + + Args: + to_email: Recipient email + subject: Email subject + body: Plain text body + html_body: Optional HTML body + attachments: Optional attachments + """ + await notification_service.send_email_async( + to_email, subject, body, html_body, attachments + ) + + +def send_notification_sync( + to_email: str, + subject: str, + body: str, + html_body: Optional[str] = None, + attachments: Optional[List[tuple]] = None +): + """ + Send a notification email synchronously + + Args: + to_email: Recipient email + subject: Email subject + body: Plain text body + html_body: Optional HTML body + attachments: Optional attachments + """ + notification_service.send_email( + to_email, subject, body, html_body, attachments + ) diff --git a/backend/src/services/pdf_service.py b/backend/src/services/pdf_service.py new file mode 100644 index 00000000..fdecc084 --- /dev/null +++ b/backend/src/services/pdf_service.py @@ -0,0 +1,392 @@ +""" +PDF generation service for applications +""" + +import os +import io +from typing import Optional, Dict, Any, List +from datetime import datetime +from pathlib import Path +import hashlib +from sqlalchemy.orm import Session + +from ..models.application_type import ( + DynamicApplication, ApplicationType, ApplicationField, + ApplicationTypeStatus, FieldType +) +from ..utils.pdf_utils import ( + fill_pdf_template, create_pdf_from_data, + add_watermark_to_pdf, merge_pdfs +) + + +class PDFService: + """Service for generating PDFs from applications""" + + def __init__(self): + self.output_dir = Path(os.getenv("PDF_OUTPUT_DIR", "./uploads/pdfs")) + self.output_dir.mkdir(parents=True, exist_ok=True) + + def generate_pdf_for_application( + self, + application: DynamicApplication, + db: Session, + include_watermark: bool = False, + watermark_text: Optional[str] = None + ) -> str: + """ + Generate a PDF for an application + + Args: + application: The application to generate PDF for + db: Database session + include_watermark: Whether to include a watermark + watermark_text: Custom watermark text + + Returns: + Path to the generated PDF file + """ + # Get application type + app_type = application.application_type + + # Prepare data for PDF + pdf_data = self._prepare_pdf_data(application, app_type, db) + + # Generate PDF + if app_type.pdf_template: + # Use template if available + pdf_content = self._generate_from_template( + app_type.pdf_template, + app_type.pdf_field_mapping, + pdf_data + ) + else: + # Generate from scratch + pdf_content = self._generate_from_scratch( + pdf_data, + app_type.name, + application.title + ) + + # Add watermark if requested + if include_watermark: + if not watermark_text: + watermark_text = self._get_default_watermark(application, db) + pdf_content = add_watermark_to_pdf(pdf_content, watermark_text) + + # Save to file + filename = self._generate_filename(application) + filepath = self.output_dir / filename + + with open(filepath, 'wb') as f: + f.write(pdf_content) + + return str(filepath) + + def _prepare_pdf_data( + self, + application: DynamicApplication, + app_type: ApplicationType, + db: Session + ) -> Dict[str, Any]: + """ + Prepare data for PDF generation + + Args: + application: The application + app_type: Application type + db: Database session + + Returns: + Dictionary of data for PDF + """ + # Start with common fields + data = { + "application_id": application.application_id, + "type": app_type.name, + "title": application.title, + "email": application.email, + "first_name": application.first_name or "", + "last_name": application.last_name or "", + "submitted_at": application.submitted_at.strftime("%d.%m.%Y %H:%M") if application.submitted_at else "", + "created_at": application.created_at.strftime("%d.%m.%Y %H:%M") if application.created_at else "", + "status": self._get_status_name(application, app_type, db), + "total_amount": f"{application.total_amount:.2f} €" + } + + # Add field data with proper formatting + for field in app_type.fields: + field_id = field.field_id + value = application.field_data.get(field_id) + + if value is not None: + formatted_value = self._format_field_value(value, field) + data[field_id] = formatted_value + # Also add with field name as key for better template compatibility + data[field.name.lower().replace(' ', '_')] = formatted_value + + # Add cost positions + if application.cost_positions: + data["cost_positions"] = self._format_cost_positions(application.cost_positions) + data["cost_positions_count"] = len(application.cost_positions) + + # Add comparison offers + if application.comparison_offers: + data["comparison_offers"] = self._format_comparison_offers(application.comparison_offers) + data["comparison_offers_count"] = len(application.comparison_offers) + + return data + + def _format_field_value(self, value: Any, field: ApplicationField) -> str: + """ + Format a field value for PDF display + + Args: + value: The raw value + field: Field definition + + Returns: + Formatted string value + """ + if value is None or value == "": + return "" + + if field.field_type == FieldType.YESNO: + return "Ja" if value else "Nein" + + elif field.field_type == FieldType.DATE: + try: + # Parse and format date + from datetime import datetime + if isinstance(value, str): + # Try different formats + for fmt in ["%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"]: + try: + dt = datetime.strptime(value, fmt) + return dt.strftime("%d.%m.%Y") + except: + continue + return str(value) + except: + return str(value) + + elif field.field_type == FieldType.DATETIME: + try: + if isinstance(value, str): + dt = datetime.fromisoformat(value) + return dt.strftime("%d.%m.%Y %H:%M") + return str(value) + except: + return str(value) + + elif field.field_type in [FieldType.AMOUNT, FieldType.CURRENCY_EUR]: + try: + amount = float(value) + return f"{amount:.2f} €" + except: + return str(value) + + elif field.field_type == FieldType.MULTISELECT: + if isinstance(value, list): + return ", ".join(str(v) for v in value) + return str(value) + + else: + return str(value) + + def _format_cost_positions(self, positions: List[Dict]) -> List[Dict]: + """Format cost positions for PDF""" + formatted = [] + for i, pos in enumerate(positions, 1): + formatted.append({ + "number": i, + "description": pos.get("description", ""), + "amount": f"{float(pos.get('amount', 0)):.2f} €", + "category": pos.get("category", ""), + "notes": pos.get("notes", "") + }) + return formatted + + def _format_comparison_offers(self, offers: List[Dict]) -> List[Dict]: + """Format comparison offers for PDF""" + formatted = [] + for i, offer in enumerate(offers, 1): + formatted.append({ + "number": i, + "vendor": offer.get("vendor", ""), + "description": offer.get("description", ""), + "amount": f"{float(offer.get('amount', 0)):.2f} €", + "selected": "✓" if offer.get("selected") else "", + "notes": offer.get("notes", "") + }) + return formatted + + def _get_status_name( + self, + application: DynamicApplication, + app_type: ApplicationType, + db: Session + ) -> str: + """Get the display name for the current status""" + status = db.query(ApplicationTypeStatus).filter( + ApplicationTypeStatus.application_type_id == app_type.id, + ApplicationTypeStatus.status_id == application.status_id + ).first() + return status.name if status else application.status_id + + def _generate_from_template( + self, + template_content: bytes, + field_mapping: Dict[str, str], + data: Dict[str, Any] + ) -> bytes: + """Generate PDF from template""" + return fill_pdf_template(template_content, field_mapping, data) + + def _generate_from_scratch( + self, + data: Dict[str, Any], + type_name: str, + title: str + ) -> bytes: + """Generate PDF from scratch""" + # Prepare formatted data + formatted_data = { + "Application Type": type_name, + "Title": title, + "Application ID": data.get("application_id", ""), + "Status": data.get("status", ""), + "Submitted": data.get("submitted_at", ""), + "Email": data.get("email", ""), + "Name": f"{data.get('first_name', '')} {data.get('last_name', '')}".strip(), + "Total Amount": data.get("total_amount", "") + } + + # Add other fields + for key, value in data.items(): + if key not in ["application_id", "type", "title", "email", "first_name", + "last_name", "submitted_at", "created_at", "status", + "total_amount", "cost_positions", "comparison_offers", + "cost_positions_count", "comparison_offers_count"]: + # Format key for display + display_key = key.replace('_', ' ').title() + formatted_data[display_key] = value + + # Add cost positions if present + if "cost_positions" in data and data["cost_positions"]: + formatted_data["Cost Positions"] = data["cost_positions"] + + # Add comparison offers if present + if "comparison_offers" in data and data["comparison_offers"]: + formatted_data["Comparison Offers"] = data["comparison_offers"] + + return create_pdf_from_data(formatted_data, title) + + def _get_default_watermark( + self, + application: DynamicApplication, + db: Session + ) -> str: + """Get default watermark text""" + status = self._get_status_name(application, application.application_type, db) + + if "draft" in status.lower(): + return "ENTWURF" + elif "approved" in status.lower() or "genehmigt" in status.lower(): + return "GENEHMIGT" + elif "rejected" in status.lower() or "abgelehnt" in status.lower(): + return "ABGELEHNT" + else: + return status.upper() + + def _generate_filename(self, application: DynamicApplication) -> str: + """Generate a unique filename for the PDF""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_title = "".join(c for c in application.title if c.isalnum() or c in (' ', '-', '_'))[:50] + safe_title = safe_title.replace(' ', '_') + + return f"{application.application_id}_{safe_title}_{timestamp}.pdf" + + def generate_batch_pdfs( + self, + application_ids: List[str], + db: Session, + merge: bool = False + ) -> Optional[str]: + """ + Generate PDFs for multiple applications + + Args: + application_ids: List of application IDs + db: Database session + merge: Whether to merge into a single PDF + + Returns: + Path to the generated file(s) + """ + pdf_contents = [] + pdf_paths = [] + + for app_id in application_ids: + application = db.query(DynamicApplication).filter( + DynamicApplication.application_id == app_id + ).first() + + if application: + path = self.generate_pdf_for_application(application, db) + pdf_paths.append(path) + + if merge: + with open(path, 'rb') as f: + pdf_contents.append(f.read()) + + if merge and pdf_contents: + # Merge all PDFs + merged_content = merge_pdfs(pdf_contents) + + # Save merged PDF + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + merged_filename = f"batch_{timestamp}.pdf" + merged_path = self.output_dir / merged_filename + + with open(merged_path, 'wb') as f: + f.write(merged_content) + + # Delete individual PDFs + for path in pdf_paths: + try: + os.remove(path) + except: + pass + + return str(merged_path) + + return pdf_paths[0] if pdf_paths else None + + +# Singleton instance +pdf_service = PDFService() + + +# Convenience function for use in routes +def generate_pdf_for_application( + application: DynamicApplication, + db: Session, + include_watermark: bool = False, + watermark_text: Optional[str] = None +) -> str: + """ + Generate a PDF for an application + + Args: + application: The application + db: Database session + include_watermark: Whether to include watermark + watermark_text: Custom watermark text + + Returns: + Path to generated PDF + """ + return pdf_service.generate_pdf_for_application( + application, db, include_watermark, watermark_text + ) diff --git a/backend/src/startup.py b/backend/src/startup.py new file mode 100644 index 00000000..5acbc31d --- /dev/null +++ b/backend/src/startup.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +Application Startup Script + +This script initializes the database and creates default data for the dynamic application system. +""" + +import sys +import os +import logging + +# Add src directory to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from config.database import init_database, get_engine +from sqlalchemy.orm import Session +from models.application_type import ( + ApplicationType, ApplicationField, ApplicationTypeStatus, + StatusTransition, FieldType, TransitionTriggerType +) +from models.user import User, Role + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +def create_default_application_types(): + """Create default QSM and VSM application types if they don't exist""" + engine = get_engine() + + with Session(engine) as session: + # Check if types already exist + existing_qsm = session.query(ApplicationType).filter_by(type_id="qsm").first() + existing_vsm = session.query(ApplicationType).filter_by(type_id="vsm").first() + + if existing_qsm and existing_vsm: + logger.info("Default application types already exist") + return + + # Create QSM type if not exists + if not existing_qsm: + logger.info("Creating QSM application type...") + qsm_type = ApplicationType( + type_id="qsm", + name="QSM - Qualitätssicherungsmittel", + description="Antrag für Qualitätssicherungsmittel zur Verbesserung der Lehre", + is_active=True, + is_public=True, + max_cost_positions=100, + max_comparison_offers=100 + ) + session.add(qsm_type) + session.flush() + + # Create default statuses for QSM + create_default_statuses(session, qsm_type) + logger.info("QSM application type created successfully") + + # Create VSM type if not exists + if not existing_vsm: + logger.info("Creating VSM application type...") + vsm_type = ApplicationType( + type_id="vsm", + name="VSM - Verfasste Studierendenschaft", + description="Antrag für Mittel der Verfassten Studierendenschaft", + is_active=True, + is_public=True, + max_cost_positions=100, + max_comparison_offers=100 + ) + session.add(vsm_type) + session.flush() + + # Create default statuses for VSM + create_default_statuses(session, vsm_type) + logger.info("VSM application type created successfully") + + session.commit() + logger.info("Default application types initialization complete") + + +def create_default_statuses(session, app_type: ApplicationType): + """Create default statuses and transitions for an application type""" + + # Define default statuses + statuses = [ + { + "status_id": "draft", + "name": "Entwurf", + "is_editable": True, + "color": "#6B7280", + "is_initial": True, + "display_order": 0 + }, + { + "status_id": "submitted", + "name": "Eingereicht", + "is_editable": False, + "color": "#3B82F6", + "send_notification": True, + "display_order": 10 + }, + { + "status_id": "under_review", + "name": "In Prüfung", + "is_editable": False, + "color": "#8B5CF6", + "display_order": 20 + }, + { + "status_id": "approved", + "name": "Genehmigt", + "is_editable": False, + "color": "#10B981", + "is_final": True, + "send_notification": True, + "display_order": 30 + }, + { + "status_id": "rejected", + "name": "Abgelehnt", + "is_editable": False, + "color": "#EF4444", + "is_final": True, + "send_notification": True, + "display_order": 40 + } + ] + + status_objects = {} + + for status_data in statuses: + status = ApplicationTypeStatus( + application_type_id=app_type.id, + **status_data + ) + session.add(status) + session.flush() + status_objects[status_data["status_id"]] = status + + # Create transitions + transitions = [ + { + "from": "draft", + "to": "submitted", + "name": "Einreichen", + "trigger_type": TransitionTriggerType.APPLICANT_ACTION + }, + { + "from": "submitted", + "to": "under_review", + "name": "Prüfung starten", + "trigger_type": TransitionTriggerType.USER_APPROVAL, + "config": {"role": "admin"} + }, + { + "from": "under_review", + "to": "approved", + "name": "Genehmigen", + "trigger_type": TransitionTriggerType.USER_APPROVAL, + "config": {"role": "admin"} + }, + { + "from": "under_review", + "to": "rejected", + "name": "Ablehnen", + "trigger_type": TransitionTriggerType.USER_APPROVAL, + "config": {"role": "admin"} + } + ] + + for trans in transitions: + transition = StatusTransition( + from_status_id=status_objects[trans["from"]].id, + to_status_id=status_objects[trans["to"]].id, + name=trans["name"], + trigger_type=trans["trigger_type"], + trigger_config=trans.get("config", {}), + is_active=True + ) + session.add(transition) + + +def create_admin_user(): + """Create a default admin user if none exists""" + engine = get_engine() + + with Session(engine) as session: + # Check if any admin user exists + admin_role = session.query(Role).filter_by(name="admin").first() + if not admin_role: + logger.warning("Admin role not found, skipping admin user creation") + return + + admin_users = session.query(User).join(User.roles).filter(Role.name == "admin").all() + + if admin_users: + logger.info("Admin user(s) already exist") + return + + logger.info("Creating default admin user...") + + # Create admin user + from services.auth_service import get_password_hash + + admin = User( + email="admin@example.com", + given_name="System", + family_name="Administrator", + display_name="System Admin", + auth_provider="local", + verification_status="fully_verified", + email_verified=True + ) + + # Set password (you should change this!) + # For production, this should be set via environment variable + default_password = os.getenv("ADMIN_PASSWORD", "changeme123") + + # Note: You'll need to implement password storage separately + # This is just a placeholder + + session.add(admin) + admin.roles.append(admin_role) + session.commit() + + logger.info(f"Default admin user created: admin@example.com") + logger.warning(f"IMPORTANT: Change the default admin password immediately!") + + +def main(): + """Main startup function""" + logger.info("Starting application initialization...") + + try: + # Initialize database schema + logger.info("Initializing database schema...") + init_database() + logger.info("Database schema initialized") + + # Create default application types + create_default_application_types() + + # Create admin user + create_admin_user() + + logger.info("Application initialization complete!") + logger.info("You can now start the application server.") + + except Exception as e: + logger.error(f"Initialization failed: {e}", exc_info=True) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/src/utils/crypto.py b/backend/src/utils/crypto.py new file mode 100644 index 00000000..52dd010d --- /dev/null +++ b/backend/src/utils/crypto.py @@ -0,0 +1,139 @@ +""" +Cryptography Utilities + +This module provides encryption and decryption utilities for sensitive data. +""" + +import base64 +import secrets +import hashlib +from typing import Optional +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2 + + +def generate_key(password: str, salt: Optional[bytes] = None) -> bytes: + """Generate an encryption key from a password""" + if salt is None: + salt = secrets.token_bytes(16) + + kdf = PBKDF2( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + ) + key = base64.urlsafe_b64encode(kdf.derive(password.encode())) + return key + + +def encrypt_token(token: str, key: str) -> str: + """Encrypt a token using Fernet encryption""" + if not token or not key: + return "" + + # Ensure key is properly formatted + if len(key) < 32: + # Pad the key if too short + key = key.ljust(32, '0') + + # Create Fernet key from the provided key + fernet_key = base64.urlsafe_b64encode(key[:32].encode()) + f = Fernet(fernet_key) + + # Encrypt the token + encrypted = f.encrypt(token.encode()) + return base64.urlsafe_b64encode(encrypted).decode() + + +def decrypt_token(encrypted_token: str, key: str) -> Optional[str]: + """Decrypt a token using Fernet encryption""" + if not encrypted_token or not key: + return None + + try: + # Ensure key is properly formatted + if len(key) < 32: + # Pad the key if too short + key = key.ljust(32, '0') + + # Create Fernet key from the provided key + fernet_key = base64.urlsafe_b64encode(key[:32].encode()) + f = Fernet(fernet_key) + + # Decode and decrypt the token + encrypted_bytes = base64.urlsafe_b64decode(encrypted_token.encode()) + decrypted = f.decrypt(encrypted_bytes) + return decrypted.decode() + except Exception: + return None + + +def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]: + """Hash a password with salt""" + if salt is None: + salt = secrets.token_hex(16) + + # Create hash + password_hash = hashlib.pbkdf2_hmac( + 'sha256', + password.encode(), + salt.encode(), + 100000 + ) + + return base64.b64encode(password_hash).decode(), salt + + +def verify_password(password: str, password_hash: str, salt: str) -> bool: + """Verify a password against its hash""" + try: + # Recreate hash with provided password and salt + new_hash = hashlib.pbkdf2_hmac( + 'sha256', + password.encode(), + salt.encode(), + 100000 + ) + new_hash_str = base64.b64encode(new_hash).decode() + + # Compare hashes + return new_hash_str == password_hash + except Exception: + return False + + +def generate_secure_token(length: int = 32) -> str: + """Generate a cryptographically secure random token""" + return secrets.token_urlsafe(length) + + +def generate_api_key() -> str: + """Generate a secure API key""" + return secrets.token_urlsafe(32) + + +def hash_api_key(api_key: str) -> str: + """Hash an API key for storage""" + return hashlib.sha256(api_key.encode()).hexdigest() + + +def verify_api_key(api_key: str, hashed_key: str) -> bool: + """Verify an API key against its hash""" + return hash_api_key(api_key) == hashed_key + + +def generate_session_id() -> str: + """Generate a secure session ID""" + return secrets.token_hex(32) + + +def encode_base64(data: bytes) -> str: + """Encode bytes to base64 string""" + return base64.urlsafe_b64encode(data).decode() + + +def decode_base64(data: str) -> bytes: + """Decode base64 string to bytes""" + return base64.urlsafe_b64decode(data.encode()) diff --git a/backend/src/utils/email.py b/backend/src/utils/email.py new file mode 100644 index 00000000..3db09357 --- /dev/null +++ b/backend/src/utils/email.py @@ -0,0 +1,359 @@ +""" +Email Service Utilities + +This module provides email sending functionality for the application. +""" + +import smtplib +import ssl +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from typing import Optional, List, Dict, Any +from pathlib import Path +import logging +from jinja2 import Template, Environment, FileSystemLoader + +logger = logging.getLogger(__name__) + + +class EmailService: + """Service for sending emails""" + + def __init__(self, settings): + self.settings = settings + self.smtp_host = settings.email.smtp_host + self.smtp_port = settings.email.smtp_port + self.smtp_tls = settings.email.smtp_tls + self.smtp_ssl = settings.email.smtp_ssl + self.smtp_username = settings.email.smtp_username + self.smtp_password = settings.email.smtp_password + self.from_email = settings.email.from_email + self.from_name = settings.email.from_name + + # Setup Jinja2 for email templates + template_dir = Path(__file__).parent.parent / "templates" / "emails" + if template_dir.exists(): + self.jinja_env = Environment(loader=FileSystemLoader(str(template_dir))) + else: + self.jinja_env = Environment() + + async def send_email( + self, + to_email: str, + subject: str, + html_content: str, + text_content: Optional[str] = None, + cc: Optional[List[str]] = None, + bcc: Optional[List[str]] = None, + attachments: Optional[List[Dict[str, Any]]] = None + ) -> bool: + """Send an email""" + try: + # Create message + message = MIMEMultipart("alternative") + message["Subject"] = subject + message["From"] = f"{self.from_name} <{self.from_email}>" + message["To"] = to_email + + if cc: + message["Cc"] = ", ".join(cc) + if bcc: + message["Bcc"] = ", ".join(bcc) + + # Add text and HTML parts + if text_content: + text_part = MIMEText(text_content, "plain") + message.attach(text_part) + + html_part = MIMEText(html_content, "html") + message.attach(html_part) + + # Add attachments if provided + if attachments: + for attachment in attachments: + # attachment should have 'content', 'filename', and optional 'content_type' + pass # Implementation would go here + + # Send email + if self.smtp_ssl: + # SSL connection + context = ssl.create_default_context() + with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=context) as server: + if self.smtp_username and self.smtp_password: + server.login(self.smtp_username, self.smtp_password) + recipients = [to_email] + if cc: + recipients.extend(cc) + if bcc: + recipients.extend(bcc) + server.sendmail(self.from_email, recipients, message.as_string()) + else: + # TLS connection + with smtplib.SMTP(self.smtp_host, self.smtp_port) as server: + if self.smtp_tls: + server.starttls() + if self.smtp_username and self.smtp_password: + server.login(self.smtp_username, self.smtp_password) + recipients = [to_email] + if cc: + recipients.extend(cc) + if bcc: + recipients.extend(bcc) + server.sendmail(self.from_email, recipients, message.as_string()) + + logger.info(f"Email sent successfully to {to_email}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {to_email}: {e}") + return False + + async def send_verification_email( + self, + to_email: str, + user_name: str, + verification_url: str + ) -> bool: + """Send email verification link""" + subject = self.settings.email.verification_subject + + # HTML content + html_content = f""" + + +
+

E-Mail-Verifizierung

+

Hallo {user_name},

+

Vielen Dank für Ihre Registrierung. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken:

+
+ + E-Mail verifizieren + +
+

Oder kopieren Sie diesen Link in Ihren Browser:

+

+ {verification_url} +

+

Dieser Link ist 24 Stunden gültig.

+
+

+ Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren. +

+
+ + + """ + + # Text content + text_content = f""" + Hallo {user_name}, + + Vielen Dank für Ihre Registrierung. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken: + + {verification_url} + + Dieser Link ist 24 Stunden gültig. + + Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren. + """ + + return await self.send_email(to_email, subject, html_content, text_content) + + async def send_magic_link_email( + self, + to_email: str, + user_name: str, + login_url: str + ) -> bool: + """Send magic link for login""" + subject = self.settings.email.magic_link_subject + + # HTML content + html_content = f""" + + +
+

Anmeldung bei STUPA

+

Hallo {user_name},

+

Sie haben eine Anmeldung angefordert. Klicken Sie auf den folgenden Link, um sich anzumelden:

+
+ + Jetzt anmelden + +
+

Oder kopieren Sie diesen Link in Ihren Browser:

+

+ {login_url} +

+

Dieser Link ist 15 Minuten gültig.

+
+

+ Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie bitte diese E-Mail. +

+
+ + + """ + + # Text content + text_content = f""" + Hallo {user_name}, + + Sie haben eine Anmeldung angefordert. Klicken Sie auf den folgenden Link, um sich anzumelden: + + {login_url} + + Dieser Link ist 15 Minuten gültig. + + Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie bitte diese E-Mail. + """ + + return await self.send_email(to_email, subject, html_content, text_content) + + async def send_application_status_email( + self, + to_email: str, + user_name: str, + application_id: str, + old_status: str, + new_status: str, + comment: Optional[str] = None, + application_url: Optional[str] = None + ) -> bool: + """Send application status update email""" + subject = f"{self.settings.email.application_notification_subject} - Antrag {application_id}" + + status_translations = { + "beantragt": "Beantragt", + "bearbeitung_gesperrt": "In Bearbeitung", + "zu_pruefen": "Wird geprüft", + "zur_abstimmung": "Zur Abstimmung", + "genehmigt": "Genehmigt", + "abgelehnt": "Abgelehnt" + } + + old_status_display = status_translations.get(old_status, old_status) + new_status_display = status_translations.get(new_status, new_status) + + # HTML content + html_content = f""" + + +
+

Status-Update zu Ihrem Antrag

+

Hallo {user_name},

+

Der Status Ihres Antrags {application_id} wurde aktualisiert:

+
+

Vorheriger Status: {old_status_display}

+

Neuer Status: {new_status_display}

+
+ """ + + if comment: + html_content += f""" +
+

Kommentar:

+

{comment}

+
+ """ + + if application_url: + html_content += f""" +
+ + Antrag anzeigen + +
+ """ + + html_content += """ +
+

+ Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail. +

+
+ + + """ + + # Text content + text_content = f""" + Hallo {user_name}, + + Der Status Ihres Antrags {application_id} wurde aktualisiert: + + Vorheriger Status: {old_status_display} + Neuer Status: {new_status_display} + """ + + if comment: + text_content += f"\n\nKommentar:\n{comment}" + + if application_url: + text_content += f"\n\nAntrag anzeigen: {application_url}" + + text_content += "\n\nDiese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail." + + return await self.send_email(to_email, subject, html_content, text_content) + + async def send_review_request_email( + self, + to_email: str, + reviewer_name: str, + application_id: str, + applicant_name: str, + review_type: str, + review_url: str + ) -> bool: + """Send review request to reviewers""" + review_type_display = { + "budget": "Haushaltsbeauftragte(r)", + "finance": "Finanzreferent" + }.get(review_type, review_type) + + subject = f"Prüfauftrag: Antrag {application_id} - {review_type_display}" + + # HTML content + html_content = f""" + + +
+

Prüfauftrag

+

Hallo {reviewer_name},

+

Es liegt ein neuer Antrag zur Prüfung vor:

+
+

Antragsnummer: {application_id}

+

Antragsteller: {applicant_name}

+

Ihre Rolle: {review_type_display}

+
+
+ + Antrag prüfen + +
+

Bitte prüfen Sie den Antrag zeitnah.

+
+ + + """ + + # Text content + text_content = f""" + Hallo {reviewer_name}, + + Es liegt ein neuer Antrag zur Prüfung vor: + + Antragsnummer: {application_id} + Antragsteller: {applicant_name} + Ihre Rolle: {review_type_display} + + Antrag prüfen: {review_url} + + Bitte prüfen Sie den Antrag zeitnah. + """ + + return await self.send_email(to_email, subject, html_content, text_content) diff --git a/backend/src/utils/file_storage.py b/backend/src/utils/file_storage.py new file mode 100644 index 00000000..d66f5cf7 --- /dev/null +++ b/backend/src/utils/file_storage.py @@ -0,0 +1,348 @@ +""" +File Storage Service + +This module provides file storage and management utilities. +""" + +import os +import shutil +import hashlib +from pathlib import Path +from typing import Optional, BinaryIO, Tuple +from datetime import datetime +import uuid +import mimetypes +import logging + +logger = logging.getLogger(__name__) + + +class FileStorageService: + """Service for managing file storage""" + + def __init__(self, base_path: str): + """Initialize file storage service + + Args: + base_path: Base directory for file storage + """ + self.base_path = Path(base_path) + self.base_path.mkdir(parents=True, exist_ok=True) + + def save_file( + self, + file_content: bytes, + filename: str, + subdirectory: Optional[str] = None, + generate_unique_name: bool = True + ) -> Tuple[str, str]: + """Save a file to storage + + Args: + file_content: File content as bytes + filename: Original filename + subdirectory: Optional subdirectory within base path + generate_unique_name: Whether to generate a unique filename + + Returns: + Tuple of (stored_path, file_hash) + """ + # Create subdirectory if specified + if subdirectory: + target_dir = self.base_path / subdirectory + else: + target_dir = self.base_path + target_dir.mkdir(parents=True, exist_ok=True) + + # Generate unique filename if requested + if generate_unique_name: + file_ext = Path(filename).suffix + unique_id = uuid.uuid4().hex[:8] + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + stored_filename = f"{timestamp}_{unique_id}{file_ext}" + else: + stored_filename = filename + + # Full path for storage + file_path = target_dir / stored_filename + + # Calculate file hash + file_hash = hashlib.sha256(file_content).hexdigest() + + # Save file + try: + with open(file_path, 'wb') as f: + f.write(file_content) + + logger.info(f"File saved: {file_path}") + return str(file_path.relative_to(self.base_path)), file_hash + + except Exception as e: + logger.error(f"Failed to save file {filename}: {e}") + raise + + def read_file(self, file_path: str) -> bytes: + """Read a file from storage + + Args: + file_path: Path relative to base_path + + Returns: + File content as bytes + """ + full_path = self.base_path / file_path + + if not full_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + try: + with open(full_path, 'rb') as f: + return f.read() + except Exception as e: + logger.error(f"Failed to read file {file_path}: {e}") + raise + + def delete_file(self, file_path: str) -> bool: + """Delete a file from storage + + Args: + file_path: Path relative to base_path + + Returns: + True if file was deleted, False if file didn't exist + """ + full_path = self.base_path / file_path + + if not full_path.exists(): + return False + + try: + os.remove(full_path) + logger.info(f"File deleted: {full_path}") + return True + except Exception as e: + logger.error(f"Failed to delete file {file_path}: {e}") + raise + + def move_file(self, source_path: str, dest_path: str) -> str: + """Move a file within storage + + Args: + source_path: Source path relative to base_path + dest_path: Destination path relative to base_path + + Returns: + New file path + """ + source_full = self.base_path / source_path + dest_full = self.base_path / dest_path + + if not source_full.exists(): + raise FileNotFoundError(f"Source file not found: {source_path}") + + # Create destination directory if needed + dest_full.parent.mkdir(parents=True, exist_ok=True) + + try: + shutil.move(str(source_full), str(dest_full)) + logger.info(f"File moved from {source_path} to {dest_path}") + return str(dest_full.relative_to(self.base_path)) + except Exception as e: + logger.error(f"Failed to move file from {source_path} to {dest_path}: {e}") + raise + + def copy_file(self, source_path: str, dest_path: str) -> str: + """Copy a file within storage + + Args: + source_path: Source path relative to base_path + dest_path: Destination path relative to base_path + + Returns: + New file path + """ + source_full = self.base_path / source_path + dest_full = self.base_path / dest_path + + if not source_full.exists(): + raise FileNotFoundError(f"Source file not found: {source_path}") + + # Create destination directory if needed + dest_full.parent.mkdir(parents=True, exist_ok=True) + + try: + shutil.copy2(str(source_full), str(dest_full)) + logger.info(f"File copied from {source_path} to {dest_path}") + return str(dest_full.relative_to(self.base_path)) + except Exception as e: + logger.error(f"Failed to copy file from {source_path} to {dest_path}: {e}") + raise + + def file_exists(self, file_path: str) -> bool: + """Check if a file exists + + Args: + file_path: Path relative to base_path + + Returns: + True if file exists, False otherwise + """ + full_path = self.base_path / file_path + return full_path.exists() + + def get_file_info(self, file_path: str) -> dict: + """Get information about a file + + Args: + file_path: Path relative to base_path + + Returns: + Dictionary with file information + """ + full_path = self.base_path / file_path + + if not full_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + stat = full_path.stat() + + # Get MIME type + mime_type, _ = mimetypes.guess_type(str(full_path)) + + return { + 'path': file_path, + 'filename': full_path.name, + 'size': stat.st_size, + 'mime_type': mime_type, + 'created_at': datetime.fromtimestamp(stat.st_ctime), + 'modified_at': datetime.fromtimestamp(stat.st_mtime), + 'is_file': full_path.is_file(), + 'is_dir': full_path.is_dir() + } + + def list_files(self, subdirectory: Optional[str] = None, pattern: str = "*") -> list: + """List files in storage + + Args: + subdirectory: Optional subdirectory to list + pattern: Glob pattern for filtering files + + Returns: + List of file paths relative to base_path + """ + if subdirectory: + target_dir = self.base_path / subdirectory + else: + target_dir = self.base_path + + if not target_dir.exists(): + return [] + + files = [] + for file_path in target_dir.glob(pattern): + if file_path.is_file(): + relative_path = file_path.relative_to(self.base_path) + files.append(str(relative_path)) + + return sorted(files) + + def get_directory_size(self, subdirectory: Optional[str] = None) -> int: + """Get total size of files in a directory + + Args: + subdirectory: Optional subdirectory + + Returns: + Total size in bytes + """ + if subdirectory: + target_dir = self.base_path / subdirectory + else: + target_dir = self.base_path + + if not target_dir.exists(): + return 0 + + total_size = 0 + for file_path in target_dir.rglob('*'): + if file_path.is_file(): + total_size += file_path.stat().st_size + + return total_size + + def cleanup_old_files(self, days: int = 30, subdirectory: Optional[str] = None) -> int: + """Delete files older than specified days + + Args: + days: Age threshold in days + subdirectory: Optional subdirectory to clean + + Returns: + Number of files deleted + """ + if subdirectory: + target_dir = self.base_path / subdirectory + else: + target_dir = self.base_path + + if not target_dir.exists(): + return 0 + + cutoff_time = datetime.utcnow().timestamp() - (days * 24 * 60 * 60) + deleted_count = 0 + + for file_path in target_dir.rglob('*'): + if file_path.is_file(): + if file_path.stat().st_mtime < cutoff_time: + try: + os.remove(file_path) + deleted_count += 1 + logger.info(f"Deleted old file: {file_path}") + except Exception as e: + logger.error(f"Failed to delete old file {file_path}: {e}") + + return deleted_count + + def create_temp_file(self, content: bytes, suffix: str = "") -> str: + """Create a temporary file + + Args: + content: File content + suffix: File suffix/extension + + Returns: + Path to temporary file + """ + temp_dir = self.base_path / "temp" + temp_dir.mkdir(parents=True, exist_ok=True) + + temp_filename = f"tmp_{uuid.uuid4().hex}{suffix}" + temp_path = temp_dir / temp_filename + + with open(temp_path, 'wb') as f: + f.write(content) + + return str(temp_path.relative_to(self.base_path)) + + def get_file_hash(self, file_path: str, algorithm: str = 'sha256') -> str: + """Calculate hash of a file + + Args: + file_path: Path relative to base_path + algorithm: Hash algorithm to use + + Returns: + Hex digest of file hash + """ + full_path = self.base_path / file_path + + if not full_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + hash_obj = hashlib.new(algorithm) + + with open(full_path, 'rb') as f: + while chunk := f.read(8192): + hash_obj.update(chunk) + + return hash_obj.hexdigest() diff --git a/backend/src/utils/pdf_utils.py b/backend/src/utils/pdf_utils.py new file mode 100644 index 00000000..2983d001 --- /dev/null +++ b/backend/src/utils/pdf_utils.py @@ -0,0 +1,405 @@ +""" +PDF utilities for template handling and field extraction +""" + +import io +import os +from typing import List, Dict, Any, Optional +from PyPDF2 import PdfReader, PdfWriter +from reportlab.pdfgen import canvas +from reportlab.lib.pagesizes import A4 +from reportlab.lib.utils import ImageReader +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +import fitz # PyMuPDF +from pathlib import Path + + +def extract_pdf_fields(pdf_content: bytes) -> List[str]: + """ + Extract form field names from a PDF template + + Args: + pdf_content: PDF file content as bytes + + Returns: + List of field names found in the PDF + """ + try: + pdf_reader = PdfReader(io.BytesIO(pdf_content)) + + fields = [] + if '/AcroForm' in pdf_reader.trailer['/Root']: + form = pdf_reader.trailer['/Root']['/AcroForm'] + if '/Fields' in form: + for field_ref in form['/Fields']: + field = field_ref.get_object() + if '/T' in field: + field_name = field['/T'] + if isinstance(field_name, bytes): + field_name = field_name.decode('utf-8') + fields.append(str(field_name)) + + return fields + except Exception as e: + raise ValueError(f"Failed to extract PDF fields: {str(e)}") + + +def validate_pdf_template(pdf_content: bytes) -> Dict[str, Any]: + """ + Validate a PDF template and extract metadata + + Args: + pdf_content: PDF file content as bytes + + Returns: + Dictionary with validation results and metadata + """ + try: + pdf_reader = PdfReader(io.BytesIO(pdf_content)) + + result = { + "valid": True, + "page_count": len(pdf_reader.pages), + "has_form": False, + "fields": [], + "metadata": {} + } + + # Extract metadata + if pdf_reader.metadata: + result["metadata"] = { + "title": pdf_reader.metadata.get('/Title', ''), + "author": pdf_reader.metadata.get('/Author', ''), + "subject": pdf_reader.metadata.get('/Subject', ''), + "creator": pdf_reader.metadata.get('/Creator', ''), + } + + # Check for form fields + if '/AcroForm' in pdf_reader.trailer['/Root']: + result["has_form"] = True + result["fields"] = extract_pdf_fields(pdf_content) + + return result + except Exception as e: + return { + "valid": False, + "error": str(e) + } + + +def fill_pdf_template( + template_content: bytes, + field_mapping: Dict[str, str], + field_data: Dict[str, Any], + output_path: Optional[str] = None +) -> bytes: + """ + Fill a PDF template with data + + Args: + template_content: PDF template content as bytes + field_mapping: Mapping from PDF field names to data field IDs + field_data: Data to fill in the fields + output_path: Optional path to save the filled PDF + + Returns: + Filled PDF content as bytes + """ + try: + # Read the template + pdf_reader = PdfReader(io.BytesIO(template_content)) + pdf_writer = PdfWriter() + + # Copy all pages and fill form fields + for page_num in range(len(pdf_reader.pages)): + page = pdf_reader.pages[page_num] + pdf_writer.add_page(page) + + # Fill form fields if they exist + if '/AcroForm' in pdf_reader.trailer['/Root']: + pdf_writer.update_page_form_field_values( + pdf_writer.pages[0], + {pdf_field: str(field_data.get(data_field, '')) + for pdf_field, data_field in field_mapping.items() + if data_field in field_data} + ) + + # Write to bytes or file + output_buffer = io.BytesIO() + pdf_writer.write(output_buffer) + pdf_content = output_buffer.getvalue() + + if output_path: + with open(output_path, 'wb') as f: + f.write(pdf_content) + + return pdf_content + except Exception as e: + raise ValueError(f"Failed to fill PDF template: {str(e)}") + + +def create_pdf_from_data( + data: Dict[str, Any], + title: str = "Application", + output_path: Optional[str] = None +) -> bytes: + """ + Create a PDF document from application data (when no template is available) + + Args: + data: Application data + title: Document title + output_path: Optional path to save the PDF + + Returns: + PDF content as bytes + """ + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=A4) + width, height = A4 + + # Set up fonts + try: + # Try to use a custom font if available + font_path = Path(__file__).parent.parent / "assets" / "fonts" / "Roboto-Regular.ttf" + if font_path.exists(): + pdfmetrics.registerFont(TTFont('Roboto', str(font_path))) + c.setFont('Roboto', 12) + else: + c.setFont('Helvetica', 12) + except: + c.setFont('Helvetica', 12) + + # Title + c.setFont('Helvetica-Bold', 16) + c.drawString(50, height - 50, title) + + # Content + y_position = height - 100 + c.setFont('Helvetica', 10) + + for key, value in data.items(): + if y_position < 100: + c.showPage() + y_position = height - 50 + c.setFont('Helvetica', 10) + + # Format key + display_key = key.replace('_', ' ').title() + + # Handle different value types + if isinstance(value, (list, dict)): + c.setFont('Helvetica-Bold', 10) + c.drawString(50, y_position, f"{display_key}:") + y_position -= 15 + c.setFont('Helvetica', 10) + + if isinstance(value, list): + for item in value: + if y_position < 100: + c.showPage() + y_position = height - 50 + c.drawString(70, y_position, f"• {str(item)}") + y_position -= 15 + else: + for sub_key, sub_value in value.items(): + if y_position < 100: + c.showPage() + y_position = height - 50 + c.drawString(70, y_position, f"{sub_key}: {str(sub_value)}") + y_position -= 15 + else: + # Simple key-value pair + text = f"{display_key}: {str(value)}" + # Handle long text + if len(text) > 80: + lines = [text[i:i+80] for i in range(0, len(text), 80)] + for line in lines: + if y_position < 100: + c.showPage() + y_position = height - 50 + c.drawString(50, y_position, line) + y_position -= 15 + else: + c.drawString(50, y_position, text) + y_position -= 15 + + y_position -= 5 # Extra spacing between fields + + # Footer + c.setFont('Helvetica-Oblique', 8) + c.drawString(50, 30, f"Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + c.save() + pdf_content = buffer.getvalue() + + if output_path: + with open(output_path, 'wb') as f: + f.write(pdf_content) + + return pdf_content + + +def merge_pdfs(pdf_contents: List[bytes], output_path: Optional[str] = None) -> bytes: + """ + Merge multiple PDF documents + + Args: + pdf_contents: List of PDF contents as bytes + output_path: Optional path to save the merged PDF + + Returns: + Merged PDF content as bytes + """ + pdf_writer = PdfWriter() + + for pdf_content in pdf_contents: + pdf_reader = PdfReader(io.BytesIO(pdf_content)) + for page in pdf_reader.pages: + pdf_writer.add_page(page) + + output_buffer = io.BytesIO() + pdf_writer.write(output_buffer) + merged_content = output_buffer.getvalue() + + if output_path: + with open(output_path, 'wb') as f: + f.write(merged_content) + + return merged_content + + +def add_watermark_to_pdf( + pdf_content: bytes, + watermark_text: str, + output_path: Optional[str] = None +) -> bytes: + """ + Add a watermark to a PDF document + + Args: + pdf_content: PDF content as bytes + watermark_text: Text to use as watermark + output_path: Optional path to save the watermarked PDF + + Returns: + Watermarked PDF content as bytes + """ + # Create watermark PDF + watermark_buffer = io.BytesIO() + c = canvas.Canvas(watermark_buffer, pagesize=A4) + width, height = A4 + + c.setFont('Helvetica', 50) + c.setFillAlpha(0.3) + c.saveState() + c.translate(width/2, height/2) + c.rotate(45) + c.drawCentredString(0, 0, watermark_text) + c.restoreState() + c.save() + + # Read original and watermark PDFs + pdf_reader = PdfReader(io.BytesIO(pdf_content)) + watermark_reader = PdfReader(watermark_buffer) + watermark_page = watermark_reader.pages[0] + + # Apply watermark to all pages + pdf_writer = PdfWriter() + for page in pdf_reader.pages: + page.merge_page(watermark_page) + pdf_writer.add_page(page) + + # Write output + output_buffer = io.BytesIO() + pdf_writer.write(output_buffer) + watermarked_content = output_buffer.getvalue() + + if output_path: + with open(output_path, 'wb') as f: + f.write(watermarked_content) + + return watermarked_content + + +def extract_text_from_pdf(pdf_content: bytes) -> str: + """ + Extract text content from a PDF + + Args: + pdf_content: PDF content as bytes + + Returns: + Extracted text as string + """ + try: + pdf_document = fitz.open(stream=pdf_content, filetype="pdf") + text = "" + + for page_num in range(pdf_document.page_count): + page = pdf_document[page_num] + text += page.get_text() + + pdf_document.close() + return text + except Exception as e: + # Fallback to PyPDF2 + try: + pdf_reader = PdfReader(io.BytesIO(pdf_content)) + text = "" + for page in pdf_reader.pages: + text += page.extract_text() + return text + except: + raise ValueError(f"Failed to extract text from PDF: {str(e)}") + + +def get_pdf_info(pdf_content: bytes) -> Dict[str, Any]: + """ + Get information about a PDF document + + Args: + pdf_content: PDF content as bytes + + Returns: + Dictionary with PDF information + """ + try: + pdf_reader = PdfReader(io.BytesIO(pdf_content)) + + info = { + "page_count": len(pdf_reader.pages), + "has_forms": '/AcroForm' in pdf_reader.trailer['/Root'], + "is_encrypted": pdf_reader.is_encrypted, + "metadata": {} + } + + if pdf_reader.metadata: + info["metadata"] = { + "title": pdf_reader.metadata.get('/Title', ''), + "author": pdf_reader.metadata.get('/Author', ''), + "subject": pdf_reader.metadata.get('/Subject', ''), + "creator": pdf_reader.metadata.get('/Creator', ''), + "producer": pdf_reader.metadata.get('/Producer', ''), + "creation_date": str(pdf_reader.metadata.get('/CreationDate', '')), + "modification_date": str(pdf_reader.metadata.get('/ModDate', '')), + } + + # Get page sizes + page_sizes = [] + for page in pdf_reader.pages: + mediabox = page.mediabox + page_sizes.append({ + "width": float(mediabox.width), + "height": float(mediabox.height) + }) + info["page_sizes"] = page_sizes + + # Get form fields if present + if info["has_forms"]: + info["form_fields"] = extract_pdf_fields(pdf_content) + + return info + except Exception as e: + raise ValueError(f"Failed to get PDF info: {str(e)}") diff --git a/backend/src/utils/validators.py b/backend/src/utils/validators.py new file mode 100644 index 00000000..8b0b317f --- /dev/null +++ b/backend/src/utils/validators.py @@ -0,0 +1,463 @@ +""" +Field validation utilities for dynamic applications +""" + +import re +from datetime import datetime +from typing import Any, Optional, Dict +from email_validator import validate_email, EmailNotValidError + +from ..models.application_type import ApplicationField, FieldType + + +def validate_field_value(value: Any, field: ApplicationField) -> bool: + """ + Validate a field value against its definition and rules + + Args: + value: The value to validate + field: The field definition + + Returns: + True if valid + + Raises: + ValueError: If validation fails + """ + # Check if required + if field.is_required and (value is None or value == ""): + raise ValueError(f"Field '{field.name}' is required") + + # If not required and empty, that's okay + if value is None or value == "": + return True + + # Type-specific validation + if field.field_type == FieldType.TEXT_SHORT: + return validate_text_short(value, field) + elif field.field_type == FieldType.TEXT_LONG: + return validate_text_long(value, field) + elif field.field_type == FieldType.OPTIONS: + return validate_options(value, field) + elif field.field_type == FieldType.YESNO: + return validate_yesno(value, field) + elif field.field_type == FieldType.MAIL: + return validate_mail(value, field) + elif field.field_type == FieldType.DATE: + return validate_date(value, field) + elif field.field_type == FieldType.DATETIME: + return validate_datetime(value, field) + elif field.field_type == FieldType.AMOUNT: + return validate_amount(value, field) + elif field.field_type == FieldType.CURRENCY_EUR: + return validate_currency_eur(value, field) + elif field.field_type == FieldType.NUMBER: + return validate_number(value, field) + elif field.field_type == FieldType.PHONE: + return validate_phone(value, field) + elif field.field_type == FieldType.URL: + return validate_url(value, field) + elif field.field_type == FieldType.CHECKBOX: + return validate_checkbox(value, field) + elif field.field_type == FieldType.RADIO: + return validate_radio(value, field) + elif field.field_type == FieldType.SELECT: + return validate_select(value, field) + elif field.field_type == FieldType.MULTISELECT: + return validate_multiselect(value, field) + + return True + + +def validate_text_short(value: Any, field: ApplicationField) -> bool: + """Validate short text field""" + if not isinstance(value, str): + raise ValueError(f"Field '{field.name}' must be a string") + + rules = field.validation_rules or {} + + # Check max length (default 255 for short text) + max_length = rules.get("max_length", 255) + if len(value) > max_length: + raise ValueError(f"Field '{field.name}' exceeds maximum length of {max_length}") + + # Check min length + min_length = rules.get("min_length") + if min_length and len(value) < min_length: + raise ValueError(f"Field '{field.name}' must be at least {min_length} characters") + + # Check pattern + pattern = rules.get("pattern") + if pattern and not re.match(pattern, value): + raise ValueError(f"Field '{field.name}' does not match required pattern") + + return True + + +def validate_text_long(value: Any, field: ApplicationField) -> bool: + """Validate long text field""" + if not isinstance(value, str): + raise ValueError(f"Field '{field.name}' must be a string") + + rules = field.validation_rules or {} + + # Check max length (default 10000 for long text) + max_length = rules.get("max_length", 10000) + if len(value) > max_length: + raise ValueError(f"Field '{field.name}' exceeds maximum length of {max_length}") + + # Check min length + min_length = rules.get("min_length") + if min_length and len(value) < min_length: + raise ValueError(f"Field '{field.name}' must be at least {min_length} characters") + + return True + + +def validate_options(value: Any, field: ApplicationField) -> bool: + """Validate options field""" + if not field.options: + raise ValueError(f"Field '{field.name}' has no options defined") + + if value not in field.options: + raise ValueError(f"Field '{field.name}' value must be one of: {', '.join(field.options)}") + + return True + + +def validate_yesno(value: Any, field: ApplicationField) -> bool: + """Validate yes/no field""" + if not isinstance(value, bool): + # Also accept "true"/"false", "yes"/"no", 1/0 + if isinstance(value, str): + if value.lower() in ["true", "yes", "1"]: + return True + elif value.lower() in ["false", "no", "0"]: + return True + else: + raise ValueError(f"Field '{field.name}' must be yes/no") + elif isinstance(value, int): + if value not in [0, 1]: + raise ValueError(f"Field '{field.name}' must be yes/no") + else: + raise ValueError(f"Field '{field.name}' must be yes/no") + + return True + + +def validate_mail(value: Any, field: ApplicationField) -> bool: + """Validate email field""" + if not isinstance(value, str): + raise ValueError(f"Field '{field.name}' must be a string") + + try: + validate_email(value) + except EmailNotValidError as e: + raise ValueError(f"Field '{field.name}' is not a valid email address: {str(e)}") + + return True + + +def validate_date(value: Any, field: ApplicationField) -> bool: + """Validate date field""" + if not isinstance(value, str): + raise ValueError(f"Field '{field.name}' must be a date string") + + # Accept various date formats + date_formats = [ + "%Y-%m-%d", + "%d.%m.%Y", + "%d/%m/%Y", + "%Y/%m/%d" + ] + + rules = field.validation_rules or {} + custom_format = rules.get("date_format") + if custom_format: + date_formats = [custom_format] + date_formats + + parsed_date = None + for fmt in date_formats: + try: + parsed_date = datetime.strptime(value, fmt) + break + except ValueError: + continue + + if not parsed_date: + raise ValueError(f"Field '{field.name}' is not a valid date") + + # Check min/max dates + min_date = rules.get("min_date") + if min_date: + min_dt = datetime.strptime(min_date, "%Y-%m-%d") + if parsed_date.date() < min_dt.date(): + raise ValueError(f"Field '{field.name}' must be after {min_date}") + + max_date = rules.get("max_date") + if max_date: + max_dt = datetime.strptime(max_date, "%Y-%m-%d") + if parsed_date.date() > max_dt.date(): + raise ValueError(f"Field '{field.name}' must be before {max_date}") + + return True + + +def validate_datetime(value: Any, field: ApplicationField) -> bool: + """Validate datetime field""" + if not isinstance(value, str): + raise ValueError(f"Field '{field.name}' must be a datetime string") + + # Accept ISO format primarily + try: + datetime.fromisoformat(value) + except ValueError: + raise ValueError(f"Field '{field.name}' is not a valid datetime (use ISO format)") + + return True + + +def validate_amount(value: Any, field: ApplicationField) -> bool: + """Validate amount field""" + try: + amount = float(value) + except (TypeError, ValueError): + raise ValueError(f"Field '{field.name}' must be a number") + + rules = field.validation_rules or {} + + # Check min/max + min_amount = rules.get("min", 0) + if amount < min_amount: + raise ValueError(f"Field '{field.name}' must be at least {min_amount}") + + max_amount = rules.get("max") + if max_amount and amount > max_amount: + raise ValueError(f"Field '{field.name}' must not exceed {max_amount}") + + # Check decimal places + decimal_places = rules.get("decimal_places", 2) + if decimal_places is not None: + decimal_str = str(amount).split('.') + if len(decimal_str) > 1 and len(decimal_str[1]) > decimal_places: + raise ValueError(f"Field '{field.name}' must have at most {decimal_places} decimal places") + + return True + + +def validate_currency_eur(value: Any, field: ApplicationField) -> bool: + """Validate EUR currency field""" + # Same as amount but with EUR-specific validation + result = validate_amount(value, field) + + # Additional EUR-specific checks if needed + try: + amount = float(value) + if amount < 0: + raise ValueError(f"Field '{field.name}' cannot be negative") + except (TypeError, ValueError): + pass # Already handled in validate_amount + + return result + + +def validate_number(value: Any, field: ApplicationField) -> bool: + """Validate number field""" + rules = field.validation_rules or {} + integer_only = rules.get("integer_only", False) + + if integer_only: + try: + num = int(value) + except (TypeError, ValueError): + raise ValueError(f"Field '{field.name}' must be an integer") + else: + try: + num = float(value) + except (TypeError, ValueError): + raise ValueError(f"Field '{field.name}' must be a number") + + # Check min/max + min_val = rules.get("min") + if min_val is not None and num < min_val: + raise ValueError(f"Field '{field.name}' must be at least {min_val}") + + max_val = rules.get("max") + if max_val is not None and num > max_val: + raise ValueError(f"Field '{field.name}' must not exceed {max_val}") + + return True + + +def validate_phone(value: Any, field: ApplicationField) -> bool: + """Validate phone number field""" + if not isinstance(value, str): + raise ValueError(f"Field '{field.name}' must be a string") + + # Remove common formatting characters + cleaned = re.sub(r'[\s\-\(\)\.]+', '', value) + + # Check if it looks like a phone number + if not re.match(r'^\+?[0-9]{7,15}$', cleaned): + raise ValueError(f"Field '{field.name}' is not a valid phone number") + + return True + + +def validate_url(value: Any, field: ApplicationField) -> bool: + """Validate URL field""" + if not isinstance(value, str): + raise ValueError(f"Field '{field.name}' must be a string") + + # Basic URL validation + url_pattern = re.compile( + r'^https?://' # http:// or https:// + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain... + r'localhost|' # localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + + if not url_pattern.match(value): + raise ValueError(f"Field '{field.name}' is not a valid URL") + + return True + + +def validate_checkbox(value: Any, field: ApplicationField) -> bool: + """Validate checkbox field""" + return validate_yesno(value, field) + + +def validate_radio(value: Any, field: ApplicationField) -> bool: + """Validate radio field""" + return validate_options(value, field) + + +def validate_select(value: Any, field: ApplicationField) -> bool: + """Validate select field""" + return validate_options(value, field) + + +def validate_multiselect(value: Any, field: ApplicationField) -> bool: + """Validate multiselect field""" + if not isinstance(value, list): + raise ValueError(f"Field '{field.name}' must be a list") + + if not field.options: + raise ValueError(f"Field '{field.name}' has no options defined") + + for item in value: + if item not in field.options: + raise ValueError(f"Field '{field.name}' contains invalid option: {item}") + + rules = field.validation_rules or {} + + # Check min/max selections + min_selections = rules.get("min_selections") + if min_selections and len(value) < min_selections: + raise ValueError(f"Field '{field.name}' requires at least {min_selections} selections") + + max_selections = rules.get("max_selections") + if max_selections and len(value) > max_selections: + raise ValueError(f"Field '{field.name}' allows at most {max_selections} selections") + + return True + + +def validate_display_conditions( + field: ApplicationField, + form_data: Dict[str, Any] +) -> bool: + """ + Check if a field should be displayed based on conditions + + Args: + field: The field to check + form_data: Current form data + + Returns: + True if field should be displayed + """ + if not field.display_conditions: + return True + + conditions = field.display_conditions + + # Support simple conditions like: + # {"field": "other_field", "operator": "equals", "value": "some_value"} + # or {"and": [...], "or": [...]} for complex conditions + + return evaluate_condition(conditions, form_data) + + +def evaluate_condition(condition: Dict[str, Any], form_data: Dict[str, Any]) -> bool: + """ + Evaluate a display condition + + Args: + condition: Condition definition + form_data: Current form data + + Returns: + True if condition is met + """ + if "and" in condition: + # All conditions must be true + return all(evaluate_condition(c, form_data) for c in condition["and"]) + + if "or" in condition: + # At least one condition must be true + return any(evaluate_condition(c, form_data) for c in condition["or"]) + + if "not" in condition: + # Negate the condition + return not evaluate_condition(condition["not"], form_data) + + # Simple condition + if "field" in condition: + field_id = condition["field"] + operator = condition.get("operator", "equals") + expected_value = condition.get("value") + + actual_value = form_data.get(field_id) + + if operator == "equals": + return actual_value == expected_value + elif operator == "not_equals": + return actual_value != expected_value + elif operator == "in": + return actual_value in expected_value + elif operator == "not_in": + return actual_value not in expected_value + elif operator == "contains": + return expected_value in str(actual_value) + elif operator == "not_contains": + return expected_value not in str(actual_value) + elif operator == "empty": + return not actual_value + elif operator == "not_empty": + return bool(actual_value) + elif operator == "greater_than": + try: + return float(actual_value) > float(expected_value) + except (TypeError, ValueError): + return False + elif operator == "less_than": + try: + return float(actual_value) < float(expected_value) + except (TypeError, ValueError): + return False + elif operator == "greater_or_equal": + try: + return float(actual_value) >= float(expected_value) + except (TypeError, ValueError): + return False + elif operator == "less_or_equal": + try: + return float(actual_value) <= float(expected_value) + except (TypeError, ValueError): + return False + + # Unknown condition format, default to true + return True diff --git a/docker-compose.yml b/docker-compose.yml index 9e5e0ff6..5a502603 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: timeout: 5s retries: 6 ports: - - "3306:3306" + - "3307:3306" volumes: - db_data:/var/lib/mysql networks: @@ -188,7 +188,7 @@ services: ADMINER_DEFAULT_SERVER: db ADMINER_DESIGN: pepa-linha-dark ports: - - "8080:8080" + - "8081:8080" networks: - stupa_network diff --git a/frontend/src/api/dynamicClient.ts b/frontend/src/api/dynamicClient.ts new file mode 100644 index 00000000..b7b2fec3 --- /dev/null +++ b/frontend/src/api/dynamicClient.ts @@ -0,0 +1,478 @@ +// API client for dynamic application system + +import axios, { AxiosInstance, AxiosError } from 'axios'; +import { + ApplicationType, + DynamicApplication, + ApplicationListItem, + CreateApplicationRequest, + UpdateApplicationRequest, + CreateApplicationResponse, + StatusTransitionRequest, + ApplicationHistoryEntry, + ApplicationApproval, + ApplicationSearchParams, + ApplicationTypeCreateRequest, + ApplicationTypeUpdateRequest, + PDFTemplateUploadResponse, + ApiResponse, + ApiError, + User, + Session, + LoginRequest, +} from '../types/dynamic'; + +class DynamicApiClient { + private client: AxiosInstance; + private baseURL: string; + private accessToken: string | null = null; + + constructor(baseURL: string = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000') { + this.baseURL = baseURL; + this.client = axios.create({ + baseURL: `${baseURL}/api`, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, + }); + + // Request interceptor to add auth token + this.client.interceptors.request.use( + (config) => { + const token = this.getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) + ); + + // Response interceptor for error handling + this.client.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + if (error.response?.status === 401) { + // Try to refresh token + const refreshed = await this.refreshToken(); + if (refreshed) { + // Retry original request + const originalRequest = error.config; + if (originalRequest) { + return this.client(originalRequest); + } + } else { + // Clear token and redirect to login + this.clearAuth(); + window.location.href = '/login'; + } + } + return Promise.reject(error); + } + ); + } + + // Auth methods + private getAccessToken(): string | null { + if (this.accessToken) { + return this.accessToken; + } + const stored = localStorage.getItem('access_token'); + if (stored) { + this.accessToken = stored; + } + return this.accessToken; + } + + private setAccessToken(token: string): void { + this.accessToken = token; + localStorage.setItem('access_token', token); + } + + private clearAuth(): void { + this.accessToken = null; + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user'); + } + + private async refreshToken(): Promise { + try { + const refreshToken = localStorage.getItem('refresh_token'); + if (!refreshToken) return false; + + const response = await axios.post(`${this.baseURL}/api/auth/refresh`, { + refresh_token: refreshToken, + }); + + if (response.data.access_token) { + this.setAccessToken(response.data.access_token); + if (response.data.refresh_token) { + localStorage.setItem('refresh_token', response.data.refresh_token); + } + return true; + } + return false; + } catch (error) { + return false; + } + } + + // Authentication + async login(request: LoginRequest): Promise> { + try { + const response = await this.client.post('/auth/login', request); + const session = response.data; + + this.setAccessToken(session.access_token); + if (session.refresh_token) { + localStorage.setItem('refresh_token', session.refresh_token); + } + localStorage.setItem('user', JSON.stringify(session.user)); + + return { success: true, data: session }; + } catch (error) { + return this.handleError(error); + } + } + + async logout(): Promise { + try { + await this.client.post('/auth/logout'); + } finally { + this.clearAuth(); + } + } + + async getCurrentUser(): Promise> { + try { + const response = await this.client.get('/auth/me'); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + // Application Types + async getApplicationTypes(includeInactive = false): Promise> { + try { + const response = await this.client.get('/application-types', { + params: { include_inactive: includeInactive }, + }); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async getApplicationType(typeId: string): Promise> { + try { + const response = await this.client.get(`/application-types/${typeId}`); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async createApplicationType( + data: ApplicationTypeCreateRequest, + pdfTemplate?: File + ): Promise> { + try { + const formData = new FormData(); + formData.append('type_data', JSON.stringify(data)); + if (pdfTemplate) { + formData.append('pdf_template', pdfTemplate); + } + + const response = await this.client.post('/application-types', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async updateApplicationType( + typeId: string, + data: ApplicationTypeUpdateRequest + ): Promise> { + try { + const response = await this.client.put( + `/application-types/${typeId}`, + data + ); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async uploadPdfTemplate( + typeId: string, + pdfFile: File + ): Promise> { + try { + const formData = new FormData(); + formData.append('pdf_template', pdfFile); + + const response = await this.client.post( + `/application-types/${typeId}/pdf-template`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async deleteApplicationType(typeId: string): Promise> { + try { + const response = await this.client.delete<{ message: string }>( + `/application-types/${typeId}` + ); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + // Applications + async getApplications(params?: ApplicationSearchParams): Promise> { + try { + const response = await this.client.get('/applications', { params }); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async getApplication( + applicationId: string, + accessKey?: string + ): Promise> { + try { + const params = accessKey ? { access_key: accessKey } : {}; + const response = await this.client.get( + `/applications/${applicationId}`, + { params } + ); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async createApplication( + data: CreateApplicationRequest + ): Promise> { + try { + const response = await this.client.post('/applications', data); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async updateApplication( + applicationId: string, + data: UpdateApplicationRequest, + accessKey?: string + ): Promise> { + try { + const params = accessKey ? { access_key: accessKey } : {}; + const response = await this.client.put( + `/applications/${applicationId}`, + data, + { params } + ); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async submitApplication( + applicationId: string, + accessKey?: string + ): Promise> { + try { + const params = accessKey ? { access_key: accessKey } : {}; + const response = await this.client.post<{ message: string; new_status: string }>( + `/applications/${applicationId}/submit`, + {}, + { params } + ); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async transitionApplicationStatus( + applicationId: string, + data: StatusTransitionRequest + ): Promise> { + try { + const response = await this.client.post<{ + message: string; + new_status: string; + new_status_name: string; + }>(`/applications/${applicationId}/transition`, data); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async approveApplication( + applicationId: string, + approval: ApplicationApproval + ): Promise> { + try { + const response = await this.client.post<{ + message: string; + role: string; + decision: string; + }>(`/applications/${applicationId}/approve`, approval); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async getApplicationHistory( + applicationId: string + ): Promise> { + try { + const response = await this.client.get( + `/applications/${applicationId}/history` + ); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async generateApplicationPdf( + applicationId: string + ): Promise> { + try { + const response = await this.client.post<{ message: string; pdf_path: string }>( + `/applications/${applicationId}/generate-pdf` + ); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async downloadApplicationPdf(applicationId: string): Promise { + const response = await this.client.get(`/applications/${applicationId}/pdf`, { + responseType: 'blob', + }); + return response.data; + } + + // Error handling + private handleError(error: any): ApiResponse { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response?.data) { + return { + success: false, + error: axiosError.response.data, + }; + } + return { + success: false, + error: { + detail: axiosError.message || 'An unknown error occurred', + }, + }; + } + return { + success: false, + error: { + detail: 'An unexpected error occurred', + }, + }; + } + + // Utility methods + async uploadFile(file: File, endpoint: string): Promise> { + try { + const formData = new FormData(); + formData.append('file', file); + + const response = await this.client.post<{ url: string }>(endpoint, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async exportApplications( + applicationIds: string[], + format: 'pdf' | 'json' | 'csv' + ): Promise { + const response = await this.client.post( + '/applications/export', + { application_ids: applicationIds, format }, + { responseType: 'blob' } + ); + return response.data; + } + + // Public access methods (no auth required) + async getPublicApplication( + applicationId: string, + accessKey: string + ): Promise> { + try { + const response = await axios.get( + `${this.baseURL}/api/public/applications/${applicationId}`, + { params: { key: accessKey } } + ); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } + + async updatePublicApplication( + applicationId: string, + accessKey: string, + data: UpdateApplicationRequest + ): Promise> { + try { + const response = await axios.put( + `${this.baseURL}/api/public/applications/${applicationId}`, + data, + { params: { key: accessKey } } + ); + return { success: true, data: response.data }; + } catch (error) { + return this.handleError(error); + } + } +} + +// Export singleton instance +export const dynamicApiClient = new DynamicApiClient(); + +// Export class for testing or multiple instances +export default DynamicApiClient; diff --git a/frontend/src/types/dynamic.ts b/frontend/src/types/dynamic.ts new file mode 100644 index 00000000..a8057571 --- /dev/null +++ b/frontend/src/types/dynamic.ts @@ -0,0 +1,527 @@ +// Dynamic Application System Types + +// Field Types +export type FieldType = + | "text_short" + | "text_long" + | "options" + | "yesno" + | "mail" + | "date" + | "datetime" + | "amount" + | "currency_eur" + | "number" + | "file" + | "signature" + | "phone" + | "url" + | "checkbox" + | "radio" + | "select" + | "multiselect"; + +// Transition Trigger Types +export type TransitionTriggerType = + | "user_approval" + | "applicant_action" + | "deadline_expired" + | "time_elapsed" + | "condition_met" + | "automatic"; + +// Field Definition +export interface FieldDefinition { + field_id: string; + field_type: FieldType; + name: string; + label?: string; + description?: string; + field_order: number; + is_required: boolean; + is_readonly: boolean; + is_hidden: boolean; + options?: string[]; + default_value?: string; + validation_rules?: ValidationRules; + display_conditions?: DisplayCondition; + placeholder?: string; + section?: string; +} + +// Validation Rules +export interface ValidationRules { + min?: number; + max?: number; + min_length?: number; + max_length?: number; + pattern?: string; + date_format?: string; + min_date?: string; + max_date?: string; + decimal_places?: number; + integer_only?: boolean; + min_selections?: number; + max_selections?: number; +} + +// Display Conditions +export interface DisplayCondition { + field?: string; + operator?: ConditionOperator; + value?: any; + and?: DisplayCondition[]; + or?: DisplayCondition[]; + not?: DisplayCondition; +} + +export type ConditionOperator = + | "equals" + | "not_equals" + | "in" + | "not_in" + | "contains" + | "not_contains" + | "empty" + | "not_empty" + | "greater_than" + | "less_than" + | "greater_or_equal" + | "less_or_equal"; + +// Status Definition +export interface StatusDefinition { + status_id: string; + name: string; + description?: string; + is_editable: boolean; + color?: string; + icon?: string; + display_order: number; + is_initial: boolean; + is_final: boolean; + is_cancelled: boolean; + send_notification: boolean; + notification_template?: string; +} + +// Transition Definition +export interface TransitionDefinition { + from_status_id: string; + to_status_id: string; + name: string; + trigger_type: TransitionTriggerType; + trigger_config: TriggerConfig; + conditions?: Record; + actions?: TransitionAction[]; + priority: number; + is_active: boolean; +} + +// Trigger Configuration +export interface TriggerConfig { + role?: string; + required_approvals?: number; + deadline_field?: string; + time_span_hours?: number; + button_label?: string; + button_style?: string; +} + +// Transition Actions +export interface TransitionAction { + type: string; + config: Record; +} + +// Application Type +export interface ApplicationType { + id: number; + type_id: string; + name: string; + description?: string; + is_active: boolean; + is_public: boolean; + allowed_roles: string[]; + max_cost_positions: number; + max_comparison_offers: number; + version: string; + usage_count: number; + pdf_template_filename?: string; + fields: FieldDefinition[]; + statuses: StatusDefinition[]; + transitions: TransitionDefinition[]; + created_at: string; + updated_at: string; +} + +// Cost Position +export interface CostPosition { + description: string; + amount: number; + category?: string; + notes?: string; +} + +// Comparison Offer +export interface ComparisonOffer { + vendor: string; + description: string; + amount: number; + selected: boolean; + notes?: string; +} + +// Dynamic Application +export interface DynamicApplication { + id: number; + application_id: string; + application_type_id: number; + type_name: string; + email: string; + status_id: string; + status_name: string; + title: string; + first_name?: string; + last_name?: string; + total_amount: number; + field_data: Record; + cost_positions: CostPosition[]; + comparison_offers: ComparisonOffer[]; + submitted_at?: string; + status_changed_at?: string; + created_at: string; + updated_at: string; + can_edit: boolean; + available_actions: string[]; +} + +// Application List Item +export interface ApplicationListItem { + id: number; + application_id: string; + type_name: string; + title: string; + email: string; + status_id: string; + status_name: string; + total_amount: number; + submitted_at?: string; + created_at: string; +} + +// Application History Entry +export interface ApplicationHistoryEntry { + id: number; + action: string; + comment?: string; + field_changes?: Record; + user_id?: number; + created_at: string; +} + +// Application Approval +export interface ApplicationApproval { + decision: "approve" | "reject" | "abstain"; + comment?: string; +} + +// Create Application Request +export interface CreateApplicationRequest { + application_type_id: string; + title: string; + field_data: Record; + cost_positions?: CostPosition[]; + comparison_offers?: ComparisonOffer[]; +} + +// Update Application Request +export interface UpdateApplicationRequest { + title?: string; + field_data?: Record; + cost_positions?: CostPosition[]; + comparison_offers?: ComparisonOffer[]; +} + +// Create Application Response +export interface CreateApplicationResponse { + application_id: string; + access_key: string; + access_url: string; + status: string; +} + +// Status Transition Request +export interface StatusTransitionRequest { + new_status_id: string; + comment?: string; + trigger_data?: Record; +} + +// Application Type Create Request +export interface ApplicationTypeCreateRequest { + type_id: string; + name: string; + description?: string; + fields: FieldDefinition[]; + statuses: StatusDefinition[]; + transitions: TransitionDefinition[]; + pdf_field_mapping?: Record; + is_active?: boolean; + is_public?: boolean; + allowed_roles?: string[]; + max_cost_positions?: number; + max_comparison_offers?: number; +} + +// Application Type Update Request +export interface ApplicationTypeUpdateRequest { + name?: string; + description?: string; + is_active?: boolean; + is_public?: boolean; + allowed_roles?: string[]; + max_cost_positions?: number; + max_comparison_offers?: number; +} + +// Search Parameters +export interface ApplicationSearchParams { + type_id?: string; + status_id?: string; + email?: string; + search?: string; + submitted_after?: string; + submitted_before?: string; + limit?: number; + offset?: number; +} + +// Form State for Dynamic Applications +export interface DynamicFormData { + // Common fields + email: string; + title: string; + first_name?: string; + last_name?: string; + + // Dynamic fields + fields: Record; + + // Cost positions + cost_positions: CostPosition[]; + + // Comparison offers + comparison_offers: ComparisonOffer[]; +} + +// Field Render Configuration +export interface FieldRenderConfig { + field: FieldDefinition; + value: any; + onChange: (value: any) => void; + error?: string; + disabled?: boolean; + visible?: boolean; +} + +// Application Type List Response +export interface ApplicationTypeListResponse { + types: ApplicationType[]; + total: number; +} + +// PDF Template Upload Response +export interface PDFTemplateUploadResponse { + message: string; + filename: string; + fields: string[]; +} + +// API Error Response +export interface ApiError { + detail: string; + field?: string; + code?: string; +} + +// API Response Wrapper +export type ApiResponse = + | { success: true; data: T } + | { success: false; error: ApiError }; + +// User Role +export interface UserRole { + id: number; + name: string; + display_name: string; + description?: string; + is_admin: boolean; + can_review_budget: boolean; + can_review_finance: boolean; + can_vote: boolean; + permissions: string[]; +} + +// User +export interface User { + id: number; + email: string; + given_name?: string; + family_name?: string; + display_name: string; + picture_url?: string; + verification_status: string; + email_verified: boolean; + roles: UserRole[]; + last_login_at?: string; + created_at: string; + updated_at: string; +} + +// Session +export interface Session { + access_token: string; + refresh_token?: string; + expires_at: string; + user: User; +} + +// Login Request +export interface LoginRequest { + email: string; + password?: string; + oidc_token?: string; +} + +// Field Value Formatter +export type FieldValueFormatter = (value: any, field: FieldDefinition) => string; + +// Field Value Validator +export type FieldValueValidator = (value: any, field: FieldDefinition) => string | undefined; + +// Application State +export interface ApplicationState { + currentApplication?: DynamicApplication; + applicationTypes: ApplicationType[]; + selectedType?: ApplicationType; + formData: DynamicFormData; + validation: Record; + isDirty: boolean; + isSubmitting: boolean; + accessKey?: string; +} + +// Notification +export interface Notification { + id: string; + type: "success" | "error" | "warning" | "info"; + title: string; + message?: string; + duration?: number; + timestamp: Date; +} + +// Field Component Props +export interface FieldComponentProps { + field: FieldDefinition; + value: any; + onChange: (value: any) => void; + error?: string; + disabled?: boolean; + formData?: Record; +} + +// Status Badge Props +export interface StatusBadgeProps { + status: StatusDefinition; + size?: "small" | "medium" | "large"; +} + +// Application Card Props +export interface ApplicationCardProps { + application: ApplicationListItem; + onClick?: (id: string) => void; + onStatusChange?: (id: string, newStatus: string) => void; +} + +// Field Group +export interface FieldGroup { + section: string; + title?: string; + description?: string; + fields: FieldDefinition[]; + collapsed?: boolean; +} + +// Export Configuration +export interface ExportConfig { + format: "pdf" | "json" | "csv"; + include_attachments?: boolean; + include_history?: boolean; + watermark?: string; +} + +// Import Configuration +export interface ImportConfig { + format: "json" | "csv"; + mapping?: Record; + validate?: boolean; + dry_run?: boolean; +} + +// Batch Operation +export interface BatchOperation { + operation: "export" | "status_change" | "delete"; + application_ids: string[]; + params?: Record; +} + +// Dashboard Statistics +export interface DashboardStats { + total_applications: number; + pending_applications: number; + approved_applications: number; + rejected_applications: number; + total_amount_requested: number; + total_amount_approved: number; + applications_by_type: Record; + applications_by_status: Record; + recent_applications: ApplicationListItem[]; +} + +// Activity Log +export interface ActivityLogEntry { + id: number; + user_id?: number; + user_name?: string; + action: string; + entity_type: string; + entity_id: string; + changes?: Record; + ip_address?: string; + user_agent?: string; + timestamp: string; +} + +// Help Text +export interface HelpText { + field_id: string; + title: string; + content: string; + examples?: string[]; + links?: { label: string; url: string }[]; +} + +// Application Template +export interface ApplicationTemplate { + id: number; + name: string; + description?: string; + type_id: string; + field_defaults: Record; + is_public: boolean; + created_by?: number; + created_at: string; + updated_at: string; +}