Skip to content
5 changes: 5 additions & 0 deletions backend/src/apps/github/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -138,6 +139,7 @@ def sync_repository(
milestone=milestone,
repository=repository,
)
ActivityEvent.update_data(issue)

# Assignees.
issue.assignees.clear()
Expand Down Expand Up @@ -186,6 +188,7 @@ def sync_repository(
milestone=milestone,
repository=repository,
)
ActivityEvent.update_data(pull_request)
Comment thread
anurag2787 marked this conversation as resolved.
Outdated

# Assignees.
pull_request.assignees.clear()
Expand Down Expand Up @@ -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:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
ActivityEvent.update_data(release)

# GitHub repository contributors.
RepositoryContributor.bulk_save(
Expand Down
1 change: 1 addition & 0 deletions backend/src/apps/owasp/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions backend/src/apps/owasp/admin/activity_event.py
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"""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)
98 changes: 98 additions & 0 deletions backend/src/apps/owasp/migrations/0073_activityevent.py
Original file line number Diff line number Diff line change
@@ -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",
)
],
},
),
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
anurag2787 marked this conversation as resolved.
1 change: 1 addition & 0 deletions backend/src/apps/owasp/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .activity_event import ActivityEvent
from .board_of_directors import BoardOfDirectors
from .chapter import Chapter
from .committee import Committee
Expand Down
154 changes: 154 additions & 0 deletions backend/src/apps/owasp/models/activity_event.py
Original file line number Diff line number Diff line change
@@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't follow the approach consistently -- see IssueState

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)]
Comment thread
anurag2787 marked this conversation as resolved.
Outdated
Comment thread
anurag2787 marked this conversation as resolved.
Outdated

@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,
},
)
1 change: 1 addition & 0 deletions backend/tests/unit/apps/github/common_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
Expand Down
13 changes: 13 additions & 0 deletions docker-compose/local/compose.override.yaml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
anurag2787 marked this conversation as resolved.
Loading