Browse Source
Add support case CRUD, photo attachments, change log, and global stats/list for PWA integration. Asset list exposes ticket count and active status. Includes migrations 008-010, collector UI, tests, ADR-004, and session handover. Co-authored-by: Cursor <cursoragent@cursor.com>master
49 changed files with 3668 additions and 157 deletions
@ -0,0 +1,117 @@ |
|||||||
|
import uuid |
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status |
||||||
|
from fastapi.responses import RedirectResponse, Response |
||||||
|
from sqlalchemy.orm import Session |
||||||
|
|
||||||
|
from app.core.auth import verify_api_bearer |
||||||
|
from app.db.session import get_db |
||||||
|
from app.schemas.agent_report import ErrorBody, ErrorResponse |
||||||
|
from app.schemas.attachments import AttachmentListOut, AttachmentOut |
||||||
|
from app.services import support_attachment_service |
||||||
|
|
||||||
|
router = APIRouter() |
||||||
|
|
||||||
|
|
||||||
|
def _not_found(message: str = "Not found") -> HTTPException: |
||||||
|
return HTTPException( |
||||||
|
status_code=status.HTTP_404_NOT_FOUND, |
||||||
|
detail=ErrorResponse(error=ErrorBody(code="NOT_FOUND", message=message)).model_dump(), |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def _bad_request(message: str) -> HTTPException: |
||||||
|
return HTTPException( |
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, |
||||||
|
detail=ErrorResponse(error=ErrorBody(code="VALIDATION_ERROR", message=message)).model_dump(), |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
@router.get("/support-cases/{case_id}/attachments", response_model=AttachmentListOut) |
||||||
|
def list_support_attachments( |
||||||
|
case_id: uuid.UUID, |
||||||
|
db: Session = Depends(get_db), |
||||||
|
_: str = Depends(verify_api_bearer), |
||||||
|
) -> AttachmentListOut: |
||||||
|
result = support_attachment_service.list_case_attachments(db, case_id) |
||||||
|
if result is None: |
||||||
|
raise _not_found("Support case not found") |
||||||
|
return result |
||||||
|
|
||||||
|
|
||||||
|
@router.post( |
||||||
|
"/support-cases/{case_id}/attachments", |
||||||
|
response_model=AttachmentOut, |
||||||
|
status_code=status.HTTP_201_CREATED, |
||||||
|
) |
||||||
|
async def upload_support_attachment( |
||||||
|
case_id: uuid.UUID, |
||||||
|
file: UploadFile = File(...), |
||||||
|
kind: str = Form("service_photo"), |
||||||
|
actor: str = Form(...), |
||||||
|
actor_display_name: str | None = Form(None), |
||||||
|
caption: str | None = Form(None), |
||||||
|
db: Session = Depends(get_db), |
||||||
|
_: str = Depends(verify_api_bearer), |
||||||
|
) -> AttachmentOut: |
||||||
|
from app.schemas.attachments import AttachmentUploadForm |
||||||
|
|
||||||
|
try: |
||||||
|
body = AttachmentUploadForm( |
||||||
|
kind=kind, # type: ignore[arg-type] |
||||||
|
actor=actor.strip(), |
||||||
|
actor_display_name=actor_display_name.strip() if actor_display_name else None, |
||||||
|
caption=caption.strip() if caption else None, |
||||||
|
) |
||||||
|
except Exception as exc: |
||||||
|
raise _bad_request(str(exc)) from exc |
||||||
|
|
||||||
|
data = await file.read() |
||||||
|
try: |
||||||
|
result = support_attachment_service.upload_support_attachment( |
||||||
|
db, |
||||||
|
case_id, |
||||||
|
filename=file.filename or "upload.bin", |
||||||
|
content_type=file.content_type, |
||||||
|
data=data, |
||||||
|
form=body, |
||||||
|
) |
||||||
|
except ValueError as exc: |
||||||
|
raise _bad_request(str(exc)) from exc |
||||||
|
|
||||||
|
if result is None: |
||||||
|
raise _not_found("Support case not found") |
||||||
|
return result |
||||||
|
|
||||||
|
|
||||||
|
@router.get("/attachments/{attachment_id}/download") |
||||||
|
def download_attachment( |
||||||
|
attachment_id: uuid.UUID, |
||||||
|
db: Session = Depends(get_db), |
||||||
|
_: str = Depends(verify_api_bearer), |
||||||
|
) -> Response: |
||||||
|
presigned = support_attachment_service.get_presigned_url(db, attachment_id) |
||||||
|
if presigned: |
||||||
|
return RedirectResponse(url=presigned, status_code=status.HTTP_302_FOUND) |
||||||
|
|
||||||
|
result = support_attachment_service.get_attachment_for_download(db, attachment_id) |
||||||
|
if result is None: |
||||||
|
raise _not_found("Attachment not found") |
||||||
|
|
||||||
|
row, data = result |
||||||
|
media = row.content_type or "application/octet-stream" |
||||||
|
headers = { |
||||||
|
"Content-Disposition": f'inline; filename="{row.original_filename}"', |
||||||
|
} |
||||||
|
return Response(content=data, media_type=media, headers=headers) |
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/attachments/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT) |
||||||
|
def delete_attachment( |
||||||
|
attachment_id: uuid.UUID, |
||||||
|
actor: str = Query(..., min_length=2, max_length=100), |
||||||
|
db: Session = Depends(get_db), |
||||||
|
_: str = Depends(verify_api_bearer), |
||||||
|
) -> None: |
||||||
|
if not support_attachment_service.soft_delete_attachment(db, attachment_id, actor=actor.strip()): |
||||||
|
raise _not_found("Attachment not found") |
||||||
@ -0,0 +1,154 @@ |
|||||||
|
import uuid |
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status |
||||||
|
from sqlalchemy.orm import Session |
||||||
|
|
||||||
|
from app.core.auth import verify_api_bearer |
||||||
|
from app.db.session import get_db |
||||||
|
from app.schemas.agent_report import ErrorBody, ErrorResponse |
||||||
|
from app.schemas.support import ( |
||||||
|
PaginatedSupportCases, |
||||||
|
SupportCaseCreateIn, |
||||||
|
SupportCaseOut, |
||||||
|
SupportCasePatchIn, |
||||||
|
SupportEventCreateIn, |
||||||
|
SupportEventOut, |
||||||
|
SupportOptionsOut, |
||||||
|
SupportStatsOut, |
||||||
|
) |
||||||
|
from app.services import support_service |
||||||
|
|
||||||
|
router = APIRouter() |
||||||
|
|
||||||
|
|
||||||
|
def _not_found(message: str = "Not found") -> HTTPException: |
||||||
|
return HTTPException( |
||||||
|
status_code=status.HTTP_404_NOT_FOUND, |
||||||
|
detail=ErrorResponse(error=ErrorBody(code="NOT_FOUND", message=message)).model_dump(), |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def _bad_request(message: str) -> HTTPException: |
||||||
|
return HTTPException( |
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, |
||||||
|
detail=ErrorResponse(error=ErrorBody(code="VALIDATION_ERROR", message=message)).model_dump(), |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
@router.get("/support/options", response_model=SupportOptionsOut) |
||||||
|
def get_support_options(_: str = Depends(verify_api_bearer)) -> SupportOptionsOut: |
||||||
|
return support_service.get_support_options() |
||||||
|
|
||||||
|
|
||||||
|
@router.get("/support/stats", response_model=SupportStatsOut) |
||||||
|
def get_support_stats( |
||||||
|
db: Session = Depends(get_db), |
||||||
|
_: str = Depends(verify_api_bearer), |
||||||
|
) -> SupportStatsOut: |
||||||
|
return support_service.get_support_stats(db) |
||||||
|
|
||||||
|
|
||||||
|
@router.get("/support-cases", response_model=PaginatedSupportCases) |
||||||
|
def list_support_cases( |
||||||
|
page: int = Query(1, ge=1), |
||||||
|
limit: int = Query(20, ge=1, le=100), |
||||||
|
status: str | None = Query(None, max_length=30), |
||||||
|
priority: str | None = Query(None, max_length=20), |
||||||
|
category: str | None = Query(None, max_length=40), |
||||||
|
active_only: bool = Query(False), |
||||||
|
q: str | None = Query(None, max_length=200), |
||||||
|
db: Session = Depends(get_db), |
||||||
|
_: str = Depends(verify_api_bearer), |
||||||
|
) -> PaginatedSupportCases: |
||||||
|
return support_service.list_support_cases( |
||||||
|
db, |
||||||
|
page=page, |
||||||
|
limit=limit, |
||||||
|
status=status, |
||||||
|
priority=priority, |
||||||
|
category=category, |
||||||
|
active_only=active_only, |
||||||
|
q=q, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assets/{asset_id}/support-cases", response_model=PaginatedSupportCases) |
||||||
|
def list_asset_support_cases( |
||||||
|
asset_id: uuid.UUID, |
||||||
|
page: int = Query(1, ge=1), |
||||||
|
limit: int = Query(20, ge=1, le=100), |
||||||
|
status: str | None = Query(None, max_length=30), |
||||||
|
db: Session = Depends(get_db), |
||||||
|
_: str = Depends(verify_api_bearer), |
||||||
|
) -> PaginatedSupportCases: |
||||||
|
result = support_service.list_asset_support_cases( |
||||||
|
db, asset_id, page=page, limit=limit, status=status |
||||||
|
) |
||||||
|
if result is None: |
||||||
|
raise _not_found("Asset not found") |
||||||
|
return result |
||||||
|
|
||||||
|
|
||||||
|
@router.post("/assets/{asset_id}/support-cases", response_model=SupportCaseOut, status_code=status.HTTP_201_CREATED) |
||||||
|
def create_support_case( |
||||||
|
asset_id: uuid.UUID, |
||||||
|
body: SupportCaseCreateIn, |
||||||
|
db: Session = Depends(get_db), |
||||||
|
_: str = Depends(verify_api_bearer), |
||||||
|
) -> SupportCaseOut: |
||||||
|
try: |
||||||
|
result = support_service.create_support_case(db, asset_id, body) |
||||||
|
except ValueError as exc: |
||||||
|
raise _bad_request(str(exc)) from exc |
||||||
|
if result is None: |
||||||
|
raise _not_found("Asset not found") |
||||||
|
return result |
||||||
|
|
||||||
|
|
||||||
|
@router.get("/support-cases/{case_id}", response_model=SupportCaseOut) |
||||||
|
def get_support_case( |
||||||
|
case_id: uuid.UUID, |
||||||
|
db: Session = Depends(get_db), |
||||||
|
_: str = Depends(verify_api_bearer), |
||||||
|
) -> SupportCaseOut: |
||||||
|
result = support_service.get_support_case(db, case_id, include_events=True) |
||||||
|
if result is None: |
||||||
|
raise _not_found("Support case not found") |
||||||
|
return result |
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/support-cases/{case_id}", response_model=SupportCaseOut) |
||||||
|
def patch_support_case( |
||||||
|
case_id: uuid.UUID, |
||||||
|
body: SupportCasePatchIn, |
||||||
|
actor: str = Query(..., min_length=2, max_length=100), |
||||||
|
actor_display_name: str | None = Query(None, max_length=200), |
||||||
|
db: Session = Depends(get_db), |
||||||
|
_: str = Depends(verify_api_bearer), |
||||||
|
) -> SupportCaseOut: |
||||||
|
try: |
||||||
|
result = support_service.patch_support_case( |
||||||
|
db, |
||||||
|
case_id, |
||||||
|
body, |
||||||
|
actor=actor.strip(), |
||||||
|
actor_display_name=actor_display_name.strip() if actor_display_name else None, |
||||||
|
) |
||||||
|
except ValueError as exc: |
||||||
|
raise _bad_request(str(exc)) from exc |
||||||
|
if result is None: |
||||||
|
raise _not_found("Support case not found") |
||||||
|
return result |
||||||
|
|
||||||
|
|
||||||
|
@router.post("/support-cases/{case_id}/events", response_model=SupportEventOut, status_code=status.HTTP_201_CREATED) |
||||||
|
def add_support_event( |
||||||
|
case_id: uuid.UUID, |
||||||
|
body: SupportEventCreateIn, |
||||||
|
db: Session = Depends(get_db), |
||||||
|
_: str = Depends(verify_api_bearer), |
||||||
|
) -> SupportEventOut: |
||||||
|
result = support_service.add_support_event(db, case_id, body) |
||||||
|
if result is None: |
||||||
|
raise _not_found("Support case not found") |
||||||
|
return result |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
from datetime import datetime |
||||||
|
from typing import Any |
||||||
|
|
||||||
|
from pydantic import BaseModel, Field |
||||||
|
|
||||||
|
|
||||||
|
class AssetChangeEventOut(BaseModel): |
||||||
|
id: str |
||||||
|
asset_id: str |
||||||
|
snapshot_id: str | None = None |
||||||
|
event_at: datetime |
||||||
|
source: str |
||||||
|
field_key: str |
||||||
|
field_label: str |
||||||
|
change_type: str |
||||||
|
old_value: str | None = None |
||||||
|
new_value: str | None = None |
||||||
|
detail: dict[str, Any] = Field(default_factory=dict) |
||||||
|
|
||||||
|
|
||||||
|
class PaginatedAssetChanges(BaseModel): |
||||||
|
items: list[AssetChangeEventOut] |
||||||
|
total: int |
||||||
|
page: int |
||||||
|
limit: int |
||||||
|
pages: int |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
"""SSOT — document attachment kinds (ADR-003 / support troubleshooting).""" |
||||||
|
|
||||||
|
from datetime import datetime |
||||||
|
from typing import Literal |
||||||
|
|
||||||
|
from pydantic import BaseModel, Field |
||||||
|
|
||||||
|
SUPPORT_ATTACHMENT_KIND = [ |
||||||
|
"service_photo", |
||||||
|
"before_photo", |
||||||
|
"after_photo", |
||||||
|
"parts_receipt", |
||||||
|
"service_report", |
||||||
|
"screenshot", |
||||||
|
"other", |
||||||
|
] |
||||||
|
|
||||||
|
SUPPORT_ATTACHMENT_KIND_LABELS: dict[str, str] = { |
||||||
|
"service_photo": "Foto kondisi / troubleshooting", |
||||||
|
"before_photo": "Foto sebelum perbaikan", |
||||||
|
"after_photo": "Foto setelah perbaikan", |
||||||
|
"parts_receipt": "Nota / invoice sparepart", |
||||||
|
"service_report": "Laporan service (PDF)", |
||||||
|
"screenshot": "Screenshot error", |
||||||
|
"other": "Lainnya", |
||||||
|
} |
||||||
|
|
||||||
|
ALLOWED_ATTACHMENT_CONTENT_TYPES = frozenset({ |
||||||
|
"image/jpeg", |
||||||
|
"image/png", |
||||||
|
"image/webp", |
||||||
|
"image/gif", |
||||||
|
"application/pdf", |
||||||
|
}) |
||||||
|
|
||||||
|
ALLOWED_ATTACHMENT_EXTENSIONS = frozenset({ |
||||||
|
".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf", |
||||||
|
}) |
||||||
|
|
||||||
|
|
||||||
|
class AttachmentOut(BaseModel): |
||||||
|
id: str |
||||||
|
domain: str |
||||||
|
kind: str |
||||||
|
kind_label: str |
||||||
|
asset_id: str |
||||||
|
support_case_id: str |
||||||
|
original_filename: str |
||||||
|
content_type: str | None = None |
||||||
|
file_size_bytes: int | None = None |
||||||
|
caption: str | None = None |
||||||
|
uploaded_by: str |
||||||
|
uploaded_by_name: str | None = None |
||||||
|
uploaded_at: datetime |
||||||
|
download_url: str |
||||||
|
is_image: bool = False |
||||||
|
|
||||||
|
|
||||||
|
class AttachmentListOut(BaseModel): |
||||||
|
items: list[AttachmentOut] |
||||||
|
total: int |
||||||
|
|
||||||
|
|
||||||
|
class AttachmentUploadForm(BaseModel): |
||||||
|
"""Validated after multipart parse.""" |
||||||
|
|
||||||
|
kind: Literal[ |
||||||
|
"service_photo", |
||||||
|
"before_photo", |
||||||
|
"after_photo", |
||||||
|
"parts_receipt", |
||||||
|
"service_report", |
||||||
|
"screenshot", |
||||||
|
"other", |
||||||
|
] = "service_photo" |
||||||
|
actor: str = Field(..., min_length=2, max_length=100) |
||||||
|
actor_display_name: str | None = Field(None, max_length=200) |
||||||
|
caption: str | None = Field(None, max_length=2000) |
||||||
@ -0,0 +1,247 @@ |
|||||||
|
"""SSOT enums & labels — IT Support troubleshooting (ADR-004).""" |
||||||
|
|
||||||
|
from datetime import datetime |
||||||
|
from typing import Any, Literal |
||||||
|
|
||||||
|
from pydantic import BaseModel, Field |
||||||
|
|
||||||
|
from app.schemas.attachments import SUPPORT_ATTACHMENT_KIND, SUPPORT_ATTACHMENT_KIND_LABELS, AttachmentOut |
||||||
|
|
||||||
|
SUPPORT_CASE_STATUS = [ |
||||||
|
"open", |
||||||
|
"in_progress", |
||||||
|
"waiting_parts", |
||||||
|
"waiting_user", |
||||||
|
"resolved", |
||||||
|
"closed", |
||||||
|
"cancelled", |
||||||
|
] |
||||||
|
|
||||||
|
SUPPORT_CASE_PRIORITY = ["low", "normal", "high", "critical"] |
||||||
|
|
||||||
|
SUPPORT_CASE_CATEGORY = [ |
||||||
|
"power_no_boot", |
||||||
|
"boot_os_error", |
||||||
|
"hardware_failure", |
||||||
|
"component_replacement", |
||||||
|
"repair", |
||||||
|
"preventive_maintenance", |
||||||
|
"software_issue", |
||||||
|
"network_connectivity", |
||||||
|
"performance", |
||||||
|
"data_backup_recovery", |
||||||
|
"security_incident", |
||||||
|
"user_request", |
||||||
|
"decommission", |
||||||
|
"other", |
||||||
|
] |
||||||
|
|
||||||
|
SUPPORT_EVENT_TYPE = [ |
||||||
|
"opened", |
||||||
|
"note", |
||||||
|
"status_change", |
||||||
|
"action", |
||||||
|
"parts", |
||||||
|
"handover", |
||||||
|
"resolution", |
||||||
|
] |
||||||
|
|
||||||
|
SUPPORT_STATUS_LABELS: dict[str, str] = { |
||||||
|
"open": "Open — baru dilaporkan", |
||||||
|
"in_progress": "In progress — sedang ditangani", |
||||||
|
"waiting_parts": "Waiting parts — menunggu komponen", |
||||||
|
"waiting_user": "Waiting user — menunggu user/konfirmasi", |
||||||
|
"resolved": "Resolved — selesai diperbaiki", |
||||||
|
"closed": "Closed — ditutup", |
||||||
|
"cancelled": "Cancelled — dibatalkan", |
||||||
|
} |
||||||
|
|
||||||
|
SUPPORT_PRIORITY_LABELS: dict[str, str] = { |
||||||
|
"low": "Low", |
||||||
|
"normal": "Normal", |
||||||
|
"high": "High", |
||||||
|
"critical": "Critical", |
||||||
|
} |
||||||
|
|
||||||
|
SUPPORT_CATEGORY_LABELS: dict[str, str] = { |
||||||
|
"power_no_boot": "Tidak nyala / mati total", |
||||||
|
"boot_os_error": "Error boot / OS", |
||||||
|
"hardware_failure": "Kerusakan hardware", |
||||||
|
"component_replacement": "Penggantian komponen", |
||||||
|
"repair": "Perbaikan", |
||||||
|
"preventive_maintenance": "Service / maintenance", |
||||||
|
"software_issue": "Masalah software / driver", |
||||||
|
"network_connectivity": "Jaringan / konektivitas", |
||||||
|
"performance": "Performa / lambat / overheat", |
||||||
|
"data_backup_recovery": "Backup / recovery data", |
||||||
|
"security_incident": "Keamanan / malware", |
||||||
|
"user_request": "Permintaan user", |
||||||
|
"decommission": "Penarikan / decommission", |
||||||
|
"other": "Lainnya", |
||||||
|
} |
||||||
|
|
||||||
|
SUPPORT_EVENT_TYPE_LABELS: dict[str, str] = { |
||||||
|
"opened": "Kasus dibuka", |
||||||
|
"note": "Catatan", |
||||||
|
"status_change": "Perubahan status", |
||||||
|
"action": "Tindakan teknis", |
||||||
|
"parts": "Komponen / parts", |
||||||
|
"handover": "Serah terima", |
||||||
|
"resolution": "Resolusi", |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
class SupportOptionsOut(BaseModel): |
||||||
|
status: list[str] |
||||||
|
status_labels: dict[str, str] |
||||||
|
priority: list[str] |
||||||
|
priority_labels: dict[str, str] |
||||||
|
category: list[str] |
||||||
|
category_labels: dict[str, str] |
||||||
|
event_type: list[str] |
||||||
|
event_type_labels: dict[str, str] |
||||||
|
device_status: list[str] |
||||||
|
attachment_kind: list[str] |
||||||
|
attachment_kind_labels: dict[str, str] |
||||||
|
|
||||||
|
|
||||||
|
class SupportEventOut(BaseModel): |
||||||
|
id: str |
||||||
|
case_id: str |
||||||
|
event_type: str |
||||||
|
event_type_label: str |
||||||
|
summary: str |
||||||
|
detail: str | None = None |
||||||
|
old_status: str | None = None |
||||||
|
new_status: str | None = None |
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict) |
||||||
|
actor: str |
||||||
|
actor_display_name: str | None = None |
||||||
|
created_at: datetime |
||||||
|
|
||||||
|
|
||||||
|
class SupportCaseOut(BaseModel): |
||||||
|
id: str |
||||||
|
asset_id: str |
||||||
|
asset_hostname: str | None = None |
||||||
|
asset_display_name: str | None = None |
||||||
|
case_number: str |
||||||
|
status: str |
||||||
|
status_label: str |
||||||
|
priority: str |
||||||
|
priority_label: str |
||||||
|
category: str |
||||||
|
category_label: str |
||||||
|
title: str |
||||||
|
symptom: str |
||||||
|
resolution: str | None = None |
||||||
|
root_cause: str | None = None |
||||||
|
reported_by: str |
||||||
|
reported_by_name: str | None = None |
||||||
|
assigned_to: str | None = None |
||||||
|
assigned_to_name: str | None = None |
||||||
|
device_status_at_open: str | None = None |
||||||
|
device_status_applied: str | None = None |
||||||
|
opened_at: datetime |
||||||
|
resolved_at: datetime | None = None |
||||||
|
closed_at: datetime | None = None |
||||||
|
created_at: datetime |
||||||
|
updated_at: datetime |
||||||
|
events: list[SupportEventOut] = Field(default_factory=list) |
||||||
|
attachments: list[AttachmentOut] = Field(default_factory=list) |
||||||
|
|
||||||
|
|
||||||
|
class SupportCaseListItem(BaseModel): |
||||||
|
id: str |
||||||
|
asset_id: str |
||||||
|
asset_hostname: str | None = None |
||||||
|
asset_display_name: str | None = None |
||||||
|
case_number: str |
||||||
|
status: str |
||||||
|
status_label: str |
||||||
|
priority: str |
||||||
|
priority_label: str |
||||||
|
category: str |
||||||
|
category_label: str |
||||||
|
title: str |
||||||
|
reported_by: str |
||||||
|
reported_by_name: str | None = None |
||||||
|
opened_at: datetime |
||||||
|
resolved_at: datetime | None = None |
||||||
|
event_count: int = 0 |
||||||
|
|
||||||
|
|
||||||
|
class PaginatedSupportCases(BaseModel): |
||||||
|
items: list[SupportCaseListItem] |
||||||
|
total: int |
||||||
|
page: int |
||||||
|
limit: int |
||||||
|
pages: int |
||||||
|
|
||||||
|
|
||||||
|
class SupportStatsOut(BaseModel): |
||||||
|
total: int |
||||||
|
active: int |
||||||
|
open: int |
||||||
|
in_progress: int |
||||||
|
waiting: int |
||||||
|
resolved: int |
||||||
|
closed: int |
||||||
|
cancelled: int |
||||||
|
critical: int |
||||||
|
|
||||||
|
|
||||||
|
class SupportCaseCreateIn(BaseModel): |
||||||
|
category: Literal[ |
||||||
|
"power_no_boot", |
||||||
|
"boot_os_error", |
||||||
|
"hardware_failure", |
||||||
|
"component_replacement", |
||||||
|
"repair", |
||||||
|
"preventive_maintenance", |
||||||
|
"software_issue", |
||||||
|
"network_connectivity", |
||||||
|
"performance", |
||||||
|
"data_backup_recovery", |
||||||
|
"security_incident", |
||||||
|
"user_request", |
||||||
|
"decommission", |
||||||
|
"other", |
||||||
|
] |
||||||
|
priority: Literal["low", "normal", "high", "critical"] = "normal" |
||||||
|
title: str = Field(..., min_length=3, max_length=200) |
||||||
|
symptom: str = Field(..., min_length=5, max_length=8000) |
||||||
|
reported_by: str = Field(..., min_length=2, max_length=100) |
||||||
|
reported_by_name: str | None = Field(None, max_length=200) |
||||||
|
assigned_to: str | None = Field(None, max_length=100) |
||||||
|
assigned_to_name: str | None = Field(None, max_length=200) |
||||||
|
apply_device_status: str | None = Field(None, max_length=30) |
||||||
|
initial_note: str | None = Field(None, max_length=4000) |
||||||
|
|
||||||
|
|
||||||
|
class SupportCasePatchIn(BaseModel): |
||||||
|
status: Literal[ |
||||||
|
"open", |
||||||
|
"in_progress", |
||||||
|
"waiting_parts", |
||||||
|
"waiting_user", |
||||||
|
"resolved", |
||||||
|
"closed", |
||||||
|
"cancelled", |
||||||
|
] | None = None |
||||||
|
priority: Literal["low", "normal", "high", "critical"] | None = None |
||||||
|
assigned_to: str | None = Field(None, max_length=100) |
||||||
|
assigned_to_name: str | None = Field(None, max_length=200) |
||||||
|
resolution: str | None = Field(None, max_length=8000) |
||||||
|
root_cause: str | None = Field(None, max_length=4000) |
||||||
|
apply_device_status: str | None = Field(None, max_length=30) |
||||||
|
note: str | None = Field(None, max_length=4000) |
||||||
|
|
||||||
|
|
||||||
|
class SupportEventCreateIn(BaseModel): |
||||||
|
event_type: Literal["note", "action", "parts", "handover", "resolution"] = "note" |
||||||
|
summary: str = Field(..., min_length=3, max_length=500) |
||||||
|
detail: str | None = Field(None, max_length=8000) |
||||||
|
actor: str = Field(..., min_length=2, max_length=100) |
||||||
|
actor_display_name: str | None = Field(None, max_length=200) |
||||||
|
parts: list[str] | None = None |
||||||
@ -0,0 +1,451 @@ |
|||||||
|
"""Diff tracked asset fields and persist audit events (only when values change).""" |
||||||
|
|
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import math |
||||||
|
import uuid |
||||||
|
from datetime import datetime |
||||||
|
from typing import Any |
||||||
|
|
||||||
|
from sqlalchemy import func, select |
||||||
|
from sqlalchemy.orm import Session |
||||||
|
|
||||||
|
from app.db.models import Asset, AssetChangeEvent, AssetSnapshot, AssetUserAssignment, User |
||||||
|
from app.schemas.agent_report import AgentReportRequest |
||||||
|
from app.schemas.asset_changes import AssetChangeEventOut, PaginatedAssetChanges |
||||||
|
|
||||||
|
TRACKED_FIELDS: dict[str, str] = { |
||||||
|
"hostname": "Hostname", |
||||||
|
"display_name": "Display name", |
||||||
|
"owner": "Owner", |
||||||
|
"agent_version": "Agent version", |
||||||
|
"primary_mac": "MAC (primary)", |
||||||
|
"network": "Network", |
||||||
|
"software": "Software", |
||||||
|
"device_status": "Device status", |
||||||
|
"asset_for": "Asset for (kepemilikan)", |
||||||
|
"asset_on": "Asset on (lokasi)", |
||||||
|
"notes": "Catatan", |
||||||
|
"os": "OS", |
||||||
|
"cpu": "CPU", |
||||||
|
"ram_gb": "RAM", |
||||||
|
"bios": "BIOS", |
||||||
|
"gpu": "Display adapter (GPU)", |
||||||
|
"physical_disks": "Physical disks", |
||||||
|
"volumes": "Volumes (partisi)", |
||||||
|
"manufacturer": "Manufacturer", |
||||||
|
"model": "Model", |
||||||
|
"serial_number": "Serial number", |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: Any) -> str | None: |
||||||
|
if value is None: |
||||||
|
return None |
||||||
|
text = str(value).strip() |
||||||
|
return text if text else None |
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ram(value: Any) -> str | None: |
||||||
|
if value is None: |
||||||
|
return None |
||||||
|
try: |
||||||
|
return f"{float(value):g} GB" |
||||||
|
except (TypeError, ValueError): |
||||||
|
return _norm(value) |
||||||
|
|
||||||
|
|
||||||
|
def _fmt_gpu(gpu: dict | None) -> str | None: |
||||||
|
if not gpu: |
||||||
|
return None |
||||||
|
name = _norm(gpu.get("name")) |
||||||
|
if not name: |
||||||
|
return None |
||||||
|
vram = gpu.get("dedicated_memory_mb") |
||||||
|
if vram: |
||||||
|
return f"{name} ({vram} MB)" |
||||||
|
return name |
||||||
|
|
||||||
|
|
||||||
|
def _serialize_network(items: list[dict] | None) -> str | None: |
||||||
|
if not items: |
||||||
|
return None |
||||||
|
lines: list[str] = [] |
||||||
|
for item in items: |
||||||
|
mac = _norm(item.get("mac_address")) or "-" |
||||||
|
ip = _norm(item.get("ip_address")) or "-" |
||||||
|
name = _norm(item.get("adapter_name")) or "-" |
||||||
|
kind = _norm(item.get("adapter_kind")) or "-" |
||||||
|
primary = " *" if item.get("is_primary") else "" |
||||||
|
lines.append(f"{name} [{kind}] {mac} {ip}{primary}".strip()) |
||||||
|
return " | ".join(sorted(lines)) if lines else None |
||||||
|
|
||||||
|
|
||||||
|
def _serialize_volumes(items: list[dict] | None) -> str | None: |
||||||
|
if not items: |
||||||
|
return None |
||||||
|
lines: list[str] = [] |
||||||
|
for item in items: |
||||||
|
letter = _norm(item.get("drive_letter")) or "?" |
||||||
|
total = item.get("total_gb") |
||||||
|
free = item.get("free_gb") |
||||||
|
fs = _norm(item.get("file_system")) or "-" |
||||||
|
model = _norm(item.get("model")) |
||||||
|
chunk = f"{letter} {total}GB free {free}GB {fs}" |
||||||
|
if model: |
||||||
|
chunk = f"{chunk} ({model})" |
||||||
|
lines.append(chunk) |
||||||
|
return " | ".join(sorted(lines)) if lines else None |
||||||
|
|
||||||
|
|
||||||
|
def _serialize_physical_disks(items: list[dict] | None) -> str | None: |
||||||
|
if not items: |
||||||
|
return None |
||||||
|
lines: list[str] = [] |
||||||
|
for item in items: |
||||||
|
label = _norm(item.get("friendly_name")) or _norm(item.get("model")) or _norm(item.get("device_id")) or "disk" |
||||||
|
size = item.get("total_gb") |
||||||
|
media = _norm(item.get("media_type")) or "-" |
||||||
|
health = _norm(item.get("health_status")) or _norm(item.get("operational_status")) or "-" |
||||||
|
serial = _norm(item.get("serial_number")) |
||||||
|
chunk = f"{label} {size}GB {media} {health}" |
||||||
|
if serial: |
||||||
|
chunk = f"{chunk} SN:{serial}" |
||||||
|
lines.append(chunk) |
||||||
|
return " | ".join(sorted(lines)) if lines else None |
||||||
|
|
||||||
|
|
||||||
|
def _software_keys(items: list[dict] | None) -> set[str]: |
||||||
|
keys: set[str] = set() |
||||||
|
if not items: |
||||||
|
return keys |
||||||
|
for item in items: |
||||||
|
name = _norm(item.get("name")) |
||||||
|
if not name: |
||||||
|
continue |
||||||
|
version = _norm(item.get("version")) or "" |
||||||
|
keys.add(f"{name}|{version}") |
||||||
|
return keys |
||||||
|
|
||||||
|
|
||||||
|
def _format_software_change(old_keys: set[str], new_keys: set[str]) -> tuple[str | None, str | None, dict[str, Any]]: |
||||||
|
if old_keys == new_keys: |
||||||
|
return None, None, {} |
||||||
|
|
||||||
|
added = sorted(new_keys - old_keys) |
||||||
|
removed = sorted(old_keys - new_keys) |
||||||
|
|
||||||
|
def _pretty(keys: list[str], limit: int = 8) -> list[str]: |
||||||
|
out: list[str] = [] |
||||||
|
for key in keys[:limit]: |
||||||
|
name, _, version = key.partition("|") |
||||||
|
out.append(f"{name} {version}".strip() if version else name) |
||||||
|
return out |
||||||
|
|
||||||
|
old_summary = f"{len(old_keys)} packages" if old_keys else "—" |
||||||
|
new_summary = f"{len(new_keys)} packages" |
||||||
|
notes: list[str] = [] |
||||||
|
if added: |
||||||
|
notes.append(f"+{len(added)}: {', '.join(_pretty(added))}" + ("…" if len(added) > 8 else "")) |
||||||
|
if removed: |
||||||
|
notes.append(f"−{len(removed)}: {', '.join(_pretty(removed))}" + ("…" if len(removed) > 8 else "")) |
||||||
|
if notes: |
||||||
|
new_summary = f"{new_summary} ({'; '.join(notes)})" |
||||||
|
|
||||||
|
detail: dict[str, Any] = {} |
||||||
|
if added: |
||||||
|
detail["added"] = _pretty(added, 50) |
||||||
|
if removed: |
||||||
|
detail["removed"] = _pretty(removed, 50) |
||||||
|
return old_summary, new_summary, detail |
||||||
|
|
||||||
|
|
||||||
|
def get_primary_owner_label(db: Session, asset_id: uuid.UUID) -> str | None: |
||||||
|
row = db.execute( |
||||||
|
select(User.username, User.display_name) |
||||||
|
.join(AssetUserAssignment, AssetUserAssignment.user_id == User.id) |
||||||
|
.where( |
||||||
|
AssetUserAssignment.asset_id == asset_id, |
||||||
|
AssetUserAssignment.is_primary.is_(True), |
||||||
|
AssetUserAssignment.ended_at.is_(None), |
||||||
|
) |
||||||
|
.limit(1) |
||||||
|
).first() |
||||||
|
if not row: |
||||||
|
return None |
||||||
|
username, display_name = row |
||||||
|
if display_name: |
||||||
|
return f"{display_name} ({username})" |
||||||
|
return username |
||||||
|
|
||||||
|
|
||||||
|
def build_tracked_state_from_report( |
||||||
|
report: AgentReportRequest, |
||||||
|
asset: Asset, |
||||||
|
owner_label: str | None, |
||||||
|
) -> dict[str, str | None]: |
||||||
|
payload = report.model_dump(mode="json") |
||||||
|
system = payload.get("system") or {} |
||||||
|
cpu = payload.get("cpu") or {} |
||||||
|
gpus = payload.get("gpus") or [] |
||||||
|
|
||||||
|
os_parts = [_norm(system.get("os_name")), _norm(system.get("os_version") or system.get("os_build"))] |
||||||
|
os_text = " ".join(p for p in os_parts if p) or None |
||||||
|
|
||||||
|
primary_mac = None |
||||||
|
if report.asset_identity: |
||||||
|
primary_mac = _norm(report.asset_identity.primary_physical_mac) |
||||||
|
if not primary_mac and report.network: |
||||||
|
for nic in report.network: |
||||||
|
if nic.is_primary and nic.mac_address: |
||||||
|
primary_mac = _norm(nic.mac_address) |
||||||
|
break |
||||||
|
|
||||||
|
gpu_primary = gpus[0] if gpus else None |
||||||
|
if not gpu_primary and system.get("gpu_model"): |
||||||
|
gpu_primary = {"name": system.get("gpu_model")} |
||||||
|
|
||||||
|
return { |
||||||
|
"hostname": _norm(asset.hostname or report.hostname), |
||||||
|
"display_name": _norm(asset.display_name), |
||||||
|
"owner": _norm(owner_label), |
||||||
|
"agent_version": _norm(report.agent_version), |
||||||
|
"primary_mac": primary_mac, |
||||||
|
"network": _serialize_network([n.model_dump(mode="json") for n in report.network]), |
||||||
|
"software": f"{len(report.software)} packages" if report.software else "0 packages", |
||||||
|
"device_status": _norm(asset.device_status), |
||||||
|
"asset_for": _norm(asset.asset_for), |
||||||
|
"asset_on": _norm(asset.asset_on), |
||||||
|
"notes": _norm(asset.notes), |
||||||
|
"os": os_text, |
||||||
|
"cpu": _norm(cpu.get("model") or system.get("cpu_model")), |
||||||
|
"ram_gb": _fmt_ram(system.get("ram_gb")), |
||||||
|
"bios": _norm(system.get("bios_version")), |
||||||
|
"gpu": _fmt_gpu(gpu_primary), |
||||||
|
"physical_disks": _serialize_physical_disks(payload.get("physical_disks")), |
||||||
|
"volumes": _serialize_volumes([d.model_dump(mode="json") for d in report.disks]), |
||||||
|
"manufacturer": _norm(system.get("manufacturer") or asset.manufacturer), |
||||||
|
"model": _norm(system.get("model") or asset.model), |
||||||
|
"serial_number": _norm(report.serial_number or asset.serial_number), |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
def build_tracked_state_from_snapshot( |
||||||
|
snapshot: AssetSnapshot, |
||||||
|
asset: Asset, |
||||||
|
owner_label: str | None, |
||||||
|
) -> dict[str, str | None]: |
||||||
|
payload = snapshot.payload or {} |
||||||
|
system = payload.get("system") or {} |
||||||
|
cpu = payload.get("cpu") or {} |
||||||
|
gpus = payload.get("gpus") or [] |
||||||
|
network = payload.get("network") or [] |
||||||
|
disks = payload.get("disks") or [] |
||||||
|
software = payload.get("software") or [] |
||||||
|
|
||||||
|
os_parts = [_norm(snapshot.os_name or system.get("os_name")), _norm(snapshot.os_version or system.get("os_version"))] |
||||||
|
os_text = " ".join(p for p in os_parts if p) or None |
||||||
|
|
||||||
|
primary_mac = _norm(asset.primary_physical_mac) |
||||||
|
if not primary_mac: |
||||||
|
identity = payload.get("asset_identity") or {} |
||||||
|
primary_mac = _norm(identity.get("primary_physical_mac")) |
||||||
|
|
||||||
|
gpu_primary = gpus[0] if gpus else None |
||||||
|
if not gpu_primary and (snapshot.gpu_model or system.get("gpu_model")): |
||||||
|
gpu_primary = {"name": snapshot.gpu_model or system.get("gpu_model")} |
||||||
|
|
||||||
|
return { |
||||||
|
"hostname": _norm(asset.hostname), |
||||||
|
"display_name": _norm(asset.display_name), |
||||||
|
"owner": _norm(owner_label), |
||||||
|
"agent_version": _norm(snapshot.agent_version), |
||||||
|
"primary_mac": primary_mac, |
||||||
|
"network": _serialize_network(network if isinstance(network, list) else []), |
||||||
|
"software": f"{len(software)} packages" if software else "0 packages", |
||||||
|
"device_status": _norm(asset.device_status), |
||||||
|
"asset_for": _norm(asset.asset_for), |
||||||
|
"asset_on": _norm(asset.asset_on), |
||||||
|
"notes": _norm(asset.notes), |
||||||
|
"os": os_text, |
||||||
|
"cpu": _norm(cpu.get("model") or snapshot.cpu_model or system.get("cpu_model")), |
||||||
|
"ram_gb": _fmt_ram(snapshot.ram_gb if snapshot.ram_gb is not None else system.get("ram_gb")), |
||||||
|
"bios": _norm(snapshot.bios_version or system.get("bios_version")), |
||||||
|
"gpu": _fmt_gpu(gpu_primary if isinstance(gpu_primary, dict) else None), |
||||||
|
"physical_disks": _serialize_physical_disks(payload.get("physical_disks")), |
||||||
|
"volumes": _serialize_volumes(disks if isinstance(disks, list) else []), |
||||||
|
"manufacturer": _norm(asset.manufacturer or system.get("manufacturer")), |
||||||
|
"model": _norm(asset.model or system.get("model")), |
||||||
|
"serial_number": _norm(asset.serial_number), |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
def build_tracked_state_from_asset( |
||||||
|
asset: Asset, |
||||||
|
owner_label: str | None, |
||||||
|
) -> dict[str, str | None]: |
||||||
|
return { |
||||||
|
"hostname": _norm(asset.hostname), |
||||||
|
"display_name": _norm(asset.display_name), |
||||||
|
"owner": _norm(owner_label), |
||||||
|
"agent_version": None, |
||||||
|
"primary_mac": _norm(asset.primary_physical_mac), |
||||||
|
"network": None, |
||||||
|
"software": None, |
||||||
|
"device_status": _norm(asset.device_status), |
||||||
|
"asset_for": _norm(asset.asset_for), |
||||||
|
"asset_on": _norm(asset.asset_on), |
||||||
|
"notes": _norm(asset.notes), |
||||||
|
"os": None, |
||||||
|
"cpu": None, |
||||||
|
"ram_gb": None, |
||||||
|
"bios": None, |
||||||
|
"gpu": None, |
||||||
|
"physical_disks": None, |
||||||
|
"volumes": None, |
||||||
|
"manufacturer": _norm(asset.manufacturer), |
||||||
|
"model": _norm(asset.model), |
||||||
|
"serial_number": _norm(asset.serial_number), |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
def _change_type(old: str | None, new: str | None) -> str: |
||||||
|
if not old and new: |
||||||
|
return "added" |
||||||
|
if old and not new: |
||||||
|
return "removed" |
||||||
|
return "updated" |
||||||
|
|
||||||
|
|
||||||
|
def record_state_changes( |
||||||
|
db: Session, |
||||||
|
*, |
||||||
|
asset_id: uuid.UUID, |
||||||
|
old_state: dict[str, str | None], |
||||||
|
new_state: dict[str, str | None], |
||||||
|
source: str, |
||||||
|
snapshot_id: uuid.UUID | None = None, |
||||||
|
event_at: datetime | None = None, |
||||||
|
old_software_keys: set[str] | None = None, |
||||||
|
new_software_keys: set[str] | None = None, |
||||||
|
) -> list[AssetChangeEvent]: |
||||||
|
when = event_at or datetime.now().astimezone() |
||||||
|
events: list[AssetChangeEvent] = [] |
||||||
|
|
||||||
|
for field_key, field_label in TRACKED_FIELDS.items(): |
||||||
|
old_val = old_state.get(field_key) |
||||||
|
new_val = new_state.get(field_key) |
||||||
|
detail: dict[str, Any] = {} |
||||||
|
|
||||||
|
if field_key == "software" and old_software_keys is not None and new_software_keys is not None: |
||||||
|
old_fmt, new_fmt, detail = _format_software_change(old_software_keys, new_software_keys) |
||||||
|
if old_fmt is None and new_fmt is None: |
||||||
|
continue |
||||||
|
old_val, new_val = old_fmt, new_fmt |
||||||
|
elif old_val == new_val: |
||||||
|
continue |
||||||
|
|
||||||
|
events.append( |
||||||
|
AssetChangeEvent( |
||||||
|
asset_id=asset_id, |
||||||
|
snapshot_id=snapshot_id, |
||||||
|
event_at=when, |
||||||
|
source=source, |
||||||
|
field_key=field_key, |
||||||
|
field_label=field_label, |
||||||
|
change_type=_change_type(old_val, new_val), |
||||||
|
old_value=old_val, |
||||||
|
new_value=new_val, |
||||||
|
detail=detail, |
||||||
|
) |
||||||
|
) |
||||||
|
|
||||||
|
if events: |
||||||
|
db.add_all(events) |
||||||
|
return events |
||||||
|
|
||||||
|
|
||||||
|
def record_agent_report_changes( |
||||||
|
db: Session, |
||||||
|
*, |
||||||
|
asset: Asset, |
||||||
|
report: AgentReportRequest, |
||||||
|
snapshot: AssetSnapshot, |
||||||
|
previous_snapshot: AssetSnapshot | None, |
||||||
|
owner_before: str | None = None, |
||||||
|
) -> list[AssetChangeEvent]: |
||||||
|
if previous_snapshot is None: |
||||||
|
return [] |
||||||
|
|
||||||
|
owner_after = get_primary_owner_label(db, asset.id) |
||||||
|
new_state = build_tracked_state_from_report(report, asset, owner_after) |
||||||
|
new_sw = _software_keys([s.model_dump(mode="json") for s in report.software]) |
||||||
|
|
||||||
|
old_state = build_tracked_state_from_snapshot(previous_snapshot, asset, owner_before) |
||||||
|
old_payload = previous_snapshot.payload or {} |
||||||
|
software = old_payload.get("software") or [] |
||||||
|
old_sw = _software_keys(software if isinstance(software, list) else []) |
||||||
|
|
||||||
|
return record_state_changes( |
||||||
|
db, |
||||||
|
asset_id=asset.id, |
||||||
|
old_state=old_state, |
||||||
|
new_state=new_state, |
||||||
|
source="agent", |
||||||
|
snapshot_id=snapshot.id, |
||||||
|
event_at=snapshot.collected_at, |
||||||
|
old_software_keys=old_sw, |
||||||
|
new_software_keys=new_sw, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def record_manual_asset_changes( |
||||||
|
db: Session, |
||||||
|
*, |
||||||
|
asset_id: uuid.UUID, |
||||||
|
old_state: dict[str, str | None], |
||||||
|
new_state: dict[str, str | None], |
||||||
|
) -> list[AssetChangeEvent]: |
||||||
|
tracked_keys = {"display_name", "device_status", "asset_for", "asset_on", "notes", "owner"} |
||||||
|
return record_state_changes( |
||||||
|
db, |
||||||
|
asset_id=asset_id, |
||||||
|
old_state={k: old_state.get(k) for k in tracked_keys}, |
||||||
|
new_state={k: new_state.get(k) for k in tracked_keys}, |
||||||
|
source="manual", |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def list_asset_changes( |
||||||
|
db: Session, |
||||||
|
asset_id: uuid.UUID, |
||||||
|
*, |
||||||
|
page: int = 1, |
||||||
|
limit: int = 50, |
||||||
|
) -> PaginatedAssetChanges | None: |
||||||
|
if not db.scalar(select(Asset.id).where(Asset.id == asset_id, Asset.deleted_at.is_(None))): |
||||||
|
return None |
||||||
|
|
||||||
|
base = select(AssetChangeEvent).where(AssetChangeEvent.asset_id == asset_id) |
||||||
|
total = db.scalar(select(func.count()).select_from(base.subquery())) or 0 |
||||||
|
offset = (page - 1) * limit |
||||||
|
rows = db.scalars(base.order_by(AssetChangeEvent.event_at.desc()).offset(offset).limit(limit)).all() |
||||||
|
|
||||||
|
items = [ |
||||||
|
AssetChangeEventOut( |
||||||
|
id=str(row.id), |
||||||
|
asset_id=str(row.asset_id), |
||||||
|
snapshot_id=str(row.snapshot_id) if row.snapshot_id else None, |
||||||
|
event_at=row.event_at, |
||||||
|
source=row.source, |
||||||
|
field_key=row.field_key, |
||||||
|
field_label=row.field_label, |
||||||
|
change_type=row.change_type, |
||||||
|
old_value=row.old_value, |
||||||
|
new_value=row.new_value, |
||||||
|
detail=row.detail or {}, |
||||||
|
) |
||||||
|
for row in rows |
||||||
|
] |
||||||
|
pages = max(1, math.ceil(total / limit)) if total else 0 |
||||||
|
return PaginatedAssetChanges(items=items, total=total, page=page, limit=limit, pages=pages) |
||||||
@ -0,0 +1,106 @@ |
|||||||
|
"""Binary storage for document attachments — local dev or QNAP S3 (ADR-003).""" |
||||||
|
|
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import hashlib |
||||||
|
import re |
||||||
|
import uuid |
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
from app.config import Settings, get_settings |
||||||
|
|
||||||
|
_FILENAME_SAFE = re.compile(r"[^a-zA-Z0-9._-]+") |
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str: |
||||||
|
base = Path(name).name.strip() or "file" |
||||||
|
base = _FILENAME_SAFE.sub("_", base) |
||||||
|
return base[:200] or "file" |
||||||
|
|
||||||
|
|
||||||
|
def build_support_object_key(case_id: uuid.UUID, attachment_id: uuid.UUID, filename: str) -> str: |
||||||
|
from datetime import datetime, timezone |
||||||
|
|
||||||
|
now = datetime.now(timezone.utc) |
||||||
|
safe = sanitize_filename(filename) |
||||||
|
return f"support/{now:%Y}/{now:%m}/{case_id}/{attachment_id}/{safe}" |
||||||
|
|
||||||
|
|
||||||
|
def sha256_hex(data: bytes) -> str: |
||||||
|
return hashlib.sha256(data).hexdigest() |
||||||
|
|
||||||
|
|
||||||
|
class DocumentStorage: |
||||||
|
def __init__(self, settings: Settings | None = None) -> None: |
||||||
|
self.settings = settings or get_settings() |
||||||
|
self._s3 = None |
||||||
|
|
||||||
|
@property |
||||||
|
def provider(self) -> str: |
||||||
|
return "qnap_s3" if self.settings.documents_use_s3() else "local" |
||||||
|
|
||||||
|
@property |
||||||
|
def bucket(self) -> str | None: |
||||||
|
if self.provider == "qnap_s3": |
||||||
|
return self.settings.s3_bucket |
||||||
|
return None |
||||||
|
|
||||||
|
def _local_root(self) -> Path: |
||||||
|
root = Path(self.settings.documents_local_path).resolve() |
||||||
|
root.mkdir(parents=True, exist_ok=True) |
||||||
|
return root |
||||||
|
|
||||||
|
def _s3_client(self): |
||||||
|
if self._s3 is not None: |
||||||
|
return self._s3 |
||||||
|
try: |
||||||
|
import boto3 |
||||||
|
from botocore.client import Config |
||||||
|
except ImportError as exc: |
||||||
|
raise RuntimeError("boto3 required for S3 storage — pip install geonetagent-collector[s3]") from exc |
||||||
|
self._s3 = boto3.client( |
||||||
|
"s3", |
||||||
|
endpoint_url=self.settings.s3_endpoint, |
||||||
|
aws_access_key_id=self.settings.s3_access_key_id, |
||||||
|
aws_secret_access_key=self.settings.s3_secret_access_key, |
||||||
|
region_name=self.settings.s3_region, |
||||||
|
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}), |
||||||
|
) |
||||||
|
return self._s3 |
||||||
|
|
||||||
|
def put(self, object_key: str, data: bytes, content_type: str | None) -> None: |
||||||
|
if self.provider == "local": |
||||||
|
path = self._local_root() / object_key |
||||||
|
path.parent.mkdir(parents=True, exist_ok=True) |
||||||
|
path.write_bytes(data) |
||||||
|
return |
||||||
|
client = self._s3_client() |
||||||
|
extra = {"ContentType": content_type} if content_type else {} |
||||||
|
client.put_object(Bucket=self.settings.s3_bucket, Key=object_key, Body=data, **extra) |
||||||
|
|
||||||
|
def get_bytes(self, object_key: str) -> bytes: |
||||||
|
if self.provider == "local": |
||||||
|
path = self._local_root() / object_key |
||||||
|
return path.read_bytes() |
||||||
|
client = self._s3_client() |
||||||
|
resp = client.get_object(Bucket=self.settings.s3_bucket, Key=object_key) |
||||||
|
return resp["Body"].read() |
||||||
|
|
||||||
|
def delete_object(self, object_key: str) -> None: |
||||||
|
if self.provider == "local": |
||||||
|
path = self._local_root() / object_key |
||||||
|
if path.is_file(): |
||||||
|
path.unlink() |
||||||
|
return |
||||||
|
client = self._s3_client() |
||||||
|
client.delete_object(Bucket=self.settings.s3_bucket, Key=object_key) |
||||||
|
|
||||||
|
def presigned_get_url(self, object_key: str) -> str | None: |
||||||
|
if self.provider == "local": |
||||||
|
return None |
||||||
|
client = self._s3_client() |
||||||
|
return client.generate_presigned_url( |
||||||
|
"get_object", |
||||||
|
Params={"Bucket": self.settings.s3_bucket, "Key": object_key}, |
||||||
|
ExpiresIn=self.settings.s3_presign_expires_seconds, |
||||||
|
) |
||||||
@ -0,0 +1,202 @@ |
|||||||
|
"""Support case photo/document attachments.""" |
||||||
|
|
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import uuid |
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
from sqlalchemy import select |
||||||
|
from sqlalchemy.orm import Session |
||||||
|
|
||||||
|
from app.db.models import AssetSupportCase, DocumentAttachment |
||||||
|
from app.schemas.attachments import ( |
||||||
|
ALLOWED_ATTACHMENT_CONTENT_TYPES, |
||||||
|
ALLOWED_ATTACHMENT_EXTENSIONS, |
||||||
|
SUPPORT_ATTACHMENT_KIND_LABELS, |
||||||
|
AttachmentListOut, |
||||||
|
AttachmentOut, |
||||||
|
AttachmentUploadForm, |
||||||
|
) |
||||||
|
from app.services import support_service |
||||||
|
from app.services.document_storage_service import ( |
||||||
|
DocumentStorage, |
||||||
|
build_support_object_key, |
||||||
|
sanitize_filename, |
||||||
|
sha256_hex, |
||||||
|
) |
||||||
|
from app.config import get_settings |
||||||
|
|
||||||
|
|
||||||
|
def _is_image(content_type: str | None) -> bool: |
||||||
|
return bool(content_type and content_type.startswith("image/")) |
||||||
|
|
||||||
|
|
||||||
|
def _attachment_download_path(attachment_id: uuid.UUID) -> str: |
||||||
|
return f"/api/v1/attachments/{attachment_id}/download" |
||||||
|
|
||||||
|
|
||||||
|
def _to_out(row: DocumentAttachment) -> AttachmentOut: |
||||||
|
ct = row.content_type or "" |
||||||
|
return AttachmentOut( |
||||||
|
id=str(row.id), |
||||||
|
domain=row.domain, |
||||||
|
kind=row.kind, |
||||||
|
kind_label=SUPPORT_ATTACHMENT_KIND_LABELS.get(row.kind, row.kind), |
||||||
|
asset_id=str(row.asset_id), |
||||||
|
support_case_id=str(row.support_case_id), |
||||||
|
original_filename=row.original_filename, |
||||||
|
content_type=row.content_type, |
||||||
|
file_size_bytes=row.file_size_bytes, |
||||||
|
caption=row.caption, |
||||||
|
uploaded_by=row.uploaded_by, |
||||||
|
uploaded_by_name=row.uploaded_by_name, |
||||||
|
uploaded_at=row.uploaded_at, |
||||||
|
download_url=_attachment_download_path(row.id), |
||||||
|
is_image=_is_image(ct), |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def _validate_upload(filename: str, content_type: str | None, size: int) -> None: |
||||||
|
settings = get_settings() |
||||||
|
if size <= 0: |
||||||
|
raise ValueError("File kosong") |
||||||
|
if size > settings.documents_max_upload_bytes: |
||||||
|
max_mb = settings.documents_max_upload_bytes // (1024 * 1024) |
||||||
|
raise ValueError(f"File terlalu besar (maks {max_mb} MB)") |
||||||
|
|
||||||
|
ext = Path(filename).suffix.lower() |
||||||
|
if ext not in ALLOWED_ATTACHMENT_EXTENSIONS: |
||||||
|
raise ValueError(f"Ekstensi tidak diizinkan: {ext or '(tanpa ekstensi)'}") |
||||||
|
|
||||||
|
ct = (content_type or "").split(";")[0].strip().lower() |
||||||
|
if ct and ct not in ALLOWED_ATTACHMENT_CONTENT_TYPES: |
||||||
|
raise ValueError(f"Tipe file tidak diizinkan: {ct}") |
||||||
|
|
||||||
|
|
||||||
|
def list_case_attachments(db: Session, case_id: uuid.UUID) -> AttachmentListOut | None: |
||||||
|
if not db.scalar(select(AssetSupportCase.id).where(AssetSupportCase.id == case_id)): |
||||||
|
return None |
||||||
|
rows = db.scalars( |
||||||
|
select(DocumentAttachment) |
||||||
|
.where( |
||||||
|
DocumentAttachment.support_case_id == case_id, |
||||||
|
DocumentAttachment.deleted_at.is_(None), |
||||||
|
) |
||||||
|
.order_by(DocumentAttachment.uploaded_at.desc()) |
||||||
|
).all() |
||||||
|
items = [_to_out(r) for r in rows] |
||||||
|
return AttachmentListOut(items=items, total=len(items)) |
||||||
|
|
||||||
|
|
||||||
|
def upload_support_attachment( |
||||||
|
db: Session, |
||||||
|
case_id: uuid.UUID, |
||||||
|
*, |
||||||
|
filename: str, |
||||||
|
content_type: str | None, |
||||||
|
data: bytes, |
||||||
|
form: AttachmentUploadForm, |
||||||
|
) -> AttachmentOut | None: |
||||||
|
case = db.scalar(select(AssetSupportCase).where(AssetSupportCase.id == case_id)) |
||||||
|
if not case: |
||||||
|
return None |
||||||
|
|
||||||
|
_validate_upload(filename, content_type, len(data)) |
||||||
|
|
||||||
|
storage = DocumentStorage() |
||||||
|
attachment_id = uuid.uuid4() |
||||||
|
safe_name = sanitize_filename(filename) |
||||||
|
object_key = build_support_object_key(case_id, attachment_id, safe_name) |
||||||
|
checksum = sha256_hex(data) |
||||||
|
|
||||||
|
storage.put(object_key, data, content_type) |
||||||
|
|
||||||
|
row = DocumentAttachment( |
||||||
|
id=attachment_id, |
||||||
|
domain="support", |
||||||
|
kind=form.kind, |
||||||
|
asset_id=case.asset_id, |
||||||
|
support_case_id=case_id, |
||||||
|
storage_provider=storage.provider, |
||||||
|
bucket=storage.bucket, |
||||||
|
object_key=object_key, |
||||||
|
original_filename=safe_name, |
||||||
|
content_type=(content_type or "").split(";")[0].strip().lower() or None, |
||||||
|
file_size_bytes=len(data), |
||||||
|
checksum_sha256=checksum, |
||||||
|
uploaded_by=form.actor.strip(), |
||||||
|
uploaded_by_name=form.actor_display_name, |
||||||
|
caption=form.caption.strip() if form.caption else None, |
||||||
|
) |
||||||
|
db.add(row) |
||||||
|
db.flush() |
||||||
|
|
||||||
|
kind_label = SUPPORT_ATTACHMENT_KIND_LABELS.get(form.kind, form.kind) |
||||||
|
support_service.add_support_event_internal( |
||||||
|
db, |
||||||
|
case, |
||||||
|
event_type="action", |
||||||
|
summary=f"Lampiran: {kind_label} — {safe_name}", |
||||||
|
detail=form.caption, |
||||||
|
actor=form.actor.strip(), |
||||||
|
actor_display_name=form.actor_display_name, |
||||||
|
metadata={"attachment_id": str(attachment_id), "kind": form.kind}, |
||||||
|
) |
||||||
|
|
||||||
|
db.commit() |
||||||
|
db.refresh(row) |
||||||
|
return _to_out(row) |
||||||
|
|
||||||
|
|
||||||
|
def get_attachment_for_download(db: Session, attachment_id: uuid.UUID) -> tuple[DocumentAttachment, bytes] | None: |
||||||
|
row = db.scalar( |
||||||
|
select(DocumentAttachment).where( |
||||||
|
DocumentAttachment.id == attachment_id, |
||||||
|
DocumentAttachment.deleted_at.is_(None), |
||||||
|
) |
||||||
|
) |
||||||
|
if not row: |
||||||
|
return None |
||||||
|
storage = DocumentStorage() |
||||||
|
if storage.provider == "qnap_s3": |
||||||
|
url = storage.presigned_get_url(row.object_key) |
||||||
|
if url: |
||||||
|
return row, b"" # caller uses redirect |
||||||
|
data = storage.get_bytes(row.object_key) |
||||||
|
return row, data |
||||||
|
|
||||||
|
|
||||||
|
def get_presigned_url(db: Session, attachment_id: uuid.UUID) -> str | None: |
||||||
|
row = db.scalar( |
||||||
|
select(DocumentAttachment).where( |
||||||
|
DocumentAttachment.id == attachment_id, |
||||||
|
DocumentAttachment.deleted_at.is_(None), |
||||||
|
) |
||||||
|
) |
||||||
|
if not row: |
||||||
|
return None |
||||||
|
storage = DocumentStorage() |
||||||
|
if storage.provider != "qnap_s3": |
||||||
|
return None |
||||||
|
return storage.presigned_get_url(row.object_key) |
||||||
|
|
||||||
|
|
||||||
|
def soft_delete_attachment( |
||||||
|
db: Session, |
||||||
|
attachment_id: uuid.UUID, |
||||||
|
*, |
||||||
|
actor: str, |
||||||
|
) -> bool: |
||||||
|
row = db.scalar( |
||||||
|
select(DocumentAttachment).where( |
||||||
|
DocumentAttachment.id == attachment_id, |
||||||
|
DocumentAttachment.deleted_at.is_(None), |
||||||
|
) |
||||||
|
) |
||||||
|
if not row: |
||||||
|
return False |
||||||
|
from datetime import datetime, timezone |
||||||
|
|
||||||
|
row.deleted_at = datetime.now(timezone.utc) |
||||||
|
db.commit() |
||||||
|
return True |
||||||
@ -0,0 +1,586 @@ |
|||||||
|
"""IT Support troubleshooting cases — SSOT service (ADR-004).""" |
||||||
|
|
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import math |
||||||
|
import uuid |
||||||
|
from datetime import datetime, timezone |
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select |
||||||
|
from sqlalchemy.orm import Session |
||||||
|
|
||||||
|
from app.db.models import Asset, AssetSupportCase, AssetSupportEvent |
||||||
|
from app.schemas.assets import DEVICE_STATUS_OPTIONS |
||||||
|
from app.schemas.attachments import SUPPORT_ATTACHMENT_KIND, SUPPORT_ATTACHMENT_KIND_LABELS |
||||||
|
from app.schemas.support import ( |
||||||
|
SUPPORT_CATEGORY_LABELS, |
||||||
|
SUPPORT_EVENT_TYPE_LABELS, |
||||||
|
SUPPORT_PRIORITY_LABELS, |
||||||
|
SUPPORT_STATUS_LABELS, |
||||||
|
SupportCaseCreateIn, |
||||||
|
SupportCaseListItem, |
||||||
|
SupportCaseOut, |
||||||
|
SupportCasePatchIn, |
||||||
|
SupportEventCreateIn, |
||||||
|
SupportEventOut, |
||||||
|
SupportOptionsOut, |
||||||
|
SupportStatsOut, |
||||||
|
PaginatedSupportCases, |
||||||
|
) |
||||||
|
from app.services import asset_change_service |
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime: |
||||||
|
return datetime.now(timezone.utc) |
||||||
|
|
||||||
|
|
||||||
|
def _paginate(total: int, page: int, limit: int) -> dict[str, int]: |
||||||
|
pages = max(1, math.ceil(total / limit)) if total else 0 |
||||||
|
return {"total": total, "page": page, "limit": limit, "pages": pages} |
||||||
|
|
||||||
|
|
||||||
|
def get_support_options() -> SupportOptionsOut: |
||||||
|
return SupportOptionsOut( |
||||||
|
status=list(SUPPORT_STATUS_LABELS.keys()), |
||||||
|
status_labels=SUPPORT_STATUS_LABELS, |
||||||
|
priority=list(SUPPORT_PRIORITY_LABELS.keys()), |
||||||
|
priority_labels=SUPPORT_PRIORITY_LABELS, |
||||||
|
category=list(SUPPORT_CATEGORY_LABELS.keys()), |
||||||
|
category_labels=SUPPORT_CATEGORY_LABELS, |
||||||
|
event_type=[k for k in SUPPORT_EVENT_TYPE_LABELS if k != "opened"], |
||||||
|
event_type_labels=SUPPORT_EVENT_TYPE_LABELS, |
||||||
|
device_status=DEVICE_STATUS_OPTIONS, |
||||||
|
attachment_kind=list(SUPPORT_ATTACHMENT_KIND), |
||||||
|
attachment_kind_labels=SUPPORT_ATTACHMENT_KIND_LABELS, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def _next_case_number(db: Session) -> str: |
||||||
|
today = _utcnow().strftime("%Y%m%d") |
||||||
|
prefix = f"GNA-SUP-{today}-" |
||||||
|
count = db.scalar( |
||||||
|
select(func.count()) |
||||||
|
.select_from(AssetSupportCase) |
||||||
|
.where(AssetSupportCase.case_number.like(f"{prefix}%")) |
||||||
|
) or 0 |
||||||
|
return f"{prefix}{count + 1:04d}" |
||||||
|
|
||||||
|
|
||||||
|
def _event_to_out(row: AssetSupportEvent) -> SupportEventOut: |
||||||
|
return SupportEventOut( |
||||||
|
id=str(row.id), |
||||||
|
case_id=str(row.case_id), |
||||||
|
event_type=row.event_type, |
||||||
|
event_type_label=SUPPORT_EVENT_TYPE_LABELS.get(row.event_type, row.event_type), |
||||||
|
summary=row.summary, |
||||||
|
detail=row.detail, |
||||||
|
old_status=row.old_status, |
||||||
|
new_status=row.new_status, |
||||||
|
metadata=row.metadata_ or {}, |
||||||
|
actor=row.actor, |
||||||
|
actor_display_name=row.actor_display_name, |
||||||
|
created_at=row.created_at, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def _case_to_out( |
||||||
|
case: AssetSupportCase, |
||||||
|
events: list[AssetSupportEvent] | None = None, |
||||||
|
attachments: list | None = None, |
||||||
|
*, |
||||||
|
asset_hostname: str | None = None, |
||||||
|
asset_display_name: str | None = None, |
||||||
|
) -> SupportCaseOut: |
||||||
|
return SupportCaseOut( |
||||||
|
id=str(case.id), |
||||||
|
asset_id=str(case.asset_id), |
||||||
|
asset_hostname=asset_hostname, |
||||||
|
asset_display_name=asset_display_name, |
||||||
|
case_number=case.case_number, |
||||||
|
status=case.status, |
||||||
|
status_label=SUPPORT_STATUS_LABELS.get(case.status, case.status), |
||||||
|
priority=case.priority, |
||||||
|
priority_label=SUPPORT_PRIORITY_LABELS.get(case.priority, case.priority), |
||||||
|
category=case.category, |
||||||
|
category_label=SUPPORT_CATEGORY_LABELS.get(case.category, case.category), |
||||||
|
title=case.title, |
||||||
|
symptom=case.symptom, |
||||||
|
resolution=case.resolution, |
||||||
|
root_cause=case.root_cause, |
||||||
|
reported_by=case.reported_by, |
||||||
|
reported_by_name=case.reported_by_name, |
||||||
|
assigned_to=case.assigned_to, |
||||||
|
assigned_to_name=case.assigned_to_name, |
||||||
|
device_status_at_open=case.device_status_at_open, |
||||||
|
device_status_applied=case.device_status_applied, |
||||||
|
opened_at=case.opened_at, |
||||||
|
resolved_at=case.resolved_at, |
||||||
|
closed_at=case.closed_at, |
||||||
|
created_at=case.created_at, |
||||||
|
updated_at=case.updated_at, |
||||||
|
events=[_event_to_out(e) for e in (events or [])], |
||||||
|
attachments=attachments or [], |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def _apply_device_status( |
||||||
|
db: Session, |
||||||
|
asset: Asset, |
||||||
|
new_status: str | None, |
||||||
|
*, |
||||||
|
actor: str, |
||||||
|
) -> None: |
||||||
|
if not new_status or new_status == asset.device_status: |
||||||
|
return |
||||||
|
if new_status not in DEVICE_STATUS_OPTIONS: |
||||||
|
raise ValueError(f"Invalid device_status: {new_status}") |
||||||
|
|
||||||
|
owner = asset_change_service.get_primary_owner_label(db, asset.id) |
||||||
|
old_state = asset_change_service.build_tracked_state_from_asset(asset, owner) |
||||||
|
asset.device_status = new_status |
||||||
|
owner_after = asset_change_service.get_primary_owner_label(db, asset.id) |
||||||
|
new_state = asset_change_service.build_tracked_state_from_asset(asset, owner_after) |
||||||
|
|
||||||
|
events = asset_change_service.record_state_changes( |
||||||
|
db, |
||||||
|
asset_id=asset.id, |
||||||
|
old_state={"device_status": old_state.get("device_status")}, |
||||||
|
new_state={"device_status": new_state.get("device_status")}, |
||||||
|
source="support", |
||||||
|
) |
||||||
|
for ev in events: |
||||||
|
ev.detail = {**(ev.detail or {}), "actor": actor} |
||||||
|
|
||||||
|
|
||||||
|
def _append_event( |
||||||
|
db: Session, |
||||||
|
case: AssetSupportCase, |
||||||
|
*, |
||||||
|
event_type: str, |
||||||
|
summary: str, |
||||||
|
detail: str | None, |
||||||
|
actor: str, |
||||||
|
actor_display_name: str | None, |
||||||
|
old_status: str | None = None, |
||||||
|
new_status: str | None = None, |
||||||
|
metadata: dict | None = None, |
||||||
|
) -> AssetSupportEvent: |
||||||
|
row = AssetSupportEvent( |
||||||
|
case_id=case.id, |
||||||
|
event_type=event_type, |
||||||
|
summary=summary, |
||||||
|
detail=detail, |
||||||
|
old_status=old_status, |
||||||
|
new_status=new_status, |
||||||
|
metadata_=metadata or {}, |
||||||
|
actor=actor, |
||||||
|
actor_display_name=actor_display_name, |
||||||
|
) |
||||||
|
db.add(row) |
||||||
|
return row |
||||||
|
|
||||||
|
|
||||||
|
def add_support_event_internal( |
||||||
|
db: Session, |
||||||
|
case: AssetSupportCase, |
||||||
|
*, |
||||||
|
event_type: str, |
||||||
|
summary: str, |
||||||
|
detail: str | None = None, |
||||||
|
actor: str, |
||||||
|
actor_display_name: str | None = None, |
||||||
|
metadata: dict | None = None, |
||||||
|
) -> AssetSupportEvent: |
||||||
|
"""Append timeline row without commit (for use inside larger transactions).""" |
||||||
|
return _append_event( |
||||||
|
db, |
||||||
|
case, |
||||||
|
event_type=event_type, |
||||||
|
summary=summary, |
||||||
|
detail=detail, |
||||||
|
actor=actor, |
||||||
|
actor_display_name=actor_display_name, |
||||||
|
metadata=metadata, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def _touch_status_timestamps(case: AssetSupportCase, new_status: str) -> None: |
||||||
|
now = _utcnow() |
||||||
|
if new_status == "resolved" and not case.resolved_at: |
||||||
|
case.resolved_at = now |
||||||
|
if new_status in {"closed", "cancelled"} and not case.closed_at: |
||||||
|
case.closed_at = now |
||||||
|
|
||||||
|
|
||||||
|
def create_support_case( |
||||||
|
db: Session, |
||||||
|
asset_id: uuid.UUID, |
||||||
|
data: SupportCaseCreateIn, |
||||||
|
) -> SupportCaseOut | None: |
||||||
|
asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None))) |
||||||
|
if not asset: |
||||||
|
return None |
||||||
|
|
||||||
|
case = AssetSupportCase( |
||||||
|
asset_id=asset.id, |
||||||
|
case_number=_next_case_number(db), |
||||||
|
status="open", |
||||||
|
priority=data.priority, |
||||||
|
category=data.category, |
||||||
|
title=data.title.strip(), |
||||||
|
symptom=data.symptom.strip(), |
||||||
|
reported_by=data.reported_by.strip(), |
||||||
|
reported_by_name=data.reported_by_name.strip() if data.reported_by_name else None, |
||||||
|
assigned_to=data.assigned_to.strip() if data.assigned_to else None, |
||||||
|
assigned_to_name=data.assigned_to_name.strip() if data.assigned_to_name else None, |
||||||
|
device_status_at_open=asset.device_status, |
||||||
|
) |
||||||
|
db.add(case) |
||||||
|
db.flush() |
||||||
|
|
||||||
|
_append_event( |
||||||
|
db, |
||||||
|
case, |
||||||
|
event_type="opened", |
||||||
|
summary=f"Kasus dibuka — {SUPPORT_CATEGORY_LABELS.get(data.category, data.category)}", |
||||||
|
detail=data.symptom.strip(), |
||||||
|
actor=data.reported_by.strip(), |
||||||
|
actor_display_name=data.reported_by_name, |
||||||
|
new_status="open", |
||||||
|
) |
||||||
|
|
||||||
|
if data.initial_note: |
||||||
|
_append_event( |
||||||
|
db, |
||||||
|
case, |
||||||
|
event_type="note", |
||||||
|
summary=data.initial_note.strip()[:500], |
||||||
|
detail=data.initial_note.strip(), |
||||||
|
actor=data.reported_by.strip(), |
||||||
|
actor_display_name=data.reported_by_name, |
||||||
|
) |
||||||
|
|
||||||
|
if data.apply_device_status: |
||||||
|
_apply_device_status(db, asset, data.apply_device_status, actor=data.reported_by.strip()) |
||||||
|
case.device_status_applied = data.apply_device_status |
||||||
|
|
||||||
|
db.commit() |
||||||
|
db.refresh(case) |
||||||
|
return get_support_case(db, case.id, include_events=True) |
||||||
|
|
||||||
|
|
||||||
|
def patch_support_case( |
||||||
|
db: Session, |
||||||
|
case_id: uuid.UUID, |
||||||
|
data: SupportCasePatchIn, |
||||||
|
*, |
||||||
|
actor: str, |
||||||
|
actor_display_name: str | None = None, |
||||||
|
) -> SupportCaseOut | None: |
||||||
|
case = db.scalar(select(AssetSupportCase).where(AssetSupportCase.id == case_id)) |
||||||
|
if not case: |
||||||
|
return None |
||||||
|
|
||||||
|
asset = db.scalar(select(Asset).where(Asset.id == case.asset_id, Asset.deleted_at.is_(None))) |
||||||
|
if not asset: |
||||||
|
return None |
||||||
|
|
||||||
|
old_status = case.status |
||||||
|
|
||||||
|
if data.priority is not None: |
||||||
|
case.priority = data.priority |
||||||
|
if data.assigned_to is not None: |
||||||
|
case.assigned_to = data.assigned_to or None |
||||||
|
if data.assigned_to_name is not None: |
||||||
|
case.assigned_to_name = data.assigned_to_name or None |
||||||
|
if data.resolution is not None: |
||||||
|
case.resolution = data.resolution or None |
||||||
|
if data.root_cause is not None: |
||||||
|
case.root_cause = data.root_cause or None |
||||||
|
|
||||||
|
if data.status is not None and data.status != case.status: |
||||||
|
case.status = data.status |
||||||
|
_touch_status_timestamps(case, data.status) |
||||||
|
_append_event( |
||||||
|
db, |
||||||
|
case, |
||||||
|
event_type="status_change", |
||||||
|
summary=f"Status: {SUPPORT_STATUS_LABELS.get(old_status, old_status)} → {SUPPORT_STATUS_LABELS.get(data.status, data.status)}", |
||||||
|
detail=data.note, |
||||||
|
actor=actor, |
||||||
|
actor_display_name=actor_display_name, |
||||||
|
old_status=old_status, |
||||||
|
new_status=data.status, |
||||||
|
) |
||||||
|
elif data.note: |
||||||
|
_append_event( |
||||||
|
db, |
||||||
|
case, |
||||||
|
event_type="note", |
||||||
|
summary=data.note.strip()[:500], |
||||||
|
detail=data.note.strip(), |
||||||
|
actor=actor, |
||||||
|
actor_display_name=actor_display_name, |
||||||
|
) |
||||||
|
|
||||||
|
if data.resolution and case.status in {"open", "in_progress", "waiting_parts", "waiting_user"}: |
||||||
|
case.status = "resolved" |
||||||
|
_touch_status_timestamps(case, "resolved") |
||||||
|
_append_event( |
||||||
|
db, |
||||||
|
case, |
||||||
|
event_type="resolution", |
||||||
|
summary="Resolusi dicatat", |
||||||
|
detail=data.resolution, |
||||||
|
actor=actor, |
||||||
|
actor_display_name=actor_display_name, |
||||||
|
new_status="resolved", |
||||||
|
) |
||||||
|
|
||||||
|
if data.apply_device_status: |
||||||
|
_apply_device_status(db, asset, data.apply_device_status, actor=actor) |
||||||
|
case.device_status_applied = data.apply_device_status |
||||||
|
|
||||||
|
db.commit() |
||||||
|
return get_support_case(db, case.id, include_events=True) |
||||||
|
|
||||||
|
|
||||||
|
def add_support_event( |
||||||
|
db: Session, |
||||||
|
case_id: uuid.UUID, |
||||||
|
data: SupportEventCreateIn, |
||||||
|
) -> SupportEventOut | None: |
||||||
|
case = db.scalar(select(AssetSupportCase).where(AssetSupportCase.id == case_id)) |
||||||
|
if not case: |
||||||
|
return None |
||||||
|
|
||||||
|
metadata = {} |
||||||
|
if data.parts: |
||||||
|
metadata["parts"] = data.parts |
||||||
|
|
||||||
|
row = _append_event( |
||||||
|
db, |
||||||
|
case, |
||||||
|
event_type=data.event_type, |
||||||
|
summary=data.summary.strip(), |
||||||
|
detail=data.detail.strip() if data.detail else None, |
||||||
|
actor=data.actor.strip(), |
||||||
|
actor_display_name=data.actor_display_name, |
||||||
|
metadata=metadata, |
||||||
|
) |
||||||
|
db.commit() |
||||||
|
db.refresh(row) |
||||||
|
return _event_to_out(row) |
||||||
|
|
||||||
|
|
||||||
|
def get_support_case( |
||||||
|
db: Session, |
||||||
|
case_id: uuid.UUID, |
||||||
|
*, |
||||||
|
include_events: bool = True, |
||||||
|
) -> SupportCaseOut | None: |
||||||
|
case = db.scalar(select(AssetSupportCase).where(AssetSupportCase.id == case_id)) |
||||||
|
if not case: |
||||||
|
return None |
||||||
|
events = [] |
||||||
|
if include_events: |
||||||
|
events = list( |
||||||
|
db.scalars( |
||||||
|
select(AssetSupportEvent) |
||||||
|
.where(AssetSupportEvent.case_id == case_id) |
||||||
|
.order_by(AssetSupportEvent.created_at.asc()) |
||||||
|
).all() |
||||||
|
) |
||||||
|
from app.services import support_attachment_service |
||||||
|
|
||||||
|
att = support_attachment_service.list_case_attachments(db, case_id) |
||||||
|
asset = db.scalar(select(Asset).where(Asset.id == case.asset_id)) |
||||||
|
return _case_to_out( |
||||||
|
case, |
||||||
|
events, |
||||||
|
att.items if att else [], |
||||||
|
asset_hostname=asset.hostname if asset else None, |
||||||
|
asset_display_name=asset.display_name if asset else None, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def _case_list_item( |
||||||
|
case: AssetSupportCase, |
||||||
|
*, |
||||||
|
event_count: int, |
||||||
|
asset_hostname: str | None = None, |
||||||
|
asset_display_name: str | None = None, |
||||||
|
) -> SupportCaseListItem: |
||||||
|
return SupportCaseListItem( |
||||||
|
id=str(case.id), |
||||||
|
asset_id=str(case.asset_id), |
||||||
|
asset_hostname=asset_hostname, |
||||||
|
asset_display_name=asset_display_name, |
||||||
|
case_number=case.case_number, |
||||||
|
status=case.status, |
||||||
|
status_label=SUPPORT_STATUS_LABELS.get(case.status, case.status), |
||||||
|
priority=case.priority, |
||||||
|
priority_label=SUPPORT_PRIORITY_LABELS.get(case.priority, case.priority), |
||||||
|
category=case.category, |
||||||
|
category_label=SUPPORT_CATEGORY_LABELS.get(case.category, case.category), |
||||||
|
title=case.title, |
||||||
|
reported_by=case.reported_by, |
||||||
|
reported_by_name=case.reported_by_name, |
||||||
|
opened_at=case.opened_at, |
||||||
|
resolved_at=case.resolved_at, |
||||||
|
event_count=event_count, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def get_support_stats(db: Session) -> SupportStatsOut: |
||||||
|
rows = db.execute( |
||||||
|
select(AssetSupportCase.status, AssetSupportCase.priority, func.count()) |
||||||
|
.group_by(AssetSupportCase.status, AssetSupportCase.priority) |
||||||
|
).all() |
||||||
|
total = 0 |
||||||
|
active = 0 |
||||||
|
open_c = 0 |
||||||
|
in_progress = 0 |
||||||
|
waiting = 0 |
||||||
|
resolved = 0 |
||||||
|
closed_c = 0 |
||||||
|
cancelled_c = 0 |
||||||
|
critical = 0 |
||||||
|
closed_statuses = {"closed", "cancelled"} |
||||||
|
for status, priority, cnt in rows: |
||||||
|
n = int(cnt) |
||||||
|
total += n |
||||||
|
if status not in closed_statuses: |
||||||
|
active += n |
||||||
|
if status == "open": |
||||||
|
open_c += n |
||||||
|
elif status == "in_progress": |
||||||
|
in_progress += n |
||||||
|
elif status in {"waiting_parts", "waiting_user"}: |
||||||
|
waiting += n |
||||||
|
elif status == "resolved": |
||||||
|
resolved += n |
||||||
|
elif status == "closed": |
||||||
|
closed_c += n |
||||||
|
elif status == "cancelled": |
||||||
|
cancelled_c += n |
||||||
|
if priority == "critical" and status not in closed_statuses: |
||||||
|
critical += n |
||||||
|
return SupportStatsOut( |
||||||
|
total=total, |
||||||
|
active=active, |
||||||
|
open=open_c, |
||||||
|
in_progress=in_progress, |
||||||
|
waiting=waiting, |
||||||
|
resolved=resolved, |
||||||
|
closed=closed_c, |
||||||
|
cancelled=cancelled_c, |
||||||
|
critical=critical, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def list_support_cases( |
||||||
|
db: Session, |
||||||
|
*, |
||||||
|
page: int = 1, |
||||||
|
limit: int = 20, |
||||||
|
status: str | None = None, |
||||||
|
priority: str | None = None, |
||||||
|
category: str | None = None, |
||||||
|
active_only: bool = False, |
||||||
|
q: str | None = None, |
||||||
|
) -> PaginatedSupportCases: |
||||||
|
base = ( |
||||||
|
select(AssetSupportCase, Asset.hostname, Asset.display_name) |
||||||
|
.join(Asset, Asset.id == AssetSupportCase.asset_id) |
||||||
|
.where(Asset.deleted_at.is_(None)) |
||||||
|
) |
||||||
|
if status: |
||||||
|
base = base.where(AssetSupportCase.status == status) |
||||||
|
if priority: |
||||||
|
base = base.where(AssetSupportCase.priority == priority) |
||||||
|
if category: |
||||||
|
base = base.where(AssetSupportCase.category == category) |
||||||
|
if active_only: |
||||||
|
base = base.where(AssetSupportCase.status.not_in(["closed", "cancelled"])) |
||||||
|
if q and q.strip(): |
||||||
|
pattern = f"%{q.strip()}%" |
||||||
|
base = base.where( |
||||||
|
or_( |
||||||
|
AssetSupportCase.case_number.ilike(pattern), |
||||||
|
AssetSupportCase.title.ilike(pattern), |
||||||
|
AssetSupportCase.symptom.ilike(pattern), |
||||||
|
AssetSupportCase.reported_by.ilike(pattern), |
||||||
|
AssetSupportCase.reported_by_name.ilike(pattern), |
||||||
|
Asset.hostname.ilike(pattern), |
||||||
|
Asset.display_name.ilike(pattern), |
||||||
|
) |
||||||
|
) |
||||||
|
|
||||||
|
count_q = select(func.count()).select_from(base.subquery()) |
||||||
|
total = db.scalar(count_q) or 0 |
||||||
|
offset = (page - 1) * limit |
||||||
|
rows = db.execute( |
||||||
|
base.order_by(AssetSupportCase.opened_at.desc()).offset(offset).limit(limit) |
||||||
|
).all() |
||||||
|
|
||||||
|
items: list[SupportCaseListItem] = [] |
||||||
|
for case, hostname, display_name in rows: |
||||||
|
event_count = db.scalar( |
||||||
|
select(func.count()) |
||||||
|
.select_from(AssetSupportEvent) |
||||||
|
.where(AssetSupportEvent.case_id == case.id) |
||||||
|
) or 0 |
||||||
|
items.append( |
||||||
|
_case_list_item( |
||||||
|
case, |
||||||
|
event_count=int(event_count), |
||||||
|
asset_hostname=hostname, |
||||||
|
asset_display_name=display_name, |
||||||
|
) |
||||||
|
) |
||||||
|
|
||||||
|
return PaginatedSupportCases(items=items, **_paginate(total, page, limit)) |
||||||
|
|
||||||
|
|
||||||
|
def list_asset_support_cases( |
||||||
|
db: Session, |
||||||
|
asset_id: uuid.UUID, |
||||||
|
*, |
||||||
|
page: int = 1, |
||||||
|
limit: int = 20, |
||||||
|
status: str | None = None, |
||||||
|
) -> PaginatedSupportCases | None: |
||||||
|
if not db.scalar(select(Asset.id).where(Asset.id == asset_id, Asset.deleted_at.is_(None))): |
||||||
|
return None |
||||||
|
|
||||||
|
base = select(AssetSupportCase).where(AssetSupportCase.asset_id == asset_id) |
||||||
|
if status: |
||||||
|
base = base.where(AssetSupportCase.status == status) |
||||||
|
|
||||||
|
total = db.scalar(select(func.count()).select_from(base.subquery())) or 0 |
||||||
|
offset = (page - 1) * limit |
||||||
|
rows = db.scalars(base.order_by(AssetSupportCase.opened_at.desc()).offset(offset).limit(limit)).all() |
||||||
|
|
||||||
|
asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None))) |
||||||
|
hostname = asset.hostname if asset else None |
||||||
|
display_name = asset.display_name if asset else None |
||||||
|
|
||||||
|
items: list[SupportCaseListItem] = [] |
||||||
|
for case in rows: |
||||||
|
event_count = db.scalar( |
||||||
|
select(func.count()) |
||||||
|
.select_from(AssetSupportEvent) |
||||||
|
.where(AssetSupportEvent.case_id == case.id) |
||||||
|
) or 0 |
||||||
|
items.append( |
||||||
|
_case_list_item( |
||||||
|
case, |
||||||
|
event_count=int(event_count), |
||||||
|
asset_hostname=hostname, |
||||||
|
asset_display_name=display_name, |
||||||
|
) |
||||||
|
) |
||||||
|
|
||||||
|
meta = _paginate(total, page, limit) |
||||||
|
return PaginatedSupportCases(items=items, **meta) |
||||||
@ -0,0 +1,58 @@ |
|||||||
|
from datetime import datetime, timezone |
||||||
|
|
||||||
|
from app.services import asset_change_service |
||||||
|
|
||||||
|
|
||||||
|
def test_record_state_changes_only_when_different(): |
||||||
|
old = {"gpu": "NVIDIA GTX 850M (2048 MB)", "ram_gb": "8 GB"} |
||||||
|
new = {"gpu": "NVIDIA GeForce RTX 5060 Ti (16384 MB)", "ram_gb": "8 GB"} |
||||||
|
|
||||||
|
class FakeDb: |
||||||
|
added = [] |
||||||
|
|
||||||
|
def add_all(self, rows): |
||||||
|
self.added.extend(rows) |
||||||
|
|
||||||
|
db = FakeDb() |
||||||
|
events = asset_change_service.record_state_changes( |
||||||
|
db, |
||||||
|
asset_id=__import__("uuid").uuid4(), |
||||||
|
old_state=old, |
||||||
|
new_state=new, |
||||||
|
source="agent", |
||||||
|
) |
||||||
|
assert len(events) == 1 |
||||||
|
assert events[0].field_key == "gpu" |
||||||
|
assert "850M" in (events[0].old_value or "") |
||||||
|
assert "5060 Ti" in (events[0].new_value or "") |
||||||
|
|
||||||
|
|
||||||
|
def test_software_diff_summary(): |
||||||
|
old_keys = {"App A|1.0", "App B|2.0"} |
||||||
|
new_keys = {"App A|1.0", "App B|2.1", "App C|1.0"} |
||||||
|
old_fmt, new_fmt, detail = asset_change_service._format_software_change(old_keys, new_keys) |
||||||
|
assert old_fmt == "2 packages" |
||||||
|
assert "3 packages" in (new_fmt or "") |
||||||
|
assert "+1" in (new_fmt or "") |
||||||
|
assert detail.get("added") |
||||||
|
|
||||||
|
|
||||||
|
def test_skip_identical_fields(): |
||||||
|
class FakeDb: |
||||||
|
added = [] |
||||||
|
|
||||||
|
def add_all(self, rows): |
||||||
|
self.added.extend(rows) |
||||||
|
|
||||||
|
db = FakeDb() |
||||||
|
state = {"hostname": "MULTIMEDIA", "cpu": "Intel Core i7"} |
||||||
|
events = asset_change_service.record_state_changes( |
||||||
|
db, |
||||||
|
asset_id=__import__("uuid").uuid4(), |
||||||
|
old_state=state, |
||||||
|
new_state=state.copy(), |
||||||
|
source="agent", |
||||||
|
event_at=datetime.now(timezone.utc), |
||||||
|
) |
||||||
|
assert events == [] |
||||||
|
assert db.added == [] |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
from app.services.document_storage_service import sanitize_filename, sha256_hex |
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_filename_strips_path(): |
||||||
|
assert sanitize_filename("foto unit.jpg") == "foto_unit.jpg" |
||||||
|
assert sanitize_filename("report.pdf") == "report.pdf" |
||||||
|
|
||||||
|
|
||||||
|
def test_sha256_hex(): |
||||||
|
assert sha256_hex(b"test") == "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
from app.schemas.support import SUPPORT_CATEGORY_LABELS, SUPPORT_STATUS_LABELS |
||||||
|
from app.services import support_service |
||||||
|
|
||||||
|
|
||||||
|
def test_get_support_options_ssot(): |
||||||
|
opts = support_service.get_support_options() |
||||||
|
assert opts.status == list(SUPPORT_STATUS_LABELS.keys()) |
||||||
|
assert opts.category_labels == SUPPORT_CATEGORY_LABELS |
||||||
|
assert "component_replacement" in opts.category |
||||||
|
assert opts.device_status # from DEVICE_STATUS_OPTIONS |
||||||
|
assert "service_photo" in opts.attachment_kind |
||||||
|
|
||||||
|
|
||||||
|
def test_support_category_labels_cover_all_categories(): |
||||||
|
opts = support_service.get_support_options() |
||||||
|
for cat in opts.category: |
||||||
|
assert cat in opts.category_labels |
||||||
@ -0,0 +1,44 @@ |
|||||||
|
# ADR-004: IT Support Troubleshooting Cases (SSOT) |
||||||
|
|
||||||
|
**Status:** Accepted |
||||||
|
**Date:** 2026-07-09 |
||||||
|
|
||||||
|
## Context |
||||||
|
|
||||||
|
CMDB GeoNetAgent sudah menyimpan inventaris otomatis (agent snapshots) dan audit perubahan field (`asset_change_events`). IT Support membutuhkan **catatan kendala & tindakan** yang: |
||||||
|
|
||||||
|
- Dibuat manual oleh teknisi (mati, error, ganti komponen, service, dll.) |
||||||
|
- Terikat ke satu asset (SSOT = `assets.id`) |
||||||
|
- Dapat dilacak jangka panjang (timeline, bukan chat/email) |
||||||
|
- Terpisah dari payload agent (agent tidak tahu konteks support) |
||||||
|
|
||||||
|
## Decision |
||||||
|
|
||||||
|
1. **Dua tabel SSOT:** |
||||||
|
- `asset_support_cases` — tiket/kasus (status, kategori, gejala, resolusi) |
||||||
|
- `asset_support_events` — timeline append-only per kasus |
||||||
|
|
||||||
|
2. **Nomor kasus:** `GNA-SUP-YYYYMMDD-NNNN` (unik, human-readable) |
||||||
|
|
||||||
|
3. **Enum** status / priority / category / event_type didefinisikan di **satu modul Python** (`schemas/support.py`) dan dicerminkan CHECK constraint DB. |
||||||
|
|
||||||
|
4. **Auth:** sama dengan CMDB read/write API — `verify_api_bearer` (Bearer token portal). |
||||||
|
|
||||||
|
5. **Integrasi asset:** opsional update `device_status` saat buka/tutup kasus; perubahan tercatat di `asset_change_events` dengan `source=support`. |
||||||
|
|
||||||
|
6. **Bukan** duplikat Zabbix alerts (`alerts` tetap untuk monitoring otomatis). |
||||||
|
|
||||||
|
## Alternatives considered |
||||||
|
|
||||||
|
| Alternatif | Alasan ditolak | |
||||||
|
|------------|----------------| |
||||||
|
| Satu tabel flat tanpa timeline | Sulit audit aksi bertahap (ganti RAM → tes → tutup) | |
||||||
|
| External ticket (Zammad only) | Tidak terikat snapshot hardware asset di CMDB | |
||||||
|
| Log file di server | Bukan queryable, bukan SSOT relational | |
||||||
|
|
||||||
|
## Consequences |
||||||
|
|
||||||
|
- Migration `009_asset_support.sql` |
||||||
|
- API `/api/v1/support/*` + nested under asset |
||||||
|
- UI form di collector `/ui/` panel detail asset |
||||||
|
- Laravel portal (fase berikutnya) bisa consume API yang sama |
||||||
@ -1,39 +1,43 @@ |
|||||||
# AI Session Summary |
# AI Session Summary |
||||||
|
|
||||||
```yaml |
```yaml |
||||||
session: 2026-07-08-cmdb-catalog-ui |
session: 2026-07-09-it-support-pwa |
||||||
project: GeoNetAgent/ |
project: GeoNetAgent/ |
||||||
version: 0.1.13-collector-ui |
version: 0.1.19-collector-support-pwa |
||||||
status: ready |
status: deployed |
||||||
next: Rollout agent ke endpoint stale; backfill owner display_name via LDAP re-select |
next: procurement attachments (migration 005); QNAP S3 prod for documents |
||||||
agent: Cursor |
agent: Cursor |
||||||
``` |
``` |
||||||
|
|
||||||
## TL;DR |
## TL;DR |
||||||
|
|
||||||
- Device model catalog SSOT (`device_model_catalog`) + sync dari assets (series pending) — live UI |
- IT Support SSOT: migrations `009`/`010`, API kasus + lampiran foto/PDF, UI collector `/ui/` |
||||||
- CMDB UI: Owner (nama+username), Agent version, search/filter/sort (owner/model/series) |
- API global: `GET /support-cases`, `GET /support/stats` (termasuk `closed`/`cancelled`) |
||||||
- Bug ingest 500: agent vs owner manual → unique primary — **fixed** (manual owner tidak ditimpa) |
- Asset list: `support_case_count`, `active_support_status` per device |
||||||
- Deploy: migrasi `006`/`007` di `.25`; collector rebuild di `.24` (`geonet-collector`) |
- PWA `my.gisportal.id` (repo terpisah): `/support`, profil Device+riwayat tiket, asset-mgmt owner fix |
||||||
|
- Deploy: collector `.24`, DB `.25`, PWA `my-portal` SW **v8** |
||||||
|
|
||||||
## Lanjut dari sini |
## Lanjut dari sini |
||||||
|
|
||||||
1. UI: [collector.gisportal.id/ui/](https://collector.gisportal.id/ui/) — isi Series pending; filter stale + rollout install agent |
1. Collector UI: [collector.gisportal.id/ui/](https://collector.gisportal.id/ui/) — support panel per asset |
||||||
2. Backfill `users.display_name`: di Edit Asset, pilih ulang owner dari LDAP autocomplete → Simpan |
2. PWA: [my.gisportal.id/support](https://my.gisportal.id/support), profil tab Device Saya |
||||||
3. Opsional: hapus asset uji `DESKTOP-RRJ9G01-00000000` (TESTSERIAL dari ingest minimal) |
3. Owner backfill: Edit asset → pilih LDAP (kirim `display_name`) — sama pola collector UI |
||||||
|
4. Opsional: `DOCUMENTS_STORAGE_BACKEND=s3` + bucket QNAP untuk lampiran prod |
||||||
|
|
||||||
## Jangan |
## Jangan |
||||||
|
|
||||||
- Jangan commit `agent/config.json` / `.env` (token) |
- Jangan commit `agent/config.json`, `.env`, token collector, file cert nginx |
||||||
- Jangan biarkan agent overwrite owner `assigned_by=manual` |
- Jangan overwrite owner manual via agent ingest (`assigned_by=manual` SSOT) |
||||||
- Jangan hardcode URL collector di kode — pakai config/`AGENT_*` env |
- Jangan hardcode URL — env `COLLECTOR_*` / `NEXT_PUBLIC_COLLECTOR_*` |
||||||
|
|
||||||
## Detail (deploy) |
## Detail (deploy) |
||||||
|
|
||||||
| Host | Aksi | |
| Host | Aksi | |
||||||
|------|------| |
|------|------| |
||||||
| `10.100.1.25` | `006_device_model_catalog.sql`, `007_..._pending_series.sql` applied | |
| `10.100.1.25` | Migrasi `008`–`010` applied (`geonetagent_dev`) | |
||||||
| `10.100.1.24` | `/opt/stacks/geonetagent-collector/` rebuild; health OK | |
| `10.100.1.24` | `push_collector.ps1`; volume `./data/documents` | |
||||||
| Guide | `docs/guide/device-model-catalog.md` | |
| PWA | `deploy-my-portal.ps1`; repo `mysupperapps-fe-pwa` | |
||||||
|
|
||||||
**Root cause stale massal:** banyak PC jarang lapor + bug owner (500) + GPO belum massal — bukan UI saja. |
**API baru:** `support/options`, `support/stats`, `support-cases`, nested `assets/{id}/support-cases`, attachments upload/download. |
||||||
|
|
||||||
|
**PWA repo:** `mysupperapps_FE_PWA/` — git terpisah, push ke `git.gisportal.id`. |
||||||
|
|||||||
@ -0,0 +1,74 @@ |
|||||||
|
# Panduan — IT Support Troubleshooting (CMDB) |
||||||
|
|
||||||
|
Modul ini mencatat kendala device dan tindakan teknisi **per asset**, terpisah dari laporan agent otomatis. SSOT: PostgreSQL + API collector + UI `/ui/`. |
||||||
|
|
||||||
|
## Kapan dipakai |
||||||
|
|
||||||
|
- PC/laptop mati, error boot, BSOD |
||||||
|
- Penggantian komponen (RAM, SSD, PSU, dll.) |
||||||
|
- Perbaikan / service / preventive maintenance |
||||||
|
- Masalah software, jaringan, performa |
||||||
|
- Penarikan / decommission |
||||||
|
|
||||||
|
**Bukan** untuk alert Zabbix (`alerts` tetap untuk monitoring). |
||||||
|
|
||||||
|
## Alur kerja |
||||||
|
|
||||||
|
1. Buka asset di **GeoNetAgent Asset Viewer** (`https://collector.gisportal.id/ui/`) |
||||||
|
2. Scroll ke panel **IT Support & Troubleshooting** |
||||||
|
3. **Buka kasus baru** — isi kategori, prioritas, gejala, teknisi |
||||||
|
4. Opsional: set **device status** (mis. `maintenance`, `rusak`) — tercatat di change log (`source=support`) |
||||||
|
5. Saat penanganan berlanjut: buka kasus → **Update kasus** (status + catatan) |
||||||
|
6. Tutup dengan status **Resolved** atau **Closed** |
||||||
|
|
||||||
|
Nomor tiket otomatis: `GNA-SUP-YYYYMMDD-NNNN`. |
||||||
|
|
||||||
|
## API (Bearer token sama dengan CMDB UI) |
||||||
|
|
||||||
|
| Method | Path | Fungsi | |
||||||
|
|--------|------|--------| |
||||||
|
| GET | `/api/v1/support/options` | Enum kategori, status, prioritas | |
||||||
|
| GET | `/api/v1/assets/{id}/support-cases` | Daftar kasus per device | |
||||||
|
| POST | `/api/v1/assets/{id}/support-cases` | Buka kasus baru | |
||||||
|
| GET | `/api/v1/support-cases/{id}` | Detail + timeline | |
||||||
|
| PATCH | `/api/v1/support-cases/{id}?actor=username` | Update status / catatan | |
||||||
|
| POST | `/api/v1/support-cases/{id}/events` | Tambah event (aksi, parts, dll.) | |
||||||
|
| GET | `/api/v1/support-cases/{id}/attachments` | Daftar foto/lampiran kasus | |
||||||
|
| POST | `/api/v1/support-cases/{id}/attachments` | Upload foto/PDF (multipart) | |
||||||
|
| GET | `/api/v1/attachments/{id}/download` | Unduh / preview file | |
||||||
|
|
||||||
|
### Lampiran foto (troubleshooting) |
||||||
|
|
||||||
|
Jenis lampiran (SSOT `schemas/attachments.py`): |
||||||
|
|
||||||
|
| Kind | Kegunaan | |
||||||
|
|------|----------| |
||||||
|
| `service_photo` | Foto kondisi saat troubleshooting | |
||||||
|
| `before_photo` / `after_photo` | Sebelum & sesudah perbaikan | |
||||||
|
| `parts_receipt` | Nota sparepart | |
||||||
|
| `service_report` | PDF laporan vendor | |
||||||
|
| `screenshot` | Screenshot error | |
||||||
|
|
||||||
|
Format: JPEG, PNG, WebP, GIF, PDF — maks 15 MB. Mobile PWA: `capture="environment"` pada input file. |
||||||
|
|
||||||
|
Storage dev: folder lokal `./data/documents`. Produksi: set `DOCUMENTS_STORAGE_BACKEND=s3` + kredensial QNAP di `.env` collector. |
||||||
|
|
||||||
|
Lihat ADR-004 dan `docs/database.md` untuk skema tabel. |
||||||
|
|
||||||
|
## Deploy migration |
||||||
|
|
||||||
|
```bash |
||||||
|
sudo -u postgres psql -d geonetagent_dev -f 008_asset_change_events.sql # jika belum |
||||||
|
sudo -u postgres psql -d geonetagent_dev -f 009_asset_support.sql |
||||||
|
sudo -u postgres psql -d geonetagent_dev -f 010_support_attachments.sql |
||||||
|
``` |
||||||
|
|
||||||
|
Rebuild & restart collector di `10.100.1.24` setelah migration. |
||||||
|
|
||||||
|
## Praktik baik |
||||||
|
|
||||||
|
- **Judul singkat** yang bisa dicari nanti (hostname + gejala) |
||||||
|
- **Gejala** = apa yang user laporkan; **resolution** = apa yang teknisi lakukan |
||||||
|
- **Root cause** untuk kasus berulang (mis. PSU lemah, thermal paste kering) |
||||||
|
- Gunakan **waiting_parts** jika menunggu sparepart — jangan tutup prematur |
||||||
|
- Username teknisi konsisten (LDAP) agar audit trail jelas |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
-- GeoNetAgent — audit log perubahan field asset (hanya jika nilai berubah) |
||||||
|
-- Jalankan: sudo -u postgres psql -d geonetagent_dev -f 008_asset_change_events.sql |
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_change_events ( |
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
||||||
|
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE, |
||||||
|
snapshot_id uuid REFERENCES asset_snapshots(id) ON DELETE SET NULL, |
||||||
|
event_at timestamptz NOT NULL DEFAULT now(), |
||||||
|
source varchar(30) NOT NULL, |
||||||
|
field_key varchar(80) NOT NULL, |
||||||
|
field_label varchar(120) NOT NULL, |
||||||
|
change_type varchar(20) NOT NULL DEFAULT 'updated', |
||||||
|
old_value text, |
||||||
|
new_value text, |
||||||
|
detail jsonb NOT NULL DEFAULT '{}'::jsonb, |
||||||
|
CONSTRAINT chk_asset_change_source CHECK (source IN ('agent', 'manual', 'system')), |
||||||
|
CONSTRAINT chk_asset_change_type CHECK (change_type IN ('added', 'removed', 'updated')) |
||||||
|
); |
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_change_events_asset_time |
||||||
|
ON asset_change_events (asset_id, event_at DESC); |
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asset_change_events_field |
||||||
|
ON asset_change_events (asset_id, field_key, event_at DESC); |
||||||
|
|
||||||
|
COMMENT ON TABLE asset_change_events IS |
||||||
|
'Audit trail per-field: old_value → new_value saat ingest agent atau edit manual UI.'; |
||||||
|
|
||||||
|
GRANT SELECT, INSERT ON asset_change_events TO geonet_collector; |
||||||
@ -0,0 +1,95 @@ |
|||||||
|
-- GeoNetAgent — IT Support / troubleshooting cases (SSOT) |
||||||
|
-- Jalankan: sudo -u postgres psql -d geonetagent_dev -f 009_asset_support.sql |
||||||
|
|
||||||
|
-- Extend audit source for support-driven asset updates |
||||||
|
ALTER TABLE asset_change_events DROP CONSTRAINT IF EXISTS chk_asset_change_source; |
||||||
|
ALTER TABLE asset_change_events ADD CONSTRAINT chk_asset_change_source |
||||||
|
CHECK (source IN ('agent', 'manual', 'system', 'support')); |
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_support_cases ( |
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
||||||
|
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE, |
||||||
|
case_number varchar(32) NOT NULL UNIQUE, |
||||||
|
status varchar(30) NOT NULL DEFAULT 'open', |
||||||
|
priority varchar(20) NOT NULL DEFAULT 'normal', |
||||||
|
category varchar(40) NOT NULL, |
||||||
|
title varchar(200) NOT NULL, |
||||||
|
symptom text NOT NULL, |
||||||
|
resolution text, |
||||||
|
root_cause text, |
||||||
|
reported_by varchar(100) NOT NULL, |
||||||
|
reported_by_name varchar(200), |
||||||
|
assigned_to varchar(100), |
||||||
|
assigned_to_name varchar(200), |
||||||
|
device_status_at_open varchar(30), |
||||||
|
device_status_applied varchar(30), |
||||||
|
opened_at timestamptz NOT NULL DEFAULT now(), |
||||||
|
resolved_at timestamptz, |
||||||
|
closed_at timestamptz, |
||||||
|
created_at timestamptz NOT NULL DEFAULT now(), |
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(), |
||||||
|
CONSTRAINT chk_support_case_status CHECK (status IN ( |
||||||
|
'open', 'in_progress', 'waiting_parts', 'waiting_user', 'resolved', 'closed', 'cancelled' |
||||||
|
)), |
||||||
|
CONSTRAINT chk_support_case_priority CHECK (priority IN ( |
||||||
|
'low', 'normal', 'high', 'critical' |
||||||
|
)), |
||||||
|
CONSTRAINT chk_support_case_category CHECK (category IN ( |
||||||
|
'power_no_boot', |
||||||
|
'boot_os_error', |
||||||
|
'hardware_failure', |
||||||
|
'component_replacement', |
||||||
|
'repair', |
||||||
|
'preventive_maintenance', |
||||||
|
'software_issue', |
||||||
|
'network_connectivity', |
||||||
|
'performance', |
||||||
|
'data_backup_recovery', |
||||||
|
'security_incident', |
||||||
|
'user_request', |
||||||
|
'decommission', |
||||||
|
'other' |
||||||
|
)) |
||||||
|
); |
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_support_cases_asset_opened |
||||||
|
ON asset_support_cases (asset_id, opened_at DESC); |
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_support_cases_status |
||||||
|
ON asset_support_cases (status, opened_at DESC); |
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_support_cases_reported_by |
||||||
|
ON asset_support_cases (reported_by, opened_at DESC); |
||||||
|
|
||||||
|
CREATE TRIGGER trg_asset_support_cases_updated_at |
||||||
|
BEFORE UPDATE ON asset_support_cases |
||||||
|
FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); |
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_support_events ( |
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
||||||
|
case_id uuid NOT NULL REFERENCES asset_support_cases(id) ON DELETE CASCADE, |
||||||
|
event_type varchar(30) NOT NULL, |
||||||
|
summary varchar(500) NOT NULL, |
||||||
|
detail text, |
||||||
|
old_status varchar(30), |
||||||
|
new_status varchar(30), |
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb, |
||||||
|
actor varchar(100) NOT NULL, |
||||||
|
actor_display_name varchar(200), |
||||||
|
created_at timestamptz NOT NULL DEFAULT now(), |
||||||
|
CONSTRAINT chk_support_event_type CHECK (event_type IN ( |
||||||
|
'opened', 'note', 'status_change', 'action', 'parts', 'handover', 'resolution' |
||||||
|
)) |
||||||
|
); |
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_support_events_case_time |
||||||
|
ON asset_support_events (case_id, created_at ASC); |
||||||
|
|
||||||
|
COMMENT ON TABLE asset_support_cases IS |
||||||
|
'SSOT tiket troubleshooting IT Support per asset — bukan file log, bukan snapshot agent.'; |
||||||
|
|
||||||
|
COMMENT ON TABLE asset_support_events IS |
||||||
|
'Timeline append-only per tiket support (catatan, aksi, ganti komponen, perubahan status).'; |
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON asset_support_cases TO geonet_collector; |
||||||
|
GRANT SELECT, INSERT ON asset_support_events TO geonet_collector; |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
-- GeoNetAgent — support case photo/doc attachments (ADR-003 + ADR-004) |
||||||
|
-- Jalankan setelah 009_asset_support.sql |
||||||
|
-- SSOT metadata: document_attachments · binary: QNAP S3 atau local (collector) |
||||||
|
|
||||||
|
\set ON_ERROR_STOP on |
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS document_attachments ( |
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), |
||||||
|
domain varchar(20) NOT NULL DEFAULT 'support', |
||||||
|
kind varchar(40) NOT NULL DEFAULT 'service_photo', |
||||||
|
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE, |
||||||
|
support_case_id uuid NOT NULL REFERENCES asset_support_cases(id) ON DELETE CASCADE, |
||||||
|
storage_provider varchar(20) NOT NULL DEFAULT 'local', |
||||||
|
bucket varchar(100), |
||||||
|
object_key varchar(500) NOT NULL, |
||||||
|
original_filename varchar(255) NOT NULL, |
||||||
|
content_type varchar(100), |
||||||
|
file_size_bytes bigint, |
||||||
|
checksum_sha256 varchar(64), |
||||||
|
uploaded_by varchar(100) NOT NULL, |
||||||
|
uploaded_by_name varchar(200), |
||||||
|
caption text, |
||||||
|
uploaded_at timestamptz NOT NULL DEFAULT now(), |
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb, |
||||||
|
deleted_at timestamptz, |
||||||
|
CONSTRAINT chk_doc_attach_domain CHECK (domain IN ( |
||||||
|
'support', 'procurement', 'service', 'complaint' |
||||||
|
)), |
||||||
|
CONSTRAINT chk_doc_attach_kind_support CHECK ( |
||||||
|
domain <> 'support' OR kind IN ( |
||||||
|
'service_photo', 'before_photo', 'after_photo', 'parts_receipt', |
||||||
|
'service_report', 'screenshot', 'other' |
||||||
|
) |
||||||
|
), |
||||||
|
CONSTRAINT uq_doc_attach_storage UNIQUE (storage_provider, object_key) |
||||||
|
); |
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_doc_attach_support_case |
||||||
|
ON document_attachments (support_case_id, uploaded_at DESC) |
||||||
|
WHERE deleted_at IS NULL; |
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_doc_attach_asset |
||||||
|
ON document_attachments (asset_id, uploaded_at DESC) |
||||||
|
WHERE deleted_at IS NULL; |
||||||
|
|
||||||
|
COMMENT ON TABLE document_attachments IS |
||||||
|
'Metadata lampiran file (foto troubleshooting, invoice, dll). Binary di QNAP/local — ADR-003.'; |
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON document_attachments TO geonet_collector; |
||||||
Loading…
Reference in new issue