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/api/internal/nodes/activity_event.py b/backend/src/apps/owasp/api/internal/nodes/activity_event.py new file mode 100644 index 0000000000..50cc36f8d4 --- /dev/null +++ b/backend/src/apps/owasp/api/internal/nodes/activity_event.py @@ -0,0 +1,53 @@ +"""OWASP activity event GraphQL node.""" + +import strawberry +import strawberry_django + +from apps.github.api.internal.nodes.repository import RepositoryNode +from apps.github.api.internal.nodes.user import UserNode +from apps.owasp.models.activity_event import ActivityEvent + + +@strawberry_django.type( + ActivityEvent, + fields=[ + "activity_type", + "occurred_at", + ], +) +class ActivityEventNode(strawberry.relay.Node): + """Activity event node.""" + + @strawberry_django.field(select_related=["github_user"]) + def github_user(self, root: ActivityEvent) -> UserNode | None: + """Resolve GitHub user.""" + return root.github_user + + @strawberry_django.field(select_related=["github_repository"]) + def github_repository(self, root: ActivityEvent) -> RepositoryNode: + """Resolve GitHub repository.""" + return root.github_repository + + @strawberry_django.field + def number(self, root: ActivityEvent) -> int | None: + """Resolve issue or PR number.""" + return root.source_number + + @strawberry_django.field + def title(self, root: ActivityEvent) -> str: + """Resolve title of the source object.""" + return root.source_title + + @strawberry_django.field + def url(self, root: ActivityEvent) -> str: + """Resolve URL of the source object.""" + return root.source_url + + +@strawberry.type +class PaginatedActivityEvents: + """A paginated list of activity events.""" + + current_page: int + events: list[ActivityEventNode] + total_pages: int diff --git a/backend/src/apps/owasp/api/internal/queries/__init__.py b/backend/src/apps/owasp/api/internal/queries/__init__.py index 3e27bea1d0..aeeb50b7c9 100644 --- a/backend/src/apps/owasp/api/internal/queries/__init__.py +++ b/backend/src/apps/owasp/api/internal/queries/__init__.py @@ -1,5 +1,6 @@ """OWASP GraphQL queries.""" +from .activity_event import ActivityEventQuery from .board_of_directors import BoardOfDirectorsQuery from .chapter import ChapterQuery from .committee import CommitteeQuery @@ -14,6 +15,7 @@ class OwaspQuery( + ActivityEventQuery, BoardOfDirectorsQuery, ChapterQuery, CommitteeQuery, diff --git a/backend/src/apps/owasp/api/internal/queries/activity_event.py b/backend/src/apps/owasp/api/internal/queries/activity_event.py new file mode 100644 index 0000000000..4d01d2e077 --- /dev/null +++ b/backend/src/apps/owasp/api/internal/queries/activity_event.py @@ -0,0 +1,115 @@ +"""OWASP activity event GraphQL queries.""" + +import strawberry +import strawberry_django + +from apps.common.utils import normalize_limit +from apps.owasp.api.internal.nodes.activity_event import ActivityEventNode, PaginatedActivityEvents +from apps.owasp.models.activity_event import ActivityEvent +from apps.owasp.models.chapter import Chapter +from apps.owasp.models.project import Project + +MAX_LIMIT = 1000 +PAGE_SIZE = 20 + + +@strawberry.type +class ActivityEventQuery: + """Activity event queries.""" + + @strawberry_django.field + def activity_events( + self, + *, + activity_type: str | None = None, + github_user_login: str | None = None, + project_key: str | None = None, + chapter_key: str | None = None, + time_range: str | None = None, + include_bots: bool = False, + order: str = "desc", + page: int = 1, + limit: int = PAGE_SIZE, + ) -> PaginatedActivityEvents: + """Resolve activity events with optional filtering and pagination.""" + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + normalized_limit = PAGE_SIZE + + if page < 1: + return PaginatedActivityEvents(current_page=1, events=[], total_pages=1) + + if order not in {"asc", "desc"}: + return PaginatedActivityEvents(current_page=1, events=[], total_pages=1) + + order_clauses = ("occurred_at", "pk") if order == "asc" else ("-occurred_at", "-pk") + + queryset = ( + ActivityEvent.objects.select_related( + "github_user", + "github_repository", + ) + .prefetch_related( + "source_object", + ) + .order_by(*order_clauses) + ) + + if not include_bots: + queryset = ActivityEvent.exclude_bots(queryset) + + if activity_type: + queryset = queryset.filter(activity_type=activity_type) + + if github_user_login and (cleaned := github_user_login.strip()): + queryset = queryset.filter(github_user__login__icontains=cleaned) + + if project_key and (cleaned := project_key.strip()): + project_repo_ids = Project.objects.filter( + key__icontains=cleaned, + ).values_list("repositories", flat=True) + queryset = queryset.filter(github_repository__in=project_repo_ids) + + if chapter_key and (cleaned := chapter_key.strip()): + chapter_repo_ids = ( + Chapter.objects.filter( + key__icontains=cleaned, + ) + .exclude( + owasp_repository__isnull=True, + ) + .values_list("owasp_repository_id", flat=True) + ) + queryset = queryset.filter(github_repository__in=chapter_repo_ids) + + if time_range and (cleaned := time_range.strip()): + queryset = ActivityEvent.filter_time_range(queryset, cleaned) + + total_count = queryset.count() + total_pages = max(1, (total_count + normalized_limit - 1) // normalized_limit) + page = max(1, min(page, total_pages)) + offset = (page - 1) * normalized_limit + + return PaginatedActivityEvents( + current_page=page, + events=list(queryset[offset : offset + normalized_limit]), + total_pages=total_pages, + ) + + @strawberry_django.field + def recent_activity_events(self, limit: int = 10) -> list[ActivityEventNode]: + """Resolve recent activity events.""" + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + queryset = ( + ActivityEvent.objects.select_related( + "github_user", + "github_repository", + ) + .prefetch_related( + "source_object", + ) + .order_by("-occurred_at", "-pk") + ) + + return list(ActivityEvent.exclude_bots(queryset)[:normalized_limit]) 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..a26a531663 --- /dev/null +++ b/backend/src/apps/owasp/models/activity_event.py @@ -0,0 +1,203 @@ +"""OWASP app activity event model.""" + +import logging +from datetime import timedelta + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.db.models import Q +from django.utils import timezone + +from apps.common.models import BulkSaveModel, TimestampedModel +from apps.github.models.user import User + +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", + } + + TIME_RANGES: dict[str, timedelta] = { + "24h": timedelta(hours=24), + "7d": timedelta(days=7), + "30d": timedelta(days=30), + "90d": timedelta(days=90), + "180d": timedelta(days=180), + "1y": timedelta(days=365), + "2y": timedelta(days=730), + } + + def __str__(self) -> str: + """Return human-readable representation.""" + return f"{self.activity_type} by {self.github_user} in {self.github_repository}" + + @property + def source_number(self) -> int | None: + """Return issue or PR number from the source object, if applicable.""" + return getattr(self.source_object, "number", None) + + @property + def source_title(self) -> str: + """Return title or name from the source object.""" + if not self.source_object: + return "" + return getattr(self.source_object, "title", getattr(self.source_object, "name", "")) + + @property + def source_url(self) -> str: + """Return URL from the source object.""" + return getattr(self.source_object, "url", "") if self.source_object else "" + + @classmethod + def exclude_bots(cls, queryset): + """Exclude bot accounts from the given queryset.""" + return queryset.exclude( + Q(github_user__is_bot=True) | Q(github_user__login__in=User.get_non_indexable_logins()) + ) + + @classmethod + def filter_time_range(cls, queryset, time_range: str): + """Filter queryset by time range.""" + if not time_range: + return queryset + + if delta := cls.TIME_RANGES.get(time_range): + return queryset.filter(occurred_at__gte=timezone.now() - delta) + + return queryset.none() + + @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/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