feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items. - Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases. - Added JavaScript functions for loading and rendering order data dynamically. - Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality. - Developed an email templates API for managing system and customer-specific email templates. - Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods. - Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
This commit is contained in:
parent
d5dd958bf9
commit
56d6d45aa2
18
.env.example
18
.env.example
@ -22,6 +22,14 @@ ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Dock
|
|||||||
SECRET_KEY=change-this-in-production-use-random-string
|
SECRET_KEY=change-this-in-production-use-random-string
|
||||||
CORS_ORIGINS=http://localhost:8000,http://localhost:3000
|
CORS_ORIGINS=http://localhost:8000,http://localhost:3000
|
||||||
|
|
||||||
|
# Shadow Admin (Emergency Access)
|
||||||
|
SHADOW_ADMIN_ENABLED=false
|
||||||
|
SHADOW_ADMIN_USERNAME=shadowadmin
|
||||||
|
SHADOW_ADMIN_PASSWORD=
|
||||||
|
SHADOW_ADMIN_TOTP_SECRET=
|
||||||
|
SHADOW_ADMIN_EMAIL=shadowadmin@bmcnetworks.dk
|
||||||
|
SHADOW_ADMIN_FULL_NAME=Shadow Administrator
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# LOGGING
|
# LOGGING
|
||||||
# =====================================================
|
# =====================================================
|
||||||
@ -45,6 +53,16 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
|
|||||||
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
||||||
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
||||||
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
||||||
|
# =====================================================
|
||||||
|
# Nextcloud Integration (Optional)
|
||||||
|
# =====================================================
|
||||||
|
NEXTCLOUD_READ_ONLY=true
|
||||||
|
NEXTCLOUD_DRY_RUN=true
|
||||||
|
NEXTCLOUD_TIMEOUT_SECONDS=15
|
||||||
|
NEXTCLOUD_CACHE_TTL_SECONDS=300
|
||||||
|
# Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
NEXTCLOUD_ENCRYPTION_KEY=
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# vTiger Cloud Integration (Required for Subscriptions)
|
# vTiger Cloud Integration (Required for Subscriptions)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|||||||
29
.github/agents/Planning with subagents.agent.md
vendored
29
.github/agents/Planning with subagents.agent.md
vendored
@ -1,5 +1,28 @@
|
|||||||
---
|
---
|
||||||
description: 'Describe what this custom agent does and when to use it.'
|
name: hub-sales-and-aggregation-agent
|
||||||
tools: []
|
|
||||||
|
description: "Planlægger og specificerer varekøb og salg i BMC Hub som en simpel sag-baseret funktion, inklusiv aggregering af varer og tid op gennem sagstræet."
|
||||||
|
|
||||||
|
scope:
|
||||||
|
- Sag-modul
|
||||||
|
- Vare- og ydelsessalg
|
||||||
|
- Aggregering i sagstræ
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- Ingen ERP-kompleksitet
|
||||||
|
- Ingen lagerstyring
|
||||||
|
- Ingen selvstændig ordre-entitet i v1
|
||||||
|
- Alt salg er knyttet til en Sag
|
||||||
|
- Aggregering er læsevisning, ikke datakopiering
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
- Eksisterende Sag-model med parent/child-relationer
|
||||||
|
- Eksisterende Tidsmodul
|
||||||
|
- Varekatalog (internt og leverandørvarer)
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
- Datamodelforslag
|
||||||
|
- UI-struktur for Varer-fanen
|
||||||
|
- Aggregeringslogik
|
||||||
|
- Faktureringsforberedelse
|
||||||
---
|
---
|
||||||
Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help.
|
|
||||||
241
NEXTCLOUD_MODULE_PLAN.md
Normal file
241
NEXTCLOUD_MODULE_PLAN.md
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
# Nextcloud-modul – BMC Hub
|
||||||
|
|
||||||
|
## 1. Formål og rolle i Hubben
|
||||||
|
Nextcloud-modulet gør det muligt at sælge, administrere og supportere kunders Nextcloud‑løsninger direkte i Hubben.
|
||||||
|
|
||||||
|
Hubben er styrende system. Nextcloud er et eksternt drifts‑ og brugersystem, som Hubben taler med direkte (ingen gateway).
|
||||||
|
|
||||||
|
## 2. Aktivering af modulet
|
||||||
|
Modulet er kontekstbaseret og aktiveres via tag:
|
||||||
|
|
||||||
|
- Når Firma, Kontakt eller Sag har tagget `nextcloud`, vises en Nextcloud‑fane i UI.
|
||||||
|
- Uden tag vises ingen Nextcloud‑funktioner.
|
||||||
|
|
||||||
|
## 3. Kunde → Nextcloud‑fane (overblik)
|
||||||
|
Fanen indeholder:
|
||||||
|
1. Drifts‑ og systeminformation (read‑only)
|
||||||
|
2. Handlinger relateret til brugere
|
||||||
|
3. Historik (hvad Hubben har gjort mod instansen)
|
||||||
|
|
||||||
|
Fanen må aldrig blokere kundevisningen, selv hvis Nextcloud er utilgængelig.
|
||||||
|
|
||||||
|
## 4. Systemstatus og driftsinformation
|
||||||
|
**Datakilde**: Nextcloud Serverinfo API
|
||||||
|
|
||||||
|
- `GET /ocs/v2.php/apps/serverinfo/api/v1/info`
|
||||||
|
- Direkte kald til Nextcloud
|
||||||
|
- Autentificeret
|
||||||
|
- Read‑only
|
||||||
|
- Cached i DB med global TTL = 5 min
|
||||||
|
|
||||||
|
### 4.1 Overblik
|
||||||
|
Vises øverst i fanen:
|
||||||
|
- Instans‑status (Online / Offline / Ukendt)
|
||||||
|
- Sidst opdateret
|
||||||
|
- Nextcloud‑version
|
||||||
|
- PHP‑version
|
||||||
|
- Database‑type og ‑version
|
||||||
|
|
||||||
|
### 4.2 Ressourceforbrug
|
||||||
|
Vises som simple værdier/badges:
|
||||||
|
- CPU
|
||||||
|
- Load average (1 / 5 / 15 min)
|
||||||
|
- Antal kerner
|
||||||
|
- RAM (total + brug i %)
|
||||||
|
- Disk (total + brug i % + fri plads)
|
||||||
|
|
||||||
|
Ved kritiske værdier vises advarsel.
|
||||||
|
|
||||||
|
### 4.3 Nextcloud‑nøgletal
|
||||||
|
Hvor API tillader det:
|
||||||
|
- Antal brugere
|
||||||
|
- Aktive brugere
|
||||||
|
- Antal filer
|
||||||
|
- Samlet datamængde
|
||||||
|
- Status på: database, cache/Redis, cron/background jobs
|
||||||
|
|
||||||
|
## 5. Handlinger i Nextcloud‑fanen
|
||||||
|
Knapper:
|
||||||
|
- Tilføj ny bruger
|
||||||
|
- Reset password
|
||||||
|
- Luk bruger
|
||||||
|
- Gensend guide
|
||||||
|
|
||||||
|
Alle handlinger:
|
||||||
|
- udføres direkte mod Nextcloud
|
||||||
|
- logges i Hub
|
||||||
|
- kan spores i historik
|
||||||
|
- kan knyttes til sag
|
||||||
|
|
||||||
|
## 6. Tilføj ny bruger (primær funktion)
|
||||||
|
|
||||||
|
### 6.1 Start af flow
|
||||||
|
- Ved “Tilføj ny bruger” oprettes automatisk en ny Sag
|
||||||
|
- Sagstype: **Nextcloud – Brugeroprettelse**
|
||||||
|
- Ingen Nextcloud‑handling udføres uden en sag
|
||||||
|
|
||||||
|
### 6.2 Sag – felter og logik
|
||||||
|
**Firma**
|
||||||
|
- Vælg eksisterende firma
|
||||||
|
- Hub slår tilknyttet Nextcloud‑instans op i DB og vælger automatisk
|
||||||
|
- Instans kan ikke ændres manuelt
|
||||||
|
|
||||||
|
**Kontaktperson**
|
||||||
|
- Vælg eksisterende kontakt eller opret ny
|
||||||
|
- Bruges til kommunikation, velkomstmail og ejerskab af sag
|
||||||
|
|
||||||
|
**Grupper**
|
||||||
|
- Multiselect
|
||||||
|
- Hentes live fra Nextcloud (OCS groups API)
|
||||||
|
- Kun gyldige grupper kan vælges
|
||||||
|
|
||||||
|
**Velkomstbrev**
|
||||||
|
- Checkbox: skal velkomstbrev sendes?
|
||||||
|
- Hvis ja: bruger oprettes, password genereres, guide + logininfo sendes
|
||||||
|
- Hvis nej: bruger oprettes uden mail, sag forbliver åben til manuel opfølgning
|
||||||
|
|
||||||
|
## 7. Øvrige handlinger
|
||||||
|
|
||||||
|
**Reset password**
|
||||||
|
- Vælg eksisterende Nextcloud‑bruger
|
||||||
|
- Nyt password genereres
|
||||||
|
- Valg: send mail til kontakt eller kun log i sag
|
||||||
|
|
||||||
|
**Luk bruger**
|
||||||
|
- Bruger deaktiveres i Nextcloud
|
||||||
|
- Data bevares
|
||||||
|
- Kræver eksplicit bekræftelse
|
||||||
|
- Logges i sag og historik
|
||||||
|
|
||||||
|
**Gensend guide**
|
||||||
|
- Gensender velkomstmail og guide
|
||||||
|
- Password ændres ikke
|
||||||
|
- Kan udføres uden ny sag, men logges
|
||||||
|
|
||||||
|
## 8. Arkitekturprincipper
|
||||||
|
- Hub ejer: firma, kontakt, sag, historik
|
||||||
|
- Nextcloud ejer: brugere, filer, rettigheder
|
||||||
|
- Integration er direkte (ingen gateway)
|
||||||
|
- Per‑instans auth ligger krypteret i DB
|
||||||
|
- Global DB‑cache (5 min) for read‑only statusdata
|
||||||
|
|
||||||
|
## 9. Logning og sporbarhed
|
||||||
|
For hver handling gemmes:
|
||||||
|
- tidspunkt
|
||||||
|
- handlingstype
|
||||||
|
- udførende bruger
|
||||||
|
- mål (bruger/instans)
|
||||||
|
- teknisk resultat (success/fejl)
|
||||||
|
|
||||||
|
Audit‑log er **separat pr. kunde**, med **manuel retention** og **tidsbaseret partitionering**.
|
||||||
|
|
||||||
|
## 10. Afgrænsninger (v1)
|
||||||
|
Modulet indeholder ikke:
|
||||||
|
- ændring af server‑konfiguration
|
||||||
|
- håndtering af apps
|
||||||
|
- ændring af kvoter
|
||||||
|
- direkte admin‑login
|
||||||
|
|
||||||
|
## 11. Klar til udvidelse
|
||||||
|
Modulet er designet til senere udvidelser:
|
||||||
|
- overvågning → automatisk sag
|
||||||
|
- historiske grafer
|
||||||
|
- offboarding‑flows
|
||||||
|
- kvote‑styring
|
||||||
|
- SLA‑rapportering
|
||||||
|
|
||||||
|
## 12. Sikkerhed og drift
|
||||||
|
- Credentials krypteres med `settings.NEXTCLOUD_ENCRYPTION_KEY`
|
||||||
|
- Safety switches: `NEXTCLOUD_READ_ONLY` og `NEXTCLOUD_DRY_RUN` (default true)
|
||||||
|
- Ingen credentials i UI eller logs
|
||||||
|
- TLS‑only base URLs
|
||||||
|
|
||||||
|
## 13. Backend‑struktur (plan)
|
||||||
|
Placering: `app/modules/nextcloud/`
|
||||||
|
- `backend/router.py`
|
||||||
|
- `backend/service.py`
|
||||||
|
- `backend/models.py`
|
||||||
|
|
||||||
|
Alle eksterne kald går via service‑laget, som:
|
||||||
|
- loader instans fra DB
|
||||||
|
- dekrypterer credentials
|
||||||
|
- bruger global DB‑cache (5 min)
|
||||||
|
- skriver audit‑log pr. kunde
|
||||||
|
|
||||||
|
## 14. Database‑model (plan)
|
||||||
|
|
||||||
|
### `nextcloud_instances`
|
||||||
|
- `customer_id` FK
|
||||||
|
- `base_url`
|
||||||
|
- `auth_type`
|
||||||
|
- `username`
|
||||||
|
- `password_encrypted`
|
||||||
|
- `is_enabled`, `disabled_at`
|
||||||
|
- `created_at`, `updated_at`, `deleted_at`
|
||||||
|
|
||||||
|
### `nextcloud_cache`
|
||||||
|
- `cache_key` (PK)
|
||||||
|
- `payload` (JSONB)
|
||||||
|
- `expires_at`
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
|
### `nextcloud_audit_log`
|
||||||
|
- `customer_id`, `instance_id`
|
||||||
|
- `event_type`
|
||||||
|
- `request_meta`, `response_meta`
|
||||||
|
- `actor_user_id`
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
|
Partitionering: månedlig range på `created_at`. Retention er manuel via admin‑UI.
|
||||||
|
|
||||||
|
## 15. API‑endpoints (v1)
|
||||||
|
|
||||||
|
### Instanser (admin)
|
||||||
|
- `GET /api/v1/nextcloud/instances`
|
||||||
|
- `POST /api/v1/nextcloud/instances`
|
||||||
|
- `PATCH /api/v1/nextcloud/instances/{id}`
|
||||||
|
- `POST /api/v1/nextcloud/instances/{id}/disable`
|
||||||
|
- `POST /api/v1/nextcloud/instances/{id}/enable`
|
||||||
|
- `POST /api/v1/nextcloud/instances/{id}/rotate-credentials`
|
||||||
|
|
||||||
|
### Status + grupper
|
||||||
|
- `GET /api/v1/nextcloud/instances/{id}/status`
|
||||||
|
- `GET /api/v1/nextcloud/instances/{id}/groups`
|
||||||
|
|
||||||
|
### Brugere (handlinger)
|
||||||
|
- `POST /api/v1/nextcloud/instances/{id}/users` (opret)
|
||||||
|
- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/reset-password`
|
||||||
|
- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/disable`
|
||||||
|
- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/resend-guide`
|
||||||
|
|
||||||
|
Alle endpoints skal:
|
||||||
|
- validere `is_enabled = true`
|
||||||
|
- håndhæve kundeejerskab
|
||||||
|
- skrive audit‑log
|
||||||
|
- respektere `READ_ONLY`/`DRY_RUN`
|
||||||
|
|
||||||
|
## 16. UI‑krav (plan)
|
||||||
|
Nextcloud‑fanen i kundevisning skal vise:
|
||||||
|
- Systemstatus
|
||||||
|
- Nøgletal
|
||||||
|
- Handlinger
|
||||||
|
- Historik
|
||||||
|
|
||||||
|
Admin‑UI (Settings) skal give:
|
||||||
|
- Liste over instanser
|
||||||
|
- Enable/disable
|
||||||
|
- Rotation af credentials
|
||||||
|
- Retentionstyring af audit‑log pr. kunde
|
||||||
|
|
||||||
|
## 17. Migrations (plan)
|
||||||
|
1. `migrations/0XX_nextcloud_instances.sql`
|
||||||
|
2. `migrations/0XX_nextcloud_cache.sql`
|
||||||
|
3. `migrations/0XX_nextcloud_audit_log.sql` (partitioneret)
|
||||||
|
|
||||||
|
## 18. Næste skridt
|
||||||
|
1. Opret migrationsfiler
|
||||||
|
2. Implementer kryptering helper
|
||||||
|
3. Implementer service‑lag
|
||||||
|
4. Implementer routere og schemas
|
||||||
|
5. Implementer UI‑fanen + admin‑UI
|
||||||
|
6. Implementer audit‑log viewer/export
|
||||||
50
app/apply_migration_084.py
Normal file
50
app/apply_migration_084.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
from app.core.database import get_db_connection, release_db_connection, init_db
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def run_migration():
|
||||||
|
init_db() # Initialize the pool
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# Files linked to a Case
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_files (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
content_type VARCHAR(100),
|
||||||
|
size_bytes INTEGER,
|
||||||
|
stored_name TEXT NOT NULL,
|
||||||
|
uploaded_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sag_files_sag_id ON sag_files(sag_id);")
|
||||||
|
cursor.execute("COMMENT ON TABLE sag_files IS 'Files uploaded directly to the Case.';")
|
||||||
|
|
||||||
|
# Emails linked to a Case (Many-to-Many)
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_emails (
|
||||||
|
sag_id INTEGER REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
email_id INTEGER REFERENCES email_messages(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (sag_id, email_id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("COMMENT ON TABLE sag_emails IS 'Emails linked to the Case.';")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Migration 084 applied successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error(f"Migration failed: {e}")
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_migration()
|
||||||
69
app/apply_migration_085.py
Normal file
69
app/apply_migration_085.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Ensure we can import app modules
|
||||||
|
sys.path.append("/app")
|
||||||
|
|
||||||
|
from app.core.database import execute_query, init_db
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SQL_MIGRATION = """
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_solutions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
solution_type VARCHAR(50),
|
||||||
|
result VARCHAR(50),
|
||||||
|
created_by_user_id INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_sag_solutions_sag_id UNIQUE (sag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS solution_id INTEGER REFERENCES sag_solutions(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE tmodule_times ALTER COLUMN vtiger_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE tmodule_times ALTER COLUMN case_id DROP NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_solutions_sag_id ON sag_solutions(sag_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_times_solution_id ON tmodule_times(solution_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_times_sag_id ON tmodule_times(sag_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
def run_migration():
|
||||||
|
logger.info("Initializing DB connection...")
|
||||||
|
try:
|
||||||
|
init_db()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to init db: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Applying migration 085...")
|
||||||
|
|
||||||
|
commands = [cmd.strip() for cmd in SQL_MIGRATION.split(";") if cmd.strip()]
|
||||||
|
|
||||||
|
for cmd in commands:
|
||||||
|
# Skip empty lines or pure comments
|
||||||
|
if not cmd or cmd.startswith("--"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Executing: {cmd[:50]}...")
|
||||||
|
try:
|
||||||
|
execute_query(cmd, ())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error executing command: {e}")
|
||||||
|
|
||||||
|
logger.info("✅ Migration applied successfully")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_migration()
|
||||||
150
app/auth/backend/admin.py
Normal file
150
app/auth/backend/admin.py
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Auth Admin API - Users, Groups, Permissions management
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Depends
|
||||||
|
from app.core.auth_dependencies import require_superadmin
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
|
||||||
|
from app.models.schemas import UserAdminCreate, UserGroupsUpdate, GroupCreate, GroupPermissionsUpdate
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users", dependencies=[Depends(require_superadmin)])
|
||||||
|
async def list_users():
|
||||||
|
users = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT u.user_id, u.username, u.email, u.full_name,
|
||||||
|
u.is_active, u.is_superadmin, u.is_2fa_enabled,
|
||||||
|
COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_groups ug ON u.user_id = ug.user_id
|
||||||
|
LEFT JOIN groups g ON ug.group_id = g.id
|
||||||
|
GROUP BY u.user_id
|
||||||
|
ORDER BY u.user_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/users", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superadmin)])
|
||||||
|
async def create_user(payload: UserAdminCreate):
|
||||||
|
existing = execute_query_single(
|
||||||
|
"SELECT user_id FROM users WHERE username = %s OR email = %s",
|
||||||
|
(payload.username, payload.email)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Username or email already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
password_hash = AuthService.hash_password(payload.password)
|
||||||
|
user_id = execute_insert(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (username, email, password_hash, full_name, is_superadmin, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s) RETURNING user_id
|
||||||
|
""",
|
||||||
|
(payload.username, payload.email, password_hash, payload.full_name, payload.is_superadmin, payload.is_active)
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.group_ids:
|
||||||
|
for group_id in payload.group_ids:
|
||||||
|
execute_insert(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_groups (user_id, group_id)
|
||||||
|
VALUES (%s, %s) ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
(user_id, group_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✅ User created via admin: %s (ID: %s)", payload.username, user_id)
|
||||||
|
return {"user_id": user_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/users/{user_id}/groups", dependencies=[Depends(require_superadmin)])
|
||||||
|
async def update_user_groups(user_id: int, payload: UserGroupsUpdate):
|
||||||
|
user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
|
||||||
|
execute_update("DELETE FROM user_groups WHERE user_id = %s", (user_id,))
|
||||||
|
|
||||||
|
for group_id in payload.group_ids:
|
||||||
|
execute_insert(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_groups (user_id, group_id)
|
||||||
|
VALUES (%s, %s) ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
(user_id, group_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "Groups updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/groups", dependencies=[Depends(require_superadmin)])
|
||||||
|
async def list_groups():
|
||||||
|
groups = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT g.id, g.name, g.description,
|
||||||
|
COALESCE(array_remove(array_agg(p.code), NULL), ARRAY[]::varchar[]) AS permissions
|
||||||
|
FROM groups g
|
||||||
|
LEFT JOIN group_permissions gp ON g.id = gp.group_id
|
||||||
|
LEFT JOIN permissions p ON gp.permission_id = p.id
|
||||||
|
GROUP BY g.id
|
||||||
|
ORDER BY g.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/groups", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superadmin)])
|
||||||
|
async def create_group(payload: GroupCreate):
|
||||||
|
existing = execute_query_single("SELECT id FROM groups WHERE name = %s", (payload.name,))
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Group already exists")
|
||||||
|
|
||||||
|
group_id = execute_insert(
|
||||||
|
"""
|
||||||
|
INSERT INTO groups (name, description)
|
||||||
|
VALUES (%s, %s) RETURNING id
|
||||||
|
""",
|
||||||
|
(payload.name, payload.description)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"group_id": group_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/admin/groups/{group_id}/permissions", dependencies=[Depends(require_superadmin)])
|
||||||
|
async def update_group_permissions(group_id: int, payload: GroupPermissionsUpdate):
|
||||||
|
group = execute_query_single("SELECT id FROM groups WHERE id = %s", (group_id,))
|
||||||
|
if not group:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
|
||||||
|
|
||||||
|
execute_update("DELETE FROM group_permissions WHERE group_id = %s", (group_id,))
|
||||||
|
|
||||||
|
for permission_id in payload.permission_ids:
|
||||||
|
execute_insert(
|
||||||
|
"""
|
||||||
|
INSERT INTO group_permissions (group_id, permission_id)
|
||||||
|
VALUES (%s, %s) ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
(group_id, permission_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "Permissions updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/permissions", dependencies=[Depends(require_superadmin)])
|
||||||
|
async def list_permissions():
|
||||||
|
permissions = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, code, description, category
|
||||||
|
FROM permissions
|
||||||
|
ORDER BY category, code
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return permissions
|
||||||
@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Auth API Router - Login, Logout, Me endpoints
|
Auth API Router - Login, Logout, Me endpoints
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, status, Request, Depends
|
from fastapi import APIRouter, HTTPException, status, Request, Depends, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
from app.core.auth_dependencies import get_current_user
|
from app.core.auth_dependencies import get_current_user
|
||||||
import logging
|
import logging
|
||||||
@ -15,6 +16,7 @@ router = APIRouter()
|
|||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
otp_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
class LoginResponse(BaseModel):
|
||||||
@ -27,18 +29,30 @@ class LogoutRequest(BaseModel):
|
|||||||
token_jti: str
|
token_jti: str
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorCodeRequest(BaseModel):
|
||||||
|
otp_code: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(request: Request, credentials: LoginRequest):
|
async def login(request: Request, credentials: LoginRequest, response: Response):
|
||||||
"""
|
"""
|
||||||
Authenticate user and return JWT token
|
Authenticate user and return JWT token
|
||||||
"""
|
"""
|
||||||
ip_address = request.client.host if request.client else None
|
ip_address = request.client.host if request.client else None
|
||||||
|
|
||||||
# Authenticate user
|
# Authenticate user
|
||||||
user = AuthService.authenticate_user(
|
user, error_detail = AuthService.authenticate_user(
|
||||||
username=credentials.username,
|
username=credentials.username,
|
||||||
password=credentials.password,
|
password=credentials.password,
|
||||||
ip_address=ip_address
|
ip_address=ip_address,
|
||||||
|
otp_code=credentials.otp_code
|
||||||
|
)
|
||||||
|
|
||||||
|
if error_detail:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=error_detail,
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
@ -52,7 +66,16 @@ async def login(request: Request, credentials: LoginRequest):
|
|||||||
access_token = AuthService.create_access_token(
|
access_token = AuthService.create_access_token(
|
||||||
user_id=user['user_id'],
|
user_id=user['user_id'],
|
||||||
username=user['username'],
|
username=user['username'],
|
||||||
is_superadmin=user['is_superadmin']
|
is_superadmin=user['is_superadmin'],
|
||||||
|
is_shadow_admin=user.get('is_shadow_admin', False)
|
||||||
|
)
|
||||||
|
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token",
|
||||||
|
value=access_token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=False
|
||||||
)
|
)
|
||||||
|
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
@ -62,11 +85,21 @@ async def login(request: Request, credentials: LoginRequest):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(request: LogoutRequest, current_user: dict = Depends(get_current_user)):
|
async def logout(
|
||||||
|
request: LogoutRequest,
|
||||||
|
response: Response,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Revoke JWT token (logout)
|
Revoke JWT token (logout)
|
||||||
"""
|
"""
|
||||||
AuthService.revoke_token(request.token_jti, current_user['id'])
|
AuthService.revoke_token(
|
||||||
|
request.token_jti,
|
||||||
|
current_user['id'],
|
||||||
|
current_user.get('is_shadow_admin', False)
|
||||||
|
)
|
||||||
|
|
||||||
|
response.delete_cookie("access_token")
|
||||||
|
|
||||||
return {"message": "Successfully logged out"}
|
return {"message": "Successfully logged out"}
|
||||||
|
|
||||||
@ -82,5 +115,75 @@ async def get_me(current_user: dict = Depends(get_current_user)):
|
|||||||
"email": current_user['email'],
|
"email": current_user['email'],
|
||||||
"full_name": current_user['full_name'],
|
"full_name": current_user['full_name'],
|
||||||
"is_superadmin": current_user['is_superadmin'],
|
"is_superadmin": current_user['is_superadmin'],
|
||||||
|
"is_2fa_enabled": current_user.get('is_2fa_enabled', False),
|
||||||
"permissions": current_user['permissions']
|
"permissions": current_user['permissions']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/setup")
|
||||||
|
async def setup_2fa(current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Generate and store TOTP secret (requires verification to enable)"""
|
||||||
|
if current_user.get("is_shadow_admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Shadow admin cannot configure 2FA",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = AuthService.setup_user_2fa(
|
||||||
|
user_id=current_user["id"],
|
||||||
|
username=current_user["username"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/enable")
|
||||||
|
async def enable_2fa(
|
||||||
|
request: TwoFactorCodeRequest,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Enable 2FA after verifying the provided code"""
|
||||||
|
if current_user.get("is_shadow_admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Shadow admin cannot configure 2FA",
|
||||||
|
)
|
||||||
|
|
||||||
|
ok = AuthService.enable_user_2fa(
|
||||||
|
user_id=current_user["id"],
|
||||||
|
otp_code=request.otp_code
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid 2FA code or missing setup",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "2FA enabled"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/disable")
|
||||||
|
async def disable_2fa(
|
||||||
|
request: TwoFactorCodeRequest,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Disable 2FA after verifying the provided code"""
|
||||||
|
if current_user.get("is_shadow_admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Shadow admin cannot configure 2FA",
|
||||||
|
)
|
||||||
|
|
||||||
|
ok = AuthService.disable_user_2fa(
|
||||||
|
user_id=current_user["id"],
|
||||||
|
otp_code=request.otp_code
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid 2FA code or missing setup",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "2FA disabled"}
|
||||||
|
|||||||
@ -39,6 +39,18 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="otp_code" class="form-label">2FA-kode</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="otp_code"
|
||||||
|
name="otp_code"
|
||||||
|
placeholder="Indtast 2FA-kode"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 form-check">
|
<div class="mb-3 form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="rememberMe">
|
<input type="checkbox" class="form-check-input" id="rememberMe">
|
||||||
<label class="form-check-label" for="rememberMe">
|
<label class="form-check-label" for="rememberMe">
|
||||||
@ -80,6 +92,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|||||||
|
|
||||||
const username = document.getElementById('username').value;
|
const username = document.getElementById('username').value;
|
||||||
const password = document.getElementById('password').value;
|
const password = document.getElementById('password').value;
|
||||||
|
const otp_code = document.getElementById('otp_code').value;
|
||||||
const errorMessage = document.getElementById('errorMessage');
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
const errorText = document.getElementById('errorText');
|
const errorText = document.getElementById('errorText');
|
||||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||||
@ -97,7 +110,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({ username, password, otp_code })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
@ -148,11 +148,13 @@ async def get_contact(contact_id: int):
|
|||||||
FROM contacts
|
FROM contacts
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
"""
|
||||||
contact = execute_query(contact_query, (contact_id,))
|
contact_result = execute_query(contact_query, (contact_id,))
|
||||||
|
|
||||||
if not contact:
|
if not contact_result:
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
|
||||||
|
contact = contact_result[0]
|
||||||
|
|
||||||
# Get linked companies
|
# Get linked companies
|
||||||
companies_query = """
|
companies_query = """
|
||||||
SELECT
|
SELECT
|
||||||
@ -163,7 +165,7 @@ async def get_contact(contact_id: int):
|
|||||||
WHERE cc.contact_id = %s
|
WHERE cc.contact_id = %s
|
||||||
ORDER BY cc.is_primary DESC, cu.name
|
ORDER BY cc.is_primary DESC, cu.name
|
||||||
"""
|
"""
|
||||||
companies = execute_query_single(companies_query, (contact_id,)) # Default is fetchall
|
companies = execute_query(companies_query, (contact_id,))
|
||||||
|
|
||||||
contact['companies'] = companies or []
|
contact['companies'] = companies or []
|
||||||
return contact
|
return contact
|
||||||
|
|||||||
@ -6,16 +6,18 @@ from fastapi import Depends, HTTPException, status, Request
|
|||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import execute_query_single
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Dependency to get current authenticated user from JWT token
|
Dependency to get current authenticated user from JWT token
|
||||||
@ -25,7 +27,13 @@ async def get_current_user(
|
|||||||
async def my_endpoint(current_user: dict = Depends(get_current_user)):
|
async def my_endpoint(current_user: dict = Depends(get_current_user)):
|
||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
token = credentials.credentials
|
token = credentials.credentials if credentials else request.cookies.get("access_token")
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
# Verify token
|
# Verify token
|
||||||
payload = AuthService.verify_token(token)
|
payload = AuthService.verify_token(token)
|
||||||
@ -41,14 +49,27 @@ async def get_current_user(
|
|||||||
user_id = int(payload.get("sub"))
|
user_id = int(payload.get("sub"))
|
||||||
username = payload.get("username")
|
username = payload.get("username")
|
||||||
is_superadmin = payload.get("is_superadmin", False)
|
is_superadmin = payload.get("is_superadmin", False)
|
||||||
|
is_shadow_admin = payload.get("shadow_admin", False)
|
||||||
|
|
||||||
# Add IP address to user info
|
# Add IP address to user info
|
||||||
ip_address = request.client.host if request.client else None
|
ip_address = request.client.host if request.client else None
|
||||||
|
|
||||||
|
if is_shadow_admin:
|
||||||
|
return {
|
||||||
|
"id": user_id,
|
||||||
|
"username": username,
|
||||||
|
"email": settings.SHADOW_ADMIN_EMAIL,
|
||||||
|
"full_name": settings.SHADOW_ADMIN_FULL_NAME,
|
||||||
|
"is_superadmin": True,
|
||||||
|
"is_shadow_admin": True,
|
||||||
|
"is_2fa_enabled": True,
|
||||||
|
"ip_address": ip_address,
|
||||||
|
"permissions": AuthService.get_all_permissions()
|
||||||
|
}
|
||||||
|
|
||||||
# Get additional user details from database
|
# Get additional user details from database
|
||||||
from app.core.database import execute_query
|
|
||||||
user_details = execute_query_single(
|
user_details = execute_query_single(
|
||||||
"SELECT email, full_name FROM users WHERE id = %s",
|
"SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s",
|
||||||
(user_id,))
|
(user_id,))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -57,6 +78,8 @@ async def get_current_user(
|
|||||||
"email": user_details.get('email') if user_details else None,
|
"email": user_details.get('email') if user_details else None,
|
||||||
"full_name": user_details.get('full_name') if user_details else None,
|
"full_name": user_details.get('full_name') if user_details else None,
|
||||||
"is_superadmin": is_superadmin,
|
"is_superadmin": is_superadmin,
|
||||||
|
"is_shadow_admin": False,
|
||||||
|
"is_2fa_enabled": user_details.get('is_2fa_enabled') if user_details else False,
|
||||||
"ip_address": ip_address,
|
"ip_address": ip_address,
|
||||||
"permissions": AuthService.get_user_permissions(user_id)
|
"permissions": AuthService.get_user_permissions(user_id)
|
||||||
}
|
}
|
||||||
@ -70,7 +93,7 @@ async def get_optional_user(
|
|||||||
Dependency to get current user if authenticated, None otherwise
|
Dependency to get current user if authenticated, None otherwise
|
||||||
Allows endpoints that work both with and without authentication
|
Allows endpoints that work both with and without authentication
|
||||||
"""
|
"""
|
||||||
if not credentials:
|
if not credentials and not request.cookies.get("access_token"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -2,12 +2,14 @@
|
|||||||
Authentication Service - Håndterer login, JWT tokens, password hashing
|
Authentication Service - Håndterer login, JWT tokens, password hashing
|
||||||
Adapted from OmniSync for BMC Hub
|
Adapted from OmniSync for BMC Hub
|
||||||
"""
|
"""
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List, Tuple
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import jwt
|
import jwt
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
import pyotp
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -18,6 +20,8 @@ SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-prod
|
|||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
"""Service for authentication and authorization"""
|
"""Service for authentication and authorization"""
|
||||||
@ -25,18 +29,124 @@ class AuthService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""
|
"""
|
||||||
Hash password using SHA256
|
Hash password using bcrypt
|
||||||
I produktion: Brug bcrypt eller argon2!
|
|
||||||
"""
|
"""
|
||||||
return hashlib.sha256(password.encode()).hexdigest()
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
"""Verify password against hash"""
|
"""Verify password against hash"""
|
||||||
return AuthService.hash_password(plain_password) == hashed_password
|
if not hashed_password:
|
||||||
|
return False
|
||||||
|
if not hashed_password.startswith("$2"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_access_token(user_id: int, username: str, is_superadmin: bool = False) -> str:
|
def verify_legacy_sha256(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify legacy SHA256 hash and upgrade when used"""
|
||||||
|
if not hashed_password or len(hashed_password) != 64:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return hashlib.sha256(plain_password.encode()).hexdigest() == hashed_password
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def upgrade_password_hash(user_id: int, plain_password: str):
|
||||||
|
"""Upgrade legacy password hash to bcrypt"""
|
||||||
|
new_hash = AuthService.hash_password(plain_password)
|
||||||
|
execute_update(
|
||||||
|
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||||
|
(new_hash, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_totp_code(secret: str, code: str) -> bool:
|
||||||
|
"""Verify TOTP code"""
|
||||||
|
if not secret or not code:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
return totp.verify(code, valid_window=1)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_2fa_secret() -> str:
|
||||||
|
"""Generate a new TOTP secret"""
|
||||||
|
return pyotp.random_base32()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_2fa_provisioning_uri(username: str, secret: str) -> str:
|
||||||
|
"""Generate provisioning URI for authenticator apps"""
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
return totp.provisioning_uri(name=username, issuer_name="BMC Hub")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setup_user_2fa(user_id: int, username: str) -> Dict:
|
||||||
|
"""Create and store a new TOTP secret (not enabled until verified)"""
|
||||||
|
secret = AuthService.generate_2fa_secret()
|
||||||
|
execute_update(
|
||||||
|
"UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||||
|
(secret, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"secret": secret,
|
||||||
|
"provisioning_uri": AuthService.get_2fa_provisioning_uri(username, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def enable_user_2fa(user_id: int, otp_code: str) -> bool:
|
||||||
|
"""Enable 2FA after verifying TOTP code"""
|
||||||
|
user = execute_query_single(
|
||||||
|
"SELECT totp_secret FROM users WHERE user_id = %s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user or not user.get("totp_secret"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not AuthService.verify_totp_code(user["totp_secret"], otp_code):
|
||||||
|
return False
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"UPDATE users SET is_2fa_enabled = TRUE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def disable_user_2fa(user_id: int, otp_code: str) -> bool:
|
||||||
|
"""Disable 2FA after verifying TOTP code"""
|
||||||
|
user = execute_query_single(
|
||||||
|
"SELECT totp_secret FROM users WHERE user_id = %s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user or not user.get("totp_secret"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not AuthService.verify_totp_code(user["totp_secret"], otp_code):
|
||||||
|
return False
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_access_token(
|
||||||
|
user_id: int,
|
||||||
|
username: str,
|
||||||
|
is_superadmin: bool = False,
|
||||||
|
is_shadow_admin: bool = False
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Create JWT access token
|
Create JWT access token
|
||||||
|
|
||||||
@ -55,6 +165,7 @@ class AuthService:
|
|||||||
"sub": str(user_id),
|
"sub": str(user_id),
|
||||||
"username": username,
|
"username": username,
|
||||||
"is_superadmin": is_superadmin,
|
"is_superadmin": is_superadmin,
|
||||||
|
"shadow_admin": is_shadow_admin,
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
"iat": datetime.utcnow(),
|
"iat": datetime.utcnow(),
|
||||||
"jti": jti
|
"jti": jti
|
||||||
@ -62,7 +173,8 @@ class AuthService:
|
|||||||
|
|
||||||
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
# Store session for token revocation
|
# Store session for token revocation (skip for shadow admin)
|
||||||
|
if not is_shadow_admin:
|
||||||
execute_insert(
|
execute_insert(
|
||||||
"""INSERT INTO sessions (user_id, token_jti, expires_at)
|
"""INSERT INTO sessions (user_id, token_jti, expires_at)
|
||||||
VALUES (%s, %s, %s)""",
|
VALUES (%s, %s, %s)""",
|
||||||
@ -82,6 +194,9 @@ class AuthService:
|
|||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
|
||||||
|
if payload.get("shadow_admin"):
|
||||||
|
return payload
|
||||||
|
|
||||||
# Check if token is revoked
|
# Check if token is revoked
|
||||||
jti = payload.get('jti')
|
jti = payload.get('jti')
|
||||||
if jti:
|
if jti:
|
||||||
@ -102,7 +217,12 @@ class AuthService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def authenticate_user(username: str, password: str, ip_address: Optional[str] = None) -> Optional[Dict]:
|
def authenticate_user(
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
otp_code: Optional[str] = None
|
||||||
|
) -> Tuple[Optional[Dict], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Authenticate user with username/password
|
Authenticate user with username/password
|
||||||
|
|
||||||
@ -114,38 +234,70 @@ class AuthService:
|
|||||||
Returns:
|
Returns:
|
||||||
User dict if successful, None otherwise
|
User dict if successful, None otherwise
|
||||||
"""
|
"""
|
||||||
|
# Shadow Admin shortcut
|
||||||
|
if settings.SHADOW_ADMIN_ENABLED and username == settings.SHADOW_ADMIN_USERNAME:
|
||||||
|
if not settings.SHADOW_ADMIN_PASSWORD or not settings.SHADOW_ADMIN_TOTP_SECRET:
|
||||||
|
logger.error("❌ Shadow admin enabled but not configured")
|
||||||
|
return None, "Shadow admin not configured"
|
||||||
|
|
||||||
|
if not secrets.compare_digest(password, settings.SHADOW_ADMIN_PASSWORD):
|
||||||
|
logger.warning(f"❌ Shadow admin login failed from IP: {ip_address}")
|
||||||
|
return None, "Invalid username or password"
|
||||||
|
|
||||||
|
if not otp_code:
|
||||||
|
return None, "2FA code required"
|
||||||
|
|
||||||
|
if not AuthService.verify_totp_code(settings.SHADOW_ADMIN_TOTP_SECRET, otp_code):
|
||||||
|
logger.warning(f"❌ Shadow admin 2FA failed from IP: {ip_address}")
|
||||||
|
return None, "Invalid 2FA code"
|
||||||
|
|
||||||
|
logger.warning(f"⚠️ Shadow admin login used from IP: {ip_address}")
|
||||||
|
return {
|
||||||
|
"user_id": 0,
|
||||||
|
"username": settings.SHADOW_ADMIN_USERNAME,
|
||||||
|
"email": settings.SHADOW_ADMIN_EMAIL,
|
||||||
|
"full_name": settings.SHADOW_ADMIN_FULL_NAME,
|
||||||
|
"is_superadmin": True,
|
||||||
|
"is_shadow_admin": True
|
||||||
|
}, None
|
||||||
|
|
||||||
# Get user
|
# Get user
|
||||||
user = execute_query_single(
|
user = execute_query_single(
|
||||||
"""SELECT id, username, email, password_hash, full_name,
|
"""SELECT user_id, username, email, password_hash, full_name,
|
||||||
is_active, is_superadmin, failed_login_attempts, locked_until
|
is_active, is_superadmin, failed_login_attempts, locked_until,
|
||||||
|
is_2fa_enabled, totp_secret
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = %s OR email = %s""",
|
WHERE username = %s OR email = %s""",
|
||||||
(username, username))
|
(username, username))
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
logger.warning(f"❌ Login failed: User not found - {username}")
|
logger.warning(f"❌ Login failed: User not found - {username}")
|
||||||
return None
|
return None, "Invalid username or password"
|
||||||
|
|
||||||
# Check if account is active
|
# Check if account is active
|
||||||
if not user['is_active']:
|
if not user['is_active']:
|
||||||
logger.warning(f"❌ Login failed: Account disabled - {username}")
|
logger.warning(f"❌ Login failed: Account disabled - {username}")
|
||||||
return None
|
return None, "Account disabled"
|
||||||
|
|
||||||
# Check if account is locked
|
# Check if account is locked
|
||||||
if user['locked_until']:
|
if user['locked_until']:
|
||||||
locked_until = user['locked_until']
|
locked_until = user['locked_until']
|
||||||
if datetime.now() < locked_until:
|
if datetime.now() < locked_until:
|
||||||
logger.warning(f"❌ Login failed: Account locked - {username}")
|
logger.warning(f"❌ Login failed: Account locked - {username}")
|
||||||
return None
|
return None, "Account locked"
|
||||||
else:
|
else:
|
||||||
# Unlock account
|
# Unlock account
|
||||||
execute_update(
|
execute_update(
|
||||||
"UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE id = %s",
|
"UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE user_id = %s",
|
||||||
(user['id'],)
|
(user['user_id'],)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify password
|
# Verify password
|
||||||
if not AuthService.verify_password(password, user['password_hash']):
|
if AuthService.verify_password(password, user['password_hash']):
|
||||||
|
pass
|
||||||
|
elif AuthService.verify_legacy_sha256(password, user['password_hash']):
|
||||||
|
AuthService.upgrade_password_hash(user['user_id'], password)
|
||||||
|
else:
|
||||||
# Increment failed attempts
|
# Increment failed attempts
|
||||||
failed_attempts = user['failed_login_attempts'] + 1
|
failed_attempts = user['failed_login_attempts'] + 1
|
||||||
|
|
||||||
@ -155,18 +307,30 @@ class AuthService:
|
|||||||
execute_update(
|
execute_update(
|
||||||
"""UPDATE users
|
"""UPDATE users
|
||||||
SET failed_login_attempts = %s, locked_until = %s
|
SET failed_login_attempts = %s, locked_until = %s
|
||||||
WHERE id = %s""",
|
WHERE user_id = %s""",
|
||||||
(failed_attempts, locked_until, user['id'])
|
(failed_attempts, locked_until, user['user_id'])
|
||||||
)
|
)
|
||||||
logger.warning(f"🔒 Account locked due to failed attempts: {username}")
|
logger.warning(f"🔒 Account locked due to failed attempts: {username}")
|
||||||
else:
|
else:
|
||||||
execute_update(
|
execute_update(
|
||||||
"UPDATE users SET failed_login_attempts = %s WHERE id = %s",
|
"UPDATE users SET failed_login_attempts = %s WHERE user_id = %s",
|
||||||
(failed_attempts, user['id'])
|
(failed_attempts, user['user_id'])
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})")
|
logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})")
|
||||||
return None
|
return None, "Invalid username or password"
|
||||||
|
|
||||||
|
# 2FA check
|
||||||
|
if user.get('is_2fa_enabled'):
|
||||||
|
if not user.get('totp_secret'):
|
||||||
|
return None, "2FA not configured"
|
||||||
|
|
||||||
|
if not otp_code:
|
||||||
|
return None, "2FA code required"
|
||||||
|
|
||||||
|
if not AuthService.verify_totp_code(user['totp_secret'], otp_code):
|
||||||
|
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
|
||||||
|
return None, "Invalid 2FA code"
|
||||||
|
|
||||||
# Success! Reset failed attempts and update last login
|
# Success! Reset failed attempts and update last login
|
||||||
execute_update(
|
execute_update(
|
||||||
@ -174,29 +338,48 @@ class AuthService:
|
|||||||
SET failed_login_attempts = 0,
|
SET failed_login_attempts = 0,
|
||||||
locked_until = NULL,
|
locked_until = NULL,
|
||||||
last_login_at = CURRENT_TIMESTAMP
|
last_login_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = %s""",
|
WHERE user_id = %s""",
|
||||||
(user['id'],)
|
(user['user_id'],)
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"✅ User logged in: {username} from IP: {ip_address}")
|
logger.info(f"✅ User logged in: {username} from IP: {ip_address}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'user_id': user['id'],
|
'user_id': user['user_id'],
|
||||||
'username': user['username'],
|
'username': user['username'],
|
||||||
'email': user['email'],
|
'email': user['email'],
|
||||||
'full_name': user['full_name'],
|
'full_name': user['full_name'],
|
||||||
'is_superadmin': bool(user['is_superadmin'])
|
'is_superadmin': bool(user['is_superadmin']),
|
||||||
}
|
'is_shadow_admin': False
|
||||||
|
}, None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def revoke_token(jti: str, user_id: int):
|
def revoke_token(jti: str, user_id: int, is_shadow_admin: bool = False):
|
||||||
"""Revoke a JWT token"""
|
"""Revoke a JWT token"""
|
||||||
|
if is_shadow_admin:
|
||||||
|
logger.info("🔒 Shadow admin logout - no session to revoke")
|
||||||
|
return
|
||||||
execute_update(
|
execute_update(
|
||||||
"UPDATE sessions SET revoked = TRUE WHERE token_jti = %s AND user_id = %s",
|
"UPDATE sessions SET revoked = TRUE WHERE token_jti = %s AND user_id = %s",
|
||||||
(jti, user_id)
|
(jti, user_id)
|
||||||
)
|
)
|
||||||
logger.info(f"🔒 Token revoked for user {user_id}")
|
logger.info(f"🔒 Token revoked for user {user_id}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all_permissions() -> List[str]:
|
||||||
|
"""Get all permission codes"""
|
||||||
|
perms = execute_query("SELECT code FROM permissions")
|
||||||
|
return [p['code'] for p in perms] if perms else []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_user_2fa_enabled(user_id: int) -> bool:
|
||||||
|
"""Check if user has 2FA enabled"""
|
||||||
|
user = execute_query_single(
|
||||||
|
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
return bool(user and user.get("is_2fa_enabled"))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_user_permissions(user_id: int) -> List[str]:
|
def get_user_permissions(user_id: int) -> List[str]:
|
||||||
"""
|
"""
|
||||||
@ -210,13 +393,12 @@ class AuthService:
|
|||||||
"""
|
"""
|
||||||
# Check if user is superadmin first
|
# Check if user is superadmin first
|
||||||
user = execute_query_single(
|
user = execute_query_single(
|
||||||
"SELECT is_superadmin FROM users WHERE id = %s",
|
"SELECT is_superadmin FROM users WHERE user_id = %s",
|
||||||
(user_id,))
|
(user_id,))
|
||||||
|
|
||||||
# Superadmins have all permissions
|
# Superadmins have all permissions
|
||||||
if user and user['is_superadmin']:
|
if user and user['is_superadmin']:
|
||||||
all_perms = execute_query_single("SELECT code FROM permissions")
|
return AuthService.get_all_permissions()
|
||||||
return [p['code'] for p in all_perms] if all_perms else []
|
|
||||||
|
|
||||||
# Get permissions through groups
|
# Get permissions through groups
|
||||||
perms = execute_query("""
|
perms = execute_query("""
|
||||||
@ -242,8 +424,8 @@ class AuthService:
|
|||||||
True if user has permission
|
True if user has permission
|
||||||
"""
|
"""
|
||||||
# Superadmins have all permissions
|
# Superadmins have all permissions
|
||||||
user = execute_query(
|
user = execute_query_single(
|
||||||
"SELECT is_superadmin FROM users WHERE id = %s",
|
"SELECT is_superadmin FROM users WHERE user_id = %s",
|
||||||
(user_id,))
|
(user_id,))
|
||||||
|
|
||||||
if user and user['is_superadmin']:
|
if user and user['is_superadmin']:
|
||||||
@ -279,7 +461,7 @@ class AuthService:
|
|||||||
user_id = execute_insert(
|
user_id = execute_insert(
|
||||||
"""INSERT INTO users
|
"""INSERT INTO users
|
||||||
(username, email, password_hash, full_name, is_superadmin)
|
(username, email, password_hash, full_name, is_superadmin)
|
||||||
VALUES (%s, %s, %s, %s, %s) RETURNING id""",
|
VALUES (%s, %s, %s, %s, %s) RETURNING user_id""",
|
||||||
(username, email, password_hash, full_name, is_superadmin)
|
(username, email, password_hash, full_name, is_superadmin)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -292,7 +474,7 @@ class AuthService:
|
|||||||
password_hash = AuthService.hash_password(new_password)
|
password_hash = AuthService.hash_password(new_password)
|
||||||
|
|
||||||
execute_update(
|
execute_update(
|
||||||
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||||
(password_hash, user_id)
|
(password_hash, user_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,14 @@ class Settings(BaseSettings):
|
|||||||
ALLOWED_ORIGINS: List[str] = ["http://localhost:8000", "http://localhost:3000"]
|
ALLOWED_ORIGINS: List[str] = ["http://localhost:8000", "http://localhost:3000"]
|
||||||
CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000"
|
CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000"
|
||||||
|
|
||||||
|
# Shadow Admin (emergency access)
|
||||||
|
SHADOW_ADMIN_ENABLED: bool = False
|
||||||
|
SHADOW_ADMIN_USERNAME: str = "shadowadmin"
|
||||||
|
SHADOW_ADMIN_PASSWORD: str = ""
|
||||||
|
SHADOW_ADMIN_TOTP_SECRET: str = ""
|
||||||
|
SHADOW_ADMIN_EMAIL: str = "shadowadmin@bmcnetworks.dk"
|
||||||
|
SHADOW_ADMIN_FULL_NAME: str = "Shadow Administrator"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL: str = "INFO"
|
LOG_LEVEL: str = "INFO"
|
||||||
LOG_FILE: str = "logs/app.log"
|
LOG_FILE: str = "logs/app.log"
|
||||||
@ -42,6 +50,13 @@ class Settings(BaseSettings):
|
|||||||
ECONOMIC_READ_ONLY: bool = True
|
ECONOMIC_READ_ONLY: bool = True
|
||||||
ECONOMIC_DRY_RUN: bool = True
|
ECONOMIC_DRY_RUN: bool = True
|
||||||
|
|
||||||
|
# Nextcloud Integration
|
||||||
|
NEXTCLOUD_READ_ONLY: bool = True
|
||||||
|
NEXTCLOUD_DRY_RUN: bool = True
|
||||||
|
NEXTCLOUD_TIMEOUT_SECONDS: int = 15
|
||||||
|
NEXTCLOUD_CACHE_TTL_SECONDS: int = 300
|
||||||
|
NEXTCLOUD_ENCRYPTION_KEY: str = ""
|
||||||
|
|
||||||
# Ollama LLM
|
# Ollama LLM
|
||||||
OLLAMA_ENDPOINT: str = "http://localhost:11434"
|
OLLAMA_ENDPOINT: str = "http://localhost:11434"
|
||||||
OLLAMA_MODEL: str = "llama3.2:3b"
|
OLLAMA_MODEL: str = "llama3.2:3b"
|
||||||
|
|||||||
31
app/core/crypto.py
Normal file
31
app/core/crypto.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Crypto helpers for encrypting/decrypting secrets at rest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
if not settings.NEXTCLOUD_ENCRYPTION_KEY:
|
||||||
|
raise ValueError("NEXTCLOUD_ENCRYPTION_KEY not configured")
|
||||||
|
return Fernet(settings.NEXTCLOUD_ENCRYPTION_KEY.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(value: str) -> str:
|
||||||
|
fernet = _get_fernet()
|
||||||
|
return fernet.encrypt(value.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(value: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
fernet = _get_fernet()
|
||||||
|
return fernet.decrypt(value.encode()).decode()
|
||||||
|
except (InvalidToken, ValueError) as exc:
|
||||||
|
logger.error("❌ Nextcloud credential decryption failed: %s", exc)
|
||||||
|
return None
|
||||||
@ -68,19 +68,18 @@ def execute_query(query: str, params: tuple = None, fetch: bool = True):
|
|||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
|
|
||||||
# Auto-detect write operations and commit
|
# Auto-detect write operations and commit
|
||||||
query_upper = query.strip().upper()
|
# Robust detection handling comments and whitespace
|
||||||
is_write = query_upper.startswith(('INSERT', 'UPDATE', 'DELETE'))
|
clean_query = "\n".join([line for line in query.split("\n") if not line.strip().startswith("--")]).strip().upper()
|
||||||
|
is_write = clean_query.startswith(('INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'COMMENT'))
|
||||||
|
|
||||||
if is_write:
|
if is_write:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# Only fetch if there are results to fetch
|
# Only fetch if there are results to fetch (cursor.description is not None)
|
||||||
# (SELECT queries or INSERT/UPDATE/DELETE with RETURNING clause)
|
if cursor.description:
|
||||||
if fetch and (not is_write or 'RETURNING' in query_upper):
|
|
||||||
return cursor.fetchall()
|
return cursor.fetchall()
|
||||||
elif is_write:
|
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
return []
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
logger.error(f"Query error: {e}")
|
logger.error(f"Query error: {e}")
|
||||||
|
|||||||
@ -316,6 +316,11 @@
|
|||||||
<i class="bi bi-hdd"></i>Hardware
|
<i class="bi bi-hdd"></i>Hardware
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item d-none" id="nextcloudTabNav">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#nextcloud">
|
||||||
|
<i class="bi bi-cloud"></i>Nextcloud
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#activity">
|
<a class="nav-link" data-bs-toggle="tab" href="#activity">
|
||||||
<i class="bi bi-clock-history"></i>Aktivitet
|
<i class="bi bi-clock-history"></i>Aktivitet
|
||||||
@ -430,6 +435,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="fw-bold mb-0">Tags</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="openCustomerTagModal()">
|
||||||
|
<i class="bi bi-tag me-1"></i>Tilføj tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="customerTagsContainer" class="d-flex flex-wrap gap-2"></div>
|
||||||
|
<div id="customerTagsEmpty" class="text-muted small">Ingen tags tilføjet endnu.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -600,6 +618,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Nextcloud Tab -->
|
||||||
|
<div class="tab-pane fade d-none" id="nextcloud">
|
||||||
|
{% include "modules/nextcloud/templates/tab.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Activity Tab -->
|
<!-- Activity Tab -->
|
||||||
<div class="tab-pane fade" id="activity">
|
<div class="tab-pane fade" id="activity">
|
||||||
<h5 class="fw-bold mb-4">Aktivitet</h5>
|
<h5 class="fw-bold mb-4">Aktivitet</h5>
|
||||||
@ -750,6 +773,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer Tag Modal -->
|
||||||
|
<div class="modal fade" id="customerTagModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-tag me-2"></i>Tilføj tag</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Vælg tag</label>
|
||||||
|
<select class="form-select" id="customerTagSelect"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="addCustomerTag()">Tilføj</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Subscription Modal -->
|
<!-- Subscription Modal -->
|
||||||
<div class="modal fade" id="subscriptionModal" tabindex="-1">
|
<div class="modal fade" id="subscriptionModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@ -867,6 +912,7 @@
|
|||||||
const customerId = parseInt(window.location.pathname.split('/').pop());
|
const customerId = parseInt(window.location.pathname.split('/').pop());
|
||||||
let customerData = null;
|
let customerData = null;
|
||||||
let pipelineStages = [];
|
let pipelineStages = [];
|
||||||
|
let allTagsCache = [];
|
||||||
|
|
||||||
let eventListenersAdded = false;
|
let eventListenersAdded = false;
|
||||||
|
|
||||||
@ -919,6 +965,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { once: false });
|
}, { once: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load Nextcloud status when tab is shown
|
||||||
|
const nextcloudTab = document.querySelector('a[href="#nextcloud"]');
|
||||||
|
if (nextcloudTab) {
|
||||||
|
nextcloudTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
loadNextcloudStatus();
|
||||||
|
}, { once: false });
|
||||||
|
}
|
||||||
|
|
||||||
eventListenersAdded = true;
|
eventListenersAdded = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -933,6 +987,7 @@ async function loadCustomer() {
|
|||||||
displayCustomer(customerData);
|
displayCustomer(customerData);
|
||||||
|
|
||||||
await loadUtilityCompany();
|
await loadUtilityCompany();
|
||||||
|
await loadCustomerTags();
|
||||||
|
|
||||||
// Check data consistency
|
// Check data consistency
|
||||||
await checkDataConsistency();
|
await checkDataConsistency();
|
||||||
@ -1025,6 +1080,280 @@ function displayCustomer(customer) {
|
|||||||
document.getElementById('createdAt').textContent = new Date(customer.created_at).toLocaleString('da-DK');
|
document.getElementById('createdAt').textContent = new Date(customer.created_at).toLocaleString('da-DK');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCustomerTags() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/tags/entity/customer/${customerId}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const tags = await response.json();
|
||||||
|
const hasNextcloud = (tags || []).some(tag => (tag.name || '').toLowerCase() === 'nextcloud');
|
||||||
|
|
||||||
|
const nextcloudNav = document.getElementById('nextcloudTabNav');
|
||||||
|
const nextcloudPane = document.getElementById('nextcloud');
|
||||||
|
|
||||||
|
if (hasNextcloud) {
|
||||||
|
nextcloudNav?.classList.remove('d-none');
|
||||||
|
nextcloudPane?.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
nextcloudNav?.classList.add('d-none');
|
||||||
|
nextcloudPane?.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCustomerTags(tags || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load customer tags:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCustomerTags(tags) {
|
||||||
|
const container = document.getElementById('customerTagsContainer');
|
||||||
|
const emptyState = document.getElementById('customerTagsEmpty');
|
||||||
|
|
||||||
|
if (!container || !emptyState) return;
|
||||||
|
|
||||||
|
if (!tags.length) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
emptyState.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyState.classList.add('d-none');
|
||||||
|
container.innerHTML = tags.map(tag => `
|
||||||
|
<span class="badge" style="background:${tag.color || '#0f4c75'}; color: white;">
|
||||||
|
${escapeHtml(tag.name)}
|
||||||
|
<button class="btn btn-sm btn-link text-white p-0 ms-2" onclick="removeCustomerTag(${tag.id})" title="Fjern tag">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCustomerTagModal() {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('customerTagModal'));
|
||||||
|
await loadAllTags();
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllTags() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/tags?is_active=true');
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
allTagsCache = await response.json();
|
||||||
|
const currentTagsResponse = await fetch(`/api/v1/tags/entity/customer/${customerId}`);
|
||||||
|
const currentTags = currentTagsResponse.ok ? await currentTagsResponse.json() : [];
|
||||||
|
const currentTagIds = new Set(currentTags.map(tag => tag.id));
|
||||||
|
|
||||||
|
const select = document.getElementById('customerTagSelect');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
const options = allTagsCache
|
||||||
|
.filter(tag => !currentTagIds.has(tag.id))
|
||||||
|
.map(tag => `<option value="${tag.id}">${escapeHtml(tag.name)}</option>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
select.innerHTML = options || '<option value="">Ingen flere tags</option>';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tags:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCustomerTag() {
|
||||||
|
const select = document.getElementById('customerTagSelect');
|
||||||
|
if (!select || !select.value) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
entity_type: 'customer',
|
||||||
|
entity_id: customerId,
|
||||||
|
tag_id: parseInt(select.value, 10)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/tags/entity', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.detail || 'Kunne ikke tilføje tag');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('customerTagModal')).hide();
|
||||||
|
await loadCustomerTags();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add tag:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCustomerTag(tagId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/tags/entity?entity_type=customer&entity_id=${customerId}&tag_id=${tagId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.detail || 'Kunne ikke fjerne tag');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadCustomerTags();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove tag:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNextcloudStatus() {
|
||||||
|
const statusBadge = document.getElementById('ncStatusBadge');
|
||||||
|
const lastUpdated = document.getElementById('ncLastUpdated');
|
||||||
|
const cpuLoad = document.getElementById('ncCpuLoad');
|
||||||
|
const freeDisk = document.getElementById('ncFreeDisk');
|
||||||
|
const ramUsage = document.getElementById('ncRamUsage');
|
||||||
|
const opcache = document.getElementById('ncOpcache');
|
||||||
|
const fileGrowth = document.getElementById('ncFileGrowth');
|
||||||
|
const publicShares = document.getElementById('ncPublicShares');
|
||||||
|
const activeUsers = document.getElementById('ncActiveUsers');
|
||||||
|
const alerts = document.getElementById('ncAlerts');
|
||||||
|
|
||||||
|
if (!statusBadge || !lastUpdated) return;
|
||||||
|
|
||||||
|
statusBadge.className = 'badge bg-secondary';
|
||||||
|
statusBadge.textContent = 'Henter...';
|
||||||
|
lastUpdated.textContent = '-';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instanceResponse = await fetch(`/api/v1/nextcloud/customers/${customerId}/instance`);
|
||||||
|
if (!instanceResponse.ok) {
|
||||||
|
statusBadge.textContent = 'Ukendt';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = await instanceResponse.json();
|
||||||
|
if (!instance?.id) {
|
||||||
|
statusBadge.textContent = 'Ikke konfigureret';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/status?customer_id=${customerId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
statusBadge.textContent = 'Ukendt';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const isOnline = payload.status === 'online';
|
||||||
|
|
||||||
|
statusBadge.className = `badge ${isOnline ? 'bg-success' : 'bg-warning text-dark'}`;
|
||||||
|
statusBadge.textContent = isOnline ? 'Online' : 'Ukendt';
|
||||||
|
lastUpdated.textContent = new Date().toLocaleString('da-DK');
|
||||||
|
|
||||||
|
const info = payload.raw?.payload?.ocs?.data || {};
|
||||||
|
const system = info?.nextcloud?.system || {};
|
||||||
|
const sharesInfo = info?.nextcloud?.shares || {};
|
||||||
|
const storageInfo = info?.nextcloud?.storage || {};
|
||||||
|
const activeUsersInfo = info?.activeUsers || {};
|
||||||
|
const phpInfo = info?.server?.php || {};
|
||||||
|
const opcacheStats = phpInfo?.opcache?.opcache_statistics || {};
|
||||||
|
|
||||||
|
const loadAvg = Array.isArray(system?.cpuload) ? system.cpuload : [];
|
||||||
|
const cpuCores = system?.cpucount || system?.cpu_count || system?.num_cores || 0;
|
||||||
|
const loadValue = loadAvg.length ? loadAvg[0] : null;
|
||||||
|
const loadText = loadAvg.length ? loadAvg.map(v => Number(v).toFixed(2)).join(' / ') : '-';
|
||||||
|
|
||||||
|
const memTotal = system?.mem_total || null;
|
||||||
|
const memFree = system?.mem_free || null;
|
||||||
|
const freeRamPct = memTotal && memFree ? (memFree / memTotal) * 100 : null;
|
||||||
|
const ramUsageText = freeRamPct !== null ? `${(100 - freeRamPct).toFixed(1)} %` : '-';
|
||||||
|
|
||||||
|
const freeDiskBytes = system?.freespace ?? null;
|
||||||
|
const freeDiskText = freeDiskBytes !== null ? formatBytes(freeDiskBytes) : '-';
|
||||||
|
|
||||||
|
const opcacheHitRate = opcacheStats?.opcache_hit_rate ?? null;
|
||||||
|
const opcacheText = opcacheHitRate !== null ? `${Number(opcacheHitRate).toFixed(2)} %` : '-';
|
||||||
|
|
||||||
|
if (cpuLoad) cpuLoad.textContent = loadText;
|
||||||
|
if (freeDisk) freeDisk.textContent = freeDiskText;
|
||||||
|
if (ramUsage) ramUsage.textContent = ramUsageText;
|
||||||
|
if (opcache) opcache.textContent = opcacheText;
|
||||||
|
if (activeUsers) activeUsers.textContent = activeUsersInfo?.last24hours ?? '-';
|
||||||
|
|
||||||
|
// File count growth (localStorage diff)
|
||||||
|
const fileCount = storageInfo?.num_files ?? null;
|
||||||
|
if (fileGrowth) {
|
||||||
|
if (fileCount === null) {
|
||||||
|
fileGrowth.textContent = '-';
|
||||||
|
} else {
|
||||||
|
const key = `nextcloud_file_count_${instance.id}`;
|
||||||
|
const prev = parseInt(localStorage.getItem(key) || '0', 10);
|
||||||
|
const diff = prev ? fileCount - prev : 0;
|
||||||
|
fileGrowth.textContent = prev ? `${fileCount} (${diff >= 0 ? '+' : ''}${diff})` : `${fileCount}`;
|
||||||
|
localStorage.setItem(key, `${fileCount}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public shares without password
|
||||||
|
if (publicShares) {
|
||||||
|
if (typeof sharesInfo?.num_shares_link_no_password !== 'undefined') {
|
||||||
|
publicShares.textContent = `${sharesInfo.num_shares_link_no_password}`;
|
||||||
|
} else {
|
||||||
|
const sharesResponse = await fetch(`/api/v1/nextcloud/instances/${instance.id}/shares?customer_id=${customerId}`);
|
||||||
|
if (sharesResponse.ok) {
|
||||||
|
const sharesPayload = await sharesResponse.json();
|
||||||
|
const list = sharesPayload?.payload?.ocs?.data || [];
|
||||||
|
const withoutPassword = list.filter(s => s.share_type === 3 && (!s.password || s.password === '')).length;
|
||||||
|
publicShares.textContent = `${withoutPassword}`;
|
||||||
|
} else {
|
||||||
|
publicShares.textContent = '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alerts) {
|
||||||
|
const items = [];
|
||||||
|
if (freeDiskBytes !== null && freeDiskBytes <= 0) items.push('⚠️ Free disk kritisk');
|
||||||
|
if (loadValue !== null && cpuCores && loadValue > cpuCores) items.push('⚠️ CPU load > cores');
|
||||||
|
if (freeRamPct !== null && freeRamPct < 10) items.push('⚠️ Free RAM < 10%');
|
||||||
|
if (opcacheHitRate !== null && opcacheHitRate < 95) items.push('⚠️ OPCache hit rate < 95%');
|
||||||
|
const sharesText = publicShares?.textContent;
|
||||||
|
if (sharesText && parseInt(sharesText, 10) > 0) items.push('⚠️ Public shares uden password');
|
||||||
|
|
||||||
|
alerts.innerHTML = items.length
|
||||||
|
? items.map(text => `<span class="badge bg-warning text-dark">${text}</span>`).join('')
|
||||||
|
: '<span class="badge bg-success">✅ Ingen alarmer</span>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Nextcloud status:', error);
|
||||||
|
statusBadge.textContent = 'Ukendt';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
if (value === null || typeof value === 'undefined') return '-';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let size = Number(value);
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNextcloudCreateUser() {
|
||||||
|
alert('Opret bruger: kommer snart');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNextcloudResetPassword() {
|
||||||
|
alert('Reset password: kommer snart');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNextcloudDisableUser() {
|
||||||
|
alert('Luk bruger: kommer snart');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUtilityCompany() {
|
async function loadUtilityCompany() {
|
||||||
const nameEl = document.getElementById('utilityCompanyName');
|
const nameEl = document.getElementById('utilityCompanyName');
|
||||||
const contactEl = document.getElementById('utilityCompanyContact');
|
const contactEl = document.getElementById('utilityCompanyContact');
|
||||||
|
|||||||
@ -3,7 +3,7 @@ Pydantic Models and Schemas
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
@ -139,3 +139,56 @@ class Conversation(ConversationBase):
|
|||||||
deleted_at: Optional[datetime] = None
|
deleted_at: Optional[datetime] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SolutionBase(BaseModel):
|
||||||
|
"""Base schema for Case Solutions"""
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
solution_type: Optional[str] = None # Support, Drift, Konsulent, etc.
|
||||||
|
result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst
|
||||||
|
|
||||||
|
class SolutionCreate(SolutionBase):
|
||||||
|
"""Schema for creating a solution"""
|
||||||
|
sag_id: int
|
||||||
|
created_by_user_id: Optional[int] = None
|
||||||
|
|
||||||
|
class SolutionUpdate(BaseModel):
|
||||||
|
"""Schema for updating a solution"""
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
solution_type: Optional[str] = None
|
||||||
|
result: Optional[str] = None
|
||||||
|
|
||||||
|
class Solution(SolutionBase):
|
||||||
|
"""Full solution schema"""
|
||||||
|
id: int
|
||||||
|
sag_id: int
|
||||||
|
created_by_user_id: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserAdminCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
is_superadmin: bool = False
|
||||||
|
is_active: bool = True
|
||||||
|
group_ids: Optional[List[int]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserGroupsUpdate(BaseModel):
|
||||||
|
group_ids: List[int]
|
||||||
|
|
||||||
|
|
||||||
|
class GroupCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GroupPermissionsUpdate(BaseModel):
|
||||||
|
permission_ids: List[int]
|
||||||
|
|||||||
1
app/modules/nextcloud/backend/__init__.py
Normal file
1
app/modules/nextcloud/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Nextcloud module backend."""
|
||||||
272
app/modules/nextcloud/backend/router.py
Normal file
272
app/modules/nextcloud/backend/router.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
"""
|
||||||
|
Nextcloud Module - API Router
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
from app.core.crypto import encrypt_secret
|
||||||
|
from app.core.database import execute_query
|
||||||
|
from app.modules.nextcloud.backend.service import NextcloudService
|
||||||
|
from app.modules.nextcloud.models.schemas import (
|
||||||
|
NextcloudInstanceCreate,
|
||||||
|
NextcloudInstanceUpdate,
|
||||||
|
NextcloudUserCreate,
|
||||||
|
NextcloudPasswordReset,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
service = NextcloudService()
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(customer_id: int, instance_id: int, event_type: str, request_meta: dict, response_meta: dict):
|
||||||
|
query = """
|
||||||
|
INSERT INTO nextcloud_audit_log
|
||||||
|
(customer_id, instance_id, event_type, request_meta, response_meta)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
"""
|
||||||
|
execute_query(
|
||||||
|
query,
|
||||||
|
(
|
||||||
|
customer_id,
|
||||||
|
instance_id,
|
||||||
|
event_type,
|
||||||
|
json.dumps(request_meta),
|
||||||
|
json.dumps(response_meta),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances")
|
||||||
|
async def list_instances(customer_id: Optional[int] = Query(None)):
|
||||||
|
query = "SELECT * FROM nextcloud_instances WHERE deleted_at IS NULL"
|
||||||
|
params: List[int] = []
|
||||||
|
if customer_id is not None:
|
||||||
|
query += " AND customer_id = %s"
|
||||||
|
params.append(customer_id)
|
||||||
|
return execute_query(query, tuple(params)) or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/{customer_id}/instance")
|
||||||
|
async def get_instance_for_customer(customer_id: int):
|
||||||
|
query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL"
|
||||||
|
result = execute_query(query, (customer_id,))
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances")
|
||||||
|
async def create_instance(payload: NextcloudInstanceCreate):
|
||||||
|
try:
|
||||||
|
password_encrypted = encrypt_secret(payload.password)
|
||||||
|
query = """
|
||||||
|
INSERT INTO nextcloud_instances
|
||||||
|
(customer_id, base_url, auth_type, username, password_encrypted)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query(
|
||||||
|
query,
|
||||||
|
(
|
||||||
|
payload.customer_id,
|
||||||
|
payload.base_url,
|
||||||
|
payload.auth_type,
|
||||||
|
payload.username,
|
||||||
|
password_encrypted,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result[0] if result else None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("❌ Failed to create Nextcloud instance: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create instance")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/instances/{instance_id}")
|
||||||
|
async def update_instance(instance_id: int, payload: NextcloudInstanceUpdate):
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if payload.base_url is not None:
|
||||||
|
updates.append("base_url = %s")
|
||||||
|
params.append(payload.base_url)
|
||||||
|
if payload.auth_type is not None:
|
||||||
|
updates.append("auth_type = %s")
|
||||||
|
params.append(payload.auth_type)
|
||||||
|
if payload.username is not None:
|
||||||
|
updates.append("username = %s")
|
||||||
|
params.append(payload.username)
|
||||||
|
if payload.password is not None:
|
||||||
|
updates.append("password_encrypted = %s")
|
||||||
|
params.append(encrypt_secret(payload.password))
|
||||||
|
if payload.is_enabled is not None:
|
||||||
|
updates.append("is_enabled = %s")
|
||||||
|
params.append(payload.is_enabled)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
updates.append("updated_at = NOW()")
|
||||||
|
params.append(instance_id)
|
||||||
|
query = f"UPDATE nextcloud_instances SET {', '.join(updates)} WHERE id = %s RETURNING *"
|
||||||
|
result = execute_query(query, tuple(params))
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Instance not found")
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instance_id}/disable")
|
||||||
|
async def disable_instance(instance_id: int):
|
||||||
|
query = """
|
||||||
|
UPDATE nextcloud_instances
|
||||||
|
SET is_enabled = false, disabled_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (instance_id,))
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Instance not found")
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instance_id}/enable")
|
||||||
|
async def enable_instance(instance_id: int):
|
||||||
|
query = """
|
||||||
|
UPDATE nextcloud_instances
|
||||||
|
SET is_enabled = true, disabled_at = NULL, updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (instance_id,))
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Instance not found")
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instance_id}/rotate-credentials")
|
||||||
|
async def rotate_credentials(instance_id: int, payload: NextcloudInstanceUpdate):
|
||||||
|
if not payload.password:
|
||||||
|
raise HTTPException(status_code=400, detail="Password is required")
|
||||||
|
|
||||||
|
query = """
|
||||||
|
UPDATE nextcloud_instances
|
||||||
|
SET password_encrypted = %s, updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (encrypt_secret(payload.password), instance_id))
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Instance not found")
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances/{instance_id}/status")
|
||||||
|
async def get_status(instance_id: int, customer_id: Optional[int] = Query(None)):
|
||||||
|
response = await service.get_status(instance_id, customer_id)
|
||||||
|
if customer_id is not None:
|
||||||
|
_audit(customer_id, instance_id, "status", {"instance_id": instance_id}, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances/{instance_id}/groups")
|
||||||
|
async def list_groups(instance_id: int, customer_id: Optional[int] = Query(None)):
|
||||||
|
response = await service.list_groups(instance_id, customer_id)
|
||||||
|
if customer_id is not None:
|
||||||
|
_audit(customer_id, instance_id, "groups", {"instance_id": instance_id}, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances/{instance_id}/shares")
|
||||||
|
async def list_shares(instance_id: int, customer_id: Optional[int] = Query(None)):
|
||||||
|
response = await service.list_public_shares(instance_id, customer_id)
|
||||||
|
if customer_id is not None:
|
||||||
|
_audit(customer_id, instance_id, "shares", {"instance_id": instance_id}, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instance_id}/users")
|
||||||
|
async def create_user(instance_id: int, payload: NextcloudUserCreate, customer_id: Optional[int] = Query(None)):
|
||||||
|
password = secrets.token_urlsafe(12)
|
||||||
|
request_payload = {
|
||||||
|
"userid": payload.uid,
|
||||||
|
"password": password,
|
||||||
|
"email": payload.email,
|
||||||
|
"displayName": payload.display_name,
|
||||||
|
"groups[]": payload.groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await service.create_user(instance_id, customer_id, request_payload)
|
||||||
|
if customer_id is not None:
|
||||||
|
_audit(customer_id, instance_id, "create_user", {"uid": payload.uid}, response)
|
||||||
|
return {"result": response, "generated_password": password if payload.send_welcome else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instance_id}/users/{uid}/reset-password")
|
||||||
|
async def reset_password(
|
||||||
|
instance_id: int,
|
||||||
|
uid: str,
|
||||||
|
payload: NextcloudPasswordReset,
|
||||||
|
customer_id: Optional[int] = Query(None),
|
||||||
|
):
|
||||||
|
password = secrets.token_urlsafe(12)
|
||||||
|
response = await service.reset_password(instance_id, customer_id, uid, password)
|
||||||
|
if customer_id is not None:
|
||||||
|
_audit(customer_id, instance_id, "reset_password", {"uid": uid}, response)
|
||||||
|
return {"result": response, "generated_password": password if payload.send_email else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instance_id}/users/{uid}/disable")
|
||||||
|
async def disable_user(instance_id: int, uid: str, customer_id: Optional[int] = Query(None)):
|
||||||
|
response = await service.disable_user(instance_id, customer_id, uid)
|
||||||
|
if customer_id is not None:
|
||||||
|
_audit(customer_id, instance_id, "disable_user", {"uid": uid}, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instance_id}/users/{uid}/resend-guide")
|
||||||
|
async def resend_guide(instance_id: int, uid: str, customer_id: Optional[int] = Query(None)):
|
||||||
|
response = {"status": "queued", "uid": uid}
|
||||||
|
if customer_id is not None:
|
||||||
|
_audit(customer_id, instance_id, "resend_guide", {"uid": uid}, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit")
|
||||||
|
async def list_audit(
|
||||||
|
customer_id: int = Query(...),
|
||||||
|
instance_id: Optional[int] = Query(None),
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
):
|
||||||
|
query = """
|
||||||
|
SELECT * FROM nextcloud_audit_log
|
||||||
|
WHERE customer_id = %s
|
||||||
|
"""
|
||||||
|
params: List[object] = [customer_id]
|
||||||
|
if instance_id is not None:
|
||||||
|
query += " AND instance_id = %s"
|
||||||
|
params.append(instance_id)
|
||||||
|
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
return execute_query(query, tuple(params)) or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/audit/purge")
|
||||||
|
async def purge_audit(data: dict):
|
||||||
|
customer_id = data.get("customer_id")
|
||||||
|
before_date = data.get("before_date")
|
||||||
|
|
||||||
|
if not customer_id or not before_date:
|
||||||
|
raise HTTPException(status_code=400, detail="customer_id and before_date are required")
|
||||||
|
|
||||||
|
query = """
|
||||||
|
DELETE FROM nextcloud_audit_log
|
||||||
|
WHERE customer_id = %s AND created_at < %s
|
||||||
|
"""
|
||||||
|
deleted = execute_query(query, (customer_id, before_date))
|
||||||
|
return {"deleted": deleted}
|
||||||
240
app/modules/nextcloud/backend/service.py
Normal file
240
app/modules/nextcloud/backend/service.py
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
Nextcloud Integration Service
|
||||||
|
Direct OCS API calls with DB cache and audit logging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.crypto import decrypt_secret
|
||||||
|
from app.core.database import execute_query
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.read_only = settings.NEXTCLOUD_READ_ONLY
|
||||||
|
self.dry_run = settings.NEXTCLOUD_DRY_RUN
|
||||||
|
self.timeout = settings.NEXTCLOUD_TIMEOUT_SECONDS
|
||||||
|
self.cache_ttl = settings.NEXTCLOUD_CACHE_TTL_SECONDS
|
||||||
|
|
||||||
|
if self.read_only:
|
||||||
|
logger.warning("🔒 Nextcloud READ_ONLY MODE ENABLED")
|
||||||
|
elif self.dry_run:
|
||||||
|
logger.warning("🏃 Nextcloud DRY_RUN MODE ENABLED")
|
||||||
|
else:
|
||||||
|
logger.warning("⚠️ Nextcloud WRITE MODE ACTIVE")
|
||||||
|
|
||||||
|
def _get_instance(self, instance_id: int, customer_id: Optional[int] = None) -> Optional[dict]:
|
||||||
|
query = "SELECT * FROM nextcloud_instances WHERE id = %s AND deleted_at IS NULL"
|
||||||
|
params = [instance_id]
|
||||||
|
if customer_id is not None:
|
||||||
|
query += " AND customer_id = %s"
|
||||||
|
params.append(customer_id)
|
||||||
|
result = execute_query(query, tuple(params))
|
||||||
|
return result[0] if result else None
|
||||||
|
|
||||||
|
def _get_auth(self, instance: dict) -> Optional[aiohttp.BasicAuth]:
|
||||||
|
password = decrypt_secret(instance["password_encrypted"])
|
||||||
|
if not password:
|
||||||
|
return None
|
||||||
|
return aiohttp.BasicAuth(instance["username"], password)
|
||||||
|
|
||||||
|
def _cache_get(self, cache_key: str) -> Optional[dict]:
|
||||||
|
query = "SELECT payload FROM nextcloud_cache WHERE cache_key = %s AND expires_at > NOW()"
|
||||||
|
result = execute_query(query, (cache_key,))
|
||||||
|
if result:
|
||||||
|
return result[0]["payload"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _cache_set(self, cache_key: str, payload: dict) -> None:
|
||||||
|
expires_at = datetime.utcnow() + timedelta(seconds=self.cache_ttl)
|
||||||
|
query = """
|
||||||
|
INSERT INTO nextcloud_cache (cache_key, payload, expires_at)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (cache_key) DO UPDATE
|
||||||
|
SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at
|
||||||
|
"""
|
||||||
|
execute_query(query, (cache_key, json.dumps(payload), expires_at))
|
||||||
|
|
||||||
|
def _audit(
|
||||||
|
self,
|
||||||
|
customer_id: int,
|
||||||
|
instance_id: int,
|
||||||
|
event_type: str,
|
||||||
|
request_meta: dict,
|
||||||
|
response_meta: dict,
|
||||||
|
actor_user_id: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
query = """
|
||||||
|
INSERT INTO nextcloud_audit_log
|
||||||
|
(customer_id, instance_id, event_type, request_meta, response_meta, actor_user_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
"""
|
||||||
|
execute_query(
|
||||||
|
query,
|
||||||
|
(
|
||||||
|
customer_id,
|
||||||
|
instance_id,
|
||||||
|
event_type,
|
||||||
|
json.dumps(request_meta),
|
||||||
|
json.dumps(response_meta),
|
||||||
|
actor_user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_write_permission(self, operation: str) -> bool:
|
||||||
|
if self.read_only:
|
||||||
|
logger.error("🚫 BLOCKED: %s - READ_ONLY mode is enabled", operation)
|
||||||
|
return False
|
||||||
|
if self.dry_run:
|
||||||
|
logger.warning("🏃 DRY_RUN: %s - Operation will not be executed", operation)
|
||||||
|
return False
|
||||||
|
logger.warning("⚠️ EXECUTING WRITE OPERATION: %s", operation)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _ocs_request(
|
||||||
|
self,
|
||||||
|
instance: dict,
|
||||||
|
endpoint: str,
|
||||||
|
method: str = "GET",
|
||||||
|
params: Optional[dict] = None,
|
||||||
|
data: Optional[dict] = None,
|
||||||
|
use_cache: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
cache_key = None
|
||||||
|
if use_cache and method.upper() == "GET":
|
||||||
|
cache_key = f"nextcloud:{instance['id']}:{endpoint}:{json.dumps(params or {}, sort_keys=True)}"
|
||||||
|
cached = self._cache_get(cache_key)
|
||||||
|
if cached:
|
||||||
|
cached["cache_hit"] = True
|
||||||
|
return cached
|
||||||
|
|
||||||
|
auth = self._get_auth(instance)
|
||||||
|
if not auth:
|
||||||
|
return {"error": "credentials_invalid"}
|
||||||
|
|
||||||
|
base_url = instance["base_url"].rstrip("/")
|
||||||
|
url = f"{base_url}/{endpoint.lstrip('/')}"
|
||||||
|
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout)) as session:
|
||||||
|
async with session.request(
|
||||||
|
method=method.upper(),
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
auth=auth,
|
||||||
|
params=params,
|
||||||
|
data=data,
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
payload = await resp.json()
|
||||||
|
except Exception:
|
||||||
|
payload = {"raw": await resp.text()}
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"status": resp.status,
|
||||||
|
"payload": payload,
|
||||||
|
"cache_hit": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache_key and resp.status == 200:
|
||||||
|
self._cache_set(cache_key, response)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def get_status(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
|
||||||
|
instance = self._get_instance(instance_id, customer_id)
|
||||||
|
if not instance or not instance["is_enabled"]:
|
||||||
|
return {"status": "offline", "checked_at": datetime.utcnow().isoformat()}
|
||||||
|
|
||||||
|
response = await self._ocs_request(
|
||||||
|
instance,
|
||||||
|
"/ocs/v2.php/apps/serverinfo/api/v1/info",
|
||||||
|
method="GET",
|
||||||
|
use_cache=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "online" if response.get("status") == 200 else "unknown",
|
||||||
|
"checked_at": datetime.utcnow().isoformat(),
|
||||||
|
"raw": response,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_groups(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
|
||||||
|
instance = self._get_instance(instance_id, customer_id)
|
||||||
|
if not instance or not instance["is_enabled"]:
|
||||||
|
return {"groups": []}
|
||||||
|
|
||||||
|
return await self._ocs_request(
|
||||||
|
instance,
|
||||||
|
"/ocs/v1.php/cloud/groups",
|
||||||
|
method="GET",
|
||||||
|
use_cache=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_public_shares(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
|
||||||
|
instance = self._get_instance(instance_id, customer_id)
|
||||||
|
if not instance or not instance["is_enabled"]:
|
||||||
|
return {"payload": {"ocs": {"data": []}}}
|
||||||
|
|
||||||
|
return await self._ocs_request(
|
||||||
|
instance,
|
||||||
|
"/ocs/v1.php/apps/files_sharing/api/v1/shares",
|
||||||
|
method="GET",
|
||||||
|
params={"share_type": 3},
|
||||||
|
use_cache=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_user(self, instance_id: int, customer_id: Optional[int], payload: dict) -> dict:
|
||||||
|
if not self._check_write_permission("create_nextcloud_user"):
|
||||||
|
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
|
||||||
|
|
||||||
|
instance = self._get_instance(instance_id, customer_id)
|
||||||
|
if not instance or not instance["is_enabled"]:
|
||||||
|
return {"error": "instance_unavailable"}
|
||||||
|
|
||||||
|
return await self._ocs_request(
|
||||||
|
instance,
|
||||||
|
"/ocs/v1.php/cloud/users",
|
||||||
|
method="POST",
|
||||||
|
data=payload,
|
||||||
|
use_cache=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reset_password(self, instance_id: int, customer_id: Optional[int], uid: str, password: str) -> dict:
|
||||||
|
if not self._check_write_permission("reset_nextcloud_password"):
|
||||||
|
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
|
||||||
|
|
||||||
|
instance = self._get_instance(instance_id, customer_id)
|
||||||
|
if not instance or not instance["is_enabled"]:
|
||||||
|
return {"error": "instance_unavailable"}
|
||||||
|
|
||||||
|
return await self._ocs_request(
|
||||||
|
instance,
|
||||||
|
f"/ocs/v1.php/cloud/users/{uid}",
|
||||||
|
method="PUT",
|
||||||
|
data={"password": password},
|
||||||
|
use_cache=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def disable_user(self, instance_id: int, customer_id: Optional[int], uid: str) -> dict:
|
||||||
|
if not self._check_write_permission("disable_nextcloud_user"):
|
||||||
|
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
|
||||||
|
|
||||||
|
instance = self._get_instance(instance_id, customer_id)
|
||||||
|
if not instance or not instance["is_enabled"]:
|
||||||
|
return {"error": "instance_unavailable"}
|
||||||
|
|
||||||
|
return await self._ocs_request(
|
||||||
|
instance,
|
||||||
|
f"/ocs/v1.php/cloud/users/{uid}/disable",
|
||||||
|
method="PUT",
|
||||||
|
use_cache=False,
|
||||||
|
)
|
||||||
1
app/modules/nextcloud/frontend/__init__.py
Normal file
1
app/modules/nextcloud/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Nextcloud module frontend."""
|
||||||
1
app/modules/nextcloud/models/__init__.py
Normal file
1
app/modules/nextcloud/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Nextcloud module models."""
|
||||||
63
app/modules/nextcloud/models/schemas.py
Normal file
63
app/modules/nextcloud/models/schemas.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudInstanceBase(BaseModel):
|
||||||
|
customer_id: int
|
||||||
|
base_url: str
|
||||||
|
auth_type: str = "basic"
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudInstanceCreate(NextcloudInstanceBase):
|
||||||
|
password: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudInstanceUpdate(BaseModel):
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
auth_type: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
is_enabled: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudInstance(NextcloudInstanceBase):
|
||||||
|
id: int
|
||||||
|
is_enabled: bool
|
||||||
|
disabled_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudStatus(BaseModel):
|
||||||
|
status: str
|
||||||
|
checked_at: datetime
|
||||||
|
version: Optional[str] = None
|
||||||
|
php: Optional[str] = None
|
||||||
|
db: Optional[str] = None
|
||||||
|
metrics: Dict[str, Optional[str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudUserCreate(BaseModel):
|
||||||
|
uid: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
groups: List[str] = []
|
||||||
|
send_welcome: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudPasswordReset(BaseModel):
|
||||||
|
send_email: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudAuditLogEntry(BaseModel):
|
||||||
|
id: int
|
||||||
|
customer_id: int
|
||||||
|
instance_id: Optional[int] = None
|
||||||
|
event_type: str
|
||||||
|
request_meta: Optional[Dict] = None
|
||||||
|
response_meta: Optional[Dict] = None
|
||||||
|
actor_user_id: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
19
app/modules/nextcloud/module.json
Normal file
19
app/modules/nextcloud/module.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "nextcloud",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Nextcloud integration: status, users, and audit log",
|
||||||
|
"author": "BMC Networks",
|
||||||
|
"enabled": true,
|
||||||
|
"dependencies": [],
|
||||||
|
"table_prefix": "nextcloud_",
|
||||||
|
"api_prefix": "/api/v1/nextcloud",
|
||||||
|
"tags": [
|
||||||
|
"Nextcloud"
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"safety_switches": {
|
||||||
|
"read_only": true,
|
||||||
|
"dry_run": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/modules/nextcloud/templates/tab.html
Normal file
38
app/modules/nextcloud/templates/tab.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<div class="row g-4" id="nextcloudTabContent">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="info-card">
|
||||||
|
<h5 class="fw-bold mb-3">Systemstatus</h5>
|
||||||
|
<div id="ncStatusBadge" class="badge bg-secondary">Ukendt</div>
|
||||||
|
<div class="mt-2 small text-muted" id="ncLastUpdated">-</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="info-row"><span class="info-label">CPU load</span><span class="info-value" id="ncCpuLoad">-</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Free disk</span><span class="info-value" id="ncFreeDisk">-</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">RAM usage</span><span class="info-value" id="ncRamUsage">-</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">OPCache hit rate</span><span class="info-value" id="ncOpcache">-</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 d-flex flex-wrap gap-2" id="ncAlerts"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="info-card">
|
||||||
|
<h5 class="fw-bold mb-3">Handlinger</h5>
|
||||||
|
<button class="btn btn-primary w-100 mb-2" onclick="openNextcloudCreateUser()">Tilføj ny bruger</button>
|
||||||
|
<button class="btn btn-outline-secondary w-100 mb-2" onclick="openNextcloudResetPassword()">Reset password</button>
|
||||||
|
<button class="btn btn-outline-danger w-100" onclick="openNextcloudDisableUser()">Luk bruger</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="info-card">
|
||||||
|
<h5 class="fw-bold mb-3">Nøgletal</h5>
|
||||||
|
<div class="info-row"><span class="info-label">File count growth</span><span class="info-value" id="ncFileGrowth">-</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Public shares uden password</span><span class="info-value" id="ncPublicShares">-</span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Active users</span><span class="info-value" id="ncActiveUsers">-</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="info-card">
|
||||||
|
<h5 class="fw-bold mb-3">Historik</h5>
|
||||||
|
<div id="ncHistory">Ingen events endnu.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
108
app/modules/sag/backend/solutions.py
Normal file
108
app/modules/sag/backend/solutions.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database import execute_query
|
||||||
|
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
|
||||||
|
async def get_solution(sag_id: int):
|
||||||
|
"""Get the solution associated with a case."""
|
||||||
|
try:
|
||||||
|
query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
|
||||||
|
result = execute_query(query, (sag_id,))
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
return result[0]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to get solution")
|
||||||
|
|
||||||
|
@router.post("/sag/{sag_id}/solution", response_model=Solution)
|
||||||
|
async def create_solution(sag_id: int, solution: SolutionCreate):
|
||||||
|
"""Create a solution for a case."""
|
||||||
|
try:
|
||||||
|
# Check if case exists
|
||||||
|
case_check = execute_query("SELECT id FROM sag_sager WHERE id = %s", (sag_id,))
|
||||||
|
if not case_check:
|
||||||
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
|
# Check if solution already exists
|
||||||
|
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
|
||||||
|
if check:
|
||||||
|
raise HTTPException(status_code=400, detail="Solution already exists for this case")
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO sag_solutions
|
||||||
|
(sag_id, title, description, solution_type, result, created_by_user_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
params = (
|
||||||
|
sag_id,
|
||||||
|
solution.title,
|
||||||
|
solution.description,
|
||||||
|
solution.solution_type,
|
||||||
|
solution.result,
|
||||||
|
solution.created_by_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
result = execute_query(query, params)
|
||||||
|
if result:
|
||||||
|
logger.info("✅ Solution created for case: %s", sag_id)
|
||||||
|
return result[0]
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create solution")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error creating solution: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create solution")
|
||||||
|
|
||||||
|
@router.patch("/sag/{sag_id}/solution", response_model=Solution)
|
||||||
|
async def update_solution(sag_id: int, updates: SolutionUpdate):
|
||||||
|
"""Update a solution."""
|
||||||
|
try:
|
||||||
|
# Check if solution exists
|
||||||
|
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
|
||||||
|
if not check:
|
||||||
|
raise HTTPException(status_code=404, detail="Solution not found")
|
||||||
|
|
||||||
|
# Build dynamic update query
|
||||||
|
set_clauses = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
# Helper to check and add params
|
||||||
|
if updates.title is not None:
|
||||||
|
set_clauses.append("title = %s")
|
||||||
|
params.append(updates.title)
|
||||||
|
if updates.description is not None:
|
||||||
|
set_clauses.append("description = %s")
|
||||||
|
params.append(updates.description)
|
||||||
|
if updates.solution_type is not None:
|
||||||
|
set_clauses.append("solution_type = %s")
|
||||||
|
params.append(updates.solution_type)
|
||||||
|
if updates.result is not None:
|
||||||
|
set_clauses.append("result = %s")
|
||||||
|
params.append(updates.result)
|
||||||
|
|
||||||
|
if not set_clauses:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
set_clauses.append("updated_at = NOW()")
|
||||||
|
|
||||||
|
params.append(sag_id)
|
||||||
|
query = f"UPDATE sag_solutions SET {', '.join(set_clauses)} WHERE sag_id = %s RETURNING *"
|
||||||
|
|
||||||
|
result = execute_query(query, tuple(params))
|
||||||
|
if result:
|
||||||
|
logger.info("✅ Solution updated for case: %s", sag_id)
|
||||||
|
return result[0]
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to update solution")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error updating solution: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to update solution")
|
||||||
@ -109,6 +109,18 @@ async def sager_liste(
|
|||||||
logger.error("❌ Error displaying case list: %s", e)
|
logger.error("❌ Error displaying case list: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to load case list")
|
raise HTTPException(status_code=500, detail="Failed to load case list")
|
||||||
|
|
||||||
|
@router.get("/sag/new", response_class=HTMLResponse)
|
||||||
|
async def opret_sag_side(request: Request):
|
||||||
|
"""Show create case form."""
|
||||||
|
return templates.TemplateResponse("modules/sag/templates/create.html", {"request": request})
|
||||||
|
|
||||||
|
@router.get("/sag/varekob-salg", response_class=HTMLResponse)
|
||||||
|
async def sag_varekob_salg(request: Request):
|
||||||
|
"""Display orders overview for all purchases and sales."""
|
||||||
|
return templates.TemplateResponse("modules/sag/templates/varekob_salg.html", {
|
||||||
|
"request": request,
|
||||||
|
})
|
||||||
|
|
||||||
@router.get("/sag/{sag_id}", response_class=HTMLResponse)
|
@router.get("/sag/{sag_id}", response_class=HTMLResponse)
|
||||||
async def sag_detaljer(request: Request, sag_id: int):
|
async def sag_detaljer(request: Request, sag_id: int):
|
||||||
"""Display case details."""
|
"""Display case details."""
|
||||||
@ -122,10 +134,21 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
|
|
||||||
sag = sag_result[0]
|
sag = sag_result[0]
|
||||||
|
|
||||||
# Fetch tags
|
# Fetch tags (Support both Legacy sag_tags and New entity_tags)
|
||||||
tags_query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
|
# First try the new system (entity_tags) which the valid frontend uses
|
||||||
|
tags_query = """
|
||||||
|
SELECT t.name as tag_navn
|
||||||
|
FROM tags t
|
||||||
|
JOIN entity_tags et ON t.id = et.tag_id
|
||||||
|
WHERE et.entity_type = 'case' AND et.entity_id = %s
|
||||||
|
"""
|
||||||
tags = execute_query(tags_query, (sag_id,))
|
tags = execute_query(tags_query, (sag_id,))
|
||||||
|
|
||||||
|
# If empty, try legacy table fallback
|
||||||
|
if not tags:
|
||||||
|
tags_query_legacy = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
|
||||||
|
tags = execute_query(tags_query_legacy, (sag_id,))
|
||||||
|
|
||||||
# Fetch relations
|
# Fetch relations
|
||||||
relationer_query = """
|
relationer_query = """
|
||||||
SELECT sr.*,
|
SELECT sr.*,
|
||||||
@ -140,6 +163,92 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
"""
|
"""
|
||||||
relationer = execute_query(relationer_query, (sag_id, sag_id))
|
relationer = execute_query(relationer_query, (sag_id, sag_id))
|
||||||
|
|
||||||
|
# --- Relation Tree Construction ---
|
||||||
|
relation_tree = []
|
||||||
|
try:
|
||||||
|
# 1. Get all connected case IDs (Recursive CTE)
|
||||||
|
tree_ids_query = """
|
||||||
|
WITH RECURSIVE CaseTree AS (
|
||||||
|
SELECT id FROM sag_sager WHERE id = %s
|
||||||
|
UNION
|
||||||
|
SELECT CASE WHEN sr.kilde_sag_id = ct.id THEN sr.målsag_id ELSE sr.kilde_sag_id END
|
||||||
|
FROM sag_relationer sr
|
||||||
|
JOIN CaseTree ct ON sr.kilde_sag_id = ct.id OR sr.målsag_id = ct.id
|
||||||
|
WHERE sr.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT id FROM CaseTree LIMIT 50;
|
||||||
|
"""
|
||||||
|
tree_ids_rows = execute_query(tree_ids_query, (sag_id,))
|
||||||
|
tree_ids = [r['id'] for r in tree_ids_rows]
|
||||||
|
|
||||||
|
if tree_ids:
|
||||||
|
# 2. Fetch details
|
||||||
|
placeholders = ','.join(['%s'] * len(tree_ids))
|
||||||
|
tree_cases_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders})"
|
||||||
|
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
|
||||||
|
|
||||||
|
# 3. Fetch edges
|
||||||
|
tree_edges_query = f"""
|
||||||
|
SELECT id, kilde_sag_id, målsag_id, relationstype
|
||||||
|
FROM sag_relationer
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND kilde_sag_id IN ({placeholders})
|
||||||
|
AND målsag_id IN ({placeholders})
|
||||||
|
"""
|
||||||
|
tree_edges = execute_query(tree_edges_query, tuple(tree_ids) * 2)
|
||||||
|
|
||||||
|
# 4. Build Graph
|
||||||
|
children_map = {cid: [] for cid in tree_ids}
|
||||||
|
parents_map = {cid: [] for cid in tree_ids}
|
||||||
|
|
||||||
|
for edge in tree_edges:
|
||||||
|
k, m, rtype = edge['kilde_sag_id'], edge['målsag_id'], edge['relationstype'].lower()
|
||||||
|
parent, child = k, m # Default (e.g. Relateret til)
|
||||||
|
|
||||||
|
if rtype == 'afledt af': # m is parent of k
|
||||||
|
parent, child = m, k
|
||||||
|
elif rtype == 'årsag til': # k is parent of m
|
||||||
|
parent, child = k, m
|
||||||
|
|
||||||
|
if parent in children_map:
|
||||||
|
children_map[parent].append({
|
||||||
|
'id': child,
|
||||||
|
'type': edge['relationstype'],
|
||||||
|
'rel_id': edge['id']
|
||||||
|
})
|
||||||
|
if child in parents_map:
|
||||||
|
parents_map[child].append(parent)
|
||||||
|
|
||||||
|
# 5. Identify Roots and Build
|
||||||
|
roots = [cid for cid in tree_ids if not parents_map[cid]]
|
||||||
|
if not roots and tree_ids: roots = [min(tree_ids)] # Fallback
|
||||||
|
|
||||||
|
def build_tree_node(cid, visited):
|
||||||
|
if cid in visited: return None
|
||||||
|
visited.add(cid)
|
||||||
|
node_case = tree_cases.get(cid)
|
||||||
|
if not node_case: return None
|
||||||
|
|
||||||
|
children_nodes = []
|
||||||
|
for child_info in children_map.get(cid, []):
|
||||||
|
c_node = build_tree_node(child_info['id'], visited.copy())
|
||||||
|
if c_node:
|
||||||
|
c_node['relation_type'] = child_info['type']
|
||||||
|
c_node['relation_id'] = child_info['rel_id']
|
||||||
|
children_nodes.append(c_node)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'case': node_case,
|
||||||
|
'children': children_nodes,
|
||||||
|
'is_current': cid == sag_id
|
||||||
|
}
|
||||||
|
|
||||||
|
relation_tree = [build_tree_node(r, set()) for r in roots]
|
||||||
|
relation_tree = [n for n in relation_tree if n]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error building relation tree: {e}")
|
||||||
|
relation_tree = []
|
||||||
|
|
||||||
# Fetch customer info if customer_id exists
|
# Fetch customer info if customer_id exists
|
||||||
customer = None
|
customer = None
|
||||||
hovedkontakt = None
|
hovedkontakt = None
|
||||||
@ -162,16 +271,110 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
if kontakt_result:
|
if kontakt_result:
|
||||||
hovedkontakt = kontakt_result[0]
|
hovedkontakt = kontakt_result[0]
|
||||||
|
|
||||||
|
# Fetch prepaid cards for customer
|
||||||
|
# Cast remaining_hours to float to avoid Jinja formatting issues with Decimal
|
||||||
|
# DEBUG: Logging customer ID
|
||||||
|
cid = sag.get('customer_id')
|
||||||
|
logger.info(f"🔎 Looking up prepaid cards for Sag {sag_id}, Customer ID: {cid} (Type: {type(cid)})")
|
||||||
|
|
||||||
|
pc_query = """
|
||||||
|
SELECT id, card_number, CAST(remaining_hours AS FLOAT) as remaining_hours
|
||||||
|
FROM tticket_prepaid_cards
|
||||||
|
WHERE customer_id = %s
|
||||||
|
AND status = 'active'
|
||||||
|
AND remaining_hours > 0
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"""
|
||||||
|
prepaid_cards = execute_query(pc_query, (cid,))
|
||||||
|
logger.info(f"💳 Found {len(prepaid_cards)} prepaid cards for customer {cid}")
|
||||||
|
else:
|
||||||
|
prepaid_cards = []
|
||||||
|
|
||||||
|
# Fetch Nextcloud Instance for this customer
|
||||||
|
nextcloud_instance = None
|
||||||
|
if customer:
|
||||||
|
nc_query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL"
|
||||||
|
nc_result = execute_query(nc_query, (customer['id'],))
|
||||||
|
if nc_result:
|
||||||
|
nextcloud_instance = nc_result[0]
|
||||||
|
|
||||||
|
# Fetch linked contacts
|
||||||
|
contacts_query = """
|
||||||
|
SELECT sk.*, c.first_name || ' ' || c.last_name as contact_name, c.email as contact_email
|
||||||
|
FROM sag_kontakter sk
|
||||||
|
JOIN contacts c ON sk.contact_id = c.id
|
||||||
|
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
contacts = execute_query(contacts_query, (sag_id,))
|
||||||
|
|
||||||
|
# Fetch linked customers
|
||||||
|
customers_query = """
|
||||||
|
SELECT sk.*, c.name as customer_name, c.email as customer_email
|
||||||
|
FROM sag_kunder sk
|
||||||
|
JOIN customers c ON sk.customer_id = c.id
|
||||||
|
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
customers = execute_query(customers_query, (sag_id,))
|
||||||
|
|
||||||
|
# Fetch comments
|
||||||
|
comments_query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at ASC"
|
||||||
|
comments = execute_query(comments_query, (sag_id,))
|
||||||
|
|
||||||
|
# Fetch Solution
|
||||||
|
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
|
||||||
|
solution_res = execute_query(solution_query, (sag_id,))
|
||||||
|
solution = solution_res[0] if solution_res else None
|
||||||
|
|
||||||
|
# Fetch Time Entries
|
||||||
|
time_query = "SELECT * FROM tmodule_times WHERE sag_id = %s ORDER BY worked_date DESC"
|
||||||
|
time_entries = execute_query(time_query, (sag_id,))
|
||||||
|
|
||||||
|
# Check for nextcloud integration (case-insensitive, insensitive to whitespace)
|
||||||
|
logger.info(f"Checking tags for Nextcloud on case {sag_id}: {tags}")
|
||||||
|
is_nextcloud = any(t['tag_navn'] and t['tag_navn'].strip().lower() == 'nextcloud' for t in tags)
|
||||||
|
logger.info(f"is_nextcloud result: {is_nextcloud}")
|
||||||
|
|
||||||
return templates.TemplateResponse("modules/sag/templates/detail.html", {
|
return templates.TemplateResponse("modules/sag/templates/detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"case": sag,
|
"case": sag,
|
||||||
"customer": customer,
|
"customer": customer,
|
||||||
"hovedkontakt": hovedkontakt,
|
"hovedkontakt": hovedkontakt,
|
||||||
|
"contacts": contacts,
|
||||||
|
"customers": customers,
|
||||||
|
"prepaid_cards": prepaid_cards,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
|
|
||||||
"relationer": relationer,
|
"relationer": relationer,
|
||||||
|
"relation_tree": relation_tree,
|
||||||
|
"comments": comments,
|
||||||
|
"solution": solution,
|
||||||
|
"time_entries": time_entries,
|
||||||
|
"is_nextcloud": is_nextcloud,
|
||||||
|
"nextcloud_instance": nextcloud_instance,
|
||||||
})
|
})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Error displaying case details: %s", e)
|
logger.error("❌ Error displaying case details: %s", e)
|
||||||
raise HTTPException(status_code=500, detail="Failed to load case details")
|
raise HTTPException(status_code=500, detail="Failed to load case details")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sag/{sag_id}/edit", response_class=HTMLResponse)
|
||||||
|
async def sag_rediger(request: Request, sag_id: int):
|
||||||
|
"""Display edit case form."""
|
||||||
|
try:
|
||||||
|
sag_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL"
|
||||||
|
sag_result = execute_query(sag_query, (sag_id,))
|
||||||
|
|
||||||
|
if not sag_result:
|
||||||
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
|
return templates.TemplateResponse("modules/sag/templates/edit.html", {
|
||||||
|
"request": request,
|
||||||
|
"case": sag_result[0],
|
||||||
|
})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error loading edit case page: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to load edit case page")
|
||||||
|
|||||||
36
app/modules/sag/migrations/002_varekob_salg.sql
Normal file
36
app/modules/sag/migrations/002_varekob_salg.sql
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
-- Sag Module: Varekøb & Salg (case-linked sales items)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_salgsvarer (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(20) NOT NULL DEFAULT 'sale', -- sale | purchase
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
quantity NUMERIC(12, 2),
|
||||||
|
unit VARCHAR(50),
|
||||||
|
unit_price NUMERIC(12, 2),
|
||||||
|
amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
currency VARCHAR(10) NOT NULL DEFAULT 'DKK',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft | confirmed | cancelled
|
||||||
|
line_date DATE,
|
||||||
|
external_ref VARCHAR(100),
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_sag_id ON sag_salgsvarer(sag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_type ON sag_salgsvarer(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_status ON sag_salgsvarer(status);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_sag_salgsvarer_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trigger_sag_salgsvarer_updated_at ON sag_salgsvarer;
|
||||||
|
CREATE TRIGGER trigger_sag_salgsvarer_updated_at
|
||||||
|
BEFORE UPDATE ON sag_salgsvarer
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_sag_salgsvarer_updated_at();
|
||||||
@ -4,106 +4,37 @@
|
|||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
.form-container {
|
/* Gradient Header for the Card */
|
||||||
max-width: 600px;
|
.card-header-custom {
|
||||||
margin: 2rem auto;
|
background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top-left-radius: 12px;
|
||||||
|
border-top-right-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-container h1 {
|
.card-custom {
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
box-shadow: 0 10px 30px rgba(0,0,0,0.08);
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
overflow: visible; /* Changed from hidden to visible for shadows/tooltips */
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.form-label {
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent);
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control, .form-select {
|
.form-control:focus, .form-select:focus {
|
||||||
border-radius: 8px;
|
border-color: #3282b8;
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
box-shadow: 0 0 0 0.25rem rgba(50, 130, 184, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-submit {
|
/* Search Results Dropdown */
|
||||||
background-color: var(--accent);
|
.search-position-relative {
|
||||||
color: white;
|
position: relative;
|
||||||
padding: 0.7rem 2rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-submit:hover {
|
|
||||||
background-color: #0056b3;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
background-color: #6c757d;
|
|
||||||
color: white;
|
|
||||||
padding: 0.7rem 2rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel:hover {
|
|
||||||
background-color: #5a6268;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
display: none;
|
|
||||||
padding: 1rem;
|
|
||||||
background-color: #f8d7da;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #721c24;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="dark"] .error {
|
|
||||||
background-color: #5c2b2f;
|
|
||||||
border-color: #8c3b3f;
|
|
||||||
color: #f8a5ac;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success {
|
|
||||||
display: none;
|
|
||||||
padding: 1rem;
|
|
||||||
background-color: #d4edda;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #155724;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-results {
|
.search-results {
|
||||||
@ -111,25 +42,25 @@
|
|||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--bg-body);
|
background: var(--bg-surface, #ffffff);
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
border: 1px solid var(--border-color, rgba(0,0,0,0.1));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-height: 300px;
|
max-height: 250px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 1000;
|
z-index: 1050;
|
||||||
margin-top: 0.5rem;
|
margin-top: 5px;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-result-item {
|
.search-result-item {
|
||||||
padding: 0.8rem 1rem;
|
padding: 10px 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.05));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
transition: background-color 0.15s ease;
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-result-item:hover {
|
.search-result-item:hover {
|
||||||
background: var(--accent-light);
|
background-color: var(--bg-hover, #f8f9fa);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-result-item:last-child {
|
.search-result-item:last-child {
|
||||||
@ -138,102 +69,190 @@
|
|||||||
|
|
||||||
.search-result-name {
|
.search-result-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent);
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-result-meta {
|
.search-result-meta {
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary, #6c757d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Selected Items (Tags) */
|
||||||
.selected-item {
|
.selected-item {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
background: var(--accent-light);
|
align-items: center;
|
||||||
color: var(--accent);
|
background-color: #e3f2fd;
|
||||||
padding: 0.4rem 0.8rem;
|
color: #0f4c75;
|
||||||
|
padding: 6px 12px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-right: 0.5rem;
|
margin-right: 8px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 8px;
|
||||||
|
border: 1px solid #bbdefb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-item button {
|
.selected-item button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--accent);
|
color: #0f4c75;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 1.1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-left: 0.4rem;
|
opacity: 0.6;
|
||||||
font-weight: bold;
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-item button:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode Adjustments */
|
||||||
|
[data-bs-theme="dark"] .card-custom {
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .selected-item {
|
||||||
|
background-color: rgba(50, 130, 184, 0.2);
|
||||||
|
color: #a6d5fa;
|
||||||
|
border-color: rgba(50, 130, 184, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .selected-item button {
|
||||||
|
color: #a6d5fa;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="form-container">
|
<div class="container py-4">
|
||||||
<div class="card">
|
<div class="row justify-content-center">
|
||||||
<div class="card-body">
|
<div class="col-lg-8">
|
||||||
<h1 style="color: var(--accent); margin-bottom: 2rem;">📝 Opret Ny Sag</h1>
|
<!-- Main Card -->
|
||||||
|
<div class="card card-custom">
|
||||||
<div id="error" class="error"></div>
|
<div class="card-header-custom">
|
||||||
<div id="success" class="success"></div>
|
<h2 class="mb-0 fs-4 fw-bold"><i class="bi bi-plus-circle me-2"></i>Opret Ny Sag</h2>
|
||||||
|
<p class="mb-0 opacity-75 small mt-1">Udfyld formularen for at oprette en ny sag i systemet.</p>
|
||||||
<form id="createForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="titel">Titel *</label>
|
|
||||||
<input type="text" class="form-control" id="titel" placeholder="Indtast sagens titel" required>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="card-body p-4">
|
||||||
<label for="beskrivelse">Beskrivelse</label>
|
<!-- Notifications -->
|
||||||
<textarea class="form-control" id="beskrivelse" rows="4" placeholder="Optionalt: Detaljeret beskrivelse af sagen"></textarea>
|
<div id="error" class="alert alert-danger d-none shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i><span id="error-text"></span>
|
||||||
|
</div>
|
||||||
|
<div id="success" class="alert alert-success d-none shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i><span id="success-text"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<form id="createForm" novalidate>
|
||||||
<label for="status">Status *</label>
|
<!-- Section: Basic Info -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="titel" class="form-label">Titel <span class="text-danger">*</span></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-light border-end-0"><i class="bi bi-type-h1"></i></span>
|
||||||
|
<input type="text" class="form-control border-start-0 ps-0" id="titel" placeholder="Kort og præcis titel (f.eks. 'Netværksproblemer hos X')" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="beskrivelse" class="form-label">Beskrivelse</label>
|
||||||
|
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
|
||||||
|
<div class="form-text text-end" id="charCount">0 tegn</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4 opacity-25">
|
||||||
|
|
||||||
|
<!-- Section: Relations -->
|
||||||
|
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<!-- Contact Search -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Kontaktpersoner</label>
|
||||||
|
<div class="search-position-relative">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-light border-end-0"><i class="bi bi-person-plus"></i></span>
|
||||||
|
<input type="text" id="contactSearch" class="form-control border-start-0 ps-0" placeholder="Søg kontakt...">
|
||||||
|
</div>
|
||||||
|
<div id="contactResults" class="search-results shadow-sm d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div id="selectedContacts" class="mt-2 text-wrap"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer Search -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Kunde</label>
|
||||||
|
<div class="search-position-relative">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-light border-end-0"><i class="bi bi-building"></i></span>
|
||||||
|
<input type="text" id="customerSearch" class="form-control border-start-0 ps-0" placeholder="Søg kunde (min. 2 tegn)...">
|
||||||
|
</div>
|
||||||
|
<div id="customerResults" class="search-results shadow-sm d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div id="selectedCustomer" class="mt-2 min-vh-20"></div>
|
||||||
|
<input type="hidden" id="customer_id" name="customer_id">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4 opacity-25">
|
||||||
|
|
||||||
|
<!-- Section: Metadata -->
|
||||||
|
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="type" class="form-label">Type <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select" id="type" required>
|
||||||
|
<option value="ticket" selected>🎫 Ticket</option>
|
||||||
|
<option value="opgave">🧩 Opgave</option>
|
||||||
|
<option value="ordre">🧾 Ordre</option>
|
||||||
|
<option value="projekt">📁 Projekt</option>
|
||||||
|
<option value="service">🛠️ Service</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
|
||||||
<select class="form-select" id="status" required>
|
<select class="form-select" id="status" required>
|
||||||
<option value="">Vælg status</option>
|
<option value="åben" selected>🟢 Åben</option>
|
||||||
<option value="åben">Åben</option>
|
<option value="afventer">🟡 Afventer</option>
|
||||||
<option value="lukket">Lukket</option>
|
<option value="lukket">🔴 Lukket</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="col-md-4">
|
||||||
<label>Kunde (valgfrit)</label>
|
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig (ID)</label>
|
||||||
<div style="position: relative; margin-bottom: 1rem;">
|
<div class="input-group">
|
||||||
<input type="text" id="customerSearch" class="form-control" placeholder="Søg efter kunde...">
|
<span class="input-group-text bg-light border-end-0"><i class="bi bi-person-badge"></i></span>
|
||||||
<div id="customerResults" class="search-results" style="display: none;"></div>
|
<input type="number" class="form-control border-start-0 ps-0" id="ansvarlig_bruger_id" placeholder="Bruger ID">
|
||||||
</div>
|
</div>
|
||||||
<div id="selectedCustomer" style="min-height: 1.5rem;"></div>
|
|
||||||
<input type="hidden" id="customer_id" name="customer_id">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="col-md-4">
|
||||||
<label>Kontakter (valgfrit)</label>
|
<label for="deadline" class="form-label">Deadline</label>
|
||||||
<div style="position: relative; margin-bottom: 1rem;">
|
<div class="input-group">
|
||||||
<input type="text" id="contactSearch" class="form-control" placeholder="Søg efter kontakt...">
|
<span class="input-group-text bg-light border-end-0"><i class="bi bi-calendar-event"></i></span>
|
||||||
<div id="contactResults" class="search-results" style="display: none;"></div>
|
<input type="datetime-local" class="form-control border-start-0 ps-0" id="deadline">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="selectedContacts" style="min-height: 1.5rem;"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<!-- Action Buttons -->
|
||||||
<label for="ansvarlig_bruger_id">Ansvarlig Bruger (valgfrit)</label>
|
<div class="d-flex justify-content-end gap-3 mt-5">
|
||||||
<input type="number" class="form-control" id="ansvarlig_bruger_id" placeholder="Brugers ID">
|
<a href="/sag" class="btn btn-light border px-4 fw-bold">Annuller</a>
|
||||||
</div>
|
<button type="submit" class="btn btn-primary px-5 fw-bold shadow-sm" id="submitBtn">
|
||||||
|
<span class="d-flex align-items-center">
|
||||||
<div class="form-group">
|
<i class="bi bi-check-lg me-2"></i>Opret Sag
|
||||||
<label for="deadline">Deadline (valgfrit)</label>
|
</span>
|
||||||
<input type="datetime-local" class="form-control" id="deadline">
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="button-group">
|
|
||||||
<button type="submit" class="btn-submit">Opret Sag</button>
|
|
||||||
<a href="/cases" class="btn-cancel">Annuller</a>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -242,208 +261,285 @@
|
|||||||
let customerSearchTimeout;
|
let customerSearchTimeout;
|
||||||
let contactSearchTimeout;
|
let contactSearchTimeout;
|
||||||
|
|
||||||
// Initialize search functionality
|
// --- Character Counter ---
|
||||||
|
const beskrInput = document.getElementById('beskrivelse');
|
||||||
|
if (beskrInput) {
|
||||||
|
beskrInput.addEventListener('input', function(e) {
|
||||||
|
document.getElementById('charCount').textContent = e.target.value.length + " tegn";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search Logic ---
|
||||||
function initializeSearch() {
|
function initializeSearch() {
|
||||||
const customerSearchInput = document.getElementById('customerSearch');
|
// Customer Search
|
||||||
const contactSearchInput = document.getElementById('contactSearch');
|
const customerInput = document.getElementById('customerSearch');
|
||||||
|
if (customerInput) {
|
||||||
if (customerSearchInput) {
|
customerInput.addEventListener('input', (e) => handleSearch(e, 'customer'));
|
||||||
customerSearchInput.addEventListener('input', function(e) {
|
// Close dropdown when clicking outside
|
||||||
clearTimeout(customerSearchTimeout);
|
document.addEventListener('click', (e) => {
|
||||||
const query = e.target.value.trim();
|
if (!e.target.closest('.search-position-relative')) {
|
||||||
|
const cr = document.getElementById('customerResults');
|
||||||
if (query.length < 2) {
|
if(cr) cr.classList.add('d-none');
|
||||||
document.getElementById('customerResults').style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
customerSearchTimeout = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
|
|
||||||
const customers = await response.json();
|
|
||||||
|
|
||||||
const resultsDiv = document.getElementById('customerResults');
|
|
||||||
if (customers.length === 0) {
|
|
||||||
resultsDiv.innerHTML = '<div style="padding: 10px; color: #999;">Ingen kunder fundet</div>';
|
|
||||||
} else {
|
|
||||||
resultsDiv.innerHTML = customers.map(c => `
|
|
||||||
<div class="search-result-item" onclick="selectCustomer(${c.id}, '${c.name.replace(/'/g, "\\'")}')">
|
|
||||||
<div class="search-result-name">${c.name}</div>
|
|
||||||
<div class="search-result-meta">${c.email || 'Ingen email'}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
resultsDiv.style.display = 'block';
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error searching customers:', err);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contactSearchInput) {
|
// Contact Search
|
||||||
contactSearchInput.addEventListener('input', function(e) {
|
const contactInput = document.getElementById('contactSearch');
|
||||||
clearTimeout(contactSearchTimeout);
|
if (contactInput) {
|
||||||
const query = e.target.value.trim();
|
contactInput.addEventListener('input', (e) => handleSearch(e, 'contact'));
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
if (query.length < 2) {
|
if (!e.target.closest('.search-position-relative')) {
|
||||||
document.getElementById('contactResults').style.display = 'none';
|
const cr = document.getElementById('contactResults');
|
||||||
return;
|
if(cr) cr.classList.add('d-none');
|
||||||
}
|
}
|
||||||
|
|
||||||
contactSearchTimeout = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(query)}`);
|
|
||||||
const contacts = await response.json();
|
|
||||||
|
|
||||||
const resultsDiv = document.getElementById('contactResults');
|
|
||||||
if (contacts.length === 0) {
|
|
||||||
resultsDiv.innerHTML = '<div style="padding: 10px; color: #999;">Ingen kontakter fundet</div>';
|
|
||||||
} else {
|
|
||||||
resultsDiv.innerHTML = contacts.map(c => `
|
|
||||||
<div class="search-result-item" onclick="selectContact(${c.id}, '${(c.first_name + ' ' + c.last_name).replace(/'/g, "\\'")}')">
|
|
||||||
<div class="search-result-name">${c.first_name} ${c.last_name}</div>
|
|
||||||
<div class="search-result-meta">${c.email || 'Ingen email'}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
resultsDiv.style.display = 'block';
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error searching contacts:', err);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
function handleSearch(event, type) {
|
||||||
if (document.readyState === 'loading') {
|
const query = event.target.value.trim();
|
||||||
document.addEventListener('DOMContentLoaded', initializeSearch);
|
const resultsId = type === 'customer' ? 'customerResults' : 'contactResults';
|
||||||
|
const timeoutVar = type === 'customer' ? customerSearchTimeout : contactSearchTimeout;
|
||||||
|
const resultsDiv = document.getElementById(resultsId);
|
||||||
|
|
||||||
|
clearTimeout(timeoutVar);
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
resultsDiv.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
resultsDiv.classList.remove('d-none');
|
||||||
|
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
|
||||||
|
|
||||||
|
const endpoint = type === 'customer' ? '/api/v1/search/customers' : '/api/v1/search/contacts';
|
||||||
|
const response = await fetch(`${endpoint}?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
resultsDiv.innerHTML = `<div class="p-3 text-danger small">Fejl ved søgning: ${errorText}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
resultsDiv.innerHTML = '<div class="p-3 text-muted small">Ingen fundet</div>';
|
||||||
} else {
|
} else {
|
||||||
initializeSearch();
|
resultsDiv.innerHTML = data.map(item => {
|
||||||
}
|
const name = type === 'customer' ? item.name : `${item.first_name} ${item.last_name}`;
|
||||||
|
const meta = item.email || (type === 'customer' ? 'CVR: ' + (item.cvr_nummer || '-') : '-');
|
||||||
|
|
||||||
function selectCustomer(customerId, customerName) {
|
// Handle escaping for JS function call
|
||||||
selectedCustomer = { id: customerId, name: customerName };
|
const safeName = name.replace(/'/g, "\\'");
|
||||||
document.getElementById('customer_id').value = customerId;
|
const fn = type === 'customer' ? `selectCustomer(${item.id}, '${safeName}')` : `selectContact(${item.id}, '${safeName}')`;
|
||||||
document.getElementById('customerSearch').value = '';
|
|
||||||
document.getElementById('customerResults').style.display = 'none';
|
|
||||||
updateSelectedCustomer();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelectedCustomer() {
|
return `
|
||||||
const div = document.getElementById('selectedCustomer');
|
<div class="search-result-item" onclick="${fn}">
|
||||||
if (selectedCustomer) {
|
<div class="search-result-name">${name}</div>
|
||||||
div.innerHTML = `
|
<div class="search-result-meta">${meta}</div>
|
||||||
<span class="selected-item">
|
</div>
|
||||||
🏢 ${selectedCustomer.name}
|
|
||||||
<button type="button" onclick="removeCustomer()">×</button>
|
|
||||||
</span>
|
|
||||||
`;
|
`;
|
||||||
} else {
|
}).join('');
|
||||||
div.innerHTML = '';
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Search error:', err);
|
||||||
|
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
if (type === 'customer') customerSearchTimeout = timeout;
|
||||||
|
else contactSearchTimeout = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Selection Logic ---
|
||||||
|
function selectCustomer(id, name) {
|
||||||
|
selectedCustomer = { id, name };
|
||||||
|
document.getElementById('customer_id').value = id;
|
||||||
|
document.getElementById('customerSearch').value = '';
|
||||||
|
document.getElementById('customerResults').classList.add('d-none');
|
||||||
|
renderSelections();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeCustomer() {
|
function removeCustomer() {
|
||||||
selectedCustomer = null;
|
selectedCustomer = null;
|
||||||
document.getElementById('customer_id').value = '';
|
document.getElementById('customer_id').value = '';
|
||||||
updateSelectedCustomer();
|
renderSelections();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectContact(contactId, contactName) {
|
async function selectContact(id, name) {
|
||||||
if (!selectedContacts[contactId]) {
|
if (!selectedContacts[id]) {
|
||||||
selectedContacts[contactId] = { id: contactId, name: contactName };
|
selectedContacts[id] = { id, name };
|
||||||
}
|
}
|
||||||
document.getElementById('contactSearch').value = '';
|
document.getElementById('contactSearch').value = '';
|
||||||
document.getElementById('contactResults').style.display = 'none';
|
document.getElementById('contactResults').classList.add('d-none');
|
||||||
updateSelectedContacts();
|
renderSelections();
|
||||||
|
|
||||||
|
// Check for associated company (auto-select if single match)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/contacts/${id}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.companies && data.companies.length === 1) {
|
||||||
|
const company = data.companies[0];
|
||||||
|
if (!selectedCustomer) {
|
||||||
|
selectCustomer(company.id, company.name);
|
||||||
|
|
||||||
|
// Show brief notification
|
||||||
|
const successDiv = document.getElementById('success');
|
||||||
|
successDiv.classList.remove('d-none');
|
||||||
|
document.getElementById('success-text').textContent = `Valgte automatisk kunde: ${company.name}`;
|
||||||
|
setTimeout(() => successDiv.classList.add('d-none'), 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Auto-select company failed", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSelectedContacts() {
|
function removeContact(id) {
|
||||||
const div = document.getElementById('selectedContacts');
|
delete selectedContacts[id];
|
||||||
const items = Object.values(selectedContacts).map(c => `
|
renderSelections();
|
||||||
<span class="selected-item">
|
}
|
||||||
👥 ${c.name}
|
|
||||||
<button type="button" onclick="removeContact(${c.id})">×</button>
|
function renderSelections() {
|
||||||
</span>
|
// Customer
|
||||||
|
const custDiv = document.getElementById('selectedCustomer');
|
||||||
|
if (selectedCustomer) {
|
||||||
|
custDiv.innerHTML = `
|
||||||
|
<div class="selected-item border-primary bg-primary bg-opacity-10 text-primary">
|
||||||
|
<i class="bi bi-building me-2"></i>${selectedCustomer.name}
|
||||||
|
<button type="button" onclick="removeCustomer()" class="text-primary hover-opacity"><i class="bi bi-x"></i></button>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
custDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
const contDiv = document.getElementById('selectedContacts');
|
||||||
|
contDiv.innerHTML = Object.values(selectedContacts).map(c => `
|
||||||
|
<div class="selected-item">
|
||||||
|
<i class="bi bi-person me-2"></i>${c.name}
|
||||||
|
<button type="button" onclick="removeContact(${c.id})"><i class="bi bi-x"></i></button>
|
||||||
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
div.innerHTML = items;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeContact(contactId) {
|
async function loadCaseTypesSelect() {
|
||||||
delete selectedContacts[contactId];
|
const select = document.getElementById('type');
|
||||||
updateSelectedContacts();
|
if (!select) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/settings/case_types');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const setting = await res.json();
|
||||||
|
const types = JSON.parse(setting.value || '[]');
|
||||||
|
if (!Array.isArray(types) || types.length === 0) return;
|
||||||
|
|
||||||
|
select.innerHTML = types
|
||||||
|
.map((type) => `<option value="${type}">${type}</option>`)
|
||||||
|
.join('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load case types', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Initialization ---
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initializeSearch();
|
||||||
|
loadCaseTypesSelect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Form Submission ---
|
||||||
document.getElementById('createForm').addEventListener('submit', async (e) => {
|
document.getElementById('createForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const titel = document.getElementById('titel').value;
|
// UI Reset
|
||||||
|
const errorDiv = document.getElementById('error');
|
||||||
|
const successDiv = document.getElementById('success');
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
errorDiv.classList.add('d-none');
|
||||||
|
successDiv.classList.add('d-none');
|
||||||
|
|
||||||
|
// Basic Validation
|
||||||
|
const titelInput = document.getElementById('titel');
|
||||||
|
const titel = titelInput.value;
|
||||||
const status = document.getElementById('status').value;
|
const status = document.getElementById('status').value;
|
||||||
|
|
||||||
if (!titel || !status) {
|
if (!titel.trim()) {
|
||||||
document.getElementById('error').textContent = `❌ Udfyld alle påkrævede felter`;
|
titelInput.classList.add('is-invalid');
|
||||||
document.getElementById('error').style.display = 'block';
|
errorDiv.classList.remove('d-none');
|
||||||
|
document.getElementById('error-text').textContent = "Titel er påkrævet";
|
||||||
return;
|
return;
|
||||||
|
} else {
|
||||||
|
titelInput.classList.remove('is-invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loading State
|
||||||
|
btn.disabled = true;
|
||||||
|
const originalBtnText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
titel: titel,
|
titel: titel,
|
||||||
beskrivelse: document.getElementById('beskrivelse').value || '',
|
beskrivelse: document.getElementById('beskrivelse').value || '',
|
||||||
|
type: document.getElementById('type').value,
|
||||||
status: status,
|
status: status,
|
||||||
customer_id: document.getElementById('customer_id').value ? parseInt(document.getElementById('customer_id').value) : null,
|
customer_id: selectedCustomer ? selectedCustomer.id : null,
|
||||||
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
||||||
created_by_user_id: 1,
|
created_by_user_id: 1, // HARDCODED for now, should come from auth
|
||||||
deadline: document.getElementById('deadline').value || null
|
deadline: document.getElementById('deadline').value || null
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Sending data:', data);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/cases', {
|
const response = await fetch('/api/v1/sag', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Response status:', response.status);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('Created case:', result);
|
|
||||||
|
|
||||||
// Add selected contacts to the case
|
// Add contacts if any
|
||||||
for (const contactId of Object.keys(selectedContacts)) {
|
const contactPromises = Object.keys(selectedContacts).map(cid =>
|
||||||
await fetch(`/api/v1/cases/${result.id}/contacts`, {
|
fetch(`/api/v1/sag/${result.id}/contacts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({contact_id: parseInt(contactId), role: 'Kontakt'})
|
body: JSON.stringify({contact_id: parseInt(cid), role: 'Kontakt'})
|
||||||
});
|
})
|
||||||
}
|
);
|
||||||
|
|
||||||
|
await Promise.all(contactPromises);
|
||||||
|
|
||||||
|
successDiv.classList.remove('d-none');
|
||||||
|
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
|
||||||
|
|
||||||
document.getElementById('success').textContent = `✅ Sag oprettet! Omdirigerer...`;
|
|
||||||
document.getElementById('success').style.display = 'block';
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/cases/${result.id}`;
|
window.location.href = `/sag/${result.id}`;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error('Error response:', errorText);
|
let errMsg = "Kunne ikke oprette sag";
|
||||||
try {
|
try {
|
||||||
const error = JSON.parse(errorText);
|
const json = JSON.parse(errorText);
|
||||||
document.getElementById('error').textContent = `❌ Fejl: ${error.detail || errorText}`;
|
errMsg = json.detail || errMsg;
|
||||||
} catch {
|
} catch(e) {}
|
||||||
document.getElementById('error').textContent = `❌ Fejl: ${errorText}`;
|
|
||||||
}
|
throw new Error(errMsg);
|
||||||
document.getElementById('error').style.display = 'block';
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Exception:', err);
|
console.error('Submit error:', err);
|
||||||
document.getElementById('error').textContent = `❌ Fejl: ${err.message}`;
|
errorDiv.classList.remove('d-none');
|
||||||
document.getElementById('error').style.display = 'block';
|
document.getElementById('error-text').textContent = err.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalBtnText;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -189,11 +189,23 @@
|
|||||||
<textarea class="form-control" id="beskrivelse" rows="4" placeholder="Optionalt: Detaljeret beskrivelse af sagen">{{ case.beskrivelse or '' }}</textarea>
|
<textarea class="form-control" id="beskrivelse" rows="4" placeholder="Optionalt: Detaljeret beskrivelse af sagen">{{ case.beskrivelse or '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="type">Type *</label>
|
||||||
|
<select class="form-select" id="type" required>
|
||||||
|
<option value="ticket" {% if case.type == 'ticket' %}selected{% endif %}>🎫 Ticket</option>
|
||||||
|
<option value="opgave" {% if case.type == 'opgave' %}selected{% endif %}>🧩 Opgave</option>
|
||||||
|
<option value="ordre" {% if case.type == 'ordre' %}selected{% endif %}>🧾 Ordre</option>
|
||||||
|
<option value="projekt" {% if case.type == 'projekt' %}selected{% endif %}>📁 Projekt</option>
|
||||||
|
<option value="service" {% if case.type == 'service' %}selected{% endif %}>🛠️ Service</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="status">Status *</label>
|
<label for="status">Status *</label>
|
||||||
<select class="form-select" id="status" required>
|
<select class="form-select" id="status" required>
|
||||||
<option value="">Vælg status</option>
|
<option value="">Vælg status</option>
|
||||||
<option value="åben" {% if case.status == 'åben' %}selected{% endif %}>Åben</option>
|
<option value="åben" {% if case.status == 'åben' %}selected{% endif %}>Åben</option>
|
||||||
|
<option value="afventer" {% if case.status == 'afventer' %}selected{% endif %}>Afventer</option>
|
||||||
<option value="lukket" {% if case.status == 'lukket' %}selected{% endif %}>Lukket</option>
|
<option value="lukket" {% if case.status == 'lukket' %}selected{% endif %}>Lukket</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -210,7 +222,7 @@
|
|||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button type="submit" class="btn-submit">Gem Ændringer</button>
|
<button type="submit" class="btn-submit">Gem Ændringer</button>
|
||||||
<a href="/cases/{{ case.id }}" class="btn-cancel">Annuller</a>
|
<a href="/sag/{{ case.id }}" class="btn-cancel">Annuller</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -219,14 +231,36 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const caseId = {{ case.id }};
|
const caseId = {{ case.id }};
|
||||||
|
const currentType = "{{ case.type or 'ticket' }}";
|
||||||
|
|
||||||
|
async function loadCaseTypesSelect() {
|
||||||
|
const select = document.getElementById('type');
|
||||||
|
if (!select) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/settings/case_types');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const setting = await res.json();
|
||||||
|
const types = JSON.parse(setting.value || '[]');
|
||||||
|
if (!Array.isArray(types) || types.length === 0) return;
|
||||||
|
|
||||||
|
select.innerHTML = types
|
||||||
|
.map((type) => `<option value="${type}" ${type === currentType ? 'selected' : ''}>${type}</option>`)
|
||||||
|
.join('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load case types', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadCaseTypesSelect);
|
||||||
|
|
||||||
document.getElementById('editForm').addEventListener('submit', async (e) => {
|
document.getElementById('editForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const titel = document.getElementById('titel').value;
|
const titel = document.getElementById('titel').value;
|
||||||
|
const type = document.getElementById('type').value;
|
||||||
const status = document.getElementById('status').value;
|
const status = document.getElementById('status').value;
|
||||||
|
|
||||||
if (!titel || !status) {
|
if (!titel || !type || !status) {
|
||||||
document.getElementById('error').textContent = `❌ Udfyld alle påkrævede felter`;
|
document.getElementById('error').textContent = `❌ Udfyld alle påkrævede felter`;
|
||||||
document.getElementById('error').style.display = 'block';
|
document.getElementById('error').style.display = 'block';
|
||||||
return;
|
return;
|
||||||
@ -235,6 +269,7 @@
|
|||||||
const data = {
|
const data = {
|
||||||
titel: titel,
|
titel: titel,
|
||||||
beskrivelse: document.getElementById('beskrivelse').value || '',
|
beskrivelse: document.getElementById('beskrivelse').value || '',
|
||||||
|
type: type,
|
||||||
status: status,
|
status: status,
|
||||||
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
||||||
deadline: document.getElementById('deadline').value || null
|
deadline: document.getElementById('deadline').value || null
|
||||||
@ -243,7 +278,7 @@
|
|||||||
console.log('Updating case with data:', data);
|
console.log('Updating case with data:', data);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/cases/${caseId}`, {
|
const response = await fetch(`/api/v1/sag/${caseId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@ -260,7 +295,7 @@
|
|||||||
document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
|
document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
|
||||||
document.getElementById('success').style.display = 'block';
|
document.getElementById('success').style.display = 'block';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/cases/${caseId}`;
|
window.location.href = `/sag/${caseId}`;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
|
|||||||
@ -252,10 +252,15 @@
|
|||||||
<h1 style="margin: 0; color: var(--accent);">
|
<h1 style="margin: 0; color: var(--accent);">
|
||||||
<i class="bi bi-list-check me-2"></i>Sager
|
<i class="bi bi-list-check me-2"></i>Sager
|
||||||
</h1>
|
</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
|
||||||
|
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
|
||||||
|
</button>
|
||||||
<button class="btn btn-primary" style="background: var(--accent); border: none;" onclick="window.location.href='/sag/new'">
|
<button class="btn btn-primary" style="background: var(--accent); border: none;" onclick="window.location.href='/sag/new'">
|
||||||
<i class="bi bi-plus-lg me-2"></i>Ny Sag
|
<i class="bi bi-plus-lg me-2"></i>Ny Sag
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stats Bar -->
|
<!-- Stats Bar -->
|
||||||
<div class="stats-bar">
|
<div class="stats-bar">
|
||||||
@ -282,11 +287,18 @@
|
|||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3 mb-3">
|
||||||
<div class="filter-pills">
|
<div class="filter-pills">
|
||||||
<div class="filter-pill active" data-filter="all">Alle</div>
|
<div class="filter-pill active" data-filter="all">Alle</div>
|
||||||
<div class="filter-pill" data-filter="åben">Åbne</div>
|
<div class="filter-pill" data-filter="åben">Åbne</div>
|
||||||
<div class="filter-pill" data-filter="lukket">Lukkede</div>
|
<div class="filter-pill" data-filter="lukket">Lukkede</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="min-width: 200px;">
|
||||||
|
<select class="form-select" id="typeFilter">
|
||||||
|
<option value="all">Alle typer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
@ -296,6 +308,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th style="width: 90px;">ID</th>
|
<th style="width: 90px;">ID</th>
|
||||||
<th>Titel & Beskrivelse</th>
|
<th>Titel & Beskrivelse</th>
|
||||||
|
<th style="width: 120px;">Type</th>
|
||||||
<th style="width: 180px;">Kunde</th>
|
<th style="width: 180px;">Kunde</th>
|
||||||
<th style="width: 150px;">Hovedkontakt</th>
|
<th style="width: 150px;">Hovedkontakt</th>
|
||||||
<th style="width: 100px;">Status</th>
|
<th style="width: 100px;">Status</th>
|
||||||
@ -309,7 +322,8 @@
|
|||||||
{% set has_relations = sag.id in relations_map and relations_map[sag.id]|length > 0 %}
|
{% set has_relations = sag.id in relations_map and relations_map[sag.id]|length > 0 %}
|
||||||
<tr class="tree-row {% if has_relations %}has-children{% endif %}"
|
<tr class="tree-row {% if has_relations %}has-children{% endif %}"
|
||||||
data-sag-id="{{ sag.id }}"
|
data-sag-id="{{ sag.id }}"
|
||||||
data-status="{{ sag.status }}">
|
data-status="{{ sag.status }}"
|
||||||
|
data-type="{{ sag.type or 'ticket' }}">
|
||||||
<td>
|
<td>
|
||||||
{% if has_relations %}
|
{% if has_relations %}
|
||||||
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
|
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
|
||||||
@ -322,6 +336,9 @@
|
|||||||
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
|
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||||
|
<span class="badge bg-light text-dark border">{{ sag.type or 'ticket' }}</span>
|
||||||
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{{ sag.customer_name if sag.customer_name else '-' }}
|
{{ sag.customer_name if sag.customer_name else '-' }}
|
||||||
</td>
|
</td>
|
||||||
@ -345,7 +362,7 @@
|
|||||||
{% if related_sag and rel.target_id not in seen_targets %}
|
{% if related_sag and rel.target_id not in seen_targets %}
|
||||||
{% set _ = seen_targets.append(rel.target_id) %}
|
{% set _ = seen_targets.append(rel.target_id) %}
|
||||||
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
|
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
|
||||||
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" style="display: none;">
|
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.type or 'ticket' }}" style="display: none;">
|
||||||
<td>
|
<td>
|
||||||
<span class="sag-id">#{{ related_sag.id }}</span>
|
<span class="sag-id">#{{ related_sag.id }}</span>
|
||||||
</td>
|
</td>
|
||||||
@ -358,6 +375,9 @@
|
|||||||
<div class="sag-beskrivelse">{{ related_sag.beskrivelse }}</div>
|
<div class="sag-beskrivelse">{{ related_sag.beskrivelse }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
||||||
|
<span class="badge bg-light text-dark border">{{ related_sag.type or 'ticket' }}</span>
|
||||||
|
</td>
|
||||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
||||||
</td>
|
</td>
|
||||||
@ -417,6 +437,7 @@
|
|||||||
const allRows = document.querySelectorAll('.tree-row');
|
const allRows = document.querySelectorAll('.tree-row');
|
||||||
let currentSearch = '';
|
let currentSearch = '';
|
||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
|
let currentType = 'all';
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
const search = currentSearch;
|
const search = currentSearch;
|
||||||
@ -424,9 +445,11 @@
|
|||||||
allRows.forEach(row => {
|
allRows.forEach(row => {
|
||||||
const text = row.textContent.toLowerCase();
|
const text = row.textContent.toLowerCase();
|
||||||
const status = row.dataset.status;
|
const status = row.dataset.status;
|
||||||
|
const type = row.dataset.type || 'ticket';
|
||||||
const matchesSearch = text.includes(search);
|
const matchesSearch = text.includes(search);
|
||||||
const matchesFilter = currentFilter === 'all' || status === currentFilter;
|
const matchesFilter = currentFilter === 'all' || status === currentFilter;
|
||||||
const visible = matchesSearch && matchesFilter;
|
const matchesType = currentType === 'all' || type === currentType;
|
||||||
|
const visible = matchesSearch && matchesFilter && matchesType;
|
||||||
|
|
||||||
row.style.display = visible ? '' : 'none';
|
row.style.display = visible ? '' : 'none';
|
||||||
|
|
||||||
@ -436,9 +459,11 @@
|
|||||||
children.forEach(child => {
|
children.forEach(child => {
|
||||||
const childText = child.textContent.toLowerCase();
|
const childText = child.textContent.toLowerCase();
|
||||||
const childStatus = child.dataset.status;
|
const childStatus = child.dataset.status;
|
||||||
|
const childType = child.dataset.type || 'ticket';
|
||||||
const childMatchesSearch = childText.includes(search);
|
const childMatchesSearch = childText.includes(search);
|
||||||
const childMatchesFilter = currentFilter === 'all' || childStatus === currentFilter;
|
const childMatchesFilter = currentFilter === 'all' || childStatus === currentFilter;
|
||||||
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter;
|
const childMatchesType = currentType === 'all' || childType === currentType;
|
||||||
|
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType;
|
||||||
child.style.display = childVisible ? '' : 'none';
|
child.style.display = childVisible ? '' : 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -465,5 +490,31 @@
|
|||||||
applyFilters();
|
applyFilters();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const typeFilter = document.getElementById('typeFilter');
|
||||||
|
if (typeFilter) {
|
||||||
|
typeFilter.addEventListener('change', function() {
|
||||||
|
currentType = this.value || 'all';
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTypeFilters() {
|
||||||
|
if (!typeFilter) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/settings/case_types');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const setting = await res.json();
|
||||||
|
const types = JSON.parse(setting.value || '[]');
|
||||||
|
if (!Array.isArray(types) || types.length === 0) return;
|
||||||
|
|
||||||
|
typeFilter.innerHTML = `<option value="all">Alle typer</option>` +
|
||||||
|
types.map(type => `<option value="${type}">${type}</option>`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load case types', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTypeFilters();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
282
app/modules/sag/templates/varekob_salg.html
Normal file
282
app/modules/sag/templates/varekob_salg.html
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Varekøb & Salg - BMC Hub{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.summary-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
border: 1px solid rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.summary-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.summary-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.table-wrapper {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.table thead th {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
|
background: var(--bg-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1"><i class="bi bi-basket3 me-2"></i>Varekøb & Salg</h2>
|
||||||
|
<div class="text-muted">Samlet oversigt over alle varelinjer på tværs af sager</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-primary" href="/sag"><i class="bi bi-arrow-left me-1"></i>Tilbage til sager</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-title">Total salg</div>
|
||||||
|
<div class="summary-value" id="summarySalesTotal">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-title">Total køb</div>
|
||||||
|
<div class="summary-value" id="summaryPurchaseTotal">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-title">Netto</div>
|
||||||
|
<div class="summary-value" id="summaryNetTotal">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-title">Linjer (total)</div>
|
||||||
|
<div class="summary-value" id="summaryLinesTotal">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Søg</label>
|
||||||
|
<input type="text" class="form-control" id="ordersSearch" placeholder="Søg i beskrivelse, sag eller kunde">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="ordersStatus">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="draft">Kladde</option>
|
||||||
|
<option value="confirmed">Bekræftet</option>
|
||||||
|
<option value="cancelled">Annulleret</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Sag ID</label>
|
||||||
|
<input type="number" class="form-control" id="ordersCaseId" placeholder="F.eks. 12">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Kunde ID</label>
|
||||||
|
<input type="number" class="form-control" id="ordersCustomerId" placeholder="F.eks. 45">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Fra dato</label>
|
||||||
|
<input type="date" class="form-control" id="ordersDateFrom">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Til dato</label>
|
||||||
|
<input type="date" class="form-control" id="ordersDateTo">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button class="btn btn-primary w-100" onclick="loadOrders()"><i class="bi bi-search me-1"></i>Filtrér</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 text-end">
|
||||||
|
<span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
||||||
|
<h6 class="mb-0 text-primary"><i class="bi bi-bag-check me-2"></i>Salgslinjer</h6>
|
||||||
|
<span class="badge bg-light text-dark border" id="salesSubtotal">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Dato</th>
|
||||||
|
<th>Beskrivelse</th>
|
||||||
|
<th>Sag</th>
|
||||||
|
<th>Kunde</th>
|
||||||
|
<th>Antal</th>
|
||||||
|
<th>Enhed</th>
|
||||||
|
<th>Enhedspris</th>
|
||||||
|
<th>Linjesum</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ordersSalesBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="text-center py-4 text-muted">Indlæser...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
||||||
|
<h6 class="mb-0 text-primary"><i class="bi bi-cart-x me-2"></i>Indkøbslinjer</h6>
|
||||||
|
<span class="badge bg-light text-dark border" id="purchaseSubtotal">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Dato</th>
|
||||||
|
<th>Beskrivelse</th>
|
||||||
|
<th>Sag</th>
|
||||||
|
<th>Kunde</th>
|
||||||
|
<th>Antal</th>
|
||||||
|
<th>Enhed</th>
|
||||||
|
<th>Enhedspris</th>
|
||||||
|
<th>Linjesum</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ordersPurchaseBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="text-center py-4 text-muted">Indlæser...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function formatCurrency(value) {
|
||||||
|
const num = Number(value || 0);
|
||||||
|
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
const num = Number(value || 0);
|
||||||
|
return new Intl.NumberFormat('da-DK', { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOrderRows(items, tbodyId) {
|
||||||
|
const tbody = document.getElementById(tbodyId);
|
||||||
|
if (!tbody) return;
|
||||||
|
if (!items.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted">Ingen linjer</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = items.map(item => {
|
||||||
|
const statusLabel = item.status || 'draft';
|
||||||
|
const caseLink = item.sag_id ? `<a href="/sag/${item.sag_id}" class="text-decoration-none">${item.sag_titel || 'Sag ' + item.sag_id}</a>` : '-';
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${item.line_date || '-'}</td>
|
||||||
|
<td>${item.description || '-'}</td>
|
||||||
|
<td>${caseLink}</td>
|
||||||
|
<td>${item.customer_name || '-'}</td>
|
||||||
|
<td>${item.quantity ?? '-'}</td>
|
||||||
|
<td>${item.unit || '-'}</td>
|
||||||
|
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
|
||||||
|
<td class="fw-bold">${formatCurrency(item.amount)}</td>
|
||||||
|
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrders() {
|
||||||
|
const search = document.getElementById('ordersSearch').value.trim();
|
||||||
|
const status = document.getElementById('ordersStatus').value;
|
||||||
|
const caseId = document.getElementById('ordersCaseId').value;
|
||||||
|
const customerId = document.getElementById('ordersCustomerId').value;
|
||||||
|
const dateFrom = document.getElementById('ordersDateFrom').value;
|
||||||
|
const dateTo = document.getElementById('ordersDateTo').value;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.append('q', search);
|
||||||
|
if (status) params.append('status', status);
|
||||||
|
if (caseId) params.append('sag_id', caseId);
|
||||||
|
if (customerId) params.append('customer_id', customerId);
|
||||||
|
if (dateFrom) params.append('date_from', dateFrom);
|
||||||
|
if (dateTo) params.append('date_to', dateTo);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/sag/sale-items/all?${params.toString()}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
document.getElementById('ordersSalesBody').innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
|
||||||
|
document.getElementById('ordersPurchaseBody').innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const sales = data.filter(item => (item.type || '').toLowerCase() !== 'purchase');
|
||||||
|
const purchases = data.filter(item => (item.type || '').toLowerCase() === 'purchase');
|
||||||
|
|
||||||
|
renderOrderRows(sales, 'ordersSalesBody');
|
||||||
|
renderOrderRows(purchases, 'ordersPurchaseBody');
|
||||||
|
|
||||||
|
const salesSum = sales.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||||
|
const purchaseSum = purchases.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||||
|
|
||||||
|
document.getElementById('summarySalesTotal').textContent = formatCurrency(salesSum);
|
||||||
|
document.getElementById('summaryPurchaseTotal').textContent = formatCurrency(purchaseSum);
|
||||||
|
document.getElementById('summaryNetTotal').textContent = formatCurrency(salesSum - purchaseSum);
|
||||||
|
document.getElementById('summaryLinesTotal').textContent = formatNumber(data.length);
|
||||||
|
document.getElementById('salesSubtotal').textContent = formatCurrency(salesSum);
|
||||||
|
document.getElementById('purchaseSubtotal').textContent = formatCurrency(purchaseSum);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadOrders();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
0
app/modules/search/backend/__init__.py
Normal file
0
app/modules/search/backend/__init__.py
Normal file
84
app/modules/search/backend/router.py
Normal file
84
app/modules/search/backend/router.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from app.core.database import execute_query
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/search/customers")
|
||||||
|
async def search_customers(q: str = Query(..., min_length=2)):
|
||||||
|
"""
|
||||||
|
Autocomplete search for customers.
|
||||||
|
Returns list of {id, name, cvr_nummer, email}
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT id, name, cvr_number as cvr_nummer, email
|
||||||
|
FROM customers
|
||||||
|
WHERE (name ILIKE %s OR cvr_number ILIKE %s) AND deleted_at IS NULL
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
term = f"%{q}%"
|
||||||
|
results = execute_query(sql, (term, term))
|
||||||
|
return results
|
||||||
|
|
||||||
|
@router.get("/search/contacts")
|
||||||
|
async def search_contacts(q: str = Query(..., min_length=2)):
|
||||||
|
"""
|
||||||
|
Autocomplete search for contacts.
|
||||||
|
Returns list of {id, first_name, last_name, email}
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT id, first_name, last_name, email
|
||||||
|
FROM contacts
|
||||||
|
WHERE
|
||||||
|
(first_name ILIKE %s OR
|
||||||
|
last_name ILIKE %s OR
|
||||||
|
email ILIKE %s)
|
||||||
|
ORDER BY first_name ASC, last_name ASC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
term = f"%{q}%"
|
||||||
|
results = execute_query(sql, (term, term, term))
|
||||||
|
return results
|
||||||
|
|
||||||
|
@router.get("/search/hardware")
|
||||||
|
async def search_hardware(q: str = Query(..., min_length=2)):
|
||||||
|
"""
|
||||||
|
Autocomplete search for hardware.
|
||||||
|
Returns list of {id, brand, model, serial_number, internal_asset_id}
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT id, brand, model, serial_number, internal_asset_id
|
||||||
|
FROM hardware_assets
|
||||||
|
WHERE
|
||||||
|
(brand ILIKE %s OR
|
||||||
|
model ILIKE %s OR
|
||||||
|
serial_number ILIKE %s OR
|
||||||
|
internal_asset_id ILIKE %s)
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY brand ASC, model ASC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
term = f"%{q}%"
|
||||||
|
results = execute_query(sql, (term, term, term, term))
|
||||||
|
return results
|
||||||
|
|
||||||
|
@router.get("/search/locations")
|
||||||
|
async def search_locations(q: str = Query(..., min_length=2)):
|
||||||
|
"""
|
||||||
|
Autocomplete search for locations.
|
||||||
|
Returns list of {id, name, address_street, address_city}
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT id, name, address_street, address_city
|
||||||
|
FROM locations_locations
|
||||||
|
WHERE
|
||||||
|
(name ILIKE %s OR
|
||||||
|
address_street ILIKE %s OR
|
||||||
|
address_city ILIKE %s)
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
term = f"%{q}%"
|
||||||
|
results = execute_query(sql, (term, term, term))
|
||||||
|
return results
|
||||||
225
app/settings/backend/email_templates.py
Normal file
225
app/settings/backend/email_templates.py
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
"""
|
||||||
|
Email Templates API Router
|
||||||
|
Management of system and customer-specific email templates
|
||||||
|
"""
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Json
|
||||||
|
from app.core.database import execute_query
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/email-templates", tags=["Email Templates"])
|
||||||
|
|
||||||
|
# --- Models ---
|
||||||
|
|
||||||
|
class EmailTemplateBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
category: str = "general"
|
||||||
|
description: Optional[str] = None
|
||||||
|
variables: Dict[str, str] = {}
|
||||||
|
customer_id: Optional[int] = None
|
||||||
|
|
||||||
|
class EmailTemplateCreate(EmailTemplateBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class EmailTemplateUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
subject: Optional[str] = None
|
||||||
|
body: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
variables: Optional[Dict[str, str]] = None
|
||||||
|
customer_id: Optional[int] = None
|
||||||
|
|
||||||
|
class EmailTemplate(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
category: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
variables: Optional[Dict[str, str]] = {}
|
||||||
|
is_system: bool
|
||||||
|
customer_id: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
# --- Endpoints ---
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[EmailTemplate])
|
||||||
|
async def get_email_templates(
|
||||||
|
category: Optional[str] = None,
|
||||||
|
customer_id: Optional[int] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all email templates.
|
||||||
|
Optionally filter by category or specific customer.
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT id, name, slug, subject, body, category, description, variables,
|
||||||
|
is_system, customer_id, created_at, updated_at
|
||||||
|
FROM email_templates
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if category:
|
||||||
|
sql += " AND category = %s"
|
||||||
|
params.append(category)
|
||||||
|
|
||||||
|
if customer_id is not None:
|
||||||
|
# Fetch global templates (customer_id IS NULL) AND this specific customer's templates
|
||||||
|
sql += " AND (customer_id IS NULL OR customer_id = %s)"
|
||||||
|
params.append(customer_id)
|
||||||
|
|
||||||
|
sql += " ORDER BY category, name"
|
||||||
|
|
||||||
|
rows = execute_query(sql, tuple(params))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@router.get("/{template_id}", response_model=EmailTemplate)
|
||||||
|
async def get_email_template(template_id: int):
|
||||||
|
"""Get a single template by ID"""
|
||||||
|
sql = """
|
||||||
|
SELECT id, name, slug, subject, body, category, description, variables,
|
||||||
|
is_system, customer_id, created_at, updated_at
|
||||||
|
FROM email_templates
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
rows = execute_query(sql, (template_id,))
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
@router.post("/", response_model=EmailTemplate)
|
||||||
|
async def create_email_template(template: EmailTemplateCreate):
|
||||||
|
"""Create a new email template"""
|
||||||
|
# Check for slug uniqueness
|
||||||
|
check_sql = "SELECT id FROM email_templates WHERE slug = %s AND (customer_id IS NULL OR customer_id = %s)"
|
||||||
|
check_val = (template.customer_id,) if template.customer_id else (None,)
|
||||||
|
# If customer_id is None, we check where customer_id IS NULL.
|
||||||
|
# Actually, SQL `slug = %s AND customer_id = %s` works if we handle NULL correctly in python -> SQL
|
||||||
|
|
||||||
|
# Simpler uniqueness check in python logic tailored to the constraint
|
||||||
|
if template.customer_id:
|
||||||
|
existing = execute_query(
|
||||||
|
"SELECT id FROM email_templates WHERE slug = %s AND customer_id = %s",
|
||||||
|
(template.slug, template.customer_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
existing = execute_query(
|
||||||
|
"SELECT id FROM email_templates WHERE slug = %s AND customer_id IS NULL",
|
||||||
|
(template.slug,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail=f"A template with slug '{template.slug}' already exists for this context.")
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
INSERT INTO email_templates
|
||||||
|
(name, slug, subject, body, category, description, variables, customer_id, is_system)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, FALSE)
|
||||||
|
RETURNING id, name, slug, subject, body, category, description, variables,
|
||||||
|
is_system, customer_id, created_at, updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = (
|
||||||
|
template.name,
|
||||||
|
template.slug,
|
||||||
|
template.subject,
|
||||||
|
template.body,
|
||||||
|
template.category,
|
||||||
|
template.description,
|
||||||
|
json.dumps(template.variables),
|
||||||
|
template.customer_id
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = execute_query(sql, params)
|
||||||
|
return rows[0]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.put("/{template_id}", response_model=EmailTemplate)
|
||||||
|
async def update_email_template(template_id: int, update: EmailTemplateUpdate):
|
||||||
|
"""Update an existing template"""
|
||||||
|
# Verify existence
|
||||||
|
current = execute_query("SELECT is_system FROM email_templates WHERE id = %s", (template_id,))
|
||||||
|
if not current:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
|
||||||
|
is_system = current[0]['is_system']
|
||||||
|
|
||||||
|
# Prevent changing critical fields on system templates?
|
||||||
|
# Usually we allow editing subject/body, but maybe not slug.
|
||||||
|
# For now, we allow everything except slug changes on system templates if defined so,
|
||||||
|
# but the logic below updates whatever is provided.
|
||||||
|
|
||||||
|
fields = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if update.name is not None:
|
||||||
|
fields.append("name = %s")
|
||||||
|
params.append(update.name)
|
||||||
|
|
||||||
|
if update.subject is not None:
|
||||||
|
fields.append("subject = %s")
|
||||||
|
params.append(update.subject)
|
||||||
|
|
||||||
|
if update.body is not None:
|
||||||
|
fields.append("body = %s")
|
||||||
|
params.append(update.body)
|
||||||
|
|
||||||
|
if update.category is not None:
|
||||||
|
fields.append("category = %s")
|
||||||
|
params.append(update.category)
|
||||||
|
|
||||||
|
if update.description is not None:
|
||||||
|
fields.append("description = %s")
|
||||||
|
params.append(update.description)
|
||||||
|
|
||||||
|
if update.variables is not None:
|
||||||
|
fields.append("variables = %s")
|
||||||
|
params.append(json.dumps(update.variables))
|
||||||
|
|
||||||
|
if update.customer_id is not None:
|
||||||
|
fields.append("customer_id = %s")
|
||||||
|
params.append(update.customer_id)
|
||||||
|
|
||||||
|
# Don't change slug if it's a system template?
|
||||||
|
# Usually slugs are fixed for system templates so the code can find them.
|
||||||
|
# But for now we don't expose slug update in the provided model if we want to be strict.
|
||||||
|
# Wait, the frontend might need to update other things.
|
||||||
|
# Let's assume we don't update slug for now via this endpoint as per my minimalist implementation.
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
fields.append("updated_at = NOW()")
|
||||||
|
|
||||||
|
sql = f"UPDATE email_templates SET {', '.join(fields)} WHERE id = %s RETURNING *"
|
||||||
|
params.append(template_id)
|
||||||
|
|
||||||
|
rows = execute_query(sql, tuple(params))
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
@router.delete("/{template_id}")
|
||||||
|
async def delete_email_template(template_id: int):
|
||||||
|
"""Delete a template (Non-system only)"""
|
||||||
|
current = execute_query("SELECT is_system FROM email_templates WHERE id = %s", (template_id,))
|
||||||
|
if not current:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
|
||||||
|
if current[0]['is_system']:
|
||||||
|
raise HTTPException(status_code=403, detail="Cannot delete system templates")
|
||||||
|
|
||||||
|
execute_query("DELETE FROM email_templates WHERE id = %s", (template_id,))
|
||||||
|
return {"message": "Template deleted successfully"}
|
||||||
@ -101,6 +101,9 @@
|
|||||||
<a class="nav-link" href="#ai-prompts" data-tab="ai-prompts">
|
<a class="nav-link" href="#ai-prompts" data-tab="ai-prompts">
|
||||||
<i class="bi bi-robot me-2"></i>AI Prompts
|
<i class="bi bi-robot me-2"></i>AI Prompts
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link" href="#email-templates" data-tab="email-templates">
|
||||||
|
<i class="bi bi-envelope-paper me-2"></i>Email skabeloner
|
||||||
|
</a>
|
||||||
<a class="nav-link" href="/admin/bmc-office-upload">
|
<a class="nav-link" href="/admin/bmc-office-upload">
|
||||||
<i class="bi bi-cloud-upload me-2"></i>BMC Office Import
|
<i class="bi bi-cloud-upload me-2"></i>BMC Office Import
|
||||||
</a>
|
</a>
|
||||||
@ -151,6 +154,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4 mt-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-1 fw-bold">Nextcloud</h5>
|
||||||
|
<p class="text-muted mb-0">Administrer kunde‑instanser, credentials og audit‑log</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="openNextcloudInstanceModal()">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Opret instans
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kunde</th>
|
||||||
|
<th>Base URL</th>
|
||||||
|
<th>Bruger</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Sidst opdateret</th>
|
||||||
|
<th class="text-end">Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="nextcloudInstancesTable">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||||
|
<div>
|
||||||
|
<h6 class="fw-bold mb-1">Audit‑log retention</h6>
|
||||||
|
<small class="text-muted">Manuel sletning pr. kunde (tidsbaseret)</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-danger" onclick="openNextcloudPurgeModal()">
|
||||||
|
<i class="bi bi-trash me-2"></i>Slet ældre events
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
@ -165,6 +212,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Templates -->
|
||||||
|
<div class="tab-pane fade" id="email-templates">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="fw-bold mb-1">Email Skabeloner</h5>
|
||||||
|
<p class="text-muted mb-0">Administrer system- og kundespecifikke email skabeloner</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="openEmailTemplateModal()">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Ny Skabelon
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small text-muted">Kategori</label>
|
||||||
|
<select class="form-select" id="emailTemplateCategoryFilter" onchange="loadEmailTemplates()">
|
||||||
|
<option value="">Alle kategorier</option>
|
||||||
|
<option value="general">Generelt</option>
|
||||||
|
<option value="internal">Internt</option>
|
||||||
|
<option value="nextcloud">Nextcloud</option>
|
||||||
|
<option value="billing">Fakturering</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small text-muted">Kunde</label>
|
||||||
|
<select class="form-select" id="emailTemplateCustomerFilter" onchange="loadEmailTemplates()">
|
||||||
|
<option value="">Alle kunder (Globale)</option>
|
||||||
|
<!-- Populated via JS -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Emne</th>
|
||||||
|
<th>Kategori</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Sidst opdateret</th>
|
||||||
|
<th class="text-end">Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="emailTemplatesTableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Users -->
|
<!-- Users -->
|
||||||
<div class="tab-pane fade" id="users">
|
<div class="tab-pane fade" id="users">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
@ -743,6 +851,21 @@ async def scan_document(file_path: str):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card p-4 mt-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-1 fw-bold">Sags-typer</h5>
|
||||||
|
<p class="text-muted mb-0">Administrer tilladte typer for sager</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<input type="text" class="form-control" id="caseTypeInput" placeholder="F.eks. ticket" style="max-width: 220px;">
|
||||||
|
<button class="btn btn-primary" onclick="addCaseType()"><i class="bi bi-plus-lg me-1"></i>Tilføj</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="caseTypesList" class="d-flex flex-wrap gap-2">
|
||||||
|
<div class="text-muted">Indlæser...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -838,18 +961,120 @@ async def scan_document(file_path: str):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Nextcloud Instance Modal -->
|
||||||
|
<div class="modal fade" id="nextcloudInstanceModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-cloud me-2"></i>Opret Nextcloud instans</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="nextcloudInstanceForm" class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Kunde</label>
|
||||||
|
<select class="form-select" id="nextcloudCustomerSelect"></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Base URL</label>
|
||||||
|
<input type="url" class="form-control" id="nextcloudBaseUrl" placeholder="https://cloud.example.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Auth type</label>
|
||||||
|
<select class="form-select" id="nextcloudAuthType">
|
||||||
|
<option value="basic">Basic / App Password</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Brugernavn</label>
|
||||||
|
<input type="text" class="form-control" id="nextcloudUsername" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="nextcloudPassword" required>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="createNextcloudInstance()">Gem</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nextcloud Rotate Credentials Modal -->
|
||||||
|
<div class="modal fade" id="nextcloudRotateModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-arrow-repeat me-2"></i>Rotér credentials</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="nextcloudRotateForm">
|
||||||
|
<input type="hidden" id="nextcloudRotateInstanceId">
|
||||||
|
<label class="form-label">Nyt password</label>
|
||||||
|
<input type="password" class="form-control" id="nextcloudRotatePassword" required>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="rotateNextcloudCredentials()">Opdater</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nextcloud Audit Purge Modal -->
|
||||||
|
<div class="modal fade" id="nextcloudPurgeModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-trash me-2"></i>Slet audit‑log</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="nextcloudPurgeForm" class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Kunde</label>
|
||||||
|
<select class="form-select" id="nextcloudPurgeCustomerSelect"></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Slet events før dato</label>
|
||||||
|
<input type="date" class="form-control" id="nextcloudPurgeBefore" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-warning mb-0">
|
||||||
|
Dette kan ikke fortrydes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="purgeNextcloudAudit()">Slet</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
let allSettings = [];
|
let allSettings = [];
|
||||||
let pipelineStagesCache = [];
|
let pipelineStagesCache = [];
|
||||||
|
let nextcloudInstancesCache = [];
|
||||||
|
let customersCache = [];
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/settings');
|
const response = await fetch('/api/v1/settings');
|
||||||
allSettings = await response.json();
|
allSettings = await response.json();
|
||||||
displaySettingsByCategory();
|
displaySettingsByCategory();
|
||||||
|
await loadCaseTypesSetting();
|
||||||
|
await loadNextcloudInstances();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
}
|
}
|
||||||
@ -875,10 +1100,236 @@ function displaySettingsByCategory() {
|
|||||||
// Notification settings
|
// Notification settings
|
||||||
displaySettings('notificationSettings', categories.notifications);
|
displaySettings('notificationSettings', categories.notifications);
|
||||||
|
|
||||||
|
// Email templates
|
||||||
|
displaySettings('emailTemplatesInternal', [
|
||||||
|
'email_template_internal_subject',
|
||||||
|
'email_template_internal_body'
|
||||||
|
]);
|
||||||
|
displaySettings('emailTemplatesExternal', [
|
||||||
|
'email_template_external_subject',
|
||||||
|
'email_template_external_body',
|
||||||
|
'nextcloud_user_welcome_subject',
|
||||||
|
'nextcloud_user_welcome_body'
|
||||||
|
]);
|
||||||
|
|
||||||
// System settings
|
// System settings
|
||||||
displaySettings('systemSettings', categories.system);
|
displaySettings('systemSettings', categories.system);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadNextcloudInstances() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/nextcloud/instances');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch instances');
|
||||||
|
}
|
||||||
|
nextcloudInstancesCache = await response.json();
|
||||||
|
|
||||||
|
if (!customersCache.length) {
|
||||||
|
const customersResponse = await fetch('/api/v1/customers?limit=1000&offset=0');
|
||||||
|
if (customersResponse.ok) {
|
||||||
|
const payload = await customersResponse.json();
|
||||||
|
customersCache = Array.isArray(payload) ? payload : (payload.customers || []);
|
||||||
|
} else {
|
||||||
|
customersCache = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNextcloudInstances();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading Nextcloud instances:', error);
|
||||||
|
const table = document.getElementById('nextcloudInstancesTable');
|
||||||
|
if (table) {
|
||||||
|
table.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Kunne ikke hente instanser</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNextcloudInstances() {
|
||||||
|
const table = document.getElementById('nextcloudInstancesTable');
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
if (!nextcloudInstancesCache.length) {
|
||||||
|
table.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Ingen instanser oprettet</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerMap = new Map(customersCache.map(c => [c.id, c]));
|
||||||
|
|
||||||
|
table.innerHTML = nextcloudInstancesCache.map(instance => {
|
||||||
|
const customer = customerMap.get(instance.customer_id);
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${customer ? escapeHtml(customer.name) : 'Ukendt'}</td>
|
||||||
|
<td>${escapeHtml(instance.base_url || '-')}</td>
|
||||||
|
<td>${escapeHtml(instance.username || '-')}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge ${instance.is_enabled ? 'bg-success' : 'bg-secondary'}">
|
||||||
|
${instance.is_enabled ? 'Aktiv' : 'Deaktiveret'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${instance.updated_at ? formatDate(instance.updated_at) : '-'}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-light" onclick="toggleNextcloudInstance(${instance.id}, ${!instance.is_enabled})" title="${instance.is_enabled ? 'Deaktiver' : 'Aktiver'}">
|
||||||
|
<i class="bi bi-${instance.is_enabled ? 'pause' : 'play'}-circle"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-light" onclick="openNextcloudRotateModal(${instance.id})" title="Rotér credentials">
|
||||||
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNextcloudInstanceModal() {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('nextcloudInstanceModal'));
|
||||||
|
populateNextcloudCustomerSelect('nextcloudCustomerSelect');
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateNextcloudCustomerSelect(selectId) {
|
||||||
|
if (!customersCache.length) {
|
||||||
|
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
|
||||||
|
if (response.ok) {
|
||||||
|
const payload = await response.json();
|
||||||
|
customersCache = Array.isArray(payload) ? payload : (payload.customers || []);
|
||||||
|
} else {
|
||||||
|
customersCache = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = document.getElementById(selectId);
|
||||||
|
if (!select) return;
|
||||||
|
if (!customersCache.length) {
|
||||||
|
select.innerHTML = '<option value="">Ingen kunder fundet</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select.innerHTML = customersCache.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNextcloudInstance() {
|
||||||
|
const payload = {
|
||||||
|
customer_id: parseInt(document.getElementById('nextcloudCustomerSelect').value || '0', 10),
|
||||||
|
base_url: document.getElementById('nextcloudBaseUrl').value.trim(),
|
||||||
|
auth_type: document.getElementById('nextcloudAuthType').value,
|
||||||
|
username: document.getElementById('nextcloudUsername').value.trim(),
|
||||||
|
password: document.getElementById('nextcloudPassword').value
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.customer_id || !payload.base_url || !payload.username || !payload.password) {
|
||||||
|
alert('Udfyld alle felter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/nextcloud/instances', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.detail || 'Kunne ikke oprette instans');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('nextcloudInstanceModal')).hide();
|
||||||
|
document.getElementById('nextcloudInstanceForm').reset();
|
||||||
|
await loadNextcloudInstances();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating Nextcloud instance:', error);
|
||||||
|
alert('Kunne ikke oprette instans');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleNextcloudInstance(instanceId, enable) {
|
||||||
|
const endpoint = enable ? 'enable' : 'disable';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/nextcloud/instances/${instanceId}/${endpoint}`, { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
alert('Kunne ikke opdatere instans');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadNextcloudInstances();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling Nextcloud instance:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNextcloudRotateModal(instanceId) {
|
||||||
|
document.getElementById('nextcloudRotateInstanceId').value = instanceId;
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('nextcloudRotateModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateNextcloudCredentials() {
|
||||||
|
const instanceId = document.getElementById('nextcloudRotateInstanceId').value;
|
||||||
|
const password = document.getElementById('nextcloudRotatePassword').value;
|
||||||
|
if (!password) {
|
||||||
|
alert('Angiv nyt password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/nextcloud/instances/${instanceId}/rotate-credentials`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
alert('Kunne ikke rotere credentials');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('nextcloudRotateModal')).hide();
|
||||||
|
document.getElementById('nextcloudRotateForm').reset();
|
||||||
|
await loadNextcloudInstances();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rotating credentials:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNextcloudPurgeModal() {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('nextcloudPurgeModal'));
|
||||||
|
populateNextcloudCustomerSelect('nextcloudPurgeCustomerSelect');
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeNextcloudAudit() {
|
||||||
|
const customerId = parseInt(document.getElementById('nextcloudPurgeCustomerSelect').value || '0', 10);
|
||||||
|
const beforeDate = document.getElementById('nextcloudPurgeBefore').value;
|
||||||
|
|
||||||
|
if (!customerId || !beforeDate) {
|
||||||
|
alert('Vælg kunde og dato');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/nextcloud/audit/purge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ customer_id: customerId, before_date: beforeDate })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.detail || 'Kunne ikke slette audit‑log');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Slettet ${result.deleted || 0} events`);
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('nextcloudPurgeModal')).hide();
|
||||||
|
document.getElementById('nextcloudPurgeForm').reset();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error purging audit log:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function displaySettings(containerId, keys) {
|
function displaySettings(containerId, keys) {
|
||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
const settings = allSettings.filter(s => keys.includes(s.key));
|
const settings = allSettings.filter(s => keys.includes(s.key));
|
||||||
@ -900,6 +1351,12 @@ function displaySettings(containerId, keys) {
|
|||||||
onchange="updateSetting('${setting.key}', this.checked ? 'true' : 'false')">
|
onchange="updateSetting('${setting.key}', this.checked ? 'true' : 'false')">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
} else if (setting.value_type === 'text' || setting.key.includes('template')) {
|
||||||
|
inputHtml = `
|
||||||
|
<textarea class="form-control" id="${inputId}" rows="6"
|
||||||
|
onblur="updateSetting('${setting.key}', this.value)"
|
||||||
|
style="max-width: 100%;"></textarea>
|
||||||
|
`;
|
||||||
} else if (setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('token')) {
|
} else if (setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('token')) {
|
||||||
inputHtml = `
|
inputHtml = `
|
||||||
<input type="password" class="form-control" id="${inputId}"
|
<input type="password" class="form-control" id="${inputId}"
|
||||||
@ -926,6 +1383,13 @@ function displaySettings(containerId, keys) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
settings.forEach(setting => {
|
||||||
|
if (setting.value_type === 'text' || setting.key.includes('template')) {
|
||||||
|
const textarea = document.getElementById(`setting_${setting.key}`);
|
||||||
|
if (textarea) textarea.value = setting.value || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSetting(key, value) {
|
async function updateSetting(key, value) {
|
||||||
@ -946,6 +1410,79 @@ async function updateSetting(key, value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCaseTypesSetting() {
|
||||||
|
return allSettings.find(setting => setting.key === 'case_types');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCaseTypesSetting() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/settings/case_types');
|
||||||
|
if (!response.ok) {
|
||||||
|
renderCaseTypes([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const setting = await response.json();
|
||||||
|
const rawValue = setting.value || '[]';
|
||||||
|
const parsed = JSON.parse(rawValue);
|
||||||
|
renderCaseTypes(Array.isArray(parsed) ? parsed : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading case types:', error);
|
||||||
|
renderCaseTypes([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCaseTypes(types) {
|
||||||
|
const container = document.getElementById('caseTypesList');
|
||||||
|
if (!container) return;
|
||||||
|
if (!types.length) {
|
||||||
|
container.innerHTML = '<span class="text-muted">Ingen typer defineret</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = types.map(type => `
|
||||||
|
<span class="badge bg-light text-dark border d-inline-flex align-items-center gap-2">
|
||||||
|
${type}
|
||||||
|
<button type="button" class="btn btn-sm p-0" onclick="removeCaseType('${type.replace(/'/g, "'")}')">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCaseTypes(types) {
|
||||||
|
await updateSetting('case_types', JSON.stringify(types));
|
||||||
|
renderCaseTypes(types);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCaseType() {
|
||||||
|
const input = document.getElementById('caseTypeInput');
|
||||||
|
if (!input) return;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
const response = await fetch('/api/v1/settings/case_types');
|
||||||
|
if (!response.ok) return;
|
||||||
|
const setting = await response.json();
|
||||||
|
const current = JSON.parse(setting.value || '[]');
|
||||||
|
const types = Array.isArray(current) ? current : [];
|
||||||
|
|
||||||
|
if (!types.includes(value)) {
|
||||||
|
types.push(value);
|
||||||
|
await saveCaseTypes(types);
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCaseType(type) {
|
||||||
|
const response = await fetch('/api/v1/settings/case_types');
|
||||||
|
if (!response.ok) return;
|
||||||
|
const setting = await response.json();
|
||||||
|
const current = JSON.parse(setting.value || '[]');
|
||||||
|
const types = Array.isArray(current) ? current : [];
|
||||||
|
const filtered = types.filter(t => t !== type);
|
||||||
|
await saveCaseTypes(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/users');
|
const response = await fetch('/api/v1/users');
|
||||||
@ -2081,4 +2618,326 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Template Modal -->
|
||||||
|
<div class="modal fade" id="emailTemplateModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="emailTemplateModalTitle">Opret Email Skabelon</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Editor Column -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<form id="emailTemplateForm">
|
||||||
|
<input type="hidden" id="emailTemplateId">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Skabelon Navn *</label>
|
||||||
|
<input type="text" class="form-control" id="emailTemplateName" required>
|
||||||
|
<small class="text-muted">Internt navn til identifikation</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Slug (Unik ID) *</label>
|
||||||
|
<input type="text" class="form-control font-monospace" id="emailTemplateSlug" required>
|
||||||
|
<small class="text-muted">Bruges i koden (f.eks. 'nextcloud_welcome')</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Kategori</label>
|
||||||
|
<select class="form-select" id="emailTemplateCategory">
|
||||||
|
<option value="general">Generelt</option>
|
||||||
|
<option value="internal">Internt</option>
|
||||||
|
<option value="nextcloud">Nextcloud</option>
|
||||||
|
<option value="billing">Fakturering</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Kunde (Valgfri)</label>
|
||||||
|
<select class="form-select" id="emailTemplateCustomer">
|
||||||
|
<option value="">Alle kunder (Global skabelon)</option>
|
||||||
|
<!-- Populated via JS -->
|
||||||
|
</select>
|
||||||
|
<small class="text-muted">Vælg en kunde for at lave en specifik override</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Emne *</label>
|
||||||
|
<input type="text" class="form-control" id="emailTemplateSubject" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Indhold *</label>
|
||||||
|
<textarea class="form-control font-monospace" id="emailTemplateBody" rows="12" required></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Beskrivelse</label>
|
||||||
|
<input type="text" class="form-control" id="emailTemplateDescription">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Variables Column -->
|
||||||
|
<div class="col-md-4 bg-light p-3 rounded">
|
||||||
|
<h6 class="fw-bold mb-3">Tilgængelige Variabler</h6>
|
||||||
|
<p class="small text-muted mb-3">Klik på en variabel for at kopiere den til udklipsholderen.</p>
|
||||||
|
|
||||||
|
<div id="emailTemplateVariablesList" class="d-grid gap-2">
|
||||||
|
<!-- Populated via JS -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h6 class="fw-bold mb-2">Tilføj Variabel info</h6>
|
||||||
|
<p class="small text-muted mb-2">Definer hvilke variabler systemet understøtter for denne slug.</p>
|
||||||
|
<textarea class="form-control font-monospace small" id="emailTemplateVariablesJson" rows="5" placeholder='{"name": "Navn", "url": "Login URL"}'></textarea>
|
||||||
|
<small class="text-muted">JSON format</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveEmailTemplate()">Gem Skabelon</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- Email Template Management ---
|
||||||
|
|
||||||
|
async function loadEmailTemplates() {
|
||||||
|
const category = document.getElementById('emailTemplateCategoryFilter').value;
|
||||||
|
const customerId = document.getElementById('emailTemplateCustomerFilter').value;
|
||||||
|
|
||||||
|
let url = '/api/v1/email-templates/';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (category) params.append('category', category);
|
||||||
|
if (customerId) params.append('customer_id', customerId);
|
||||||
|
if (params.toString()) url += '?' + params.toString();
|
||||||
|
|
||||||
|
const tbody = document.getElementById('emailTemplatesTableBody');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const templates = await response.json();
|
||||||
|
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
if (templates.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">Ingen skabeloner fundet</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.forEach(tpl => {
|
||||||
|
const customerLabel = tpl.customer_id ? '<span class="badge bg-info text-dark">Kunde-specifik</span>' : '<span class="badge bg-secondary">Global</span>';
|
||||||
|
const updatedAt = new Date(tpl.updated_at).toLocaleDateString('da-DK');
|
||||||
|
|
||||||
|
tbody.innerHTML += `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-bold">${tpl.name}</div>
|
||||||
|
<small class="text-muted font-monospace">${tpl.slug}</small>
|
||||||
|
</td>
|
||||||
|
<td>${tpl.subject}</td>
|
||||||
|
<td><span class="badge bg-light text-dark border">${tpl.category}</span></td>
|
||||||
|
<td>${customerLabel}</td>
|
||||||
|
<td>${updatedAt}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-1" onclick="editEmailTemplate(${tpl.id})">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
${!tpl.is_system ? `
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteEmailTemplate(${tpl.id})">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error loading templates:", e);
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-danger">Fejl ved indlæsning af skabeloner</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEmailTemplateCustomers() {
|
||||||
|
// Populate customer dropdowns (Filter and Modal)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/customers');
|
||||||
|
const customers = await response.json();
|
||||||
|
|
||||||
|
const filterSelect = document.getElementById('emailTemplateCustomerFilter');
|
||||||
|
const modalSelect = document.getElementById('emailTemplateCustomer');
|
||||||
|
|
||||||
|
// Reset (keep first option)
|
||||||
|
filterSelect.length = 1;
|
||||||
|
modalSelect.length = 1;
|
||||||
|
|
||||||
|
customers.forEach(c => {
|
||||||
|
const opt1 = new Option(`${c.name} (#${c.id})`, c.id);
|
||||||
|
const opt2 = new Option(`${c.name} (#${c.id})`, c.id);
|
||||||
|
filterSelect.add(opt1);
|
||||||
|
modalSelect.add(opt2);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error loading customers:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEmailTemplateModal() {
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('emailTemplateForm').reset();
|
||||||
|
document.getElementById('emailTemplateId').value = '';
|
||||||
|
document.getElementById('emailTemplateModalTitle').textContent = 'Opret Email Skabelon';
|
||||||
|
document.getElementById('emailTemplateSlug').readOnly = false;
|
||||||
|
document.getElementById('emailTemplateVariablesList').innerHTML = '<p class="text-muted small">Ingen variabler defineret</p>';
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('emailTemplateModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editEmailTemplate(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/email-templates/${id}`);
|
||||||
|
if(!response.ok) throw new Error("Load failed");
|
||||||
|
const tpl = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('emailTemplateId').value = tpl.id;
|
||||||
|
document.getElementById('emailTemplateName').value = tpl.name;
|
||||||
|
document.getElementById('emailTemplateSlug').value = tpl.slug;
|
||||||
|
document.getElementById('emailTemplateSlug').readOnly = tpl.is_system; // Cannot change slug of system template
|
||||||
|
document.getElementById('emailTemplateCategory').value = tpl.category;
|
||||||
|
document.getElementById('emailTemplateCustomer').value = tpl.customer_id || "";
|
||||||
|
document.getElementById('emailTemplateSubject').value = tpl.subject;
|
||||||
|
document.getElementById('emailTemplateBody').value = tpl.body;
|
||||||
|
document.getElementById('emailTemplateDescription').value = tpl.description || "";
|
||||||
|
document.getElementById('emailTemplateVariablesJson').value = JSON.stringify(tpl.variables || {}, null, 2);
|
||||||
|
|
||||||
|
document.getElementById('emailTemplateModalTitle').textContent = 'Rediger Email Skabelon';
|
||||||
|
|
||||||
|
updateVariablesList(tpl.variables);
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('emailTemplateModal'));
|
||||||
|
modal.show();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Kunne ikke hente skabelon data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVariablesList(variables) {
|
||||||
|
const list = document.getElementById('emailTemplateVariablesList');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
if (!variables || Object.keys(variables).length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-muted small">Ingen variabler defineret</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, desc] of Object.entries(variables)) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'btn btn-outline-secondary btn-sm text-start';
|
||||||
|
// Escape Jinja2 curly braces to prevent conflict with JS template literals
|
||||||
|
btn.innerHTML = `<span class="fw-bold">{{ '{{' }}${key}{{ '}}' }}</span> <br><small>${desc}</small>`;
|
||||||
|
btn.onclick = () => {
|
||||||
|
navigator.clipboard.writeText(`{{ '{{' }}${key}{{ '}}' }}`);
|
||||||
|
// Flash button
|
||||||
|
const originalClass = btn.className;
|
||||||
|
btn.className = 'btn btn-success btn-sm text-start';
|
||||||
|
setTimeout(() => btn.className = originalClass, 500);
|
||||||
|
};
|
||||||
|
list.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update variables preview when JSON changes
|
||||||
|
document.getElementById('emailTemplateVariablesJson').addEventListener('input', function(e) {
|
||||||
|
try {
|
||||||
|
const vars = JSON.parse(e.target.value);
|
||||||
|
updateVariablesList(vars);
|
||||||
|
e.target.classList.remove('is-invalid');
|
||||||
|
} catch {
|
||||||
|
// e.target.classList.add('is-invalid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveEmailTemplate() {
|
||||||
|
const id = document.getElementById('emailTemplateId').value;
|
||||||
|
const isNew = !id;
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
let variables = {};
|
||||||
|
try {
|
||||||
|
const jsonStr = document.getElementById('emailTemplateVariablesJson').value;
|
||||||
|
if (jsonStr.trim()) variables = JSON.parse(jsonStr);
|
||||||
|
} catch (e) {
|
||||||
|
alert("Ugyldigt JSON format i variabler");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById('emailTemplateName').value,
|
||||||
|
slug: document.getElementById('emailTemplateSlug').value,
|
||||||
|
category: document.getElementById('emailTemplateCategory').value,
|
||||||
|
subject: document.getElementById('emailTemplateSubject').value,
|
||||||
|
body: document.getElementById('emailTemplateBody').value,
|
||||||
|
description: document.getElementById('emailTemplateDescription').value,
|
||||||
|
customer_id: document.getElementById('emailTemplateCustomer').value || null,
|
||||||
|
variables: variables
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.name || !payload.slug || !payload.subject || !payload.body) {
|
||||||
|
alert("Udfyld venligst alle obligatoriske felter (*)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = isNew ? '/api/v1/email-templates/' : `/api/v1/email-templates/${id}`;
|
||||||
|
const method = isNew ? 'POST' : 'PUT';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.detail || "Fejl ved gemning");
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('emailTemplateModal')).hide();
|
||||||
|
loadEmailTemplates();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEmailTemplate(id) {
|
||||||
|
if(!confirm("Er du sikker på at du vil slette denne skabelon?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/email-templates/${id}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) throw new Error("Fejl ved sletning");
|
||||||
|
loadEmailTemplates();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Other loaders are called at bottom of file in existing script
|
||||||
|
loadEmailTemplates();
|
||||||
|
loadEmailTemplateCustomers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -149,6 +149,21 @@ async def remove_tag_from_entity(
|
|||||||
raise HTTPException(status_code=404, detail="Tag association not found")
|
raise HTTPException(status_code=404, detail="Tag association not found")
|
||||||
return {"message": "Tag removed from entity"}
|
return {"message": "Tag removed from entity"}
|
||||||
|
|
||||||
|
@router.delete("/entity/{entity_type}/{entity_id}/{tag_id}")
|
||||||
|
async def remove_tag_from_entity_path(
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: int,
|
||||||
|
tag_id: int
|
||||||
|
):
|
||||||
|
"""Remove tag from entity (Path param version)"""
|
||||||
|
result = execute_update(
|
||||||
|
"DELETE FROM entity_tags WHERE entity_type = %s AND entity_id = %s AND tag_id = %s",
|
||||||
|
(entity_type, entity_id, tag_id)
|
||||||
|
)
|
||||||
|
if result == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Tag association not found")
|
||||||
|
return {"message": "Tag removed from entity"}
|
||||||
|
|
||||||
@router.get("/entity/{entity_type}/{entity_id}", response_model=List[Tag])
|
@router.get("/entity/{entity_type}/{entity_id}", response_model=List[Tag])
|
||||||
async def get_entity_tags(entity_type: str, entity_id: int):
|
async def get_entity_tags(entity_type: str, entity_id: int):
|
||||||
"""Get all tags for a specific entity"""
|
"""Get all tags for a specific entity"""
|
||||||
|
|||||||
@ -52,7 +52,8 @@ async def worklog_review_page(
|
|||||||
status: Filter by status (default: draft)
|
status: Filter by status (default: draft)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Build query with filters
|
# Build query with filters (UNION of Ticket Worklogs and Sag/Module Times)
|
||||||
|
# Ticket Worklogs (Positive IDs)
|
||||||
query = """
|
query = """
|
||||||
SELECT
|
SELECT
|
||||||
w.id,
|
w.id,
|
||||||
@ -73,7 +74,8 @@ async def worklog_review_page(
|
|||||||
c.name AS customer_name,
|
c.name AS customer_name,
|
||||||
u.username AS user_name,
|
u.username AS user_name,
|
||||||
pc.card_number,
|
pc.card_number,
|
||||||
pc.remaining_hours AS card_remaining_hours
|
pc.remaining_hours AS card_remaining_hours,
|
||||||
|
false as is_sag_module
|
||||||
FROM tticket_worklog w
|
FROM tticket_worklog w
|
||||||
INNER JOIN tticket_tickets t ON t.id = w.ticket_id
|
INNER JOIN tticket_tickets t ON t.id = w.ticket_id
|
||||||
LEFT JOIN customers c ON c.id = t.customer_id
|
LEFT JOIN customers c ON c.id = t.customer_id
|
||||||
@ -88,7 +90,52 @@ async def worklog_review_page(
|
|||||||
query += " AND t.customer_id = %s"
|
query += " AND t.customer_id = %s"
|
||||||
params.append(customer_id)
|
params.append(customer_id)
|
||||||
|
|
||||||
query += " ORDER BY w.work_date DESC, w.created_at DESC"
|
# UNION ALL with Sag Module Times (Negative IDs)
|
||||||
|
# Map status: pending -> draft, approved -> billable
|
||||||
|
mapped_status = 'pending' if status == 'draft' else ('approved' if status == 'billable' else status)
|
||||||
|
|
||||||
|
query += """
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
(tm.id * -1) as id,
|
||||||
|
tm.sag_id as ticket_id,
|
||||||
|
NULL as user_id,
|
||||||
|
tm.worked_date as work_date,
|
||||||
|
tm.original_hours as hours,
|
||||||
|
'sag' as work_type,
|
||||||
|
tm.description,
|
||||||
|
COALESCE(tm.billing_method, 'invoice') as billing_method,
|
||||||
|
CASE
|
||||||
|
WHEN tm.status = 'pending' THEN 'draft'
|
||||||
|
WHEN tm.status = 'approved' THEN 'billable'
|
||||||
|
ELSE tm.status
|
||||||
|
END as status,
|
||||||
|
tm.prepaid_card_id,
|
||||||
|
tm.created_at,
|
||||||
|
CONCAT('SAG-', tm.sag_id) as ticket_number,
|
||||||
|
COALESCE(sol.title, 'Sagssarbejde') as ticket_subject,
|
||||||
|
cust.id as customer_id, -- Access core customer ID via Sag join
|
||||||
|
'Open' as ticket_status,
|
||||||
|
COALESCE(cust.name, 'Ukendt Kunde') as customer_name,
|
||||||
|
tm.user_name,
|
||||||
|
pc.card_number,
|
||||||
|
pc.remaining_hours as card_remaining_hours,
|
||||||
|
true as is_sag_module
|
||||||
|
FROM tmodule_times tm
|
||||||
|
LEFT JOIN sag_sager s ON tm.sag_id = s.id
|
||||||
|
LEFT JOIN sag_solutions sol ON tm.solution_id = sol.id
|
||||||
|
LEFT JOIN customers cust ON s.customer_id = cust.id
|
||||||
|
LEFT JOIN tticket_prepaid_cards pc ON tm.prepaid_card_id = pc.id
|
||||||
|
WHERE tm.status = %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
params.append(mapped_status)
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
query += " AND s.customer_id = %s"
|
||||||
|
params.append(customer_id)
|
||||||
|
|
||||||
|
query += " ORDER BY work_date DESC, created_at DESC"
|
||||||
|
|
||||||
worklogs = execute_query(query, tuple(params))
|
worklogs = execute_query(query, tuple(params))
|
||||||
|
|
||||||
@ -142,6 +189,14 @@ async def approve_worklog_entry(
|
|||||||
redirect_to: URL to redirect after approval
|
redirect_to: URL to redirect after approval
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Handle Sag Module Entries (Negative IDs)
|
||||||
|
if worklog_id < 0:
|
||||||
|
tm_id = abs(worklog_id)
|
||||||
|
update_query = "UPDATE tmodule_times SET status = 'approved' WHERE id = %s AND status = 'pending'"
|
||||||
|
execute_update(update_query, (tm_id,))
|
||||||
|
logger.info(f"✅ Approved Sag time entry {tm_id}")
|
||||||
|
return RedirectResponse(url=redirect_to, status_code=303)
|
||||||
|
|
||||||
# Check entry exists and is draft
|
# Check entry exists and is draft
|
||||||
check_query = """
|
check_query = """
|
||||||
SELECT id, status, billing_method
|
SELECT id, status, billing_method
|
||||||
|
|||||||
@ -86,13 +86,15 @@ class TModuleCase(TModuleCaseBase):
|
|||||||
|
|
||||||
class TModuleTimeBase(BaseModel):
|
class TModuleTimeBase(BaseModel):
|
||||||
"""Base model for time entry"""
|
"""Base model for time entry"""
|
||||||
vtiger_id: str = Field(..., description="vTiger ModComments ID")
|
vtiger_id: Optional[str] = Field(None, description="vTiger ModComments ID (Optional)")
|
||||||
case_id: int = Field(..., gt=0)
|
case_id: Optional[int] = Field(None, gt=0, description="vTiger Case ID (Optional)")
|
||||||
|
sag_id: Optional[int] = Field(None, gt=0, description="Hub Sag ID (Optional)")
|
||||||
|
solution_id: Optional[int] = Field(None, gt=0, description="Hub Solution ID (Optional)")
|
||||||
customer_id: int = Field(..., gt=0)
|
customer_id: int = Field(..., gt=0)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
original_hours: Decimal = Field(..., gt=0, description="Original timer fra vTiger")
|
original_hours: Decimal = Field(..., gt=0, description="Original timer")
|
||||||
worked_date: Optional[date] = None
|
worked_date: Optional[date] = None
|
||||||
user_name: Optional[str] = Field(None, max_length=255, description="vTiger bruger")
|
user_name: Optional[str] = Field(None, max_length=255, description="Bruger")
|
||||||
|
|
||||||
|
|
||||||
class TModuleTimeCreate(TModuleTimeBase):
|
class TModuleTimeCreate(TModuleTimeBase):
|
||||||
@ -176,6 +178,7 @@ class TModuleOrderLineBase(BaseModel):
|
|||||||
unit_price: Decimal = Field(..., ge=0, description="DKK pr. time")
|
unit_price: Decimal = Field(..., ge=0, description="DKK pr. time")
|
||||||
line_total: Decimal = Field(..., ge=0, description="Total for linje")
|
line_total: Decimal = Field(..., ge=0, description="Total for linje")
|
||||||
case_id: Optional[int] = Field(None, gt=0)
|
case_id: Optional[int] = Field(None, gt=0)
|
||||||
|
sag_id: Optional[int] = Field(None, gt=0)
|
||||||
time_entry_ids: List[int] = Field(default_factory=list)
|
time_entry_ids: List[int] = Field(default_factory=list)
|
||||||
product_number: Optional[str] = Field(None, max_length=50)
|
product_number: Optional[str] = Field(None, max_length=50)
|
||||||
case_contact: Optional[str] = Field(None, max_length=255)
|
case_contact: Optional[str] = Field(None, max_length=255)
|
||||||
|
|||||||
@ -89,28 +89,34 @@ class OrderService:
|
|||||||
# Debug log
|
# Debug log
|
||||||
logger.info(f"✅ Found customer: {customer.get('name') if isinstance(customer, dict) else type(customer)}")
|
logger.info(f"✅ Found customer: {customer.get('name') if isinstance(customer, dict) else type(customer)}")
|
||||||
|
|
||||||
# Hent godkendte tider for kunden med case og contact detaljer
|
# Hent godkendte tider for kunden med case og contact detaljer (Supports both vTiger Cases and Hub Sags)
|
||||||
query = """
|
query = """
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NULLIF(TRIM(c.title), ''),
|
NULLIF(TRIM(c.title), ''),
|
||||||
NULLIF(TRIM(c.vtiger_data->>'ticket_title'), ''),
|
NULLIF(TRIM(c.vtiger_data->>'ticket_title'), ''),
|
||||||
|
NULLIF(TRIM(s.titel), ''),
|
||||||
'Ingen titel'
|
'Ingen titel'
|
||||||
) as case_title,
|
) as case_title,
|
||||||
c.vtiger_id as case_vtiger_id,
|
COALESCE(c.vtiger_id, CONCAT('SAG-', s.id)) as case_vtiger_id,
|
||||||
COALESCE(c.vtiger_data->>'case_no', c.vtiger_data->>'ticket_no') as case_number,
|
COALESCE(
|
||||||
|
c.vtiger_data->>'case_no',
|
||||||
|
c.vtiger_data->>'ticket_no',
|
||||||
|
CONCAT('SAG-', s.id)
|
||||||
|
) as case_number,
|
||||||
c.vtiger_data->>'ticket_title' as vtiger_title,
|
c.vtiger_data->>'ticket_title' as vtiger_title,
|
||||||
c.priority as case_priority,
|
COALESCE(c.priority, 'Normal') as case_priority,
|
||||||
c.status as case_status,
|
COALESCE(c.status, s.status) as case_status,
|
||||||
c.module_type as case_type,
|
COALESCE(c.module_type, 'sag') as case_type,
|
||||||
CONCAT(cont.first_name, ' ', cont.last_name) as contact_name
|
CONCAT(cont.first_name, ' ', cont.last_name) as contact_name
|
||||||
FROM tmodule_times t
|
FROM tmodule_times t
|
||||||
JOIN tmodule_cases c ON t.case_id = c.id
|
LEFT JOIN tmodule_cases c ON t.case_id = c.id
|
||||||
|
LEFT JOIN sag_sager s ON t.sag_id = s.id
|
||||||
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
|
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
|
||||||
WHERE t.customer_id = %s
|
WHERE t.customer_id = %s
|
||||||
AND t.status = 'approved'
|
AND t.status = 'approved'
|
||||||
AND t.billable = true
|
AND t.billable = true
|
||||||
ORDER BY c.id, t.worked_date
|
ORDER BY COALESCE(c.id, s.id), t.worked_date
|
||||||
"""
|
"""
|
||||||
approved_times = execute_query(query, (customer_id,))
|
approved_times = execute_query(query, (customer_id,))
|
||||||
|
|
||||||
@ -131,11 +137,18 @@ class OrderService:
|
|||||||
# Group by case og gem ekstra metadata
|
# Group by case og gem ekstra metadata
|
||||||
case_groups = {}
|
case_groups = {}
|
||||||
for time_entry in approved_times:
|
for time_entry in approved_times:
|
||||||
case_id = time_entry['case_id']
|
# Use case_number as unique key to prevent collision between Cases and Sags
|
||||||
if case_id not in case_groups:
|
case_key = time_entry.get('case_number')
|
||||||
|
if not case_key:
|
||||||
|
# Fallback to ID if no number (should not happen with updated query)
|
||||||
|
case_key = str(time_entry.get('case_id') or time_entry.get('sag_id'))
|
||||||
|
|
||||||
|
if case_key not in case_groups:
|
||||||
# Prioriter case title fra vTiger (c.title), fallback til vtiger_data title
|
# Prioriter case title fra vTiger (c.title), fallback til vtiger_data title
|
||||||
case_title = time_entry.get('case_title') or time_entry.get('vtiger_title')
|
case_title = time_entry.get('case_title') or time_entry.get('vtiger_title')
|
||||||
case_groups[case_id] = {
|
case_groups[case_key] = {
|
||||||
|
'case_id': time_entry.get('case_id'),
|
||||||
|
'sag_id': time_entry.get('sag_id'),
|
||||||
'case_vtiger_id': time_entry.get('case_vtiger_id'),
|
'case_vtiger_id': time_entry.get('case_vtiger_id'),
|
||||||
'case_number': time_entry.get('case_number'), # Fra vtiger_data
|
'case_number': time_entry.get('case_number'), # Fra vtiger_data
|
||||||
'case_title': case_title, # Case titel fra vTiger
|
'case_title': case_title, # Case titel fra vTiger
|
||||||
@ -148,17 +161,17 @@ class OrderService:
|
|||||||
'entries': [],
|
'entries': [],
|
||||||
'descriptions': [] # Samle alle beskrivelser
|
'descriptions': [] # Samle alle beskrivelser
|
||||||
}
|
}
|
||||||
case_groups[case_id]['entries'].append(time_entry)
|
case_groups[case_key]['entries'].append(time_entry)
|
||||||
# Opdater til seneste dato
|
# Opdater til seneste dato
|
||||||
if time_entry.get('worked_date'):
|
if time_entry.get('worked_date'):
|
||||||
if not case_groups[case_id]['worked_date'] or time_entry['worked_date'] > case_groups[case_id]['worked_date']:
|
if not case_groups[case_key]['worked_date'] or time_entry['worked_date'] > case_groups[case_key]['worked_date']:
|
||||||
case_groups[case_id]['worked_date'] = time_entry['worked_date']
|
case_groups[case_key]['worked_date'] = time_entry['worked_date']
|
||||||
# Marker som rejse hvis nogen entry er rejse
|
# Marker som rejse hvis nogen entry er rejse
|
||||||
if time_entry.get('is_travel'):
|
if time_entry.get('is_travel'):
|
||||||
case_groups[case_id]['is_travel'] = True
|
case_groups[case_key]['is_travel'] = True
|
||||||
# Tilføj beskrivelse hvis den ikke er tom
|
# Tilføj beskrivelse hvis den ikke er tom
|
||||||
if time_entry.get('description') and time_entry['description'].strip():
|
if time_entry.get('description') and time_entry['description'].strip():
|
||||||
case_groups[case_id]['descriptions'].append(time_entry['description'].strip())
|
case_groups[case_key]['descriptions'].append(time_entry['description'].strip())
|
||||||
|
|
||||||
# Build order lines
|
# Build order lines
|
||||||
order_lines = []
|
order_lines = []
|
||||||
@ -236,7 +249,8 @@ class OrderService:
|
|||||||
quantity=case_hours,
|
quantity=case_hours,
|
||||||
unit_price=hourly_rate,
|
unit_price=hourly_rate,
|
||||||
line_total=line_total,
|
line_total=line_total,
|
||||||
case_id=case_id,
|
case_id=group.get('case_id'),
|
||||||
|
sag_id=group.get('sag_id'),
|
||||||
time_entry_ids=time_entry_ids,
|
time_entry_ids=time_entry_ids,
|
||||||
case_contact=group.get('contact_name'),
|
case_contact=group.get('contact_name'),
|
||||||
time_date=group.get('worked_date'),
|
time_date=group.get('worked_date'),
|
||||||
@ -282,13 +296,14 @@ class OrderService:
|
|||||||
for line in order_lines:
|
for line in order_lines:
|
||||||
line_id = execute_insert(
|
line_id = execute_insert(
|
||||||
"""INSERT INTO tmodule_order_lines
|
"""INSERT INTO tmodule_order_lines
|
||||||
(order_id, case_id, line_number, description, quantity, unit_price,
|
(order_id, case_id, sag_id, line_number, description, quantity, unit_price,
|
||||||
line_total, time_entry_ids, case_contact, time_date, is_travel)
|
line_total, time_entry_ids, case_contact, time_date, is_travel)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id""",
|
RETURNING id""",
|
||||||
(
|
(
|
||||||
order_id,
|
order_id,
|
||||||
line.case_id,
|
line.case_id,
|
||||||
|
line.sag_id,
|
||||||
line.line_number,
|
line.line_number,
|
||||||
line.description,
|
line.description,
|
||||||
line.quantity,
|
line.quantity,
|
||||||
|
|||||||
@ -1742,3 +1742,143 @@ async def uninstall_module(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Uninstall failed: {e}")
|
logger.error(f"❌ Uninstall failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# INTERNAL / HUB INTEGRATION ENDPOINTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/entries/sag/{sag_id}", tags=["Internal"])
|
||||||
|
async def get_time_entries_for_sag(sag_id: int):
|
||||||
|
"""Get time entries linked to a Hub Sag (Case)."""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
SELECT * FROM tmodule_times
|
||||||
|
WHERE sag_id = %s
|
||||||
|
ORDER BY worked_date DESC, created_at DESC
|
||||||
|
"""
|
||||||
|
results = execute_query(query, (sag_id,))
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching time entries for sag {sag_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to fetch time entries")
|
||||||
|
|
||||||
|
@router.post("/entries/internal", tags=["Internal"])
|
||||||
|
async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
|
||||||
|
"""
|
||||||
|
Create a time entry manually (Internal/Hub).
|
||||||
|
Requires: sag_id, original_hours
|
||||||
|
Optional: customer_id (auto-resolved from sag if missing), solution_id
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sag_id = entry.get("sag_id")
|
||||||
|
solution_id = entry.get("solution_id")
|
||||||
|
customer_id = entry.get("customer_id")
|
||||||
|
description = entry.get("description")
|
||||||
|
hours = entry.get("original_hours")
|
||||||
|
worked_date = entry.get("worked_date") or datetime.now().date()
|
||||||
|
user_name = entry.get("user_name", "Hub User")
|
||||||
|
prepaid_card_id = entry.get("prepaid_card_id")
|
||||||
|
work_type = entry.get("work_type", "support")
|
||||||
|
is_internal = entry.get("is_internal", False)
|
||||||
|
|
||||||
|
if not sag_id or not hours:
|
||||||
|
raise HTTPException(status_code=400, detail="sag_id and original_hours required")
|
||||||
|
|
||||||
|
hours_decimal = float(hours)
|
||||||
|
|
||||||
|
# Auto-resolve customer if missing
|
||||||
|
if not customer_id:
|
||||||
|
# Get Hub Customer ID from Sag (fallback to linked customers)
|
||||||
|
sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sag_id,))
|
||||||
|
if not sag:
|
||||||
|
raise HTTPException(status_code=404, detail="Sag not found")
|
||||||
|
hub_customer_id = sag.get("customer_id")
|
||||||
|
|
||||||
|
if not hub_customer_id:
|
||||||
|
linked_customer = execute_query_single(
|
||||||
|
"SELECT customer_id FROM sag_kunder WHERE sag_id = %s AND deleted_at IS NULL ORDER BY id ASC LIMIT 1",
|
||||||
|
(sag_id,)
|
||||||
|
)
|
||||||
|
hub_customer_id = linked_customer.get("customer_id") if linked_customer else None
|
||||||
|
|
||||||
|
if hub_customer_id:
|
||||||
|
# Find matching tmodule_customer
|
||||||
|
tm_cust = execute_query_single("SELECT id FROM tmodule_customers WHERE hub_customer_id = %s", (hub_customer_id,))
|
||||||
|
if tm_cust:
|
||||||
|
customer_id = tm_cust["id"]
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Customer (Hub ID: {hub_customer_id}) not found in Time Module. Please sync customers."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Sag has no customer linked")
|
||||||
|
|
||||||
|
# Handle Prepaid Card
|
||||||
|
billing_method = entry.get('billing_method', 'invoice')
|
||||||
|
status = 'pending'
|
||||||
|
billable = True
|
||||||
|
|
||||||
|
if is_internal:
|
||||||
|
billing_method = 'internal'
|
||||||
|
billable = False
|
||||||
|
elif prepaid_card_id:
|
||||||
|
# Verify card
|
||||||
|
card = execute_query_single("SELECT * FROM tticket_prepaid_cards WHERE id = %s", (prepaid_card_id,))
|
||||||
|
if not card:
|
||||||
|
raise HTTPException(status_code=404, detail="Prepaid card not found")
|
||||||
|
|
||||||
|
if float(card['remaining_hours']) < hours_decimal:
|
||||||
|
# Optional: Allow overdraft? For now, block.
|
||||||
|
raise HTTPException(status_code=400, detail=f"Insufficient hours on prepaid card (Remaining: {card['remaining_hours']})")
|
||||||
|
|
||||||
|
# Deduct hours (remaining_hours is generated; update used_hours instead)
|
||||||
|
new_used = float(card['used_hours']) + hours_decimal
|
||||||
|
execute_update(
|
||||||
|
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
|
||||||
|
(new_used, prepaid_card_id)
|
||||||
|
)
|
||||||
|
updated_card = execute_query_single(
|
||||||
|
"SELECT remaining_hours FROM tticket_prepaid_cards WHERE id = %s",
|
||||||
|
(prepaid_card_id,)
|
||||||
|
)
|
||||||
|
if updated_card and float(updated_card['remaining_hours']) <= 0:
|
||||||
|
execute_update(
|
||||||
|
"UPDATE tticket_prepaid_cards SET status = 'depleted' WHERE id = %s",
|
||||||
|
(prepaid_card_id,)
|
||||||
|
)
|
||||||
|
logger.info(f"💳 Deducted {hours_decimal} hours from prepaid card {prepaid_card_id}")
|
||||||
|
|
||||||
|
billing_method = 'prepaid'
|
||||||
|
status = 'billed' # Mark as processed/billed so it skips invoicing
|
||||||
|
|
||||||
|
elif billing_method == 'internal' or billing_method == 'warranty':
|
||||||
|
billable = False
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO tmodule_times (
|
||||||
|
sag_id, solution_id, customer_id, description,
|
||||||
|
original_hours, worked_date, user_name,
|
||||||
|
status, billable, billing_method, prepaid_card_id, work_type
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s
|
||||||
|
) RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = (
|
||||||
|
sag_id, solution_id, customer_id, description,
|
||||||
|
hours, worked_date, user_name,
|
||||||
|
status, billable, billing_method, prepaid_card_id, work_type
|
||||||
|
)
|
||||||
|
result = execute_query(query, params)
|
||||||
|
if result:
|
||||||
|
return result[0]
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create entry")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error creating internal time entry: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
36
apply_migration_085.py
Normal file
36
apply_migration_085.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.append(os.getcwd())
|
||||||
|
|
||||||
|
from app.core.database import execute_query
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def run_migration():
|
||||||
|
migration_file = "migrations/085_sag_solutions.sql"
|
||||||
|
logger.info(f"Applying migration: {migration_file}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(migration_file, "r") as f:
|
||||||
|
sql = f.read()
|
||||||
|
|
||||||
|
# Split by command (simple split by semicolon)
|
||||||
|
# Note: This is a fragile split, but works for simple migrations without function bodies containing semicolons
|
||||||
|
commands = [cmd.strip() for cmd in sql.split(";") if cmd.strip()]
|
||||||
|
|
||||||
|
for cmd in commands:
|
||||||
|
logger.info(f"Executing: {cmd[:50]}...")
|
||||||
|
execute_query(cmd, ())
|
||||||
|
|
||||||
|
logger.info("✅ Migration applied successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Migration failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_migration()
|
||||||
76
main.py
76
main.py
@ -5,7 +5,7 @@ Main application entry point
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
@ -13,6 +13,8 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import init_db
|
from app.core.database import init_db
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
from app.core.database import execute_query_single
|
||||||
|
|
||||||
def get_version():
|
def get_version():
|
||||||
"""Read version from VERSION file"""
|
"""Read version from VERSION file"""
|
||||||
@ -48,6 +50,7 @@ from app.tags.backend import views as tags_views
|
|||||||
from app.emails.backend import router as emails_api
|
from app.emails.backend import router as emails_api
|
||||||
from app.emails.frontend import views as emails_views
|
from app.emails.frontend import views as emails_views
|
||||||
from app.settings.backend import router as settings_api
|
from app.settings.backend import router as settings_api
|
||||||
|
from app.settings.backend import email_templates as email_templates_api
|
||||||
from app.settings.backend import views as settings_views
|
from app.settings.backend import views as settings_views
|
||||||
from app.backups.backend.router import router as backups_api
|
from app.backups.backend.router import router as backups_api
|
||||||
from app.backups.frontend import views as backups_views
|
from app.backups.frontend import views as backups_views
|
||||||
@ -56,6 +59,9 @@ from app.conversations.backend import router as conversations_api
|
|||||||
from app.conversations.frontend import views as conversations_views
|
from app.conversations.frontend import views as conversations_views
|
||||||
from app.opportunities.backend import router as opportunities_api
|
from app.opportunities.backend import router as opportunities_api
|
||||||
from app.opportunities.frontend import views as opportunities_views
|
from app.opportunities.frontend import views as opportunities_views
|
||||||
|
from app.auth.backend import router as auth_api
|
||||||
|
from app.auth.backend import views as auth_views
|
||||||
|
from app.auth.backend import admin as auth_admin_api
|
||||||
|
|
||||||
# Modules
|
# Modules
|
||||||
from app.modules.webshop.backend import router as webshop_api
|
from app.modules.webshop.backend import router as webshop_api
|
||||||
@ -66,6 +72,8 @@ from app.modules.hardware.backend import router as hardware_module_api
|
|||||||
from app.modules.hardware.frontend import views as hardware_module_views
|
from app.modules.hardware.frontend import views as hardware_module_views
|
||||||
from app.modules.locations.backend import router as locations_api
|
from app.modules.locations.backend import router as locations_api
|
||||||
from app.modules.locations.frontend import views as locations_views
|
from app.modules.locations.frontend import views as locations_views
|
||||||
|
from app.modules.nextcloud.backend import router as nextcloud_api
|
||||||
|
from app.modules.search.backend import router as search_api
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -126,6 +134,66 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Global auth middleware
|
||||||
|
@app.middleware("http")
|
||||||
|
async def auth_middleware(request: Request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
public_paths = {
|
||||||
|
"/health",
|
||||||
|
"/login",
|
||||||
|
"/api/v1/auth/login"
|
||||||
|
}
|
||||||
|
|
||||||
|
if path in public_paths or path.startswith("/static") or path.startswith("/docs"):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
token = None
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if auth_header and auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header.split(" ", 1)[1]
|
||||||
|
else:
|
||||||
|
token = request.cookies.get("access_token")
|
||||||
|
|
||||||
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
if not payload:
|
||||||
|
if path.startswith("/api"):
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Not authenticated"}
|
||||||
|
)
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
|
if path.startswith("/api") and not payload.get("shadow_admin"):
|
||||||
|
if not payload.get("sub"):
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Invalid token"}
|
||||||
|
)
|
||||||
|
user_id = int(payload.get("sub"))
|
||||||
|
user = execute_query_single(
|
||||||
|
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
is_2fa_enabled = bool(user and user.get("is_2fa_enabled"))
|
||||||
|
|
||||||
|
if not is_2fa_enabled:
|
||||||
|
allowed_2fa_paths = (
|
||||||
|
"/api/v1/auth/2fa",
|
||||||
|
"/api/v1/auth/me",
|
||||||
|
"/api/v1/auth/logout"
|
||||||
|
)
|
||||||
|
if not path.startswith(allowed_2fa_paths):
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": "2FA required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
|
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
|
||||||
app.include_router(bmc_office_router.router, prefix="/api/v1", tags=["BMC Office"])
|
app.include_router(bmc_office_router.router, prefix="/api/v1", tags=["BMC Office"])
|
||||||
@ -142,15 +210,20 @@ app.include_router(timetracking_api, prefix="/api/v1", tags=["Time Tracking"])
|
|||||||
app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"])
|
app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"])
|
||||||
app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"])
|
app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"])
|
||||||
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
|
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
|
||||||
|
app.include_router(email_templates_api.router, prefix="/api/v1", tags=["Email Templates"])
|
||||||
app.include_router(backups_api, prefix="/api/v1", tags=["Backups"])
|
app.include_router(backups_api, prefix="/api/v1", tags=["Backups"])
|
||||||
app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
|
app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
|
||||||
app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunities"])
|
app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunities"])
|
||||||
|
app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Auth"])
|
||||||
|
app.include_router(auth_admin_api.router, prefix="/api/v1", tags=["Auth Admin"])
|
||||||
|
|
||||||
# Module Routers
|
# Module Routers
|
||||||
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
|
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
|
||||||
app.include_router(sag_api.router, prefix="/api/v1", tags=["Cases"])
|
app.include_router(sag_api.router, prefix="/api/v1", tags=["Cases"])
|
||||||
app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware Module"])
|
app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware Module"])
|
||||||
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
|
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
|
||||||
|
app.include_router(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Nextcloud"])
|
||||||
|
app.include_router(search_api.router, prefix="/api/v1", tags=["Search"])
|
||||||
|
|
||||||
# Frontend Routers
|
# Frontend Routers
|
||||||
app.include_router(dashboard_views.router, tags=["Frontend"])
|
app.include_router(dashboard_views.router, tags=["Frontend"])
|
||||||
@ -168,6 +241,7 @@ app.include_router(backups_views.router, tags=["Frontend"])
|
|||||||
app.include_router(conversations_views.router, tags=["Frontend"])
|
app.include_router(conversations_views.router, tags=["Frontend"])
|
||||||
app.include_router(webshop_views.router, tags=["Frontend"])
|
app.include_router(webshop_views.router, tags=["Frontend"])
|
||||||
app.include_router(opportunities_views.router, tags=["Frontend"])
|
app.include_router(opportunities_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(auth_views.router, tags=["Frontend"])
|
||||||
app.include_router(sag_views.router, tags=["Frontend"])
|
app.include_router(sag_views.router, tags=["Frontend"])
|
||||||
app.include_router(hardware_module_views.router, tags=["Frontend"])
|
app.include_router(hardware_module_views.router, tags=["Frontend"])
|
||||||
app.include_router(locations_views.router, tags=["Frontend"])
|
app.include_router(locations_views.router, tags=["Frontend"])
|
||||||
|
|||||||
22
migrations/076_nextcloud_instances.sql
Normal file
22
migrations/076_nextcloud_instances.sql
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
-- Migration: 076_nextcloud_instances
|
||||||
|
-- Created: 2026-02-01
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS nextcloud_instances (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
auth_type VARCHAR(20) NOT NULL DEFAULT 'basic',
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password_encrypted TEXT NOT NULL,
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
disabled_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_nextcloud_instances_customer
|
||||||
|
ON nextcloud_instances(customer_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nextcloud_instances_enabled
|
||||||
|
ON nextcloud_instances(is_enabled);
|
||||||
12
migrations/077_nextcloud_cache.sql
Normal file
12
migrations/077_nextcloud_cache.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- Migration: 077_nextcloud_cache
|
||||||
|
-- Created: 2026-02-01
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS nextcloud_cache (
|
||||||
|
cache_key TEXT PRIMARY KEY,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nextcloud_cache_expires
|
||||||
|
ON nextcloud_cache(expires_at);
|
||||||
22
migrations/078_nextcloud_audit_log.sql
Normal file
22
migrations/078_nextcloud_audit_log.sql
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
-- Migration: 078_nextcloud_audit_log
|
||||||
|
-- Created: 2026-02-01
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS nextcloud_audit_log (
|
||||||
|
id BIGSERIAL NOT NULL,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
instance_id INTEGER REFERENCES nextcloud_instances(id) ON DELETE SET NULL,
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
request_meta JSONB,
|
||||||
|
response_meta JSONB,
|
||||||
|
actor_user_id INTEGER,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (id, created_at)
|
||||||
|
) PARTITION BY RANGE (created_at);
|
||||||
|
|
||||||
|
-- Create current month partition
|
||||||
|
CREATE TABLE IF NOT EXISTS nextcloud_audit_log_2026_02
|
||||||
|
PARTITION OF nextcloud_audit_log
|
||||||
|
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nextcloud_audit_customer_created
|
||||||
|
ON nextcloud_audit_log (customer_id, created_at DESC);
|
||||||
38
migrations/079_email_templates.sql
Normal file
38
migrations/079_email_templates.sql
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
-- Migration: 079_email_templates
|
||||||
|
-- Created: 2026-02-01
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'email_template_internal_subject',
|
||||||
|
'Intern besked fra BMC Hub',
|
||||||
|
'email_templates',
|
||||||
|
'Intern email emne',
|
||||||
|
'string',
|
||||||
|
false
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'email_template_internal_body',
|
||||||
|
'Hej team,\n\n[Indsæt besked]\n\n--\nBMC Hub',
|
||||||
|
'email_templates',
|
||||||
|
'Intern email skabelon',
|
||||||
|
'text',
|
||||||
|
false
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'email_template_external_subject',
|
||||||
|
'Besked fra BMC Networks',
|
||||||
|
'email_templates',
|
||||||
|
'Ekstern email emne',
|
||||||
|
'string',
|
||||||
|
false
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'email_template_external_body',
|
||||||
|
'Hej {{customer_name}},\n\n[Indsæt besked]\n\nVenlig hilsen\nBMC Networks',
|
||||||
|
'email_templates',
|
||||||
|
'Ekstern email skabelon',
|
||||||
|
'text',
|
||||||
|
false
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
22
migrations/080_nextcloud_user_email_template.sql
Normal file
22
migrations/080_nextcloud_user_email_template.sql
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
-- Migration: 080_nextcloud_user_email_template
|
||||||
|
-- Created: 2026-02-01
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'nextcloud_user_welcome_subject',
|
||||||
|
'Velkommen til Nextcloud hos BMC Networks',
|
||||||
|
'email_templates',
|
||||||
|
'Nextcloud velkomstmail emne',
|
||||||
|
'string',
|
||||||
|
false
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'nextcloud_user_welcome_body',
|
||||||
|
'Hej {{name}},\n\nDin Nextcloud-konto er oprettet.\n\nLogin URL: {{url}}\nBrugernavn: {{username}}\nPassword: {{password}}\n\nGuide – sådan logger du ind:\n1) Åbn linket\n2) Indtast brugernavn og password\n3) Skift gerne password ved første login\n\nVenlig hilsen\nBMC Networks',
|
||||||
|
'email_templates',
|
||||||
|
'Nextcloud velkomstmail skabelon',
|
||||||
|
'text',
|
||||||
|
false
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
96
migrations/081_better_email_templates.sql
Normal file
96
migrations/081_better_email_templates.sql
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
-- Migration: 081_better_email_templates
|
||||||
|
-- Created: 2026-02-01
|
||||||
|
|
||||||
|
-- Create email_templates table
|
||||||
|
CREATE TABLE IF NOT EXISTS email_templates (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) NOT NULL,
|
||||||
|
subject VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
category VARCHAR(50) DEFAULT 'general',
|
||||||
|
description TEXT,
|
||||||
|
variables JSONB DEFAULT '{}',
|
||||||
|
is_system BOOLEAN DEFAULT FALSE,
|
||||||
|
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(slug, customer_id) -- A slug must be unique per customer (or global if customer_id is null)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster lookups
|
||||||
|
CREATE INDEX idx_email_templates_slug ON email_templates(slug);
|
||||||
|
CREATE INDEX idx_email_templates_customer ON email_templates(customer_id);
|
||||||
|
|
||||||
|
-- Migrate existing settings if they exist
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
internal_subj TEXT;
|
||||||
|
internal_body TEXT;
|
||||||
|
external_subj TEXT;
|
||||||
|
external_body TEXT;
|
||||||
|
nc_subj TEXT;
|
||||||
|
nc_body TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Intern besked
|
||||||
|
SELECT value INTO internal_subj FROM settings WHERE key = 'email_template_internal_subject';
|
||||||
|
SELECT value INTO internal_body FROM settings WHERE key = 'email_template_internal_body';
|
||||||
|
|
||||||
|
IF internal_subj IS NOT NULL AND internal_body IS NOT NULL THEN
|
||||||
|
INSERT INTO email_templates (name, slug, subject, body, category, description, variables, is_system)
|
||||||
|
VALUES (
|
||||||
|
'Intern besked',
|
||||||
|
'internal_message',
|
||||||
|
internal_subj,
|
||||||
|
internal_body,
|
||||||
|
'internal',
|
||||||
|
'Standard skabelon til interne beskeder',
|
||||||
|
'{"message": "Selve beskeden"}'::jsonb,
|
||||||
|
TRUE
|
||||||
|
) ON CONFLICT DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Ekstern besked
|
||||||
|
SELECT value INTO external_subj FROM settings WHERE key = 'email_template_external_subject';
|
||||||
|
SELECT value INTO external_body FROM settings WHERE key = 'email_template_external_body';
|
||||||
|
|
||||||
|
IF external_subj IS NOT NULL AND external_body IS NOT NULL THEN
|
||||||
|
INSERT INTO email_templates (name, slug, subject, body, category, description, variables, is_system)
|
||||||
|
VALUES (
|
||||||
|
'Generel kundebesked',
|
||||||
|
'external_message',
|
||||||
|
external_subj,
|
||||||
|
external_body,
|
||||||
|
'general',
|
||||||
|
'Standard skabelon til emails sendt til kunder',
|
||||||
|
'{"customer_name": "Navnet på kunden", "message": "Selve beskeden"}'::jsonb,
|
||||||
|
TRUE
|
||||||
|
) ON CONFLICT DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Nextcloud velkomst
|
||||||
|
SELECT value INTO nc_subj FROM settings WHERE key = 'nextcloud_user_welcome_subject';
|
||||||
|
SELECT value INTO nc_body FROM settings WHERE key = 'nextcloud_user_welcome_body';
|
||||||
|
|
||||||
|
IF nc_subj IS NOT NULL AND nc_body IS NOT NULL THEN
|
||||||
|
INSERT INTO email_templates (name, slug, subject, body, category, description, variables, is_system)
|
||||||
|
VALUES (
|
||||||
|
'Nextcloud Velkomst',
|
||||||
|
'nextcloud_welcome',
|
||||||
|
nc_subj,
|
||||||
|
nc_body,
|
||||||
|
'nextcloud',
|
||||||
|
'Sendes til nye brugere når deres konto oprettes',
|
||||||
|
'{"name": "Modtagerens navn", "url": "URL til Nextcloud instans", "username": "Brugernavn", "password": "Det autogenererede password"}'::jsonb,
|
||||||
|
TRUE
|
||||||
|
) ON CONFLICT DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Cleanup old settings (optional, keeping them for backward compatibility if needed, but usually safe to remove if code is updated simultaneously)
|
||||||
|
-- DELETE FROM settings WHERE key IN (
|
||||||
|
-- 'email_template_internal_subject', 'email_template_internal_body',
|
||||||
|
-- 'email_template_external_subject', 'email_template_external_body',
|
||||||
|
-- 'nextcloud_user_welcome_subject', 'nextcloud_user_welcome_body'
|
||||||
|
-- );
|
||||||
14
migrations/082_sag_comments.sql
Normal file
14
migrations/082_sag_comments.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
-- Migration: 082_sag_comments
|
||||||
|
-- Created: 2026-02-01
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_kommentarer (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
forfatter VARCHAR(255) NOT NULL,
|
||||||
|
indhold TEXT NOT NULL,
|
||||||
|
er_system_besked BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sag_kommentarer_sag_id ON sag_comments(sag_id);
|
||||||
27
migrations/083_sag_hardware_locations.sql
Normal file
27
migrations/083_sag_hardware_locations.sql
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-- Migration: 083_sag_hardware_locations
|
||||||
|
-- Created: 2026-02-01
|
||||||
|
-- Description: Enable linking Hardware and Locations to Cases
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_hardware (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMP,
|
||||||
|
UNIQUE(sag_id, hardware_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_lokationer (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMP,
|
||||||
|
UNIQUE(sag_id, location_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
27
migrations/084_sag_files_and_emails.sql
Normal file
27
migrations/084_sag_files_and_emails.sql
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-- Migration 084: Sag (Case) Files and Linked Emails
|
||||||
|
-- Adds support for uploading files and linking emails to cases (Sager)
|
||||||
|
|
||||||
|
-- Files linked to a Case
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_files (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
content_type VARCHAR(100),
|
||||||
|
size_bytes INTEGER,
|
||||||
|
stored_name TEXT NOT NULL,
|
||||||
|
uploaded_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_files_sag_id ON sag_files(sag_id);
|
||||||
|
COMMENT ON TABLE sag_files IS 'Files uploaded directly to the Case.';
|
||||||
|
|
||||||
|
-- Emails linked to a Case (Many-to-Many)
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_emails (
|
||||||
|
sag_id INTEGER REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
email_id INTEGER REFERENCES email_messages(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (sag_id, email_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE sag_emails IS 'Emails linked to the Case.';
|
||||||
28
migrations/085_sag_solutions.sql
Normal file
28
migrations/085_sag_solutions.sql
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
-- Migration 085: Solution Module & Timetracking Integration
|
||||||
|
|
||||||
|
-- 1. Create Solutions Table
|
||||||
|
CREATE TABLE IF NOT EXISTS sag_solutions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
solution_type VARCHAR(50), -- Support, Drift, Konsulent, etc.
|
||||||
|
result VARCHAR(50), -- Løst, Delvist, Workaround, Ej løst
|
||||||
|
created_by_user_id INTEGER, -- Reference to auth user (no FK to abstract 'users' table if not std)
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_sag_solutions_sag_id UNIQUE (sag_id) -- 1:1 relation
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Update Timetracking Table to support Internal Cases & Solutions
|
||||||
|
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS solution_id INTEGER REFERENCES sag_solutions(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- 3. Relax vTiger constraints to allow manual creation in Hub
|
||||||
|
ALTER TABLE tmodule_times ALTER COLUMN vtiger_id DROP NOT NULL;
|
||||||
|
ALTER TABLE tmodule_times ALTER COLUMN case_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- 4. Indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_solutions_sag_id ON sag_solutions(sag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_times_solution_id ON tmodule_times(solution_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_times_sag_id ON tmodule_times(sag_id);
|
||||||
11
migrations/086_case_types_settings.sql
Normal file
11
migrations/086_case_types_settings.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
-- Add case types setting
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES (
|
||||||
|
'case_types',
|
||||||
|
'["ticket", "opgave", "ordre", "projekt", "service"]',
|
||||||
|
'system',
|
||||||
|
'Sags-typer',
|
||||||
|
'json',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
8
migrations/086_sag_prepaid_integration.sql
Normal file
8
migrations/086_sag_prepaid_integration.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- Migration: Add prepaid_card_id to tmodule_times for integration with Ticket Prepaid Cards
|
||||||
|
-- Date: 2025-02-02
|
||||||
|
-- Description: Allows Solutions/Time Module entries to be linked to prepaid cards (klippekort)
|
||||||
|
|
||||||
|
ALTER TABLE tmodule_times
|
||||||
|
ADD COLUMN IF NOT EXISTS prepaid_card_id INTEGER REFERENCES tticket_prepaid_cards(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_times_prepaid_card ON tmodule_times(prepaid_card_id);
|
||||||
10
migrations/087_sag_billing_method.sql
Normal file
10
migrations/087_sag_billing_method.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- Migration: Add billing_method to tmodule_times
|
||||||
|
-- Date: 2025-02-02
|
||||||
|
-- Description: Adds detailed billing method to replace simple boolean, aligning with tticket_worklog
|
||||||
|
|
||||||
|
ALTER TABLE tmodule_times
|
||||||
|
ADD COLUMN IF NOT EXISTS billing_method VARCHAR(50) DEFAULT 'invoice';
|
||||||
|
|
||||||
|
-- Migrate existing data
|
||||||
|
UPDATE tmodule_times SET billing_method = 'internal' WHERE billable = false;
|
||||||
|
UPDATE tmodule_times SET billing_method = 'invoice' WHERE billable = true;
|
||||||
8
migrations/088_sag_order_lines.sql
Normal file
8
migrations/088_sag_order_lines.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- Migration: Add sag_id to tmodule_order_lines
|
||||||
|
-- Date: 2025-02-02
|
||||||
|
-- Description: Allows order lines to reference Sag module cases
|
||||||
|
|
||||||
|
ALTER TABLE tmodule_order_lines
|
||||||
|
ADD COLUMN IF NOT EXISTS sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_order_lines_sag ON tmodule_order_lines(sag_id);
|
||||||
8
migrations/089_sag_work_type.sql
Normal file
8
migrations/089_sag_work_type.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- Migration: Add work_type to tmodule_times
|
||||||
|
-- Date: 2026-02-02
|
||||||
|
-- Description: Adds work_type to align with tticket_worklog
|
||||||
|
|
||||||
|
ALTER TABLE tmodule_times
|
||||||
|
ADD COLUMN IF NOT EXISTS work_type VARCHAR(50) DEFAULT 'support';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tmodule_times_work_type ON tmodule_times(work_type);
|
||||||
18
migrations/090_auth_2fa.sql
Normal file
18
migrations/090_auth_2fa.sql
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
-- Migration 090: Add 2FA support to users table
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'users' AND column_name = 'totp_secret'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE users ADD COLUMN totp_secret VARCHAR(255);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'users' AND column_name = 'is_2fa_enabled'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE users ADD COLUMN is_2fa_enabled BOOLEAN DEFAULT FALSE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@ -8,6 +8,7 @@ python-multipart==0.0.17
|
|||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
aiohttp==3.10.10
|
aiohttp==3.10.10
|
||||||
|
cryptography==42.0.8
|
||||||
msal==1.31.1
|
msal==1.31.1
|
||||||
paramiko==3.4.1
|
paramiko==3.4.1
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
@ -15,3 +16,5 @@ pandas==2.2.3
|
|||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
extract-msg==0.55.0
|
extract-msg==0.55.0
|
||||||
pdfplumber==0.11.4
|
pdfplumber==0.11.4
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
pyotp==2.9.0
|
||||||
|
|||||||
@ -396,16 +396,22 @@ window.removeEntityTag = async function(entityType, entityId, tagId, containerId
|
|||||||
if (!confirm('Fjern dette tag?')) return;
|
if (!confirm('Fjern dette tag?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/tags/entity?entity_type=${entityType}&entity_id=${entityId}&tag_id=${tagId}`, {
|
console.log(`🏷️ Removing tag ${tagId} from ${entityType} #${entityId}`);
|
||||||
|
// Use path-based endpoint for better compatibility
|
||||||
|
const response = await fetch(`/api/v1/tags/entity/${entityType}/${entityId}/${tagId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to remove tag');
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Failed to remove tag: ${response.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🏷️ Tag removed successfully');
|
||||||
// Refresh tags display
|
// Refresh tags display
|
||||||
await window.renderEntityTags(entityType, entityId, containerId);
|
await window.renderEntityTags(entityType, entityId, containerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error removing tag:', error);
|
console.error('Error removing tag:', error);
|
||||||
alert('Fejl ved fjernelse af tag');
|
alert('Fejl ved fjernelse af tag: ' + error.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user