#!/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()