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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ REDDIT_USER_AGENT=newsletter-maker/0.1
# Set this to a stable secret in each environment.
BLUESKY_CREDENTIALS_ENCRYPTION_KEY=

# Used to encrypt project-scoped LinkedIn OAuth tokens stored in the database.
# Set this to a stable secret in each environment.
LINKEDIN_CREDENTIALS_ENCRYPTION_KEY=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
LINKEDIN_OAUTH_SCOPES=openid profile email offline_access

# Outbound mail provider. Use Resend or Amazon SES.
EMAIL_BACKEND=anymail.backends.resend.EmailBackend
DEFAULT_FROM_EMAIL=onboarding@resend.dev
Expand Down
2 changes: 1 addition & 1 deletion content/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ def queue_topic_centroid_on_feedback_save(sender, instance, created, **kwargs):

config, _ = ProjectConfig.objects.get_or_create(project=instance.project)
if config.recompute_topic_centroid_on_feedback_save:
queue_topic_centroid_recompute(instance.project_id)
queue_topic_centroid_recompute(instance.project_id)
57 changes: 57 additions & 0 deletions core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,31 @@
response_only=True,
)

LINKEDIN_CREDENTIALS_RESPONSE_EXAMPLE = OpenApiExample(
"LinkedIn Credentials Response",
value={
"id": 1,
"project": 1,
"member_urn": "urn:li:person:abc123",
"expires_at": "2026-04-27T13:00:00Z",
"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,
)

LINKEDIN_OAUTH_AUTHORIZE_RESPONSE_EXAMPLE = OpenApiExample(
"LinkedIn OAuth Authorize Response",
value={
"authorize_url": "https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=linkedin-client-id&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fapi%2Fv1%2Flinkedin%2Foauth%2Fcallback%2F&scope=openid+profile+email+offline_access&state=signed-state-token",
},
response_only=True,
)

SOURCE_CONFIG_CREATE_REQUEST_EXAMPLE = OpenApiExample(
"Create RSS Source Request",
value={
Expand Down Expand Up @@ -163,6 +188,20 @@
request_only=True,
)

SOURCE_CONFIG_LINKEDIN_REQUEST_EXAMPLE = OpenApiExample(
"Create LinkedIn Source Request",
value={
"plugin_name": "linkedin",
"config": {
"organization_urn": "urn:li:organization:1337",
"include_reshares": False,
"max_posts_per_fetch": 50,
},
"is_active": True,
},
request_only=True,
)

SOURCE_CONFIG_RESPONSE_EXAMPLE = OpenApiExample(
"Source Configuration Response",
value={
Expand Down Expand Up @@ -301,6 +340,24 @@
},
)

LINKEDIN_CREDENTIALS_VERIFY_RESPONSE = inline_serializer(
name="LinkedInCredentialsVerifyResponse",
fields={
"status": serializers.CharField(),
"member_urn": serializers.CharField(allow_blank=True),
"expires_at": serializers.DateTimeField(allow_null=True),
"last_verified_at": serializers.DateTimeField(allow_null=True),
"last_error": serializers.CharField(allow_blank=True),
},
)

LINKEDIN_OAUTH_AUTHORIZE_RESPONSE = inline_serializer(
name="LinkedInOAuthAuthorizeResponse",
fields={
"authorize_url": serializers.URLField(),
},
)


def build_success_response(
response, description: str, examples: list[OpenApiExample] | None = None
Expand Down
7 changes: 7 additions & 0 deletions core/api_urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Aggregate app-owned API route registrations under the public v1 surface."""

from django.urls import path
from rest_framework.routers import DefaultRouter
from rest_framework_nested.routers import NestedSimpleRouter

Expand All @@ -22,6 +23,7 @@
from projects.api_urls import (
register_root_routes as register_projects_root_routes,
)
from projects.linkedin_oauth import linkedin_oauth_callback_view
from trends.api_urls import register_project_routes as register_trends_project_routes

app_name = "api"
Expand All @@ -39,6 +41,11 @@
register_trends_project_routes(project_router)

urlpatterns = [
path(
"linkedin/oauth/callback/",
linkedin_oauth_callback_view,
name="linkedin-oauth-callback",
),
*router.urls,
*project_router.urls,
]
4 changes: 4 additions & 0 deletions core/settings_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ class CoreSettings(Protocol):
BLUESKY_CREDENTIALS_ENCRYPTION_KEY: str
CELERY_TASK_ALWAYS_EAGER: bool
DEFAULT_FROM_EMAIL: str
LINKEDIN_CLIENT_ID: str
LINKEDIN_CLIENT_SECRET: str
LINKEDIN_CREDENTIALS_ENCRYPTION_KEY: str
LINKEDIN_OAUTH_SCOPES: str
METRICS_TOKEN: str
NEWSLETTER_API_BASE_URL: str
QDRANT_URL: str
Expand Down
6 changes: 6 additions & 0 deletions core/tests/test_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import os
import sys

from newsletter_maker.celery import app


def _import_fresh(module_name: str):
sys.modules.pop(module_name, None)
Expand Down Expand Up @@ -36,3 +38,7 @@ def test_wsgi_module_sets_default_settings_and_builds_application(mocker):
)
get_app_mock.assert_called_once_with()
assert module.application == "wsgi-app"


def test_celery_app_redirects_worker_stdout_at_info_level():
assert app.conf.worker_redirect_stdouts_level == "INFO"
55 changes: 55 additions & 0 deletions core/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
from pipeline.serializers import ReviewQueueSerializer, SkillResultSerializer
from projects.model_support import SourcePluginName
from projects.models import (
LinkedInCredentials,
MastodonCredentials,
Project,
ProjectMembership,
ProjectRole,
SourceConfig,
)
from projects.serializers import (
LinkedInCredentialsSerializer,
MastodonCredentialsSerializer,
ProjectSerializer,
SourceConfigSerializer,
Expand Down Expand Up @@ -361,6 +363,32 @@ def test_source_config_serializer_normalizes_mastodon_hashtag_config(
}


def test_source_config_serializer_normalizes_linkedin_organization_config(
serializer_context,
):
serializer = SourceConfigSerializer(
data={
"plugin_name": SourcePluginName.LINKEDIN,
"config": {
"organization_urn": "urn:li:organization:1337",
"max_posts_per_fetch": "25",
},
"is_active": True,
},
context={
"request": _request_for(serializer_context.user),
"project": serializer_context.project,
},
)

assert serializer.is_valid(), serializer.errors
assert _validated_data(serializer)["config"] == {
"organization_urn": "urn:li:organization:1337",
"include_reshares": False,
"max_posts_per_fetch": 25,
}


def test_mastodon_credentials_serializer_encrypts_access_token(serializer_context):
serializer = MastodonCredentialsSerializer(
data={
Expand All @@ -387,6 +415,33 @@ def test_mastodon_credentials_serializer_encrypts_access_token(serializer_contex
assert credentials.get_access_token() == "secret-token"


def test_linkedin_credentials_serializer_encrypts_oauth_tokens(serializer_context):
serializer = LinkedInCredentialsSerializer(
data={
"member_urn": "urn:li:person:abc123",
"access_token": "access-token",
"refresh_token": "refresh-token",
"expires_at": "2026-04-27T13:00:00Z",
"is_active": True,
},
context={
"request": _request_for(serializer_context.user),
"project": serializer_context.project,
},
)

assert serializer.is_valid(), serializer.errors
credentials = cast(
LinkedInCredentials,
serializer.save(project=serializer_context.project),
)

assert credentials.member_urn == "urn:li:person:abc123"
assert credentials.has_stored_credential() is True
assert credentials.get_access_token() == "access-token"
assert credentials.get_refresh_token() == "refresh-token"


def test_entity_serializer_filters_project_queryset_to_request_user(serializer_context):
serializer = EntitySerializer(
context={"request": _request_for(serializer_context.user)}
Expand Down
2 changes: 1 addition & 1 deletion entities/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,4 +383,4 @@ def test_entity_candidate_merge_action_returns_updated_candidate(self):
self.assertEqual(candidate.merged_into, self.owner_entity)
self.assertEqual(
response.json()["merged_into"], _require_pk(self.owner_entity)
)
)
2 changes: 1 addition & 1 deletion frontend/.storybook/shims.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
declare module "*.css"
declare module "*.css"
2 changes: 1 addition & 1 deletion frontend/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
55 changes: 53 additions & 2 deletions frontend/src/app/admin/sources/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
BlueskyCredentials,
IngestionRun,
IntakeAllowlistEntry,
LinkedInCredentials,
MastodonCredentials,
NewsletterIntake,
Project,
Expand All @@ -16,6 +17,7 @@ const {
getProjectBlueskyCredentialsMock,
getProjectIngestionRunsMock,
getProjectIntakeAllowlistMock,
getProjectLinkedInCredentialsMock,
getProjectMastodonCredentialsMock,
getProjectNewsletterIntakesMock,
getProjectsMock,
Expand All @@ -25,6 +27,7 @@ const {
getProjectBlueskyCredentialsMock: vi.fn(),
getProjectIngestionRunsMock: vi.fn(),
getProjectIntakeAllowlistMock: vi.fn(),
getProjectLinkedInCredentialsMock: vi.fn(),
getProjectMastodonCredentialsMock: vi.fn(),
getProjectNewsletterIntakesMock: vi.fn(),
getProjectsMock: vi.fn(),
Expand Down Expand Up @@ -68,6 +71,7 @@ vi.mock("@/lib/api", () => ({
getProjectBlueskyCredentials: getProjectBlueskyCredentialsMock,
getProjectIngestionRuns: getProjectIngestionRunsMock,
getProjectIntakeAllowlist: getProjectIntakeAllowlistMock,
getProjectLinkedInCredentials: getProjectLinkedInCredentialsMock,
getProjectMastodonCredentials: getProjectMastodonCredentialsMock,
getProjectNewsletterIntakes: getProjectNewsletterIntakesMock,
getProjects: getProjectsMock,
Expand Down Expand Up @@ -205,6 +209,24 @@ function createMastodonCredentials(
}
}

function createLinkedInCredentials(
overrides: Partial<LinkedInCredentials> = {},
): LinkedInCredentials {
return {
id: 10,
project: 1,
member_urn: "urn:li:person:abc123",
expires_at: "2026-04-30T10:00:00Z",
is_active: true,
has_stored_credential: true,
last_verified_at: "2026-04-29T10:00:00Z",
last_error: "",
created_at: "2026-04-29T09:00:00Z",
updated_at: "2026-04-29T10:00:00Z",
...overrides,
}
}

async function loadSourcesPageModule() {
return import("./page")
}
Expand Down Expand Up @@ -267,6 +289,7 @@ describe("SourcesPage", () => {
getProjectSourceConfigsMock.mockReset()
getProjectIngestionRunsMock.mockReset()
getProjectIntakeAllowlistMock.mockReset()
getProjectLinkedInCredentialsMock.mockReset()
getProjectMastodonCredentialsMock.mockReset()
getProjectNewsletterIntakesMock.mockReset()
selectProjectMock.mockReset()
Expand All @@ -276,6 +299,7 @@ describe("SourcesPage", () => {
getProjectSourceConfigsMock.mockResolvedValue([])
getProjectIngestionRunsMock.mockResolvedValue([])
getProjectIntakeAllowlistMock.mockResolvedValue([])
getProjectLinkedInCredentialsMock.mockResolvedValue([])
getProjectMastodonCredentialsMock.mockResolvedValue([])
getProjectNewsletterIntakesMock.mockResolvedValue([])
selectProjectMock.mockImplementation((projects: Project[]) => {
Expand All @@ -300,6 +324,7 @@ describe("SourcesPage", () => {
expect(getProjectIngestionRunsMock).not.toHaveBeenCalled()
expect(getProjectBlueskyCredentialsMock).not.toHaveBeenCalled()
expect(getProjectIntakeAllowlistMock).not.toHaveBeenCalled()
expect(getProjectLinkedInCredentialsMock).not.toHaveBeenCalled()
expect(getProjectMastodonCredentialsMock).not.toHaveBeenCalled()
expect(getProjectNewsletterIntakesMock).not.toHaveBeenCalled()
})
Expand Down Expand Up @@ -340,6 +365,7 @@ describe("SourcesPage", () => {
expect(getProjectIngestionRunsMock).toHaveBeenCalledWith(1)
expect(getProjectBlueskyCredentialsMock).toHaveBeenCalledWith(1)
expect(getProjectIntakeAllowlistMock).toHaveBeenCalledWith(1)
expect(getProjectLinkedInCredentialsMock).toHaveBeenCalledWith(1)
expect(getProjectMastodonCredentialsMock).toHaveBeenCalledWith(1)
expect(getProjectNewsletterIntakesMock).toHaveBeenCalledWith(1)
})
Expand Down Expand Up @@ -471,6 +497,28 @@ describe("SourcesPage", () => {
).toBeEnabled()
})

it("renders LinkedIn authorization controls from stored credentials", async () => {
const selectedProject = createProject({ id: 5 })

getProjectsMock.mockResolvedValue([selectedProject])
selectProjectMock.mockReturnValue(selectedProject)
getProjectLinkedInCredentialsMock.mockResolvedValue([
createLinkedInCredentials({ project: 5 }),
])

await renderSourcesPage({ project: "5" })

expect(screen.getByText("urn:li:person:abc123")).toBeInTheDocument()
expect(screen.getByText("OAuth authorization")).toBeInTheDocument()
expect(screen.getByText(/Token expires/)).toBeInTheDocument()
expect(
screen.getByRole("button", { name: "Verify LinkedIn credentials" }),
).toBeEnabled()
expect(
screen.getByRole("button", { name: "Reauthorize LinkedIn" }),
).toBeInTheDocument()
})

it("renders source cards with badge tones and the latest run summary", async () => {
const selectedProject = createProject({ id: 3 })
getProjectsMock.mockResolvedValue([selectedProject])
Expand Down Expand Up @@ -522,7 +570,7 @@ describe("SourcesPage", () => {
expect(screen.getByText("Rate limited")).toBeInTheDocument()

const badges = screen.getAllByTestId("status-badge")
expect(badges).toHaveLength(5)
expect(badges).toHaveLength(6)
expect(
badges.some(
(badge) =>
Expand Down Expand Up @@ -550,9 +598,12 @@ describe("SourcesPage", () => {
expect(screen.getByText("No recent error")).toBeInTheDocument()
})

it("includes Mastodon in the source creation options", async () => {
it("includes LinkedIn and Mastodon in the source creation options", async () => {
await renderSourcesPage({ project: "1" })

expect(
screen.getByRole("option", { name: "LinkedIn" }),
).toBeInTheDocument()
expect(
screen.getByRole("option", { name: "Mastodon" }),
).toBeInTheDocument()
Expand Down
Loading
Loading