Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 120 additions & 1 deletion core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,28 @@
EntityCandidate,
EntityMention,
IngestionRun,
IntakeAllowlist,
NewsletterIntake,
Project,
ProjectConfig,
ReviewQueue,
SkillResult,
SourceConfig,
TopicCentroidSnapshot,
UserFeedback,
generate_project_intake_token,
)
from core.serializers import (
BlueskyCredentialsSerializer,
ContentSerializer,
EntityAuthoritySnapshotSerializer,
EntityCandidateMergeSerializer,
EntityCandidateSerializer,
EntityMentionSummarySerializer,
EntitySerializer,
IngestionRunSerializer,
IntakeAllowlistSerializer,
NewsletterIntakeSerializer,
ProjectConfigSerializer,
ProjectSerializer,
ReviewQueueSerializer,
Expand Down Expand Up @@ -104,11 +110,35 @@
"group": 3,
"topic_description": "Coverage of developer tools, model releases, and applied AI workflows.",
"content_retention_days": 180,
"intake_token": "project-token-123",
"intake_enabled": True,
"has_bluesky_credentials": True,
"bluesky_handle": "aiweekly.bsky.social",
"bluesky_is_active": True,
"bluesky_last_verified_at": "2026-04-26T13:00:00Z",
"bluesky_last_error": "",
"created_at": "2026-04-26T12:00:00Z",
},
response_only=True,
)

BLUESKY_CREDENTIALS_RESPONSE_EXAMPLE = OpenApiExample(
"Bluesky Credentials Response",
value={
"id": 1,
"project": 1,
"handle": "aiweekly.bsky.social",
"pds_url": "",
"is_active": True,
"has_stored_credential": True,
"last_verified_at": "2026-04-26T13:00:00Z",
"last_error": "",
"created_at": "2026-04-26T12:30:00Z",
"updated_at": "2026-04-26T13:00:00Z",
},
response_only=True,
)

SOURCE_CONFIG_CREATE_REQUEST_EXAMPLE = OpenApiExample(
"Create RSS Source Request",
value={
Expand Down Expand Up @@ -594,14 +624,34 @@ class ProjectViewSet(viewsets.ModelViewSet):
"""Manage projects accessible through the current user's group memberships."""

serializer_class = ProjectSerializer
queryset = Project.objects.select_related("group")
queryset = Project.objects.select_related("group", "bluesky_credentials")
lookup_url_kwarg = "id"

def get_queryset(self):
"""Limit projects to those visible through the authenticated user."""

return self.queryset.filter(group__user=self.request.user).distinct()

@extend_schema(
summary="Rotate newsletter intake token",
description=(
"Generate a fresh project-specific newsletter intake token and return the "
"updated project payload."
),
tags=["Project Management"],
request=None,
responses={200: ProjectSerializer, 403: AUTHENTICATION_REQUIRED_RESPONSE},
)
@action(detail=True, methods=["post"], url_path="rotate-intake-token")
def rotate_intake_token(self, request, *args, **kwargs):
"""Generate a fresh intake token for the selected project."""

project = self.get_object()
project.intake_token = generate_project_intake_token()
project.save(update_fields=["intake_token"])
serializer = self.get_serializer(project)
return Response(serializer.data)

@extend_schema(
summary="Verify Bluesky credentials",
description=(
Expand Down Expand Up @@ -1013,6 +1063,75 @@ class IngestionRunViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet):
queryset = IngestionRun.objects.select_related("project")


@document_project_owned_viewset(
resource_plural="Bluesky credentials",
resource_singular="Bluesky credentials",
create_description=(
"Create Bluesky credentials for the selected project. The app password is "
"accepted write-only and is never returned in API responses."
),
tag="Ingestion",
action_overrides=build_crud_action_overrides(
BlueskyCredentialsSerializer,
resource_plural="Bluesky credentials for the selected project",
resource_singular="Bluesky credentials",
retrieve_examples=[BLUESKY_CREDENTIALS_RESPONSE_EXAMPLE],
),
)
class BlueskyCredentialsViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet):
"""Manage project-scoped Bluesky credentials."""

serializer_class = BlueskyCredentialsSerializer
queryset = BlueskyCredentials.objects.select_related("project")

def get_queryset(self):
"""Restrict credentials to the selected project and current user."""

return super().get_queryset().order_by("-updated_at")


@document_project_owned_viewset(
resource_plural="intake allowlist entries",
resource_singular="intake allowlist entry",
create_description=(
"Create a new confirmed or pending sender allowlist entry for the selected "
"project's newsletter intake workflow."
),
tag="Ingestion",
action_overrides=build_crud_action_overrides(
IntakeAllowlistSerializer,
resource_plural="intake allowlist entries for the selected project",
resource_singular="intake allowlist entry",
),
)
class IntakeAllowlistViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet):
"""Manage newsletter sender allowlist entries for a project."""

serializer_class = IntakeAllowlistSerializer
queryset = IntakeAllowlist.objects.select_related("project")


@document_project_owned_viewset(
resource_plural="newsletter intake entries",
resource_singular="newsletter intake entry",
create_description=(
"Newsletter intake entries are created by inbound email processing and are "
"exposed read-only for audit and troubleshooting."
),
tag="Ingestion",
action_overrides=build_crud_action_overrides(
NewsletterIntakeSerializer,
resource_plural="newsletter intake entries for the selected project",
resource_singular="newsletter intake entry",
),
)
class NewsletterIntakeViewSet(ProjectOwnedQuerysetMixin, viewsets.ReadOnlyModelViewSet):
"""Inspect inbound newsletter history for a project."""

serializer_class = NewsletterIntakeSerializer
queryset = NewsletterIntake.objects.select_related("project")


@document_project_owned_viewset(
resource_plural="source configurations",
resource_singular="source configuration",
Expand Down
18 changes: 18 additions & 0 deletions core/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
from rest_framework_nested.routers import NestedSimpleRouter

from core.api import (
BlueskyCredentialsViewSet,
ContentViewSet,
EntityCandidateViewSet,
EntityViewSet,
IngestionRunViewSet,
IntakeAllowlistViewSet,
NewsletterIntakeViewSet,
ProjectConfigViewSet,
ProjectViewSet,
ReviewQueueViewSet,
Expand Down Expand Up @@ -38,6 +41,21 @@
project_router.register(
r"ingestion-runs", IngestionRunViewSet, basename="project-ingestion-run"
)
project_router.register(
r"bluesky-credentials",
BlueskyCredentialsViewSet,
basename="project-bluesky-credentials",
)
project_router.register(
r"intake-allowlist",
IntakeAllowlistViewSet,
basename="project-intake-allowlist",
)
project_router.register(
r"newsletter-intakes",
NewsletterIntakeViewSet,
basename="project-newsletter-intake",
)
project_router.register(
r"source-configs", SourceConfigViewSet, basename="project-source-config"
)
Expand Down
127 changes: 127 additions & 0 deletions core/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from rest_framework import serializers

from core.models import (
BlueskyCredentials,
Content,
Entity,
EntityAuthoritySnapshot,
Expand Down Expand Up @@ -83,6 +84,12 @@ def __init__(self, *args, **kwargs):
class ProjectSerializer(ProjectScopedSerializerMixin, serializers.ModelSerializer):
"""Serialize top-level project records."""

has_bluesky_credentials = serializers.SerializerMethodField()
bluesky_handle = serializers.SerializerMethodField()
bluesky_is_active = serializers.SerializerMethodField()
bluesky_last_verified_at = serializers.SerializerMethodField()
bluesky_last_error = serializers.SerializerMethodField()

class Meta:
model = Project
fields = [
Expand All @@ -93,10 +100,52 @@ class Meta:
"content_retention_days",
"intake_token",
"intake_enabled",
"has_bluesky_credentials",
"bluesky_handle",
"bluesky_is_active",
"bluesky_last_verified_at",
"bluesky_last_error",
"created_at",
]
read_only_fields = ["id", "created_at"]

def _get_bluesky_credentials(self, obj: Project):
"""Return the project's stored Bluesky credentials, if configured."""

try:
return obj.bluesky_credentials
except Project.bluesky_credentials.RelatedObjectDoesNotExist:
return None

def get_has_bluesky_credentials(self, obj: Project) -> bool:
"""Return whether the project has stored Bluesky credentials."""

return self._get_bluesky_credentials(obj) is not None

def get_bluesky_handle(self, obj: Project) -> str:
"""Return the stored Bluesky handle, or an empty string."""

credentials = self._get_bluesky_credentials(obj)
return credentials.handle if credentials else ""

def get_bluesky_is_active(self, obj: Project) -> bool:
"""Return whether the stored Bluesky credentials are currently active."""

credentials = self._get_bluesky_credentials(obj)
return credentials.is_active if credentials else False

def get_bluesky_last_verified_at(self, obj: Project):
"""Return the last successful verification timestamp, if available."""

credentials = self._get_bluesky_credentials(obj)
return credentials.last_verified_at if credentials else None

def get_bluesky_last_error(self, obj: Project) -> str:
"""Return the latest Bluesky verification error, or an empty string."""

credentials = self._get_bluesky_credentials(obj)
return credentials.last_error if credentials else ""


class ProjectConfigSerializer(
ProjectScopedSerializerMixin, serializers.ModelSerializer
Expand All @@ -115,6 +164,81 @@ class Meta:
read_only_fields = ["id", "project"]


class BlueskyCredentialsSerializer(
ProjectScopedSerializerMixin, serializers.ModelSerializer
):
"""Serialize project-scoped Bluesky credentials without exposing secrets."""

app_password = serializers.CharField(
write_only=True,
required=False,
allow_blank=True,
trim_whitespace=False,
)
has_stored_credential = serializers.SerializerMethodField()

class Meta:
model = BlueskyCredentials
fields = [
"id",
"project",
"handle",
"pds_url",
"is_active",
"has_stored_credential",
"app_password",
"last_verified_at",
"last_error",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"project",
"has_stored_credential",
"last_verified_at",
"last_error",
"created_at",
"updated_at",
]

def get_has_stored_credential(self, obj: BlueskyCredentials) -> bool:
"""Return whether the project has an encrypted Bluesky credential stored."""

return obj.has_stored_credential()

def validate(self, attrs):
"""Require an app password when creating a credential record."""

attrs = super().validate(attrs)
app_password = attrs.get("app_password", "")
if self.instance is None and not app_password:
raise serializers.ValidationError(
{"app_password": "A Bluesky app credential is required."}
)
return attrs

def create(self, validated_data):
"""Encrypt the submitted Bluesky app password before saving the record."""

app_password = validated_data.pop("app_password", "")
instance = super().create(validated_data)
if app_password:
instance.set_app_password(app_password)
instance.save(update_fields=["app_password_encrypted", "updated_at"])
return instance

def update(self, instance, validated_data):
"""Keep the stored credential unless a replacement app password is submitted."""

app_password = validated_data.pop("app_password", "")
instance = super().update(instance, validated_data)
if app_password:
instance.set_app_password(app_password)
instance.save(update_fields=["app_password_encrypted", "updated_at"])
return instance


class EntitySerializer(ProjectScopedSerializerMixin, serializers.ModelSerializer):
"""Serialize tracked entities for a project."""

Expand Down Expand Up @@ -462,12 +586,15 @@ class IntakeAllowlistSerializer(
):
"""Serialize confirmed and pending newsletter sender allowlist entries."""

is_confirmed = serializers.BooleanField(read_only=True)

class Meta:
model = IntakeAllowlist
fields = [
"id",
"project",
"sender_email",
"is_confirmed",
"confirmed_at",
"confirmation_token",
"created_at",
Expand Down
Loading
Loading