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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,39 +1,43 @@
|
||||
# AI Session Summary |
||||
|
||||
```yaml |
||||
session: 2026-07-08-cmdb-catalog-ui |
||||
session: 2026-07-09-it-support-pwa |
||||
project: GeoNetAgent/ |
||||
version: 0.1.13-collector-ui |
||||
status: ready |
||||
next: Rollout agent ke endpoint stale; backfill owner display_name via LDAP re-select |
||||
version: 0.1.19-collector-support-pwa |
||||
status: deployed |
||||
next: procurement attachments (migration 005); QNAP S3 prod for documents |
||||
agent: Cursor |
||||
``` |
||||
|
||||
## TL;DR |
||||
|
||||
- Device model catalog SSOT (`device_model_catalog`) + sync dari assets (series pending) — live UI |
||||
- CMDB UI: Owner (nama+username), Agent version, search/filter/sort (owner/model/series) |
||||
- Bug ingest 500: agent vs owner manual → unique primary — **fixed** (manual owner tidak ditimpa) |
||||
- Deploy: migrasi `006`/`007` di `.25`; collector rebuild di `.24` (`geonet-collector`) |
||||
- IT Support SSOT: migrations `009`/`010`, API kasus + lampiran foto/PDF, UI collector `/ui/` |
||||
- API global: `GET /support-cases`, `GET /support/stats` (termasuk `closed`/`cancelled`) |
||||
- Asset list: `support_case_count`, `active_support_status` per device |
||||
- 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 |
||||
|
||||
1. UI: [collector.gisportal.id/ui/](https://collector.gisportal.id/ui/) — isi Series pending; filter stale + rollout install agent |
||||
2. Backfill `users.display_name`: di Edit Asset, pilih ulang owner dari LDAP autocomplete → Simpan |
||||
3. Opsional: hapus asset uji `DESKTOP-RRJ9G01-00000000` (TESTSERIAL dari ingest minimal) |
||||
1. Collector UI: [collector.gisportal.id/ui/](https://collector.gisportal.id/ui/) — support panel per asset |
||||
2. PWA: [my.gisportal.id/support](https://my.gisportal.id/support), profil tab Device Saya |
||||
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 commit `agent/config.json` / `.env` (token) |
||||
- Jangan biarkan agent overwrite owner `assigned_by=manual` |
||||
- Jangan hardcode URL collector di kode — pakai config/`AGENT_*` env |
||||
- Jangan commit `agent/config.json`, `.env`, token collector, file cert nginx |
||||
- Jangan overwrite owner manual via agent ingest (`assigned_by=manual` SSOT) |
||||
- Jangan hardcode URL — env `COLLECTOR_*` / `NEXT_PUBLIC_COLLECTOR_*` |
||||
|
||||
## Detail (deploy) |
||||
|
||||
| Host | Aksi | |
||||
|------|------| |
||||
| `10.100.1.25` | `006_device_model_catalog.sql`, `007_..._pending_series.sql` applied | |
||||
| `10.100.1.24` | `/opt/stacks/geonetagent-collector/` rebuild; health OK | |
||||
| Guide | `docs/guide/device-model-catalog.md` | |
||||
| `10.100.1.25` | Migrasi `008`–`010` applied (`geonetagent_dev`) | |
||||
| `10.100.1.24` | `push_collector.ps1`; volume `./data/documents` | |
||||
| 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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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