Skip to content

Commit dff4774

Browse files
committed
Fix test error
1 parent 1b978c8 commit dff4774

19 files changed

Lines changed: 1198 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: 134 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,44 @@
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 app password without exposing ciphertext."""
36+
37+
app_password = forms.CharField(
38+
required=False,
39+
strip=False,
40+
widget=forms.PasswordInput(render_value=False),
41+
help_text=(
42+
"Leave blank to keep the existing app password. Stored encrypted at rest."
43+
),
44+
label="App password",
45+
)
46+
47+
class Meta:
48+
model = BlueskyCredentials
49+
fields = ["project", "handle", "app_password", "pds_url", "is_active"]
50+
51+
def clean(self):
52+
"""Require an app password when creating credentials for the first time."""
53+
54+
cleaned_data = super().clean()
55+
app_password = cleaned_data.get("app_password", "")
56+
if not self.instance.has_app_password() and not app_password:
57+
self.add_error("app_password", "App password is required.")
58+
return cleaned_data
59+
60+
def save(self, commit=True):
61+
"""Encrypt a new app password before saving the model instance."""
62+
63+
instance = super().save(commit=False)
64+
app_password = self.cleaned_data.get("app_password", "")
65+
if app_password:
66+
instance.set_app_password(app_password)
67+
if commit:
68+
instance.save()
69+
return instance
70+
71+
3272
@admin.register(Project)
3373
class ProjectAdmin(ExportActionMixin, admin.ModelAdmin):
3474
"""Admin configuration for top-level project workspaces."""
@@ -49,6 +89,100 @@ class ProjectAdmin(ExportActionMixin, admin.ModelAdmin):
4989
list_editable = ("content_retention_days",)
5090

5191

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

‎core/api.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from rest_framework.response import Response
2222

2323
from core.models import (
24+
BlueskyCredentials,
2425
Content,
2526
Entity,
2627
IngestionRun,
@@ -115,6 +116,20 @@
115116
request_only=True,
116117
)
117118

119+
SOURCE_CONFIG_BLUESKY_REQUEST_EXAMPLE = OpenApiExample(
120+
"Create Bluesky Source Request",
121+
value={
122+
"plugin_name": "bluesky",
123+
"config": {
124+
"author_handle": "alice.bsky.social",
125+
"include_replies": False,
126+
"max_posts_per_fetch": 100,
127+
},
128+
"is_active": True,
129+
},
130+
request_only=True,
131+
)
132+
118133
SOURCE_CONFIG_RESPONSE_EXAMPLE = OpenApiExample(
119134
"Source Configuration Response",
120135
value={
@@ -228,6 +243,16 @@
228243
examples=[AUTHENTICATION_REQUIRED_EXAMPLE],
229244
)
230245

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

232257
def build_success_response(
233258
response, description: str, examples: list[OpenApiExample] | None = None
@@ -554,6 +579,69 @@ def get_queryset(self):
554579

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

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

558646
@document_project_owned_viewset(
559647
resource_plural="project configurations",
@@ -741,6 +829,7 @@ class IngestionRunViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet):
741829
create_examples=[
742830
SOURCE_CONFIG_CREATE_REQUEST_EXAMPLE,
743831
SOURCE_CONFIG_REDDIT_REQUEST_EXAMPLE,
832+
SOURCE_CONFIG_BLUESKY_REQUEST_EXAMPLE,
744833
SOURCE_CONFIG_RESPONSE_EXAMPLE,
745834
],
746835
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+
]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Generated by Django 6.0.4 on 2026-04-28 23:59
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
(
11+
"core",
12+
"0003_rename_core_newsle_project_2c63fb_idx_core_newsle_project_eee7a4_idx_and_more",
13+
),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="BlueskyCredentials",
19+
fields=[
20+
(
21+
"id",
22+
models.BigAutoField(
23+
auto_created=True,
24+
primary_key=True,
25+
serialize=False,
26+
verbose_name="ID",
27+
),
28+
),
29+
("handle", models.CharField(max_length=255)),
30+
("app_password_encrypted", models.TextField(blank=True)),
31+
("pds_url", models.URLField(blank=True)),
32+
("is_active", models.BooleanField(default=True)),
33+
("last_verified_at", models.DateTimeField(blank=True, null=True)),
34+
("last_error", models.TextField(blank=True)),
35+
("created_at", models.DateTimeField(auto_now_add=True)),
36+
("updated_at", models.DateTimeField(auto_now=True)),
37+
(
38+
"project",
39+
models.OneToOneField(
40+
on_delete=django.db.models.deletion.CASCADE,
41+
related_name="bluesky_credentials",
42+
to="core.project",
43+
),
44+
),
45+
],
46+
options={
47+
"verbose_name_plural": "Bluesky credentials",
48+
"ordering": ["project__name"],
49+
},
50+
),
51+
]

0 commit comments

Comments
 (0)