diff --git a/backend/apps/owasp/admin.py b/backend/apps/owasp/admin.py index 51db63fcd9..e67a61d576 100644 --- a/backend/apps/owasp/admin.py +++ b/backend/apps/owasp/admin.py @@ -121,6 +121,7 @@ class SnapshotAdmin(admin.ModelAdmin): "new_users", ) list_display = ( + "title", "start_at", "end_at", "status", @@ -134,6 +135,8 @@ class SnapshotAdmin(admin.ModelAdmin): ) ordering = ("-start_at",) search_fields = ( + "title", + "key", "status", "error_message", ) diff --git a/backend/apps/owasp/graphql/nodes/snapshot.py b/backend/apps/owasp/graphql/nodes/snapshot.py new file mode 100644 index 0000000000..cfc42c3e19 --- /dev/null +++ b/backend/apps/owasp/graphql/nodes/snapshot.py @@ -0,0 +1,71 @@ +"""OWASP snapshot GraphQL node.""" + +import graphene + +from apps.github.graphql.nodes.issue import IssueNode +from apps.github.graphql.nodes.release import ReleaseNode +from apps.github.graphql.nodes.user import UserNode +from apps.owasp.graphql.nodes.chapter import ChapterNode +from apps.owasp.graphql.nodes.common import GenericEntityNode +from apps.owasp.graphql.nodes.project import ProjectNode +from apps.owasp.models.snapshot import Snapshot + +RECENT_ISSUES_LIMIT = 10 +RECENT_RELEASES_LIMIT = 10 +RECENT_PROJECTS_LIMIT = 10 +RECENT_USERS_LIMIT = 10 + + +class SnapshotNode(GenericEntityNode): + """Snapshot node.""" + + key = graphene.String() + status = graphene.String() + error_message = graphene.String() + new_chapters = graphene.List(ChapterNode) + new_issues = graphene.List(IssueNode) + new_projects = graphene.List(ProjectNode) + new_releases = graphene.List(ReleaseNode) + new_users = graphene.List(UserNode) + + class Meta: + model = Snapshot + fields = ( + "title", + "created_at", + "updated_at", + "start_at", + "end_at", + ) + + def resolve_key(self, info): + """Resolve key.""" + return self.key + + def resolve_status(self, info): + """Resolve status.""" + return self.status + + def resolve_error_message(self, info): + """Resolve error message.""" + return self.error_message + + def resolve_new_chapters(self, info): + """Resolve new chapters.""" + return self.new_chapters.all() + + def resolve_new_issues(self, info): + """Resolve recent new issues.""" + return self.new_issues.order_by("-created_at")[:RECENT_ISSUES_LIMIT] + + def resolve_new_projects(self, info): + """Resolve recent new projects.""" + return self.new_projects.order_by("-created_at")[:RECENT_PROJECTS_LIMIT] + + def resolve_new_releases(self, info): + """Resolve recent new releases.""" + return self.new_releases.order_by("-published_at")[:RECENT_RELEASES_LIMIT] + + def resolve_new_users(self, info): + """Resolve recent new users.""" + return self.new_users.order_by("-created_at")[:RECENT_USERS_LIMIT] diff --git a/backend/apps/owasp/graphql/queries/__init__.py b/backend/apps/owasp/graphql/queries/__init__.py index 8515e957da..ecf847215f 100644 --- a/backend/apps/owasp/graphql/queries/__init__.py +++ b/backend/apps/owasp/graphql/queries/__init__.py @@ -3,8 +3,9 @@ from .chapter import ChapterQuery from .committee import CommitteeQuery from .project import ProjectQuery +from .snapshot import SnapshotQuery from .stats import StatsQuery -class OwaspQuery(ChapterQuery, CommitteeQuery, ProjectQuery, StatsQuery): +class OwaspQuery(ChapterQuery, CommitteeQuery, ProjectQuery, SnapshotQuery, StatsQuery): """OWASP queries.""" diff --git a/backend/apps/owasp/graphql/queries/snapshot.py b/backend/apps/owasp/graphql/queries/snapshot.py new file mode 100644 index 0000000000..21560eecd2 --- /dev/null +++ b/backend/apps/owasp/graphql/queries/snapshot.py @@ -0,0 +1,32 @@ +"""OWASP snapshot GraphQL queries.""" + +import graphene + +from apps.common.graphql.queries import BaseQuery +from apps.owasp.graphql.nodes.snapshot import SnapshotNode +from apps.owasp.models.snapshot import Snapshot + + +class SnapshotQuery(BaseQuery): + """Snapshot queries.""" + + snapshot = graphene.Field( + SnapshotNode, + key=graphene.String(required=True), + ) + + recent_snapshots = graphene.List( + SnapshotNode, + limit=graphene.Int(default_value=8), + ) + + def resolve_snapshot(root, info, key): + """Resolve snapshot by key.""" + try: + return Snapshot.objects.get(key=key) + except Snapshot.DoesNotExist: + return None + + def resolve_recent_snapshots(root, info, limit): + """Resolve recent snapshots.""" + return Snapshot.objects.order_by("-created_at")[:limit] diff --git a/backend/apps/owasp/migrations/0015_snapshot.py b/backend/apps/owasp/migrations/0015_snapshot.py index d26b5b3db4..07b0245688 100644 --- a/backend/apps/owasp/migrations/0015_snapshot.py +++ b/backend/apps/owasp/migrations/0015_snapshot.py @@ -1,11 +1,11 @@ -# Generated by Django 5.1.6 on 2025-02-22 18:37 +# Generated by Django 5.1.6 on 2025-03-02 05:18 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("github", "0015_alter_release_author"), + ("github", "0016_user_is_bot"), ("owasp", "0014_project_custom_tags"), ] @@ -19,6 +19,10 @@ class Migration(migrations.Migration): auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), + ("title", models.CharField(default="", max_length=255)), + ("key", models.CharField(max_length=7, unique=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ("start_at", models.DateTimeField()), ("end_at", models.DateTimeField()), ( @@ -34,8 +38,6 @@ class Migration(migrations.Migration): max_length=10, ), ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), ("error_message", models.TextField(blank=True, default="")), ( "new_chapters", diff --git a/backend/apps/owasp/models/snapshot.py b/backend/apps/owasp/models/snapshot.py index c509dbacfb..96051ea581 100644 --- a/backend/apps/owasp/models/snapshot.py +++ b/backend/apps/owasp/models/snapshot.py @@ -1,6 +1,7 @@ """OWASP app snapshot models.""" from django.db import models +from django.utils.timezone import now class Snapshot(models.Model): @@ -16,6 +17,9 @@ class Status(models.TextChoices): COMPLETED = "completed", "Completed" ERROR = "error", "Error" + title = models.CharField(max_length=255, default="") + key = models.CharField(max_length=7, unique=True) # Format: YYYY-mm + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -31,6 +35,12 @@ class Status(models.TextChoices): new_releases = models.ManyToManyField("github.Release", related_name="snapshots", blank=True) new_users = models.ManyToManyField("github.User", related_name="snapshots", blank=True) + def save(self, *args, **kwargs): + """Automatically set the key in YYYY-mm format before saving.""" + if not self.key: + self.key = now().strftime("%Y-%m") + super().save(*args, **kwargs) + def __str__(self): """Return a string representation of the snapshot.""" return f"Snapshot {self.start_at} to {self.end_at} ({self.status})" diff --git a/backend/tests/owasp/models/snapshot_test.py b/backend/tests/owasp/models/snapshot_test.py index 6dcf6b8baa..5e89f480b9 100644 --- a/backend/tests/owasp/models/snapshot_test.py +++ b/backend/tests/owasp/models/snapshot_test.py @@ -12,6 +12,8 @@ def setUp(self): """Set up a mocked snapshot object.""" self.snapshot = MagicMock(spec=Snapshot) # Mock entire model self.snapshot.id = 1 # Set an ID to avoid ManyToMany errors + self.snapshot.title = "Mock Snapshot Title" + self.snapshot.key = "2025-02" self.snapshot.start_at = "2025-02-21" self.snapshot.end_at = "2025-02-21" self.snapshot.status = Snapshot.Status.PROCESSING @@ -27,3 +29,8 @@ def test_mocked_many_to_many_relations(self): """Test ManyToMany relationships using mocks.""" self.snapshot.new_chapters.set(["Mock Chapter"]) self.snapshot.new_chapters.set.assert_called_once_with(["Mock Chapter"]) + + def test_snapshot_attributes(self): + """Test that title and key are correctly assigned.""" + assert self.snapshot.title == "Mock Snapshot Title" + assert self.snapshot.key == "2025-02" diff --git a/frontend/src/api/queries/snapshotQueries.ts b/frontend/src/api/queries/snapshotQueries.ts new file mode 100644 index 0000000000..86d5619048 --- /dev/null +++ b/frontend/src/api/queries/snapshotQueries.ts @@ -0,0 +1,40 @@ +import { gql } from '@apollo/client' + +export const GET_SNAPSHOT_DETAILS = gql` + query GetSnapshotDetails($key: String!) { + snapshot(key: $key) { + title + key + createdAt + updatedAt + startAt + endAt + newReleases { + name + version + releaseDate + } + newProjects { + key + name + summary + starsCount + forksCount + repositoriesCount + topContributors { + name + login + contributionsCount + } + } + newChapters { + key + name + geoLocation { + lat + lng + } + } + } + } +`