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
|
Auth Admin API - Users, Groups, Permissions management
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, status, Depends
|
from fastapi import APIRouter, HTTPException, status, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from app.core.auth_dependencies import require_permission
|
from app.core.auth_dependencies import require_permission
|
||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
|
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
|
||||||
@ -13,6 +14,14 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
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"))])
|
@router.get("/admin/users", dependencies=[Depends(require_permission("users.manage"))])
|
||||||
async def list_users():
|
async def list_users():
|
||||||
users = execute_query(
|
users = execute_query(
|
||||||
@ -94,6 +103,48 @@ async def update_user_groups(user_id: int, payload: UserGroupsUpdate):
|
|||||||
return {"message": "Groups updated"}
|
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")
|
@router.post("/admin/users/{user_id}/2fa/reset")
|
||||||
async def reset_user_2fa(
|
async def reset_user_2fa(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@ -180,7 +180,7 @@ async def sync_settings_from_env():
|
|||||||
@router.get("/users", response_model=List[User], tags=["Users"])
|
@router.get("/users", response_model=List[User], tags=["Users"])
|
||||||
async def get_users(is_active: Optional[bool] = None):
|
async def get_users(is_active: Optional[bool] = None):
|
||||||
"""Get all users"""
|
"""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 = []
|
params = []
|
||||||
|
|
||||||
if is_active is not None:
|
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"])
|
@router.get("/users/{user_id}", response_model=User, tags=["Users"])
|
||||||
async def get_user(user_id: int):
|
async def get_user(user_id: int):
|
||||||
"""Get user by ID"""
|
"""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,))
|
result = execute_query(query, (user_id,))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
@ -219,7 +219,7 @@ async def create_user(user: UserCreate):
|
|||||||
query = """
|
query = """
|
||||||
INSERT INTO users (username, email, password_hash, full_name, is_active)
|
INSERT INTO users (username, email, password_hash, full_name, is_active)
|
||||||
VALUES (%s, %s, %s, %s, true)
|
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))
|
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
|
UPDATE users
|
||||||
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE user_id = %s
|
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))
|
result = execute_query(query, tuple(params))
|
||||||
|
|||||||
@ -764,6 +764,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Sync Log -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header bg-white">
|
<div class="card-header bg-white">
|
||||||
@ -2473,17 +2529,21 @@ async function createUser() {
|
|||||||
|
|
||||||
async function toggleUserActive(userId, isActive) {
|
async function toggleUserActive(userId, isActive) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/users/${userId}`, {
|
const response = await fetch(`/api/v1/admin/users/${userId}`, {
|
||||||
method: 'PUT',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ is_active: isActive })
|
body: JSON.stringify({ is_active: isActive })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
loadUsers();
|
alert(await getErrorMessage(response, 'Kunne ikke opdatere brugerstatus'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling user:', error);
|
console.error('Error toggling user:', error);
|
||||||
|
alert('Kunne ikke opdatere brugerstatus');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2666,13 +2726,18 @@ async function resetPassword(userId) {
|
|||||||
if (!newPassword) return;
|
if (!newPassword) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/users/${userId}/reset-password?new_password=${newPassword}`, {
|
const response = await fetch(`/api/v1/admin/users/${userId}/reset-password`, {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ new_password: newPassword })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
alert('Adgangskode nulstillet!');
|
alert(await getErrorMessage(response, 'Kunne ikke nulstille adgangskode'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
alert('Adgangskode nulstillet!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error resetting password:', error);
|
console.error('Error resetting password:', error);
|
||||||
alert('Kunne ikke nulstille adgangskode');
|
alert('Kunne ikke nulstille adgangskode');
|
||||||
@ -3313,6 +3378,7 @@ if (tagsNavLink) {
|
|||||||
|
|
||||||
// ====== SYNC MANAGEMENT ======
|
// ====== SYNC MANAGEMENT ======
|
||||||
let syncLog = [];
|
let syncLog = [];
|
||||||
|
let archivedSyncPollInterval = null;
|
||||||
|
|
||||||
async function loadSyncStats() {
|
async function loadSyncStats() {
|
||||||
try {
|
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.';
|
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;
|
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() {
|
async function syncFromVtiger() {
|
||||||
const btn = document.getElementById('btnSyncVtiger');
|
const btn = document.getElementById('btnSyncVtiger');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@ -3578,9 +3830,17 @@ if (syncNavLink) {
|
|||||||
syncNavLink.addEventListener('click', () => {
|
syncNavLink.addEventListener('click', () => {
|
||||||
loadSyncStats();
|
loadSyncStats();
|
||||||
loadSyncLog();
|
loadSyncLog();
|
||||||
|
loadArchivedSyncStatus();
|
||||||
|
startArchivedSyncPolling();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopArchivedSyncPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Notification helper
|
// Notification helper
|
||||||
function showNotification(message, type = 'info') {
|
function showNotification(message, type = 'info') {
|
||||||
// Create toast notification
|
// Create toast notification
|
||||||
|
|||||||
@ -31,15 +31,17 @@ SYNC RULES:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from app.core.database import execute_query
|
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
|
from app.services.vtiger_service import get_vtiger_service
|
||||||
import re
|
import re
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
sync_admin_access = require_any_permission("users.manage", "system.admin")
|
||||||
|
|
||||||
|
|
||||||
def normalize_name(name: str) -> str:
|
def normalize_name(name: str) -> str:
|
||||||
@ -53,7 +55,7 @@ def normalize_name(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/sync/vtiger")
|
@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
|
Link vTiger accounts to existing Hub customers
|
||||||
Matches by CVR or normalized name, updates vtiger_id
|
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")
|
@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
|
SIMPEL TILGANG - Sync contacts from vTiger and link to customers
|
||||||
Step 1: Fetch all contacts from vTiger
|
Step 1: Fetch all contacts from vTiger
|
||||||
@ -446,7 +448,7 @@ async def sync_vtiger_contacts() -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/sync/economic")
|
@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)
|
Sync customers from e-conomic (PRIMARY SOURCE)
|
||||||
Creates/updates Hub customers with e-conomic data
|
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")
|
@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
|
Find customers in Hub with CVR but without e-conomic customer number
|
||||||
Search e-conomic for matching CVR and update Hub
|
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")
|
@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
|
Diagnostics: Check contact linking coverage
|
||||||
Shows why contacts aren't linking to customers
|
Shows why contacts aren't linking to customers
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Optional
|
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 fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.ticket.backend.ticket_service import TicketService
|
from app.ticket.backend.ticket_service import TicketService
|
||||||
@ -55,11 +55,13 @@ from app.ticket.backend.models import (
|
|||||||
TicketDeadlineUpdateRequest
|
TicketDeadlineUpdateRequest
|
||||||
)
|
)
|
||||||
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
|
||||||
|
from app.core.auth_dependencies import require_any_permission
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
sync_admin_access = require_any_permission("users.manage", "system.admin")
|
||||||
|
|
||||||
|
|
||||||
def _get_first_value(data: dict, keys: List[str]) -> Optional[str]:
|
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("'", "''")
|
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]:
|
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."""
|
"""Run vTiger query with exponential backoff on rate-limit responses."""
|
||||||
for attempt in range(retries + 1):
|
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"),
|
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
|
||||||
include_messages: bool = Query(True, description="Include comments and emails"),
|
include_messages: bool = Query(True, description="Include comments and emails"),
|
||||||
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
|
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.
|
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"),
|
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
|
||||||
include_messages: bool = Query(True, description="Include comments and emails"),
|
include_messages: bool = Query(True, description="Include comments and emails"),
|
||||||
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
|
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).
|
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))
|
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"])
|
@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).
|
List available Simply-CRM modules (debug helper).
|
||||||
"""
|
"""
|
||||||
@ -2510,7 +2624,8 @@ async def list_simply_modules():
|
|||||||
@router.get("/archived/simply/ticket", tags=["Archived Tickets"])
|
@router.get("/archived/simply/ticket", tags=["Archived Tickets"])
|
||||||
async def fetch_simply_ticket(
|
async def fetch_simply_ticket(
|
||||||
ticket_number: Optional[str] = Query(None, description="Ticket number, e.g. TT934"),
|
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.
|
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"])
|
@router.get("/archived/simply/record", tags=["Archived Tickets"])
|
||||||
async def fetch_simply_record(
|
async def fetch_simply_record(
|
||||||
record_id: str = Query(..., description="VTiger record ID, e.g. 11x2601"),
|
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.
|
Fetch a single record from Simply-CRM by record id.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user