Skip to content

Commit 53b658b

Browse files
committed
Instake API implementation
1 parent a06e869 commit 53b658b

22 files changed

Lines changed: 1678 additions & 24 deletions

File tree

core/api.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,28 @@
3535
EntityCandidate,
3636
EntityMention,
3737
IngestionRun,
38+
IntakeAllowlist,
39+
NewsletterIntake,
3840
Project,
3941
ProjectConfig,
4042
ReviewQueue,
4143
SkillResult,
4244
SourceConfig,
4345
TopicCentroidSnapshot,
4446
UserFeedback,
47+
generate_project_intake_token,
4548
)
4649
from core.serializers import (
50+
BlueskyCredentialsSerializer,
4751
ContentSerializer,
4852
EntityAuthoritySnapshotSerializer,
4953
EntityCandidateMergeSerializer,
5054
EntityCandidateSerializer,
5155
EntityMentionSummarySerializer,
5256
EntitySerializer,
5357
IngestionRunSerializer,
58+
IntakeAllowlistSerializer,
59+
NewsletterIntakeSerializer,
5460
ProjectConfigSerializer,
5561
ProjectSerializer,
5662
ReviewQueueSerializer,
@@ -116,6 +122,23 @@
116122
response_only=True,
117123
)
118124

125+
BLUESKY_CREDENTIALS_RESPONSE_EXAMPLE = OpenApiExample(
126+
"Bluesky Credentials Response",
127+
value={
128+
"id": 1,
129+
"project": 1,
130+
"handle": "aiweekly.bsky.social",
131+
"pds_url": "",
132+
"is_active": True,
133+
"has_stored_credential": True,
134+
"last_verified_at": "2026-04-26T13:00:00Z",
135+
"last_error": "",
136+
"created_at": "2026-04-26T12:30:00Z",
137+
"updated_at": "2026-04-26T13:00:00Z",
138+
},
139+
response_only=True,
140+
)
141+
119142
SOURCE_CONFIG_CREATE_REQUEST_EXAMPLE = OpenApiExample(
120143
"Create RSS Source Request",
121144
value={
@@ -609,6 +632,26 @@ def get_queryset(self):
609632

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

635+
@extend_schema(
636+
summary="Rotate newsletter intake token",
637+
description=(
638+
"Generate a fresh project-specific newsletter intake token and return the "
639+
"updated project payload."
640+
),
641+
tags=["Project Management"],
642+
request=None,
643+
responses={200: ProjectSerializer, 403: AUTHENTICATION_REQUIRED_RESPONSE},
644+
)
645+
@action(detail=True, methods=["post"], url_path="rotate-intake-token")
646+
def rotate_intake_token(self, request, *args, **kwargs):
647+
"""Generate a fresh intake token for the selected project."""
648+
649+
project = self.get_object()
650+
project.intake_token = generate_project_intake_token()
651+
project.save(update_fields=["intake_token"])
652+
serializer = self.get_serializer(project)
653+
return Response(serializer.data)
654+
612655
@extend_schema(
613656
summary="Verify Bluesky credentials",
614657
description=(
@@ -1020,6 +1063,75 @@ class IngestionRunViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet):
10201063
queryset = IngestionRun.objects.select_related("project")
10211064

10221065

1066+
@document_project_owned_viewset(
1067+
resource_plural="Bluesky credentials",
1068+
resource_singular="Bluesky credentials",
1069+
create_description=(
1070+
"Create Bluesky credentials for the selected project. The app password is "
1071+
"accepted write-only and is never returned in API responses."
1072+
),
1073+
tag="Ingestion",
1074+
action_overrides=build_crud_action_overrides(
1075+
BlueskyCredentialsSerializer,
1076+
resource_plural="Bluesky credentials for the selected project",
1077+
resource_singular="Bluesky credentials",
1078+
retrieve_examples=[BLUESKY_CREDENTIALS_RESPONSE_EXAMPLE],
1079+
),
1080+
)
1081+
class BlueskyCredentialsViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet):
1082+
"""Manage project-scoped Bluesky credentials."""
1083+
1084+
serializer_class = BlueskyCredentialsSerializer
1085+
queryset = BlueskyCredentials.objects.select_related("project")
1086+
1087+
def get_queryset(self):
1088+
"""Restrict credentials to the selected project and current user."""
1089+
1090+
return super().get_queryset().order_by("-updated_at")
1091+
1092+
1093+
@document_project_owned_viewset(
1094+
resource_plural="intake allowlist entries",
1095+
resource_singular="intake allowlist entry",
1096+
create_description=(
1097+
"Create a new confirmed or pending sender allowlist entry for the selected "
1098+
"project's newsletter intake workflow."
1099+
),
1100+
tag="Ingestion",
1101+
action_overrides=build_crud_action_overrides(
1102+
IntakeAllowlistSerializer,
1103+
resource_plural="intake allowlist entries for the selected project",
1104+
resource_singular="intake allowlist entry",
1105+
),
1106+
)
1107+
class IntakeAllowlistViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet):
1108+
"""Manage newsletter sender allowlist entries for a project."""
1109+
1110+
serializer_class = IntakeAllowlistSerializer
1111+
queryset = IntakeAllowlist.objects.select_related("project")
1112+
1113+
1114+
@document_project_owned_viewset(
1115+
resource_plural="newsletter intake entries",
1116+
resource_singular="newsletter intake entry",
1117+
create_description=(
1118+
"Newsletter intake entries are created by inbound email processing and are "
1119+
"exposed read-only for audit and troubleshooting."
1120+
),
1121+
tag="Ingestion",
1122+
action_overrides=build_crud_action_overrides(
1123+
NewsletterIntakeSerializer,
1124+
resource_plural="newsletter intake entries for the selected project",
1125+
resource_singular="newsletter intake entry",
1126+
),
1127+
)
1128+
class NewsletterIntakeViewSet(ProjectOwnedQuerysetMixin, viewsets.ReadOnlyModelViewSet):
1129+
"""Inspect inbound newsletter history for a project."""
1130+
1131+
serializer_class = NewsletterIntakeSerializer
1132+
queryset = NewsletterIntake.objects.select_related("project")
1133+
1134+
10231135
@document_project_owned_viewset(
10241136
resource_plural="source configurations",
10251137
resource_singular="source configuration",

core/api_urls.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
from rest_framework_nested.routers import NestedSimpleRouter
33

44
from core.api import (
5+
BlueskyCredentialsViewSet,
56
ContentViewSet,
67
EntityCandidateViewSet,
78
EntityViewSet,
89
IngestionRunViewSet,
10+
IntakeAllowlistViewSet,
11+
NewsletterIntakeViewSet,
912
ProjectConfigViewSet,
1013
ProjectViewSet,
1114
ReviewQueueViewSet,
@@ -38,6 +41,21 @@
3841
project_router.register(
3942
r"ingestion-runs", IngestionRunViewSet, basename="project-ingestion-run"
4043
)
44+
project_router.register(
45+
r"bluesky-credentials",
46+
BlueskyCredentialsViewSet,
47+
basename="project-bluesky-credentials",
48+
)
49+
project_router.register(
50+
r"intake-allowlist",
51+
IntakeAllowlistViewSet,
52+
basename="project-intake-allowlist",
53+
)
54+
project_router.register(
55+
r"newsletter-intakes",
56+
NewsletterIntakeViewSet,
57+
basename="project-newsletter-intake",
58+
)
4159
project_router.register(
4260
r"source-configs", SourceConfigViewSet, basename="project-source-config"
4361
)

core/serializers.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from rest_framework import serializers
1010

1111
from core.models import (
12+
BlueskyCredentials,
1213
Content,
1314
Entity,
1415
EntityAuthoritySnapshot,
@@ -163,6 +164,81 @@ class Meta:
163164
read_only_fields = ["id", "project"]
164165

165166

167+
class BlueskyCredentialsSerializer(
168+
ProjectScopedSerializerMixin, serializers.ModelSerializer
169+
):
170+
"""Serialize project-scoped Bluesky credentials without exposing secrets."""
171+
172+
app_password = serializers.CharField(
173+
write_only=True,
174+
required=False,
175+
allow_blank=True,
176+
trim_whitespace=False,
177+
)
178+
has_stored_credential = serializers.SerializerMethodField()
179+
180+
class Meta:
181+
model = BlueskyCredentials
182+
fields = [
183+
"id",
184+
"project",
185+
"handle",
186+
"pds_url",
187+
"is_active",
188+
"has_stored_credential",
189+
"app_password",
190+
"last_verified_at",
191+
"last_error",
192+
"created_at",
193+
"updated_at",
194+
]
195+
read_only_fields = [
196+
"id",
197+
"project",
198+
"has_stored_credential",
199+
"last_verified_at",
200+
"last_error",
201+
"created_at",
202+
"updated_at",
203+
]
204+
205+
def get_has_stored_credential(self, obj: BlueskyCredentials) -> bool:
206+
"""Return whether the project has an encrypted Bluesky credential stored."""
207+
208+
return obj.has_stored_credential()
209+
210+
def validate(self, attrs):
211+
"""Require an app password when creating a credential record."""
212+
213+
attrs = super().validate(attrs)
214+
app_password = attrs.get("app_password", "")
215+
if self.instance is None and not app_password:
216+
raise serializers.ValidationError(
217+
{"app_password": "A Bluesky app credential is required."}
218+
)
219+
return attrs
220+
221+
def create(self, validated_data):
222+
"""Encrypt the submitted Bluesky app password before saving the record."""
223+
224+
app_password = validated_data.pop("app_password", "")
225+
instance = super().create(validated_data)
226+
if app_password:
227+
instance.set_app_password(app_password)
228+
instance.save(update_fields=["app_password_encrypted", "updated_at"])
229+
return instance
230+
231+
def update(self, instance, validated_data):
232+
"""Keep the stored credential unless a replacement app password is submitted."""
233+
234+
app_password = validated_data.pop("app_password", "")
235+
instance = super().update(instance, validated_data)
236+
if app_password:
237+
instance.set_app_password(app_password)
238+
instance.save(update_fields=["app_password_encrypted", "updated_at"])
239+
return instance
240+
241+
166242
class EntitySerializer(ProjectScopedSerializerMixin, serializers.ModelSerializer):
167243
"""Serialize tracked entities for a project."""
168244

@@ -510,12 +586,15 @@ class IntakeAllowlistSerializer(
510586
):
511587
"""Serialize confirmed and pending newsletter sender allowlist entries."""
512588

589+
is_confirmed = serializers.BooleanField(read_only=True)
590+
513591
class Meta:
514592
model = IntakeAllowlist
515593
fields = [
516594
"id",
517595
"project",
518596
"sender_email",
597+
"is_confirmed",
519598
"confirmed_at",
520599
"confirmation_token",
521600
"created_at",

0 commit comments

Comments
 (0)