From 38fa3b6c0aef06c4ab23e4afdbb1f24b791c7751 Mon Sep 17 00:00:00 2001
From: Christian
Date: Sat, 13 Dec 2025 12:06:28 +0100
Subject: [PATCH] feat: Add subscriptions lock feature to customers
- Added a new column `subscriptions_locked` to the `customers` table to manage subscription access.
- Implemented a script to create new modules from a template, including updates to various files (module.json, README.md, router.py, views.py, and migration SQL).
- Developed a script to import BMC Office subscriptions from an Excel file into the database, including error handling and statistics reporting.
- Created a script to lookup and update missing CVR numbers using the CVR.dk API.
- Implemented a script to relink Hub customers to e-conomic customer numbers based on name matching.
- Developed scripts to sync CVR numbers from Simply-CRM and vTiger to the local customers database.
---
.env.example | 26 +
app/core/config.py | 30 +
app/core/database.py | 67 ++-
app/core/module_loader.py | 292 ++++++++++
app/customers/backend/router.py | 263 ++++++++-
app/customers/frontend/customer_detail.html | 403 ++++++++++++--
app/customers/frontend/customers.html | 18 +-
app/models/schemas.py | 1 +
app/modules/_template/README.md | 137 +++++
app/modules/_template/backend/__init__.py | 1 +
app/modules/_template/backend/router.py | 267 +++++++++
app/modules/_template/frontend/__init__.py | 1 +
app/modules/_template/frontend/views.py | 52 ++
app/modules/_template/migrations/001_init.sql | 37 ++
app/modules/_template/module.json | 17 +
app/modules/_template/templates/index.html | 59 ++
app/modules/test_module/README.md | 137 +++++
app/modules/test_module/backend/__init__.py | 1 +
app/modules/test_module/backend/router.py | 267 +++++++++
app/modules/test_module/frontend/__init__.py | 1 +
app/modules/test_module/frontend/views.py | 52 ++
.../test_module/migrations/001_init.sql | 37 ++
app/modules/test_module/module.json | 19 +
app/modules/test_module/templates/index.html | 59 ++
app/services/simplycrm_service.py | 522 ++++++++++++++++++
app/services/vtiger_service.py | 215 ++++++++
app/settings/frontend/settings.html | 281 ++++++++++
app/shared/frontend/base.html | 433 ++++++++++++---
app/timetracking/backend/economic_export.py | 11 +-
app/timetracking/backend/router.py | 22 +
app/timetracking/backend/wizard.py | 83 +++
app/timetracking/frontend/customers.html | 154 ++----
app/timetracking/frontend/dashboard.html | 427 ++++++++------
app/timetracking/frontend/orders.html | 133 +----
app/timetracking/frontend/views.py | 50 +-
app/timetracking/frontend/wizard.html | 160 +-----
docs/MODULE_IMPLEMENTATION_COMPLETE.md | 436 +++++++++++++++
docs/MODULE_QUICKSTART.md | 214 +++++++
docs/MODULE_SYSTEM.md | 470 ++++++++++++++++
docs/MODULE_SYSTEM_OVERVIEW.md | 255 +++++++++
main.py | 38 +-
migrations/023_subscriptions_lock.sql | 7 +
scripts/create_module.py | 189 +++++++
scripts/import_bmc_office_subscriptions.py | 220 ++++++++
scripts/lookup_missing_cvr.py | 96 ++++
scripts/relink_economic_customers.py | 273 +++++++++
scripts/sync_cvr_from_simplycrm.py | 133 +++++
scripts/sync_cvr_from_vtiger.py | 131 +++++
48 files changed, 6462 insertions(+), 735 deletions(-)
create mode 100644 app/core/module_loader.py
create mode 100644 app/modules/_template/README.md
create mode 100644 app/modules/_template/backend/__init__.py
create mode 100644 app/modules/_template/backend/router.py
create mode 100644 app/modules/_template/frontend/__init__.py
create mode 100644 app/modules/_template/frontend/views.py
create mode 100644 app/modules/_template/migrations/001_init.sql
create mode 100644 app/modules/_template/module.json
create mode 100644 app/modules/_template/templates/index.html
create mode 100644 app/modules/test_module/README.md
create mode 100644 app/modules/test_module/backend/__init__.py
create mode 100644 app/modules/test_module/backend/router.py
create mode 100644 app/modules/test_module/frontend/__init__.py
create mode 100644 app/modules/test_module/frontend/views.py
create mode 100644 app/modules/test_module/migrations/001_init.sql
create mode 100644 app/modules/test_module/module.json
create mode 100644 app/modules/test_module/templates/index.html
create mode 100644 app/services/simplycrm_service.py
create mode 100644 docs/MODULE_IMPLEMENTATION_COMPLETE.md
create mode 100644 docs/MODULE_QUICKSTART.md
create mode 100644 docs/MODULE_SYSTEM.md
create mode 100644 docs/MODULE_SYSTEM_OVERVIEW.md
create mode 100644 migrations/023_subscriptions_lock.sql
create mode 100755 scripts/create_module.py
create mode 100644 scripts/import_bmc_office_subscriptions.py
create mode 100644 scripts/lookup_missing_cvr.py
create mode 100755 scripts/relink_economic_customers.py
create mode 100644 scripts/sync_cvr_from_simplycrm.py
create mode 100644 scripts/sync_cvr_from_vtiger.py
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 @@
?
@@ -353,9 +354,14 @@
Abonnementer & Salgsordre
-
+
+
+
+
@@ -386,6 +392,67 @@
+
+
+
{% endblock %}
{% block extra_js %}
@@ -465,6 +532,19 @@ function displayCustomer(customer) {
: 'Lokal';
document.getElementById('customerSource').innerHTML = sourceBadge;
+ // BMC Låst badge
+ const bmcLockedBadge = document.getElementById('bmcLockedBadge');
+ if (customer.bmc_locked) {
+ bmcLockedBadge.innerHTML = `
+
+
+ BMC LÅST
+
+ `;
+ } else {
+ bmcLockedBadge.innerHTML = '';
+ }
+
// Company Information
document.getElementById('cvrNumber').textContent = customer.cvr_number || '-';
document.getElementById('address').textContent = customer.address || '-';
@@ -595,6 +675,9 @@ function displaySubscriptions(data) {
const container = document.getElementById('subscriptionsContainer');
const { recurring_orders, sales_orders, subscriptions, expired_subscriptions, bmc_office_subscriptions } = data;
+ // Store subscriptions for editing
+ currentSubscriptions = subscriptions || [];
+
const totalItems = (sales_orders?.length || 0) + (subscriptions?.length || 0) + (bmc_office_subscriptions?.length || 0);
if (totalItems === 0) {
@@ -605,18 +688,32 @@ function displaySubscriptions(data) {
// Create 3-column layout
let html = '';
+ const isLocked = customerData?.subscriptions_locked || false;
+
// Column 1: vTiger Subscriptions
html += `
-
-
- vTiger Abonnementer
-
-
Fra Simply-CRM
+
+
+
+
+ vTiger Abonnementer
+ ${isLocked ? '' : ''}
+
+ Fra vTiger Cloud
+
+
+
- ${renderSubscriptionsList(subscriptions || [])}
+ ${isLocked ? '
Abonnementer er låst - kan kun redigeres i vTiger
' : ''}
+ ${renderSubscriptionsList(subscriptions || [], isLocked)}
`;
@@ -889,32 +986,47 @@ function renderBmcOfficeSubscriptionsList(subscriptions) {
}).join('');
}
-function renderSubscriptionsList(subscriptions) {
+function renderSubscriptionsList(subscriptions, isLocked = false) {
if (!subscriptions || subscriptions.length === 0) {
return '
Ingen abonnementer
';
}
return subscriptions.map((sub, idx) => {
const itemId = `subscription-${idx}`;
- const lineItems = sub.lineItems || [];
- const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
const total = parseFloat(sub.hdnGrandTotal || 0);
+ // Extract numeric record ID from vTiger ID (e.g., "72x29932" -> "29932")
+ const recordId = sub.id.includes('x') ? sub.id.split('x')[1] : sub.id;
+ const vtigerUrl = `https://bmcnetworks.od2.vtiger.com/view/detail?module=Subscription&id=${recordId}&viewtype=summary`;
return `
-
-
-
+
+
+
${escapeHtml(sub.subject || sub.subscription_no || 'Unnamed')}
+ ${sub.cf_subscription_bmclst === '1' ? '' : ''}
${sub.subscription_no ? `#${escapeHtml(sub.subscription_no)}` : ''}
-
${escapeHtml(sub.subscriptionstatus || 'Active')}
-
${total.toFixed(2)} DKK
+
+
+ ${sub.cf_subscription_bmclst === '1' ? '
BMC LÅST
' : ''}
+
${escapeHtml(sub.subscriptionstatus || 'Active')}
+
${total.toFixed(2)} DKK
+
@@ -924,33 +1036,14 @@ function renderSubscriptionsList(subscriptions) {
${sub.startdate ? `
Start: ${formatDate(sub.startdate)}` : ''}
- ${hasLineItems ? `
-
- ${lineItems.map(line => `
-
-
-
${escapeHtml(line.product_name || line.productid)}
-
- ${line.quantity || 0} stk × ${parseFloat(line.listprice || 0).toFixed(2)} DKK
-
-
-
- ${parseFloat(line.netprice || 0).toFixed(2)} DKK
-
-
- `).join('')}
-
- Subtotal:
- ${parseFloat(sub.hdnSubTotal || 0).toFixed(2)} DKK
-
-
-
Total inkl. moms:
-
${total.toFixed(2)} DKK
+
+
+ Indlæser...
+
Henter produktlinjer...
- ` : ''}
`;
}).join('');
@@ -1041,7 +1134,7 @@ async function loadActivity() {
}, 500);
}
-function toggleLineItems(itemId) {
+async function toggleSubscriptionDetails(subscriptionId, itemId) {
const linesDiv = document.getElementById(`${itemId}-lines`);
const icon = document.getElementById(`${itemId}-icon`);
@@ -1053,12 +1146,85 @@ function toggleLineItems(itemId) {
linesDiv.style.display = 'block';
if (icon) icon.className = 'bi bi-chevron-down me-2 text-primary';
if (item) item.classList.add('expanded');
+
+ // Fetch line items if not already loaded
+ if (linesDiv.querySelector('.spinner-border')) {
+ try {
+ const response = await fetch(`/api/v1/subscriptions/${subscriptionId}`);
+ const data = await response.json();
+
+ if (data.status === 'success') {
+ const sub = data.subscription;
+ const lineItems = sub.LineItems || [];
+ const total = parseFloat(sub.hdnGrandTotal || 0);
+
+ if (lineItems.length > 0) {
+ linesDiv.innerHTML = `
+
+
+ Produktlinjer:
+
+
+ For at ændre priser, klik "Åbn i vTiger"
+
+
+ ${lineItems.map(line => `
+
+
+
${escapeHtml(line.product_name || line.productid)}
+
+ ${line.quantity} stk × ${parseFloat(line.listprice).toFixed(2)} DKK
+
+ ${line.comment ? `
${escapeHtml(line.comment)}
` : ''}
+
+
+ ${parseFloat(line.netprice || 0).toFixed(2)} DKK
+
+
+ `).join('')}
+
+ Subtotal:
+ ${parseFloat(sub.hdnSubTotal || 0).toFixed(2)} DKK
+
+
+ Total inkl. moms:
+ ${total.toFixed(2)} DKK
+
+
+ `;
+ } else {
+ linesDiv.innerHTML = '
Ingen produktlinjer
';
+ }
+ } else {
+ linesDiv.innerHTML = '
Kunne ikke hente produktlinjer
';
+ }
+ } catch (error) {
+ console.error('Error fetching subscription details:', error);
+ linesDiv.innerHTML = '
Fejl ved indlæsning
';
+ }
+ }
} else {
linesDiv.style.display = 'none';
- if (icon) {
- const isSubscription = itemId.includes('subscription');
- icon.className = `bi bi-chevron-right me-2 ${isSubscription ? 'text-primary' : 'text-success'}`;
- }
+ if (icon) icon.className = 'bi bi-chevron-right me-2 text-primary';
+ if (item) item.classList.remove('expanded');
+ }
+}
+
+function toggleLineItems(itemId) {
+ const linesDiv = document.getElementById(`${itemId}-lines`);
+ const icon = document.getElementById(`${itemId}-icon`);
+
+ if (!linesDiv) return;
+
+ const item = linesDiv.closest('.subscription-item');
+
+ if (linesDiv.style.display === 'none') {
+ linesDiv.style.display = 'block';
+ if (icon) icon.className = 'bi bi-chevron-down me-2 text-success';
+ if (item) item.classList.add('expanded');
+ } else {
+ linesDiv.style.display = 'none';
+ if (icon) icon.className = 'bi bi-chevron-right me-2 text-success';
if (item) item.classList.remove('expanded');
}
}
@@ -1073,6 +1239,117 @@ function showAddContactModal() {
console.log('Add contact for customer:', customerId);
}
+// Subscription management functions
+let currentSubscriptions = [];
+
+function showCreateSubscriptionModal() {
+ if (!customerData || !customerData.vtiger_id) {
+ alert('Kunden er ikke linket til vTiger');
+ return;
+ }
+
+ const modal = new bootstrap.Modal(document.getElementById('subscriptionModal'));
+ document.getElementById('subscriptionModalLabel').textContent = 'Opret Nyt Abonnement';
+ document.getElementById('subscriptionForm').reset();
+ document.getElementById('subscriptionId').value = '';
+ modal.show();
+}
+
+async function editSubscription(subscriptionId, event) {
+ event.stopPropagation();
+
+ // Find subscription data
+ const sub = currentSubscriptions.find(s => s.id === subscriptionId);
+ if (!sub) {
+ alert('Abonnement ikke fundet');
+ return;
+ }
+
+ // Fill form
+ document.getElementById('subscriptionId').value = subscriptionId;
+ document.getElementById('subjectInput').value = sub.subject || '';
+ document.getElementById('startdateInput').value = sub.startdate || '';
+ document.getElementById('enddateInput').value = sub.enddate || '';
+ document.getElementById('frequencyInput').value = sub.generateinvoiceevery || 'Monthly';
+ document.getElementById('statusInput').value = sub.subscriptionstatus || 'Active';
+
+ // Show modal
+ const modal = new bootstrap.Modal(document.getElementById('subscriptionModal'));
+ document.getElementById('subscriptionModalLabel').textContent = 'Rediger Abonnement';
+ modal.show();
+}
+
+async function deleteSubscription(subscriptionId, event) {
+ event.stopPropagation();
+
+ if (!confirm('Er du sikker på at du vil slette dette abonnement?')) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/v1/subscriptions/${subscriptionId}`, {
+ method: 'DELETE'
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete subscription');
+ }
+
+ alert('Abonnement slettet');
+ loadSubscriptions(); // Reload
+ } catch (error) {
+ console.error('Error deleting subscription:', error);
+ alert('Kunne ikke slette abonnement: ' + error.message);
+ }
+}
+
+async function saveSubscription() {
+ const subscriptionId = document.getElementById('subscriptionId').value;
+ const isEdit = !!subscriptionId;
+
+ const data = {
+ subject: document.getElementById('subjectInput').value,
+ startdate: document.getElementById('startdateInput').value,
+ enddate: document.getElementById('enddateInput').value || null,
+ generateinvoiceevery: document.getElementById('frequencyInput').value,
+ subscriptionstatus: document.getElementById('statusInput').value,
+ products: [] // TODO: Add product picker
+ };
+
+ if (!isEdit) {
+ data.account_id = customerData.vtiger_id;
+ }
+
+ try {
+ let response;
+ if (isEdit) {
+ response = await fetch(`/api/v1/subscriptions/${subscriptionId}`, {
+ method: 'PUT',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(data)
+ });
+ } else {
+ response = await fetch(`/api/v1/customers/${customerId}/subscriptions`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(data)
+ });
+ }
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || 'Failed to save subscription');
+ }
+
+ alert(isEdit ? 'Abonnement opdateret' : 'Abonnement oprettet');
+ bootstrap.Modal.getInstance(document.getElementById('subscriptionModal')).hide();
+ loadSubscriptions(); // Reload
+ } catch (error) {
+ console.error('Error saving subscription:', error);
+ alert('Kunne ikke gemme abonnement: ' + error.message);
+ }
+}
+
function getInitials(name) {
if (!name) return '?';
const words = name.trim().split(' ');
@@ -1085,5 +1362,39 @@ function escapeHtml(text) {
div.textContent = text;
return div.innerHTML;
}
+
+async function toggleSubscriptionsLock() {
+ const currentlyLocked = customerData?.subscriptions_locked || false;
+ const action = currentlyLocked ? 'låse op' : 'låse';
+
+ if (!confirm(`Er du sikker på at du vil ${action} abonnementer for denne kunde?\n\n${currentlyLocked ? 'Efter oplåsning kan abonnementer redigeres i BMC Hub.' : 'Efter låsning kan abonnementer kun redigeres direkte i vTiger.'}`)) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/v1/customers/${customerId}/subscriptions/lock`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({ locked: !currentlyLocked })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || 'Kunne ikke opdatere låsestatus');
+ }
+
+ const result = await response.json();
+
+ // Reload customer and subscriptions
+ await loadCustomer();
+ await loadSubscriptions();
+
+ alert(`✓ ${result.message}\n\n${result.note || ''}`);
+
+ } catch (error) {
+ console.error('Error toggling lock:', error);
+ alert('Fejl: ' + error.message);
+ }
+}
{% endblock %}
diff --git a/app/customers/frontend/customers.html b/app/customers/frontend/customers.html
index 7985aed..03345c2 100644
--- a/app/customers/frontend/customers.html
+++ b/app/customers/frontend/customers.html
@@ -224,18 +224,33 @@ let totalCustomers = 0;
document.addEventListener('DOMContentLoaded', () => {
loadCustomers();
+ const searchInput = document.getElementById('searchInput');
+
// Search with debounce
let searchTimeout;
- document.getElementById('searchInput').addEventListener('input', (e) => {
+ searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchQuery = e.target.value;
currentPage = 0;
+ console.log('🔍 Searching for:', searchQuery);
loadCustomers();
}, 300);
});
});
+// Cmd+K / Ctrl+K keyboard shortcut (outside DOMContentLoaded so it works everywhere)
+document.addEventListener('keydown', (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
+ e.preventDefault();
+ const searchInput = document.getElementById('searchInput');
+ if (searchInput) {
+ searchInput.focus();
+ searchInput.select();
+ }
+ }
+});
+
function setFilter(filter) {
currentFilter = filter;
currentPage = 0;
@@ -262,6 +277,7 @@ async function loadCustomers() {
if (searchQuery) {
params.append('search', searchQuery);
+ console.log('📤 Sending search query:', searchQuery);
}
if (currentFilter === 'active') {
diff --git a/app/models/schemas.py b/app/models/schemas.py
index e9a0ca5..9a9968c 100644
--- a/app/models/schemas.py
+++ b/app/models/schemas.py
@@ -40,6 +40,7 @@ class CustomerUpdate(BaseModel):
city: Optional[str] = None
website: Optional[str] = None
is_active: Optional[bool] = None
+ subscriptions_locked: Optional[bool] = None
class Customer(CustomerBase):
diff --git a/app/modules/_template/README.md b/app/modules/_template/README.md
new file mode 100644
index 0000000..7c2ea43
--- /dev/null
+++ b/app/modules/_template/README.md
@@ -0,0 +1,137 @@
+# Template Module
+
+Dette er template strukturen for nye BMC Hub moduler.
+
+## Struktur
+
+```
+my_module/
+├── module.json # Metadata og konfiguration
+├── README.md # Dokumentation
+├── backend/
+│ ├── __init__.py
+│ └── router.py # FastAPI routes (API endpoints)
+├── frontend/
+│ ├── __init__.py
+│ └── views.py # HTML view routes
+├── templates/
+│ └── index.html # Jinja2 templates
+└── migrations/
+ └── 001_init.sql # Database migrations
+```
+
+## Opret nyt modul
+
+```bash
+python scripts/create_module.py my_module "My Module Description"
+```
+
+## Database Tables
+
+Alle tabeller SKAL bruge `table_prefix` fra module.json:
+
+```sql
+-- Hvis table_prefix = "mymod_"
+CREATE TABLE mymod_customers (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255)
+);
+```
+
+Dette sikrer at moduler ikke kolliderer med core eller andre moduler.
+
+## Konfiguration
+
+Modul-specifikke miljøvariable følger mønsteret:
+
+```bash
+MODULES__MY_MODULE__API_KEY=secret123
+MODULES__MY_MODULE__READ_ONLY=true
+```
+
+Tilgå i kode:
+
+```python
+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", default="true") == "true"
+```
+
+## Database Queries
+
+Brug ALTID helper functions fra `app.core.database`:
+
+```python
+from app.core.database import execute_query, execute_insert
+
+# Fetch
+customers = execute_query(
+ "SELECT * FROM mymod_customers WHERE active = %s",
+ (True,)
+)
+
+# Insert
+customer_id = execute_insert(
+ "INSERT INTO mymod_customers (name) VALUES (%s)",
+ ("Test Customer",)
+)
+```
+
+## Migrations
+
+Migrations ligger i `migrations/` og køres manuelt eller via migration tool:
+
+```python
+from app.core.database import execute_module_migration
+
+with open("migrations/001_init.sql") as f:
+ migration_sql = f.read()
+
+success = execute_module_migration("my_module", migration_sql)
+```
+
+## Enable/Disable
+
+```bash
+# Enable via API
+curl -X POST http://localhost:8000/api/v1/modules/my_module/enable
+
+# Eller rediger module.json
+{
+ "enabled": true
+}
+
+# Restart app
+docker-compose restart api
+```
+
+## Fejlhåndtering
+
+Moduler er isolerede - hvis dit modul crasher ved opstart:
+- Core systemet kører videre
+- Modulet bliver ikke loaded
+- Fejl logges til console og logs/app.log
+
+Runtime fejl i endpoints påvirker ikke andre moduler.
+
+## Testing
+
+```python
+import pytest
+from app.core.database import execute_query
+
+def test_my_module():
+ # Test bruger samme database helpers
+ result = execute_query("SELECT 1 as test")
+ assert result[0]["test"] == 1
+```
+
+## Best Practices
+
+1. **Database isolering**: Brug ALTID `table_prefix` fra module.json
+2. **Safety switches**: Tilføj `READ_ONLY` og `DRY_RUN` flags
+3. **Error handling**: Log fejl, raise HTTPException med status codes
+4. **Dependencies**: Deklarer i `module.json` hvis du bruger andre moduler
+5. **Migrations**: Nummer sekventielt (001, 002, 003...)
+6. **Documentation**: Opdater README.md med API endpoints og use cases
diff --git a/app/modules/_template/backend/__init__.py b/app/modules/_template/backend/__init__.py
new file mode 100644
index 0000000..2db4e36
--- /dev/null
+++ b/app/modules/_template/backend/__init__.py
@@ -0,0 +1 @@
+# Backend package for template module
diff --git a/app/modules/_template/backend/router.py b/app/modules/_template/backend/router.py
new file mode 100644
index 0000000..7623f19
--- /dev/null
+++ b/app/modules/_template/backend/router.py
@@ -0,0 +1,267 @@
+"""
+Template Module - API Router
+Backend endpoints for template module
+"""
+
+from fastapi import APIRouter, HTTPException
+from typing import List
+import logging
+
+from app.core.database import execute_query, execute_insert, execute_update
+from app.core.config import get_module_config
+
+logger = logging.getLogger(__name__)
+
+# APIRouter instance (module_loader kigger efter denne)
+router = APIRouter()
+
+
+@router.get("/template/items")
+async def get_items():
+ """
+ Hent alle items fra template module
+
+ Returns:
+ Liste af items
+ """
+ try:
+ # Check safety switch
+ read_only = get_module_config("template_module", "READ_ONLY", "true") == "true"
+
+ # Hent items (bemærk table_prefix)
+ items = execute_query(
+ "SELECT * FROM template_items ORDER BY created_at DESC"
+ )
+
+ return {
+ "success": True,
+ "items": items,
+ "read_only": read_only
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching items: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/template/items/{item_id}")
+async def get_item(item_id: int):
+ """
+ Hent enkelt item
+
+ Args:
+ item_id: Item ID
+
+ Returns:
+ Item object
+ """
+ try:
+ item = execute_query(
+ "SELECT * FROM template_items WHERE id = %s",
+ (item_id,),
+ fetchone=True
+ )
+
+ if not item:
+ raise HTTPException(status_code=404, detail="Item not found")
+
+ return {
+ "success": True,
+ "item": item
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error fetching item {item_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/template/items")
+async def create_item(name: str, description: str = ""):
+ """
+ Opret nyt item
+
+ Args:
+ name: Item navn
+ description: Item beskrivelse
+
+ Returns:
+ Nyt item med ID
+ """
+ try:
+ # Check safety switches
+ read_only = get_module_config("template_module", "READ_ONLY", "true") == "true"
+ dry_run = get_module_config("template_module", "DRY_RUN", "true") == "true"
+
+ if read_only:
+ logger.warning("⚠️ READ_ONLY mode enabled - operation blocked")
+ return {
+ "success": False,
+ "message": "Module is in READ_ONLY mode",
+ "read_only": True
+ }
+
+ if dry_run:
+ logger.info(f"🧪 DRY_RUN: Would create item: {name}")
+ return {
+ "success": True,
+ "dry_run": True,
+ "message": f"DRY_RUN: Item '{name}' would be created"
+ }
+
+ # Opret item
+ item_id = execute_insert(
+ "INSERT INTO template_items (name, description) VALUES (%s, %s)",
+ (name, description)
+ )
+
+ logger.info(f"✅ Created item {item_id}: {name}")
+
+ return {
+ "success": True,
+ "item_id": item_id,
+ "name": name
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Error creating item: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.put("/template/items/{item_id}")
+async def update_item(item_id: int, name: str = None, description: str = None):
+ """
+ Opdater item
+
+ Args:
+ item_id: Item ID
+ name: Nyt navn (optional)
+ description: Ny beskrivelse (optional)
+
+ Returns:
+ Success status
+ """
+ try:
+ # Check safety switches
+ read_only = get_module_config("template_module", "READ_ONLY", "true") == "true"
+
+ if read_only:
+ logger.warning("⚠️ READ_ONLY mode enabled - operation blocked")
+ return {
+ "success": False,
+ "message": "Module is in READ_ONLY mode"
+ }
+
+ # Build update query dynamically
+ updates = []
+ params = []
+
+ if name is not None:
+ updates.append("name = %s")
+ params.append(name)
+
+ if description is not None:
+ updates.append("description = %s")
+ params.append(description)
+
+ if not updates:
+ raise HTTPException(status_code=400, detail="No fields to update")
+
+ params.append(item_id)
+
+ query = f"UPDATE template_items SET {', '.join(updates)} WHERE id = %s"
+ affected = execute_update(query, tuple(params))
+
+ if affected == 0:
+ raise HTTPException(status_code=404, detail="Item not found")
+
+ logger.info(f"✅ Updated item {item_id}")
+
+ return {
+ "success": True,
+ "item_id": item_id,
+ "affected": affected
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error updating item {item_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/template/items/{item_id}")
+async def delete_item(item_id: int):
+ """
+ Slet item
+
+ Args:
+ item_id: Item ID
+
+ Returns:
+ Success status
+ """
+ try:
+ # Check safety switches
+ read_only = get_module_config("template_module", "READ_ONLY", "true") == "true"
+
+ if read_only:
+ logger.warning("⚠️ READ_ONLY mode enabled - operation blocked")
+ return {
+ "success": False,
+ "message": "Module is in READ_ONLY mode"
+ }
+
+ affected = execute_update(
+ "DELETE FROM template_items WHERE id = %s",
+ (item_id,)
+ )
+
+ if affected == 0:
+ raise HTTPException(status_code=404, detail="Item not found")
+
+ logger.info(f"✅ Deleted item {item_id}")
+
+ return {
+ "success": True,
+ "item_id": item_id
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error deleting item {item_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/template/health")
+async def health_check():
+ """
+ Modul health check
+
+ Returns:
+ Health status
+ """
+ try:
+ # Test database connectivity
+ result = execute_query("SELECT 1 as test", fetchone=True)
+
+ return {
+ "status": "healthy",
+ "module": "template_module",
+ "version": "1.0.0",
+ "database": "connected" if result else "error",
+ "config": {
+ "read_only": get_module_config("template_module", "READ_ONLY", "true"),
+ "dry_run": get_module_config("template_module", "DRY_RUN", "true")
+ }
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Health check failed: {e}")
+ return {
+ "status": "unhealthy",
+ "module": "template_module",
+ "error": str(e)
+ }
diff --git a/app/modules/_template/frontend/__init__.py b/app/modules/_template/frontend/__init__.py
new file mode 100644
index 0000000..abc5ac0
--- /dev/null
+++ b/app/modules/_template/frontend/__init__.py
@@ -0,0 +1 @@
+# Frontend package for template module
diff --git a/app/modules/_template/frontend/views.py b/app/modules/_template/frontend/views.py
new file mode 100644
index 0000000..6e7e3f4
--- /dev/null
+++ b/app/modules/_template/frontend/views.py
@@ -0,0 +1,52 @@
+"""
+Template Module - Frontend Views
+HTML view routes for template module
+"""
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+import logging
+
+from app.core.database import execute_query
+
+logger = logging.getLogger(__name__)
+
+# APIRouter instance (module_loader kigger efter denne)
+router = APIRouter()
+
+# Templates til dette modul (relativ til module root)
+templates = Jinja2Templates(directory="app/modules/_template/templates")
+
+
+@router.get("/template", response_class=HTMLResponse)
+async def template_page(request: Request):
+ """
+ Template module hovedside
+
+ Args:
+ request: FastAPI request object
+
+ Returns:
+ HTML response
+ """
+ try:
+ # Hent items til visning
+ items = execute_query(
+ "SELECT * FROM template_items ORDER BY created_at DESC LIMIT 10"
+ )
+
+ return templates.TemplateResponse("index.html", {
+ "request": request,
+ "page_title": "Template Module",
+ "items": items or []
+ })
+
+ except Exception as e:
+ logger.error(f"❌ Error rendering template page: {e}")
+ return templates.TemplateResponse("index.html", {
+ "request": request,
+ "page_title": "Template Module",
+ "error": str(e),
+ "items": []
+ })
diff --git a/app/modules/_template/migrations/001_init.sql b/app/modules/_template/migrations/001_init.sql
new file mode 100644
index 0000000..d266086
--- /dev/null
+++ b/app/modules/_template/migrations/001_init.sql
@@ -0,0 +1,37 @@
+-- Template Module - Initial Migration
+-- Opret basis tabeller for template module
+
+-- Items tabel (eksempel)
+CREATE TABLE IF NOT EXISTS template_items (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Index for performance
+CREATE INDEX IF NOT EXISTS idx_template_items_active ON template_items(active);
+CREATE INDEX IF NOT EXISTS idx_template_items_created ON template_items(created_at DESC);
+
+-- Trigger for updated_at
+CREATE OR REPLACE FUNCTION update_template_items_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_template_items_updated_at
+BEFORE UPDATE ON template_items
+FOR EACH ROW
+EXECUTE FUNCTION update_template_items_updated_at();
+
+-- Indsæt test data (optional)
+INSERT INTO template_items (name, description)
+VALUES
+ ('Test Item 1', 'This is a test item from template module'),
+ ('Test Item 2', 'Another test item')
+ON CONFLICT DO NOTHING;
diff --git a/app/modules/_template/module.json b/app/modules/_template/module.json
new file mode 100644
index 0000000..51de22a
--- /dev/null
+++ b/app/modules/_template/module.json
@@ -0,0 +1,17 @@
+{
+ "name": "template_module",
+ "version": "1.0.0",
+ "description": "Template for nye BMC Hub moduler",
+ "author": "BMC Networks",
+ "enabled": false,
+ "dependencies": [],
+ "table_prefix": "template_",
+ "api_prefix": "/api/v1/template",
+ "tags": ["Template"],
+ "config": {
+ "safety_switches": {
+ "read_only": true,
+ "dry_run": true
+ }
+ }
+}
diff --git a/app/modules/_template/templates/index.html b/app/modules/_template/templates/index.html
new file mode 100644
index 0000000..80ecb1b
--- /dev/null
+++ b/app/modules/_template/templates/index.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+
{{ page_title }} - BMC Hub
+
+
+
+
+
{{ page_title }}
+
+ {% if error %}
+
+ Error: {{ error }}
+
+ {% endif %}
+
+
+
+
+ {% if items %}
+
+
+
+ | ID |
+ Name |
+ Description |
+ Created |
+
+
+
+ {% for item in items %}
+
+ | {{ item.id }} |
+ {{ item.name }} |
+ {{ item.description or '-' }} |
+ {{ item.created_at }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
No items found. This is a template module.
+ {% endif %}
+
+
+
+
+
+
+
+
+
diff --git a/app/modules/test_module/README.md b/app/modules/test_module/README.md
new file mode 100644
index 0000000..19804d2
--- /dev/null
+++ b/app/modules/test_module/README.md
@@ -0,0 +1,137 @@
+# Test Module Module
+
+Dette er template strukturen for nye BMC Hub moduler.
+
+## Struktur
+
+```
+test_module/
+├── module.json # Metadata og konfiguration
+├── README.md # Dokumentation
+├── backend/
+│ ├── __init__.py
+│ └── router.py # FastAPI routes (API endpoints)
+├── frontend/
+│ ├── __init__.py
+│ └── views.py # HTML view routes
+├── templates/
+│ └── index.html # Jinja2 templates
+└── migrations/
+ └── 001_init.sql # Database migrations
+```
+
+## Opret nyt modul
+
+```bash
+python scripts/create_module.py test_module "My Module Description"
+```
+
+## Database Tables
+
+Alle tabeller SKAL bruge `table_prefix` fra module.json:
+
+```sql
+-- Hvis table_prefix = "test_module_"
+CREATE TABLE test_module_customers (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255)
+);
+```
+
+Dette sikrer at moduler ikke kolliderer med core eller andre moduler.
+
+## Konfiguration
+
+Modul-specifikke miljøvariable følger mønsteret:
+
+```bash
+MODULES__MY_MODULE__API_KEY=secret123
+MODULES__MY_MODULE__READ_ONLY=true
+```
+
+Tilgå i kode:
+
+```python
+from app.core.config import get_module_config
+
+api_key = get_module_config("test_module", "API_KEY")
+read_only = get_module_config("test_module", "READ_ONLY", default="true") == "true"
+```
+
+## Database Queries
+
+Brug ALTID helper functions fra `app.core.database`:
+
+```python
+from app.core.database import execute_query, execute_insert
+
+# Fetch
+customers = execute_query(
+ "SELECT * FROM test_module_customers WHERE active = %s",
+ (True,)
+)
+
+# Insert
+customer_id = execute_insert(
+ "INSERT INTO test_module_customers (name) VALUES (%s)",
+ ("Test Customer",)
+)
+```
+
+## Migrations
+
+Migrations ligger i `migrations/` og køres manuelt eller via migration tool:
+
+```python
+from app.core.database import execute_module_migration
+
+with open("migrations/001_init.sql") as f:
+ migration_sql = f.read()
+
+success = execute_module_migration("test_module", migration_sql)
+```
+
+## Enable/Disable
+
+```bash
+# Enable via API
+curl -X POST http://localhost:8000/api/v1/modules/test_module/enable
+
+# Eller rediger module.json
+{
+ "enabled": true
+}
+
+# Restart app
+docker-compose restart api
+```
+
+## Fejlhåndtering
+
+Moduler er isolerede - hvis dit modul crasher ved opstart:
+- Core systemet kører videre
+- Modulet bliver ikke loaded
+- Fejl logges til console og logs/app.log
+
+Runtime fejl i endpoints påvirker ikke andre moduler.
+
+## Testing
+
+```python
+import pytest
+from app.core.database import execute_query
+
+def test_test_module():
+ # Test bruger samme database helpers
+ result = execute_query("SELECT 1 as test")
+ assert result[0]["test"] == 1
+```
+
+## Best Practices
+
+1. **Database isolering**: Brug ALTID `table_prefix` fra module.json
+2. **Safety switches**: Tilføj `READ_ONLY` og `DRY_RUN` flags
+3. **Error handling**: Log fejl, raise HTTPException med status codes
+4. **Dependencies**: Deklarer i `module.json` hvis du bruger andre moduler
+5. **Migrations**: Nummer sekventielt (001, 002, 003...)
+6. **Documentation**: Opdater README.md med API endpoints og use cases
diff --git a/app/modules/test_module/backend/__init__.py b/app/modules/test_module/backend/__init__.py
new file mode 100644
index 0000000..2db4e36
--- /dev/null
+++ b/app/modules/test_module/backend/__init__.py
@@ -0,0 +1 @@
+# Backend package for template module
diff --git a/app/modules/test_module/backend/router.py b/app/modules/test_module/backend/router.py
new file mode 100644
index 0000000..1e5f3e9
--- /dev/null
+++ b/app/modules/test_module/backend/router.py
@@ -0,0 +1,267 @@
+"""
+Test Module Module - API Router
+Backend endpoints for template module
+"""
+
+from fastapi import APIRouter, HTTPException
+from typing import List
+import logging
+
+from app.core.database import execute_query, execute_insert, execute_update
+from app.core.config import get_module_config
+
+logger = logging.getLogger(__name__)
+
+# APIRouter instance (module_loader kigger efter denne)
+router = APIRouter()
+
+
+@router.get("/test_module/items")
+async def get_items():
+ """
+ Hent alle items fra template module
+
+ Returns:
+ Liste af items
+ """
+ try:
+ # Check safety switch
+ read_only = get_module_config("test_module", "READ_ONLY", "true") == "true"
+
+ # Hent items (bemærk table_prefix)
+ items = execute_query(
+ "SELECT * FROM test_module_items ORDER BY created_at DESC"
+ )
+
+ return {
+ "success": True,
+ "items": items,
+ "read_only": read_only
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching items: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/test_module/items/{item_id}")
+async def get_item(item_id: int):
+ """
+ Hent enkelt item
+
+ Args:
+ item_id: Item ID
+
+ Returns:
+ Item object
+ """
+ try:
+ item = execute_query(
+ "SELECT * FROM test_module_items WHERE id = %s",
+ (item_id,),
+ fetchone=True
+ )
+
+ if not item:
+ raise HTTPException(status_code=404, detail="Item not found")
+
+ return {
+ "success": True,
+ "item": item
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error fetching item {item_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/test_module/items")
+async def create_item(name: str, description: str = ""):
+ """
+ Opret nyt item
+
+ Args:
+ name: Item navn
+ description: Item beskrivelse
+
+ Returns:
+ Nyt item med ID
+ """
+ try:
+ # Check safety switches
+ read_only = get_module_config("test_module", "READ_ONLY", "true") == "true"
+ dry_run = get_module_config("test_module", "DRY_RUN", "true") == "true"
+
+ if read_only:
+ logger.warning("⚠️ READ_ONLY mode enabled - operation blocked")
+ return {
+ "success": False,
+ "message": "Module is in READ_ONLY mode",
+ "read_only": True
+ }
+
+ if dry_run:
+ logger.info(f"🧪 DRY_RUN: Would create item: {name}")
+ return {
+ "success": True,
+ "dry_run": True,
+ "message": f"DRY_RUN: Item '{name}' would be created"
+ }
+
+ # Opret item
+ item_id = execute_insert(
+ "INSERT INTO test_module_items (name, description) VALUES (%s, %s)",
+ (name, description)
+ )
+
+ logger.info(f"✅ Created item {item_id}: {name}")
+
+ return {
+ "success": True,
+ "item_id": item_id,
+ "name": name
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Error creating item: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.put("/test_module/items/{item_id}")
+async def update_item(item_id: int, name: str = None, description: str = None):
+ """
+ Opdater item
+
+ Args:
+ item_id: Item ID
+ name: Nyt navn (optional)
+ description: Ny beskrivelse (optional)
+
+ Returns:
+ Success status
+ """
+ try:
+ # Check safety switches
+ read_only = get_module_config("test_module", "READ_ONLY", "true") == "true"
+
+ if read_only:
+ logger.warning("⚠️ READ_ONLY mode enabled - operation blocked")
+ return {
+ "success": False,
+ "message": "Module is in READ_ONLY mode"
+ }
+
+ # Build update query dynamically
+ updates = []
+ params = []
+
+ if name is not None:
+ updates.append("name = %s")
+ params.append(name)
+
+ if description is not None:
+ updates.append("description = %s")
+ params.append(description)
+
+ if not updates:
+ raise HTTPException(status_code=400, detail="No fields to update")
+
+ params.append(item_id)
+
+ query = f"UPDATE test_module_items SET {', '.join(updates)} WHERE id = %s"
+ affected = execute_update(query, tuple(params))
+
+ if affected == 0:
+ raise HTTPException(status_code=404, detail="Item not found")
+
+ logger.info(f"✅ Updated item {item_id}")
+
+ return {
+ "success": True,
+ "item_id": item_id,
+ "affected": affected
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error updating item {item_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/test_module/items/{item_id}")
+async def delete_item(item_id: int):
+ """
+ Slet item
+
+ Args:
+ item_id: Item ID
+
+ Returns:
+ Success status
+ """
+ try:
+ # Check safety switches
+ read_only = get_module_config("test_module", "READ_ONLY", "true") == "true"
+
+ if read_only:
+ logger.warning("⚠️ READ_ONLY mode enabled - operation blocked")
+ return {
+ "success": False,
+ "message": "Module is in READ_ONLY mode"
+ }
+
+ affected = execute_update(
+ "DELETE FROM test_module_items WHERE id = %s",
+ (item_id,)
+ )
+
+ if affected == 0:
+ raise HTTPException(status_code=404, detail="Item not found")
+
+ logger.info(f"✅ Deleted item {item_id}")
+
+ return {
+ "success": True,
+ "item_id": item_id
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error deleting item {item_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/test_module/health")
+async def health_check():
+ """
+ Modul health check
+
+ Returns:
+ Health status
+ """
+ try:
+ # Test database connectivity
+ result = execute_query("SELECT 1 as test", fetchone=True)
+
+ return {
+ "status": "healthy",
+ "module": "test_module",
+ "version": "1.0.0",
+ "database": "connected" if result else "error",
+ "config": {
+ "read_only": get_module_config("test_module", "READ_ONLY", "true"),
+ "dry_run": get_module_config("test_module", "DRY_RUN", "true")
+ }
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Health check failed: {e}")
+ return {
+ "status": "unhealthy",
+ "module": "test_module",
+ "error": str(e)
+ }
diff --git a/app/modules/test_module/frontend/__init__.py b/app/modules/test_module/frontend/__init__.py
new file mode 100644
index 0000000..abc5ac0
--- /dev/null
+++ b/app/modules/test_module/frontend/__init__.py
@@ -0,0 +1 @@
+# Frontend package for template module
diff --git a/app/modules/test_module/frontend/views.py b/app/modules/test_module/frontend/views.py
new file mode 100644
index 0000000..2e46d5d
--- /dev/null
+++ b/app/modules/test_module/frontend/views.py
@@ -0,0 +1,52 @@
+"""
+Test Module Module - Frontend Views
+HTML view routes for template module
+"""
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+import logging
+
+from app.core.database import execute_query
+
+logger = logging.getLogger(__name__)
+
+# APIRouter instance (module_loader kigger efter denne)
+router = APIRouter()
+
+# Templates til dette modul (relativ til module root)
+templates = Jinja2Templates(directory="app/modules/test_module/test_modules")
+
+
+@router.get("/test_module", response_class=HTMLResponse)
+async def template_page(request: Request):
+ """
+ Template module hovedside
+
+ Args:
+ request: FastAPI request object
+
+ Returns:
+ HTML response
+ """
+ try:
+ # Hent items til visning
+ items = execute_query(
+ "SELECT * FROM test_module_items ORDER BY created_at DESC LIMIT 10"
+ )
+
+ return templates.TemplateResponse("index.html", {
+ "request": request,
+ "page_title": "Test Module Module",
+ "items": items or []
+ })
+
+ except Exception as e:
+ logger.error(f"❌ Error rendering template page: {e}")
+ return templates.TemplateResponse("index.html", {
+ "request": request,
+ "page_title": "Test Module Module",
+ "error": str(e),
+ "items": []
+ })
diff --git a/app/modules/test_module/migrations/001_init.sql b/app/modules/test_module/migrations/001_init.sql
new file mode 100644
index 0000000..94567a0
--- /dev/null
+++ b/app/modules/test_module/migrations/001_init.sql
@@ -0,0 +1,37 @@
+-- Test Module Module - Initial Migration
+-- Opret basis tabeller for template module
+
+-- Items tabel (eksempel)
+CREATE TABLE IF NOT EXISTS test_module_items (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Index for performance
+CREATE INDEX IF NOT EXISTS idx_test_module_items_active ON test_module_items(active);
+CREATE INDEX IF NOT EXISTS idx_test_module_items_created ON test_module_items(created_at DESC);
+
+-- Trigger for updated_at
+CREATE OR REPLACE FUNCTION update_test_module_items_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_test_module_items_updated_at
+BEFORE UPDATE ON test_module_items
+FOR EACH ROW
+EXECUTE FUNCTION update_test_module_items_updated_at();
+
+-- Indsæt test data (optional)
+INSERT INTO test_module_items (name, description)
+VALUES
+ ('Test Item 1', 'This is a test item from template module'),
+ ('Test Item 2', 'Another test item')
+ON CONFLICT DO NOTHING;
diff --git a/app/modules/test_module/module.json b/app/modules/test_module/module.json
new file mode 100644
index 0000000..7189366
--- /dev/null
+++ b/app/modules/test_module/module.json
@@ -0,0 +1,19 @@
+{
+ "name": "test_module",
+ "version": "1.0.0",
+ "description": "Test modul for demo",
+ "author": "BMC Networks",
+ "enabled": false,
+ "dependencies": [],
+ "table_prefix": "test_module_",
+ "api_prefix": "/api/v1/test_module",
+ "tags": [
+ "Test Module"
+ ],
+ "config": {
+ "safety_switches": {
+ "read_only": true,
+ "dry_run": true
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/modules/test_module/templates/index.html b/app/modules/test_module/templates/index.html
new file mode 100644
index 0000000..80ecb1b
--- /dev/null
+++ b/app/modules/test_module/templates/index.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+
{{ page_title }} - BMC Hub
+
+
+
+
+
{{ page_title }}
+
+ {% if error %}
+
+ Error: {{ error }}
+
+ {% endif %}
+
+
+
+
+ {% if items %}
+
+
+
+ | ID |
+ Name |
+ Description |
+ Created |
+
+
+
+ {% for item in items %}
+
+ | {{ item.id }} |
+ {{ item.name }} |
+ {{ item.description or '-' }} |
+ {{ item.created_at }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
No items found. This is a template module.
+ {% endif %}
+
+
+
+
+
+
+
+
+
diff --git a/app/services/simplycrm_service.py b/app/services/simplycrm_service.py
new file mode 100644
index 0000000..cd44634
--- /dev/null
+++ b/app/services/simplycrm_service.py
@@ -0,0 +1,522 @@
+"""
+Simply-CRM Integration Service
+Sync abonnementer, fakturaer og kunder fra Simply-CRM (VTiger fork)
+
+Simply-CRM bruger webservice.php endpoint med challenge-token authentication:
+- Endpoint: /webservice.php
+- Auth: getchallenge + login med MD5 hash
+- Moduler: Accounts, Invoice, Products, SalesOrder
+"""
+import logging
+import json
+import hashlib
+import aiohttp
+from typing import List, Dict, Optional, Any
+from datetime import datetime, date
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class SimplyCRMService:
+ """Service for integrating with Simply-CRM via webservice.php (VTiger fork)"""
+
+ def __init__(self):
+ # Simply-CRM bruger OLD_VTIGER settings
+ self.base_url = getattr(settings, 'OLD_VTIGER_URL', None)
+ self.username = getattr(settings, 'OLD_VTIGER_USERNAME', None)
+ self.access_key = getattr(settings, 'OLD_VTIGER_ACCESS_KEY', None)
+
+ self.session_name: Optional[str] = None
+ self.session: Optional[aiohttp.ClientSession] = None
+
+ if not all([self.base_url, self.username, self.access_key]):
+ logger.warning("⚠️ Simply-CRM credentials not configured (OLD_VTIGER_* settings)")
+
+ async def __aenter__(self):
+ """Context manager entry - create session and login"""
+ self.session = aiohttp.ClientSession()
+ await self.login()
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ """Context manager exit - close session"""
+ if self.session:
+ await self.session.close()
+ self.session = None
+ self.session_name = None
+
+ async def login(self) -> bool:
+ """
+ Login to Simply-CRM using challenge-token authentication
+
+ Returns:
+ True if login successful
+ """
+ if not self.base_url or not self.username or not self.access_key:
+ logger.error("❌ Simply-CRM credentials not configured")
+ return False
+
+ try:
+ if not self.session:
+ self.session = aiohttp.ClientSession()
+
+ # Step 1: Get challenge token
+ async with self.session.get(
+ f"{self.base_url}/webservice.php",
+ params={"operation": "getchallenge", "username": self.username},
+ timeout=aiohttp.ClientTimeout(total=30)
+ ) as response:
+ if not response.ok:
+ logger.error(f"❌ Simply-CRM challenge request failed: {response.status}")
+ return False
+
+ data = await response.json()
+ if not data.get("success"):
+ logger.error(f"❌ Simply-CRM challenge failed: {data}")
+ return False
+
+ token = data["result"]["token"]
+
+ # Step 2: Generate access key hash
+ access_key_hash = hashlib.md5(f"{token}{self.access_key}".encode()).hexdigest()
+
+ # Step 3: Login
+ async with self.session.post(
+ f"{self.base_url}/webservice.php",
+ data={
+ "operation": "login",
+ "username": self.username,
+ "accessKey": access_key_hash
+ },
+ timeout=aiohttp.ClientTimeout(total=30)
+ ) as response:
+ if not response.ok:
+ logger.error(f"❌ Simply-CRM login request failed: {response.status}")
+ return False
+
+ data = await response.json()
+ if not data.get("success"):
+ logger.error(f"❌ Simply-CRM login failed: {data}")
+ return False
+
+ self.session_name = data["result"]["sessionName"]
+ session_preview = self.session_name[:20] if self.session_name else "unknown"
+ logger.info(f"✅ Simply-CRM login successful (session: {session_preview}...)")
+ return True
+
+ except aiohttp.ClientError as e:
+ logger.error(f"❌ Simply-CRM connection error: {e}")
+ return False
+ except Exception as e:
+ logger.error(f"❌ Simply-CRM login error: {e}")
+ return False
+
+ async def test_connection(self) -> bool:
+ """
+ Test Simply-CRM connection by logging in
+
+ Returns:
+ True if connection successful
+ """
+ try:
+ async with aiohttp.ClientSession() as session:
+ self.session = session
+ result = await self.login()
+ self.session = None
+ return result
+ except Exception as e:
+ logger.error(f"❌ Simply-CRM connection test failed: {e}")
+ return False
+
+ async def _ensure_session(self):
+ """Ensure we have an active session"""
+ if not self.session:
+ self.session = aiohttp.ClientSession()
+ if not self.session_name:
+ await self.login()
+
+ async def query(self, query_string: str) -> List[Dict]:
+ """
+ Execute a query on Simply-CRM
+
+ Args:
+ query_string: SQL-like query (e.g., "SELECT * FROM Accounts LIMIT 100;")
+
+ Returns:
+ List of records
+ """
+ await self._ensure_session()
+
+ if not self.session_name or not self.session:
+ logger.error("❌ Not logged in to Simply-CRM")
+ return []
+
+ try:
+ async with self.session.get(
+ f"{self.base_url}/webservice.php",
+ params={
+ "operation": "query",
+ "sessionName": self.session_name,
+ "query": query_string
+ },
+ timeout=aiohttp.ClientTimeout(total=60)
+ ) as response:
+ if not response.ok:
+ logger.error(f"❌ Simply-CRM query failed: {response.status}")
+ return []
+
+ data = await response.json()
+ if not data.get("success"):
+ error = data.get("error", {})
+ logger.error(f"❌ Simply-CRM query error: {error}")
+ return []
+
+ result = data.get("result", [])
+ logger.debug(f"✅ Simply-CRM query returned {len(result)} records")
+ return result
+
+ except Exception as e:
+ logger.error(f"❌ Simply-CRM query error: {e}")
+ return []
+
+ async def retrieve(self, record_id: str) -> Optional[Dict]:
+ """
+ Retrieve a specific record by ID
+
+ Args:
+ record_id: VTiger-style ID (e.g., "6x12345")
+
+ Returns:
+ Record dict or None
+ """
+ await self._ensure_session()
+
+ if not self.session_name or not self.session:
+ return None
+
+ try:
+ async with self.session.get(
+ f"{self.base_url}/webservice.php",
+ params={
+ "operation": "retrieve",
+ "sessionName": self.session_name,
+ "id": record_id
+ },
+ timeout=aiohttp.ClientTimeout(total=30)
+ ) as response:
+ if not response.ok:
+ return None
+
+ data = await response.json()
+ if data.get("success"):
+ return data.get("result")
+ return None
+ except Exception as e:
+ logger.error(f"❌ Simply-CRM retrieve error: {e}")
+ return None
+
+ # =========================================================================
+ # SUBSCRIPTIONS
+ # =========================================================================
+
+ async def fetch_subscriptions(self, limit: int = 100, offset: int = 0) -> List[Dict]:
+ """
+ Fetch subscriptions from Simply-CRM via recurring SalesOrders
+
+ SalesOrders with enable_recurring='1' are the subscription source in Simply-CRM.
+ """
+ query = f"SELECT * FROM SalesOrder WHERE enable_recurring = '1' LIMIT {offset}, {limit};"
+ return await self.query(query)
+
+ async def fetch_active_subscriptions(self) -> List[Dict]:
+ """
+ Fetch all active recurring SalesOrders (subscriptions)
+
+ Returns deduplicated list of unique SalesOrders.
+ """
+ all_records = []
+ offset = 0
+ limit = 100
+ seen_ids = set()
+
+ while True:
+ query = f"SELECT * FROM SalesOrder WHERE enable_recurring = '1' LIMIT {offset}, {limit};"
+ batch = await self.query(query)
+
+ if not batch:
+ break
+
+ # Deduplicate by id (VTiger returns one row per line item)
+ for record in batch:
+ record_id = record.get('id')
+ if record_id and record_id not in seen_ids:
+ seen_ids.add(record_id)
+ all_records.append(record)
+
+ if len(batch) < limit:
+ break
+
+ offset += limit
+
+ logger.info(f"📊 Fetched {len(all_records)} unique recurring SalesOrders from Simply-CRM")
+ return all_records
+
+ # =========================================================================
+ # INVOICES
+ # =========================================================================
+
+ async def fetch_invoices(self, limit: int = 100, offset: int = 0) -> List[Dict]:
+ """Fetch invoices from Simply-CRM"""
+ query = f"SELECT * FROM Invoice LIMIT {offset}, {limit};"
+ return await self.query(query)
+
+ async def fetch_invoices_by_account(self, account_id: str) -> List[Dict]:
+ """Fetch all invoices for a specific account"""
+ query = f"SELECT * FROM Invoice WHERE account_id = '{account_id}';"
+ return await self.query(query)
+
+ async def fetch_invoice_with_lines(self, invoice_id: str) -> Optional[Dict]:
+ """Fetch invoice with full line item details"""
+ return await self.retrieve(invoice_id)
+
+ async def fetch_recent_invoices(self, days: int = 30) -> List[Dict]:
+ """Fetch invoices from the last N days"""
+ from datetime import datetime, timedelta
+ since_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
+
+ all_invoices = []
+ offset = 0
+ limit = 100
+
+ while True:
+ query = f"SELECT * FROM Invoice WHERE invoicedate >= '{since_date}' LIMIT {offset}, {limit};"
+ batch = await self.query(query)
+
+ if not batch:
+ break
+
+ all_invoices.extend(batch)
+
+ if len(batch) < limit:
+ break
+
+ offset += limit
+
+ logger.info(f"📊 Fetched {len(all_invoices)} invoices from last {days} days")
+ return all_invoices
+
+ # =========================================================================
+ # ACCOUNTS (CUSTOMERS)
+ # =========================================================================
+
+ async def fetch_accounts(self, limit: int = 100, offset: int = 0) -> List[Dict]:
+ """Fetch accounts (customers) from Simply-CRM"""
+ query = f"SELECT * FROM Accounts LIMIT {offset}, {limit};"
+ return await self.query(query)
+
+ async def fetch_account_by_id(self, account_id: str) -> Optional[Dict]:
+ """Fetch account by VTiger ID (e.g., '11x12345')"""
+ return await self.retrieve(account_id)
+
+ async def fetch_account_by_name(self, name: str) -> Optional[Dict]:
+ """Find account by name"""
+ query = f"SELECT * FROM Accounts WHERE accountname = '{name}';"
+ results = await self.query(query)
+ return results[0] if results else None
+
+ async def fetch_account_by_cvr(self, cvr: str) -> Optional[Dict]:
+ """Find account by CVR number"""
+ query = f"SELECT * FROM Accounts WHERE siccode = '{cvr}';"
+ results = await self.query(query)
+
+ if not results:
+ # Try vat_number field
+ query = f"SELECT * FROM Accounts WHERE vat_number = '{cvr}';"
+ results = await self.query(query)
+
+ return results[0] if results else None
+
+ async def fetch_all_accounts(self) -> List[Dict]:
+ """Fetch all accounts (with pagination)"""
+ all_accounts = []
+ offset = 0
+ limit = 100
+
+ while True:
+ batch = await self.fetch_accounts(limit, offset)
+
+ if not batch:
+ break
+
+ all_accounts.extend(batch)
+
+ if len(batch) < limit:
+ break
+
+ offset += limit
+
+ logger.info(f"📊 Fetched {len(all_accounts)} accounts from Simply-CRM")
+ return all_accounts
+
+ # =========================================================================
+ # PRODUCTS
+ # =========================================================================
+
+ async def fetch_products(self, limit: int = 100, offset: int = 0) -> List[Dict]:
+ """Fetch products from Simply-CRM"""
+ query = f"SELECT * FROM Products LIMIT {offset}, {limit};"
+ return await self.query(query)
+
+ async def fetch_product_by_number(self, product_number: str) -> Optional[Dict]:
+ """Find product by product number"""
+ query = f"SELECT * FROM Products WHERE product_no = '{product_number}';"
+ results = await self.query(query)
+ return results[0] if results else None
+
+ # =========================================================================
+ # SYNC HELPERS
+ # =========================================================================
+
+ async def get_modified_since(self, module: str, since_date: str) -> List[Dict]:
+ """Get records modified since a specific date"""
+ all_records = []
+ offset = 0
+ limit = 100
+
+ while True:
+ query = f"SELECT * FROM {module} WHERE modifiedtime >= '{since_date}' LIMIT {offset}, {limit};"
+ batch = await self.query(query)
+
+ if not batch:
+ break
+
+ all_records.extend(batch)
+
+ if len(batch) < limit:
+ break
+
+ offset += limit
+
+ return all_records
+
+ def extract_subscription_data(self, raw_salesorder: Dict) -> Dict:
+ """
+ Extract and normalize subscription data from Simply-CRM SalesOrder format
+
+ SalesOrders with enable_recurring='1' are the subscription source.
+ Key fields:
+ - recurring_frequency: Monthly, Quarterly, Yearly
+ - start_period / end_period: Subscription period
+ - cf_abonnementsperiode_dato: Binding end date
+ - arr: Annual Recurring Revenue
+ - sostatus: Created, Approved, Lukket
+ """
+ return {
+ 'simplycrm_id': raw_salesorder.get('id'),
+ 'salesorder_no': raw_salesorder.get('salesorder_no'),
+ 'name': raw_salesorder.get('subject', ''),
+ 'account_id': raw_salesorder.get('account_id'),
+ 'status': self._map_salesorder_status(raw_salesorder.get('sostatus')),
+ 'start_date': raw_salesorder.get('start_period'),
+ 'end_date': raw_salesorder.get('end_period'),
+ 'binding_end_date': raw_salesorder.get('cf_abonnementsperiode_dato'),
+ 'billing_frequency': self._map_billing_frequency(raw_salesorder.get('recurring_frequency')),
+ 'auto_invoicing': raw_salesorder.get('auto_invoicing'),
+ 'last_recurring_date': raw_salesorder.get('last_recurring_date'),
+ 'total_amount': self._parse_amount(raw_salesorder.get('hdnGrandTotal')),
+ 'subtotal': self._parse_amount(raw_salesorder.get('hdnSubTotal')),
+ 'arr': self._parse_amount(raw_salesorder.get('arr')),
+ 'currency': 'DKK',
+ 'modified_time': raw_salesorder.get('modifiedtime'),
+ 'created_time': raw_salesorder.get('createdtime'),
+ 'eco_order_number': raw_salesorder.get('eco_order_number'),
+ }
+
+ def _map_salesorder_status(self, status: Optional[str]) -> str:
+ """Map Simply-CRM SalesOrder status to OmniSync status"""
+ if not status:
+ return 'active'
+ status_map = {
+ 'Created': 'active',
+ 'Approved': 'active',
+ 'Delivered': 'active',
+ 'Lukket': 'cancelled',
+ 'Cancelled': 'cancelled',
+ }
+ return status_map.get(status, 'active')
+
+ def extract_invoice_data(self, raw_invoice: Dict) -> Dict:
+ """Extract and normalize invoice data from Simply-CRM format"""
+ return {
+ 'simplycrm_id': raw_invoice.get('id'),
+ 'invoice_number': raw_invoice.get('invoice_no'),
+ 'invoice_date': raw_invoice.get('invoicedate'),
+ 'account_id': raw_invoice.get('account_id'),
+ 'status': self._map_invoice_status(raw_invoice.get('invoicestatus')),
+ 'subtotal': self._parse_amount(raw_invoice.get('hdnSubTotal')),
+ 'tax_amount': self._parse_amount(raw_invoice.get('hdnTax')),
+ 'total_amount': self._parse_amount(raw_invoice.get('hdnGrandTotal')),
+ 'currency': raw_invoice.get('currency_id', 'DKK'),
+ 'line_items': raw_invoice.get('LineItems', []),
+ 'subscription_period': raw_invoice.get('cf_subscription_period'),
+ 'is_subscription': raw_invoice.get('invoicestatus') == 'Auto Created',
+ 'modified_time': raw_invoice.get('modifiedtime'),
+ }
+
+ def _map_subscription_status(self, status: Optional[str]) -> str:
+ """Map Simply-CRM subscription status to OmniSync status"""
+ if not status:
+ return 'active'
+ status_map = {
+ 'Active': 'active',
+ 'Cancelled': 'cancelled',
+ 'Expired': 'expired',
+ 'Suspended': 'suspended',
+ 'Pending': 'draft',
+ }
+ return status_map.get(status, 'active')
+
+ def _map_invoice_status(self, status: Optional[str]) -> str:
+ """Map Simply-CRM invoice status"""
+ if not status:
+ return 'active'
+ status_map = {
+ 'Created': 'active',
+ 'Approved': 'active',
+ 'Sent': 'active',
+ 'Paid': 'paid',
+ 'Cancelled': 'cancelled',
+ 'Credit Invoice': 'credited',
+ 'Auto Created': 'active',
+ }
+ return status_map.get(status, 'active')
+
+ def _map_billing_frequency(self, frequency: Optional[str]) -> str:
+ """Map billing frequency to standard format"""
+ if not frequency:
+ return 'monthly'
+ freq_map = {
+ 'Monthly': 'monthly',
+ 'Quarterly': 'quarterly',
+ 'Semi-annually': 'semi_annual',
+ 'Annually': 'yearly',
+ 'Yearly': 'yearly',
+ }
+ return freq_map.get(frequency, 'monthly')
+
+ def _parse_amount(self, value: Any) -> float:
+ """Parse amount from string or number"""
+ if value is None:
+ return 0.0
+ if isinstance(value, (int, float)):
+ return float(value)
+ try:
+ cleaned = str(value).replace(' ', '').replace(',', '.').replace('DKK', '').replace('kr', '')
+ return float(cleaned)
+ except (ValueError, TypeError):
+ return 0.0
+
+
+# Singleton instance
+simplycrm_service = SimplyCRMService()
diff --git a/app/services/vtiger_service.py b/app/services/vtiger_service.py
index b149cc3..2dd94aa 100644
--- a/app/services/vtiger_service.py
+++ b/app/services/vtiger_service.py
@@ -3,6 +3,7 @@ vTiger Cloud CRM Integration Service
Handles subscription and sales order data retrieval
"""
import logging
+import json
import aiohttp
from typing import List, Dict, Optional
from app.core.config import settings
@@ -137,6 +138,75 @@ class VTigerService:
logger.error(f"❌ Error fetching subscriptions: {e}")
return []
+ async def get_subscription(self, subscription_id: str) -> Optional[Dict]:
+ """
+ Fetch full subscription details with LineItems from vTiger
+
+ Args:
+ subscription_id: vTiger subscription ID (e.g., "72x123")
+
+ Returns:
+ Subscription record with LineItems array or None
+ """
+ if not self.rest_endpoint:
+ raise ValueError("VTIGER_URL not configured")
+
+ try:
+ auth = self._get_auth()
+
+ async with aiohttp.ClientSession() as session:
+ url = f"{self.rest_endpoint}/retrieve?id={subscription_id}"
+
+ async with session.get(url, auth=auth) as response:
+ if response.status == 200:
+ text = await response.text()
+ data = json.loads(text)
+ if data.get('success'):
+ logger.info(f"✅ Retrieved subscription {subscription_id}")
+ return data.get('result')
+ else:
+ logger.error(f"❌ vTiger API error: {data.get('error')}")
+ return None
+ else:
+ logger.error(f"❌ HTTP {response.status} retrieving subscription")
+ return None
+
+ except Exception as e:
+ logger.error(f"❌ Error retrieving subscription: {e}")
+ return None
+
+ async def get_account(self, vtiger_account_id: str) -> Optional[Dict]:
+ """
+ Fetch account details from vTiger
+
+ Args:
+ vtiger_account_id: vTiger account ID (e.g., "3x760")
+
+ Returns:
+ Account record with BMC Låst field (cf_accounts_bmclst) or None
+ """
+ if not vtiger_account_id:
+ logger.warning("⚠️ No vTiger account ID provided")
+ return None
+
+ try:
+ # Query for account by ID
+ query = f"SELECT * FROM Accounts WHERE id='{vtiger_account_id}';"
+
+ logger.info(f"🔍 Fetching account details for {vtiger_account_id}")
+ accounts = await self.query(query)
+
+ if accounts:
+ logger.info(f"✅ Found account: {accounts[0].get('accountname', 'Unknown')}")
+ return accounts[0]
+ else:
+ logger.warning(f"⚠️ No account found with ID {vtiger_account_id}")
+ return None
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching account: {e}")
+ return None
+
async def test_connection(self) -> bool:
"""
Test vTiger connection using /me endpoint
@@ -177,6 +247,151 @@ class VTigerService:
except Exception as e:
logger.error(f"❌ vTiger connection error: {e}")
return False
+
+ async def create_subscription(
+ self,
+ account_id: str,
+ subject: str,
+ startdate: str,
+ generateinvoiceevery: str,
+ subscriptionstatus: str = "Active",
+ enddate: Optional[str] = None,
+ products: Optional[List[Dict]] = None
+ ) -> Dict:
+ """
+ Create a new subscription in vTiger
+
+ Args:
+ account_id: vTiger account ID (e.g., "3x123")
+ subject: Subscription subject/name
+ startdate: Start date (YYYY-MM-DD)
+ generateinvoiceevery: Frequency ("Monthly", "Quarterly", "Yearly")
+ subscriptionstatus: Status ("Active", "Cancelled", "Stopped")
+ enddate: End date (YYYY-MM-DD), optional
+ products: List of products [{"productid": "id", "quantity": 1, "listprice": 100}]
+ """
+ if not self.rest_endpoint:
+ raise ValueError("VTIGER_URL not configured")
+
+ try:
+ auth = self._get_auth()
+
+ # Build subscription data
+ subscription_data = {
+ "account_id": account_id,
+ "subject": subject,
+ "startdate": startdate,
+ "generateinvoiceevery": generateinvoiceevery,
+ "subscriptionstatus": subscriptionstatus,
+ }
+
+ if enddate:
+ subscription_data["enddate"] = enddate
+
+ # Add products if provided
+ if products:
+ subscription_data["products"] = products
+
+ async with aiohttp.ClientSession() as session:
+ create_url = f"{self.rest_endpoint}/create"
+
+ payload = {
+ "elementType": "Subscription",
+ "element": subscription_data
+ }
+
+ logger.info(f"📤 Creating subscription: {subject}")
+
+ async with session.post(create_url, json=payload, auth=auth) as response:
+ if response.status == 200:
+ data = await response.json()
+ if data.get("success"):
+ result = data.get("result", {})
+ logger.info(f"✅ Created subscription: {result.get('id')}")
+ return result
+ else:
+ error_msg = data.get("error", {}).get("message", "Unknown error")
+ raise Exception(f"vTiger API error: {error_msg}")
+ else:
+ error_text = await response.text()
+ raise Exception(f"HTTP {response.status}: {error_text}")
+
+ except Exception as e:
+ logger.error(f"❌ Error creating subscription: {e}")
+ raise
+
+ async def update_subscription(self, subscription_id: str, updates: Dict, line_items: List[Dict] = None) -> Dict:
+ """
+ Update a subscription in vTiger with optional line item price updates
+
+ Args:
+ subscription_id: vTiger subscription ID (e.g., "72x123")
+ updates: Dictionary of fields to update (subject, startdate, etc.)
+ line_items: Optional list of line items with updated prices
+ Each item: {"productid": "6x123", "quantity": "3", "listprice": "299.00"}
+ """
+ if not self.rest_endpoint:
+ raise ValueError("VTIGER_URL not configured")
+
+ try:
+ auth = self._get_auth()
+
+ async with aiohttp.ClientSession() as session:
+ # For custom fields (cf_*), we need to retrieve first for context
+ if any(k.startswith('cf_') for k in updates.keys()):
+ logger.info(f"📥 Retrieving subscription {subscription_id} for custom field update")
+ current_sub = await self.get_subscription(subscription_id)
+ if current_sub:
+ # Only include essential fields + custom field update
+ essential_fields = ['id', 'account_id', 'subject', 'startdate',
+ 'generateinvoiceevery', 'subscriptionstatus']
+ element_data = {
+ k: current_sub[k]
+ for k in essential_fields
+ if k in current_sub
+ }
+ element_data.update(updates)
+ element_data['id'] = subscription_id
+ else:
+ element_data = {"id": subscription_id, **updates}
+ else:
+ element_data = {"id": subscription_id, **updates}
+
+ update_url = f"{self.rest_endpoint}/update"
+
+ # Add LineItems if provided
+ if line_items:
+ element_data["LineItems"] = line_items
+ logger.info(f"📝 Updating {len(line_items)} line items")
+
+ payload = {
+ "elementType": "Subscription",
+ "element": element_data
+ }
+
+ logger.info(f"📤 Updating subscription {subscription_id}: {list(updates.keys())}")
+ logger.debug(f"Payload: {json.dumps(payload, indent=2)}")
+
+ async with session.post(update_url, json=payload, auth=auth) as response:
+ if response.status == 200:
+ text = await response.text()
+ data = json.loads(text)
+ if data.get("success"):
+ result = data.get("result", {})
+ logger.info(f"✅ Updated subscription: {subscription_id}")
+ return result
+ else:
+ error_msg = data.get("error", {}).get("message", "Unknown error")
+ logger.error(f"❌ vTiger error response: {data.get('error')}")
+ raise Exception(f"vTiger API error: {error_msg}")
+ else:
+ error_text = await response.text()
+ logger.error(f"❌ vTiger HTTP error: {error_text[:500]}")
+ raise Exception(f"HTTP {response.status}: {error_text}")
+
+ except Exception as e:
+ logger.error(f"❌ Error updating subscription: {e}")
+ raise
# Singleton instance
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index f49799e..9552ef0 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -92,6 +92,9 @@
AI Prompts
+
+ Moduler
+
System
@@ -197,6 +200,226 @@
+
+
+
+
+
+
📦 Modul System
+
Dynamisk feature loading - udvikl moduler isoleret fra core systemet
+
+
+ API
+
+
+
+
+
+
Quick Start
+
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...
+
+
+
+
+
+
+
+
+
+
Safety First
+
+ - ✅ Moduler starter disabled
+ - ✅ READ_ONLY og DRY_RUN defaults
+ - ✅ Error isolation - crashes påvirker ikke core
+ - ✅ Graceful degradation
+
+
+
+
+
+
+
+
Database Isolering
+
+ - ✅ Table prefix pattern (fx
mymod_customers)
+ - ✅ Separate migration tracking
+ - ✅ Helper functions til queries
+ - ✅ Core database uberørt
+
+
+
+
+
+
+
+
+
+
+
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}
+
+
+
+
+
+
+
+
+
+
Quick Start
+
5 minutter guide til at komme i gang
+
docs/MODULE_QUICKSTART.md
+
+
+
Full Guide
+
Komplet reference (6000+ ord)
+
docs/MODULE_SYSTEM.md
+
+
+
Template
+
Working example modul
+
app/modules/_template/
+
+
+
+
+
+
+
+
+
+
+
+
+ - Brug
create_module.py CLI tool
+ - Brug table prefix konsistent
+ - Enable safety switches i development
+ - Test isoleret før enable i production
+ - Log med emoji prefix (🔄 ✅ ❌)
+ - Dokumenter API endpoints
+ - Version moduler semantisk
+
+
+
+
+
+
+
+
+
+ - Skip table prefix
+ - Hardcode credentials
+ - Disable safety uden grund
+ - Tilgå andre modulers tabeller direkte
+ - Glem at køre migrations
+ - Commit
.env files
+ - Enable direkte i production
+
+
+
+
+
+
+
+
@@ -587,10 +810,68 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
loadUsers();
} else if (tab === 'ai-prompts') {
loadAIPrompts();
+ } else if (tab === 'modules') {
+ loadModules();
}
});
});
+// Load modules function
+async function loadModules() {
+ try {
+ const response = await fetch('/api/v1/modules');
+ const data = await response.json();
+
+ const modulesContainer = document.getElementById('activeModules');
+
+ if (!data.modules || Object.keys(data.modules).length === 0) {
+ modulesContainer.innerHTML = `
+
+
+
Ingen aktive moduler fundet
+
Opret dit første modul med python3 scripts/create_module.py
+
+ `;
+ return;
+ }
+
+ const modulesList = Object.values(data.modules).map(module => `
+
+
+
+
+
+ ${module.enabled ? '' : ''}
+ ${module.name}
+ v${module.version}
+
+
${module.description}
+
+ ${module.author}
+ Prefix: ${module.table_prefix}
+ ${module.has_api ? 'API' : ''}
+ ${module.has_frontend ? 'Frontend' : ''}
+
+
+
+
+
+
+ `).join('');
+
+ modulesContainer.innerHTML = modulesList;
+
+ } catch (error) {
+ console.error('Error loading modules:', error);
+ document.getElementById('activeModules').innerHTML =
+ '
Kunne ikke indlæse moduler
';
+ }
+}
+
// Load on page ready
document.addEventListener('DOMContentLoaded', () => {
loadSettings();
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index b2e52a8..2e127f1 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -132,6 +132,34 @@
padding: 0.5rem;
background-color: var(--bg-card);
}
+
+ /* Nested dropdown support - simplified click-based approach */
+ .dropdown-submenu {
+ position: relative;
+ }
+
+ .dropdown-submenu .dropdown-menu {
+ position: absolute;
+ top: 0;
+ left: 100%;
+ margin-left: 0.1rem;
+ margin-top: -0.5rem;
+ display: none;
+ }
+
+ .dropdown-submenu .dropdown-menu.show {
+ display: block;
+ }
+
+ .dropdown-submenu .dropdown-toggle::after {
+ display: none;
+ }
+
+ .dropdown-submenu > a {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
.dropdown-item {
border-radius: 8px;
@@ -145,6 +173,28 @@
background-color: var(--accent-light);
color: var(--accent);
}
+
+ .result-item {
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ margin-bottom: 0.5rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: all 0.2s;
+ border: 1px solid transparent;
+ }
+
+ .result-item:hover {
+ background-color: var(--accent-light);
+ border-color: var(--accent);
+ }
+
+ .result-item.selected {
+ background-color: var(--accent-light);
+ border-color: var(--accent);
+ box-shadow: 0 2px 8px rgba(15, 76, 117, 0.1);
+ }
{% block extra_css %}{% endblock %}
@@ -209,6 +259,19 @@
Abonnementer
Betalinger
+
+
Rapporter
@@ -457,9 +520,38 @@
});
// Global Search Modal (Cmd+K) - Initialize after DOM is ready
+ let selectedResultIndex = -1;
+ let allResults = [];
+
document.addEventListener('DOMContentLoaded', () => {
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
- const searchInput = document.getElementById('globalSearchInput');
+ const globalSearchInput = document.getElementById('globalSearchInput');
+
+ // Search input listener with debounce
+ let searchTimeout;
+ if (globalSearchInput) {
+ globalSearchInput.addEventListener('input', (e) => {
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(() => {
+ selectedResultIndex = -1;
+ performGlobalSearch(e.target.value);
+ }, 300);
+ });
+
+ // Keyboard navigation
+ globalSearchInput.addEventListener('keydown', (e) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ navigateResults(1);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ navigateResults(-1);
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ selectCurrentResult();
+ }
+ });
+ }
// Keyboard shortcut: Cmd+K or Ctrl+K
document.addEventListener('keydown', (e) => {
@@ -468,7 +560,9 @@
console.log('Cmd+K pressed - opening search modal'); // Debug
searchModal.show();
setTimeout(() => {
- searchInput.focus();
+ if (globalSearchInput) {
+ globalSearchInput.focus();
+ }
loadLiveStats();
loadRecentActivity();
}, 300);
@@ -482,7 +576,9 @@
// Reset search when modal is closed
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
- searchInput.value = '';
+ if (globalSearchInput) {
+ globalSearchInput.value = '';
+ }
selectedEntity = null;
document.getElementById('emptyState').style.display = 'block';
document.getElementById('workflowActions').style.display = 'none';
@@ -599,9 +695,212 @@
return 'Lige nu';
}
- let searchTimeout;
+ // Helper function to escape HTML
+ function escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
let selectedEntity = null;
+ // Navigate through search results
+ function navigateResults(direction) {
+ const resultItems = document.querySelectorAll('.result-item');
+ allResults = Array.from(resultItems);
+
+ if (allResults.length === 0) return;
+
+ // Remove previous selection
+ if (selectedResultIndex >= 0 && allResults[selectedResultIndex]) {
+ allResults[selectedResultIndex].classList.remove('selected');
+ }
+
+ // Update index
+ selectedResultIndex += direction;
+
+ // Wrap around
+ if (selectedResultIndex < 0) {
+ selectedResultIndex = allResults.length - 1;
+ } else if (selectedResultIndex >= allResults.length) {
+ selectedResultIndex = 0;
+ }
+
+ // Add selection
+ if (allResults[selectedResultIndex]) {
+ allResults[selectedResultIndex].classList.add('selected');
+ allResults[selectedResultIndex].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+ }
+ }
+
+ // Select current result and navigate
+ function selectCurrentResult() {
+ if (selectedResultIndex >= 0 && allResults[selectedResultIndex]) {
+ allResults[selectedResultIndex].click();
+ } else if (allResults.length > 0) {
+ // No selection, select first result
+ allResults[0].click();
+ }
+ }
+
+ // Global search function
+ async function performGlobalSearch(query) {
+ if (!query || query.trim().length < 2) {
+ document.getElementById('emptyState').style.display = 'block';
+ 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';
+ return;
+ }
+
+ console.log('🔍 Performing global search:', query);
+ document.getElementById('emptyState').style.display = 'none';
+
+ let hasResults = false;
+
+ try {
+ // Search customers
+ const customerResponse = await fetch(`/api/v1/customers?search=${encodeURIComponent(query)}&limit=5`);
+ const customerData = await customerResponse.json();
+
+ const crmResults = document.getElementById('crmResults');
+ if (customerData.customers && customerData.customers.length > 0) {
+ hasResults = true;
+ crmResults.style.display = 'block';
+ const resultsList = crmResults.querySelector('.result-items');
+ if (resultsList) {
+ resultsList.innerHTML = customerData.customers.map(customer => `
+
+
+
${escapeHtml(customer.name)}
+
+ Kunde
+ ${customer.cvr_number ? ` • CVR: ${customer.cvr_number}` : ''}
+ ${customer.email ? ` • ${customer.email}` : ''}
+
+
+
+
+ `).join('');
+ }
+ } else {
+ crmResults.style.display = 'none';
+ }
+
+ // Search contacts
+ try {
+ const contactsResponse = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=5`);
+ const contactsData = await contactsResponse.json();
+
+ if (contactsData.contacts && contactsData.contacts.length > 0) {
+ hasResults = true;
+ const supportResults = document.getElementById('supportResults');
+ supportResults.style.display = 'block';
+ const supportList = supportResults.querySelector('.result-items');
+ if (supportList) {
+ supportList.innerHTML = contactsData.contacts.map(contact => `
+
+
+
${escapeHtml(contact.first_name)} ${escapeHtml(contact.last_name)}
+
+ Kontakt
+ ${contact.email ? ` • ${contact.email}` : ''}
+ ${contact.title ? ` • ${contact.title}` : ''}
+
+
+
+
+ `).join('');
+ }
+ } else {
+ document.getElementById('supportResults').style.display = 'none';
+ }
+ } catch (e) {
+ console.log('Contacts search not available');
+ }
+
+ // Search hardware
+ try {
+ const hardwareResponse = await fetch(`/api/v1/hardware?search=${encodeURIComponent(query)}&limit=5`);
+ const hardwareData = await hardwareResponse.json();
+
+ if (hardwareData.hardware && hardwareData.hardware.length > 0) {
+ hasResults = true;
+ const salesResults = document.getElementById('salesResults');
+ if (salesResults) {
+ salesResults.style.display = 'block';
+ const salesList = salesResults.querySelector('.result-items');
+ if (salesList) {
+ salesList.innerHTML = hardwareData.hardware.map(hw => `
+
+
+
${escapeHtml(hw.serial_number || hw.name)}
+
+ Hardware
+ ${hw.type ? ` • ${hw.type}` : ''}
+ ${hw.customer_name ? ` • ${hw.customer_name}` : ''}
+
+
+
+
+ `).join('');
+ }
+ }
+ }
+ } catch (e) {
+ console.log('Hardware search not available');
+ }
+
+ // Search vendors
+ try {
+ const vendorsResponse = await fetch(`/api/v1/vendors?search=${encodeURIComponent(query)}&limit=5`);
+ const vendorsData = await vendorsResponse.json();
+
+ if (vendorsData.vendors && vendorsData.vendors.length > 0) {
+ hasResults = true;
+ const financeResults = document.getElementById('financeResults');
+ if (financeResults) {
+ financeResults.style.display = 'block';
+ const financeList = financeResults.querySelector('.result-items');
+ if (financeList) {
+ financeList.innerHTML = vendorsData.vendors.map(vendor => `
+
+
+
${escapeHtml(vendor.name)}
+
+ Leverandør
+ ${vendor.cvr_number ? ` • CVR: ${vendor.cvr_number}` : ''}
+ ${vendor.email ? ` • ${vendor.email}` : ''}
+
+
+
+
+ `).join('');
+ }
+ }
+ }
+ } catch (e) {
+ console.log('Vendors search not available');
+ }
+
+ // Show empty state if no results
+ if (!hasResults) {
+ document.getElementById('emptyState').style.display = 'block';
+ document.getElementById('emptyState').innerHTML = `
+
+
+
Ingen resultater for "${escapeHtml(query)}"
+
+ `;
+ }
+
+ } catch (error) {
+ console.error('Search error:', error);
+ }
+ }
+
// Workflow definitions per entity type
const workflows = {
customer: [
@@ -668,88 +967,7 @@
}
};
- // Search function
- searchInput.addEventListener('input', (e) => {
- const query = e.target.value.trim();
-
- clearTimeout(searchTimeout);
-
- // Reset empty state text
- const emptyState = document.getElementById('emptyState');
- emptyState.innerHTML = `
-
-
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 : ''}
-
-
-
-
-
- `).join('');
-
- // Auto-select first result
- if (allResults.length > 0) {
- showWorkflows(allResults[0].entityType, allResults[0]);
- }
- } else {
- crmSection.style.display = 'none';
- emptyState.style.display = 'block';
- emptyState.innerHTML = `
-
-
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 %}
-
-
+
+{% 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 @@
-
-
-