Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70a01db422 | ||
|
|
9dfa7ca936 | ||
|
|
96f4a36724 | ||
|
|
1e84ba267c | ||
|
|
f4bc2828e8 | ||
|
|
fd8f4d6d88 | ||
|
|
5f2452f222 | ||
|
|
592ed8640d | ||
|
|
c019a0367b | ||
|
|
071d926781 | ||
|
|
94f6735ed5 | ||
|
|
ef8e68fc16 | ||
|
|
468814ca8d | ||
|
|
08f40977f9 | ||
|
|
e162ee3fe1 | ||
|
|
e0c4e138d6 | ||
|
|
1b6b37e96e | ||
|
|
c5478b7e29 | ||
|
|
6a68aecafa | ||
|
|
8e5b3cf3d2 |
@ -258,6 +258,32 @@ crontab -e
|
|||||||
|
|
||||||
## 🔄 Opdatering til Ny Version
|
## 🔄 Opdatering til Ny Version
|
||||||
|
|
||||||
|
### Valg Af Update-metode
|
||||||
|
|
||||||
|
| Situation | Brug metode | Hvorfor |
|
||||||
|
|---|---|---|
|
||||||
|
| Små kodeændringer i `app/*` eller `main.py` | `./update_fast.sh --ref <ref>` | Hurtig update uden ny release-tag/pakke |
|
||||||
|
| Ændringer i `migrations/*` | `./updateto.sh <version>` | Kræver kontrolleret release + migrations-flow |
|
||||||
|
| Ændringer i `requirements.txt` eller `Dockerfile` | `./updateto.sh <version>` | Kræver fuld image-build og versionsstyring |
|
||||||
|
| Ændringer i `docker-compose*.yml`, scripts eller `.env` | `./updateto.sh <version>` | Drift/infra-konfiguration skal deployes fuldt |
|
||||||
|
| Når du er i tvivl | `./updateto.sh <version>` | Sikreste og mest forudsigelige metode |
|
||||||
|
|
||||||
|
Hurtig start for fast mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tjek først scope
|
||||||
|
./update_fast.sh --ref main --dry-run --allow-prod
|
||||||
|
|
||||||
|
# Kør update
|
||||||
|
./update_fast.sh --ref <commit-eller-tag> --allow-prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback i fast mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./update_fast.sh --rollback <backup-id> --allow-prod
|
||||||
|
```
|
||||||
|
|
||||||
### På din Mac:
|
### På din Mac:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -22,6 +22,34 @@ cd /srv/podman/bmc_hub_v1.0
|
|||||||
./updateto.sh v1.3.16
|
./updateto.sh v1.3.16
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Fast small update (kode-only, uden ny release tag)
|
||||||
|
|
||||||
|
Brug denne metode til meget små ændringer i `app/*` eller `main.py`, hvor du ikke vil lave en fuld release-pakke.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh bmcadmin@172.16.31.183
|
||||||
|
cd /srv/podman/bmc_hub_v1.0
|
||||||
|
|
||||||
|
# Download/refresh fast update script
|
||||||
|
curl -O https://g.bmcnetworks.dk/ct/bmc_hub/raw/branch/main/update_fast.sh
|
||||||
|
chmod +x update_fast.sh
|
||||||
|
|
||||||
|
# Tjek først hvad der ændres (anbefalet)
|
||||||
|
./update_fast.sh --ref main --dry-run --allow-prod
|
||||||
|
|
||||||
|
# Kør fast update (eksempel: specifik commit)
|
||||||
|
./update_fast.sh --ref 08f4097 --allow-prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Vigtigt:
|
||||||
|
- `update_fast.sh` er kun til kode/templates/static ændringer i fast scope.
|
||||||
|
- Hvis der er ændringer i migrationer, dependencies, docker/compose eller env: brug `./updateto.sh`.
|
||||||
|
- Rollback kan køres med backup-id:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./update_fast.sh --rollback 20260517-142155 --allow-prod
|
||||||
|
```
|
||||||
|
|
||||||
## Manuel deployment (hvis scriptet ikke virker)
|
## Manuel deployment (hvis scriptet ikke virker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
17
RELEASE_NOTES_v2.3.5.md
Normal file
17
RELEASE_NOTES_v2.3.5.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Release Notes - BMC Hub v2.3.5
|
||||||
|
|
||||||
|
**Release Date:** 16. maj 2026
|
||||||
|
**Release Tag:** `v2.3.5`
|
||||||
|
|
||||||
|
## Hotfix
|
||||||
|
|
||||||
|
- Restored `app/modules/telefoni/templates/log.html` from known good state (`v2.3.2`) after template corruption in `v2.3.4`.
|
||||||
|
- Fixes production issue where `/telefoni` rendered blank.
|
||||||
|
|
||||||
|
## Affected File
|
||||||
|
|
||||||
|
- `app/modules/telefoni/templates/log.html`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This is a corrective patch release intended to be deployed immediately.
|
||||||
6
RELEASE_NOTES_v2.3.6.md
Normal file
6
RELEASE_NOTES_v2.3.6.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Release Notes v2.3.6 — 16. maj 2026
|
||||||
|
|
||||||
|
## Telefoni Callback Fix
|
||||||
|
- telefoni callbacks now use both env and DB whitelist
|
||||||
|
- added internal 172.16.0.0/12 fallback acceptance
|
||||||
|
- added migration `migrations/186_telefoni_ip_whitelist_setting.sql`
|
||||||
10
RELEASE_NOTES_v2.3.7.md
Normal file
10
RELEASE_NOTES_v2.3.7.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Release Notes: v2.3.7
|
||||||
|
**Date:** 16. maj 2026
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- fallback to mission_call_state when telefoni_opkald is empty
|
||||||
|
- legacy rows shown read-only in telefoni UI
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- app/modules/telefoni/backend/router.py
|
||||||
|
- app/modules/telefoni/templates/log.html
|
||||||
9
RELEASE_NOTES_v2.3.8.md
Normal file
9
RELEASE_NOTES_v2.3.8.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Release Notes: v2.3.8
|
||||||
|
**Date:** 16. maj 2026
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- Added secure read-only SQL Console page under /settings/sql
|
||||||
|
- Added superadmin-protected execute endpoint /settings/sql/execute (SELECT/WITH only)
|
||||||
|
- Added SQL Console nav link in settings
|
||||||
|
- Added preset query buttons for telefoni/mission diagnostics
|
||||||
|
- files: app/settings/backend/views.py, app/settings/frontend/sql_console.html, app/settings/frontend/settings.html
|
||||||
@ -9,7 +9,7 @@ from typing import List, Dict, Optional
|
|||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single, table_has_column
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.economic_service import get_economic_service
|
from app.services.economic_service import get_economic_service
|
||||||
from app.services.ollama_service import ollama_service
|
from app.services.ollama_service import ollama_service
|
||||||
@ -710,8 +710,29 @@ async def list_supplier_invoices(
|
|||||||
params.append(vendor_id)
|
params.append(vendor_id)
|
||||||
|
|
||||||
if sag_id:
|
if sag_id:
|
||||||
query += " AND si.sag_id = %s"
|
if table_has_column("supplier_invoices", "sag_id"):
|
||||||
params.append(sag_id)
|
query += " AND si.sag_id = %s"
|
||||||
|
params.append(sag_id)
|
||||||
|
elif (
|
||||||
|
table_has_column("supplier_invoice_relations", "supplier_invoice_id")
|
||||||
|
and table_has_column("supplier_invoice_relations", "relation_type")
|
||||||
|
and table_has_column("supplier_invoice_relations", "relation_id")
|
||||||
|
):
|
||||||
|
query += """
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM supplier_invoice_relations sir
|
||||||
|
WHERE sir.supplier_invoice_id = si.id
|
||||||
|
AND sir.relation_type = 'sag'
|
||||||
|
AND sir.relation_id = %s
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params.append(sag_id)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ supplier invoice sag filter requested, but no schema link available (sag_id column/relation table missing)"
|
||||||
|
)
|
||||||
|
query += " AND 1 = 0"
|
||||||
|
|
||||||
if overdue_only:
|
if overdue_only:
|
||||||
query += " AND si.due_date < CURRENT_DATE AND si.paid_date IS NULL"
|
query += " AND si.due_date < CURRENT_DATE AND si.paid_date IS NULL"
|
||||||
|
|||||||
@ -88,8 +88,26 @@ async def get_contacts(
|
|||||||
params = []
|
params = []
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)")
|
where_clauses.append(
|
||||||
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
"""
|
||||||
|
(
|
||||||
|
c.first_name ILIKE %s
|
||||||
|
OR c.last_name ILIKE %s
|
||||||
|
OR c.email ILIKE %s
|
||||||
|
OR c.phone ILIKE %s
|
||||||
|
OR c.mobile ILIKE %s
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM contact_companies cc2
|
||||||
|
JOIN customers cu2 ON cu2.id = cc2.customer_id
|
||||||
|
WHERE cc2.contact_id = c.id
|
||||||
|
AND cu2.name ILIKE %s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
like = f"%{search}%"
|
||||||
|
params.extend([like, like, like, like, like, like])
|
||||||
|
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
where_clauses.append("c.is_active = %s")
|
where_clauses.append("c.is_active = %s")
|
||||||
|
|||||||
@ -113,8 +113,27 @@ async def get_contacts(
|
|||||||
params = []
|
params = []
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)")
|
where_clauses.append(
|
||||||
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
"""
|
||||||
|
(
|
||||||
|
c.first_name ILIKE %s
|
||||||
|
OR c.last_name ILIKE %s
|
||||||
|
OR c.email ILIKE %s
|
||||||
|
OR c.phone ILIKE %s
|
||||||
|
OR c.mobile ILIKE %s
|
||||||
|
OR c.user_company ILIKE %s
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM contact_companies cc2
|
||||||
|
JOIN customers cu2 ON cu2.id = cc2.customer_id
|
||||||
|
WHERE cc2.contact_id = c.id
|
||||||
|
AND cu2.name ILIKE %s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
like = f"%{search}%"
|
||||||
|
params.extend([like, like, like, like, like, like, like])
|
||||||
|
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
where_clauses.append("c.is_active = %s")
|
where_clauses.append("c.is_active = %s")
|
||||||
|
|||||||
@ -953,7 +953,7 @@ async function loadContacts() {
|
|||||||
totalContacts = data.total;
|
totalContacts = data.total;
|
||||||
currentContactsData = Array.isArray(data.contacts) ? data.contacts : [];
|
currentContactsData = Array.isArray(data.contacts) ? data.contacts : [];
|
||||||
displayContacts(currentContactsData);
|
displayContacts(currentContactsData);
|
||||||
updatePagination(data.total);
|
updatePagination(data.total, currentContactsData.length);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
@ -1182,11 +1182,12 @@ function persistTablePreferences() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePagination(total) {
|
function updatePagination(total, rowsOnPage = 0) {
|
||||||
const start = currentPage * pageSize + 1;
|
const safeRowsOnPage = Number.isFinite(Number(rowsOnPage)) ? Math.max(0, Number(rowsOnPage)) : 0;
|
||||||
const end = Math.min((currentPage + 1) * pageSize, total);
|
const start = total > 0 ? (currentPage * pageSize + 1) : 0;
|
||||||
|
const end = total > 0 ? Math.min(currentPage * pageSize + safeRowsOnPage, total) : 0;
|
||||||
|
|
||||||
document.getElementById('showingStart').textContent = total > 0 ? start : 0;
|
document.getElementById('showingStart').textContent = start;
|
||||||
document.getElementById('showingEnd').textContent = end;
|
document.getElementById('showingEnd').textContent = end;
|
||||||
document.getElementById('totalCount').textContent = total;
|
document.getElementById('totalCount').textContent = total;
|
||||||
|
|
||||||
|
|||||||
@ -595,6 +595,37 @@ class MissionService:
|
|||||||
(project_id,),
|
(project_id,),
|
||||||
) or []
|
) or []
|
||||||
|
|
||||||
|
project_open_todo_count = 0
|
||||||
|
project_open_todo_titles: list[str] = []
|
||||||
|
if MissionService._table_exists("sag_todo_steps"):
|
||||||
|
project_todo_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE t.deleted_at IS NULL
|
||||||
|
AND COALESCE(t.is_done, FALSE) = FALSE
|
||||||
|
) AS open_todo_count,
|
||||||
|
ARRAY_REMOVE(
|
||||||
|
ARRAY_AGG(
|
||||||
|
CASE
|
||||||
|
WHEN t.deleted_at IS NULL
|
||||||
|
AND COALESCE(t.is_done, FALSE) = FALSE
|
||||||
|
THEN t.title
|
||||||
|
END
|
||||||
|
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
|
||||||
|
),
|
||||||
|
NULL
|
||||||
|
) AS open_todo_titles
|
||||||
|
FROM sag_todo_steps t
|
||||||
|
WHERE t.sag_id = %s
|
||||||
|
""",
|
||||||
|
(project_id,),
|
||||||
|
) or {}
|
||||||
|
project_open_todo_count = int(project_todo_row.get("open_todo_count") or 0)
|
||||||
|
titles_raw = project_todo_row.get("open_todo_titles") or []
|
||||||
|
if isinstance(titles_raw, list):
|
||||||
|
project_open_todo_titles = [str(item).strip() for item in titles_raw if str(item or "").strip()]
|
||||||
|
|
||||||
# Fallback for case-backed projects: fetch directly related/under cases from relation table.
|
# Fallback for case-backed projects: fetch directly related/under cases from relation table.
|
||||||
# This is used when a project is a case of type project/projekt and tasks are linked as case relations.
|
# This is used when a project is a case of type project/projekt and tasks are linked as case relations.
|
||||||
if not tasks and MissionService._table_exists("sag_relationer"):
|
if not tasks and MissionService._table_exists("sag_relationer"):
|
||||||
@ -667,6 +698,8 @@ class MissionService:
|
|||||||
"milestones": [dict(row) for row in milestones],
|
"milestones": [dict(row) for row in milestones],
|
||||||
"blockers": [dict(row) for row in blockers],
|
"blockers": [dict(row) for row in blockers],
|
||||||
"tasks": [dict(row) for row in tasks],
|
"tasks": [dict(row) for row in tasks],
|
||||||
|
"project_open_todo_count": project_open_todo_count,
|
||||||
|
"project_open_todo_titles": project_open_todo_titles,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -805,6 +805,16 @@
|
|||||||
padding: 0.45rem 0.8rem;
|
padding: 0.45rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mc-day-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-touch-btn {
|
||||||
|
min-height: 56px;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.55rem 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mc-case-link,
|
.mc-case-link,
|
||||||
.mc-email-link {
|
.mc-email-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@ -829,6 +839,46 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mc-day-actions {
|
||||||
|
margin-top: 0.52rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-touch-btn {
|
||||||
|
border: 1px solid rgba(126, 194, 239, 0.45);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(15, 76, 117, 0.45);
|
||||||
|
color: #dff3ff;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.42rem 0.58rem;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-touch-btn.secondary {
|
||||||
|
border-color: rgba(157, 181, 210, 0.35);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #e3f1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-touch-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-modal-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-modal-meta {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--mc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.mc-day-agents {
|
.mc-day-agents {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@ -1125,6 +1175,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="dayCaseQuickModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="dayCaseQuickTitle">Sag detaljer</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body mc-day-modal-body">
|
||||||
|
<div class="mc-day-modal-meta" id="dayCaseQuickMeta">Vaelg en sag fra listen.</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="dayCaseQuickUser" class="form-label">Ansvarlig medarbejder</label>
|
||||||
|
<select id="dayCaseQuickUser" class="form-select"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="dayCaseQuickGroup" class="form-label">Gruppe</label>
|
||||||
|
<select id="dayCaseQuickGroup" class="form-select"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="dayCaseQuickStart" class="form-label">Start</label>
|
||||||
|
<input id="dayCaseQuickStart" type="datetime-local" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="dayCaseQuickDeadline" class="form-label">Deadline</label>
|
||||||
|
<input id="dayCaseQuickDeadline" type="datetime-local" class="form-control">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="dayCaseQuickDesc" class="form-label">Beskrivelse</label>
|
||||||
|
<textarea id="dayCaseQuickDesc" class="form-control" rows="4" readonly></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="dayCaseQuickSaveBtn">Gem aendringer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="view-calls" class="mc-view">
|
<div id="view-calls" class="mc-view">
|
||||||
<div class="mc-view-grid">
|
<div class="mc-view-grid">
|
||||||
<div class="mc-card">
|
<div class="mc-card">
|
||||||
@ -1234,6 +1328,8 @@
|
|||||||
renderedCameraUrl: null,
|
renderedCameraUrl: null,
|
||||||
renderedCameraMode: null,
|
renderedCameraMode: null,
|
||||||
selectedProjectId: null,
|
selectedProjectId: null,
|
||||||
|
selectedDayCaseId: null,
|
||||||
|
dayCaseModal: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
@ -1328,6 +1424,10 @@
|
|||||||
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : [];
|
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : [];
|
||||||
const milestones = Array.isArray(detail?.milestones) ? detail.milestones : [];
|
const milestones = Array.isArray(detail?.milestones) ? detail.milestones : [];
|
||||||
const blockers = Array.isArray(detail?.blockers) ? detail.blockers : [];
|
const blockers = Array.isArray(detail?.blockers) ? detail.blockers : [];
|
||||||
|
const projectOpenTodoCount = Number(detail?.project_open_todo_count || 0);
|
||||||
|
const projectOpenTodoTitles = Array.isArray(detail?.project_open_todo_titles)
|
||||||
|
? detail.project_open_todo_titles.map((item) => String(item || '').trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
const grouped = { todo: [], doing: [], done: [] };
|
const grouped = { todo: [], doing: [], done: [] };
|
||||||
tasks.forEach((task) => {
|
tasks.forEach((task) => {
|
||||||
@ -1339,6 +1439,7 @@
|
|||||||
|
|
||||||
kpis.innerHTML = [
|
kpis.innerHTML = [
|
||||||
{ label: 'Opgaver', value: tasks.length },
|
{ label: 'Opgaver', value: tasks.length },
|
||||||
|
{ label: 'Projekt todo', value: projectOpenTodoCount },
|
||||||
{ label: 'Milepæle', value: milestones.length },
|
{ label: 'Milepæle', value: milestones.length },
|
||||||
{ label: 'Blockers', value: blockers.length },
|
{ label: 'Blockers', value: blockers.length },
|
||||||
{ label: 'Deadline', value: formatShortDate(detail?.ended_at || detail?.deadline) },
|
{ label: 'Deadline', value: formatShortDate(detail?.ended_at || detail?.deadline) },
|
||||||
@ -1355,11 +1456,29 @@
|
|||||||
{ key: 'done', label: 'Lukket' },
|
{ key: 'done', label: 'Lukket' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const projectTodoPreview = projectOpenTodoTitles.slice(0, 5)
|
||||||
|
.map((title) => `<div>• ${escapeHtml(title)}</div>`)
|
||||||
|
.join('');
|
||||||
|
const projectTodoMore = Math.max(projectOpenTodoCount - Math.min(projectOpenTodoTitles.length, 5), 0);
|
||||||
|
const projectTodoCard = projectOpenTodoCount > 0
|
||||||
|
? `
|
||||||
|
<div class="mc-kanban-card" style="border-left:3px solid #0f4c75;">
|
||||||
|
<div class="mc-kanban-title">Projekt todo (${projectOpenTodoCount})</div>
|
||||||
|
<div class="mc-kanban-meta">
|
||||||
|
${projectTodoPreview || '<div>-</div>'}
|
||||||
|
${projectTodoMore > 0 ? `<div>+${projectTodoMore} flere</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
board.innerHTML = laneMeta.map((lane) => {
|
board.innerHTML = laneMeta.map((lane) => {
|
||||||
const laneTasks = grouped[lane.key] || [];
|
const laneTasks = grouped[lane.key] || [];
|
||||||
|
const extraCard = lane.key === 'todo' ? projectTodoCard : '';
|
||||||
return `
|
return `
|
||||||
<div class="mc-kanban-col">
|
<div class="mc-kanban-col">
|
||||||
<h6>${escapeHtml(lane.label)} (${laneTasks.length})</h6>
|
<h6>${escapeHtml(lane.label)} (${laneTasks.length + (lane.key === 'todo' && projectOpenTodoCount > 0 ? 1 : 0)})</h6>
|
||||||
|
${extraCard}
|
||||||
${laneTasks.length ? laneTasks.map((task) => `
|
${laneTasks.length ? laneTasks.map((task) => `
|
||||||
<div class="mc-kanban-card">
|
<div class="mc-kanban-card">
|
||||||
<div class="mc-kanban-title">#${Number(task.id || 0)} ${escapeHtml(task.titel || task.title || 'Uden titel')}</div>
|
<div class="mc-kanban-title">#${Number(task.id || 0)} ${escapeHtml(task.titel || task.title || 'Uden titel')}</div>
|
||||||
@ -1408,6 +1527,14 @@
|
|||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toDatetimeLocalValue(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
function truncateText(value, maxLength = 180) {
|
function truncateText(value, maxLength = 180) {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw) return '';
|
if (!raw) return '';
|
||||||
@ -1415,6 +1542,23 @@
|
|||||||
return `${raw.slice(0, maxLength - 1)}...`;
|
return `${raw.slice(0, maxLength - 1)}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDayCaseById(caseId) {
|
||||||
|
const id = Number(caseId || 0);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) return null;
|
||||||
|
|
||||||
|
const source = Array.isArray(state.dayUnassignedCases) ? state.dayUnassignedCases : [];
|
||||||
|
const found = source.find((item) => Number(item.id || 0) === id);
|
||||||
|
return found || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateDayCaseModal() {
|
||||||
|
if (state.dayCaseModal) return state.dayCaseModal;
|
||||||
|
const el = document.getElementById('dayCaseQuickModal');
|
||||||
|
if (!el || !window.bootstrap) return null;
|
||||||
|
state.dayCaseModal = new bootstrap.Modal(el);
|
||||||
|
return state.dayCaseModal;
|
||||||
|
}
|
||||||
|
|
||||||
function getEmailHref(emailId) {
|
function getEmailHref(emailId) {
|
||||||
const id = Number(emailId || 0);
|
const id = Number(emailId || 0);
|
||||||
if (!Number.isFinite(id) || id <= 0) return '/emails';
|
if (!Number.isFinite(id) || id <= 0) return '/emails';
|
||||||
@ -2013,7 +2157,12 @@
|
|||||||
<select class="mc-day-select" id="assignGroup-${caseId}">
|
<select class="mc-day-select" id="assignGroup-${caseId}">
|
||||||
${groupSelectOptions}
|
${groupSelectOptions}
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="mc-day-btn" onclick="quickAssignCase(${caseId})">Tildel</button>
|
<button type="button" class="mc-day-btn" onclick="quickAssignCase(${caseId})">Tildel begge</button>
|
||||||
|
</div>
|
||||||
|
<div class="mc-day-actions">
|
||||||
|
<button type="button" class="mc-day-touch-btn" onclick="quickAssignUser(${caseId})">Tildel medarbejder</button>
|
||||||
|
<button type="button" class="mc-day-touch-btn" onclick="quickAssignGroup(${caseId})">Tildel gruppe</button>
|
||||||
|
<button type="button" class="mc-day-touch-btn secondary" onclick="openDayCaseQuick(${caseId})">Mere info</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -2122,7 +2271,154 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function patchDayCase(caseId, payload, button, buttonText = 'Gemmer...') {
|
||||||
|
if (!payload || typeof payload !== 'object') return;
|
||||||
|
|
||||||
|
const btn = button || null;
|
||||||
|
const originalText = btn ? btn.textContent : null;
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = buttonText;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/sag/${caseId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err?.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadInitialState();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Kunne ikke opdatere sag: ${error?.message || 'ukendt fejl'}`);
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalText || 'Gem';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickAssignUser(caseId) {
|
||||||
|
const id = Number(caseId || 0);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) return;
|
||||||
|
|
||||||
|
const userSelect = document.getElementById(`assignUser-${id}`);
|
||||||
|
const userId = toOptionalInt(userSelect?.value);
|
||||||
|
if (userId === null) {
|
||||||
|
alert('Vaelg en medarbejder foerst.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = document.getElementById(`dayCase-${id}`);
|
||||||
|
const button = card?.querySelector('.mc-day-actions .mc-day-touch-btn');
|
||||||
|
await patchDayCase(id, { ansvarlig_bruger_id: userId }, button, 'Tildeler...');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickAssignGroup(caseId) {
|
||||||
|
const id = Number(caseId || 0);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) return;
|
||||||
|
|
||||||
|
const groupSelect = document.getElementById(`assignGroup-${id}`);
|
||||||
|
const groupId = toOptionalInt(groupSelect?.value);
|
||||||
|
if (groupId === null) {
|
||||||
|
alert('Vaelg en gruppe foerst.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = document.getElementById(`dayCase-${id}`);
|
||||||
|
const buttons = card?.querySelectorAll('.mc-day-actions .mc-day-touch-btn');
|
||||||
|
const button = buttons && buttons.length > 1 ? buttons[1] : null;
|
||||||
|
await patchDayCase(id, { assigned_group_id: groupId }, button, 'Tildeler...');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDayCaseQuick(caseId) {
|
||||||
|
const id = Number(caseId || 0);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) return;
|
||||||
|
|
||||||
|
const item = getDayCaseById(id);
|
||||||
|
if (!item) {
|
||||||
|
alert('Kunne ikke finde sag i Dagen-listen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = document.getElementById('dayCaseQuickTitle');
|
||||||
|
const meta = document.getElementById('dayCaseQuickMeta');
|
||||||
|
const userSelect = document.getElementById('dayCaseQuickUser');
|
||||||
|
const groupSelect = document.getElementById('dayCaseQuickGroup');
|
||||||
|
const startInput = document.getElementById('dayCaseQuickStart');
|
||||||
|
const deadlineInput = document.getElementById('dayCaseQuickDeadline');
|
||||||
|
const descInput = document.getElementById('dayCaseQuickDesc');
|
||||||
|
|
||||||
|
if (!title || !meta || !userSelect || !groupSelect || !startInput || !deadlineInput || !descInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selectedDayCaseId = id;
|
||||||
|
title.textContent = `#${id} ${item.titel || 'Uden titel'}`;
|
||||||
|
meta.textContent = `${item.customer_name || 'Ukendt kunde'} • Status: ${item.status || '-'} • Type: ${item.case_type || item.template_key || '-'}`;
|
||||||
|
descInput.value = String(item.beskrivelse || '').trim();
|
||||||
|
|
||||||
|
const userOptions = [
|
||||||
|
'<option value="">Ingen medarbejder</option>',
|
||||||
|
...(state.assignmentUsers || []).map((user) => (`
|
||||||
|
<option value="${Number(user.user_id)}">${escapeHtml(user.display_name || 'Ukendt')}</option>
|
||||||
|
`)),
|
||||||
|
];
|
||||||
|
const groupOptions = [
|
||||||
|
'<option value="">Ingen gruppe</option>',
|
||||||
|
...(state.assignmentGroups || []).map((group) => (`
|
||||||
|
<option value="${Number(group.id)}">${escapeHtml(group.name || 'Ukendt')}</option>
|
||||||
|
`)),
|
||||||
|
];
|
||||||
|
|
||||||
|
userSelect.innerHTML = userOptions.join('');
|
||||||
|
groupSelect.innerHTML = groupOptions.join('');
|
||||||
|
userSelect.value = Number(item.ansvarlig_bruger_id || 0) > 0 ? String(Number(item.ansvarlig_bruger_id)) : '';
|
||||||
|
groupSelect.value = Number(item.assigned_group_id || 0) > 0 ? String(Number(item.assigned_group_id)) : '';
|
||||||
|
|
||||||
|
startInput.value = toDatetimeLocalValue(item.start_date);
|
||||||
|
deadlineInput.value = toDatetimeLocalValue(item.deadline);
|
||||||
|
|
||||||
|
const modal = getOrCreateDayCaseModal();
|
||||||
|
if (modal) modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDayCaseQuick() {
|
||||||
|
const id = Number(state.selectedDayCaseId || 0);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) return;
|
||||||
|
|
||||||
|
const userSelect = document.getElementById('dayCaseQuickUser');
|
||||||
|
const groupSelect = document.getElementById('dayCaseQuickGroup');
|
||||||
|
const startInput = document.getElementById('dayCaseQuickStart');
|
||||||
|
const deadlineInput = document.getElementById('dayCaseQuickDeadline');
|
||||||
|
const saveBtn = document.getElementById('dayCaseQuickSaveBtn');
|
||||||
|
|
||||||
|
if (!userSelect || !groupSelect || !startInput || !deadlineInput) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
ansvarlig_bruger_id: toOptionalInt(userSelect.value),
|
||||||
|
assigned_group_id: toOptionalInt(groupSelect.value),
|
||||||
|
start_date: startInput.value ? new Date(startInput.value).toISOString() : null,
|
||||||
|
deadline: deadlineInput.value ? new Date(deadlineInput.value).toISOString() : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await patchDayCase(id, payload, saveBtn, 'Gemmer...');
|
||||||
|
|
||||||
|
const modal = getOrCreateDayCaseModal();
|
||||||
|
if (modal) modal.hide();
|
||||||
|
}
|
||||||
|
|
||||||
window.quickAssignCase = quickAssignCase;
|
window.quickAssignCase = quickAssignCase;
|
||||||
|
window.quickAssignUser = quickAssignUser;
|
||||||
|
window.quickAssignGroup = quickAssignGroup;
|
||||||
|
window.openDayCaseQuick = openDayCaseQuick;
|
||||||
|
|
||||||
function renderEnvironmentReadings() {
|
function renderEnvironmentReadings() {
|
||||||
const container = document.getElementById('environmentReadings');
|
const container = document.getElementById('environmentReadings');
|
||||||
@ -2427,6 +2723,11 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('dayCaseQuickSaveBtn')?.addEventListener('click', () => {
|
||||||
|
saveDayCaseQuick();
|
||||||
|
resetIdleTimer();
|
||||||
|
});
|
||||||
|
|
||||||
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
|
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
|
||||||
document.addEventListener(name, resetIdleTimer, { passive: true });
|
document.addEventListener(name, resetIdleTimer, { passive: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response
|
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from app.core.database import execute_query, execute_query_single, table_has_column
|
from app.core.database import execute_query, execute_query_single, table_has_column
|
||||||
@ -1273,7 +1273,7 @@ async def delete_todo_step(step_id: int):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to delete todo step")
|
raise HTTPException(status_code=500, detail="Failed to delete todo step")
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id:int}")
|
@router.patch("/sag/{sag_id:int}")
|
||||||
async def update_sag(sag_id: int, updates: dict):
|
async def update_sag(sag_id: int, updates: dict = Body(...)):
|
||||||
"""Update a case."""
|
"""Update a case."""
|
||||||
try:
|
try:
|
||||||
# Check if case exists
|
# Check if case exists
|
||||||
@ -2892,7 +2892,7 @@ async def get_sale_item(sag_id: int, item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id}/sale-items/{item_id}")
|
@router.patch("/sag/{sag_id}/sale-items/{item_id}")
|
||||||
async def update_sale_item(sag_id: int, item_id: int, updates: dict):
|
async def update_sale_item(sag_id: int, item_id: int, updates: dict = Body(...)):
|
||||||
"""Update a sale item for a case."""
|
"""Update a sale item for a case."""
|
||||||
try:
|
try:
|
||||||
check = execute_query(
|
check = execute_query(
|
||||||
|
|||||||
@ -3461,7 +3461,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
|
||||||
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;" onchange="saveCaseStatusFromTopbar()">
|
||||||
{% for st in status_options %}
|
{% for st in status_options %}
|
||||||
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
|
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -3708,11 +3708,6 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
bindChange('topbarStatusSelect', async (el) => {
|
|
||||||
await patchCase({ status: el.value || 'åben' });
|
|
||||||
location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
bindChange('topbarTypeSelect', async (el) => {
|
bindChange('topbarTypeSelect', async (el) => {
|
||||||
await patchCase({ type: String(el.value || 'ticket').toLowerCase() });
|
await patchCase({ type: String(el.value || 'ticket').toLowerCase() });
|
||||||
location.reload();
|
location.reload();
|
||||||
|
|||||||
@ -23,6 +23,7 @@ from .utils import (
|
|||||||
digits_only,
|
digits_only,
|
||||||
extract_extension,
|
extract_extension,
|
||||||
ip_in_whitelist,
|
ip_in_whitelist,
|
||||||
|
normalize_external_number,
|
||||||
is_outbound_call,
|
is_outbound_call,
|
||||||
normalize_e164,
|
normalize_e164,
|
||||||
phone_suffix_8,
|
phone_suffix_8,
|
||||||
@ -122,11 +123,23 @@ def _is_internal_bmc_ip(client_ip: str) -> bool:
|
|||||||
return ip_obj in ipaddress.ip_network("172.16.31.0/24")
|
return ip_obj in ipaddress.ip_network("172.16.31.0/24")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal_bmc_supernet_ip(client_ip: str) -> bool:
|
||||||
|
if not client_ip:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ip_obj = ipaddress.ip_address(client_ip)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return ip_obj in ipaddress.ip_network("172.16.0.0/12")
|
||||||
|
|
||||||
|
|
||||||
def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
|
def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
|
||||||
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
|
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
|
||||||
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
|
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
|
||||||
accepted_tokens = {s for s in (env_secret, db_secret) if s}
|
accepted_tokens = {s for s in (env_secret, db_secret) if s}
|
||||||
whitelist = (getattr(settings, "TELEFONI_IP_WHITELIST", "") or "").strip()
|
env_whitelist = (getattr(settings, "TELEFONI_IP_WHITELIST", "") or "").strip()
|
||||||
|
db_whitelist = (_get_setting_value("telefoni_ip_whitelist", "") or "").strip()
|
||||||
|
whitelist = ",".join([part for part in (env_whitelist, db_whitelist) if part])
|
||||||
client_ip = _get_client_ip(request)
|
client_ip = _get_client_ip(request)
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
@ -174,6 +187,15 @@ def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
|
|||||||
else:
|
else:
|
||||||
logger.info("ℹ️ Telefoni callback whitelist not configured path=%s ip=%s", path, client_ip)
|
logger.info("ℹ️ Telefoni callback whitelist not configured path=%s ip=%s", path, client_ip)
|
||||||
|
|
||||||
|
# Safety fallback: allow callbacks from internal BMC network range.
|
||||||
|
if _is_internal_bmc_supernet_ip(client_ip):
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Telefoni callback accepted via internal supernet fallback path=%s ip=%s",
|
||||||
|
path,
|
||||||
|
client_ip,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
logger.warning("❌ Telefoni callback forbidden path=%s ip=%s", path, client_ip)
|
logger.warning("❌ Telefoni callback forbidden path=%s ip=%s", path, client_ip)
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
@ -259,8 +281,9 @@ async def yealink_established(
|
|||||||
if candidate:
|
if candidate:
|
||||||
ekstern_raw = candidate
|
ekstern_raw = candidate
|
||||||
break
|
break
|
||||||
ekstern_e164 = normalize_e164(ekstern_raw)
|
ekstern_normalized = normalize_external_number(ekstern_raw)
|
||||||
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
|
ekstern_e164 = normalize_e164(ekstern_normalized or ekstern_raw)
|
||||||
|
ekstern_value = ekstern_normalized or ((ekstern_raw or "").strip() or None)
|
||||||
|
|
||||||
user_ids = TelefoniService.find_user_by_extension(local_extension)
|
user_ids = TelefoniService.find_user_by_extension(local_extension)
|
||||||
|
|
||||||
@ -675,9 +698,7 @@ async def list_calls(
|
|||||||
t.direction,
|
t.direction,
|
||||||
t.ekstern_nummer,
|
t.ekstern_nummer,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NULLIF(TRIM(t.ekstern_nummer), ''),
|
NULLIF(TRIM(t.ekstern_nummer), '')
|
||||||
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
|
|
||||||
NULLIF(TRIM(t.raw_payload->>'callee'), '')
|
|
||||||
) AS display_number,
|
) AS display_number,
|
||||||
t.intern_extension,
|
t.intern_extension,
|
||||||
t.kontakt_id,
|
t.kontakt_id,
|
||||||
@ -714,8 +735,74 @@ async def list_calls(
|
|||||||
"""
|
"""
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
rows = execute_query(query, tuple(params))
|
rows = execute_query(query, tuple(params)) or []
|
||||||
return rows or []
|
for row in rows:
|
||||||
|
display_raw = row.get("display_number") or row.get("ekstern_nummer")
|
||||||
|
repaired = normalize_external_number(display_raw)
|
||||||
|
if repaired:
|
||||||
|
row["display_number"] = repaired
|
||||||
|
if rows:
|
||||||
|
return rows
|
||||||
|
|
||||||
|
# Fallback: legacy mission call history (read-only rows) for environments
|
||||||
|
# where historical calls were stored before telefoni_opkald was populated.
|
||||||
|
if user_id is not None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
legacy_where = []
|
||||||
|
legacy_params = []
|
||||||
|
if date_from:
|
||||||
|
legacy_where.append("m.started_at >= %s")
|
||||||
|
legacy_params.append(date_from)
|
||||||
|
if date_to:
|
||||||
|
legacy_where.append("m.started_at <= %s")
|
||||||
|
legacy_params.append(date_to)
|
||||||
|
|
||||||
|
legacy_where_sql = ("WHERE " + " AND ".join(legacy_where)) if legacy_where else ""
|
||||||
|
|
||||||
|
legacy_query = f"""
|
||||||
|
SELECT
|
||||||
|
-ROW_NUMBER() OVER (ORDER BY m.started_at DESC, m.call_id) AS id,
|
||||||
|
m.call_id AS callid,
|
||||||
|
NULL::INTEGER AS bruger_id,
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(COALESCE(m.state, '')) IN ('outbound', 'udgaaende') THEN 'outbound'
|
||||||
|
ELSE 'inbound'
|
||||||
|
END AS direction,
|
||||||
|
m.caller_number AS ekstern_nummer,
|
||||||
|
m.caller_number AS display_number,
|
||||||
|
NULL::VARCHAR AS intern_extension,
|
||||||
|
NULL::INTEGER AS kontakt_id,
|
||||||
|
NULL::INTEGER AS sag_id,
|
||||||
|
m.started_at,
|
||||||
|
m.ended_at,
|
||||||
|
CASE
|
||||||
|
WHEN m.started_at IS NOT NULL AND m.ended_at IS NOT NULL
|
||||||
|
THEN GREATEST(EXTRACT(EPOCH FROM (m.ended_at - m.started_at))::int, 0)
|
||||||
|
ELSE NULL
|
||||||
|
END AS duration_sec,
|
||||||
|
m.updated_at AS created_at,
|
||||||
|
NULL::VARCHAR AS username,
|
||||||
|
NULL::VARCHAR AS full_name,
|
||||||
|
COALESCE(NULLIF(TRIM(m.contact_name), ''), NULL) AS contact_name,
|
||||||
|
COALESCE(NULLIF(TRIM(m.company_name), ''), NULL) AS contact_company,
|
||||||
|
NULL::VARCHAR AS sag_titel,
|
||||||
|
'legacy_mission'::VARCHAR AS source
|
||||||
|
FROM mission_call_state m
|
||||||
|
{legacy_where_sql}
|
||||||
|
ORDER BY m.started_at DESC, m.call_id
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
legacy_params.extend([limit, offset])
|
||||||
|
|
||||||
|
try:
|
||||||
|
legacy_rows = execute_query(legacy_query, tuple(legacy_params)) or []
|
||||||
|
except Exception:
|
||||||
|
legacy_rows = []
|
||||||
|
|
||||||
|
if without_case:
|
||||||
|
return [r for r in legacy_rows if not r.get("sag_id")]
|
||||||
|
return legacy_rows
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/telefoni/calls/{call_id}")
|
@router.patch("/telefoni/calls/{call_id}")
|
||||||
|
|||||||
@ -39,6 +39,39 @@ def normalize_e164(number: Optional[str]) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_external_number(number: Optional[str]) -> Optional[str]:
|
||||||
|
"""Normalize external numbers and recover malformed concatenated callback values.
|
||||||
|
|
||||||
|
E.164 allows max 15 digits after '+'. If payload contains longer values,
|
||||||
|
we treat it as malformed concatenation and recover from the right-most part.
|
||||||
|
"""
|
||||||
|
if not number:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw = number.strip().replace(" ", "").replace("-", "")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
d = digits_only(raw)
|
||||||
|
if not d:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(d) > 15:
|
||||||
|
# Malformed concatenation observed from callbacks.
|
||||||
|
# Prefer the leading Danish segment (user-confirmed cases like 53125928...),
|
||||||
|
# then fall back to right-most recovery.
|
||||||
|
if len(d) >= 10 and d[:2] == "45":
|
||||||
|
return "+" + d[:10]
|
||||||
|
if len(d) >= 8 and d[0] in "23456789":
|
||||||
|
return "+45" + d[:8]
|
||||||
|
if len(d) >= 10 and d[-10:].startswith("45"):
|
||||||
|
return "+" + d[-10:]
|
||||||
|
if len(d) >= 8:
|
||||||
|
return "+45" + d[-8:]
|
||||||
|
|
||||||
|
return normalize_e164(raw) or raw
|
||||||
|
|
||||||
|
|
||||||
def phone_suffix_8(number: Optional[str]) -> Optional[str]:
|
def phone_suffix_8(number: Optional[str]) -> Optional[str]:
|
||||||
d = digits_only(number)
|
d = digits_only(number)
|
||||||
if len(d) < 8:
|
if len(d) < 8:
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from fastapi import APIRouter, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
|
from app.modules.telefoni.backend.utils import normalize_external_number
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -20,9 +21,7 @@ async def telefoni_log_page(request: Request):
|
|||||||
t.id,
|
t.id,
|
||||||
t.direction,
|
t.direction,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NULLIF(TRIM(t.ekstern_nummer), ''),
|
NULLIF(TRIM(t.ekstern_nummer), '')
|
||||||
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
|
|
||||||
NULLIF(TRIM(t.raw_payload->>'callee'), '')
|
|
||||||
) AS display_number,
|
) AS display_number,
|
||||||
t.started_at,
|
t.started_at,
|
||||||
t.duration_sec,
|
t.duration_sec,
|
||||||
@ -42,6 +41,11 @@ async def telefoni_log_page(request: Request):
|
|||||||
""",
|
""",
|
||||||
(),
|
(),
|
||||||
) or []
|
) or []
|
||||||
|
for row in initial_calls:
|
||||||
|
display_raw = row.get("display_number")
|
||||||
|
repaired = normalize_external_number(display_raw)
|
||||||
|
if repaired:
|
||||||
|
row["display_number"] = repaired
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("⚠️ Could not load initial telefoni calls for SSR fallback: %s", e)
|
logger.warning("⚠️ Could not load initial telefoni calls for SSR fallback: %s", e)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1156,11 +1156,13 @@ class EmailWorkflowService:
|
|||||||
if sag_id_from_tag:
|
if sag_id_from_tag:
|
||||||
if sag_id and sag_id != sag_id_from_tag:
|
if sag_id and sag_id != sag_id_from_tag:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"⚠️ Email %s contains conflicting case hints (thread: SAG-%s, tag: SAG-%s). Using thread match.",
|
"⚠️ Email %s contains conflicting case hints (thread: SAG-%s, tag: SAG-%s). Using SAG tag.",
|
||||||
email_id,
|
email_id,
|
||||||
sag_id,
|
sag_id,
|
||||||
sag_id_from_tag
|
sag_id_from_tag
|
||||||
)
|
)
|
||||||
|
sag_id = sag_id_from_tag
|
||||||
|
routing_source = 'sag_tag'
|
||||||
elif not sag_id:
|
elif not sag_id:
|
||||||
sag_id = sag_id_from_tag
|
sag_id = sag_id_from_tag
|
||||||
routing_source = 'sag_tag'
|
routing_source = 'sag_tag'
|
||||||
|
|||||||
@ -5,16 +5,18 @@ Settings Frontend Views
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
from fastapi import APIRouter, Request, HTTPException
|
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_db_connection, release_db_connection, execute_query_single
|
from app.core.database import get_db_connection, release_db_connection, execute_query_single, execute_query
|
||||||
|
from app.core.auth_dependencies import require_any_permission
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
|
sql_console_access = require_any_permission("users.manage", "system.admin")
|
||||||
|
|
||||||
CREATE_TABLE_RE = re.compile(
|
CREATE_TABLE_RE = re.compile(
|
||||||
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(",
|
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(",
|
||||||
@ -33,6 +35,13 @@ SKIP_COLUMN_LINE_RE = re.compile(
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SQL_COMMENT_BLOCK_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
|
||||||
|
SQL_COMMENT_LINE_RE = re.compile(r"--[^\n]*")
|
||||||
|
SQL_FORBIDDEN_RE = re.compile(
|
||||||
|
r"\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|comment|copy|vacuum|analyze|refresh|merge)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _strip_sql_comments(sql: str) -> str:
|
def _strip_sql_comments(sql: str) -> str:
|
||||||
sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.DOTALL)
|
sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.DOTALL)
|
||||||
@ -291,6 +300,67 @@ class MigrationExecution(BaseModel):
|
|||||||
file_name: str
|
file_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class SqlConsoleRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
limit: int = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_and_validate_sql(sql: str) -> str:
|
||||||
|
raw = (sql or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail="SQL query is required")
|
||||||
|
|
||||||
|
cleaned = SQL_COMMENT_BLOCK_RE.sub("", raw)
|
||||||
|
cleaned = SQL_COMMENT_LINE_RE.sub("", cleaned).strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise HTTPException(status_code=400, detail="SQL query is empty after removing comments")
|
||||||
|
|
||||||
|
lowered = cleaned.lower()
|
||||||
|
if not (lowered.startswith("select") or lowered.startswith("with")):
|
||||||
|
raise HTTPException(status_code=400, detail="Only SELECT/WITH queries are allowed")
|
||||||
|
|
||||||
|
single_statement = cleaned.rstrip(";").strip()
|
||||||
|
if ";" in single_statement:
|
||||||
|
raise HTTPException(status_code=400, detail="Only one SQL statement is allowed")
|
||||||
|
|
||||||
|
if SQL_FORBIDDEN_RE.search(single_statement):
|
||||||
|
raise HTTPException(status_code=400, detail="Query contains forbidden SQL keywords")
|
||||||
|
|
||||||
|
return single_statement
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/sql", response_class=HTMLResponse, tags=["Frontend"])
|
||||||
|
async def sql_console_page(request: Request, _current_user: dict = Depends(sql_console_access)):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"settings/frontend/sql_console.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"title": "SQL Console",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/sql/execute", tags=["Frontend"])
|
||||||
|
async def execute_sql_console_query(payload: SqlConsoleRequest, _current_user: dict = Depends(sql_console_access)):
|
||||||
|
query = _sanitize_and_validate_sql(payload.query)
|
||||||
|
limit = payload.limit if isinstance(payload.limit, int) else 200
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
if limit > 1000:
|
||||||
|
limit = 1000
|
||||||
|
|
||||||
|
wrapped_query = f"SELECT * FROM ({query}) AS bmc_sql_console LIMIT %s"
|
||||||
|
rows = execute_query(wrapped_query, (limit,)) or []
|
||||||
|
columns = list(rows[0].keys()) if rows else []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"columns": columns,
|
||||||
|
"rows": rows,
|
||||||
|
"row_count": len(rows),
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings/migrations/status", tags=["Frontend"])
|
@router.get("/settings/migrations/status", tags=["Frontend"])
|
||||||
def migration_statuses():
|
def migration_statuses():
|
||||||
"""Check migration files against current schema and return per-file color status."""
|
"""Check migration files against current schema and return per-file color status."""
|
||||||
|
|||||||
@ -125,6 +125,9 @@
|
|||||||
<a class="nav-link" href="/settings/migrations">
|
<a class="nav-link" href="/settings/migrations">
|
||||||
<i class="bi bi-database me-2"></i>DB Migrationer
|
<i class="bi bi-database me-2"></i>DB Migrationer
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link" href="/settings/sql">
|
||||||
|
<i class="bi bi-terminal me-2"></i>SQL Console
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
189
app/settings/frontend/sql_console.html
Normal file
189
app/settings/frontend/sql_console.html
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}SQL Console - BMC Hub{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1"><i class="bi bi-database me-2"></i>SQL Console</h2>
|
||||||
|
<div class="text-muted small">Read-only SQL for superadmin. Kun SELECT/WITH queries.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Presets</label>
|
||||||
|
<div class="d-flex flex-wrap gap-2" id="sqlPresets">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="serverTime">Server time</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="telefoniLatest">Telefoni latest</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="telefoniCount">Telefoni count</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="missionLatest">Mission latest</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="sourceCompare">Source compare</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="sqlQuery" class="form-label">SQL query</label>
|
||||||
|
<textarea id="sqlQuery" class="form-control" rows="8" spellcheck="false" placeholder="SELECT * FROM telefoni_opkald ORDER BY started_at DESC"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label for="sqlLimit" class="form-label">Limit</label>
|
||||||
|
<input id="sqlLimit" type="number" class="form-control" value="200" min="1" max="1000" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-10 d-flex gap-2 justify-content-end">
|
||||||
|
<button id="runSqlBtn" class="btn btn-primary">
|
||||||
|
<i class="bi bi-play-fill me-1"></i>Run Query
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="sqlStatus" class="alert alert-info py-2 mt-3 mb-0">Ready</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-striped align-middle mb-0">
|
||||||
|
<thead id="sqlResultHead"></thead>
|
||||||
|
<tbody id="sqlResultBody">
|
||||||
|
<tr><td class="text-muted">No results yet</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const SQL_PRESETS = {
|
||||||
|
serverTime: {
|
||||||
|
query: 'SELECT now() AS server_time',
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
telefoniLatest: {
|
||||||
|
query: `SELECT id, callid, direction, ekstern_nummer, started_at, ended_at, duration_sec\nFROM telefoni_opkald\nORDER BY started_at DESC\nLIMIT 50`,
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
telefoniCount: {
|
||||||
|
query: 'SELECT COUNT(*) AS total_calls, MAX(started_at) AS latest_started_at FROM telefoni_opkald',
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
missionLatest: {
|
||||||
|
query: `SELECT call_id, state, caller_number, contact_name, company_name, started_at, ended_at\nFROM mission_call_state\nORDER BY started_at DESC\nLIMIT 50`,
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
sourceCompare: {
|
||||||
|
query: `WITH telefoni AS (\n SELECT 'telefoni_opkald'::text AS source, COUNT(*)::bigint AS total_calls, MAX(started_at) AS latest_started_at\n FROM telefoni_opkald\n), mission AS (\n SELECT 'mission_call_state'::text AS source, COUNT(*)::bigint AS total_calls, MAX(started_at) AS latest_started_at\n FROM mission_call_state\n)\nSELECT * FROM telefoni\nUNION ALL\nSELECT * FROM mission`,
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(data) {
|
||||||
|
const head = document.getElementById('sqlResultHead');
|
||||||
|
const body = document.getElementById('sqlResultBody');
|
||||||
|
const columns = Array.isArray(data.columns) ? data.columns : [];
|
||||||
|
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||||||
|
|
||||||
|
if (columns.length === 0) {
|
||||||
|
head.innerHTML = '';
|
||||||
|
body.innerHTML = '<tr><td class="text-muted">No rows returned</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
head.innerHTML = '<tr>' + columns.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr>';
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
body.innerHTML = `<tr><td colspan="${columns.length}" class="text-muted">No rows returned</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = rows.map((row) => {
|
||||||
|
return '<tr>' + columns.map((c) => {
|
||||||
|
const value = row[c];
|
||||||
|
if (value === null || value === undefined) return '<td class="text-muted">NULL</td>';
|
||||||
|
if (typeof value === 'object') return `<td>${escapeHtml(JSON.stringify(value))}</td>`;
|
||||||
|
return `<td>${escapeHtml(String(value))}</td>`;
|
||||||
|
}).join('') + '</tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSqlQuery() {
|
||||||
|
const query = (document.getElementById('sqlQuery').value || '').trim();
|
||||||
|
const limitInput = Number(document.getElementById('sqlLimit').value || 200);
|
||||||
|
const limit = Number.isFinite(limitInput) ? limitInput : 200;
|
||||||
|
const status = document.getElementById('sqlStatus');
|
||||||
|
const button = document.getElementById('runSqlBtn');
|
||||||
|
|
||||||
|
status.className = 'alert alert-info py-2 mt-3 mb-0';
|
||||||
|
status.textContent = 'Running query...';
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/sql/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ query, limit }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const message = errorData.detail || 'Failed to run query';
|
||||||
|
status.className = 'alert alert-danger py-2 mt-3 mb-0';
|
||||||
|
status.textContent = message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
renderResults(data);
|
||||||
|
status.className = 'alert alert-success py-2 mt-3 mb-0';
|
||||||
|
status.textContent = `Rows returned: ${data.row_count} (limit ${data.limit})`;
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'alert alert-danger py-2 mt-3 mb-0';
|
||||||
|
status.textContent = 'Network or server error while running query';
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const queryEl = document.getElementById('sqlQuery');
|
||||||
|
const limitEl = document.getElementById('sqlLimit');
|
||||||
|
|
||||||
|
document.getElementById('runSqlBtn').addEventListener('click', runSqlQuery);
|
||||||
|
queryEl.addEventListener('keydown', (event) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
runSqlQuery();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('#sqlPresets [data-preset]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const key = button.getAttribute('data-preset');
|
||||||
|
const preset = SQL_PRESETS[key];
|
||||||
|
if (!preset) return;
|
||||||
|
queryEl.value = preset.query;
|
||||||
|
limitEl.value = String(preset.limit || 50);
|
||||||
|
queryEl.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
queryEl.value = SQL_PRESETS.serverTime.query;
|
||||||
|
limitEl.value = String(SQL_PRESETS.serverTime.limit);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -1267,7 +1267,7 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
<script src="/static/js/tag-picker.js?v=2.2"></script>
|
<script src="/static/js/tag-picker.js?v=2.2"></script>
|
||||||
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
||||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=2.2"></script>
|
<script src="/static/js/telefoni.js?v=2.3"></script>
|
||||||
<script src="/static/js/sms.js?v=1.0"></script>
|
<script src="/static/js/sms.js?v=1.0"></script>
|
||||||
<script src="/static/js/bug-report.js?v=1.0"></script>
|
<script src="/static/js/bug-report.js?v=1.0"></script>
|
||||||
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
||||||
|
|||||||
8
main.py
8
main.py
@ -536,12 +536,18 @@ if __name__ == "__main__":
|
|||||||
log_level="info"
|
log_level="info"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
api_workers_raw = os.getenv("API_WORKERS", "1").strip()
|
||||||
|
try:
|
||||||
|
api_workers = max(1, int(api_workers_raw))
|
||||||
|
except ValueError:
|
||||||
|
api_workers = 1
|
||||||
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"main:app",
|
"main:app",
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=8000,
|
port=8000,
|
||||||
reload=False,
|
reload=False,
|
||||||
workers=2,
|
workers=api_workers,
|
||||||
timeout_keep_alive=65,
|
timeout_keep_alive=65,
|
||||||
access_log=True,
|
access_log=True,
|
||||||
log_level="info"
|
log_level="info"
|
||||||
|
|||||||
6
migrations/186_telefoni_ip_whitelist_setting.sql
Normal file
6
migrations/186_telefoni_ip_whitelist_setting.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-- Migration 186: Telefoni callback IP whitelist setting
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES
|
||||||
|
('telefoni_ip_whitelist', '', 'telefoni', 'CSV med tilladte callback IP/CIDR til Yealink callbacks', 'string', false)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
48
migrations/190_sag_status_constraint_expand.sql
Normal file
48
migrations/190_sag_status_constraint_expand.sql
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
-- Migration 190: Align sag_sager status constraint with current case status model
|
||||||
|
-- Fixes PATCH /api/v1/sag/{id} failures when using statuses beyond 'åben'/'lukket'.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
constraint_row RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- Drop legacy check constraints on sag_sager.status regardless of their generated name.
|
||||||
|
FOR constraint_row IN
|
||||||
|
SELECT c.conname
|
||||||
|
FROM pg_constraint c
|
||||||
|
JOIN pg_class t ON t.oid = c.conrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'sag_sager'
|
||||||
|
AND c.contype = 'c'
|
||||||
|
AND pg_get_constraintdef(c.oid) ILIKE '%status%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE public.sag_sager DROP CONSTRAINT IF EXISTS %I', constraint_row.conname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
UPDATE public.sag_sager
|
||||||
|
SET status = CASE
|
||||||
|
WHEN lower(trim(status)) IN ('aaben', 'open') THEN 'åben'
|
||||||
|
WHEN lower(trim(status)) IN ('i_gang', 'in_progress', 'under behandling') THEN 'under behandling'
|
||||||
|
WHEN lower(trim(status)) IN ('on_hold', 'waiting', 'afventer') THEN 'afventer'
|
||||||
|
WHEN lower(trim(status)) IN ('resolved', 'løst') THEN 'løst'
|
||||||
|
WHEN lower(trim(status)) IN ('closed', 'afsluttet', 'lukket') THEN 'lukket'
|
||||||
|
ELSE status
|
||||||
|
END;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint c
|
||||||
|
JOIN pg_class t ON t.oid = c.conrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'sag_sager'
|
||||||
|
AND c.conname = 'sag_sager_status_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.sag_sager
|
||||||
|
ADD CONSTRAINT sag_sager_status_check
|
||||||
|
CHECK (status IN ('åben', 'under behandling', 'afventer', 'løst', 'lukket'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@ -143,6 +143,7 @@
|
|||||||
<div class="d-flex gap-2 mt-3">
|
<div class="d-flex gap-2 mt-3">
|
||||||
${openContactBtn}
|
${openContactBtn}
|
||||||
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
|
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" data-action="link-case">Link sag</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -153,7 +154,30 @@
|
|||||||
|
|
||||||
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
|
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
|
||||||
|
|
||||||
toastEl.addEventListener('click', (e) => {
|
async function patchCallCase(caseId) {
|
||||||
|
const parsedCaseId = Number(caseId);
|
||||||
|
if (!Number.isInteger(parsedCaseId) || parsedCaseId <= 0) {
|
||||||
|
throw new Error('Ugyldigt sag-ID');
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(Number(callId)) || Number(callId) <= 0) {
|
||||||
|
throw new Error('Mangler call_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/telefoni/calls/${Number(callId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ sag_id: parsedCaseId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text();
|
||||||
|
throw new Error(t || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return parsedCaseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
toastEl.addEventListener('click', async (e) => {
|
||||||
const btn = e.target.closest('button[data-action]');
|
const btn = e.target.closest('button[data-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const action = btn.getAttribute('data-action');
|
const action = btn.getAttribute('data-action');
|
||||||
@ -168,6 +192,28 @@
|
|||||||
qs.set('telefoni_opkald_id', String(callId));
|
qs.set('telefoni_opkald_id', String(callId));
|
||||||
window.location.href = `/sag/new?${qs.toString()}`;
|
window.location.href = `/sag/new?${qs.toString()}`;
|
||||||
}
|
}
|
||||||
|
if (action === 'link-case') {
|
||||||
|
const answer = window.prompt('Indtast eksisterende sag-ID, som opkaldet skal linkes til:');
|
||||||
|
if (answer === null) return;
|
||||||
|
const caseId = Number(String(answer).trim());
|
||||||
|
if (!Number.isInteger(caseId) || caseId <= 0) {
|
||||||
|
window.alert('Ugyldigt sag-ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = 'Gemmer...';
|
||||||
|
try {
|
||||||
|
const linkedCaseId = await patchCallCase(caseId);
|
||||||
|
window.location.href = `/sag/${linkedCaseId}/v3`;
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(`Kunne ikke linke sag: ${err?.message || 'ukendt fejl'}`);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,18 +226,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
|
||||||
scheduleReconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const url = `${proto}://${window.location.host}/api/v1/telefoni/ws?token=${encodeURIComponent(token)}`;
|
// Fallback to cookie-auth websocket when token is HttpOnly and cannot be read by JS.
|
||||||
|
const url = token
|
||||||
|
? `${proto}://${window.location.host}/api/v1/telefoni/ws?token=${encodeURIComponent(token)}`
|
||||||
|
: `${proto}://${window.location.host}/api/v1/telefoni/ws`;
|
||||||
ws = new WebSocket(url);
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
ws.onopen = () => console.log('📞 Telefoni WS connected');
|
ws.onopen = () => console.log('📞 Telefoni WS connected');
|
||||||
|
|||||||
515
update_fast.sh
Executable file
515
update_fast.sh
Executable file
@ -0,0 +1,515 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Fast code-only update for BMC Hub (DEV/PROD)
|
||||||
|
#
|
||||||
|
# Purpose:
|
||||||
|
# - Deploy small app code/template/static changes without creating a new tag/package.
|
||||||
|
# - Keep full release workflow in updateto.sh for dependency/runtime/migration changes.
|
||||||
|
#
|
||||||
|
# Safety model:
|
||||||
|
# - Allowed paths: app/* and main.py
|
||||||
|
# - Disallowed: migrations, Docker/compose, requirements, env files, deploy scripts.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PODMAN_COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
|
STATE_DIR=".fast-update"
|
||||||
|
STATE_FILE="${STATE_DIR}/current_ref"
|
||||||
|
LAST_BACKUP_FILE="${STATE_DIR}/last_backup"
|
||||||
|
|
||||||
|
TARGET_REF=""
|
||||||
|
DRY_RUN=false
|
||||||
|
ALLOW_PROD=false
|
||||||
|
ROLLBACK_ID=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
./update_fast.sh --ref <git-ref> [--dry-run] [--allow-prod]
|
||||||
|
./update_fast.sh --rollback <backup-id> [--allow-prod]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
./update_fast.sh --ref main --dry-run
|
||||||
|
./update_fast.sh --ref 08f4097
|
||||||
|
./update_fast.sh --rollback 20260517-142155
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Fast mode is ONLY for code/template/static updates in app/* and main.py.
|
||||||
|
- For migrations, dependencies, Docker/compose, or env changes: use ./updateto.sh.
|
||||||
|
- On production hosts, --allow-prod is required.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
|
||||||
|
echo "Error: do not run as root. Use the normal rootless podman user."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--ref)
|
||||||
|
TARGET_REF="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--allow-prod)
|
||||||
|
ALLOW_PROD=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--rollback)
|
||||||
|
ROLLBACK_ID="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: unknown option: $1"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$ROLLBACK_ID" ] && [ -n "$TARGET_REF" ]; then
|
||||||
|
echo "Error: use either --ref or --rollback, not both."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$ROLLBACK_ID" ] && [ -z "$TARGET_REF" ]; then
|
||||||
|
echo "Error: missing required option --ref <git-ref> or --rollback <backup-id>."
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo "Error: .env not found in $(pwd)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$PODMAN_COMPOSE_FILE" ]; then
|
||||||
|
echo "Error: $PODMAN_COMPOSE_FILE not found in $(pwd)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
load_env_file() {
|
||||||
|
local env_file="$1"
|
||||||
|
local line=""
|
||||||
|
local trimmed=""
|
||||||
|
local key=""
|
||||||
|
local value=""
|
||||||
|
local first_char=""
|
||||||
|
local last_char=""
|
||||||
|
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
line="${line%$'\r'}"
|
||||||
|
trimmed="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
|
||||||
|
if [ -z "$trimmed" ] || [[ "$trimmed" == \#* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$line" != *=* ]]; then
|
||||||
|
echo "Error: invalid line in .env: $line"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
key="${line%%=*}"
|
||||||
|
value="${line#*=}"
|
||||||
|
|
||||||
|
key="${key#"${key%%[![:space:]]*}"}"
|
||||||
|
key="${key%"${key##*[![:space:]]}"}"
|
||||||
|
|
||||||
|
if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
|
||||||
|
echo "Error: invalid .env key: $key"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#value}" -ge 2 ]; then
|
||||||
|
first_char="${value:0:1}"
|
||||||
|
last_char="${value: -1}"
|
||||||
|
if { [ "$first_char" = '"' ] && [ "$last_char" = '"' ]; } || { [ "$first_char" = "'" ] && [ "$last_char" = "'" ]; }; then
|
||||||
|
value="${value:1:${#value}-2}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
export "$key=$value"
|
||||||
|
done < "$env_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_file .env
|
||||||
|
|
||||||
|
get_current_ip() {
|
||||||
|
local ip=""
|
||||||
|
|
||||||
|
if ip=$(hostname -I 2>/dev/null | awk '{print $1}'); then
|
||||||
|
if [ -n "$ip" ]; then
|
||||||
|
echo "$ip"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v ipconfig >/dev/null 2>&1; then
|
||||||
|
ip=$(ipconfig getifaddr en0 2>/dev/null || true)
|
||||||
|
if [ -z "$ip" ]; then
|
||||||
|
ip=$(ipconfig getifaddr en1 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [ -n "$ip" ]; then
|
||||||
|
echo "$ip"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
CURRENT_IP="$(get_current_ip)"
|
||||||
|
CURRENT_DIR=$(pwd)
|
||||||
|
IS_PROD=false
|
||||||
|
if [[ "$CURRENT_IP" == "172.16.31.183" ]] || [[ "$CURRENT_DIR" == "/srv/podman/bmc_hub_v2" ]] || [[ "$CURRENT_DIR" == "/srv/podman/bmc_hub_v1.0" ]]; then
|
||||||
|
IS_PROD=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$IS_PROD" = true ] && [ "$ALLOW_PROD" != true ]; then
|
||||||
|
echo "Error: production host detected. Re-run with --allow-prod to continue."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$IS_PROD" = true ] && [ "$DRY_RUN" != true ]; then
|
||||||
|
echo "Production safety check:"
|
||||||
|
echo " host ip: $CURRENT_IP"
|
||||||
|
echo " cwd: $CURRENT_DIR"
|
||||||
|
read -r -p "Type FAST-UPDATE to continue: " CONFIRM
|
||||||
|
if [ "$CONFIRM" != "FAST-UPDATE" ]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$STATE_DIR/backups"
|
||||||
|
|
||||||
|
GITEA_BASE="${GITEA_URL:-https://g.bmcnetworks.dk}"
|
||||||
|
REPO="${GITHUB_REPO:-ct/bmc_hub}"
|
||||||
|
API_PORT="${API_PORT:-8000}"
|
||||||
|
|
||||||
|
url_encode() {
|
||||||
|
python3 - "$1" <<'PY'
|
||||||
|
import sys
|
||||||
|
from urllib.parse import quote
|
||||||
|
print(quote(sys.argv[1], safe=""))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_url_to_file() {
|
||||||
|
local url="$1"
|
||||||
|
local out_file="$2"
|
||||||
|
|
||||||
|
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||||
|
curl -fsSL -H "Authorization: token ${GITHUB_TOKEN}" "$url" -o "$out_file"
|
||||||
|
else
|
||||||
|
curl -fsSL "$url" -o "$out_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build_changed_from_compare_json() {
|
||||||
|
local compare_file="$1"
|
||||||
|
local out_file="$2"
|
||||||
|
|
||||||
|
python3 - "$compare_file" > "$out_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
files = data.get("files") or []
|
||||||
|
for item in files:
|
||||||
|
status = str(item.get("status") or "")
|
||||||
|
filename = str(item.get("filename") or "")
|
||||||
|
prev = str(item.get("previous_filename") or "")
|
||||||
|
print(f"{status}\t{filename}\t{prev}")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
build_changed_from_local_git() {
|
||||||
|
local base_ref="$1"
|
||||||
|
local target_ref="$2"
|
||||||
|
local out_file="$3"
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! git rev-parse --verify "$target_ref" >/dev/null 2>&1; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git diff --name-status "$base_ref".."$target_ref" \
|
||||||
|
| awk 'BEGIN{OFS="\t"}
|
||||||
|
/^A\t/ {print "added", $2, ""}
|
||||||
|
/^M\t/ {print "modified", $2, ""}
|
||||||
|
/^D\t/ {print "deleted", $2, ""}
|
||||||
|
/^R[0-9]*\t/ {print "renamed", $3, $2}
|
||||||
|
/^C[0-9]*\t/ {print "copied", $3, $2}
|
||||||
|
/^T\t/ {print "type_changed", $2, ""}
|
||||||
|
/^U\t/ {print "unmerged", $2, ""}
|
||||||
|
/^X\t/ {print "unknown", $2, ""}
|
||||||
|
/^B\t/ {print "broken", $2, ""}' > "$out_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_disallowed_path() {
|
||||||
|
local path="$1"
|
||||||
|
case "$path" in
|
||||||
|
migrations/*|Dockerfile|requirements.txt|docker-compose.yml|docker-compose.prod.yml|.env|.env.*|updateto.sh|update_fast.sh|scripts/*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
is_allowed_path() {
|
||||||
|
local path="$1"
|
||||||
|
case "$path" in
|
||||||
|
app/*|main.py)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
health_check() {
|
||||||
|
local attempts=30
|
||||||
|
local i=1
|
||||||
|
while [ "$i" -le "$attempts" ]; do
|
||||||
|
if curl -fsS "http://localhost:${API_PORT}/health" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Waiting for API health (${i}/${attempts})"
|
||||||
|
sleep 2
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild_api_latest() {
|
||||||
|
RELEASE_VERSION=latest podman-compose -f "$PODMAN_COMPOSE_FILE" build api
|
||||||
|
RELEASE_VERSION=latest podman-compose -f "$PODMAN_COMPOSE_FILE" up -d --no-deps api
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_backup() {
|
||||||
|
local backup_id="$1"
|
||||||
|
local backup_dir="${STATE_DIR}/backups/${backup_id}"
|
||||||
|
local manifest="${backup_dir}/manifest.tsv"
|
||||||
|
|
||||||
|
if [ ! -d "$backup_dir" ]; then
|
||||||
|
echo "Error: backup id not found: $backup_id"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$manifest" ]; then
|
||||||
|
echo "Error: manifest missing: $manifest"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Restoring backup: ${backup_id}"
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r status path existed; do
|
||||||
|
[ -z "$path" ] && continue
|
||||||
|
mkdir -p "$(dirname "$path")"
|
||||||
|
if [ "$existed" = "1" ]; then
|
||||||
|
cp "${backup_dir}/original/${path}" "$path"
|
||||||
|
else
|
||||||
|
rm -f "$path"
|
||||||
|
fi
|
||||||
|
done < "$manifest"
|
||||||
|
|
||||||
|
rebuild_api_latest
|
||||||
|
if ! health_check; then
|
||||||
|
echo "Error: health check failed after rollback."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "${backup_dir}/meta.env" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "${backup_dir}/meta.env"
|
||||||
|
if [ -n "${BASE_REF:-}" ]; then
|
||||||
|
echo "$BASE_REF" > "$STATE_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Rollback completed: ${backup_id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "$ROLLBACK_ID" ]; then
|
||||||
|
restore_backup "$ROLLBACK_ID"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE_REF=""
|
||||||
|
if [ -f "$STATE_FILE" ]; then
|
||||||
|
BASE_REF="$(cat "$STATE_FILE")"
|
||||||
|
elif [ -n "${RELEASE_VERSION:-}" ] && [ "${RELEASE_VERSION}" != "latest" ]; then
|
||||||
|
BASE_REF="$RELEASE_VERSION"
|
||||||
|
elif [ -n "${FAST_BASE_REF:-}" ]; then
|
||||||
|
BASE_REF="$FAST_BASE_REF"
|
||||||
|
else
|
||||||
|
echo "Error: cannot determine base ref."
|
||||||
|
echo "Set RELEASE_VERSION in .env, or create ${STATE_FILE}, or provide FAST_BASE_REF env var."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Fast update"
|
||||||
|
echo " base ref: $BASE_REF"
|
||||||
|
echo " target ref: $TARGET_REF"
|
||||||
|
|
||||||
|
BASE_ENC="$(url_encode "$BASE_REF")"
|
||||||
|
TARGET_ENC="$(url_encode "$TARGET_REF")"
|
||||||
|
COMPARE_URL="${GITEA_BASE}/api/v1/repos/${REPO}/compare/${BASE_ENC}...${TARGET_ENC}"
|
||||||
|
|
||||||
|
TMP_COMPARE="$(mktemp)"
|
||||||
|
TMP_CHANGED="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP_COMPARE" "$TMP_CHANGED"' EXIT
|
||||||
|
|
||||||
|
COMPARE_SOURCE=""
|
||||||
|
if fetch_url_to_file "$COMPARE_URL" "$TMP_COMPARE"; then
|
||||||
|
build_changed_from_compare_json "$TMP_COMPARE" "$TMP_CHANGED"
|
||||||
|
COMPARE_SOURCE="gitea-api"
|
||||||
|
else
|
||||||
|
if [ -d ".git" ] && build_changed_from_local_git "$BASE_REF" "$TARGET_REF" "$TMP_CHANGED"; then
|
||||||
|
COMPARE_SOURCE="local-git"
|
||||||
|
echo "Warning: compare API unavailable, using local git diff fallback."
|
||||||
|
else
|
||||||
|
echo "Error: could not fetch compare data from: $COMPARE_URL"
|
||||||
|
echo "Hint: ensure GITHUB_TOKEN is set, refs exist, or run in a git clone where both refs are available."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Compare source: ${COMPARE_SOURCE}"
|
||||||
|
|
||||||
|
if [ ! -s "$TMP_CHANGED" ]; then
|
||||||
|
echo "No changed files between ${BASE_REF} and ${TARGET_REF}."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -a DISALLOWED_FILES=()
|
||||||
|
declare -a NOT_ALLOWED_FILES=()
|
||||||
|
declare -a UNSUPPORTED_STATUS=()
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r status file_path prev_path; do
|
||||||
|
[ -z "$file_path" ] && continue
|
||||||
|
|
||||||
|
case "$status" in
|
||||||
|
added|modified)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
UNSUPPORTED_STATUS+=("${status}: ${file_path}")
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if is_disallowed_path "$file_path"; then
|
||||||
|
DISALLOWED_FILES+=("$file_path")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! is_allowed_path "$file_path"; then
|
||||||
|
NOT_ALLOWED_FILES+=("$file_path")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
done < "$TMP_CHANGED"
|
||||||
|
|
||||||
|
if [ "${#UNSUPPORTED_STATUS[@]}" -gt 0 ]; then
|
||||||
|
echo "Error: unsupported change types in fast mode:"
|
||||||
|
for item in "${UNSUPPORTED_STATUS[@]}"; do
|
||||||
|
echo " - $item"
|
||||||
|
done
|
||||||
|
echo "Use ./updateto.sh for these changes."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#DISALLOWED_FILES[@]}" -gt 0 ]; then
|
||||||
|
echo "Error: disallowed files changed for fast mode:"
|
||||||
|
for file_path in "${DISALLOWED_FILES[@]}"; do
|
||||||
|
echo " - $file_path"
|
||||||
|
done
|
||||||
|
echo "Use ./updateto.sh for this update."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#NOT_ALLOWED_FILES[@]}" -gt 0 ]; then
|
||||||
|
echo "Error: files outside fast-mode scope changed:"
|
||||||
|
for file_path in "${NOT_ALLOWED_FILES[@]}"; do
|
||||||
|
echo " - $file_path"
|
||||||
|
done
|
||||||
|
echo "Fast mode only allows app/* and main.py. Use ./updateto.sh instead."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Changed files in fast scope:"
|
||||||
|
while IFS=$'\t' read -r status file_path prev_path; do
|
||||||
|
[ -z "$file_path" ] && continue
|
||||||
|
echo " - ${status}: ${file_path}"
|
||||||
|
done < "$TMP_CHANGED"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
echo "Dry-run complete. No files updated."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_ID="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
BACKUP_DIR="${STATE_DIR}/backups/${BACKUP_ID}"
|
||||||
|
MANIFEST="${BACKUP_DIR}/manifest.tsv"
|
||||||
|
mkdir -p "${BACKUP_DIR}/original"
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r status file_path prev_path; do
|
||||||
|
[ -z "$file_path" ] && continue
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$file_path")"
|
||||||
|
|
||||||
|
existed=0
|
||||||
|
if [ -f "$file_path" ]; then
|
||||||
|
mkdir -p "${BACKUP_DIR}/original/$(dirname "$file_path")"
|
||||||
|
cp "$file_path" "${BACKUP_DIR}/original/${file_path}"
|
||||||
|
existed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "%s\t%s\t%s\n" "$status" "$file_path" "$existed" >> "$MANIFEST"
|
||||||
|
|
||||||
|
file_enc="$(url_encode "$file_path")"
|
||||||
|
raw_url="${GITEA_BASE}/api/v1/repos/${REPO}/raw/${file_enc}?ref=${TARGET_ENC}"
|
||||||
|
tmp_file="${file_path}.tmp.fast"
|
||||||
|
|
||||||
|
fetch_url_to_file "$raw_url" "$tmp_file"
|
||||||
|
mv "$tmp_file" "$file_path"
|
||||||
|
done < "$TMP_CHANGED"
|
||||||
|
|
||||||
|
cat > "${BACKUP_DIR}/meta.env" <<EOF
|
||||||
|
BASE_REF=${BASE_REF}
|
||||||
|
TARGET_REF=${TARGET_REF}
|
||||||
|
BACKUP_ID=${BACKUP_ID}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Building and restarting API container only"
|
||||||
|
rebuild_api_latest
|
||||||
|
|
||||||
|
if ! health_check; then
|
||||||
|
echo "Error: health check failed after fast update. Starting automatic rollback."
|
||||||
|
restore_backup "$BACKUP_ID"
|
||||||
|
echo "Rollback completed. Fast update aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$TARGET_REF" > "$STATE_FILE"
|
||||||
|
echo "$BACKUP_ID" > "$LAST_BACKUP_FILE"
|
||||||
|
|
||||||
|
echo "Fast update complete"
|
||||||
|
echo " backup id: $BACKUP_ID"
|
||||||
|
echo " deployed: $TARGET_REF"
|
||||||
|
echo " state file: $STATE_FILE"
|
||||||
Loading…
Reference in New Issue
Block a user