Skip to content

Commit efe2d3e

Browse files
authored
Merge pull request #22 from webstackdev/feature/implement-bluesky-plugin
Fix test error
2 parents 1b978c8 + 5c9f951 commit efe2d3e

19 files changed

Lines changed: 1234 additions & 2 deletions

‎.env.example‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ REDDIT_CLIENT_ID=
3333
REDDIT_CLIENT_SECRET=
3434
REDDIT_USER_AGENT=newsletter-maker/0.1
3535

36+
# Used to encrypt project-scoped Bluesky app passwords stored in the database.
37+
# Set this to a stable secret in each environment.
38+
BLUESKY_CREDENTIALS_ENCRYPTION_KEY=
39+
3640
# Outbound mail provider. Use Resend or Amazon SES.
3741
EMAIL_BACKEND=anymail.backends.resend.EmailBackend
3842
DEFAULT_FROM_EMAIL=onboarding@resend.dev

‎core/admin.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import json
99

10+
from django import forms
1011
from django.contrib import admin, messages
1112
from django.db.models import Avg
1213
from django.utils import timezone
@@ -16,6 +17,7 @@
1617
from unfold.admin import ModelAdmin
1718

1819
from core.models import (
20+
BlueskyCredentials,
1921
Content,
2022
Entity,
2123
IngestionRun,
@@ -29,6 +31,42 @@
2931
from core.plugins import get_plugin_for_source_config, validate_plugin_config
3032

3133

34+
class BlueskyCredentialsAdminForm(forms.ModelForm):
35+
"""Admin form that accepts a plaintext Bluesky app credential input."""
36+
37+
credential_input = forms.CharField(
38+
required=False,
39+
strip=False,
40+
widget=forms.PasswordInput(render_value=False),
41+
help_text="Leave blank to keep the existing stored credential.",
42+
label="Bluesky app credential",
43+
)
44+
45+
class Meta:
46+
model = BlueskyCredentials
47+
fields = ["project", "handle", "pds_url", "is_active"]
48+
49+
def clean(self):
50+
"""Require a credential when creating the record for the first time."""
51+
52+
cleaned_data = super().clean()
53+
credential_input = cleaned_data.get("credential_input", "")
54+
if not self.instance.has_stored_credential() and not credential_input:
55+
self.add_error("credential_input", "A Bluesky app credential is required.")
56+
return cleaned_data
57+
58+
def save(self, commit=True):
59+
"""Encrypt a new credential value before saving the model instance."""
60+
61+
instance = super().save(commit=False)
62+
credential_input = self.cleaned_data.get("credential_input", "")
63+
if credential_input:
64+
instance.set_stored_credential(credential_input)
65+
if commit:
66+
instance.save()
67+
return instance
68+
69+
3270
@admin.register(Project)
3371
class ProjectAdmin(ExportActionMixin, admin.ModelAdmin):
3472
"""Admin configuration for top-level project workspaces."""
@@ -49,6 +87,100 @@ class ProjectAdmin(ExportActionMixin, admin.ModelAdmin):
4987
list_editable = ("content_retention_days",)
5088

5189

90+
@admin.register(BlueskyCredentials)
91+
class BlueskyCredentialsAdmin(ModelAdmin):
92+
"""Admin view for project-scoped Bluesky authentication settings."""
93+
94+
form = BlueskyCredentialsAdminForm
95+
actions = ["verify_selected_credentials"]
96+
list_display = (
97+
"project",
98+
"handle",
99+
"display_pds_host",
100+
"has_stored_credential",
101+
"is_active",
102+
"last_verified_at",
103+
)
104+
list_filter = ("is_active", ("project", admin.RelatedOnlyFieldListFilter))
105+
search_fields = ("project__name", "handle", "pds_url")
106+
autocomplete_fields = ("project",)
107+
readonly_fields = (
108+
"has_stored_credential",
109+
"last_verified_at",
110+
"last_error",
111+
"created_at",
112+
"updated_at",
113+
)
114+
fieldsets = (
115+
(
116+
"Account",
117+
{"fields": ("project", "handle", "credential_input", "is_active")},
118+
),
119+
(
120+
"PDS Override",
121+
{
122+
"fields": ("pds_url",),
123+
"description": "Leave blank to use the default Bluesky-hosted account flow.",
124+
},
125+
),
126+
(
127+
"Verification",
128+
{
129+
"fields": (
130+
"has_stored_credential",
131+
"last_verified_at",
132+
"last_error",
133+
"created_at",
134+
"updated_at",
135+
)
136+
},
137+
),
138+
)
139+
140+
@admin.display(description="PDS")
141+
def display_pds_host(self, obj):
142+
"""Show whether the credentials use the hosted default or a custom PDS."""
143+
144+
return obj.pds_url or "Bluesky hosted default"
145+
146+
@admin.display(boolean=True, description="Stored Credential")
147+
def has_stored_credential(self, obj):
148+
"""Return whether an encrypted Bluesky credential has been configured."""
149+
150+
return obj.has_stored_credential()
151+
152+
@admin.action(description="Verify Selected Credentials")
153+
def verify_selected_credentials(self, request, queryset):
154+
"""Authenticate the selected Bluesky accounts and report the outcome."""
155+
156+
from core.plugins.bluesky import BlueskySourcePlugin
157+
158+
verified_credentials = []
159+
failed_credentials = []
160+
161+
for credentials in queryset.select_related("project"):
162+
try:
163+
BlueskySourcePlugin.verify_credentials(credentials)
164+
except Exception as exc:
165+
failed_credentials.append(f"{credentials}: {exc}")
166+
else:
167+
verified_credentials.append(str(credentials))
168+
169+
if verified_credentials:
170+
self.message_user(
171+
request,
172+
f"Credential verification passed for {len(verified_credentials)} account(s).",
173+
messages.SUCCESS,
174+
)
175+
176+
if failed_credentials:
177+
self.message_user(
178+
request,
179+
"Credential verification failed for: " + "; ".join(failed_credentials),
180+
messages.ERROR,
181+
)
182+
183+
52184
@admin.register(ProjectConfig)
53185
class ProjectConfigAdmin(admin.ModelAdmin):
54186
"""Admin configuration for per-project scoring settings."""

‎core/api.py‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
generated schema consistent across similar viewsets.
66
"""
77

8+
import logging
89
from typing import Any
910

1011
from drf_spectacular.utils import (
@@ -21,6 +22,7 @@
2122
from rest_framework.response import Response
2223

2324
from core.models import (
25+
BlueskyCredentials,
2426
Content,
2527
Entity,
2628
IngestionRun,
@@ -48,6 +50,8 @@
4850
SUMMARIZATION_SKILL_NAME = "summarization"
4951
RELATED_CONTENT_SKILL_NAME = "find_related"
5052

53+
logger = logging.getLogger(__name__)
54+
5155
PROJECT_ID_PARAMETER = OpenApiParameter(
5256
name="project_id",
5357
type=int,
@@ -115,6 +119,20 @@
115119
request_only=True,
116120
)
117121

122+
SOURCE_CONFIG_BLUESKY_REQUEST_EXAMPLE = OpenApiExample(
123+
"Create Bluesky Source Request",
124+
value={
125+
"plugin_name": "bluesky",
126+
"config": {
127+
"author_handle": "alice.bsky.social",
128+
"include_replies": False,
129+
"max_posts_per_fetch": 100,
130+
},
131+
"is_active": True,
132+
},
133+
request_only=True,
134+
)
135+
118136
SOURCE_CONFIG_RESPONSE_EXAMPLE = OpenApiExample(
119137
"Source Configuration Response",
120138
value={
@@ -228,6 +246,16 @@
228246
examples=[AUTHENTICATION_REQUIRED_EXAMPLE],
229247
)
230248

249+
BLUESKY_CREDENTIALS_VERIFY_RESPONSE = inline_serializer(
250+
name="BlueskyCredentialsVerifyResponse",
251+
fields={
252+
"status": serializers.CharField(),
253+
"handle": serializers.CharField(),
254+
"last_verified_at": serializers.DateTimeField(allow_null=True),
255+
"last_error": serializers.CharField(allow_blank=True),
256+
},
257+
)
258+
231259

232260
def build_success_response(
233261
response, description: str, examples: list[OpenApiExample] | None = None
@@ -554,6 +582,80 @@ def get_queryset(self):
554582

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

585+
@extend_schema(
586+
summary="Verify Bluesky credentials",
587+
description=(
588+
"Verify the selected project's stored Bluesky credentials by authenticating "
589+
"the account and checking the current session."
590+
),
591+
tags=["Ingestion"],
592+
request=None,
593+
responses={
594+
200: build_success_response(
595+
BLUESKY_CREDENTIALS_VERIFY_RESPONSE,
596+
"The project's Bluesky credentials were verified successfully.",
597+
),
598+
400: OpenApiResponse(
599+
response=inline_serializer(
600+
name="BlueskyCredentialsVerifyErrorResponse",
601+
fields={
602+
"type": serializers.CharField(),
603+
"errors": inline_serializer(
604+
name="BlueskyCredentialsVerifyError",
605+
fields={
606+
"code": serializers.CharField(),
607+
"detail": serializers.CharField(),
608+
"attr": serializers.CharField(allow_null=True),
609+
},
610+
many=True,
611+
),
612+
},
613+
),
614+
description="The project is missing Bluesky credentials or verification failed.",
615+
),
616+
403: AUTHENTICATION_REQUIRED_RESPONSE,
617+
},
618+
)
619+
@action(detail=True, methods=["post"], url_path="verify-bluesky-credentials")
620+
def verify_bluesky_credentials(self, request, *args, **kwargs):
621+
"""Verify the Bluesky credentials stored for the selected project."""
622+
623+
from core.plugins.bluesky import BlueskySourcePlugin
624+
625+
project = self.get_object()
626+
try:
627+
credentials = project.bluesky_credentials
628+
except BlueskyCredentials.DoesNotExist as exc:
629+
raise serializers.ValidationError(
630+
{"bluesky_credentials": "No Bluesky credentials are configured for this project."}
631+
) from exc
632+
633+
try:
634+
BlueskySourcePlugin.verify_credentials(credentials)
635+
except Exception as exc:
636+
logger.exception(
637+
"Bluesky credential verification failed for project id=%s",
638+
project.id,
639+
)
640+
raise serializers.ValidationError(
641+
{
642+
"bluesky_credentials": (
643+
"Credential verification failed. Please re-check the credentials "
644+
"and try again."
645+
)
646+
}
647+
) from exc
648+
649+
credentials.refresh_from_db()
650+
return Response(
651+
{
652+
"status": "verified",
653+
"handle": credentials.handle,
654+
"last_verified_at": credentials.last_verified_at,
655+
"last_error": "",
656+
}
657+
)
658+
557659

558660
@document_project_owned_viewset(
559661
resource_plural="project configurations",
@@ -741,6 +843,7 @@ class IngestionRunViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet):
741843
create_examples=[
742844
SOURCE_CONFIG_CREATE_REQUEST_EXAMPLE,
743845
SOURCE_CONFIG_REDDIT_REQUEST_EXAMPLE,
846+
SOURCE_CONFIG_BLUESKY_REQUEST_EXAMPLE,
744847
SOURCE_CONFIG_RESPONSE_EXAMPLE,
745848
],
746849
create_response_examples=[SOURCE_CONFIG_RESPONSE_EXAMPLE],
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated by Django 6.0.4 on 2026-04-28 23:38
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("core", "0002_newsletter_intake"),
10+
]
11+
12+
operations = [
13+
migrations.RenameIndex(
14+
model_name="newsletterintake",
15+
new_name="core_newsle_project_eee7a4_idx",
16+
old_name="core_newsle_project_2c63fb_idx",
17+
),
18+
migrations.AlterField(
19+
model_name="sourceconfig",
20+
name="plugin_name",
21+
field=models.CharField(
22+
choices=[("rss", "RSS"), ("reddit", "Reddit"), ("bluesky", "Bluesky")],
23+
max_length=64,
24+
),
25+
),
26+
]

0 commit comments

Comments
 (0)