|
2 | 2 |
|
3 | 3 | from typing import Any |
4 | 4 |
|
| 5 | +from django.conf import settings |
5 | 6 | from django.db.models import Avg, Count, OuterRef, Prefetch, Q, Subquery |
6 | | -from drf_spectacular.utils import OpenApiParameter, extend_schema |
7 | | -from rest_framework import serializers, viewsets |
| 7 | +from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer |
| 8 | +from rest_framework import serializers, status, viewsets |
8 | 9 | from rest_framework.decorators import action |
9 | 10 | from rest_framework.filters import OrderingFilter |
10 | 11 | from rest_framework.response import Response |
|
19 | 20 | from core.permissions import IsProjectContributor, IsProjectMember |
20 | 21 | from trends.models import ( |
21 | 22 | ContentClusterMembership, |
| 23 | + OriginalContentIdea, |
22 | 24 | SourceDiversitySnapshot, |
23 | 25 | ThemeSuggestion, |
24 | 26 | ThemeSuggestionStatus, |
|
27 | 29 | TopicVelocitySnapshot, |
28 | 30 | ) |
29 | 31 | from trends.serializers import ( |
| 32 | + OriginalContentIdeaDismissSerializer, |
| 33 | + OriginalContentIdeaSerializer, |
30 | 34 | SourceDiversityObservabilitySummarySerializer, |
31 | 35 | SourceDiversitySnapshotSerializer, |
32 | 36 | ThemeSuggestionDismissSerializer, |
|
38 | 42 | TopicVelocitySnapshotSerializer, |
39 | 43 | ) |
40 | 44 | from trends.tasks import accept_theme_suggestion, dismiss_theme_suggestion |
| 45 | +from trends.tasks import ( |
| 46 | + accept_original_content_idea, |
| 47 | + dismiss_original_content_idea, |
| 48 | + generate_original_content_ideas, |
| 49 | + mark_original_content_idea_written, |
| 50 | +) |
41 | 51 |
|
42 | 52 |
|
43 | 53 | def _require_pk(instance: Any) -> int: |
@@ -278,6 +288,192 @@ def dismiss(self, request, *args, **kwargs): |
278 | 288 | return Response(response_serializer.data) |
279 | 289 |
|
280 | 290 |
|
| 291 | +@document_project_owned_viewset( |
| 292 | + resource_plural="original content ideas", |
| 293 | + resource_singular="original content idea", |
| 294 | + create_description="Original content ideas are pipeline-managed rows and are exposed read-only aside from editorial workflow actions.", |
| 295 | + tag="Trend Analysis", |
| 296 | + action_overrides=build_crud_action_overrides( |
| 297 | + OriginalContentIdeaSerializer, |
| 298 | + resource_plural="original content ideas for the selected project", |
| 299 | + resource_singular="original content idea", |
| 300 | + ), |
| 301 | +) |
| 302 | +class OriginalContentIdeaViewSet( |
| 303 | + ProjectOwnedQuerysetMixin, viewsets.ReadOnlyModelViewSet |
| 304 | +): |
| 305 | + """Inspect and resolve project-scoped original-content ideas.""" |
| 306 | + |
| 307 | + serializer_class = OriginalContentIdeaSerializer |
| 308 | + filter_backends = [OrderingFilter] |
| 309 | + ordering_fields = ["created_at", "self_critique_score", "status"] |
| 310 | + ordering = ["status", "-self_critique_score", "-created_at"] |
| 311 | + queryset = OriginalContentIdea.objects.select_related( |
| 312 | + "project", "related_cluster", "decided_by" |
| 313 | + ) |
| 314 | + |
| 315 | + def get_queryset(self): |
| 316 | + """Prefetch supporting contents for original-content idea responses.""" |
| 317 | + |
| 318 | + return ( |
| 319 | + super() |
| 320 | + .get_queryset() |
| 321 | + .select_related("related_cluster__dominant_entity") |
| 322 | + .prefetch_related( |
| 323 | + Prefetch( |
| 324 | + "supporting_contents", |
| 325 | + queryset=Content.objects.order_by("-published_date", "-id"), |
| 326 | + ) |
| 327 | + ) |
| 328 | + ) |
| 329 | + |
| 330 | + def get_permissions(self): |
| 331 | + """Allow members to read ideas and contributors to resolve them.""" |
| 332 | + |
| 333 | + if self.action in {"accept", "dismiss", "mark_written", "generate"}: |
| 334 | + return [IsProjectContributor()] |
| 335 | + return [IsProjectMember()] |
| 336 | + |
| 337 | + @extend_schema( |
| 338 | + summary="Generate original content ideas", |
| 339 | + description=( |
| 340 | + "Trigger original-content idea generation for the selected project. " |
| 341 | + "When Celery runs eagerly, ideas are generated before the response is returned. " |
| 342 | + "Otherwise, the generation task is queued for background execution." |
| 343 | + ), |
| 344 | + request=None, |
| 345 | + responses={ |
| 346 | + 200: inline_serializer( |
| 347 | + name="OriginalContentIdeaGenerateCompletedResponse", |
| 348 | + fields={ |
| 349 | + "status": serializers.CharField(), |
| 350 | + "project_id": serializers.IntegerField(), |
| 351 | + "result": inline_serializer( |
| 352 | + name="OriginalContentIdeaGenerateResult", |
| 353 | + fields={ |
| 354 | + "project_id": serializers.IntegerField(), |
| 355 | + "clusters_considered": serializers.IntegerField(), |
| 356 | + "created": serializers.IntegerField(), |
| 357 | + "skipped": serializers.IntegerField(), |
| 358 | + }, |
| 359 | + ), |
| 360 | + }, |
| 361 | + ), |
| 362 | + 202: inline_serializer( |
| 363 | + name="OriginalContentIdeaGenerateQueuedResponse", |
| 364 | + fields={ |
| 365 | + "status": serializers.CharField(), |
| 366 | + "project_id": serializers.IntegerField(), |
| 367 | + }, |
| 368 | + ), |
| 369 | + 403: AUTHENTICATION_REQUIRED_RESPONSE, |
| 370 | + }, |
| 371 | + tags=["Trend Analysis"], |
| 372 | + ) |
| 373 | + @action(detail=False, methods=["post"], url_path="generate") |
| 374 | + def generate(self, request, *args, **kwargs): |
| 375 | + """Trigger original-content idea generation for the current project.""" |
| 376 | + |
| 377 | + project = self.get_project() |
| 378 | + project_id = _require_pk(project) |
| 379 | + if settings.CELERY_TASK_ALWAYS_EAGER: |
| 380 | + result = generate_original_content_ideas(project_id) |
| 381 | + return Response( |
| 382 | + { |
| 383 | + "status": "completed", |
| 384 | + "project_id": project_id, |
| 385 | + "result": result, |
| 386 | + } |
| 387 | + ) |
| 388 | + generate_original_content_ideas.delay(project_id) |
| 389 | + return Response( |
| 390 | + {"status": "queued", "project_id": project_id}, |
| 391 | + status=status.HTTP_202_ACCEPTED, |
| 392 | + ) |
| 393 | + |
| 394 | + @extend_schema( |
| 395 | + summary="Accept original content idea", |
| 396 | + description="Mark a pending original content idea as accepted by the current editor.", |
| 397 | + request=None, |
| 398 | + responses={ |
| 399 | + 200: OriginalContentIdeaSerializer, |
| 400 | + 403: AUTHENTICATION_REQUIRED_RESPONSE, |
| 401 | + }, |
| 402 | + tags=["Trend Analysis"], |
| 403 | + ) |
| 404 | + @action(detail=True, methods=["post"], url_path="accept") |
| 405 | + def accept(self, request, *args, **kwargs): |
| 406 | + """Accept the selected pending original-content idea.""" |
| 407 | + |
| 408 | + idea = self.get_object() |
| 409 | + try: |
| 410 | + accept_original_content_idea(idea, user_id=request.user.id) |
| 411 | + except ValueError as exc: |
| 412 | + raise serializers.ValidationError( |
| 413 | + {"status": "Unable to accept this original content idea."} |
| 414 | + ) from exc |
| 415 | + response_serializer = self.get_serializer(idea) |
| 416 | + return Response(response_serializer.data) |
| 417 | + |
| 418 | + @extend_schema( |
| 419 | + summary="Dismiss original content idea", |
| 420 | + description="Dismiss a pending original content idea and persist the editor's reason.", |
| 421 | + request=OriginalContentIdeaDismissSerializer, |
| 422 | + responses={ |
| 423 | + 200: OriginalContentIdeaSerializer, |
| 424 | + 400: OriginalContentIdeaDismissSerializer, |
| 425 | + 403: AUTHENTICATION_REQUIRED_RESPONSE, |
| 426 | + }, |
| 427 | + tags=["Trend Analysis"], |
| 428 | + ) |
| 429 | + @action(detail=True, methods=["post"], url_path="dismiss") |
| 430 | + def dismiss(self, request, *args, **kwargs): |
| 431 | + """Dismiss the selected pending original-content idea.""" |
| 432 | + |
| 433 | + idea = self.get_object() |
| 434 | + serializer = OriginalContentIdeaDismissSerializer( |
| 435 | + data=request.data, |
| 436 | + context=self.get_serializer_context(), |
| 437 | + ) |
| 438 | + serializer.is_valid(raise_exception=True) |
| 439 | + try: |
| 440 | + dismiss_original_content_idea( |
| 441 | + idea, |
| 442 | + user_id=request.user.id, |
| 443 | + reason=serializer.validated_data["reason"], |
| 444 | + ) |
| 445 | + except ValueError as exc: |
| 446 | + raise serializers.ValidationError( |
| 447 | + {"status": "Unable to dismiss this original content idea."} |
| 448 | + ) from exc |
| 449 | + response_serializer = self.get_serializer(idea) |
| 450 | + return Response(response_serializer.data) |
| 451 | + |
| 452 | + @extend_schema( |
| 453 | + summary="Mark original content idea written", |
| 454 | + description="Mark an accepted original content idea as written by the current editor.", |
| 455 | + request=None, |
| 456 | + responses={ |
| 457 | + 200: OriginalContentIdeaSerializer, |
| 458 | + 403: AUTHENTICATION_REQUIRED_RESPONSE, |
| 459 | + }, |
| 460 | + tags=["Trend Analysis"], |
| 461 | + ) |
| 462 | + @action(detail=True, methods=["post"], url_path="mark_written") |
| 463 | + def mark_written(self, request, *args, **kwargs): |
| 464 | + """Mark the selected accepted original-content idea as written.""" |
| 465 | + |
| 466 | + idea = self.get_object() |
| 467 | + try: |
| 468 | + mark_original_content_idea_written(idea, user_id=request.user.id) |
| 469 | + except ValueError as exc: |
| 470 | + raise serializers.ValidationError( |
| 471 | + {"status": "Unable to mark this original content idea as written."} |
| 472 | + ) from exc |
| 473 | + response_serializer = self.get_serializer(idea) |
| 474 | + return Response(response_serializer.data) |
| 475 | + |
| 476 | + |
281 | 477 | @document_project_owned_viewset( |
282 | 478 | resource_plural="topic centroid snapshots", |
283 | 479 | resource_singular="topic centroid snapshot", |
|
0 commit comments