Skip to content

Commit 7e790ac

Browse files
authored
Merge pull request #30 from webstackdev/feature/mastodon-plugin
Mastodon plugin
2 parents 2f6af69 + 2296477 commit 7e790ac

32 files changed

Lines changed: 2190 additions & 43 deletions

File tree

.github/workflows/lint.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,5 @@ jobs:
4747
- name: Install dependencies
4848
run: just install
4949

50-
- name: Install pre-commit hooks
51-
run: pre-commit install --install-hooks
52-
5350
- name: Run lint and type checks
5451
run: just lint

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,11 @@ The system is designed for graceful failure, not silent corruption. Unparseable
9595
```bash
9696
python3 -m venv .venv
9797
source .venv/bin/activate
98-
python3 -m pip install -r requirements.txt
98+
just install
9999
```
100100

101+
`just install` installs the backend and frontend dependencies and registers the repository's `pre-commit` hooks, so `git commit` runs the configured lint and test hooks locally.
102+
101103
1. Run `just dev` to start Django, Celery, Postgres, Redis, Qdrant, and Nginx. On the first run Docker builds the app image automatically. After that, `just dev` reuses the existing image so normal restarts are fast. If `.env` is missing, the `just` command copies `.env.example` automatically.
102104
2. Run `just build` after changing `requirements.txt` or `docker/web/Dockerfile`.
103105
3. For a fully fresh local stack after schema changes, run `just reset-volumes` before starting the containers again. This drops the Docker-backed Postgres, Redis, and Qdrant state so regenerated migrations apply cleanly.

SESSION.md

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1 @@
11
# Session Restore Point
2-
3-
I’m checking how this repo wires the database for just migrate so I can tell you whether reset-volumes will actually drop the database you’re about to migrate.
4-
5-
Searched for database configuration in settings files
6-
Not necessarily.
7-
8-
just reset-volumes runs docker compose down -v --remove-orphans, so it deletes the Docker named volumes from justfile:119 and justfile:159. In this repo that includes the Postgres volume from docker-compose.yml:35 and docker-compose.yml:79, but also Redis, Qdrant, and the frontend node_modules volume.
9-
10-
The important detail is that just migrate is a local python3 manage.py migrate command from justfile:123, and the repo default .env.example points DATABASE_URL at SQLite in .env.example:5. So:
11-
12-
If your current .env still uses sqlite:///db.sqlite3, just reset-volumes will not drop the database you are migrating. You would need to remove db.sqlite3 instead, then run just migrate.
13-
If your current .env points at the Docker Postgres service, then yes, just reset-volumes will wipe that Postgres database. After that, bring services back up so Postgres exists again, then run just migrate.
14-
So the short answer is: only use just reset-volumes if the database you want to drop is the Dockerized Postgres one. If you want, I can help you verify which DB your current .env is targeting before you do anything destructive.
15-
16-
DATABASE_URL=sqlite:///db.sqlite3

core/api.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,23 @@
117117
response_only=True,
118118
)
119119

120+
MASTODON_CREDENTIALS_RESPONSE_EXAMPLE = OpenApiExample(
121+
"Mastodon Credentials Response",
122+
value={
123+
"id": 1,
124+
"project": 1,
125+
"instance_url": "https://hachyderm.io",
126+
"account_acct": "alice@hachyderm.io",
127+
"is_active": True,
128+
"has_stored_credential": True,
129+
"last_verified_at": "2026-04-26T13:00:00Z",
130+
"last_error": "",
131+
"created_at": "2026-04-26T12:30:00Z",
132+
"updated_at": "2026-04-26T13:00:00Z",
133+
},
134+
response_only=True,
135+
)
136+
120137
SOURCE_CONFIG_CREATE_REQUEST_EXAMPLE = OpenApiExample(
121138
"Create RSS Source Request",
122139
value={
@@ -157,6 +174,22 @@
157174
request_only=True,
158175
)
159176

177+
SOURCE_CONFIG_MASTODON_REQUEST_EXAMPLE = OpenApiExample(
178+
"Create Mastodon Source Request",
179+
value={
180+
"plugin_name": "mastodon",
181+
"config": {
182+
"instance_url": "https://hachyderm.io",
183+
"hashtag": "platformengineering",
184+
"include_replies": False,
185+
"include_reblogs": True,
186+
"max_statuses_per_fetch": 100,
187+
},
188+
"is_active": True,
189+
},
190+
request_only=True,
191+
)
192+
160193
SOURCE_CONFIG_RESPONSE_EXAMPLE = OpenApiExample(
161194
"Source Configuration Response",
162195
value={
@@ -284,6 +317,17 @@
284317
},
285318
)
286319

320+
MASTODON_CREDENTIALS_VERIFY_RESPONSE = inline_serializer(
321+
name="MastodonCredentialsVerifyResponse",
322+
fields={
323+
"status": serializers.CharField(),
324+
"account_acct": serializers.CharField(allow_blank=True),
325+
"instance_url": serializers.URLField(),
326+
"last_verified_at": serializers.DateTimeField(allow_null=True),
327+
"last_error": serializers.CharField(allow_blank=True),
328+
},
329+
)
330+
287331

288332
def build_success_response(
289333
response, description: str, examples: list[OpenApiExample] | None = None

core/plugins/mastodon.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Compatibility wrapper for the Mastodon source plugin."""
2+
3+
from mastodon import Mastodon
4+
5+
from ingestion.plugins.mastodon import MastodonSourcePlugin
6+
7+
__all__ = ["Mastodon", "MastodonSourcePlugin"]

core/tests/test_admin.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,19 @@
3939
from projects.admin import (
4040
BlueskyCredentialsAdmin,
4141
BlueskyCredentialsAdminForm,
42+
MastodonCredentialsAdmin,
43+
MastodonCredentialsAdminForm,
4244
ProjectConfigAdmin,
4345
SourceConfigAdmin,
4446
)
4547
from projects.model_support import SourcePluginName
46-
from projects.models import BlueskyCredentials, Project, ProjectConfig, SourceConfig
48+
from projects.models import (
49+
BlueskyCredentials,
50+
MastodonCredentials,
51+
Project,
52+
ProjectConfig,
53+
SourceConfig,
54+
)
4755

4856
pytestmark = pytest.mark.django_db
4957

@@ -365,6 +373,82 @@ def test_verify_selected_bluesky_credentials_reports_failures(
365373
)
366374

367375

376+
def test_mastodon_credentials_admin_form_encrypts_access_token(source_admin_context):
377+
form = MastodonCredentialsAdminForm(
378+
data={
379+
"project": source_admin_context.project.id,
380+
"instance_url": "https://hachyderm.io/@alice/",
381+
"account_acct": "@Alice",
382+
"credential_input": "access-token",
383+
"is_active": True,
384+
}
385+
)
386+
387+
assert form.is_valid(), form.errors
388+
credentials = form.save()
389+
390+
assert credentials.instance_url == "https://hachyderm.io"
391+
assert credentials.account_acct == "alice@hachyderm.io"
392+
assert credentials.has_access_token() is True
393+
assert credentials.get_access_token() == "access-token"
394+
395+
396+
def test_verify_selected_mastodon_credentials_reports_success(
397+
source_admin_context, mocker
398+
):
399+
credentials = MastodonCredentials.objects.create(
400+
project=source_admin_context.project,
401+
instance_url="https://hachyderm.io",
402+
account_acct="alice@hachyderm.io",
403+
access_token_encrypted="ciphertext",
404+
)
405+
verify_mock = mocker.patch(
406+
"core.plugins.mastodon.MastodonSourcePlugin.verify_credentials"
407+
)
408+
admin_instance = MastodonCredentialsAdmin(MastodonCredentials, AdminSite())
409+
admin_instance.message_user = mocker.Mock()
410+
411+
admin_instance.verify_selected_credentials(
412+
request=SimpleNamespace(),
413+
queryset=MastodonCredentials.objects.filter(pk=credentials.pk),
414+
)
415+
416+
verify_mock.assert_called_once_with(credentials)
417+
admin_instance.message_user.assert_called_once_with(
418+
ANY,
419+
"Credential verification passed for 1 account(s).",
420+
messages.SUCCESS,
421+
)
422+
423+
424+
def test_verify_selected_mastodon_credentials_reports_failures(
425+
source_admin_context, mocker
426+
):
427+
credentials = MastodonCredentials.objects.create(
428+
project=source_admin_context.project,
429+
instance_url="https://hachyderm.io",
430+
account_acct="alice@hachyderm.io",
431+
access_token_encrypted="ciphertext",
432+
)
433+
mocker.patch(
434+
"core.plugins.mastodon.MastodonSourcePlugin.verify_credentials",
435+
side_effect=RuntimeError("bad token"),
436+
)
437+
admin_instance = MastodonCredentialsAdmin(MastodonCredentials, AdminSite())
438+
admin_instance.message_user = mocker.Mock()
439+
440+
admin_instance.verify_selected_credentials(
441+
request=SimpleNamespace(),
442+
queryset=MastodonCredentials.objects.filter(pk=credentials.pk),
443+
)
444+
445+
admin_instance.message_user.assert_called_once_with(
446+
ANY,
447+
"Credential verification failed for: Mastodon credentials for Admin Project: bad token",
448+
messages.ERROR,
449+
)
450+
451+
368452
def test_ingestion_run_display_efficiency_renders_without_django6_format_error(
369453
source_admin_context,
370454
):

core/tests/test_api.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from projects.model_support import SourcePluginName
3030
from projects.models import (
3131
BlueskyCredentials,
32+
MastodonCredentials,
3233
Project,
3334
ProjectConfig,
3435
ProjectMembership,
@@ -760,6 +761,81 @@ def test_verify_bluesky_credentials_surfaces_verification_errors(
760761
self.owner_project.id,
761762
)
762763

764+
def test_verify_mastodon_credentials_requires_configured_project_credentials(self):
765+
response = self.client.post(
766+
reverse(
767+
"v1:project-verify-mastodon-credentials",
768+
kwargs={"id": self.owner_project.id},
769+
),
770+
format="json",
771+
)
772+
773+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
774+
self.assert_standardized_validation_error(
775+
response.json(), "mastodon_credentials"
776+
)
777+
778+
@patch("core.plugins.mastodon.MastodonSourcePlugin.verify_credentials")
779+
def test_verify_mastodon_credentials_verifies_project_account(self, verify_mock):
780+
credentials = MastodonCredentials(
781+
project=self.owner_project,
782+
instance_url="https://hachyderm.io",
783+
account_acct="alice@hachyderm.io",
784+
)
785+
credentials.set_access_token("access-token")
786+
credentials.save()
787+
788+
response = self.client.post(
789+
reverse(
790+
"v1:project-verify-mastodon-credentials",
791+
kwargs={"id": self.owner_project.id},
792+
),
793+
format="json",
794+
)
795+
796+
self.assertEqual(response.status_code, status.HTTP_200_OK)
797+
verify_mock.assert_called_once()
798+
verified_credentials = verify_mock.call_args.args[0]
799+
self.assertEqual(verified_credentials.id, credentials.id)
800+
self.assertEqual(response.json()["status"], "verified")
801+
self.assertEqual(response.json()["account_acct"], "alice@hachyderm.io")
802+
self.assertEqual(response.json()["instance_url"], "https://hachyderm.io")
803+
self.assertEqual(response.json()["last_error"], "")
804+
805+
@patch("core.api.logger.exception")
806+
@patch(
807+
"core.plugins.mastodon.MastodonSourcePlugin.verify_credentials",
808+
side_effect=RuntimeError("bad token"),
809+
)
810+
def test_verify_mastodon_credentials_surfaces_verification_errors(
811+
self, _verify_mock, logger_exception_mock
812+
):
813+
credentials = MastodonCredentials(
814+
project=self.owner_project,
815+
instance_url="https://hachyderm.io",
816+
account_acct="alice@hachyderm.io",
817+
)
818+
credentials.set_access_token("access-token")
819+
credentials.save()
820+
821+
response = self.client.post(
822+
reverse(
823+
"v1:project-verify-mastodon-credentials",
824+
kwargs={"id": self.owner_project.id},
825+
),
826+
format="json",
827+
)
828+
829+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
830+
self.assert_standardized_validation_error(
831+
response.json(), "mastodon_credentials"
832+
)
833+
self.assertNotIn("bad token", str(response.json()))
834+
logger_exception_mock.assert_called_once_with(
835+
"Mastodon credential verification failed for project id=%s",
836+
self.owner_project.id,
837+
)
838+
763839
@patch("core.signals.queue_topic_centroid_recompute")
764840
def test_feedback_create_assigns_current_user(self, queue_centroid_mock):
765841
response = self.client.post(
@@ -918,6 +994,10 @@ def test_authenticated_nested_list_endpoints_smoke(self):
918994
"v1:project-bluesky-credentials-list",
919995
kwargs={"project_id": self.owner_project.id},
920996
),
997+
reverse(
998+
"v1:project-mastodon-credentials-list",
999+
kwargs={"project_id": self.owner_project.id},
1000+
),
9211001
reverse(
9221002
"v1:project-intake-allowlist-list",
9231003
kwargs={"project_id": self.owner_project.id},

0 commit comments

Comments
 (0)