From b10a3f3da64ff2facdddc5382f258f90ec9dcb19 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sat, 18 Jul 2026 22:08:21 +0530 Subject: [PATCH 1/8] Implemented data model --- backend/src/apps/github/admin/__init__.py | 1 + .../src/apps/github/admin/activity_event.py | 33 ++++++++ .../github/migrations/0045_activityevent.py | 35 +++++++++ backend/src/apps/github/models/__init__.py | 1 + .../src/apps/github/models/activity_event.py | 78 +++++++++++++++++++ .../src/apps/github/models/enums/__init__.py | 0 .../github/models/enums/activity_event.py | 18 +++++ docker-compose/local/compose.yaml | 26 +++---- 8 files changed, 179 insertions(+), 13 deletions(-) create mode 100644 backend/src/apps/github/admin/activity_event.py create mode 100644 backend/src/apps/github/migrations/0045_activityevent.py create mode 100644 backend/src/apps/github/models/activity_event.py create mode 100644 backend/src/apps/github/models/enums/__init__.py create mode 100644 backend/src/apps/github/models/enums/activity_event.py diff --git a/backend/src/apps/github/admin/__init__.py b/backend/src/apps/github/admin/__init__.py index 60ae27406f..eb7170e91f 100644 --- a/backend/src/apps/github/admin/__init__.py +++ b/backend/src/apps/github/admin/__init__.py @@ -1,5 +1,6 @@ """Github app admin.""" +from .activity_event import ActivityEventAdmin from .comment import CommentAdmin from .commit import CommitAdmin from .issue import IssueAdmin diff --git a/backend/src/apps/github/admin/activity_event.py b/backend/src/apps/github/admin/activity_event.py new file mode 100644 index 0000000000..715aba9cd5 --- /dev/null +++ b/backend/src/apps/github/admin/activity_event.py @@ -0,0 +1,33 @@ +"""GitHub app ActivityEvent model admin.""" + +from django.contrib import admin + +from apps.github.models.activity_event import ActivityEvent + + +class ActivityEventAdmin(admin.ModelAdmin): + """Admin for ActivityEvent model.""" + + autocomplete_fields = ( + "actor", + "repository", + ) + list_display = ( + "activity_type", + "actor", + "nest_created_at", + "occurred_at", + "repository", + ) + list_filter = ( + "activity_type", + "occurred_at", + ) + search_fields = ( + "activity_type", + "actor__login", + "repository__name", + ) + + +admin.site.register(ActivityEvent, ActivityEventAdmin) diff --git a/backend/src/apps/github/migrations/0045_activityevent.py b/backend/src/apps/github/migrations/0045_activityevent.py new file mode 100644 index 0000000000..046aa38f51 --- /dev/null +++ b/backend/src/apps/github/migrations/0045_activityevent.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.7 on 2026-07-17 08:13 + +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'), + ] + + 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=[('pr_opened', 'PR Opened'), ('pr_closed', 'PR Closed'), ('pr_merged', 'PR Merged'), ('issue_opened', 'Issue Opened'), ('issue_closed', 'Issue Closed'), ('commit_pushed', 'Commit Pushed'), ('release_published', 'Release Published')], max_length=32, verbose_name='Activity Type')), + ('occurred_at', models.DateTimeField(help_text='Timestamp when the activity event occurred on GitHub', verbose_name='Occurred at')), + ('object_id', models.PositiveBigIntegerField()), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_events', to='github.user', verbose_name='Actor')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('repository', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_events', to='github.repository', verbose_name='Repository')), + ], + options={ + 'verbose_name_plural': 'Activity Events', + 'db_table': 'github_activity_events', + 'indexes': [models.Index(fields=['occurred_at'], name='activity_event_occurred_at_idx'), models.Index(fields=['activity_type'], name='activity_event_type_idx'), models.Index(fields=['repository'], name='activity_event_repository_idx'), models.Index(fields=['actor'], name='activity_event_actor_idx'), models.Index(fields=['content_type', 'object_id'], name='activity_event_source_idx')], + 'constraints': [models.UniqueConstraint(fields=('activity_type', 'content_type', 'object_id'), name='unique_activity_event')], + }, + ), + ] diff --git a/backend/src/apps/github/models/__init__.py b/backend/src/apps/github/models/__init__.py index 5b4113b60b..d25fed562a 100644 --- a/backend/src/apps/github/models/__init__.py +++ b/backend/src/apps/github/models/__init__.py @@ -1,5 +1,6 @@ """Github app.""" +from .activity_event import ActivityEvent from .comment import Comment from .commit import Commit from .issue import Issue diff --git a/backend/src/apps/github/models/activity_event.py b/backend/src/apps/github/models/activity_event.py new file mode 100644 index 0000000000..19bcc14f03 --- /dev/null +++ b/backend/src/apps/github/models/activity_event.py @@ -0,0 +1,78 @@ +"""Github app activity event model.""" + +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 + + +from apps.github.models.enums.activity_event import ActivityType + + +class ActivityEvent(BulkSaveModel, TimestampedModel): + """Represents a discrete GitHub activity event linked to a single source object via a polymorphic GenericForeignKey.""" + + class Meta: + """Model options.""" + + db_table = "github_activity_events" + verbose_name_plural = "Activity Events" + + constraints = [ + models.UniqueConstraint( + fields=[ + "activity_type", + "content_type", + "object_id", + ], + name="unique_activity_event", + ), + ] + + indexes = [ + models.Index(fields=["activity_type"], name="activity_event_type_idx"), + models.Index(fields=["actor"], name="activity_event_actor_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=["repository"], name="activity_event_repository_idx"), + ] + + activity_type = models.CharField( + verbose_name="Activity Type", + max_length=32, + choices=ActivityType.choices, + ) + actor = models.ForeignKey( + "github.User", + verbose_name="Actor", + 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", + ) + repository = models.ForeignKey( + "github.Repository", + verbose_name="Repository", + on_delete=models.CASCADE, + related_name="activity_events", + ) + source_object = GenericForeignKey("content_type", "object_id") + + def __str__(self) -> str: + """Return human-readable representation.""" + return f"{self.activity_type} by {self.actor} in {self.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) diff --git a/backend/src/apps/github/models/enums/__init__.py b/backend/src/apps/github/models/enums/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/apps/github/models/enums/activity_event.py b/backend/src/apps/github/models/enums/activity_event.py new file mode 100644 index 0000000000..caffcd055a --- /dev/null +++ b/backend/src/apps/github/models/enums/activity_event.py @@ -0,0 +1,18 @@ +"""Enums for GitHub activity events.""" + +from django.db import models + + +class ActivityType(models.TextChoices): + """Activity type choices.""" + + COMMIT_PUSHED = "commit_pushed", "Commit Pushed" + + 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" diff --git a/docker-compose/local/compose.yaml b/docker-compose/local/compose.yaml index 8b04f7b329..f9a8a056d0 100644 --- a/docker-compose/local/compose.yaml +++ b/docker-compose/local/compose.yaml @@ -23,7 +23,7 @@ services: - 8000:8000 volumes: - ../../backend:/home/owasp - - backend-venv:/home/owasp/.venv + - backend-venv-pulse:/home/owasp/.venv cache: command: > @@ -41,7 +41,7 @@ services: networks: - nest-network volumes: - - cache-data:/data + - cache-data-pulse:/data db: container_name: nest-db @@ -55,7 +55,7 @@ services: networks: - nest-network volumes: - - db-data-5079:/var/lib/postgresql/data + - db-data-pulse:/var/lib/postgresql/data docs: container_name: nest-docs @@ -77,7 +77,7 @@ services: - ../../README.md:/home/owasp/README.md:ro - ../../CODE_OF_CONDUCT.md:/home/owasp/CODE_OF_CONDUCT.md:ro - ../../CONTRIBUTING.md:/home/owasp/CONTRIBUTING.md:ro - - docs-venv:/home/owasp/.venv + - docs-venv-pulse:/home/owasp/.venv frontend: container_name: nest-frontend @@ -99,8 +99,8 @@ services: - 3000:3000 volumes: - ../../frontend:/home/owasp - - frontend-next:/home/owasp/.next - - frontend-node-modules:/home/owasp/node_modules + - frontend-next-pulse:/home/owasp/.next + - frontend-node-modules-pulse:/home/owasp/node_modules worker: container_name: nest-worker @@ -121,15 +121,15 @@ services: - nest-network volumes: - ../../backend:/home/owasp - - backend-venv:/home/owasp/.venv + - backend-venv-pulse:/home/owasp/.venv networks: nest-network: volumes: - backend-venv: - cache-data: - db-data-5079: - docs-venv: - frontend-next: - frontend-node-modules: + backend-venv-pulse: + cache-data-pulse: + db-data-pulse: + docs-venv-pulse: + frontend-next-pulse: + frontend-node-modules-pulse: From dc695f521927bd768bf9c9508c2458c6d43623d9 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sat, 18 Jul 2026 22:54:27 +0530 Subject: [PATCH 2/8] adress review --- .../github/migrations/0045_activityevent.py | 97 +++++++++++++++---- .../src/apps/github/models/activity_event.py | 8 +- .../github/models/enums/activity_event.py | 3 - 3 files changed, 84 insertions(+), 24 deletions(-) diff --git a/backend/src/apps/github/migrations/0045_activityevent.py b/backend/src/apps/github/migrations/0045_activityevent.py index 046aa38f51..cba54f7b0c 100644 --- a/backend/src/apps/github/migrations/0045_activityevent.py +++ b/backend/src/apps/github/migrations/0045_activityevent.py @@ -1,35 +1,96 @@ -# Generated by Django 6.0.7 on 2026-07-17 08:13 +# Generated by Django 6.0.7 on 2026-07-18 17:15 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'), + ("contenttypes", "0002_remove_content_type_name"), + ("github", "0044_user_indexes"), ] operations = [ migrations.CreateModel( - name='ActivityEvent', + 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=[('pr_opened', 'PR Opened'), ('pr_closed', 'PR Closed'), ('pr_merged', 'PR Merged'), ('issue_opened', 'Issue Opened'), ('issue_closed', 'Issue Closed'), ('commit_pushed', 'Commit Pushed'), ('release_published', 'Release Published')], max_length=32, verbose_name='Activity Type')), - ('occurred_at', models.DateTimeField(help_text='Timestamp when the activity event occurred on GitHub', verbose_name='Occurred at')), - ('object_id', models.PositiveBigIntegerField()), - ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_events', to='github.user', verbose_name='Actor')), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), - ('repository', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_events', to='github.repository', verbose_name='Repository')), + ( + "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=[ + ("commit_pushed", "Commit Pushed"), + ("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", + ), + ), + ( + "actor", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="activity_events", + to="github.user", + verbose_name="Actor", + ), + ), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype" + ), + ), + ( + "repository", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="activity_events", + to="github.repository", + verbose_name="Repository", + ), + ), ], options={ - 'verbose_name_plural': 'Activity Events', - 'db_table': 'github_activity_events', - 'indexes': [models.Index(fields=['occurred_at'], name='activity_event_occurred_at_idx'), models.Index(fields=['activity_type'], name='activity_event_type_idx'), models.Index(fields=['repository'], name='activity_event_repository_idx'), models.Index(fields=['actor'], name='activity_event_actor_idx'), models.Index(fields=['content_type', 'object_id'], name='activity_event_source_idx')], - 'constraints': [models.UniqueConstraint(fields=('activity_type', 'content_type', 'object_id'), name='unique_activity_event')], + "verbose_name_plural": "Activity Events", + "db_table": "github_activity_events", + "indexes": [ + models.Index(fields=["activity_type"], name="activity_event_type_idx"), + models.Index(fields=["actor"], name="activity_event_actor_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=["repository"], name="activity_event_repository_idx"), + ], + "constraints": [ + models.UniqueConstraint( + fields=("activity_type", "content_type", "object_id", "occurred_at"), + name="unique_activity_event", + ) + ], }, ), ] diff --git a/backend/src/apps/github/models/activity_event.py b/backend/src/apps/github/models/activity_event.py index 19bcc14f03..586eacac1b 100644 --- a/backend/src/apps/github/models/activity_event.py +++ b/backend/src/apps/github/models/activity_event.py @@ -5,13 +5,14 @@ from django.db import models from apps.common.models import BulkSaveModel, TimestampedModel - - from apps.github.models.enums.activity_event import ActivityType class ActivityEvent(BulkSaveModel, TimestampedModel): - """Represents a discrete GitHub activity event linked to a single source object via a polymorphic GenericForeignKey.""" + """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.""" @@ -25,6 +26,7 @@ class Meta: "activity_type", "content_type", "object_id", + "occurred_at", ], name="unique_activity_event", ), diff --git a/backend/src/apps/github/models/enums/activity_event.py b/backend/src/apps/github/models/enums/activity_event.py index caffcd055a..ea998da463 100644 --- a/backend/src/apps/github/models/enums/activity_event.py +++ b/backend/src/apps/github/models/enums/activity_event.py @@ -7,12 +7,9 @@ class ActivityType(models.TextChoices): """Activity type choices.""" COMMIT_PUSHED = "commit_pushed", "Commit Pushed" - 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" From 61931da99153ffb3e355c2e9633bb361598e5a8c Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Wed, 29 Jul 2026 15:41:40 +0530 Subject: [PATCH 3/8] added actitivty builder --- backend/src/apps/github/admin/__init__.py | 1 - backend/src/apps/github/common.py | 5 + .../github/migrations/0045_activityevent.py | 96 ----------- backend/src/apps/github/models/__init__.py | 1 - .../src/apps/github/models/activity_event.py | 80 --------- .../src/apps/github/models/enums/__init__.py | 0 .../github/models/enums/activity_event.py | 15 -- backend/src/apps/owasp/admin/__init__.py | 1 + .../{github => owasp}/admin/activity_event.py | 17 +- .../owasp/migrations/0073_activityevent.py | 36 +++++ backend/src/apps/owasp/models/__init__.py | 1 + .../src/apps/owasp/models/activity_event.py | 153 ++++++++++++++++++ backend/tests/unit/apps/github/common_test.py | 1 + 13 files changed, 205 insertions(+), 202 deletions(-) delete mode 100644 backend/src/apps/github/migrations/0045_activityevent.py delete mode 100644 backend/src/apps/github/models/activity_event.py delete mode 100644 backend/src/apps/github/models/enums/__init__.py delete mode 100644 backend/src/apps/github/models/enums/activity_event.py rename backend/src/apps/{github => owasp}/admin/activity_event.py (60%) create mode 100644 backend/src/apps/owasp/migrations/0073_activityevent.py create mode 100644 backend/src/apps/owasp/models/activity_event.py diff --git a/backend/src/apps/github/admin/__init__.py b/backend/src/apps/github/admin/__init__.py index eb7170e91f..60ae27406f 100644 --- a/backend/src/apps/github/admin/__init__.py +++ b/backend/src/apps/github/admin/__init__.py @@ -1,6 +1,5 @@ """Github app admin.""" -from .activity_event import ActivityEventAdmin from .comment import CommentAdmin from .commit import CommitAdmin from .issue import IssueAdmin 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/github/migrations/0045_activityevent.py b/backend/src/apps/github/migrations/0045_activityevent.py deleted file mode 100644 index cba54f7b0c..0000000000 --- a/backend/src/apps/github/migrations/0045_activityevent.py +++ /dev/null @@ -1,96 +0,0 @@ -# Generated by Django 6.0.7 on 2026-07-18 17:15 - -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"), - ] - - 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=[ - ("commit_pushed", "Commit Pushed"), - ("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", - ), - ), - ( - "actor", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="activity_events", - to="github.user", - verbose_name="Actor", - ), - ), - ( - "content_type", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype" - ), - ), - ( - "repository", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="activity_events", - to="github.repository", - verbose_name="Repository", - ), - ), - ], - 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=["actor"], name="activity_event_actor_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=["repository"], name="activity_event_repository_idx"), - ], - "constraints": [ - models.UniqueConstraint( - fields=("activity_type", "content_type", "object_id", "occurred_at"), - name="unique_activity_event", - ) - ], - }, - ), - ] diff --git a/backend/src/apps/github/models/__init__.py b/backend/src/apps/github/models/__init__.py index d25fed562a..5b4113b60b 100644 --- a/backend/src/apps/github/models/__init__.py +++ b/backend/src/apps/github/models/__init__.py @@ -1,6 +1,5 @@ """Github app.""" -from .activity_event import ActivityEvent from .comment import Comment from .commit import Commit from .issue import Issue diff --git a/backend/src/apps/github/models/activity_event.py b/backend/src/apps/github/models/activity_event.py deleted file mode 100644 index 586eacac1b..0000000000 --- a/backend/src/apps/github/models/activity_event.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Github app activity event model.""" - -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 -from apps.github.models.enums.activity_event import ActivityType - - -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=["actor"], name="activity_event_actor_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=["repository"], name="activity_event_repository_idx"), - ] - - activity_type = models.CharField( - verbose_name="Activity Type", - max_length=32, - choices=ActivityType.choices, - ) - actor = models.ForeignKey( - "github.User", - verbose_name="Actor", - 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", - ) - repository = models.ForeignKey( - "github.Repository", - verbose_name="Repository", - on_delete=models.CASCADE, - related_name="activity_events", - ) - source_object = GenericForeignKey("content_type", "object_id") - - def __str__(self) -> str: - """Return human-readable representation.""" - return f"{self.activity_type} by {self.actor} in {self.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) diff --git a/backend/src/apps/github/models/enums/__init__.py b/backend/src/apps/github/models/enums/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/src/apps/github/models/enums/activity_event.py b/backend/src/apps/github/models/enums/activity_event.py deleted file mode 100644 index ea998da463..0000000000 --- a/backend/src/apps/github/models/enums/activity_event.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Enums for GitHub activity events.""" - -from django.db import models - - -class ActivityType(models.TextChoices): - """Activity type choices.""" - - COMMIT_PUSHED = "commit_pushed", "Commit Pushed" - 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" 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/github/admin/activity_event.py b/backend/src/apps/owasp/admin/activity_event.py similarity index 60% rename from backend/src/apps/github/admin/activity_event.py rename to backend/src/apps/owasp/admin/activity_event.py index 715aba9cd5..72da546259 100644 --- a/backend/src/apps/github/admin/activity_event.py +++ b/backend/src/apps/owasp/admin/activity_event.py @@ -1,23 +1,22 @@ -"""GitHub app ActivityEvent model admin.""" +"""OWASP app ActivityEvent model admin.""" from django.contrib import admin -from apps.github.models.activity_event import ActivityEvent +from apps.owasp.models.activity_event import ActivityEvent class ActivityEventAdmin(admin.ModelAdmin): """Admin for ActivityEvent model.""" autocomplete_fields = ( - "actor", - "repository", + "github_user", + "github_repository", ) list_display = ( "activity_type", - "actor", - "nest_created_at", + "github_user", "occurred_at", - "repository", + "github_repository", ) list_filter = ( "activity_type", @@ -25,8 +24,8 @@ class ActivityEventAdmin(admin.ModelAdmin): ) search_fields = ( "activity_type", - "actor__login", - "repository__name", + "github_user__login", + "github_repository__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..f5cf1fbc03 --- /dev/null +++ b/backend/src/apps/owasp/migrations/0073_activityevent.py @@ -0,0 +1,36 @@ +# 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..79b2194ffd --- /dev/null +++ b/backend/src/apps/owasp/models/activity_event.py @@ -0,0 +1,153 @@ +"""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__, + ) + raise TypeError(f"Unsupported model type: {type(obj)}") + + 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 ), From ae10e4fb532817b9f5047e6e81f9e8afab8405e4 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Thu, 30 Jul 2026 00:37:26 +0530 Subject: [PATCH 4/8] Address review --- .../owasp/migrations/0073_activityevent.py | 98 +++++++++++++++---- .../src/apps/owasp/models/activity_event.py | 3 +- docker-compose/local/compose.override.yaml | 13 +++ docker-compose/local/compose.yaml | 10 +- 4 files changed, 100 insertions(+), 24 deletions(-) diff --git a/backend/src/apps/owasp/migrations/0073_activityevent.py b/backend/src/apps/owasp/migrations/0073_activityevent.py index f5cf1fbc03..ba75297650 100644 --- a/backend/src/apps/owasp/migrations/0073_activityevent.py +++ b/backend/src/apps/owasp/migrations/0073_activityevent.py @@ -5,32 +5,94 @@ class Migration(migrations.Migration): - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('github', '0044_user_indexes'), - ('owasp', '0072_project_project_name_gin_idx_and_more'), + ("contenttypes", "0002_remove_content_type_name"), + ("github", "0044_user_indexes"), + ("owasp", "0072_project_project_name_gin_idx_and_more"), ] operations = [ migrations.CreateModel( - name='ActivityEvent', + 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')), + ( + "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')], + "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/activity_event.py b/backend/src/apps/owasp/models/activity_event.py index 79b2194ffd..3db60269c3 100644 --- a/backend/src/apps/owasp/models/activity_event.py +++ b/backend/src/apps/owasp/models/activity_event.py @@ -131,7 +131,8 @@ def update_data(obj) -> None: "ActivityEvent.update_data received unsupported model type: %s", type(obj).__name__, ) - raise TypeError(f"Unsupported model type: {type(obj)}") + message = f"Unsupported model type: {type(obj)}" + raise TypeError(message) handler = getattr(ActivityEvent, handler_name) events = handler(obj) 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 diff --git a/docker-compose/local/compose.yaml b/docker-compose/local/compose.yaml index e664a4b493..a76bb34021 100644 --- a/docker-compose/local/compose.yaml +++ b/docker-compose/local/compose.yaml @@ -23,7 +23,7 @@ services: - 8000:8000 volumes: - ../../backend:/home/owasp - - backend-venv-pulse:/home/owasp/.venv + - backend-venv:/home/owasp/.venv cache: command: > @@ -41,7 +41,7 @@ services: networks: - nest-network volumes: - - cache-data-pulse:/data + - cache-data:/data db: container_name: nest-db @@ -97,8 +97,8 @@ services: - 3000:3000 volumes: - ../../frontend:/home/owasp - - frontend-next-pulse:/home/owasp/.next - - frontend-node-modules-pulse:/home/owasp/node_modules + - frontend-next:/home/owasp/.next + - frontend-node-modules:/home/owasp/node_modules worker: container_name: nest-worker @@ -119,7 +119,7 @@ services: - nest-network volumes: - ../../backend:/home/owasp - - backend-venv-pulse:/home/owasp/.venv + - backend-venv:/home/owasp/.venv networks: nest-network: From cc0f1dea1a6a8be17f9203cc1fafe4f95505573d Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Thu, 30 Jul 2026 01:07:13 +0530 Subject: [PATCH 5/8] updated order --- backend/src/apps/owasp/admin/activity_event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/apps/owasp/admin/activity_event.py b/backend/src/apps/owasp/admin/activity_event.py index 72da546259..6a16479878 100644 --- a/backend/src/apps/owasp/admin/activity_event.py +++ b/backend/src/apps/owasp/admin/activity_event.py @@ -14,9 +14,9 @@ class ActivityEventAdmin(admin.ModelAdmin): ) list_display = ( "activity_type", + "github_repository", "github_user", "occurred_at", - "github_repository", ) list_filter = ( "activity_type", @@ -24,8 +24,8 @@ class ActivityEventAdmin(admin.ModelAdmin): ) search_fields = ( "activity_type", - "github_user__login", "github_repository__name", + "github_user__login", ) From 92c3dfac6984370697e4b419f1a5fd51215d204a Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Fri, 7 Aug 2026 21:49:36 +0530 Subject: [PATCH 6/8] Implmented Graphql Queries for Pulse Signed-off-by: Anurag Yadav --- .../api/internal/nodes/activity_event.py | 53 ++++++++++ .../owasp/api/internal/queries/__init__.py | 2 + .../api/internal/queries/activity_event.py | 98 +++++++++++++++++++ .../src/apps/owasp/models/activity_event.py | 50 ++++++++++ 4 files changed, 203 insertions(+) create mode 100644 backend/src/apps/owasp/api/internal/nodes/activity_event.py create mode 100644 backend/src/apps/owasp/api/internal/queries/activity_event.py 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..90bd66406d --- /dev/null +++ b/backend/src/apps/owasp/api/internal/queries/activity_event.py @@ -0,0 +1,98 @@ +"""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_clause = "occurred_at" if order == "asc" else "-occurred_at" + + queryset = ActivityEvent.objects.select_related( + "github_user", + "github_repository", + ).order_by(order_clause) + + 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", + ).order_by("-occurred_at") + + return list(ActivityEvent.exclude_bots(queryset)[:normalized_limit]) diff --git a/backend/src/apps/owasp/models/activity_event.py b/backend/src/apps/owasp/models/activity_event.py index 3db60269c3..58f0c9d05b 100644 --- a/backend/src/apps/owasp/models/activity_event.py +++ b/backend/src/apps/owasp/models/activity_event.py @@ -1,12 +1,16 @@ """OWASP app activity event model.""" +from datetime import timedelta import logging 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__) @@ -89,10 +93,56 @@ class ActivityType(models.TextChoices): "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.""" From 6e710e8bae12471ee97a90877d5842dcbc729439 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sat, 8 Aug 2026 00:23:36 +0530 Subject: [PATCH 7/8] address review Signed-off-by: Anurag Yadav --- .../api/internal/queries/activity_event.py | 21 ++++++++++++------- .../src/apps/owasp/models/activity_event.py | 5 ++--- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/backend/src/apps/owasp/api/internal/queries/activity_event.py b/backend/src/apps/owasp/api/internal/queries/activity_event.py index 90bd66406d..744ff05d67 100644 --- a/backend/src/apps/owasp/api/internal/queries/activity_event.py +++ b/backend/src/apps/owasp/api/internal/queries/activity_event.py @@ -20,6 +20,7 @@ class ActivityEventQuery: @strawberry_django.field def activity_events( self, + *, activity_type: str | None = None, github_user_login: str | None = None, project_key: str | None = None, @@ -40,12 +41,12 @@ def activity_events( if order not in {"asc", "desc"}: return PaginatedActivityEvents(current_page=1, events=[], total_pages=1) - order_clause = "occurred_at" if order == "asc" else "-occurred_at" + order_clauses = ("occurred_at", "pk") if order == "asc" else ("-occurred_at", "-pk") queryset = ActivityEvent.objects.select_related( "github_user", "github_repository", - ).order_by(order_clause) + ).order_by(*order_clauses) if not include_bots: queryset = ActivityEvent.exclude_bots(queryset) @@ -63,11 +64,15 @@ def activity_events( 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) + 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()): @@ -93,6 +98,6 @@ def recent_activity_events(self, limit: int = 10) -> list[ActivityEventNode]: queryset = ActivityEvent.objects.select_related( "github_user", "github_repository", - ).order_by("-occurred_at") + ).order_by("-occurred_at", "-pk") return list(ActivityEvent.exclude_bots(queryset)[:normalized_limit]) diff --git a/backend/src/apps/owasp/models/activity_event.py b/backend/src/apps/owasp/models/activity_event.py index 58f0c9d05b..a26a531663 100644 --- a/backend/src/apps/owasp/models/activity_event.py +++ b/backend/src/apps/owasp/models/activity_event.py @@ -1,7 +1,7 @@ """OWASP app activity event model.""" -from datetime import timedelta import logging +from datetime import timedelta from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType @@ -128,8 +128,7 @@ def source_url(self) -> str: 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()) + Q(github_user__is_bot=True) | Q(github_user__login__in=User.get_non_indexable_logins()) ) @classmethod From 2b118a47e2d51a838e554aa45c7c9294761695fb Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sat, 8 Aug 2026 23:34:19 +0530 Subject: [PATCH 8/8] Address coderabbitai review Signed-off-by: Anurag Yadav --- .../api/internal/queries/activity_event.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/backend/src/apps/owasp/api/internal/queries/activity_event.py b/backend/src/apps/owasp/api/internal/queries/activity_event.py index 744ff05d67..4d01d2e077 100644 --- a/backend/src/apps/owasp/api/internal/queries/activity_event.py +++ b/backend/src/apps/owasp/api/internal/queries/activity_event.py @@ -43,10 +43,16 @@ def activity_events( order_clauses = ("occurred_at", "pk") if order == "asc" else ("-occurred_at", "-pk") - queryset = ActivityEvent.objects.select_related( - "github_user", - "github_repository", - ).order_by(*order_clauses) + 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) @@ -95,9 +101,15 @@ def recent_activity_events(self, limit: int = 10) -> list[ActivityEventNode]: if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: return [] - queryset = ActivityEvent.objects.select_related( - "github_user", - "github_repository", - ).order_by("-occurred_at", "-pk") + 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])