Split web detail payloads from related data

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funnywolf
2026-07-10 22:35:55 +08:00
co-authored by Copilot
parent 5cc4992d08
commit 90ac8385e0
8 changed files with 394 additions and 71 deletions
+106 -24
View File
@@ -1,38 +1,120 @@
from rest_framework import serializers
from rest_framework.permissions import SAFE_METHODS
from apps.enrichments.models import Enrichment
from .models import Alert
class AlertSerializer(serializers.ModelSerializer):
artifact_count = serializers.SerializerMethodField()
enrichment_count = serializers.SerializerMethodField()
class AlertDetailSerializer(serializers.ModelSerializer):
case_id = serializers.CharField(source="case.id", read_only=True)
case_readable_id = serializers.CharField(source="case.case_id", read_only=True)
case_title = serializers.CharField(source="case.title", read_only=True)
case_status = serializers.CharField(source="case.status", read_only=True)
case_category = serializers.CharField(source="case.category", read_only=True)
def get_artifact_count(self, obj):
request = self.context.get("request")
if request is not None and request.method not in SAFE_METHODS:
return obj.artifacts.count()
prefetched_artifacts = getattr(obj, "_prefetched_objects_cache", {}).get("artifacts")
if prefetched_artifacts is not None:
return len(prefetched_artifacts)
annotated_value = getattr(obj, "artifact_count", None)
if annotated_value is not None:
return annotated_value
return obj.artifacts.count()
def get_enrichment_count(self, obj):
return Enrichment.objects.filter(alert=obj).count()
class Meta:
model = Alert
fields = "__all__"
fields = (
"id",
"alert_id",
"case",
"case_id",
"case_readable_id",
"case_title",
"case_status",
"case_category",
"title",
"severity",
"confidence",
"impact",
"disposition",
"action",
"labels",
"desc",
"first_seen_time",
"last_seen_time",
"rule_id",
"rule_name",
"correlation_uid",
"src_url",
"source_uid",
"data_sources",
"analytic_name",
"analytic_type",
"analytic_state",
"analytic_desc",
"tactic",
"technique",
"sub_technique",
"mitigation",
"product_category",
"product_vendor",
"product_name",
"product_feature",
"policy_name",
"policy_type",
"policy_desc",
"risk_level",
"status",
"status_detail",
"remediation",
"unmapped",
"raw_data",
"created_at",
"updated_at",
)
read_only_fields = ("id", "alert_id", "created_at", "updated_at")
class AlertListSerializer(AlertDetailSerializer):
artifact_count = serializers.IntegerField(read_only=True, default=0)
enrichment_count = serializers.IntegerField(read_only=True, default=0)
class Meta(AlertDetailSerializer.Meta):
fields = (
"id",
"alert_id",
"case",
"case_id",
"case_readable_id",
"case_title",
"case_status",
"case_category",
"title",
"severity",
"confidence",
"impact",
"disposition",
"action",
"labels",
"desc",
"first_seen_time",
"last_seen_time",
"rule_id",
"rule_name",
"correlation_uid",
"src_url",
"source_uid",
"data_sources",
"analytic_name",
"analytic_type",
"analytic_state",
"analytic_desc",
"tactic",
"technique",
"sub_technique",
"mitigation",
"product_category",
"product_vendor",
"product_name",
"product_feature",
"policy_name",
"policy_type",
"policy_desc",
"risk_level",
"status",
"status_detail",
"remediation",
"artifact_count",
"enrichment_count",
"created_at",
"updated_at",
)
+41 -6
View File
@@ -1,4 +1,5 @@
from django.db.models import Count
from django.db.models import Count, IntegerField, OuterRef, Subquery, Value
from django.db.models.functions import Coalesce
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets, permissions
from rest_framework.filters import OrderingFilter, SearchFilter
@@ -6,13 +7,14 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from apps.accounts.permissions import IsBusinessWriterOrReadOnly
from apps.audit.mixins import AuditActorMixin
from apps.common.advanced_filters import AdvancedFilterBackend
from apps.enrichments.models import Enrichment
from .models import Alert
from .serializers import AlertSerializer
from .serializers import AlertDetailSerializer, AlertListSerializer
class AlertViewSet(AuditActorMixin, viewsets.ModelViewSet):
queryset = Alert.objects.select_related("case").prefetch_related("artifacts").order_by("-created_at")
serializer_class = AlertSerializer
queryset = Alert.objects.select_related("case").order_by("-created_at")
serializer_class = AlertDetailSerializer
permission_classes = [permissions.IsAuthenticated, IsBusinessWriterOrReadOnly]
lookup_field = "id"
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
@@ -85,11 +87,44 @@ class AlertViewSet(AuditActorMixin, viewsets.ModelViewSet):
raw_ordering = self.request.query_params.get("ordering", "")
return any(field.strip().lstrip("-") == "artifact_count" for field in raw_ordering.split(","))
def get_queryset(self):
queryset = super().get_queryset()
def annotate_list_counts(self, queryset):
if self.is_ordering_by_artifact_count():
queryset = queryset.annotate(artifact_count=Count("artifacts", distinct=True))
else:
artifact_count = (
Alert.artifacts.through.objects
.filter(alert_id=OuterRef("pk"))
.order_by()
.values("alert_id")
.annotate(count=Count("artifact_id"))
.values("count")[:1]
)
queryset = queryset.annotate(
artifact_count=Coalesce(Subquery(artifact_count, output_field=IntegerField()), Value(0))
)
enrichment_count = (
Enrichment.objects
.filter(alert_id=OuterRef("pk"))
.order_by()
.values("alert_id")
.annotate(count=Count("id"))
.values("count")[:1]
)
return queryset.annotate(
enrichment_count=Coalesce(Subquery(enrichment_count, output_field=IntegerField()), Value(0))
)
def get_queryset(self):
queryset = super().get_queryset()
if self.action == "list":
queryset = self.annotate_list_counts(queryset)
artifact_id = self.request.query_params.get("artifacts")
if artifact_id:
queryset = queryset.filter(artifacts__id=artifact_id)
return queryset
def get_serializer_class(self):
if self.action == "list":
return AlertListSerializer
return AlertDetailSerializer
+30 -15
View File
@@ -1,23 +1,38 @@
from rest_framework import serializers
from apps.enrichments.models import Enrichment
from .models import Artifact
class ArtifactSerializer(serializers.ModelSerializer):
alert_count = serializers.SerializerMethodField()
enrichment_count = serializers.SerializerMethodField()
def get_alert_count(self, obj):
annotated_value = getattr(obj, "alert_count", None)
if annotated_value is not None:
return annotated_value
return obj.alerts.count()
def get_enrichment_count(self, obj):
return Enrichment.objects.filter(artifact=obj).count()
class ArtifactDetailSerializer(serializers.ModelSerializer):
class Meta:
model = Artifact
fields = "__all__"
fields = (
"id",
"artifact_id",
"name",
"type",
"role",
"value",
"created_at",
"updated_at",
)
read_only_fields = ("id", "artifact_id", "created_at", "updated_at")
class ArtifactListSerializer(ArtifactDetailSerializer):
alert_count = serializers.IntegerField(read_only=True, default=0)
enrichment_count = serializers.IntegerField(read_only=True, default=0)
class Meta(ArtifactDetailSerializer.Meta):
fields = (
"id",
"artifact_id",
"name",
"type",
"role",
"value",
"alert_count",
"enrichment_count",
"created_at",
"updated_at",
)
+24 -3
View File
@@ -7,13 +7,14 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from apps.accounts.permissions import IsBusinessWriterOrReadOnly
from apps.audit.mixins import AuditActorMixin
from apps.common.advanced_filters import AdvancedFilterBackend
from apps.enrichments.models import Enrichment
from .models import Artifact
from .serializers import ArtifactSerializer
from .serializers import ArtifactDetailSerializer, ArtifactListSerializer
class ArtifactViewSet(AuditActorMixin, viewsets.ModelViewSet):
queryset = Artifact.objects.order_by("-created_at")
serializer_class = ArtifactSerializer
serializer_class = ArtifactDetailSerializer
permission_classes = [permissions.IsAuthenticated, IsBusinessWriterOrReadOnly]
lookup_field = "id"
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
@@ -50,9 +51,29 @@ class ArtifactViewSet(AuditActorMixin, viewsets.ModelViewSet):
alert_count=Coalesce(Subquery(alert_count, output_field=IntegerField()), Value(0))
)
def annotate_enrichment_count(self, queryset):
enrichment_count = (
Enrichment.objects
.filter(artifact_id=OuterRef("pk"))
.order_by()
.values("artifact_id")
.annotate(count=Count("id"))
.values("count")[:1]
)
return queryset.annotate(
enrichment_count=Coalesce(Subquery(enrichment_count, output_field=IntegerField()), Value(0))
)
def get_queryset(self):
queryset = self.annotate_alert_count(super().get_queryset())
queryset = super().get_queryset()
if self.action == "list":
queryset = self.annotate_enrichment_count(self.annotate_alert_count(queryset))
alert_id = self.request.query_params.get("alerts")
if alert_id:
queryset = queryset.filter(alerts__id=alert_id)
return queryset
def get_serializer_class(self):
if self.action == "list":
return ArtifactListSerializer
return ArtifactDetailSerializer
+72 -15
View File
@@ -1,17 +1,11 @@
from django.utils import timezone
from rest_framework import serializers
from apps.alerts.serializers import AlertSerializer
from apps.enrichments.models import Enrichment
from apps.inbox.notifications import notify_case_assignment
from .models import Case, CaseStatus
class CaseSerializer(serializers.ModelSerializer):
alerts = AlertSerializer(many=True, read_only=True)
alert_count = serializers.IntegerField(read_only=True, default=0)
playbook_count = serializers.IntegerField(read_only=True, default=0)
enrichment_count = serializers.SerializerMethodField()
class CaseDetailSerializer(serializers.ModelSerializer):
assignee_name = serializers.SerializerMethodField()
first_alert_seen_time = serializers.SerializerMethodField()
detection_time_seconds = serializers.SerializerMethodField()
@@ -26,9 +20,6 @@ class CaseSerializer(serializers.ModelSerializer):
def get_assignee_name(self, obj):
return self._get_user_name(obj.assignee)
def get_enrichment_count(self, obj):
return Enrichment.objects.filter(case=obj).count()
def _duration_seconds(self, start, end):
if not start or not end:
return None
@@ -87,11 +78,77 @@ class CaseSerializer(serializers.ModelSerializer):
class Meta:
model = Case
fields = "__all__"
fields = (
"id",
"case_id",
"title",
"severity",
"impact",
"priority",
"confidence",
"description",
"category",
"tags",
"status",
"verdict",
"summary",
"assignee",
"assignee_name",
"acknowledged_time",
"closed_time",
"correlation_uid",
"severity_ai",
"confidence_ai",
"impact_ai",
"priority_ai",
"verdict_ai",
"first_alert_seen_time",
"detection_time_seconds",
"acknowledgement_time_seconds",
"response_time_seconds",
"created_at",
"updated_at",
)
read_only_fields = ("id", "case_id", "created_at", "updated_at")
class CaseListSerializer(CaseSerializer):
class Meta(CaseSerializer.Meta):
exclude = ("investigation_report_ai_json",)
fields = None
class CaseListSerializer(CaseDetailSerializer):
alert_count = serializers.IntegerField(read_only=True, default=0)
playbook_count = serializers.IntegerField(read_only=True, default=0)
enrichment_count = serializers.IntegerField(read_only=True, default=0)
class Meta(CaseDetailSerializer.Meta):
fields = (
"id",
"case_id",
"title",
"severity",
"impact",
"priority",
"confidence",
"description",
"category",
"tags",
"status",
"verdict",
"summary",
"assignee",
"assignee_name",
"acknowledged_time",
"closed_time",
"correlation_uid",
"severity_ai",
"confidence_ai",
"impact_ai",
"priority_ai",
"verdict_ai",
"alert_count",
"playbook_count",
"enrichment_count",
"first_alert_seen_time",
"detection_time_seconds",
"acknowledgement_time_seconds",
"response_time_seconds",
"created_at",
"updated_at",
)
+45 -6
View File
@@ -2,20 +2,24 @@ from django.db.models import Count, DateTimeField, IntegerField, Min, OuterRef,
from django.db.models.functions import Coalesce
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.response import Response
from apps.accounts.permissions import IsBusinessWriterOrReadOnly
from apps.alerts.models import Alert
from apps.audit.context import audit_actor
from apps.audit.mixins import AuditActorMixin
from apps.common.advanced_filters import AdvancedFilterBackend
from apps.enrichments.models import Enrichment
from apps.playbooks.models import Playbook
from .models import Case
from .serializers import CaseListSerializer, CaseSerializer
from .serializers import CaseDetailSerializer, CaseListSerializer
class CaseViewSet(AuditActorMixin, viewsets.ModelViewSet):
queryset = Case.objects.select_related("assignee").order_by("-created_at")
serializer_class = CaseSerializer
serializer_class = CaseDetailSerializer
permission_classes = [permissions.IsAuthenticated, IsBusinessWriterOrReadOnly]
lookup_field = "id"
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
@@ -105,19 +109,54 @@ class CaseViewSet(AuditActorMixin, viewsets.ModelViewSet):
.order_by("first_seen_time")
.values("first_seen_time")[:1]
)
enrichment_count = (
Enrichment.objects
.filter(case_id=OuterRef("pk"))
.order_by()
.values("case_id")
.annotate(count=Count("id"))
.values("count")[:1]
)
return queryset.annotate(
alert_count=Coalesce(Subquery(alert_count, output_field=IntegerField()), Value(0)),
playbook_count=Coalesce(Subquery(playbook_count, output_field=IntegerField()), Value(0)),
enrichment_count=Coalesce(Subquery(enrichment_count, output_field=IntegerField()), Value(0)),
first_alert_seen_time=Subquery(first_alert_seen_time, output_field=DateTimeField()),
)
def annotate_detail_metrics(self, queryset):
first_alert_seen_time = (
Alert.objects
.filter(case_id=OuterRef("pk"), first_seen_time__isnull=False)
.order_by("first_seen_time")
.values("first_seen_time")[:1]
)
return queryset.annotate(
first_alert_seen_time=Subquery(first_alert_seen_time, output_field=DateTimeField()),
)
def get_queryset(self):
queryset = self.annotate_list_metrics(super().get_queryset())
if self.action == "list":
return queryset.defer("investigation_report_ai_json")
return queryset
return self.annotate_list_metrics(super().get_queryset()).defer("investigation_report_ai_json")
if self.action in {"retrieve", "update", "partial_update"}:
return self.annotate_detail_metrics(super().get_queryset()).defer("investigation_report_ai_json")
return super().get_queryset()
def get_serializer_class(self):
if self.action == "list":
return CaseListSerializer
return CaseSerializer
return CaseDetailSerializer
@action(detail=True, methods=["get", "patch"], url_path="investigation")
def investigation(self, request, *args, **kwargs):
case = self.get_object()
if request.method == "PATCH":
value = request.data.get("investigation_report_ai_json", "")
with audit_actor(request.user):
case.investigation_report_ai_json = value
case.save(update_fields=["investigation_report_ai_json", "updated_at"])
return Response({
"id": str(case.id),
"case_id": case.case_id,
"investigation_report_ai_json": case.investigation_report_ai_json,
})
@@ -0,0 +1,74 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {Alert, Button, Spin} from 'antd'
import {ReloadOutlined} from '@ant-design/icons'
import client from '../api/client'
import InvestigationReportView from './InvestigationReportView'
interface CaseInvestigationViewProps {
caseId: string
}
interface CaseInvestigationResponse {
investigation_report_ai_json: unknown
}
function errorMessage(error: unknown) {
const data = (error as { response?: { data?: unknown } }).response?.data
if (typeof data === 'string') return data
if (data && typeof data === 'object' && 'detail' in data) {
return String((data as { detail?: unknown }).detail || 'Failed to load investigation report')
}
return 'Failed to load investigation report'
}
export default function CaseInvestigationView({ caseId }: CaseInvestigationViewProps) {
const [value, setValue] = useState<unknown>('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const requestIdRef = useRef(0)
const loadReport = useCallback(() => {
if (!caseId) return
const requestId = requestIdRef.current + 1
requestIdRef.current = requestId
setLoading(true)
setError('')
client.get<CaseInvestigationResponse>(`/cases/${encodeURIComponent(caseId)}/investigation/`)
.then(({ data }) => {
if (requestId !== requestIdRef.current) return
setValue(data.investigation_report_ai_json || '')
})
.catch((loadError) => {
if (requestId !== requestIdRef.current) return
setValue('')
setError(errorMessage(loadError))
})
.finally(() => {
if (requestId === requestIdRef.current) setLoading(false)
})
}, [caseId])
useEffect(() => {
loadReport()
return () => {
requestIdRef.current += 1
}
}, [loadReport])
if (loading) return <Spin style={{ margin: 32 }} />
if (error) {
return (
<div style={{ padding: 20 }}>
<Alert
type="error"
showIcon
message={error}
action={<Button size="small" icon={<ReloadOutlined />} onClick={loadReport}>Retry</Button>}
/>
</div>
)
}
return <InvestigationReportView value={value} />
}
+2 -2
View File
@@ -14,10 +14,10 @@ import {BookOpenText, BrainCircuit, BriefcaseBusiness, Fingerprint, Siren, WandS
import AlertBasicView from '../components/AlertBasicView'
import ArtifactBasicView from '../components/ArtifactBasicView'
import CaseBasicView from '../components/CaseBasicView'
import CaseInvestigationView from '../components/CaseInvestigationView'
import CaseKnowledgeView from '../components/CaseKnowledgeView'
import CasePlaybookAction from '../components/CasePlaybookRunModal'
import EnrichmentBasicView from '../components/EnrichmentBasicView'
import InvestigationReportView from '../components/InvestigationReportView'
import KnowledgeBasicView from '../components/KnowledgeBasicView'
import OverflowTags from '../components/OverflowTags'
import PlaybookBasicView from '../components/PlaybookBasicView'
@@ -256,7 +256,7 @@ const emptyTabs = {
key: 'investigation',
label: 'Investigation',
icon: <FileTextOutlined/>,
render: (record: RecordRow) => <InvestigationReportView value={record.investigation_report_ai_json}/>,
render: (record: RecordRow) => <CaseInvestigationView caseId={String(record.id || '')}/>,
},
],
alert: [