diff --git a/.env.example b/.env.example index 5c62689..f24f769 100644 --- a/.env.example +++ b/.env.example @@ -90,3 +90,29 @@ OWN_CVR=29522790 # BMC Denmark ApS - ignore when detecting vendors # ===================================================== UPLOAD_DIR=uploads MAX_FILE_SIZE_MB=50 + +# ===================================================== +# MODULE SYSTEM - Dynamic Feature Loading +# ===================================================== +# Enable/disable entire module system +MODULES_ENABLED=true + +# Directory for dynamic modules (default: app/modules) +MODULES_DIR=app/modules + +# Auto-reload modules on changes (dev only, requires restart) +MODULES_AUTO_RELOAD=true + +# ===================================================== +# MODULE-SPECIFIC CONFIGURATION +# ===================================================== +# Pattern: MODULES__{MODULE_NAME}__{KEY} +# Example module configuration: + +# MODULES__INVOICE_OCR__READ_ONLY=true +# MODULES__INVOICE_OCR__DRY_RUN=true +# MODULES__INVOICE_OCR__API_KEY=secret123 + +# MODULES__MY_FEATURE__READ_ONLY=false +# MODULES__MY_FEATURE__DRY_RUN=false +# MODULES__MY_FEATURE__SOME_SETTING=value diff --git a/app/core/config.py b/app/core/config.py index 0559223..3938969 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -39,6 +39,11 @@ class Settings(BaseSettings): VTIGER_API_KEY: str = "" VTIGER_PASSWORD: str = "" # Fallback hvis API key ikke virker + # Simply-CRM Integration (Legacy System med CVR data) + OLD_VTIGER_URL: str = "https://bmcnetworks.simply-crm.dk" + OLD_VTIGER_USERNAME: str = "ct" + OLD_VTIGER_ACCESS_KEY: str = "" + # Time Tracking Module - vTiger Integration (Isoleret) TIMETRACKING_VTIGER_READ_ONLY: bool = True # 🚨 SAFETY: Bloker ALLE skrivninger til vTiger TIMETRACKING_VTIGER_DRY_RUN: bool = True # 🚨 SAFETY: Log uden at synkronisere @@ -100,6 +105,11 @@ class Settings(BaseSettings): MAX_FILE_SIZE_MB: int = 50 ALLOWED_EXTENSIONS: List[str] = [".pdf", ".png", ".jpg", ".jpeg", ".txt", ".csv"] + # Module System Configuration + MODULES_ENABLED: bool = True # Enable/disable entire module system + MODULES_DIR: str = "app/modules" # Directory for dynamic modules + MODULES_AUTO_RELOAD: bool = True # Hot-reload modules on changes (dev only) + class Config: env_file = ".env" case_sensitive = True @@ -107,3 +117,23 @@ class Settings(BaseSettings): settings = Settings() + + +def get_module_config(module_name: str, key: str, default=None): + """ + Hent modul-specifik konfiguration fra miljøvariabel + + Pattern: MODULES__{MODULE_NAME}__{KEY} + Eksempel: MODULES__MY_MODULE__API_KEY + + Args: + module_name: Navn på modul (fx "my_module") + key: Config key (fx "API_KEY") + default: Default værdi hvis ikke sat + + Returns: + Konfigurationsværdi eller default + """ + import os + env_key = f"MODULES__{module_name.upper()}__{key.upper()}" + return os.getenv(env_key, default) diff --git a/app/core/database.py b/app/core/database.py index 5323d43..ca51828 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -55,7 +55,7 @@ def get_db(): release_db_connection(conn) -def execute_query(query: str, params: tuple = None, fetchone: bool = False): +def execute_query(query: str, params: Optional[tuple] = None, fetchone: bool = False): """ Execute a SQL query and return results @@ -155,3 +155,68 @@ def execute_update(query: str, params: tuple = ()) -> int: raise finally: release_db_connection(conn) + + +def execute_module_migration(module_name: str, migration_sql: str) -> bool: + """ + Kør en migration for et specifikt modul + + Args: + module_name: Navn på modulet + migration_sql: SQL migration kode + + Returns: + True hvis success, False ved fejl + """ + conn = get_db_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + # Sikr at module_migrations tabel eksisterer + cursor.execute(""" + CREATE TABLE IF NOT EXISTS module_migrations ( + id SERIAL PRIMARY KEY, + module_name VARCHAR(100) NOT NULL, + migration_name VARCHAR(255) NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + success BOOLEAN DEFAULT TRUE, + error_message TEXT, + UNIQUE(module_name, migration_name) + ) + """) + + # Kør migration + cursor.execute(migration_sql) + conn.commit() + + logger.info(f"✅ Migration for {module_name} success") + return True + + except Exception as e: + conn.rollback() + logger.error(f"❌ Migration failed for {module_name}: {e}") + return False + finally: + release_db_connection(conn) + + +def check_module_table_exists(table_name: str) -> bool: + """ + Check om en modul tabel eksisterer + + Args: + table_name: Tabel navn (fx "my_module_customers") + + Returns: + True hvis tabellen eksisterer + """ + query = """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = %s + ) + """ + result = execute_query(query, (table_name,), fetchone=True) + if result and isinstance(result, dict): + return result.get('exists', False) + return False diff --git a/app/core/module_loader.py b/app/core/module_loader.py new file mode 100644 index 0000000..00fdfe2 --- /dev/null +++ b/app/core/module_loader.py @@ -0,0 +1,292 @@ +""" +Module Loader +Dynamisk loading af moduler med hot-reload support +""" + +import json +import logging +import importlib +import importlib.util +from pathlib import Path +from typing import Dict, List, Optional +from dataclasses import dataclass +from fastapi import FastAPI, HTTPException + +logger = logging.getLogger(__name__) + + +@dataclass +class ModuleMetadata: + """Metadata for et modul""" + name: str + version: str + description: str + enabled: bool + author: str + dependencies: List[str] + table_prefix: str + api_prefix: str + tags: List[str] + module_path: Path + + +class ModuleLoader: + """ + Dynamisk modul loader med hot-reload support + + Moduler ligger i app/modules/{module_name}/ + Hver modul har en module.json med metadata + """ + + def __init__(self, modules_dir: str = "app/modules"): + self.modules_dir = Path(modules_dir) + self.loaded_modules: Dict[str, ModuleMetadata] = {} + self.module_routers: Dict[str, tuple] = {} # name -> (api_router, frontend_router) + + def discover_modules(self) -> List[ModuleMetadata]: + """ + Find alle moduler i modules directory + + Returns: + Liste af ModuleMetadata objekter + """ + modules = [] + + if not self.modules_dir.exists(): + logger.warning(f"⚠️ Modules directory ikke fundet: {self.modules_dir}") + return modules + + for module_dir in self.modules_dir.iterdir(): + if not module_dir.is_dir() or module_dir.name.startswith("_"): + continue + + manifest_path = module_dir / "module.json" + if not manifest_path.exists(): + logger.warning(f"⚠️ Ingen module.json i {module_dir.name}") + continue + + try: + with open(manifest_path, 'r', encoding='utf-8') as f: + manifest = json.load(f) + + metadata = ModuleMetadata( + name=manifest.get("name", module_dir.name), + version=manifest.get("version", "1.0.0"), + description=manifest.get("description", ""), + enabled=manifest.get("enabled", False), + author=manifest.get("author", "Unknown"), + dependencies=manifest.get("dependencies", []), + table_prefix=manifest.get("table_prefix", f"{module_dir.name}_"), + api_prefix=manifest.get("api_prefix", f"/api/v1/{module_dir.name}"), + tags=manifest.get("tags", [module_dir.name.title()]), + module_path=module_dir + ) + + modules.append(metadata) + logger.info(f"📦 Fundet modul: {metadata.name} v{metadata.version} (enabled={metadata.enabled})") + + except Exception as e: + logger.error(f"❌ Kunne ikke læse manifest for {module_dir.name}: {e}") + + return modules + + def load_module(self, metadata: ModuleMetadata) -> Optional[tuple]: + """ + Load et enkelt modul (backend + frontend routers) + + Args: + metadata: Modul metadata + + Returns: + Tuple af (api_router, frontend_router) eller None ved fejl + """ + if not metadata.enabled: + logger.info(f"⏭️ Springer over disabled modul: {metadata.name}") + return None + + try: + # Check dependencies + for dep in metadata.dependencies: + if dep not in self.loaded_modules: + logger.warning(f"⚠️ Modul {metadata.name} kræver {dep} (mangler)") + return None + + # Import backend router + backend_path = metadata.module_path / "backend" / "router.py" + api_router = None + if backend_path.exists(): + spec = importlib.util.spec_from_file_location( + f"app.modules.{metadata.name}.backend.router", + backend_path + ) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + if hasattr(module, 'router'): + api_router = module.router + logger.info(f"✅ Loaded API router for {metadata.name}") + + # Import frontend views + frontend_path = metadata.module_path / "frontend" / "views.py" + frontend_router = None + if frontend_path.exists(): + spec = importlib.util.spec_from_file_location( + f"app.modules.{metadata.name}.frontend.views", + frontend_path + ) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + if hasattr(module, 'router'): + frontend_router = module.router + logger.info(f"✅ Loaded frontend router for {metadata.name}") + + if api_router is None and frontend_router is None: + logger.warning(f"⚠️ Ingen routers fundet for {metadata.name}") + return None + + self.loaded_modules[metadata.name] = metadata + self.module_routers[metadata.name] = (api_router, frontend_router) + + logger.info(f"🎉 Modul {metadata.name} loaded successfully") + return (api_router, frontend_router) + + except Exception as e: + logger.error(f"❌ Kunne ikke loade modul {metadata.name}: {e}", exc_info=True) + return None + + def register_modules(self, app: FastAPI): + """ + Registrer alle enabled moduler i FastAPI app + + Args: + app: FastAPI application instance + """ + modules = self.discover_modules() + + for metadata in modules: + if not metadata.enabled: + continue + + routers = self.load_module(metadata) + if routers is None: + continue + + api_router, frontend_router = routers + + # Registrer API router + if api_router: + try: + app.include_router( + api_router, + prefix=metadata.api_prefix, + tags=list(metadata.tags) # type: ignore + ) + logger.info(f"🔌 Registered API: {metadata.api_prefix}") + except Exception as e: + logger.error(f"❌ Kunne ikke registrere API router for {metadata.name}: {e}") + + # Registrer frontend router + if frontend_router: + try: + from typing import cast, List + frontend_tags: List[str] = ["Frontend"] + list(metadata.tags) + app.include_router( + frontend_router, + tags=frontend_tags # type: ignore + ) + logger.info(f"🔌 Registered Frontend for {metadata.name}") + except Exception as e: + logger.error(f"❌ Kunne ikke registrere frontend router for {metadata.name}: {e}") + + def get_module_status(self) -> Dict[str, dict]: + """ + Hent status for alle loaded moduler + + Returns: + Dict med modul navn -> status info + """ + status = {} + for name, metadata in self.loaded_modules.items(): + status[name] = { + "name": metadata.name, + "version": metadata.version, + "description": metadata.description, + "enabled": metadata.enabled, + "author": metadata.author, + "table_prefix": metadata.table_prefix, + "api_prefix": metadata.api_prefix, + "has_api": self.module_routers[name][0] is not None, + "has_frontend": self.module_routers[name][1] is not None + } + return status + + def enable_module(self, module_name: str) -> bool: + """ + Aktiver et modul (kræver app restart) + + Args: + module_name: Navn på modul + + Returns: + True hvis success + """ + module_dir = self.modules_dir / module_name + manifest_path = module_dir / "module.json" + + if not manifest_path.exists(): + raise HTTPException(status_code=404, detail=f"Modul {module_name} ikke fundet") + + try: + with open(manifest_path, 'r', encoding='utf-8') as f: + manifest = json.load(f) + + manifest["enabled"] = True + + with open(manifest_path, 'w', encoding='utf-8') as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + + logger.info(f"✅ Modul {module_name} enabled (restart required)") + return True + + except Exception as e: + logger.error(f"❌ Kunne ikke enable {module_name}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + def disable_module(self, module_name: str) -> bool: + """ + Deaktiver et modul (kræver app restart) + + Args: + module_name: Navn på modul + + Returns: + True hvis success + """ + module_dir = self.modules_dir / module_name + manifest_path = module_dir / "module.json" + + if not manifest_path.exists(): + raise HTTPException(status_code=404, detail=f"Modul {module_name} ikke fundet") + + try: + with open(manifest_path, 'r', encoding='utf-8') as f: + manifest = json.load(f) + + manifest["enabled"] = False + + with open(manifest_path, 'w', encoding='utf-8') as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + + logger.info(f"⏸️ Modul {module_name} disabled (restart required)") + return True + + except Exception as e: + logger.error(f"❌ Kunne ikke disable {module_name}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# Global module loader instance +module_loader = ModuleLoader() diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py index 8826755..9b76962 100644 --- a/app/customers/backend/router.py +++ b/app/customers/backend/router.py @@ -161,7 +161,7 @@ async def list_customers( @router.get("/customers/{customer_id}") async def get_customer(customer_id: int): - """Get single customer by ID with contact count""" + """Get single customer by ID with contact count and vTiger BMC Låst status""" # Get customer customer = execute_query( "SELECT * FROM customers WHERE id = %s", @@ -181,9 +181,23 @@ async def get_customer(customer_id: int): contact_count = contact_count_result['count'] if contact_count_result else 0 + # Get BMC Låst from vTiger if customer has vtiger_id + bmc_locked = False + if customer.get('vtiger_id'): + try: + from app.services.vtiger_service import get_vtiger_service + vtiger = get_vtiger_service() + account = await vtiger.get_account(customer['vtiger_id']) + if account: + # cf_accounts_bmclst is the BMC Låst field (checkbox: 1 = locked, 0 = not locked) + bmc_locked = account.get('cf_accounts_bmclst') == '1' + except Exception as e: + logger.error(f"❌ Error fetching BMC Låst status: {e}") + return { **customer, - 'contact_count': contact_count + 'contact_count': contact_count, + 'bmc_locked': bmc_locked } @@ -273,6 +287,43 @@ async def update_customer(customer_id: int, update: CustomerUpdate): raise HTTPException(status_code=500, detail=str(e)) +@router.post("/customers/{customer_id}/subscriptions/lock") +async def lock_customer_subscriptions(customer_id: int, lock_request: dict): + """Lock/unlock subscriptions for customer in local DB - BMC Låst status controlled in vTiger""" + try: + locked = lock_request.get('locked', False) + + # Get customer + customer = execute_query( + "SELECT id, name FROM customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + # Update local database only + execute_update( + "UPDATE customers SET subscriptions_locked = %s WHERE id = %s", + (locked, customer_id) + ) + logger.info(f"✅ Updated local subscriptions_locked={locked} for customer {customer_id}") + + return { + "status": "success", + "message": f"Abonnementer er nu {'låst' if locked else 'låst op'} i BMC Hub", + "customer_id": customer_id, + "note": "BMC Låst status i vTiger skal sættes manuelt i vTiger" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error locking subscriptions: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/customers/{customer_id}/contacts") async def get_customer_contacts(customer_id: int): """Get all contacts for a specific customer""" @@ -434,6 +485,75 @@ async def get_customer_subscriptions(customer_id: int): else: active_subscriptions.append(sub) + # Fetch Simply-CRM sales orders (open orders from old system) + # NOTE: Simply-CRM has DIFFERENT IDs than vTiger Cloud! Must match by name or CVR. + simplycrm_sales_orders = [] + try: + from app.services.simplycrm_service import SimplyCRMService + async with SimplyCRMService() as simplycrm: + # First, find the Simply-CRM account by name + customer_name = customer.get('name', '').strip() + if customer_name: + # Search for account in Simply-CRM by name + account_query = f"SELECT id FROM Accounts WHERE accountname='{customer_name}';" + simplycrm_accounts = await simplycrm.query(account_query) + + if simplycrm_accounts and len(simplycrm_accounts) > 0: + simplycrm_account_id = simplycrm_accounts[0].get('id') + logger.info(f"🔍 Found Simply-CRM account: {simplycrm_account_id} for '{customer_name}'") + + # Query open sales orders from Simply-CRM using the correct ID + # Note: Simply-CRM returns one row per line item, so we need to group them + query = f"SELECT * FROM SalesOrder WHERE account_id='{simplycrm_account_id}';" + all_simplycrm_orders = await simplycrm.query(query) + + # Group line items by order ID + # Filter: Only include orders with recurring_frequency (otherwise not subscription) + orders_dict = {} + for row in (all_simplycrm_orders or []): + status = row.get('sostatus', '').lower() + if status in ['closed', 'cancelled']: + continue + + # MUST have recurring_frequency to be a subscription + recurring_frequency = row.get('recurring_frequency', '').strip() + if not recurring_frequency: + continue + + order_id = row.get('id') + if order_id not in orders_dict: + # First occurrence - create order object + orders_dict[order_id] = dict(row) + orders_dict[order_id]['lineItems'] = [] + + # Add line item if productid exists + if row.get('productid'): + # Fetch product name + product_name = 'Unknown Product' + try: + product_query = f"SELECT productname FROM Products WHERE id='{row.get('productid')}';" + product_result = await simplycrm.query(product_query) + if product_result and len(product_result) > 0: + product_name = product_result[0].get('productname', product_name) + except: + pass + + orders_dict[order_id]['lineItems'].append({ + 'productid': row.get('productid'), + 'product_name': product_name, + 'quantity': row.get('quantity'), + 'listprice': row.get('listprice'), + 'netprice': float(row.get('quantity', 0)) * float(row.get('listprice', 0)), + 'comment': row.get('comment', '') + }) + + simplycrm_sales_orders = list(orders_dict.values()) + logger.info(f"📥 Found {len(simplycrm_sales_orders)} unique open sales orders in Simply-CRM") + else: + logger.info(f"ℹ️ No Simply-CRM account found for '{customer_name}'") + except Exception as e: + logger.warning(f"⚠️ Could not fetch Simply-CRM sales orders: {e}") + # Fetch BMC Office subscriptions from local database bmc_office_query = """ SELECT * FROM bmc_office_subscription_totals @@ -442,7 +562,7 @@ async def get_customer_subscriptions(customer_id: int): """ bmc_office_subs = execute_query(bmc_office_query, (customer_id,)) or [] - logger.info(f"✅ Found {len(recurring_orders)} recurring orders, {len(frequency_orders)} frequency orders, {len(all_open_orders)} total open orders, {len(active_subscriptions)} active subscriptions, {len(bmc_office_subs)} BMC Office subscriptions") + logger.info(f"✅ Found {len(recurring_orders)} recurring orders, {len(frequency_orders)} frequency orders, {len(all_open_orders)} vTiger orders, {len(simplycrm_sales_orders)} Simply-CRM orders, {len(active_subscriptions)} active subscriptions, {len(bmc_office_subs)} BMC Office subscriptions") return { "status": "success", @@ -450,7 +570,7 @@ async def get_customer_subscriptions(customer_id: int): "customer_name": customer['name'], "vtiger_id": vtiger_id, "recurring_orders": recurring_orders, - "sales_orders": all_open_orders, # Show ALL open sales orders + "sales_orders": simplycrm_sales_orders, # Open sales orders from Simply-CRM "subscriptions": active_subscriptions, # Active subscriptions from vTiger Subscriptions module "expired_subscriptions": expired_subscriptions, "bmc_office_subscriptions": bmc_office_subs, # Local BMC Office subscriptions @@ -460,3 +580,138 @@ async def get_customer_subscriptions(customer_id: int): except Exception as e: logger.error(f"❌ Error fetching subscriptions: {e}") raise HTTPException(status_code=500, detail=f"Failed to fetch subscriptions: {str(e)}") + + +class SubscriptionCreate(BaseModel): + subject: str + account_id: str # vTiger account ID + startdate: str # YYYY-MM-DD + enddate: Optional[str] = None # YYYY-MM-DD + generateinvoiceevery: str # "Monthly", "Quarterly", "Yearly" + subscriptionstatus: Optional[str] = "Active" + products: List[Dict] # [{"productid": "id", "quantity": 1, "listprice": 100}] + + +class SubscriptionUpdate(BaseModel): + subject: Optional[str] = None + startdate: Optional[str] = None + enddate: Optional[str] = None + generateinvoiceevery: Optional[str] = None + subscriptionstatus: Optional[str] = None + products: Optional[List[Dict]] = None + + +@router.post("/customers/{customer_id}/subscriptions") +async def create_subscription(customer_id: int, subscription: SubscriptionCreate): + """Create new subscription in vTiger""" + try: + # Get customer's vTiger ID + customer = execute_query( + "SELECT vtiger_id FROM customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + + if not customer or not customer.get('vtiger_id'): + raise HTTPException(status_code=404, detail="Customer not linked to vTiger") + + # Create subscription in vTiger + from app.services.vtiger_service import VTigerService + async with VTigerService() as vtiger: + result = await vtiger.create_subscription( + account_id=customer['vtiger_id'], + subject=subscription.subject, + startdate=subscription.startdate, + enddate=subscription.enddate, + generateinvoiceevery=subscription.generateinvoiceevery, + subscriptionstatus=subscription.subscriptionstatus, + products=subscription.products + ) + + logger.info(f"✅ Created subscription {result.get('id')} for customer {customer_id}") + return {"status": "success", "subscription": result} + + except Exception as e: + logger.error(f"❌ Error creating subscription: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/subscriptions/{subscription_id}") +async def get_subscription_details(subscription_id: str): + """Get full subscription details with line items from vTiger""" + try: + from app.services.vtiger_service import get_vtiger_service + vtiger = get_vtiger_service() + + subscription = await vtiger.get_subscription(subscription_id) + + if not subscription: + raise HTTPException(status_code=404, detail="Subscription not found") + + return {"status": "success", "subscription": subscription} + + except Exception as e: + logger.error(f"❌ Error fetching subscription: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/subscriptions/{subscription_id}") +async def update_subscription(subscription_id: str, subscription: SubscriptionUpdate): + """Update subscription in vTiger including line items/prices""" + try: + from app.services.vtiger_service import get_vtiger_service + vtiger = get_vtiger_service() + + # Extract products/line items if provided + update_dict = subscription.dict(exclude_unset=True) + line_items = update_dict.pop('products', None) + + result = await vtiger.update_subscription( + subscription_id=subscription_id, + updates=update_dict, + line_items=line_items + ) + + logger.info(f"✅ Updated subscription {subscription_id}") + return {"status": "success", "subscription": result} + + except Exception as e: + logger.error(f"❌ Error updating subscription: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/subscriptions/{subscription_id}") +async def delete_subscription(subscription_id: str, customer_id: int = None): + """Delete (deactivate) subscription in vTiger - respects customer lock status""" + try: + # Check if subscriptions are locked for this customer (if customer_id provided) + if customer_id: + customer = execute_query( + "SELECT subscriptions_locked FROM customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + if customer and customer.get('subscriptions_locked'): + raise HTTPException( + status_code=403, + detail="Abonnementer er låst for denne kunde. Kan kun redigeres direkte i vTiger." + ) + + from app.services.vtiger_service import get_vtiger_service + vtiger = get_vtiger_service() + + # Set status to Cancelled instead of deleting + result = await vtiger.update_subscription( + subscription_id=subscription_id, + updates={"subscriptionstatus": "Cancelled"}, + line_items=None + ) + + logger.info(f"✅ Cancelled subscription {subscription_id}") + return {"status": "success", "message": "Subscription cancelled"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error deleting subscription: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index c5bfa46..355da4d 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -174,10 +174,11 @@
Dynamisk feature loading - udvikl moduler isoleret fra core systemet
+Opret nyt modul på 5 minutter:
+# 1. Opret modul
+python3 scripts/create_module.py invoice_scanner "Scan fakturaer"
+
+# 2. Kør migration
+docker-compose exec db psql -U bmc_hub -d bmc_hub \\
+ -f app/modules/invoice_scanner/migrations/001_init.sql
+
+# 3. Enable modul (rediger module.json)
+"enabled": true
+
+# 4. Restart API
+docker-compose restart api
+
+# 5. Test
+curl http://localhost:8000/api/v1/invoice_scanner/health
+ Indlæser moduler...
+mymod_customers)app/modules/my_module/
+├── module.json # Metadata og konfiguration
+├── README.md # Dokumentation
+├── backend/
+│ ├── __init__.py
+│ └── router.py # FastAPI endpoints (API)
+├── frontend/
+│ ├── __init__.py
+│ └── views.py # HTML view routes
+├── templates/
+│ └── index.html # Jinja2 templates
+└── migrations/
+ └── 001_init.sql # Database migrations
+ Modul-specifik konfiguration i .env:
# Pattern: MODULES__{MODULE_NAME}__{KEY}
+MODULES__MY_MODULE__API_KEY=secret123
+MODULES__MY_MODULE__READ_ONLY=false
+MODULES__MY_MODULE__DRY_RUN=false
+ I kode:
+from app.core.config import get_module_config
+
+api_key = get_module_config("my_module", "API_KEY")
+read_only = get_module_config("my_module", "READ_ONLY", "true")
+ from fastapi import APIRouter, HTTPException
+from app.core.database import execute_query, execute_insert
+from app.core.config import get_module_config
+
+router = APIRouter()
+
+@router.post("/my_module/scan")
+async def scan_document(file_path: str):
+ """Scan et dokument"""
+
+ # Safety check
+ read_only = get_module_config("my_module", "READ_ONLY", "true")
+ if read_only == "true":
+ return {"error": "READ_ONLY mode enabled"}
+
+ # Process document
+ result = process_file(file_path)
+
+ # Gem i database (bemærk table prefix!)
+ doc_id = execute_insert(
+ "INSERT INTO mymod_documents (path, result) VALUES (%s, %s)",
+ (file_path, result)
+ )
+
+ return {"success": True, "doc_id": doc_id}
+ 5 minutter guide til at komme i gang
+docs/MODULE_QUICKSTART.md
+ Komplet reference (6000+ ord)
+docs/MODULE_SYSTEM.md
+ Working example modul
+app/modules/_template/
+ create_module.py CLI tool.env filesIngen aktive moduler fundet
+ Opret dit første modul medpython3 scripts/create_module.py
+ ${module.description}
+${module.table_prefix}
+ ${module.has_api ? 'API' : ''}
+ ${module.has_frontend ? 'Frontend' : ''}
+ Ingen resultater for "${escapeHtml(query)}"
+Begynd at skrive for at søge...
- `; - - if (query.length < 2) { - emptyState.style.display = 'block'; - document.getElementById('workflowActions').style.display = 'none'; - document.getElementById('crmResults').style.display = 'none'; - document.getElementById('supportResults').style.display = 'none'; - if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none'; - if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none'; - selectedEntity = null; - return; - } - - searchTimeout = setTimeout(async () => { - try { - const response = await fetch(`/api/v1/dashboard/search?q=${encodeURIComponent(query)}`); - const data = await response.json(); - - emptyState.style.display = 'none'; - - // CRM Results (Customers + Contacts + Vendors) - const crmSection = document.getElementById('crmResults'); - const allResults = [ - ...(data.customers || []).map(c => ({...c, entityType: 'customer', url: `/customers/${c.id}`, icon: 'building'})), - ...(data.contacts || []).map(c => ({...c, entityType: 'contact', url: `/contacts/${c.id}`, icon: 'person'})), - ...(data.vendors || []).map(c => ({...c, entityType: 'vendor', url: `/vendors/${c.id}`, icon: 'shop'})) - ]; - - if (allResults.length > 0) { - crmSection.style.display = 'block'; - crmSection.querySelector('.result-items').innerHTML = allResults.map(item => ` -${item.name}
-${item.type} ${item.email ? '• ' + item.email : ''}
-Ingen resultater fundet for "${query}"
- `; - } - - // Hide other sections for now as we don't have real data for them yet - document.getElementById('supportResults').style.display = 'none'; - if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none'; - if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none'; - - } catch (error) { - console.error('Search error:', error); - } - }, 300); // Debounce 300ms - }); + // Search function already implemented in DOMContentLoaded above - duplicate removed // Hover effects for result items document.addEventListener('DOMContentLoaded', () => { @@ -762,6 +980,49 @@ `; document.head.appendChild(style); }); + + // Nested dropdown support - simple click-based approach that works reliably + document.addEventListener('DOMContentLoaded', () => { + // Find all submenu toggle links + document.querySelectorAll('.dropdown-submenu > a').forEach((toggle) => { + toggle.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + // Get the submenu + const submenu = this.nextElementSibling; + if (!submenu || !submenu.classList.contains('dropdown-menu')) return; + + // Close all other submenus first + document.querySelectorAll('.dropdown-submenu .dropdown-menu').forEach((menu) => { + if (menu !== submenu) { + menu.classList.remove('show'); + } + }); + + // Toggle this submenu + submenu.classList.toggle('show'); + }); + }); + + // Close all submenus when parent dropdown closes + document.querySelectorAll('.dropdown').forEach((dropdown) => { + dropdown.addEventListener('hide.bs.dropdown', () => { + dropdown.querySelectorAll('.dropdown-submenu .dropdown-menu').forEach((submenu) => { + submenu.classList.remove('show'); + }); + }); + }); + + // Close submenu when clicking outside + document.addEventListener('click', (e) => { + if (!e.target.closest('.dropdown-submenu')) { + document.querySelectorAll('.dropdown-submenu .dropdown-menu.show').forEach((submenu) => { + submenu.classList.remove('show'); + }); + } + }); + }); {% block extra_js %}{% endblock %}diff --git a/app/timetracking/backend/economic_export.py b/app/timetracking/backend/economic_export.py index 26dc23d..8944d8b 100644 --- a/app/timetracking/backend/economic_export.py +++ b/app/timetracking/backend/economic_export.py @@ -200,18 +200,19 @@ class EconomicExportService: # REAL EXPORT (kun hvis safety flags er disabled) logger.warning(f"⚠️ REAL EXPORT STARTING for order {request.order_id}") - # Hent e-conomic customer number fra vTiger customer + # Hent e-conomic customer number fra Hub customers via hub_customer_id customer_number_query = """ - SELECT economic_customer_number - FROM tmodule_customers - WHERE id = %s + SELECT c.economic_customer_number + FROM tmodule_customers tc + LEFT JOIN customers c ON tc.hub_customer_id = c.id + WHERE tc.id = %s """ customer_data = execute_query(customer_number_query, (order['customer_id'],), fetchone=True) if not customer_data or not customer_data.get('economic_customer_number'): raise HTTPException( status_code=400, - detail=f"Customer {order['customer_name']} has no e-conomic customer number" + detail=f"Customer {order['customer_name']} has no e-conomic customer number. Link customer to Hub customer first." ) customer_number = customer_data['economic_customer_number'] diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py index d69d3ab..7e9f29c 100644 --- a/app/timetracking/backend/router.py +++ b/app/timetracking/backend/router.py @@ -242,6 +242,28 @@ async def reject_time_entry( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/wizard/reset/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) +async def reset_to_pending( + time_id: int, + reason: Optional[str] = None, + user_id: Optional[int] = None +): + """ + Nulstil en godkendt/afvist tidsregistrering tilbage til pending. + + Query params: + - reason: Årsag til nulstilling (optional) + - user_id: ID på brugeren der nulstiller (optional) + """ + try: + return wizard.reset_to_pending(time_id, reason=reason, user_id=user_id) + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Reset failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/wizard/case/{case_id}/entries", response_model=List[TModuleTimeWithContext], tags=["Wizard"]) async def get_case_entries( case_id: int, diff --git a/app/timetracking/backend/wizard.py b/app/timetracking/backend/wizard.py index cb20540..42cbd5a 100644 --- a/app/timetracking/backend/wizard.py +++ b/app/timetracking/backend/wizard.py @@ -294,6 +294,89 @@ class WizardService: logger.error(f"❌ Error rejecting time entry: {e}") raise HTTPException(status_code=500, detail=str(e)) + @staticmethod + def reset_to_pending( + time_id: int, + reason: Optional[str] = None, + user_id: Optional[int] = None + ) -> TModuleTimeWithContext: + """ + Nulstil en godkendt/afvist tidsregistrering tilbage til pending. + + Args: + time_id: ID på tidsregistreringen + reason: Årsag til nulstilling + user_id: ID på brugeren der nulstiller + + Returns: + Opdateret tidsregistrering + """ + try: + # Check exists + query = """ + SELECT t.*, c.title as case_title, c.status as case_status, + cust.name as customer_name, cust.hourly_rate as customer_rate + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + JOIN tmodule_customers cust ON t.customer_id = cust.id + WHERE t.id = %s + """ + entry = execute_query(query, (time_id,), fetchone=True) + + if not entry: + raise HTTPException(status_code=404, detail="Time entry not found") + + if entry['status'] == 'pending': + raise HTTPException( + status_code=400, + detail="Time entry is already pending" + ) + + if entry['status'] == 'billed': + raise HTTPException( + status_code=400, + detail="Cannot reset billed entries" + ) + + # Reset to pending - clear all approval data + update_query = """ + UPDATE tmodule_times + SET status = 'pending', + approved_hours = NULL, + rounded_to = NULL, + approval_note = %s, + billable = true, + approved_at = NULL, + approved_by = NULL + WHERE id = %s + """ + + execute_update(update_query, (reason, time_id)) + + # Log reset + audit.log_event( + event_type="reset_to_pending", + entity_type="time_entry", + entity_id=time_id, + user_id=user_id, + details={ + "reason": reason or "Reset to pending", + "timestamp": datetime.now().isoformat() + } + ) + + logger.info(f"🔄 Reset time entry {time_id} to pending: {reason}") + + # Return updated + updated = execute_query(query, (time_id,), fetchone=True) + return TModuleTimeWithContext(**updated) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error resetting time entry: {e}") + raise HTTPException(status_code=500, detail=str(e)) + @staticmethod def approve_case_entries( case_id: int, diff --git a/app/timetracking/frontend/customers.html b/app/timetracking/frontend/customers.html index d724585..2c99727 100644 --- a/app/timetracking/frontend/customers.html +++ b/app/timetracking/frontend/customers.html @@ -1,48 +1,10 @@ - - -
- - -
- - - - -
-