Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/apps/common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ def natural_number(value: int, unit=None) -> str:
return f"{number} {unit}{pluralize(value)}" if unit else number


def round_down(value: int, base: int) -> int:
"""Round down the stats to the nearest base.

Args:
value: The value to round down.
base: The base to round down to.

Returns:
int: The rounded down value.

"""
return value - (value % base)


def slugify(text: str) -> str:
"""Generate a slug from the given text.

Expand Down
3 changes: 2 additions & 1 deletion backend/apps/owasp/graphql/nodes/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
class StatsNode(graphene.ObjectType):
"""Stats node."""

active_projects_stats = graphene.Int()
active_chapters_stats = graphene.Int()
active_projects_stats = graphene.Int()
contributors_stats = graphene.Int()
countries_stats = graphene.Int()
slack_workspace_stats = graphene.Int()
17 changes: 13 additions & 4 deletions backend/apps/owasp/graphql/queries/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import graphene

from apps.common.utils import round_down
from apps.github.models.user import User
from apps.owasp.graphql.nodes.stats import StatsNode
from apps.owasp.models.chapter import Chapter
from apps.owasp.models.project import Project
from apps.slack.models.workspace import Workspace


class StatsQuery:
Expand Down Expand Up @@ -35,9 +37,16 @@ def resolve_stats_overview(self, info) -> StatsNode:
.count()
)

slack_workspace_stats = (
workspace.total_members_count
if (workspace := Workspace.get_default_workspace())
else 0
)

return StatsNode(
(active_projects_stats // 10) * 10, # nearest 10
(active_chapters_stats // 10) * 10, # nearest 10
(contributors_stats // 100) * 100, # nearest 100
(countries_stats // 10) * 10, # nearest 10
active_chapters_stats=round_down(active_chapters_stats, 10),
active_projects_stats=round_down(active_projects_stats, 10),
contributors_stats=round_down(contributors_stats, 100),
countries_stats=round_down(countries_stats, 10),
slack_workspace_stats=round_down(slack_workspace_stats, 100),
)
8 changes: 7 additions & 1 deletion backend/apps/slack/management/commands/slack_sync_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,15 @@ def handle(self, *args, **options):
self.stdout.write(
self.style.ERROR(f"Failed to fetch members: {e.response['error']}")
)

if members:
Member.bulk_save(members)
self.stdout.write(self.style.SUCCESS(f"Populated {total_members} members"))

# Update the workspace with the total members count.
workspace.total_members_count = total_members
workspace.save(update_fields=["total_members_count"])

self.stdout.write(self.style.SUCCESS(f"Populated {total_members} members"))

self.stdout.write(self.style.SUCCESS("\nFinished processing all workspaces"))

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.2 on 2025-05-21 18:02

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("slack", "0009_rename_channel_conversation_and_more"),
]

operations = [
migrations.AddField(
model_name="workspace",
name="total_members_count",
field=models.PositiveIntegerField(default=0),
),
]
11 changes: 11 additions & 0 deletions backend/apps/slack/models/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,22 @@ class Meta:

name = models.CharField(verbose_name="Workspace Name", max_length=100, default="")
slack_workspace_id = models.CharField(verbose_name="Workspace ID", max_length=50, unique=True)
total_members_count = models.PositiveIntegerField(default=0)

def __str__(self):
"""Workspace human readable representation."""
return f"{self.name or self.slack_workspace_id}"

@staticmethod
def get_default_workspace() -> "Workspace":
"""Get the default workspace.

Returns:
Workspace: The default workspace.

"""
return Workspace.objects.first()

@property
def bot_token(self) -> str:
"""Get bot token for the workspace.
Expand Down
20 changes: 20 additions & 0 deletions backend/tests/apps/common/utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
join_values,
natural_date,
natural_number,
round_down,
)


Expand Down Expand Up @@ -79,3 +80,22 @@ def test_get_user_ip_address_local(self, mocker):
mocker.patch.dict(settings._wrapped.__dict__, {"PUBLIC_IP_ADDRESS": "1.1.1.1"})

assert get_user_ip_address(request) == "1.1.1.1"

@pytest.mark.parametrize(
("value", "base", "expected"),
[
(100, 10, 100),
(101, 10, 100),
(123, 10, 120),
(123, 100, 100),
(1230, 10, 1230),
(1230, 100, 1200),
(1230, 1000, 1000),
(126, 5, 125),
(43, 10, 40),
(430, 100, 400),
(99, 10, 90),
],
)
def test_round_down(self, value, base, expected):
assert round_down(value, base) == expected
2 changes: 1 addition & 1 deletion frontend/__tests__/e2e/data/mockHomeData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ export const mockHomeData = {
activeProjectsStats: 250,
contributorsStats: 11400,
countriesStats: 90,
__typename: 'StatsNode',
slackWorkspaceStats: 35800,
},
upcomingEvents: [
{
Expand Down
13 changes: 13 additions & 0 deletions frontend/__tests__/e2e/pages/Home.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ test.describe('Home Page', () => {
await page.getByRole('button', { name: 'Event 1' }).click()
})

test('should have stats', async ({ page }) => {
const headers = [
'Active Projects',
'Local Chapters',
'Contributors',
'Countries',
'Slack Community',
]
for (const header of headers) {
await expect(page.getByText(header, { exact: true })).toBeVisible()
}
})

test('Bluesky icon should be present and link correctly', async ({ page }) => {
const blueskyIcon = page.locator('footer a[aria-label="OWASP Nest Bluesky"]')
await expect(blueskyIcon).toBeVisible()
Expand Down
9 changes: 5 additions & 4 deletions frontend/__tests__/unit/data/mockHomeData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,11 @@ export const mockGraphQLData = {
},
],
statsOverview: {
activeChaptersStats: 540,
activeProjectsStats: 95,
countriesStats: 245,
contributorsStats: 9673,
activeChaptersStats: 250,
activeProjectsStats: 90,
countriesStats: 240,
contributorsStats: 9600,
slackWorkspaceStats: 31500,
},
upcomingEvents: [
{
Expand Down
24 changes: 24 additions & 0 deletions frontend/__tests__/unit/pages/Home.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useQuery } from '@apollo/client'
import { addToast } from '@heroui/toast'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { mockAlgoliaData, mockGraphQLData } from '@unit/data/mockHomeData'
import millify from 'millify'
import { useRouter } from 'next/navigation'
import { render } from 'wrappers/testUtil'
import Home from 'app/page'
Expand Down Expand Up @@ -257,6 +258,29 @@ describe('Home', () => {
})
})

test('renders stats correctly', async () => {
render(<Home />)

const headers = [
'Active Projects',
'Local Chapters',
'Contributors',
'Countries',
'Slack Community',
]
const stats = mockGraphQLData.statsOverview

await waitFor(() => {
headers.forEach((header) => expect(screen.getByText(header)).toBeInTheDocument())
// Wait for 2 seconds
setTimeout(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This doesn't look right but I'm not sure how to make that better 🤷‍♂️

Object.values(stats).forEach((value) =>
expect(screen.getByText(`${millify(value)}+`)).toBeInTheDocument()
)
}, 2000)
})
})

test('renders event details including date range and location', async () => {
render(<Home />)

Expand Down
16 changes: 10 additions & 6 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,23 @@ export default function Home() {
const counterData = [
{
label: 'Active Projects',
value: data.statsOverview.activeProjectsStats.toString().concat('+'),
value: data.statsOverview.activeProjectsStats,
},
{
label: 'Contributors',
value: data.statsOverview.contributorsStats.toString().concat('+'),
value: data.statsOverview.contributorsStats,
},
{
label: 'Local Chapters',
value: data.statsOverview.activeChaptersStats.toString().concat('+'),
value: data.statsOverview.activeChaptersStats,
},
{
label: 'Countries',
value: data.statsOverview.countriesStats.toString().concat('+'),
value: data.statsOverview.countriesStats,
},
{
label: 'Slack Community',
value: data.statsOverview.slackWorkspaceStats,
},
]

Expand Down Expand Up @@ -352,12 +356,12 @@ export default function Home() {
))}
</div>
</SecondaryCard>
<div className="grid gap-6 md:grid-cols-4">
<div className="grid gap-6 lg:grid-cols-5">
{counterData.map((stat, index) => (
<div key={index}>
<SecondaryCard className="text-center">
<div className="mb-2 text-3xl font-bold text-blue-400">
<AnimatedCounter end={parseInt(stat.value)} duration={2} />+
<AnimatedCounter end={stat.value} duration={2} />+
</div>
<div className="text-gray-600 dark:text-gray-400">{stat.label}</div>
</SecondaryCard>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/server/queries/homeQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const GET_MAIN_PAGE_DATA = gql`
activeProjectsStats
contributorsStats
countriesStats
slackWorkspaceStats
}
upcomingEvents(limit: 9) {
category
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type MainPageData = {
activeProjectsStats: number
contributorsStats: number
countriesStats: number
slackWorkspaceStats: number
}
}

Expand Down