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
3 changes: 0 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,5 @@ jobs:
- name: Install dependencies
run: just install

- name: Install pre-commit hooks
run: pre-commit install --install-hooks

- name: Run lint and type checks
run: just lint
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,11 @@ The system is designed for graceful failure, not silent corruption. Unparseable
```bash
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install -r requirements.txt
just install
```

`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.

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.
2. Run `just build` after changing `requirements.txt` or `docker/web/Dockerfile`.
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.
Expand Down
15 changes: 0 additions & 15 deletions SESSION.md
Original file line number Diff line number Diff line change
@@ -1,16 +1 @@
# Session Restore Point

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.

Searched for database configuration in settings files
Not necessarily.

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.

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:

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.
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.
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.

DATABASE_URL=sqlite:///db.sqlite3
44 changes: 44 additions & 0 deletions core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,23 @@
response_only=True,
)

MASTODON_CREDENTIALS_RESPONSE_EXAMPLE = OpenApiExample(
"Mastodon Credentials Response",
value={
"id": 1,
"project": 1,
"instance_url": "https://hachyderm.io",
"account_acct": "alice@hachyderm.io",
"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,
)

SOURCE_CONFIG_CREATE_REQUEST_EXAMPLE = OpenApiExample(
"Create RSS Source Request",
value={
Expand Down Expand Up @@ -157,6 +174,22 @@
request_only=True,
)

SOURCE_CONFIG_MASTODON_REQUEST_EXAMPLE = OpenApiExample(
"Create Mastodon Source Request",
value={
"plugin_name": "mastodon",
"config": {
"instance_url": "https://hachyderm.io",
"hashtag": "platformengineering",
"include_replies": False,
"include_reblogs": True,
"max_statuses_per_fetch": 100,
},
"is_active": True,
},
request_only=True,
)

SOURCE_CONFIG_RESPONSE_EXAMPLE = OpenApiExample(
"Source Configuration Response",
value={
Expand Down Expand Up @@ -284,6 +317,17 @@
},
)

MASTODON_CREDENTIALS_VERIFY_RESPONSE = inline_serializer(
name="MastodonCredentialsVerifyResponse",
fields={
"status": serializers.CharField(),
"account_acct": serializers.CharField(allow_blank=True),
"instance_url": serializers.URLField(),
"last_verified_at": serializers.DateTimeField(allow_null=True),
"last_error": serializers.CharField(allow_blank=True),
},
)


def build_success_response(
response, description: str, examples: list[OpenApiExample] | None = None
Expand Down
7 changes: 7 additions & 0 deletions core/plugins/mastodon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Compatibility wrapper for the Mastodon source plugin."""

from mastodon import Mastodon

from ingestion.plugins.mastodon import MastodonSourcePlugin

__all__ = ["Mastodon", "MastodonSourcePlugin"]
86 changes: 85 additions & 1 deletion core/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,19 @@
from projects.admin import (
BlueskyCredentialsAdmin,
BlueskyCredentialsAdminForm,
MastodonCredentialsAdmin,
MastodonCredentialsAdminForm,
ProjectConfigAdmin,
SourceConfigAdmin,
)
from projects.model_support import SourcePluginName
from projects.models import BlueskyCredentials, Project, ProjectConfig, SourceConfig
from projects.models import (
BlueskyCredentials,
MastodonCredentials,
Project,
ProjectConfig,
SourceConfig,
)

pytestmark = pytest.mark.django_db

Expand Down Expand Up @@ -365,6 +373,82 @@ def test_verify_selected_bluesky_credentials_reports_failures(
)


def test_mastodon_credentials_admin_form_encrypts_access_token(source_admin_context):
form = MastodonCredentialsAdminForm(
data={
"project": source_admin_context.project.id,
"instance_url": "https://hachyderm.io/@alice/",
"account_acct": "@Alice",
"credential_input": "access-token",
"is_active": True,
}
)

assert form.is_valid(), form.errors
credentials = form.save()

assert credentials.instance_url == "https://hachyderm.io"
assert credentials.account_acct == "alice@hachyderm.io"
assert credentials.has_access_token() is True
assert credentials.get_access_token() == "access-token"


def test_verify_selected_mastodon_credentials_reports_success(
source_admin_context, mocker
):
credentials = MastodonCredentials.objects.create(
project=source_admin_context.project,
instance_url="https://hachyderm.io",
account_acct="alice@hachyderm.io",
access_token_encrypted="ciphertext",
)
verify_mock = mocker.patch(
"core.plugins.mastodon.MastodonSourcePlugin.verify_credentials"
)
admin_instance = MastodonCredentialsAdmin(MastodonCredentials, AdminSite())
admin_instance.message_user = mocker.Mock()

admin_instance.verify_selected_credentials(
request=SimpleNamespace(),
queryset=MastodonCredentials.objects.filter(pk=credentials.pk),
)

verify_mock.assert_called_once_with(credentials)
admin_instance.message_user.assert_called_once_with(
ANY,
"Credential verification passed for 1 account(s).",
messages.SUCCESS,
)


def test_verify_selected_mastodon_credentials_reports_failures(
source_admin_context, mocker
):
credentials = MastodonCredentials.objects.create(
project=source_admin_context.project,
instance_url="https://hachyderm.io",
account_acct="alice@hachyderm.io",
access_token_encrypted="ciphertext",
)
mocker.patch(
"core.plugins.mastodon.MastodonSourcePlugin.verify_credentials",
side_effect=RuntimeError("bad token"),
)
admin_instance = MastodonCredentialsAdmin(MastodonCredentials, AdminSite())
admin_instance.message_user = mocker.Mock()

admin_instance.verify_selected_credentials(
request=SimpleNamespace(),
queryset=MastodonCredentials.objects.filter(pk=credentials.pk),
)

admin_instance.message_user.assert_called_once_with(
ANY,
"Credential verification failed for: Mastodon credentials for Admin Project: bad token",
messages.ERROR,
)


def test_ingestion_run_display_efficiency_renders_without_django6_format_error(
source_admin_context,
):
Expand Down
80 changes: 80 additions & 0 deletions core/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from projects.model_support import SourcePluginName
from projects.models import (
BlueskyCredentials,
MastodonCredentials,
Project,
ProjectConfig,
ProjectMembership,
Expand Down Expand Up @@ -760,6 +761,81 @@ def test_verify_bluesky_credentials_surfaces_verification_errors(
self.owner_project.id,
)

def test_verify_mastodon_credentials_requires_configured_project_credentials(self):
response = self.client.post(
reverse(
"v1:project-verify-mastodon-credentials",
kwargs={"id": self.owner_project.id},
),
format="json",
)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assert_standardized_validation_error(
response.json(), "mastodon_credentials"
)

@patch("core.plugins.mastodon.MastodonSourcePlugin.verify_credentials")
def test_verify_mastodon_credentials_verifies_project_account(self, verify_mock):
credentials = MastodonCredentials(
project=self.owner_project,
instance_url="https://hachyderm.io",
account_acct="alice@hachyderm.io",
)
credentials.set_access_token("access-token")
credentials.save()

response = self.client.post(
reverse(
"v1:project-verify-mastodon-credentials",
kwargs={"id": self.owner_project.id},
),
format="json",
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
verify_mock.assert_called_once()
verified_credentials = verify_mock.call_args.args[0]
self.assertEqual(verified_credentials.id, credentials.id)
self.assertEqual(response.json()["status"], "verified")
self.assertEqual(response.json()["account_acct"], "alice@hachyderm.io")
self.assertEqual(response.json()["instance_url"], "https://hachyderm.io")
self.assertEqual(response.json()["last_error"], "")

@patch("core.api.logger.exception")
@patch(
"core.plugins.mastodon.MastodonSourcePlugin.verify_credentials",
side_effect=RuntimeError("bad token"),
)
def test_verify_mastodon_credentials_surfaces_verification_errors(
self, _verify_mock, logger_exception_mock
):
credentials = MastodonCredentials(
project=self.owner_project,
instance_url="https://hachyderm.io",
account_acct="alice@hachyderm.io",
)
credentials.set_access_token("access-token")
credentials.save()

response = self.client.post(
reverse(
"v1:project-verify-mastodon-credentials",
kwargs={"id": self.owner_project.id},
),
format="json",
)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assert_standardized_validation_error(
response.json(), "mastodon_credentials"
)
self.assertNotIn("bad token", str(response.json()))
logger_exception_mock.assert_called_once_with(
"Mastodon credential verification failed for project id=%s",
self.owner_project.id,
)

@patch("core.signals.queue_topic_centroid_recompute")
def test_feedback_create_assigns_current_user(self, queue_centroid_mock):
response = self.client.post(
Expand Down Expand Up @@ -918,6 +994,10 @@ def test_authenticated_nested_list_endpoints_smoke(self):
"v1:project-bluesky-credentials-list",
kwargs={"project_id": self.owner_project.id},
),
reverse(
"v1:project-mastodon-credentials-list",
kwargs={"project_id": self.owner_project.id},
),
reverse(
"v1:project-intake-allowlist-list",
kwargs={"project_id": self.owner_project.id},
Expand Down
Loading
Loading