diff --git a/backend/make/apps/owasp.mk b/backend/make/apps/owasp.mk index 22e15ca8a0..995f2498b1 100644 --- a/backend/make/apps/owasp.mk +++ b/backend/make/apps/owasp.mk @@ -1,5 +1,5 @@ .PHONY: owasp-add-project-custom-tags owasp-aggregate-entity-contributions \ - owasp-aggregate-member-contributions owasp-aggregate-projects owasp-create-project-metadata-file \ + owasp-aggregate-member-contributions owasp-aggregate-projects owasp-backfill-activity-events owasp-create-project-metadata-file \ owasp-enrich-chapters owasp-enrich-committees owasp-enrich-events owasp-enrich-projects \ owasp-generate-community-snapshot-video owasp-process-snapshots owasp-scrape-chapters \ owasp-scrape-committees owasp-scrape-projects owasp-sync-posts owasp-update-events \ @@ -23,6 +23,10 @@ owasp-aggregate-projects: @echo "Aggregating OWASP projects" @CMD="python manage.py owasp_aggregate_projects" $(MAKE) backend-exec-command +owasp-backfill-activity-events: + @echo "Backfilling activity events for existing pull requests, issues, and releases" + @CMD="python manage.py owasp_backfill_activity_events" $(MAKE) backend-exec-command + owasp-create-project-metadata-file: @echo "Generating metadata" @CMD="python manage.py owasp_create_project_metadata_file $(entity_key)" $(MAKE) backend-exec-command diff --git a/backend/src/apps/github/common.py b/backend/src/apps/github/common.py index 1371fcf782..eb0abfe3d7 100644 --- a/backend/src/apps/github/common.py +++ b/backend/src/apps/github/common.py @@ -23,6 +23,7 @@ from apps.github.models.repository_contributor import RepositoryContributor from apps.github.models.user import User from apps.github.utils import check_owasp_site_repository +from apps.owasp.models.activity_event import ActivityEvent logger: logging.Logger = logging.getLogger(__name__) @@ -138,6 +139,7 @@ def sync_repository( milestone=milestone, repository=repository, ) + ActivityEvent.update_data(issue) # Assignees. issue.assignees.clear() @@ -186,6 +188,7 @@ def sync_repository( milestone=milestone, repository=repository, ) + ActivityEvent.update_data(pull_request) # Assignees. pull_request.assignees.clear() @@ -217,6 +220,8 @@ def sync_repository( author = User.update_data(gh_release.author) releases.append(Release.update_data(gh_release, author=author, repository=repository)) Release.bulk_save(releases) + for release in releases: + ActivityEvent.update_data(release) # GitHub repository contributors. RepositoryContributor.bulk_save( diff --git a/backend/src/apps/owasp/admin/__init__.py b/backend/src/apps/owasp/admin/__init__.py index 261225145a..524c70a757 100644 --- a/backend/src/apps/owasp/admin/__init__.py +++ b/backend/src/apps/owasp/admin/__init__.py @@ -4,6 +4,7 @@ from apps.owasp.models.project_health_requirements import ProjectHealthRequirements +from .activity_event import ActivityEventAdmin from .board_of_directors import BoardOfDirectorsAdmin from .chapter import ChapterAdmin from .committee import CommitteeAdmin diff --git a/backend/src/apps/owasp/admin/activity_event.py b/backend/src/apps/owasp/admin/activity_event.py new file mode 100644 index 0000000000..6a16479878 --- /dev/null +++ b/backend/src/apps/owasp/admin/activity_event.py @@ -0,0 +1,32 @@ +"""OWASP app ActivityEvent model admin.""" + +from django.contrib import admin + +from apps.owasp.models.activity_event import ActivityEvent + + +class ActivityEventAdmin(admin.ModelAdmin): + """Admin for ActivityEvent model.""" + + autocomplete_fields = ( + "github_user", + "github_repository", + ) + list_display = ( + "activity_type", + "github_repository", + "github_user", + "occurred_at", + ) + list_filter = ( + "activity_type", + "occurred_at", + ) + search_fields = ( + "activity_type", + "github_repository__name", + "github_user__login", + ) + + +admin.site.register(ActivityEvent, ActivityEventAdmin) diff --git a/backend/src/apps/owasp/management/commands/owasp_backfill_activity_events.py b/backend/src/apps/owasp/management/commands/owasp_backfill_activity_events.py new file mode 100644 index 0000000000..b9175abeb5 --- /dev/null +++ b/backend/src/apps/owasp/management/commands/owasp_backfill_activity_events.py @@ -0,0 +1,100 @@ +"""A command to backfill activity events for existing pull requests, issues, and releases.""" + +import logging +from collections.abc import Callable +from typing import Any + +from django.core.management.base import BaseCommand +from django.db.models import QuerySet + +from apps.github.models.issue import Issue +from apps.github.models.pull_request import PullRequest +from apps.github.models.release import Release +from apps.owasp.models.activity_event import ActivityEvent + +logger: logging.Logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Backfill ActivityEvent records for existing pull requests, issues, and releases." + + def add_arguments(self, parser) -> None: + """Add command-line arguments to the parser.""" + parser.add_argument( + "--offset", + default=0, + required=False, + type=int, + help="Number of records to skip before starting backfill.", + ) + parser.add_argument( + "--model", + default="all", + required=False, + choices=["all", "issue", "pull_request", "release"], + help="Which model type to backfill. Defaults to 'all'.", + ) + + def handle(self, *args, **options) -> None: + """Handle the command execution.""" + offset = options["offset"] + model = options["model"] + + if model in ("all", "issue"): + self.backfill_issues(offset) + + if model in ("all", "pull_request"): + self.backfill_pull_requests(offset) + + if model in ("all", "release"): + self.backfill_releases(offset) + + def backfill_objects( + self, + queryset: QuerySet, + offset: int, + noun: str, + get_label: Callable[[Any], str], + ) -> None: + """Backfill ActivityEvent records for a queryset of GitHub objects.""" + count = queryset.count() + self.stdout.write(f"Backfilling activity events for {count} {noun}...\n") + + created_count = 0 + for obj in queryset[offset:].iterator(chunk_size=2000): + if not obj.repository: + logger.warning("Skipping %s %s: no repository", noun.rstrip("s"), get_label(obj)) + continue + + try: + ActivityEvent.update_data(obj) + created_count += 1 + except Exception: + logger.exception( + "Error backfilling activity events for %s %s", + noun.rstrip("s"), + get_label(obj), + ) + + self.stdout.write(f"{noun.capitalize()} processed: {created_count}\n") + + def backfill_issues(self, offset: int) -> None: + """Backfill ActivityEvent records for existing issues.""" + queryset = Issue.objects.select_related("author", "repository").order_by( + "created_at", "pk" + ) + self.backfill_objects(queryset, offset, "issues", lambda obj: f"#{obj.number}") + + def backfill_pull_requests(self, offset: int) -> None: + """Backfill ActivityEvent records for existing pull requests.""" + queryset = PullRequest.objects.select_related("author", "repository").order_by( + "created_at", "pk" + ) + self.backfill_objects(queryset, offset, "pull requests", lambda obj: f"#{obj.number}") + + def backfill_releases(self, offset: int) -> None: + """Backfill ActivityEvent records for existing releases.""" + queryset = Release.objects.select_related("author", "repository").order_by( + "created_at", "pk" + ) + self.backfill_objects(queryset, offset, "releases", lambda obj: obj.tag_name) diff --git a/backend/src/apps/owasp/migrations/0073_activityevent.py b/backend/src/apps/owasp/migrations/0073_activityevent.py new file mode 100644 index 0000000000..ba75297650 --- /dev/null +++ b/backend/src/apps/owasp/migrations/0073_activityevent.py @@ -0,0 +1,98 @@ +# Generated by Django 6.0.7 on 2026-07-28 07:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("github", "0044_user_indexes"), + ("owasp", "0072_project_project_name_gin_idx_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ActivityEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ( + "activity_type", + models.CharField( + choices=[ + ("issue_closed", "Issue Closed"), + ("issue_opened", "Issue Opened"), + ("pr_closed", "PR Closed"), + ("pr_merged", "PR Merged"), + ("pr_opened", "PR Opened"), + ("release_published", "Release Published"), + ], + max_length=32, + verbose_name="Activity Type", + ), + ), + ("object_id", models.PositiveBigIntegerField()), + ( + "occurred_at", + models.DateTimeField( + help_text="Timestamp when the activity event occurred on GitHub", + verbose_name="Occurred at", + ), + ), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype" + ), + ), + ( + "github_repository", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="activity_events", + to="github.repository", + verbose_name="GitHub Repository", + ), + ), + ( + "github_user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="activity_events", + to="github.user", + verbose_name="GitHub User", + ), + ), + ], + options={ + "verbose_name_plural": "Activity Events", + "db_table": "github_activity_events", + "indexes": [ + models.Index(fields=["activity_type"], name="activity_event_type_idx"), + models.Index(fields=["github_user"], name="activity_event_github_user_idx"), + models.Index( + fields=["content_type", "object_id"], name="activity_event_source_idx" + ), + models.Index(fields=["occurred_at"], name="activity_event_occurred_at_idx"), + models.Index( + fields=["github_repository"], name="activity_event_github_repo_idx" + ), + ], + "constraints": [ + models.UniqueConstraint( + fields=("activity_type", "content_type", "object_id", "occurred_at"), + name="unique_activity_event", + ) + ], + }, + ), + ] diff --git a/backend/src/apps/owasp/models/__init__.py b/backend/src/apps/owasp/models/__init__.py index 3cbb120b8b..3bbb4a7832 100644 --- a/backend/src/apps/owasp/models/__init__.py +++ b/backend/src/apps/owasp/models/__init__.py @@ -1,3 +1,4 @@ +from .activity_event import ActivityEvent from .board_of_directors import BoardOfDirectors from .chapter import Chapter from .committee import Committee diff --git a/backend/src/apps/owasp/models/activity_event.py b/backend/src/apps/owasp/models/activity_event.py new file mode 100644 index 0000000000..3db60269c3 --- /dev/null +++ b/backend/src/apps/owasp/models/activity_event.py @@ -0,0 +1,154 @@ +"""OWASP app activity event model.""" + +import logging + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from apps.common.models import BulkSaveModel, TimestampedModel + +logger = logging.getLogger(__name__) + + +class ActivityEvent(BulkSaveModel, TimestampedModel): + """Represents a discrete GitHub activity event linked to a single source object. + + Uses a polymorphic GenericForeignKey to reference the source object. + """ + + class Meta: + """Model options.""" + + db_table = "github_activity_events" + verbose_name_plural = "Activity Events" + + constraints = [ + models.UniqueConstraint( + fields=[ + "activity_type", + "content_type", + "object_id", + "occurred_at", + ], + name="unique_activity_event", + ), + ] + + indexes = [ + models.Index(fields=["activity_type"], name="activity_event_type_idx"), + models.Index(fields=["github_user"], name="activity_event_github_user_idx"), + models.Index( + fields=["content_type", "object_id"], + name="activity_event_source_idx", + ), + models.Index(fields=["occurred_at"], name="activity_event_occurred_at_idx"), + models.Index(fields=["github_repository"], name="activity_event_github_repo_idx"), + ] + + class ActivityType(models.TextChoices): + """Activity type choices.""" + + ISSUE_CLOSED = "issue_closed", "Issue Closed" + ISSUE_OPENED = "issue_opened", "Issue Opened" + PR_CLOSED = "pr_closed", "PR Closed" + PR_MERGED = "pr_merged", "PR Merged" + PR_OPENED = "pr_opened", "PR Opened" + RELEASE_PUBLISHED = "release_published", "Release Published" + + activity_type = models.CharField( + verbose_name="Activity Type", + max_length=32, + choices=ActivityType.choices, + ) + github_user = models.ForeignKey( + "github.User", + verbose_name="GitHub User", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="activity_events", + ) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveBigIntegerField() + occurred_at = models.DateTimeField( + verbose_name="Occurred at", + help_text="Timestamp when the activity event occurred on GitHub", + ) + github_repository = models.ForeignKey( + "github.Repository", + verbose_name="GitHub Repository", + on_delete=models.CASCADE, + related_name="activity_events", + ) + source_object = GenericForeignKey("content_type", "object_id") + + HANDLERS: dict[str, str] = { + "Issue": "build_for_issue", + "PullRequest": "build_for_pull_request", + "Release": "build_for_release", + } + + def __str__(self) -> str: + """Return human-readable representation.""" + return f"{self.activity_type} by {self.github_user} in {self.github_repository}" + + @staticmethod + def bulk_save(activity_events, fields=None) -> None: # type: ignore[override] + """Bulk save activity events.""" + BulkSaveModel.bulk_save(ActivityEvent, activity_events, fields=fields) + + @staticmethod + def build_for_issue(issue) -> list[tuple]: + """Return event tuples for an Issue.""" + events = [(ActivityEvent.ActivityType.ISSUE_OPENED, issue.created_at, issue.author)] + if issue.state == "closed" and issue.closed_at: + events.append((ActivityEvent.ActivityType.ISSUE_CLOSED, issue.closed_at, issue.author)) + return events + + @staticmethod + def build_for_pull_request(pr) -> list[tuple]: + """Return event tuples for a PullRequest.""" + events = [(ActivityEvent.ActivityType.PR_OPENED, pr.created_at, pr.author)] + if pr.merged_at: + events.append((ActivityEvent.ActivityType.PR_MERGED, pr.merged_at, pr.author)) + elif pr.state == "closed" and pr.closed_at: + events.append((ActivityEvent.ActivityType.PR_CLOSED, pr.closed_at, pr.author)) + return events + + @staticmethod + def build_for_release(release) -> list[tuple]: + """Return event tuples for a Release.""" + occurred_at = release.published_at or release.created_at + return [(ActivityEvent.ActivityType.RELEASE_PUBLISHED, occurred_at, release.author)] + + @staticmethod + def update_data(obj) -> None: + """Create ActivityEvent row(s) for a saved GitHub model instance if they do not exist.""" + handler_name = ActivityEvent.HANDLERS.get(type(obj).__name__) + if handler_name is None: + logger.error( + "ActivityEvent.update_data received unsupported model type: %s", + type(obj).__name__, + ) + message = f"Unsupported model type: {type(obj)}" + raise TypeError(message) + + handler = getattr(ActivityEvent, handler_name) + events = handler(obj) + content_type = ContentType.objects.get_for_model(obj) + + for activity_type, occurred_at, github_user in events: + if occurred_at is None: + continue + + ActivityEvent.objects.get_or_create( + activity_type=activity_type, + content_type=content_type, + object_id=obj.pk, + occurred_at=occurred_at, + defaults={ + "github_user": github_user, + "github_repository": obj.repository, + }, + ) diff --git a/backend/tests/unit/apps/github/common_test.py b/backend/tests/unit/apps/github/common_test.py index a5501eed1c..8106f85584 100644 --- a/backend/tests/unit/apps/github/common_test.py +++ b/backend/tests/unit/apps/github/common_test.py @@ -21,6 +21,7 @@ def mock_common_deps(mocker): "Label": mocker.patch("apps.github.common.Label"), "Release": mocker.patch("apps.github.common.Release"), "RepositoryContributor": mocker.patch("apps.github.common.RepositoryContributor"), + "ActivityEvent": mocker.patch("apps.github.common.ActivityEvent"), "check_owasp": mocker.patch( "apps.github.common.check_owasp_site_repository", return_value=False ), diff --git a/backend/tests/unit/apps/owasp/management/commands/owasp_backfill_activity_events_test.py b/backend/tests/unit/apps/owasp/management/commands/owasp_backfill_activity_events_test.py new file mode 100644 index 0000000000..0e02b678d2 --- /dev/null +++ b/backend/tests/unit/apps/owasp/management/commands/owasp_backfill_activity_events_test.py @@ -0,0 +1,236 @@ +"""Tests for the owasp_backfill_activity_events Django management command.""" + +from unittest.mock import MagicMock, patch + +import pytest +from django.core.management.base import BaseCommand + +from apps.owasp.management.commands.owasp_backfill_activity_events import Command + + +class TestOwaspBackfillActivityEventsCommand: + def test_command_help_text(self): + """Test that the command has the correct help text.""" + command = Command() + assert command.help == ( + "Backfill ActivityEvent records for existing pull requests, issues, and releases." + ) + + def test_command_inheritance(self): + """Test that the command inherits from BaseCommand.""" + assert issubclass(Command, BaseCommand) + + @pytest.mark.parametrize( + ("argument_name", "expected_properties"), + [ + ( + "--offset", + { + "default": 0, + "required": False, + "type": int, + "help": "Number of records to skip before starting backfill.", + }, + ), + ( + "--model", + { + "default": "all", + "required": False, + "choices": ["all", "issue", "pull_request", "release"], + "help": "Which model type to backfill. Defaults to 'all'.", + }, + ), + ], + ) + def test_add_arguments(self, argument_name, expected_properties): + """Test that the command adds the correct arguments.""" + mock_parser = MagicMock() + command = Command() + command.add_arguments(mock_parser) + mock_parser.add_argument.assert_any_call(argument_name, **expected_properties) + + @pytest.mark.parametrize( + "model_option", + ["all", "issue", "pull_request", "release"], + ) + def test_handle_calls_correct_backfill_methods(self, mocker, model_option): + """Test that handle() delegates to the correct backfill methods based on --model.""" + command = Command() + command.stdout = MagicMock() + + mock_backfill_issues = mocker.patch.object(command, "backfill_issues") + mock_backfill_pull_requests = mocker.patch.object(command, "backfill_pull_requests") + mock_backfill_releases = mocker.patch.object(command, "backfill_releases") + + command.handle(offset=0, model=model_option) + + if model_option in ("all", "issue"): + mock_backfill_issues.assert_called_once_with(0) + else: + mock_backfill_issues.assert_not_called() + + if model_option in ("all", "pull_request"): + mock_backfill_pull_requests.assert_called_once_with(0) + else: + mock_backfill_pull_requests.assert_not_called() + + if model_option in ("all", "release"): + mock_backfill_releases.assert_called_once_with(0) + else: + mock_backfill_releases.assert_not_called() + + @patch("apps.owasp.management.commands.owasp_backfill_activity_events.ActivityEvent") + def test_backfill_objects_processes_all(self, mock_activity_event_class): + """Test that backfill_objects calls update_data for every object.""" + mock_obj1 = MagicMock(repository=MagicMock()) + mock_obj2 = MagicMock(repository=MagicMock()) + + mock_qs = MagicMock() + mock_qs.count.return_value = 2 + mock_qs.__getitem__.return_value.iterator.return_value = iter([mock_obj1, mock_obj2]) + + command = Command() + command.stdout = MagicMock() + command.backfill_objects(mock_qs, 0, "issues", str) + + mock_activity_event_class.update_data.assert_any_call(mock_obj1) + mock_activity_event_class.update_data.assert_any_call(mock_obj2) + assert mock_activity_event_class.update_data.call_count == 2 + + @patch("apps.owasp.management.commands.owasp_backfill_activity_events.ActivityEvent") + def test_backfill_objects_skips_without_repository(self, mock_activity_event_class): + """Test that backfill_objects skips objects that have no repository.""" + mock_obj = MagicMock(repository=None) + + mock_qs = MagicMock() + mock_qs.count.return_value = 1 + mock_qs.__getitem__.return_value.iterator.return_value = iter([mock_obj]) + + command = Command() + command.stdout = MagicMock() + command.backfill_objects(mock_qs, 0, "issues", str) + + mock_activity_event_class.update_data.assert_not_called() + + @patch("apps.owasp.management.commands.owasp_backfill_activity_events.ActivityEvent") + def test_backfill_objects_continues_on_error(self, mock_activity_event_class): + """Test that backfill_objects continues when one object raises an exception.""" + mock_obj1 = MagicMock(repository=MagicMock()) + mock_obj2 = MagicMock(repository=MagicMock()) + + mock_activity_event_class.update_data.side_effect = [Exception("DB error"), None] + + mock_qs = MagicMock() + mock_qs.count.return_value = 2 + mock_qs.__getitem__.return_value.iterator.return_value = iter([mock_obj1, mock_obj2]) + + command = Command() + command.stdout = MagicMock() + command.backfill_objects(mock_qs, 0, "issues", str) + + assert mock_activity_event_class.update_data.call_count == 2 + + def test_backfill_objects_respects_offset(self): + """Test that backfill_objects slices the queryset with the given offset.""" + mock_qs = MagicMock() + mock_qs.count.return_value = 5 + mock_qs.__getitem__.return_value.iterator.return_value = iter([]) + + command = Command() + command.stdout = MagicMock() + command.backfill_objects(mock_qs, 3, "issues", str) + + mock_qs.__getitem__.assert_called_once_with(slice(3, None)) + + @patch("apps.owasp.management.commands.owasp_backfill_activity_events.Issue") + def test_backfill_issues_passes_correct_queryset(self, mock_issue_class, mocker): + """Test that backfill_issues builds the right queryset and delegates.""" + mock_qs = MagicMock() + mock_qs.__getitem__.return_value = mock_qs + mock_issue_class.objects.select_related.return_value.order_by.return_value = mock_qs + + command = Command() + command.stdout = MagicMock() + mock_backfill_objects = mocker.patch.object(command, "backfill_objects") + + command.backfill_issues(offset=0) + + mock_issue_class.objects.select_related.assert_called_once_with("author", "repository") + mock_issue_class.objects.select_related.return_value.order_by.assert_called_once_with( + "created_at", "pk" + ) + assert mock_backfill_objects.call_count == 1 + assert mock_backfill_objects.call_args[0][2] == "issues" + + @patch("apps.owasp.management.commands.owasp_backfill_activity_events.PullRequest") + def test_backfill_pull_requests_passes_correct_queryset(self, mock_pr_class, mocker): + """Test that backfill_pull_requests builds the right queryset and delegates.""" + mock_qs = MagicMock() + mock_qs.__getitem__.return_value = mock_qs + mock_pr_class.objects.select_related.return_value.order_by.return_value = mock_qs + + command = Command() + command.stdout = MagicMock() + mock_backfill_objects = mocker.patch.object(command, "backfill_objects") + + command.backfill_pull_requests(offset=0) + + mock_pr_class.objects.select_related.assert_called_once_with("author", "repository") + mock_pr_class.objects.select_related.return_value.order_by.assert_called_once_with( + "created_at", "pk" + ) + assert mock_backfill_objects.call_count == 1 + assert mock_backfill_objects.call_args[0][2] == "pull requests" + + @patch("apps.owasp.management.commands.owasp_backfill_activity_events.Release") + def test_backfill_releases_passes_correct_queryset(self, mock_release_class, mocker): + """Test that backfill_releases builds the right queryset and delegates.""" + mock_qs = MagicMock() + mock_qs.__getitem__.return_value = mock_qs + mock_release_class.objects.select_related.return_value.order_by.return_value = mock_qs + + command = Command() + command.stdout = MagicMock() + mock_backfill_objects = mocker.patch.object(command, "backfill_objects") + + command.backfill_releases(offset=0) + + mock_release_class.objects.select_related.assert_called_once_with("author", "repository") + mock_release_class.objects.select_related.return_value.order_by.assert_called_once_with( + "created_at", "pk" + ) + assert mock_backfill_objects.call_count == 1 + assert mock_backfill_objects.call_args[0][2] == "releases" + + @pytest.mark.parametrize( + ("method_name", "model_patch_path"), + [ + ( + "backfill_issues", + "apps.owasp.management.commands.owasp_backfill_activity_events.Issue", + ), + ( + "backfill_pull_requests", + "apps.owasp.management.commands.owasp_backfill_activity_events.PullRequest", + ), + ( + "backfill_releases", + "apps.owasp.management.commands.owasp_backfill_activity_events.Release", + ), + ], + ) + def test_backfill_wrapper_methods_pass_offset(self, method_name, model_patch_path, mocker): + """Test that wrapper methods forward the offset argument to backfill_objects.""" + mock_model_class = mocker.patch(model_patch_path) + mock_qs = MagicMock() + mock_model_class.objects.select_related.return_value.order_by.return_value = mock_qs + + command = Command() + command.stdout = MagicMock() + mock_backfill_objects = mocker.patch.object(command, "backfill_objects") + + getattr(command, method_name)(offset=7) + + mock_backfill_objects.assert_called_once() + assert mock_backfill_objects.call_args[0][1] == 7 diff --git a/docker-compose/local/compose.override.yaml b/docker-compose/local/compose.override.yaml index e69de29bb2..25ce0eb8cb 100644 --- a/docker-compose/local/compose.override.yaml +++ b/docker-compose/local/compose.override.yaml @@ -0,0 +1,13 @@ +volumes: + backend-venv: + name: backend-venv-pulse + cache-data: + name: cache-data-pulse + db-data: + name: db-data-pulse + docs-venv: + name: docs-venv-pulse + frontend-next: + name: frontend-next-pulse + frontend-node-modules: + name: frontend-node-modules-pulse