Fix user admin actions on v2 + add archived sync monitor in settings
This commit is contained in:
parent
959c9b4401
commit
e3094d7ed0
@ -2,6 +2,7 @@
|
||||
Auth Admin API - Users, Groups, Permissions management
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from app.core.auth_dependencies import require_permission
|
||||
from app.core.auth_service import AuthService
|
||||
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
|
||||
@ -13,6 +14,14 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UserStatusUpdateRequest(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
class UserPasswordResetRequest(BaseModel):
|
||||
new_password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
@router.get("/admin/users", dependencies=[Depends(require_permission("users.manage"))])
|
||||
async def list_users():
|
||||
users = execute_query(
|
||||
@ -94,6 +103,48 @@ async def update_user_groups(user_id: int, payload: UserGroupsUpdate):
|
||||
return {"message": "Groups updated"}
|
||||
|
||||
|
||||
@router.patch("/admin/users/{user_id}", dependencies=[Depends(require_permission("users.manage"))])
|
||||
async def update_user_status(user_id: int, payload: UserStatusUpdateRequest):
|
||||
user = execute_query_single(
|
||||
"SELECT user_id, username 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(
|
||||
"UPDATE users SET is_active = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||
(payload.is_active, user_id)
|
||||
)
|
||||
|
||||
logger.info("✅ Updated user status via admin: %s -> active=%s", user.get("username"), payload.is_active)
|
||||
return {"message": "User status updated", "user_id": user_id, "is_active": payload.is_active}
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/reset-password", dependencies=[Depends(require_permission("users.manage"))])
|
||||
async def admin_reset_user_password(user_id: int, payload: UserPasswordResetRequest):
|
||||
user = execute_query_single(
|
||||
"SELECT user_id, username FROM users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
try:
|
||||
password_hash = AuthService.hash_password(payload.new_password)
|
||||
except Exception as exc:
|
||||
logger.error("❌ Password hash failed for user_id=%s: %s", user_id, exc)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Kunne ikke hashe adgangskoden") from exc
|
||||
|
||||
execute_update(
|
||||
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||
(password_hash, user_id)
|
||||
)
|
||||
|
||||
logger.info("✅ Password reset via admin for user: %s", user.get("username"))
|
||||
return {"message": "Password reset", "user_id": user_id}
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/2fa/reset")
|
||||
async def reset_user_2fa(
|
||||
user_id: int,
|
||||
|
||||
@ -180,7 +180,7 @@ async def sync_settings_from_env():
|
||||
@router.get("/users", response_model=List[User], tags=["Users"])
|
||||
async def get_users(is_active: Optional[bool] = None):
|
||||
"""Get all users"""
|
||||
query = "SELECT user_id as id, username, email, full_name, is_active, last_login, created_at FROM users"
|
||||
query = "SELECT user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at FROM users"
|
||||
params = []
|
||||
|
||||
if is_active is not None:
|
||||
@ -195,7 +195,7 @@ async def get_users(is_active: Optional[bool] = None):
|
||||
@router.get("/users/{user_id}", response_model=User, tags=["Users"])
|
||||
async def get_user(user_id: int):
|
||||
"""Get user by ID"""
|
||||
query = "SELECT user_id as id, username, email, full_name, is_active, last_login, created_at FROM users WHERE user_id = %s"
|
||||
query = "SELECT user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at FROM users WHERE user_id = %s"
|
||||
result = execute_query(query, (user_id,))
|
||||
|
||||
if not result:
|
||||
@ -219,7 +219,7 @@ async def create_user(user: UserCreate):
|
||||
query = """
|
||||
INSERT INTO users (username, email, password_hash, full_name, is_active)
|
||||
VALUES (%s, %s, %s, %s, true)
|
||||
RETURNING user_id as id, username, email, full_name, is_active, last_login, created_at
|
||||
RETURNING user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at
|
||||
"""
|
||||
result = execute_query(query, (user.username, user.email, password_hash, user.full_name))
|
||||
|
||||
@ -260,7 +260,7 @@ async def update_user(user_id: int, user: UserUpdate):
|
||||
UPDATE users
|
||||
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = %s
|
||||
RETURNING user_id as id, username, email, full_name, is_active, last_login, created_at
|
||||
RETURNING user_id as id, username, email, full_name, is_active, last_login_at as last_login, created_at
|
||||
"""
|
||||
|
||||
result = execute_query(query, tuple(params))
|
||||
|
||||
@ -764,6 +764,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archived Ticket Sync + Monitor -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="mb-0 fw-bold">Archived Tickets Sync</h6>
|
||||
<small class="text-muted">Overvaager om alle archived tickets er synket ned (kildeantal vs lokal DB)</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-secondary" id="archivedOverallBadge">Status ukendt</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadArchivedSyncStatus()" id="btnCheckArchivedSync">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Tjek nu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="mb-0">Simply archived</h6>
|
||||
<span class="badge bg-secondary" id="archivedSimplyBadge">Ukendt</span>
|
||||
</div>
|
||||
<div class="small text-muted mb-2">Remote: <span id="archivedSimplyRemoteCount">-</span> | Lokal: <span id="archivedSimplyLocalCount">-</span> | Diff: <span id="archivedSimplyDiff">-</span></div>
|
||||
<div class="small text-muted mb-3">Beskeder lokalt: <span id="archivedSimplyMessagesCount">-</span></div>
|
||||
<div class="d-grid">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="syncArchivedSimply()" id="btnSyncArchivedSimply">
|
||||
<i class="bi bi-cloud-download me-2"></i>Sync Simply Archived
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="mb-0">vTiger Cases archived</h6>
|
||||
<span class="badge bg-secondary" id="archivedVtigerBadge">Ukendt</span>
|
||||
</div>
|
||||
<div class="small text-muted mb-2">Remote: <span id="archivedVtigerRemoteCount">-</span> | Lokal: <span id="archivedVtigerLocalCount">-</span> | Diff: <span id="archivedVtigerDiff">-</span></div>
|
||||
<div class="small text-muted mb-3">Beskeder lokalt: <span id="archivedVtigerMessagesCount">-</span></div>
|
||||
<div class="d-grid">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="syncArchivedVtiger()" id="btnSyncArchivedVtiger">
|
||||
<i class="bi bi-cloud-download me-2"></i>Sync vTiger Archived
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<small class="text-muted">Sidst tjekket: <span id="archivedLastChecked">Aldrig</span></small>
|
||||
<small class="text-muted" id="archivedStatusHint">Polling aktiv naar Sync-fanen er aaben.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Log -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-white">
|
||||
@ -2473,17 +2529,21 @@ async function createUser() {
|
||||
|
||||
async function toggleUserActive(userId, isActive) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
const response = await fetch(`/api/v1/admin/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_active: isActive })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadUsers();
|
||||
if (!response.ok) {
|
||||
alert(await getErrorMessage(response, 'Kunne ikke opdatere brugerstatus'));
|
||||
return;
|
||||
}
|
||||
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Error toggling user:', error);
|
||||
alert('Kunne ikke opdatere brugerstatus');
|
||||
}
|
||||
}
|
||||
|
||||
@ -2666,13 +2726,18 @@ async function resetPassword(userId) {
|
||||
if (!newPassword) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/users/${userId}/reset-password?new_password=${newPassword}`, {
|
||||
method: 'POST'
|
||||
const response = await fetch(`/api/v1/admin/users/${userId}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_password: newPassword })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Adgangskode nulstillet!');
|
||||
if (!response.ok) {
|
||||
alert(await getErrorMessage(response, 'Kunne ikke nulstille adgangskode'));
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Adgangskode nulstillet!');
|
||||
} catch (error) {
|
||||
console.error('Error resetting password:', error);
|
||||
alert('Kunne ikke nulstille adgangskode');
|
||||
@ -3313,6 +3378,7 @@ if (tagsNavLink) {
|
||||
|
||||
// ====== SYNC MANAGEMENT ======
|
||||
let syncLog = [];
|
||||
let archivedSyncPollInterval = null;
|
||||
|
||||
async function loadSyncStats() {
|
||||
try {
|
||||
@ -3397,9 +3463,195 @@ async function parseApiError(response, fallbackMessage) {
|
||||
return '2FA kræves for sync API. Aktivér 2FA på din bruger og log ind igen.';
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
if (String(detailMessage).includes('Missing required permission') || String(detailMessage).includes('Superadmin access required')) {
|
||||
return 'Kun admin/superadmin må starte eller overvåge sync.';
|
||||
}
|
||||
}
|
||||
|
||||
return detailMessage;
|
||||
}
|
||||
|
||||
function updateArchivedSourceBadge(sourceKey, isSynced, hasError) {
|
||||
const badgeId = sourceKey === 'simplycrm' ? 'archivedSimplyBadge' : 'archivedVtigerBadge';
|
||||
const badge = document.getElementById(badgeId);
|
||||
if (!badge) return;
|
||||
|
||||
if (hasError) {
|
||||
badge.className = 'badge bg-danger';
|
||||
badge.textContent = 'Fejl';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSynced === true) {
|
||||
badge.className = 'badge bg-success';
|
||||
badge.textContent = 'Synket';
|
||||
return;
|
||||
}
|
||||
|
||||
badge.className = 'badge bg-warning text-dark';
|
||||
badge.textContent = 'Mangler';
|
||||
}
|
||||
|
||||
function startArchivedSyncPolling() {
|
||||
if (archivedSyncPollInterval) return;
|
||||
archivedSyncPollInterval = setInterval(() => {
|
||||
loadArchivedSyncStatus();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function stopArchivedSyncPolling() {
|
||||
if (!archivedSyncPollInterval) return;
|
||||
clearInterval(archivedSyncPollInterval);
|
||||
archivedSyncPollInterval = null;
|
||||
}
|
||||
|
||||
async function loadArchivedSyncStatus() {
|
||||
const overallBadge = document.getElementById('archivedOverallBadge');
|
||||
const lastChecked = document.getElementById('archivedLastChecked');
|
||||
const hint = document.getElementById('archivedStatusHint');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/ticket/archived/status');
|
||||
if (!response.ok) {
|
||||
const errorMessage = await parseApiError(response, 'Kunne ikke hente archived status');
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
const simply = (status.sources || {}).simplycrm || {};
|
||||
const vtiger = (status.sources || {}).vtiger || {};
|
||||
|
||||
const setText = (id, value) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value === null || value === undefined ? '-' : value;
|
||||
};
|
||||
|
||||
setText('archivedSimplyRemoteCount', simply.remote_total_tickets);
|
||||
setText('archivedSimplyLocalCount', simply.local_total_tickets);
|
||||
setText('archivedSimplyDiff', simply.diff);
|
||||
setText('archivedSimplyMessagesCount', simply.local_total_messages);
|
||||
|
||||
setText('archivedVtigerRemoteCount', vtiger.remote_total_tickets);
|
||||
setText('archivedVtigerLocalCount', vtiger.local_total_tickets);
|
||||
setText('archivedVtigerDiff', vtiger.diff);
|
||||
setText('archivedVtigerMessagesCount', vtiger.local_total_messages);
|
||||
|
||||
updateArchivedSourceBadge('simplycrm', simply.is_synced, !!simply.error);
|
||||
updateArchivedSourceBadge('vtiger', vtiger.is_synced, !!vtiger.error);
|
||||
|
||||
if (overallBadge) {
|
||||
if (status.overall_synced === true) {
|
||||
overallBadge.className = 'badge bg-success';
|
||||
overallBadge.textContent = 'Alt synced';
|
||||
} else {
|
||||
overallBadge.className = 'badge bg-warning text-dark';
|
||||
overallBadge.textContent = 'Ikke fuldt synced';
|
||||
}
|
||||
}
|
||||
|
||||
if (lastChecked) {
|
||||
lastChecked.textContent = new Date().toLocaleString('da-DK');
|
||||
}
|
||||
|
||||
if (hint) {
|
||||
const errors = [simply.error, vtiger.error].filter(Boolean);
|
||||
hint.textContent = errors.length > 0
|
||||
? `Statusfejl: ${errors.join(' | ')}`
|
||||
: 'Polling aktiv mens Sync-fanen er åben.';
|
||||
}
|
||||
} catch (error) {
|
||||
if (overallBadge) {
|
||||
overallBadge.className = 'badge bg-danger';
|
||||
overallBadge.textContent = 'Statusfejl';
|
||||
}
|
||||
if (hint) {
|
||||
hint.textContent = error.message;
|
||||
}
|
||||
console.error('Error loading archived sync status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncArchivedSimply() {
|
||||
const btn = document.getElementById('btnSyncArchivedSimply');
|
||||
if (!btn) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
|
||||
|
||||
try {
|
||||
addSyncLogEntry('Simply Archived Sync Startet', 'Importerer archived tickets fra Simply...', 'info');
|
||||
|
||||
const response = await fetch('/api/v1/ticket/archived/simply/import?limit=5000&include_messages=true&force=false', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await parseApiError(response, 'Simply archived sync fejlede');
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const details = [
|
||||
`Importeret: ${result.imported || 0}`,
|
||||
`Opdateret: ${result.updated || 0}`,
|
||||
`Sprunget over: ${result.skipped || 0}`,
|
||||
`Fejl: ${result.errors || 0}`,
|
||||
`Beskeder: ${result.messages_imported || 0}`
|
||||
].join(' | ');
|
||||
addSyncLogEntry('Simply Archived Sync Fuldført', details, 'success');
|
||||
|
||||
await loadArchivedSyncStatus();
|
||||
showNotification('Simply archived sync fuldført!', 'success');
|
||||
} catch (error) {
|
||||
addSyncLogEntry('Simply Archived Sync Fejl', error.message, 'error');
|
||||
showNotification('Fejl: ' + error.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Sync Simply Archived';
|
||||
}
|
||||
}
|
||||
|
||||
async function syncArchivedVtiger() {
|
||||
const btn = document.getElementById('btnSyncArchivedVtiger');
|
||||
if (!btn) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
|
||||
|
||||
try {
|
||||
addSyncLogEntry('vTiger Archived Sync Startet', 'Importerer archived tickets fra vTiger Cases...', 'info');
|
||||
|
||||
const response = await fetch('/api/v1/ticket/archived/vtiger/import?limit=5000&include_messages=true&force=false', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await parseApiError(response, 'vTiger archived sync fejlede');
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const details = [
|
||||
`Importeret: ${result.imported || 0}`,
|
||||
`Opdateret: ${result.updated || 0}`,
|
||||
`Sprunget over: ${result.skipped || 0}`,
|
||||
`Fejl: ${result.errors || 0}`,
|
||||
`Beskeder: ${result.messages_imported || 0}`
|
||||
].join(' | ');
|
||||
addSyncLogEntry('vTiger Archived Sync Fuldført', details, 'success');
|
||||
|
||||
await loadArchivedSyncStatus();
|
||||
showNotification('vTiger archived sync fuldført!', 'success');
|
||||
} catch (error) {
|
||||
addSyncLogEntry('vTiger Archived Sync Fejl', error.message, 'error');
|
||||
showNotification('Fejl: ' + error.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Sync vTiger Archived';
|
||||
}
|
||||
}
|
||||
|
||||
async function syncFromVtiger() {
|
||||
const btn = document.getElementById('btnSyncVtiger');
|
||||
btn.disabled = true;
|
||||
@ -3578,9 +3830,17 @@ if (syncNavLink) {
|
||||
syncNavLink.addEventListener('click', () => {
|
||||
loadSyncStats();
|
||||
loadSyncLog();
|
||||
loadArchivedSyncStatus();
|
||||
startArchivedSyncPolling();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
stopArchivedSyncPolling();
|
||||
}
|
||||
});
|
||||
|
||||
// Notification helper
|
||||
function showNotification(message, type = 'info') {
|
||||
// Create toast notification
|
||||
|
||||
@ -31,15 +31,17 @@ SYNC RULES:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, Any
|
||||
from app.core.database import execute_query
|
||||
from app.core.auth_dependencies import require_any_permission
|
||||
from app.services.vtiger_service import get_vtiger_service
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
sync_admin_access = require_any_permission("users.manage", "system.admin")
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
@ -53,7 +55,7 @@ def normalize_name(name: str) -> str:
|
||||
|
||||
|
||||
@router.post("/sync/vtiger")
|
||||
async def sync_from_vtiger() -> Dict[str, Any]:
|
||||
async def sync_from_vtiger(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
|
||||
"""
|
||||
Link vTiger accounts to existing Hub customers
|
||||
Matches by CVR or normalized name, updates vtiger_id
|
||||
@ -186,7 +188,7 @@ async def sync_from_vtiger() -> Dict[str, Any]:
|
||||
|
||||
|
||||
@router.post("/sync/vtiger-contacts")
|
||||
async def sync_vtiger_contacts() -> Dict[str, Any]:
|
||||
async def sync_vtiger_contacts(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
|
||||
"""
|
||||
SIMPEL TILGANG - Sync contacts from vTiger and link to customers
|
||||
Step 1: Fetch all contacts from vTiger
|
||||
@ -446,7 +448,7 @@ async def sync_vtiger_contacts() -> Dict[str, Any]:
|
||||
|
||||
|
||||
@router.post("/sync/economic")
|
||||
async def sync_from_economic() -> Dict[str, Any]:
|
||||
async def sync_from_economic(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync customers from e-conomic (PRIMARY SOURCE)
|
||||
Creates/updates Hub customers with e-conomic data
|
||||
@ -606,7 +608,7 @@ async def sync_from_economic() -> Dict[str, Any]:
|
||||
|
||||
|
||||
@router.post("/sync/cvr-to-economic")
|
||||
async def sync_cvr_to_economic() -> Dict[str, Any]:
|
||||
async def sync_cvr_to_economic(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
|
||||
"""
|
||||
Find customers in Hub with CVR but without e-conomic customer number
|
||||
Search e-conomic for matching CVR and update Hub
|
||||
@ -668,7 +670,7 @@ async def sync_cvr_to_economic() -> Dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("/sync/diagnostics")
|
||||
async def sync_diagnostics() -> Dict[str, Any]:
|
||||
async def sync_diagnostics(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
|
||||
"""
|
||||
Diagnostics: Check contact linking coverage
|
||||
Shows why contacts aren't linking to customers
|
||||
|
||||
@ -11,7 +11,7 @@ import json
|
||||
import re
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.ticket.backend.ticket_service import TicketService
|
||||
@ -55,11 +55,13 @@ from app.ticket.backend.models import (
|
||||
TicketDeadlineUpdateRequest
|
||||
)
|
||||
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||
from app.core.auth_dependencies import require_any_permission
|
||||
from datetime import date, datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
sync_admin_access = require_any_permission("users.manage", "system.admin")
|
||||
|
||||
|
||||
def _get_first_value(data: dict, keys: List[str]) -> Optional[str]:
|
||||
@ -127,6 +129,31 @@ def _escape_simply_value(value: str) -> str:
|
||||
return value.replace("'", "''")
|
||||
|
||||
|
||||
def _extract_count_value(rows: List[dict]) -> Optional[int]:
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
row = rows[0] or {}
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
|
||||
for key in ("total_count", "count", "count(*)", "COUNT(*)"):
|
||||
value = row.get(key)
|
||||
if value is not None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
for value in row.values():
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _vtiger_query_with_retry(vtiger, query_string: str, retries: int = 5, base_delay: float = 1.25) -> List[dict]:
|
||||
"""Run vTiger query with exponential backoff on rate-limit responses."""
|
||||
for attempt in range(retries + 1):
|
||||
@ -1825,7 +1852,8 @@ async def import_simply_archived_tickets(
|
||||
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
|
||||
include_messages: bool = Query(True, description="Include comments and emails"),
|
||||
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
|
||||
force: bool = Query(False, description="Update even if sync hash matches")
|
||||
force: bool = Query(False, description="Update even if sync hash matches"),
|
||||
current_user: dict = Depends(sync_admin_access)
|
||||
):
|
||||
"""
|
||||
One-time import of archived tickets from Simply-CRM.
|
||||
@ -2157,7 +2185,8 @@ async def import_vtiger_archived_tickets(
|
||||
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
|
||||
include_messages: bool = Query(True, description="Include comments and emails"),
|
||||
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
|
||||
force: bool = Query(False, description="Update even if sync hash matches")
|
||||
force: bool = Query(False, description="Update even if sync hash matches"),
|
||||
current_user: dict = Depends(sync_admin_access)
|
||||
):
|
||||
"""
|
||||
One-time import of archived tickets from vTiger (Cases module).
|
||||
@ -2493,8 +2522,93 @@ async def import_vtiger_archived_tickets(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/archived/status", tags=["Archived Tickets"])
|
||||
async def get_archived_sync_status(current_user: dict = Depends(sync_admin_access)):
|
||||
"""
|
||||
Return archived sync parity status for Simply-CRM and vTiger.
|
||||
"""
|
||||
source_keys = ("simplycrm", "vtiger")
|
||||
sources: dict[str, dict] = {}
|
||||
|
||||
for source_key in source_keys:
|
||||
local_ticket_row = execute_query_single(
|
||||
"""
|
||||
SELECT COUNT(*) AS total_tickets,
|
||||
MAX(last_synced_at) AS last_synced_at
|
||||
FROM tticket_archived_tickets
|
||||
WHERE source_system = %s
|
||||
""",
|
||||
(source_key,)
|
||||
) or {}
|
||||
|
||||
local_message_row = execute_query_single(
|
||||
"""
|
||||
SELECT COUNT(*) AS total_messages
|
||||
FROM tticket_archived_messages m
|
||||
INNER JOIN tticket_archived_tickets t ON t.id = m.archived_ticket_id
|
||||
WHERE t.source_system = %s
|
||||
""",
|
||||
(source_key,)
|
||||
) or {}
|
||||
|
||||
local_tickets = int(local_ticket_row.get("total_tickets") or 0)
|
||||
local_messages = int(local_message_row.get("total_messages") or 0)
|
||||
last_synced_value = local_ticket_row.get("last_synced_at")
|
||||
if isinstance(last_synced_value, (datetime, date)):
|
||||
last_synced_at_iso = last_synced_value.isoformat()
|
||||
else:
|
||||
last_synced_at_iso = None
|
||||
|
||||
sources[source_key] = {
|
||||
"remote_total_tickets": None,
|
||||
"local_total_tickets": local_tickets,
|
||||
"local_total_messages": local_messages,
|
||||
"last_synced_at": last_synced_at_iso,
|
||||
"diff": None,
|
||||
"is_synced": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
async with SimplyCRMService() as service:
|
||||
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "Tickets")
|
||||
simply_rows = await service.query(f"SELECT count(*) AS total_count FROM {module_name};")
|
||||
simply_remote_count = _extract_count_value(simply_rows)
|
||||
sources["simplycrm"]["remote_total_tickets"] = simply_remote_count
|
||||
if simply_remote_count is not None:
|
||||
sources["simplycrm"]["diff"] = simply_remote_count - sources["simplycrm"]["local_total_tickets"]
|
||||
sources["simplycrm"]["is_synced"] = sources["simplycrm"]["diff"] == 0
|
||||
elif service.last_query_error:
|
||||
sources["simplycrm"]["error"] = service.last_query_error.get("message") or str(service.last_query_error)
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ Simply-CRM archived status check failed: %s", e)
|
||||
sources["simplycrm"]["error"] = str(e)
|
||||
|
||||
try:
|
||||
vtiger = get_vtiger_service()
|
||||
vtiger_rows = await _vtiger_query_with_retry(vtiger, "SELECT count(*) AS total_count FROM Cases;")
|
||||
vtiger_remote_count = _extract_count_value(vtiger_rows)
|
||||
sources["vtiger"]["remote_total_tickets"] = vtiger_remote_count
|
||||
if vtiger_remote_count is not None:
|
||||
sources["vtiger"]["diff"] = vtiger_remote_count - sources["vtiger"]["local_total_tickets"]
|
||||
sources["vtiger"]["is_synced"] = sources["vtiger"]["diff"] == 0
|
||||
elif vtiger.last_query_error:
|
||||
sources["vtiger"]["error"] = vtiger.last_query_error.get("message") or str(vtiger.last_query_error)
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ vTiger archived status check failed: %s", e)
|
||||
sources["vtiger"]["error"] = str(e)
|
||||
|
||||
overall_synced = all(sources[key].get("is_synced") is True for key in source_keys)
|
||||
|
||||
return {
|
||||
"checked_at": datetime.utcnow().isoformat(),
|
||||
"overall_synced": overall_synced,
|
||||
"sources": sources,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/archived/simply/modules", tags=["Archived Tickets"])
|
||||
async def list_simply_modules():
|
||||
async def list_simply_modules(current_user: dict = Depends(sync_admin_access)):
|
||||
"""
|
||||
List available Simply-CRM modules (debug helper).
|
||||
"""
|
||||
@ -2510,7 +2624,8 @@ async def list_simply_modules():
|
||||
@router.get("/archived/simply/ticket", tags=["Archived Tickets"])
|
||||
async def fetch_simply_ticket(
|
||||
ticket_number: Optional[str] = Query(None, description="Ticket number, e.g. TT934"),
|
||||
external_id: Optional[str] = Query(None, description="VTiger record ID, e.g. 17x1234")
|
||||
external_id: Optional[str] = Query(None, description="VTiger record ID, e.g. 17x1234"),
|
||||
current_user: dict = Depends(sync_admin_access)
|
||||
):
|
||||
"""
|
||||
Fetch a single HelpDesk ticket from Simply-CRM by ticket number or record id.
|
||||
@ -2544,7 +2659,8 @@ async def fetch_simply_ticket(
|
||||
@router.get("/archived/simply/record", tags=["Archived Tickets"])
|
||||
async def fetch_simply_record(
|
||||
record_id: str = Query(..., description="VTiger record ID, e.g. 11x2601"),
|
||||
module: Optional[str] = Query(None, description="Optional module name for context")
|
||||
module: Optional[str] = Query(None, description="Optional module name for context"),
|
||||
current_user: dict = Depends(sync_admin_access)
|
||||
):
|
||||
"""
|
||||
Fetch a single record from Simply-CRM by record id.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user