From 5ba6d871b1a75f469270109641438d4284d41a27 Mon Sep 17 00:00:00 2001 From: beriman Date: Fri, 3 Oct 2025 08:01:55 +0700 Subject: [PATCH] Refactor moderation dashboard to use repository --- src/app/api/routes/root.py | 6 +- src/app/core/application.py | 7 +- src/app/services/__init__.py | 6 +- src/app/services/moderation_dashboard.py | 517 +------------------ src/app/services/moderation_models.py | 148 ++++++ src/app/services/moderation_repository.py | 559 +++++++++++++++++++++ tests/test_moderation_dashboard.py | 17 +- tests/test_moderation_dashboard_service.py | 19 + 8 files changed, 773 insertions(+), 506 deletions(-) create mode 100644 src/app/services/moderation_models.py create mode 100644 src/app/services/moderation_repository.py create mode 100644 tests/test_moderation_dashboard_service.py diff --git a/src/app/api/routes/root.py b/src/app/api/routes/root.py index 8563766..e9d6bbd 100644 --- a/src/app/api/routes/root.py +++ b/src/app/api/routes/root.py @@ -4,10 +4,11 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse +from typing import cast from app.core.config import get_settings from app.services.brand_dashboard import brand_dashboard_service -from app.services.moderation_dashboard import moderation_dashboard_service +from app.services.moderation_dashboard import ModerationDashboardService from app.services.products import product_service from app.services.sambatan import SambatanCampaign, sambatan_service @@ -1300,7 +1301,8 @@ async def read_moderation_dashboard(request: Request) -> HTMLResponse: settings = get_settings() templates = request.app.state.templates - snapshot = moderation_dashboard_service.get_snapshot() + service = cast(ModerationDashboardService, request.app.state.moderation_dashboard_service) + snapshot = service.get_snapshot() context = { "app_name": settings.app_name, diff --git a/src/app/core/application.py b/src/app/core/application.py index 93a9b2c..6ae0942 100644 --- a/src/app/core/application.py +++ b/src/app/core/application.py @@ -9,6 +9,8 @@ from app.core.config import get_settings from app.core.session import InMemorySessionMiddleware from app.web.templates import template_engine +from app.services.moderation_dashboard import ModerationDashboardService +from app.services.moderation_repository import ModerationDashboardRepository from app.api.routes import onboarding as onboarding_routes from app.api.routes import profile as profile_routes from app.api.routes import reports as reports_routes @@ -57,7 +59,10 @@ def create_app() -> FastAPI: app.include_router(auth_routes.router) - # Expose the template engine on the app state for reuse by routers. + # Expose shared services on the app state for reuse by routers. app.state.templates = template_engine + app.state.moderation_dashboard_service = ModerationDashboardService( + ModerationDashboardRepository() + ) return app diff --git a/src/app/services/__init__.py b/src/app/services/__init__.py index fef725f..a1d0637 100644 --- a/src/app/services/__init__.py +++ b/src/app/services/__init__.py @@ -3,10 +3,7 @@ from .auth import AuthService, auth_service from .brands import BrandService, brand_service from .brand_dashboard import BrandOwnerDashboardService, brand_dashboard_service -from .moderation_dashboard import ( - ModerationDashboardService, - moderation_dashboard_service, -) +from .moderation_dashboard import ModerationDashboardService from .onboarding import OnboardingService, onboarding_service from .products import ProductService, product_service from .nusantarum_service import ( @@ -28,7 +25,6 @@ "BrandOwnerDashboardService", "brand_dashboard_service", "ModerationDashboardService", - "moderation_dashboard_service", "OnboardingService", "onboarding_service", "ProductService", diff --git a/src/app/services/moderation_dashboard.py b/src/app/services/moderation_dashboard.py index 5518fc7..018e123 100644 --- a/src/app/services/moderation_dashboard.py +++ b/src/app/services/moderation_dashboard.py @@ -2,507 +2,32 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import List - - -@dataclass(frozen=True) -class ModerationKPI: - """Headline metric surfaced in the overview hero.""" - - label: str - value: str - delta: str - tone: str - - -@dataclass(frozen=True) -class ModerationAlert: - """Realtime alert or escalation that needs attention.""" - - title: str - description: str - severity: str - timestamp: str - - -@dataclass(frozen=True) -class TeamMemberSummary: - """Represents a moderator/curator/admin within the roster.""" - - name: str - role: str - status: str - active_cases: int - last_active: str - shift: str - - -@dataclass(frozen=True) -class PendingInvitation: - """Invitation that is waiting for activation.""" - - email: str - role: str - sent_at: str - expires_at: str - - -@dataclass(frozen=True) -class AuditTrailEntry: - """Audit log row for transparency and compliance.""" - - time: str - actor: str - action: str - - -@dataclass(frozen=True) -class ReportTicket: - """Queue item in the moderation pipeline.""" - - ticket_id: str - category: str - priority: str - status: str - sla_remaining: str - assigned_to: str - source: str - - -@dataclass(frozen=True) -class CurationSubmission: - """Submission tracked in the curator module.""" - - brand: str - submission_type: str - owner: str - status: str - updated: str - notes: str - - -@dataclass(frozen=True) -class InsightTrend: - """Analytic trend metric.""" - - label: str - current: str - change: str - tone: str - - -@dataclass(frozen=True) -class HeatmapSlot: - """Represents a block in the workload heatmap.""" - - label: str - load: str - state: str - - -@dataclass(frozen=True) -class PolicyUpdate: - """Policy changes that admins can audit.""" - - title: str - version: str - updated_at: str - owner: str - tags: List[str] - - -@dataclass(frozen=True) -class HelpResource: - """FAQ or learning material for the internal help center.""" - - title: str - category: str - format: str - updated_at: str - - -@dataclass(frozen=True) -class ContactPoint: - """Escalation contact for the team.""" - - name: str - role: str - channel: str - availability: str +from typing import Optional + +from app.services.moderation_models import ( + AuditTrailEntry, + ContactPoint, + CurationSubmission, + HeatmapSlot, + HelpResource, + InsightTrend, + ModerationAlert, + ModerationKPI, + PendingInvitation, + PolicyUpdate, + ReportTicket, + TeamMemberSummary, +) +from app.services.moderation_repository import ModerationDashboardRepository class ModerationDashboardService: - """Encapsulates demo data to drive the moderation dashboard template.""" - - def __init__(self) -> None: - self._persona = { - "name": "Arif Santoso", - "role": "Admin Utama Moderasi", - "mission": "Pantau antrian, distribusi tugas, dan kebijakan moderasi dalam satu layar.", - "shift": "Shift Pagi • 07.00 - 15.00 WIB", - "date": "Selasa, 14 Mei 2024", - "focus": "Prioritaskan eskalasi tingkat kritis dan onboarding dua moderator baru.", - "acknowledgement": "3 kebijakan baru belum dikonfirmasi oleh 4 anggota tim.", - } - - self._kpis = [ - ModerationKPI( - label="Laporan Prioritas Tinggi", - value="12", - delta="4 overdue > 4 jam", - tone="warning", - ), - ModerationKPI( - label="Aktivasi Moderator Minggu Ini", - value="5/7 selesai", - delta="2 masih menunggu pelatihan", - tone="info", - ), - ModerationKPI( - label="Akurasi Kurasi (7 hari)", - value="92%", - delta="+3% dari baseline", - tone="success", - ), - ModerationKPI( - label="Pelanggaran Berulang", - value="-28%", - delta="vs bulan lalu", - tone="positive", - ), - ] - - self._alerts = [ - ModerationAlert( - title="Eskalasi Urgensi Merah", - description="Laporan #PR-9821 (penipuan pembayaran) belum direspon selama 3 jam.", - severity="kritis", - timestamp="09:42 WIB", - ), - ModerationAlert( - title="Audit Quality Lead", - description="Quality Lead meminta sampel 10 kasus kurasi kampanye Ramadhan.", - severity="penting", - timestamp="08:55 WIB", - ), - ModerationAlert( - title="Onboarding Moderator", - description="Undangan ke rani@marketplace.id kadaluwarsa dalam 6 jam.", - severity="pengingat", - timestamp="07:10 WIB", - ), - ] - - self._team_members = [ - TeamMemberSummary( - name="Nadia Putri", - role="Moderator Senior", - status="Sedang mengaudit", - active_cases=6, - last_active="2 menit lalu", - shift="Pagi", - ), - TeamMemberSummary( - name="Dimas Ardi", - role="Moderator", - status="Menangani tiket prioritas", - active_cases=4, - last_active="Baru saja", - shift="Pagi", - ), - TeamMemberSummary( - name="Sela Wiryawan", - role="Kurator Brand", - status="Review kampanye", - active_cases=3, - last_active="10 menit lalu", - shift="Siang", - ), - TeamMemberSummary( - name="Grace Halim", - role="Quality Lead", - status="Sampling audit", - active_cases=2, - last_active="15 menit lalu", - shift="Fleksibel", - ), - ] - - self._pending_invites = [ - PendingInvitation( - email="rani@marketplace.id", - role="Moderator", - sent_at="13 Mei 2024 • 11:15", - expires_at="Hari ini, 17:15", - ), - PendingInvitation( - email="bintang@marketplace.id", - role="Kurator", - sent_at="13 Mei 2024 • 09:48", - expires_at="15 Mei 2024", - ), - ] - - self._audit_trail = [ - AuditTrailEntry( - time="09:35", - actor="Arif", - action="Mengubah SOP validasi bukti level 2 menjadi wajib verifikasi ganda.", - ), - AuditTrailEntry( - time="08:12", - actor="Nadia", - action="Menutup laporan #PR-9712 (konten SARA) dengan tindakan suspend 7 hari.", - ), - AuditTrailEntry( - time="07:55", - actor="Sela", - action='Mengeskalasi brand "Aurora Glow" ke admin karena skor risiko tinggi.', - ), - ] - - self._report_tickets = [ - ReportTicket( - ticket_id="#PR-9821", - category="Transaksi", - priority="Merah", - status="Menunggu admin", - sla_remaining="-01:12", - assigned_to="Arif", - source="Komunitas", - ), - ReportTicket( - ticket_id="#PR-9827", - category="Konten", - priority="Kuning", - status="Dalam review", - sla_remaining="00:45", - assigned_to="Dimas", - source="AI signal", - ), - ReportTicket( - ticket_id="#PR-9819", - category="Seller", - priority="Hijau", - status="Butuh klarifikasi", - sla_remaining="04:20", - assigned_to="Nadia", - source="Internal QA", - ), - ReportTicket( - ticket_id="#PR-9805", - category="Pembeli", - priority="Kuning", - status="Menunggu bukti", - sla_remaining="02:55", - assigned_to="Belum ditetapkan", - source="Pelapor premium", - ), - ] + """Encapsulates moderation dashboard orchestration.""" - self._report_summary = [ - {"label": "Total antrean", "value": "86", "tone": "info"}, - {"label": "Over SLA", "value": "9", "tone": "danger"}, - {"label": "Butuh eskalasi", "value": "5", "tone": "warning"}, - {"label": "Mode fokus aktif", "value": "3 moderator", "tone": "primary"}, - ] - - self._curation_submissions = [ - CurationSubmission( - brand="Aurora Glow", - submission_type="Pengajuan Brand", - owner="Amelia R.", - status="Menunggu admin", - updated="23 menit lalu", - notes="Perlu verifikasi SIUP & legalitas distributor.", - ), - CurationSubmission( - brand="Rantau Craft", - submission_type="Kampanye", - owner="Galih P.", - status="Butuh revisi", - updated="1 jam lalu", - notes="Foto hero tidak sesuai panduan, minta versi ulang.", - ), - CurationSubmission( - brand="Laguna Living", - submission_type="Produk Baru", - owner="Intan M.", - status="Disetujui", - updated="Kemarin", - notes="Produk otomatis aktif karena brand sudah terverifikasi.", - ), - ] - - self._curation_summary = [ - {"label": "Pengajuan baru", "value": "18", "tone": "primary"}, - {"label": "Brand high risk", "value": "3", "tone": "danger"}, - {"label": "Butuh revisi", "value": "7", "tone": "warning"}, - {"label": "Verifikasi otomatis", "value": "42 produk", "tone": "success"}, - ] - - self._checklist_highlights = [ - "Verifikasi legalitas brand minimal 2 dokumen valid.", - "Checklist foto produk wajib resolusi > 1200px.", - "Pastikan riwayat pelanggaran brand < 2 dalam 90 hari.", - ] - - self._insights = [ - InsightTrend( - label="Trend laporan mingguan", - current="+18%", - change="Lonjakan dari kategori transaksi", - tone="warning", - ), - InsightTrend( - label="Kurasi disetujui", - current="74%", - change="Stabil dibanding minggu lalu", - tone="info", - ), - InsightTrend( - label="SLA 4 jam terpenuhi", - current="91%", - change="Target minimal 90% terpenuhi", - tone="success", - ), - ] - - self._heatmap = [ - HeatmapSlot(label="07.00-09.00", load="78%", state="padat"), - HeatmapSlot(label="09.00-11.00", load="95%", state="kritikal"), - HeatmapSlot(label="11.00-13.00", load="68%", state="stabil"), - HeatmapSlot(label="13.00-15.00", load="54%", state="rendah"), - ] - - self._violations = [ - {"category": "Penipuan pembayaran", "count": 21}, - {"category": "Konten SARA", "count": 15}, - {"category": "Pelanggaran hak cipta", "count": 11}, - ] - - self._team_productivity = [ - {"name": "Nadia", "resolved": 18, "accuracy": "94%"}, - {"name": "Dimas", "resolved": 15, "accuracy": "89%"}, - {"name": "Sela", "resolved": 12, "accuracy": "93%"}, - ] - - self._policies = [ - PolicyUpdate( - title="SOP Verifikasi Pembayaran", - version="v2.1", - updated_at="13 Mei 2024", - owner="Arif", - tags=["transaksi", "compliance"], - ), - PolicyUpdate( - title="Panduan Konten Sensitif", - version="v1.4", - updated_at="10 Mei 2024", - owner="Nadia", - tags=["konten", "komunitas"], - ), - PolicyUpdate( - title="Checklist Kurasi Brand Premium", - version="v0.9", - updated_at="8 Mei 2024", - owner="Sela", - tags=["kurasi", "brand"], - ), - ] - - self._templates = [ - { - "name": "Template konfirmasi bukti tambahan", - "usage": "Moderator", - "updated_at": "Kemarin", - }, - { - "name": "Template permintaan revisi brand", - "usage": "Kurator", - "updated_at": "2 hari lalu", - }, - { - "name": "Template eskalasi ke legal", - "usage": "Admin", - "updated_at": "Minggu lalu", - }, - ] - - self._automation_rules = [ - "Laporan prioritas merah tanpa respon >2 jam otomatis eskalasi ke admin.", - "Brand dengan skor risiko > 70 dikirim ke Quality Lead untuk audit.", - "3 pelanggaran serupa dalam 30 hari memicu suspend sementara 48 jam.", - ] - - self._help_resources = [ - HelpResource( - title="Panduan cepat eskalasi kasus penipuan", - category="Moderator", - format="Playbook", - updated_at="1 minggu lalu", - ), - HelpResource( - title="Checklist onboarding kurator", - category="Kurator", - format="Spreadsheet", - updated_at="3 hari lalu", - ), - HelpResource( - title="Video refresher audit SOP", - category="Quality Lead", - format="Video", - updated_at="April 2024", - ), - ] - - self._contacts = [ - ContactPoint( - name="Arif Santoso", - role="Admin Utama", - channel="Slack #ops-escalation", - availability="07.00 - 21.00", - ), - ContactPoint( - name="Intan Pratiwi", - role="Legal Advisor", - channel="legal@sensasiwangi.id", - availability="Hari kerja", - ), - ContactPoint( - name="Rudi Hartono", - role="Quality Lead", - channel="Ext. 8891", - availability="09.00 - 18.00", - ), - ] + def __init__(self, repository: Optional[ModerationDashboardRepository] = None) -> None: + self._repository = repository or ModerationDashboardRepository() def get_snapshot(self) -> dict: """Return the moderation dashboard snapshot.""" - return { - "persona": self._persona, - "kpis": self._kpis, - "alerts": self._alerts, - "report_summary": self._report_summary, - "team_members": self._team_members, - "pending_invites": self._pending_invites, - "audit_trail": self._audit_trail, - "report_tickets": self._report_tickets, - "curation_summary": self._curation_summary, - "curation_submissions": self._curation_submissions, - "checklist_highlights": self._checklist_highlights, - "insights": self._insights, - "heatmap": self._heatmap, - "violations": self._violations, - "team_productivity": self._team_productivity, - "policies": self._policies, - "templates": self._templates, - "automation_rules": self._automation_rules, - "help_resources": self._help_resources, - "contacts": self._contacts, - } - - -moderation_dashboard_service = ModerationDashboardService() + return self._repository.fetch_dashboard_snapshot() diff --git a/src/app/services/moderation_models.py b/src/app/services/moderation_models.py new file mode 100644 index 0000000..749e3ba --- /dev/null +++ b/src/app/services/moderation_models.py @@ -0,0 +1,148 @@ +"""Shared dataclasses for the moderation dashboard domain.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + + +@dataclass(frozen=True) +class ModerationKPI: + """Headline metric surfaced in the overview hero.""" + + label: str + value: str + delta: str + tone: str + + +@dataclass(frozen=True) +class ModerationAlert: + """Realtime alert or escalation that needs attention.""" + + title: str + description: str + severity: str + timestamp: str + + +@dataclass(frozen=True) +class TeamMemberSummary: + """Represents a moderator/curator/admin within the roster.""" + + name: str + role: str + status: str + active_cases: int + last_active: str + shift: str + + +@dataclass(frozen=True) +class PendingInvitation: + """Invitation that is waiting for activation.""" + + email: str + role: str + sent_at: str + expires_at: str + + +@dataclass(frozen=True) +class AuditTrailEntry: + """Audit log row for transparency and compliance.""" + + time: str + actor: str + action: str + + +@dataclass(frozen=True) +class ReportTicket: + """Queue item in the moderation pipeline.""" + + ticket_id: str + category: str + priority: str + status: str + sla_remaining: str + assigned_to: str + source: str + + +@dataclass(frozen=True) +class CurationSubmission: + """Submission tracked in the curator module.""" + + brand: str + submission_type: str + owner: str + status: str + updated: str + notes: str + + +@dataclass(frozen=True) +class InsightTrend: + """Analytic trend metric.""" + + label: str + current: str + change: str + tone: str + + +@dataclass(frozen=True) +class HeatmapSlot: + """Represents a block in the workload heatmap.""" + + label: str + load: str + state: str + + +@dataclass(frozen=True) +class PolicyUpdate: + """Policy changes that admins can audit.""" + + title: str + version: str + updated_at: str + owner: str + tags: List[str] + + +@dataclass(frozen=True) +class HelpResource: + """FAQ or learning material for the internal help center.""" + + title: str + category: str + format: str + updated_at: str + + +@dataclass(frozen=True) +class ContactPoint: + """Escalation contact for the team.""" + + name: str + role: str + channel: str + availability: str + + +__all__ = [ + "ModerationKPI", + "ModerationAlert", + "TeamMemberSummary", + "PendingInvitation", + "AuditTrailEntry", + "ReportTicket", + "CurationSubmission", + "InsightTrend", + "HeatmapSlot", + "PolicyUpdate", + "HelpResource", + "ContactPoint", +] diff --git a/src/app/services/moderation_repository.py b/src/app/services/moderation_repository.py new file mode 100644 index 0000000..f8023c9 --- /dev/null +++ b/src/app/services/moderation_repository.py @@ -0,0 +1,559 @@ +"""Repository layer for the moderation dashboard domain structures.""" + +from __future__ import annotations + +from collections import Counter +from datetime import UTC, date, datetime, timedelta +from pathlib import Path +from typing import Iterable, List, Sequence + +from app.services.brands import Brand, BrandHighlight, BrandProduct, BrandService, brand_service +from app.services.moderation_models import ( + AuditTrailEntry, + ContactPoint, + CurationSubmission, + HeatmapSlot, + HelpResource, + InsightTrend, + ModerationAlert, + ModerationKPI, + PendingInvitation, + PolicyUpdate, + ReportTicket, + TeamMemberSummary, +) +from app.services.reporting import SalesRecord, SalesReportService + + +class ModerationDashboardRepository: + """Access layer aggregating data for the moderation dashboard.""" + + def __init__( + self, + *, + brand_source: BrandService | None = None, + sales_source: SalesReportService | None = None, + plan_path: Path | None = None, + ) -> None: + self._brand_source = brand_source or brand_service + self._sales_source = sales_source or SalesReportService() + self._plan_path = plan_path or Path(__file__).resolve().parents[3] / "docs" / "moderation-dashboard-plan.md" + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def fetch_dashboard_snapshot(self) -> dict: + """Compose the dashboard snapshot from upstream sources.""" + + persona = self._build_persona() + team_members = self._build_team_members() + pending_invites = self._build_pending_invites() + sales_records = self._load_recent_sales() + + report_tickets = self._build_report_tickets(team_members, sales_records) + curation_submissions = self._build_curation_submissions() + + return { + "persona": persona, + "kpis": self._build_kpis(sales_records, pending_invites), + "alerts": self._build_alerts(), + "report_summary": self._build_report_summary(report_tickets), + "team_members": team_members, + "pending_invites": pending_invites, + "audit_trail": self._build_audit_trail(), + "report_tickets": report_tickets, + "curation_summary": self._build_curation_summary(curation_submissions), + "curation_submissions": curation_submissions, + "checklist_highlights": self._extract_plan_bullets("## 8. KPI & Monitoring Keberhasilan"), + "insights": self._build_insights(sales_records), + "heatmap": self._build_heatmap(sales_records), + "violations": self._build_violation_stats(sales_records), + "team_productivity": self._build_team_productivity(team_members), + "policies": self._build_policies(persona["name"]), + "templates": self._build_templates(), + "automation_rules": self._extract_plan_bullets("### Mekanisme Monitoring"), + "help_resources": self._build_help_resources(), + "contacts": self._build_contacts(team_members), + } + + # ------------------------------------------------------------------ + # Persona & team helpers + # ------------------------------------------------------------------ + def _build_persona(self) -> dict: + brand = self._brand_source.get_brand("langit-senja") + owners = brand.list_owners() + owner = owners[0] if owners else brand.members[0] + + today = datetime.now(UTC).astimezone().strftime("%A, %d %B %Y") + pending_invites = sum(len(b.list_pending_members()) for b in self._brand_source.list_brands()) + + return { + "name": owner.full_name, + "role": "Admin Utama Moderasi", + "mission": f"Sinkronkan moderasi & kurasi lintas brand seperti {brand.name} agar SLA terjaga.", + "shift": "Shift Pagi • 07.00 - 15.00 WIB", + "date": today, + "focus": "Pantau eskalasi transaksi besar dan onboarding anggota komunitas terbaru.", + "acknowledgement": f"{pending_invites} undangan tim menunggu aktivasi.", + } + + def _build_team_members(self) -> List[TeamMemberSummary]: + roster: List[TeamMemberSummary] = [] + shift_cycle = ["Pagi", "Siang", "Malam"] + status_map = { + "active": "Sedang bertugas", + "pending": "Menunggu aktivasi", + "inactive": "Tidak aktif", + } + + for index, brand in enumerate(self._brand_source.list_brands()): + for member in brand.members: + status = status_map.get(member.status, member.status.title()) + position = len(roster) + active_cases = 2 + (index + position) % 5 + last_active = f"{(index + position) * 3 or 1} menit lalu" + role = "Admin" if member.role == "owner" else "Kurator" if member.role == "co-owner" else member.role.title() + roster.append( + TeamMemberSummary( + name=member.full_name, + role=role, + status=status, + active_cases=active_cases, + last_active=last_active, + shift=shift_cycle[(index + position) % len(shift_cycle)], + ) + ) + + return roster[:5] + + def _build_pending_invites(self) -> List[PendingInvitation]: + invites: List[PendingInvitation] = [] + now = datetime.now().astimezone() + for offset, brand in enumerate(self._brand_source.list_brands()): + for member in brand.list_pending_members(): + sent_at = now - timedelta(hours=offset * 6) + expires_at = sent_at + timedelta(days=1) + invites.append( + PendingInvitation( + email=f"{member.username}@sensasiwangi.id", + role=member.role.title(), + sent_at=sent_at.strftime("%d %b %Y • %H:%M"), + expires_at=expires_at.strftime("%d %b %Y"), + ) + ) + + return invites + + # ------------------------------------------------------------------ + # KPI & insight helpers + # ------------------------------------------------------------------ + def _load_recent_sales(self) -> List[SalesRecord]: + today = date.today() + start = today - timedelta(days=6) + return self._sales_source.get_sales_report(start, today) + + def _build_kpis(self, sales_records: Sequence[SalesRecord], invites: Sequence[PendingInvitation]) -> List[ModerationKPI]: + total_amount = sum(record.total_amount for record in sales_records) + pending = sum(1 for record in sales_records if record.status != "settled") + avg_items = (sum(record.total_items for record in sales_records) / len(sales_records)) if sales_records else 0 + unique_customers = {record.customer_name for record in sales_records} + transfer_orders = sum(1 for record in sales_records if record.payment_method == "transfer") + + return [ + ModerationKPI( + label="Order 7 hari terakhir", + value=str(len(sales_records)), + delta=f"{pending} menunggu penyelesaian", + tone="info", + ), + ModerationKPI( + label="Total nilai transaksi", + value=self._format_currency(total_amount), + delta=f"{len(unique_customers)} pelanggan unik", + tone="success" if total_amount else "info", + ), + ModerationKPI( + label="Rata-rata item per order", + value=f"{avg_items:.1f}", + delta=f"{transfer_orders} via transfer", + tone="primary", + ), + ModerationKPI( + label="Onboarding anggota baru", + value=f"{len(invites)} undangan", + delta="Pantau aktivasi dalam 24 jam", + tone="warning" if invites else "info", + ), + ] + + def _build_insights(self, sales_records: Sequence[SalesRecord]) -> List[InsightTrend]: + if not sales_records: + return [] + + totals_by_day: Counter[date] = Counter() + for record in sales_records: + totals_by_day[record.order_date] += record.total_amount + + sorted_days = sorted(totals_by_day.keys()) + latest_day = sorted_days[-1] + previous_day = sorted_days[-2] if len(sorted_days) > 1 else sorted_days[-1] + latest_total = totals_by_day[latest_day] + previous_total = totals_by_day[previous_day] + delta = latest_total - previous_total + + avg_amount = sum(totals_by_day.values()) / len(totals_by_day) + highest_day = max(totals_by_day, key=totals_by_day.get) + + tone = "success" if delta >= 0 else "warning" + + return [ + InsightTrend( + label="Nilai transaksi harian", + current=self._format_currency(latest_total), + change=f"{self._format_currency(delta)} vs hari sebelumnya", + tone=tone, + ), + InsightTrend( + label="Rata-rata order harian", + current=self._format_currency(avg_amount), + change=f"Puncak di {highest_day.strftime('%d %b')}", + tone="info", + ), + InsightTrend( + label="Metode pembayaran populer", + current=self._top_payment_method(sales_records), + change="Optimalkan SOP verifikasi manual", + tone="warning", + ), + ] + + def _build_heatmap(self, sales_records: Sequence[SalesRecord]) -> List[HeatmapSlot]: + if not sales_records: + return [] + + totals = Counter(record.order_date.strftime("%a") for record in sales_records) + max_total = max(totals.values()) or 1 + slots: List[HeatmapSlot] = [] + for day in ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]: + if day not in totals: + continue + load_percent = int((totals[day] / max_total) * 100) + state = "stabil" + if load_percent >= 90: + state = "kritikal" + elif load_percent >= 70: + state = "padat" + elif load_percent <= 40: + state = "rendah" + slots.append(HeatmapSlot(label=day, load=f"{load_percent}%", state=state)) + + return slots + + def _build_violation_stats(self, sales_records: Sequence[SalesRecord]) -> List[dict]: + categories = Counter(record.payment_method for record in sales_records) + mapping = { + "transfer": "Verifikasi transfer", + "virtual_account": "Virtual account", + "ewallet": "Dompet digital", + "cash_on_delivery": "COD", + } + return [ + {"category": mapping.get(method, method.title()), "count": count} + for method, count in categories.most_common() + ] + + # ------------------------------------------------------------------ + # Alerts & narrative helpers + # ------------------------------------------------------------------ + def _build_alerts(self) -> List[ModerationAlert]: + alerts: List[ModerationAlert] = [] + for highlight in self._iter_highlights(): + severity = "pengingat" + if "Sambatan" in highlight.title: + severity = "kritis" + elif "Nusantarum" in highlight.title: + severity = "penting" + alerts.append( + ModerationAlert( + title=highlight.title, + description=highlight.description, + severity=severity, + timestamp=highlight.timestamp, + ) + ) + return alerts[:3] + + def _build_audit_trail(self) -> List[AuditTrailEntry]: + entries: List[AuditTrailEntry] = [] + for index, (brand, story) in enumerate(self._iter_story_points()): + owner = brand.list_owners() + actor = owner[0].full_name if owner else brand.name + time = f"{8 + index:02d}:{15 + index * 7:02d}" + entries.append( + AuditTrailEntry( + time=time, + actor=actor, + action=story, + ) + ) + return entries[:3] + + # ------------------------------------------------------------------ + # Report & curation helpers + # ------------------------------------------------------------------ + def _build_report_tickets( + self, + team_members: Sequence[TeamMemberSummary], + sales_records: Sequence[SalesRecord], + ) -> List[ReportTicket]: + tickets: List[ReportTicket] = [] + assignees = [member.name for member in team_members] or ["Tim Moderasi"] + + for index, record in enumerate(sorted(sales_records, key=lambda r: r.total_amount, reverse=True)): + priority = self._classify_priority(record.total_amount) + status = "Menunggu admin" if record.status != "settled" else "Selesai" + sla_remaining = "-00:45" if index == 0 and status != "Selesai" else f"0{2 + index}:30" + tickets.append( + ReportTicket( + ticket_id=record.order_id, + category=self._map_payment_to_category(record.payment_method), + priority=priority, + status=status, + sla_remaining=sla_remaining, + assigned_to=assignees[index % len(assignees)], + source=record.payment_method.replace("_", " ").title(), + ) + ) + + return tickets[:4] + + def _build_report_summary(self, report_tickets: Sequence[ReportTicket]) -> List[dict]: + total = len(report_tickets) + overdue = sum(1 for ticket in report_tickets if ticket.sla_remaining.startswith("-")) + critical = sum(1 for ticket in report_tickets if ticket.priority == "Merah") + waiting_assignment = sum(1 for ticket in report_tickets if ticket.assigned_to == "Belum ditetapkan") + + return [ + {"label": "Total antrean", "value": str(total), "tone": "info"}, + {"label": "Over SLA", "value": str(overdue), "tone": "danger"}, + {"label": "Prioritas merah", "value": str(critical), "tone": "warning"}, + {"label": "Belum ditetapkan", "value": f"{waiting_assignment} tiket", "tone": "primary"}, + ] + + def _build_curation_submissions(self) -> List[CurationSubmission]: + submissions: List[CurationSubmission] = [] + now = datetime.now().astimezone() + for index, (brand, product) in enumerate(self._iter_products()): + status = "Disetujui" if brand.is_verified else "Menunggu admin" + if product.is_sambatan: + status = "Butuh klarifikasi" + updated = now - timedelta(minutes=15 * (index + 1)) + notes = f"{brand.name}: {product.hero_note}" + submissions.append( + CurationSubmission( + brand=brand.name, + submission_type="Produk Sambatan" if product.is_sambatan else "Koleksi Brand", + owner=brand.list_owners()[0].full_name if brand.list_owners() else brand.name, + status=status, + updated=updated.strftime("%d %b %Y" if index > 1 else "%H:%M"), + notes=notes, + ) + ) + return submissions[:3] + + def _build_curation_summary(self, submissions: Sequence[CurationSubmission]) -> List[dict]: + totals = Counter(submission.status for submission in submissions) + return [ + {"label": "Total pengajuan", "value": str(sum(totals.values())), "tone": "primary"}, + {"label": "Disetujui", "value": str(totals.get("Disetujui", 0)), "tone": "success"}, + {"label": "Menunggu admin", "value": str(totals.get("Menunggu admin", 0)), "tone": "info"}, + {"label": "Butuh klarifikasi", "value": str(totals.get("Butuh klarifikasi", 0)), "tone": "warning"}, + ] + + # ------------------------------------------------------------------ + # Handbook & policy helpers + # ------------------------------------------------------------------ + def _build_team_productivity(self, team_members: Sequence[TeamMemberSummary]) -> List[dict]: + stats = [] + for index, member in enumerate(team_members): + resolved = 12 + index * 3 + accuracy = 90 + (index % 3) * 2 + stats.append( + {"name": member.name.split()[0], "resolved": resolved, "accuracy": f"{accuracy}%"} + ) + return stats + + def _build_policies(self, owner_name: str) -> List[PolicyUpdate]: + bullets = self._extract_plan_bullets("## 7. Kebijakan Akses & Peran") + if self._plan_path.exists(): + updated_at = datetime.fromtimestamp(self._plan_path.stat().st_mtime).strftime("%d %b %Y") + else: + updated_at = datetime.now().strftime("%d %b %Y") + policies: List[PolicyUpdate] = [] + for index, bullet in enumerate(bullets[:3], start=1): + title = bullet.split("|")[0].strip().strip("-").strip() + title = title.strip("* ") + tags = [tag.strip().lower() for tag in title.split()[:2]] + policies.append( + PolicyUpdate( + title=title or f"Kebijakan #{index}", + version=f"v1.{index}", + updated_at=updated_at, + owner=owner_name, + tags=tags, + ) + ) + return policies + + def _build_templates(self) -> List[dict]: + return [ + { + "name": "Template follow-up transaksi", + "usage": "Moderator", + "updated_at": "Minggu ini", + }, + { + "name": "Template klarifikasi kurasi", + "usage": "Kurator", + "updated_at": "3 hari lalu", + }, + { + "name": "Template eskalasi legal", + "usage": "Admin", + "updated_at": "1 minggu lalu", + }, + ] + + def _build_help_resources(self) -> List[HelpResource]: + bullets = self._extract_plan_bullets("## 5. Alur Pengguna Kunci") + defaults = [ + ( + "Panduan audit quality lead", + "Quality Lead", + "Playbook", + "April 2024", + ), + ( + "Checklist onboarding moderator", + "Moderator", + "Spreadsheet", + "Maret 2024", + ), + ( + "Video training kurator", + "Kurator", + "Video", + "Februari 2024", + ), + ] + + resources: List[HelpResource] = [] + for bullet, default in zip(bullets, defaults): + resources.append( + HelpResource( + title=bullet, + category=default[1], + format=default[2], + updated_at=default[3], + ) + ) + + if not resources: + resources = [ + HelpResource( + title=title, + category=category, + format=file_format, + updated_at=updated, + ) + for title, category, file_format, updated in defaults + ] + + return resources + + def _build_contacts(self, team_members: Sequence[TeamMemberSummary]) -> List[ContactPoint]: + contacts = [] + for index, member in enumerate(team_members[:3]): + contacts.append( + ContactPoint( + name=member.name, + role=member.role, + channel=f"Slack #{member.name.split()[0].lower()}-ops", + availability="09.00 - 18.00", + ) + ) + return contacts + + # ------------------------------------------------------------------ + # Iterators & utilities + # ------------------------------------------------------------------ + def _iter_highlights(self) -> Iterable[BrandHighlight]: + for brand in self._brand_source.list_brands(): + for highlight in brand.highlights: + yield highlight + + def _iter_story_points(self) -> Iterable[tuple[Brand, str]]: + for brand in self._brand_source.list_brands(): + for story in brand.story_points: + yield brand, story + + def _iter_products(self) -> Iterable[tuple[Brand, BrandProduct]]: + for brand in self._brand_source.list_brands(): + for product in brand.products: + yield brand, product + + def _extract_plan_bullets(self, marker: str, limit: int = 3) -> List[str]: + if not self._plan_path.exists(): + return [] + + text = self._plan_path.read_text(encoding="utf-8") + try: + section = text.split(marker, maxsplit=1)[1] + except IndexError: + return [] + + lines: List[str] = [] + for raw_line in section.splitlines()[1:]: + if raw_line.startswith("## ") and marker.startswith("## "): + break + if raw_line.startswith("### ") and marker.startswith("### "): + break + stripped = raw_line.strip() + if stripped.startswith("- "): + lines.append(stripped[2:].strip()) + if len(lines) >= limit: + break + return lines[:limit] + + @staticmethod + def _format_currency(amount: float) -> str: + return f"Rp {int(amount):,}".replace(",", ".") + + @staticmethod + def _top_payment_method(records: Sequence[SalesRecord]) -> str: + if not records: + return "-" + method = Counter(record.payment_method for record in records).most_common(1)[0][0] + return method.replace("_", " ").title() + + @staticmethod + def _classify_priority(total_amount: float) -> str: + if total_amount >= 5_000_000: + return "Merah" + if total_amount >= 3_500_000: + return "Kuning" + return "Hijau" + + @staticmethod + def _map_payment_to_category(payment_method: str) -> str: + mapping = { + "transfer": "Transaksi", + "virtual_account": "Transaksi", + "ewallet": "Pembeli", + "cash_on_delivery": "Pembeli", + } + return mapping.get(payment_method, "Lainnya") + + +__all__ = ["ModerationDashboardRepository"] + diff --git a/tests/test_moderation_dashboard.py b/tests/test_moderation_dashboard.py index a70a54a..0a84195 100644 --- a/tests/test_moderation_dashboard.py +++ b/tests/test_moderation_dashboard.py @@ -1,17 +1,30 @@ """Integration tests for the moderation dashboard route.""" +from unittest.mock import MagicMock + from fastapi.testclient import TestClient from app.core.application import create_app +from app.services.moderation_repository import ModerationDashboardRepository def test_moderation_dashboard_renders_snapshot_artifacts() -> None: - """The moderation dashboard should render the demo snapshot data.""" + """The moderation dashboard should render data returned by the service.""" + + repository = ModerationDashboardRepository() + snapshot = repository.fetch_dashboard_snapshot() + snapshot["persona"]["name"] = "Repository Persona" + + fake_service = MagicMock() + fake_service.get_snapshot.return_value = snapshot app = create_app() + app.state.moderation_dashboard_service = fake_service + with TestClient(app) as client: response = client.get("/dashboard/moderation") assert response.status_code == 200 body = response.text - assert "Dashboard Moderasi" in body or "Arif Santoso" in body + assert "Repository Persona" in body + fake_service.get_snapshot.assert_called_once_with() diff --git a/tests/test_moderation_dashboard_service.py b/tests/test_moderation_dashboard_service.py new file mode 100644 index 0000000..77d9d5a --- /dev/null +++ b/tests/test_moderation_dashboard_service.py @@ -0,0 +1,19 @@ +"""Unit tests for the moderation dashboard service.""" + +from unittest.mock import MagicMock + +from app.services.moderation_dashboard import ModerationDashboardService + + +def test_service_uses_repository_snapshot() -> None: + """The service should delegate snapshot generation to the repository.""" + + repository = MagicMock() + repository.fetch_dashboard_snapshot.return_value = {"persona": {"name": "Mock"}} + + service = ModerationDashboardService(repository=repository) + + snapshot = service.get_snapshot() + + repository.fetch_dashboard_snapshot.assert_called_once_with() + assert snapshot["persona"]["name"] == "Mock"