Skip to content

Snapshot Summary #1336

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

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions backend/apps/owasp/graphql/nodes/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class SnapshotNode(GenericEntityNode):
new_projects = graphene.List(ProjectNode)
new_releases = graphene.List(ReleaseNode)
new_users = graphene.List(UserNode)
summary = graphene.String()

class Meta:
model = Snapshot
Expand Down Expand Up @@ -55,3 +56,7 @@ def resolve_new_releases(self, info):
def resolve_new_users(self, info):
"""Resolve recent new users."""
return self.new_users.order_by("-created_at")

def resolve_summary(self, info):
"""Resolve summary of the snapshot."""
return self.generate_summary()
31 changes: 31 additions & 0 deletions backend/apps/owasp/models/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,34 @@ def save(self, *args, **kwargs):
self.key = now().strftime("%Y-%m")

super().save(*args, **kwargs)

def generate_summary(self, max_examples=2):
"""Generate a snapshot summary with counts and examples."""
summary_parts = []

def summarize(queryset, label, example_attr):
count = queryset.count()
if count == 0:
return None
examples = list(queryset.values_list(example_attr, flat=True)[:max_examples])
example_str = ", ".join(str(e) for e in examples)
return f"{count} {label}{'s' if count != 1 else ''} (e.g., {example_str})"

entities = [
(self.new_users, "user", "login"),
(self.new_projects, "project", "name"),
(self.new_chapters, "chapter", "name"),
(self.new_issues, "issue", "title"),
(self.new_releases, "release", "tag_name"),
]

for queryset, label, attr in entities:
part = summarize(queryset, label, attr)
if part:
summary_parts.append(part)

return (
"Snapshot Summary: " + "; ".join(summary_parts)
if summary_parts
else "No new entities were added."
)
Comment on lines +59 to +84
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Add unit tests for the new functionality.

The generate_summary method is a good candidate for unit testing to ensure it handles different data scenarios correctly.


🏁 Script executed:

#!/bin/bash
# Check if there are existing tests for the Snapshot model
echo "Checking for existing tests for Snapshot model..."
find backend -name "test_*.py" -o -name "*_test.py" | xargs grep -l "Snapshot" || echo "No existing tests found"

# Look for test patterns in the project
echo -e "\nExamining test patterns in the project..."
find backend -name "test_*.py" -o -name "*_test.py" | head -n 1 | xargs cat || echo "No test files found"

Length of output: 1868


Action Required: Add Dedicated Unit Tests for the generate_summary Method

While there are existing tests for the Snapshot model (as seen in backend/tests/apps/owasp/models/snapshot_test.py), it isn’t clear that the new generate_summary functionality is specifically covered. Please add unit tests that ensure the method behaves correctly under various data conditions. For example:

  • Test when the queryset returns zero records (should return "No new entities were added.").
  • Test when there is exactly one record (ensuring the singular label is used).
  • Test when multiple records are returned and the maximum examples limit (max_examples) is enforced.

4 changes: 4 additions & 0 deletions cspell/custom-dict.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
a2eeef
abhayymishraaaa
vithobasatish
Juiz
Agsoc
Aichi
Aissue
Expand All @@ -20,6 +23,7 @@ csrfguard
csrfprotector
csrftoken
cva
Cyclonedx
dismissable
DRF
dsn
Expand Down
2 changes: 2 additions & 0 deletions frontend/__tests__/unit/data/mockSnapshotData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const mockSnapshotDetailsData = {
createdAt: '2025-03-01T22:00:34.361937+00:00',
startAt: '2024-12-01T00:00:00+00:00',
endAt: '2024-12-31T22:00:30+00:00',
summary:
'Snapshot Summary: 10 users (e.g., abhayymishraaaa, vithobasatish); 3 projects (e.g., OWASP Top 10 for Business Logic Abuse, OWASP ProdSecMan); 14 chapters (e.g., OWASP Oshawa, OWASP Juiz de Fora); 422 issues (e.g., Duplicate Components, Cyclonedx seems to ignore some configuration options); 71 releases (e.g., 2.0.1, v5.0.1)',
status: 'completed',
errorMessage: '',
newReleases: [
Expand Down
37 changes: 37 additions & 0 deletions frontend/__tests__/unit/pages/SnapshotDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,43 @@ describe('SnapshotDetailsPage', () => {
expect(screen.getByText('New Releases')).toBeInTheDocument()
})

test('correctly parses and displays summary data', async () => {
;(useQuery as jest.Mock).mockReturnValue({
data: mockSnapshotDetailsData,
error: null,
})

render(<SnapshotDetailsPage />)

// Wait for the page to render
await waitFor(() => {
expect(screen.getByText('New Snapshot')).toBeInTheDocument()
})

// Check that summary section exists
expect(screen.getByText('Snapshot Summary')).toBeInTheDocument()

// Check for correctly parsed user count
expect(screen.getByText('10 Users')).toBeInTheDocument()
expect(screen.getByText(/abhayymishraaaa/)).toBeInTheDocument()

// Check for correctly parsed project count
expect(screen.getByText('3 Projects')).toBeInTheDocument()
expect(screen.getByText(/OWASP Top 10 for Business Logic Abuse/)).toBeInTheDocument()

// Check for correctly parsed chapter count
expect(screen.getByText('14 Chapters')).toBeInTheDocument()
expect(screen.getByText(/OWASP Oshawa/)).toBeInTheDocument()

// Check for correctly parsed issues count
expect(screen.getByText('422 Issues')).toBeInTheDocument()
expect(screen.getByText(/Duplicate Components/)).toBeInTheDocument()

// Check for correctly parsed releases count
expect(screen.getByText('71 Releases')).toBeInTheDocument()
expect(screen.getByText(/2\.0\.1/)).toBeInTheDocument()
})

test('renders error message when GraphQL request fails', async () => {
;(useQuery as jest.Mock).mockReturnValue({
data: null,
Expand Down
86 changes: 85 additions & 1 deletion frontend/src/app/snapshots/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,78 @@
'use client'

import { useQuery } from '@apollo/client'
import { faCalendar } from '@fortawesome/free-solid-svg-icons'
import {
faCalendar,
faUsers,
faFolder,
faBook,
faBug,
faTag,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useRouter, useParams } from 'next/navigation'
import React, { useState, useEffect } from 'react'

import { GET_SNAPSHOT_DETAILS } from 'server/queries/snapshotQueries'
import { ChapterTypeGraphQL } from 'types/chapter'
import { ProjectTypeGraphql } from 'types/project'
import { SnapshotDetailsProps } from 'types/snapshot'
import { level } from 'utils/data'
import { formatDate } from 'utils/dateFormatter'
import { getFilteredIconsGraphql, handleSocialUrls } from 'utils/utility'

import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper'
import Card from 'components/Card'
import ChapterMapWrapper from 'components/ChapterMapWrapper'
import LoadingSpinner from 'components/LoadingSpinner'
import { handleAppError, ErrorDisplay } from 'app/global-error'

type ParsedSummary = {
users: { count: number; examples: string[] }
projects: { count: number; examples: string[] }
chapters: { count: number; examples: string[] }
issues: { count: number; examples: string[] }
releases: { count: number; examples: string[] }
}

const parseSnapshotSummary = (summary: string): ParsedSummary => {
const result: ParsedSummary = {
users: { count: 0, examples: [] },
projects: { count: 0, examples: [] },
chapters: { count: 0, examples: [] },
issues: { count: 0, examples: [] },
releases: { count: 0, examples: [] },
}

if (!summary) return result

const sections = [
{ key: 'users', pattern: /(\d+) users \(e\.g\.,\s*([^)]+)\)/i },
{ key: 'projects', pattern: /(\d+) projects \(e\.g\.,\s*([^)]+)\)/i },
{ key: 'chapters', pattern: /(\d+) chapters \(e\.g\.,\s*([^)]+)\)/i },
{ key: 'issues', pattern: /(\d+) issues \(e\.g\.,\s*([^)]+)\)/i },
{ key: 'releases', pattern: /(\d+) releases \(e\.g\.,\s*([^)]+)\)/i },
]

sections.forEach((section) => {
const match = summary.match(section.pattern)
if (match && match.length >= 3) {
result[section.key as keyof ParsedSummary] = {
count: parseInt(match[1], 10),
examples: match[2].split(',').map((s) => s.trim()),
}
}
})

return result
}

const SnapshotDetailsPage: React.FC = () => {
const { id: snapshotKey } = useParams()
const [snapshot, setSnapshot] = useState<SnapshotDetailsProps | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(true)
const router = useRouter()
const [summaryData, setSummaryData] = useState<ParsedSummary | null>(null)

const { data: graphQLData, error: graphQLRequestError } = useQuery(GET_SNAPSHOT_DETAILS, {
variables: { key: snapshotKey },
Expand All @@ -30,6 +81,7 @@ const SnapshotDetailsPage: React.FC = () => {
useEffect(() => {
if (graphQLData) {
setSnapshot(graphQLData.snapshot)
setSummaryData(parseSnapshotSummary(graphQLData.snapshot.summary))
setIsLoading(false)
}
if (graphQLRequestError) {
Expand Down Expand Up @@ -129,6 +181,38 @@ const SnapshotDetailsPage: React.FC = () => {
</div>
</div>

{summaryData && (
<div className="mb-8 rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-4 text-2xl font-semibold text-gray-700 dark:text-gray-200">
Snapshot Summary
</h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[
{ label: 'Users', data: summaryData.users, icon: faUsers },
{ label: 'Projects', data: summaryData.projects, icon: faFolder },
{ label: 'Chapters', data: summaryData.chapters, icon: faBook },
{ label: 'Issues', data: summaryData.issues, icon: faBug },
{ label: 'Releases', data: summaryData.releases, icon: faTag },
].map(({ label, data, icon }) => (
<div
key={label}
className="flex items-start gap-4 rounded-lg border p-4 shadow-sm dark:border-gray-700"
>
<FontAwesomeIcon icon={icon} className="h-6 w-6 text-blue-500" />
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
{data.count} {label}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
e.g., {data.examples.join(', ')}
</p>
</div>
</div>
))}
</div>
</div>
)}

{snapshot?.newChapters && snapshot?.newChapters.length > 0 && (
<div className="mb-8">
<h2 className="mb-6 text-2xl font-semibold text-gray-700 dark:text-gray-200">
Expand Down
1 change: 1 addition & 0 deletions frontend/src/server/queries/snapshotQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const GET_SNAPSHOT_DETAILS = gql`
key
startAt
title
summary
newReleases {
name
publishedAt
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface SnapshotDetailsProps {
newReleases: ReleaseType[]
newProjects: ProjectTypeGraphql[]
newChapters: ChapterTypeGraphQL[]
summary: string
}

export interface Snapshots {
Expand Down