Skip to content

Commit 407d131

Browse files
authored
Merge pull request #27 from webstackdev/feature/frontend-updates
Feature/frontend updates
2 parents a8a9ea2 + db1ef94 commit 407d131

28 files changed

Lines changed: 2926 additions & 69 deletions

File tree

‎core/api.py‎

Lines changed: 120 additions & 1 deletion
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,
@@ -104,11 +110,35 @@
104110
"group": 3,
105111
"topic_description": "Coverage of developer tools, model releases, and applied AI workflows.",
106112
"content_retention_days": 180,
113+
"intake_token": "project-token-123",
114+
"intake_enabled": True,
115+
"has_bluesky_credentials": True,
116+
"bluesky_handle": "aiweekly.bsky.social",
117+
"bluesky_is_active": True,
118+
"bluesky_last_verified_at": "2026-04-26T13:00:00Z",
119+
"bluesky_last_error": "",
107120
"created_at": "2026-04-26T12:00:00Z",
108121
},
109122
response_only=True,
110123
)
111124

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+
112142
SOURCE_CONFIG_CREATE_REQUEST_EXAMPLE = OpenApiExample(
113143
"Create RSS Source Request",
114144
value={
@@ -594,14 +624,34 @@ class ProjectViewSet(viewsets.ModelViewSet):
594624
"""Manage projects accessible through the current user's group memberships."""
595625

596626
serializer_class = ProjectSerializer
597-
queryset = Project.objects.select_related("group")
627+
queryset = Project.objects.select_related("group", "bluesky_credentials")
598628
lookup_url_kwarg = "id"
599629

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

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

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+
605655
@extend_schema(
606656
summary="Verify Bluesky credentials",
607657
description=(
@@ -1013,6 +1063,75 @@ class IngestionRunViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet):
10131063
queryset = IngestionRun.objects.select_related("project")
10141064

10151065

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+
10161135
@document_project_owned_viewset(
10171136
resource_plural="source configurations",
10181137
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: 127 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,
@@ -83,6 +84,12 @@ def __init__(self, *args, **kwargs):
8384
class ProjectSerializer(ProjectScopedSerializerMixin, serializers.ModelSerializer):
8485
"""Serialize top-level project records."""
8586

87+
has_bluesky_credentials = serializers.SerializerMethodField()
88+
bluesky_handle = serializers.SerializerMethodField()
89+
bluesky_is_active = serializers.SerializerMethodField()
90+
bluesky_last_verified_at = serializers.SerializerMethodField()
91+
bluesky_last_error = serializers.SerializerMethodField()
92+
8693
class Meta:
8794
model = Project
8895
fields = [
@@ -93,10 +100,52 @@ class Meta:
93100
"content_retention_days",
94101
"intake_token",
95102
"intake_enabled",
103+
"has_bluesky_credentials",
104+
"bluesky_handle",
105+
"bluesky_is_active",
106+
"bluesky_last_verified_at",
107+
"bluesky_last_error",
96108
"created_at",
97109
]
98110
read_only_fields = ["id", "created_at"]
99111

112+
def _get_bluesky_credentials(self, obj: Project):
113+
"""Return the project's stored Bluesky credentials, if configured."""
114+
115+
try:
116+
return obj.bluesky_credentials
117+
except Project.bluesky_credentials.RelatedObjectDoesNotExist:
118+
return None
119+
120+
def get_has_bluesky_credentials(self, obj: Project) -> bool:
121+
"""Return whether the project has stored Bluesky credentials."""
122+
123+
return self._get_bluesky_credentials(obj) is not None
124+
125+
def get_bluesky_handle(self, obj: Project) -> str:
126+
"""Return the stored Bluesky handle, or an empty string."""
127+
128+
credentials = self._get_bluesky_credentials(obj)
129+
return credentials.handle if credentials else ""
130+
131+
def get_bluesky_is_active(self, obj: Project) -> bool:
132+
"""Return whether the stored Bluesky credentials are currently active."""
133+
134+
credentials = self._get_bluesky_credentials(obj)
135+
return credentials.is_active if credentials else False
136+
137+
def get_bluesky_last_verified_at(self, obj: Project):
138+
"""Return the last successful verification timestamp, if available."""
139+
140+
credentials = self._get_bluesky_credentials(obj)
141+
return credentials.last_verified_at if credentials else None
142+
143+
def get_bluesky_last_error(self, obj: Project) -> str:
144+
"""Return the latest Bluesky verification error, or an empty string."""
145+
146+
credentials = self._get_bluesky_credentials(obj)
147+
return credentials.last_error if credentials else ""
148+
100149

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

117166

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+
118242
class EntitySerializer(ProjectScopedSerializerMixin, serializers.ModelSerializer):
119243
"""Serialize tracked entities for a project."""
120244

@@ -462,12 +586,15 @@ class IntakeAllowlistSerializer(
462586
):
463587
"""Serialize confirmed and pending newsletter sender allowlist entries."""
464588

589+
is_confirmed = serializers.BooleanField(read_only=True)
590+
465591
class Meta:
466592
model = IntakeAllowlist
467593
fields = [
468594
"id",
469595
"project",
470596
"sender_email",
597+
"is_confirmed",
471598
"confirmed_at",
472599
"confirmation_token",
473600
"created_at",

0 commit comments

Comments
 (0)