feat(obligations): Go PARTIAL DEPRECATED, Python x-user-id, UCCA Proxy Headers, 62 Tests
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 31s
CI / test-python-backend-compliance (push) Successful in 29s
CI / test-python-document-crawler (push) Successful in 19s
CI / test-python-dsms-gateway (push) Successful in 26s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 31s
CI / test-python-backend-compliance (push) Successful in 29s
CI / test-python-document-crawler (push) Successful in 19s
CI / test-python-dsms-gateway (push) Successful in 26s
- Go obligations_handlers.go: CRUD-Overlap als deprecated markiert, AI-Features (Assess/Gap/TOM/Export) bleiben aktiv - Python obligation_routes.py: x-user-id Header + Audit-Logging an 4 Write-Endpoints - 3 UCCA Proxy-Dateien: Default X-Tenant-ID + X-User-ID Headers - Tests von 39 auf 62 erweitert (+23 Route-Integration-Tests mit mock_db/TestClient) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||
|
||||
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = 'admin'
|
||||
|
||||
async function proxyRequest(request: NextRequest, method: string) {
|
||||
try {
|
||||
const pathSegments = request.nextUrl.pathname.replace('/api/sdk/v1/ucca/obligations/', '')
|
||||
const targetUrl = `${SDK_BASE_URL}/sdk/v1/ucca/obligations/${pathSegments}${request.nextUrl.search}`
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const tenantId = request.headers.get('X-Tenant-ID')
|
||||
if (tenantId) headers['X-Tenant-ID'] = tenantId
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = { method, headers }
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
|
||||
@@ -2,19 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||
|
||||
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = 'admin'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Forward the request to the SDK backend
|
||||
const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/assess`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Forward tenant ID if present
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
@@ -2,19 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_BASE_URL = process.env.SDK_BASE_URL || 'http://localhost:8085'
|
||||
|
||||
const DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = 'admin'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Forward the request to the SDK backend
|
||||
const response = await fetch(`${SDK_BASE_URL}/sdk/v1/ucca/obligations/export/direct`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Forward tenant ID if present
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
'X-User-ID': request.headers.get('X-User-ID') || DEFAULT_USER_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
// PARTIAL DEPRECATED — CRUD-Overlap mit Python backend-compliance
|
||||
//
|
||||
// Die folgenden CRUD-Endpoints sind deprecated, da Python (obligation_routes.py)
|
||||
// nun als Primary fuer List/Create/Update/Delete dient:
|
||||
// - ListObligations, CreateObligation, GetObligation, UpdateObligation, DeleteObligation
|
||||
//
|
||||
// AKTIV bleiben (einzigartige AI-Features ohne Python-Aequivalent):
|
||||
// - AssessFromScope, RunGapAnalysis, ExportAssessment, ExportDirect
|
||||
// - ListAssessments, GetAssessment, UpdateAssessment
|
||||
// - TOM-Controls: ListTOMControls, MapTOMControls, GetTOMGapAnalysis
|
||||
// - GetObligationsByFramework, GetFrameworks
|
||||
//
|
||||
// Go-Routen werden NICHT entfernt (Abwaertskompatibilitaet), aber Frontend
|
||||
// nutzt Python-Backend fuer alle CRUD-Operationen.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
||||
@@ -188,9 +188,11 @@ async def create_obligation(
|
||||
payload: ObligationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
x_user_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a new compliance obligation."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
logger.info("create_obligation user_id=%s tenant_id=%s title=%s", x_user_id, tenant_id, payload.title)
|
||||
|
||||
import json
|
||||
linked_systems = json.dumps(payload.linked_systems or [])
|
||||
@@ -244,9 +246,11 @@ async def update_obligation(
|
||||
payload: ObligationUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
x_user_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Update an obligation's fields."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
logger.info("update_obligation user_id=%s tenant_id=%s id=%s", x_user_id, tenant_id, obligation_id)
|
||||
import json
|
||||
|
||||
updates: Dict[str, Any] = {"id": obligation_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
||||
@@ -282,9 +286,11 @@ async def update_obligation_status(
|
||||
payload: ObligationStatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
x_user_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Quick status update for an obligation."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
logger.info("update_obligation_status user_id=%s tenant_id=%s id=%s status=%s", x_user_id, tenant_id, obligation_id, payload.status)
|
||||
valid_statuses = {"pending", "in-progress", "completed", "overdue"}
|
||||
if payload.status not in valid_statuses:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
|
||||
@@ -307,8 +313,10 @@ async def delete_obligation(
|
||||
obligation_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
x_user_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
logger.info("delete_obligation user_id=%s tenant_id=%s id=%s", x_user_id, tenant_id, obligation_id)
|
||||
result = db.execute(text("""
|
||||
DELETE FROM compliance_obligations
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
|
||||
@@ -4,7 +4,11 @@ import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from compliance.api.obligation_routes import (
|
||||
router,
|
||||
ObligationCreate,
|
||||
ObligationUpdate,
|
||||
ObligationStatusUpdate,
|
||||
@@ -12,6 +16,71 @@ from compliance.api.obligation_routes import (
|
||||
_get_tenant_id,
|
||||
DEFAULT_TENANT_ID,
|
||||
)
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route-Test infrastructure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
OBLIGATION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
class _MockRow:
|
||||
"""Simulates a SQLAlchemy row with _mapping attribute."""
|
||||
def __init__(self, data: dict):
|
||||
self._mapping = data
|
||||
|
||||
def __getitem__(self, idx):
|
||||
vals = list(self._mapping.values())
|
||||
return vals[idx]
|
||||
|
||||
|
||||
def _make_obligation_row(overrides=None):
|
||||
now = datetime(2026, 3, 6, 12, 0, 0)
|
||||
data = {
|
||||
"id": OBLIGATION_ID,
|
||||
"tenant_id": DEFAULT_TENANT_ID,
|
||||
"title": "Art. 30 VVT führen",
|
||||
"description": "Pflicht nach DSGVO",
|
||||
"source": "DSGVO",
|
||||
"source_article": "Art. 30",
|
||||
"deadline": None,
|
||||
"status": "pending",
|
||||
"priority": "medium",
|
||||
"responsible": None,
|
||||
"linked_systems": [],
|
||||
"assessment_id": None,
|
||||
"rule_code": None,
|
||||
"notes": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
return _MockRow(data)
|
||||
|
||||
|
||||
def _count_row(val):
|
||||
"""Simulates a COUNT(*) row — fetchone()[0] returns the value."""
|
||||
row = MagicMock()
|
||||
row.__getitem__ = lambda self, idx: val
|
||||
return row
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
yield db
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_db):
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -323,3 +392,249 @@ class TestObligationBusinessLogic:
|
||||
for source in ["DSGVO", "AI Act", "NIS2", "BDSG", "ISO 27001"]:
|
||||
req = ObligationCreate(title="Test", source=source)
|
||||
assert req.source == source
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — List
|
||||
# =============================================================================
|
||||
|
||||
class TestListObligationsRoute:
|
||||
def test_list_empty(self, client, mock_db):
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=MagicMock(return_value=_count_row(0))),
|
||||
MagicMock(fetchall=MagicMock(return_value=[])),
|
||||
]
|
||||
resp = client.get("/obligations")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["obligations"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_with_items(self, client, mock_db):
|
||||
row = _make_obligation_row()
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=MagicMock(return_value=_count_row(1))),
|
||||
MagicMock(fetchall=MagicMock(return_value=[row])),
|
||||
]
|
||||
resp = client.get("/obligations")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["obligations"]) == 1
|
||||
assert data["obligations"][0]["id"] == OBLIGATION_ID
|
||||
assert data["total"] == 1
|
||||
|
||||
def test_list_filter_status(self, client, mock_db):
|
||||
row = _make_obligation_row({"status": "overdue"})
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=MagicMock(return_value=_count_row(1))),
|
||||
MagicMock(fetchall=MagicMock(return_value=[row])),
|
||||
]
|
||||
resp = client.get("/obligations?status=overdue")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["obligations"][0]["status"] == "overdue"
|
||||
|
||||
def test_list_filter_priority(self, client, mock_db):
|
||||
row = _make_obligation_row({"priority": "critical"})
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=MagicMock(return_value=_count_row(1))),
|
||||
MagicMock(fetchall=MagicMock(return_value=[row])),
|
||||
]
|
||||
resp = client.get("/obligations?priority=critical")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["obligations"][0]["priority"] == "critical"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Create
|
||||
# =============================================================================
|
||||
|
||||
class TestCreateObligationRoute:
|
||||
def test_create_basic(self, client, mock_db):
|
||||
row = _make_obligation_row()
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
|
||||
resp = client.post("/obligations", json={"title": "Art. 30 VVT führen"})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["id"] == OBLIGATION_ID
|
||||
assert data["title"] == "Art. 30 VVT führen"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_create_full_fields(self, client, mock_db):
|
||||
row = _make_obligation_row({
|
||||
"title": "DSFA durchführen",
|
||||
"source": "DSGVO",
|
||||
"source_article": "Art. 35",
|
||||
"priority": "critical",
|
||||
"responsible": "DSB",
|
||||
})
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
|
||||
resp = client.post("/obligations", json={
|
||||
"title": "DSFA durchführen",
|
||||
"source": "DSGVO",
|
||||
"source_article": "Art. 35",
|
||||
"priority": "critical",
|
||||
"responsible": "DSB",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["priority"] == "critical"
|
||||
assert resp.json()["responsible"] == "DSB"
|
||||
|
||||
def test_create_missing_title_422(self, client, mock_db):
|
||||
resp = client.post("/obligations", json={"source": "DSGVO"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Get
|
||||
# =============================================================================
|
||||
|
||||
class TestGetObligationRoute:
|
||||
def test_get_existing(self, client, mock_db):
|
||||
row = _make_obligation_row()
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
|
||||
resp = client.get(f"/obligations/{OBLIGATION_ID}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == OBLIGATION_ID
|
||||
|
||||
def test_get_not_found(self, client, mock_db):
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
|
||||
resp = client.get("/obligations/nonexistent-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Update
|
||||
# =============================================================================
|
||||
|
||||
class TestUpdateObligationRoute:
|
||||
def test_update_partial(self, client, mock_db):
|
||||
updated = _make_obligation_row({"priority": "high"})
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=updated))
|
||||
resp = client.put(f"/obligations/{OBLIGATION_ID}", json={"priority": "high"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["priority"] == "high"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_update_not_found(self, client, mock_db):
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
|
||||
resp = client.put(f"/obligations/{OBLIGATION_ID}", json={"title": "Updated"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_empty_body_400(self, client, mock_db):
|
||||
resp = client.put(f"/obligations/{OBLIGATION_ID}", json={})
|
||||
assert resp.status_code == 400
|
||||
assert "No fields to update" in resp.json()["detail"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Status Update
|
||||
# =============================================================================
|
||||
|
||||
class TestUpdateObligationStatusRoute:
|
||||
def test_valid_status(self, client, mock_db):
|
||||
row = _make_obligation_row({"status": "in-progress"})
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
|
||||
resp = client.put(f"/obligations/{OBLIGATION_ID}/status", json={"status": "in-progress"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "in-progress"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_invalid_status_400(self, client, mock_db):
|
||||
resp = client.put(f"/obligations/{OBLIGATION_ID}/status", json={"status": "invalid"})
|
||||
assert resp.status_code == 400
|
||||
assert "Invalid status" in resp.json()["detail"]
|
||||
|
||||
def test_status_not_found(self, client, mock_db):
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
|
||||
resp = client.put(f"/obligations/{OBLIGATION_ID}/status", json={"status": "completed"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_status_to_completed(self, client, mock_db):
|
||||
row = _make_obligation_row({"status": "completed"})
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
|
||||
resp = client.put(f"/obligations/{OBLIGATION_ID}/status", json={"status": "completed"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "completed"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Delete
|
||||
# =============================================================================
|
||||
|
||||
class TestDeleteObligationRoute:
|
||||
def test_delete_existing(self, client, mock_db):
|
||||
mock_db.execute.return_value = MagicMock(rowcount=1)
|
||||
resp = client.delete(f"/obligations/{OBLIGATION_ID}")
|
||||
assert resp.status_code == 204
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_not_found(self, client, mock_db):
|
||||
mock_db.execute.return_value = MagicMock(rowcount=0)
|
||||
resp = client.delete(f"/obligations/{OBLIGATION_ID}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Stats
|
||||
# =============================================================================
|
||||
|
||||
class TestGetObligationStatsRoute:
|
||||
def test_stats_empty(self, client, mock_db):
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
|
||||
resp = client.get("/obligations/stats")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert data["pending"] == 0
|
||||
|
||||
def test_stats_with_data(self, client, mock_db):
|
||||
row = MagicMock()
|
||||
row._mapping = {
|
||||
"pending": 3, "in_progress": 2, "overdue": 1,
|
||||
"completed": 5, "critical": 2, "high": 3, "total": 11,
|
||||
}
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
|
||||
resp = client.get("/obligations/stats")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 11
|
||||
assert data["pending"] == 3
|
||||
assert data["critical"] == 2
|
||||
|
||||
def test_stats_structure(self, client, mock_db):
|
||||
row = MagicMock()
|
||||
row._mapping = {
|
||||
"pending": 0, "in_progress": 0, "overdue": 0,
|
||||
"completed": 0, "critical": 0, "high": 0, "total": 0,
|
||||
}
|
||||
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
|
||||
resp = client.get("/obligations/stats")
|
||||
data = resp.json()
|
||||
expected_keys = {"pending", "in_progress", "overdue", "completed", "critical", "high", "total"}
|
||||
assert set(data.keys()) == expected_keys
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Integration Tests — Search
|
||||
# =============================================================================
|
||||
|
||||
class TestObligationSearchRoute:
|
||||
def test_search_param(self, client, mock_db):
|
||||
row = _make_obligation_row({"title": "DSFA Pflicht"})
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=MagicMock(return_value=_count_row(1))),
|
||||
MagicMock(fetchall=MagicMock(return_value=[row])),
|
||||
]
|
||||
resp = client.get("/obligations?search=DSFA")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["obligations"]) == 1
|
||||
|
||||
def test_source_filter(self, client, mock_db):
|
||||
row = _make_obligation_row({"source": "AI Act"})
|
||||
mock_db.execute.side_effect = [
|
||||
MagicMock(fetchone=MagicMock(return_value=_count_row(1))),
|
||||
MagicMock(fetchall=MagicMock(return_value=[row])),
|
||||
]
|
||||
resp = client.get("/obligations?source=AI Act")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["obligations"][0]["source"] == "AI Act"
|
||||
|
||||
Reference in New Issue
Block a user