-
-
Notifications
You must be signed in to change notification settings - Fork 662
Add ActivityEvent model and builder #5233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/owasp-pulse
Are you sure you want to change the base?
Changes from all commits
ecd8bb6
6c15d09
3c115ce
154b03c
0ef2ff5
ee30f90
3758d68
5244db5
5264756
80e1c5f
19a7374
023e9b4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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): | ||
| """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) | ||
| 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", | ||
| ) | ||
| ], | ||
| }, | ||
| ), | ||
| ] | ||
|
coderabbitai[bot] marked this conversation as resolved.
anurag2787 marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| """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 | ||
| from apps.github.models.generic_issue_model import GenericIssueModel | ||
|
|
||
| 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 == GenericIssueModel.IssueState.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 == GenericIssueModel.IssueState.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.""" | ||
| if release.published_at is None: | ||
|
anurag2787 marked this conversation as resolved.
|
||
| return [] | ||
|
|
||
| return [ | ||
| (ActivityEvent.ActivityType.RELEASE_PUBLISHED, release.published_at, release.author) | ||
| ] | ||
|
|
||
| @staticmethod | ||
| def update_data(source) -> list["ActivityEvent"]: | ||
| """Return unsaved ActivityEvent instances for a GitHub model object.""" | ||
| handler_name = ActivityEvent.HANDLERS.get(type(source).__name__) | ||
| if handler_name is None: | ||
| logger.error( | ||
| "ActivityEvent.update_data received unsupported model type: %s", | ||
| type(source).__name__, | ||
| ) | ||
| message = f"Unsupported model type: {type(source)}" | ||
| raise TypeError(message) | ||
|
|
||
| handler = getattr(ActivityEvent, handler_name) | ||
| events = handler(source) | ||
| content_type = ContentType.objects.get_for_model(source) | ||
|
|
||
| return [ | ||
| ActivityEvent( | ||
| activity_type=activity_type, | ||
| content_type=content_type, | ||
| object_id=source.pk, | ||
| occurred_at=occurred_at, | ||
| github_user=github_user, | ||
| github_repository=source.repository, | ||
| ) | ||
| for activity_type, occurred_at, github_user in events | ||
| if occurred_at is not None | ||
| ] | ||
|
|
||
| @staticmethod | ||
| def bulk_save_for_sources(sources: list) -> None: | ||
| """Bulk-insert ActivityEvent rows for source objects, skipping duplicates.""" | ||
| events = [event for source in sources for event in ActivityEvent.update_data(source)] | ||
| if events: | ||
| ActivityEvent.objects.bulk_create(events, ignore_conflicts=True) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do you need to ignore conflicts?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. since the sync can run multiple times so some events may already exist in that case we just want to skip them instead of getting an IntegrityError so because of that i added this
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also why not using
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven't use ActivityEvent.bulk_save because it doesn't ignore conflict since ActivityEvents are immutable so we only need to insert new ones and skip the ones that already exist
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why would you have a situation where you need to ignore conflicts, e.g. insert (re-insert) ones that already exist? We wan only new events as a source of data.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The reason is that the issue and PR sync is based on updated_at and not on whether an |
||
Uh oh!
There was an error while loading. Please reload this page.