diff --git a/backend/apps/common/utils.py b/backend/apps/common/utils.py
index ca49b63a9e..f99f36dea2 100644
--- a/backend/apps/common/utils.py
+++ b/backend/apps/common/utils.py
@@ -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.
diff --git a/backend/apps/owasp/graphql/nodes/stats.py b/backend/apps/owasp/graphql/nodes/stats.py
index 1804502188..fcb835f7b9 100644
--- a/backend/apps/owasp/graphql/nodes/stats.py
+++ b/backend/apps/owasp/graphql/nodes/stats.py
@@ -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()
diff --git a/backend/apps/owasp/graphql/queries/stats.py b/backend/apps/owasp/graphql/queries/stats.py
index 407be5d9aa..b461343d4b 100644
--- a/backend/apps/owasp/graphql/queries/stats.py
+++ b/backend/apps/owasp/graphql/queries/stats.py
@@ -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:
@@ -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),
)
diff --git a/backend/apps/slack/management/commands/slack_sync_data.py b/backend/apps/slack/management/commands/slack_sync_data.py
index 4cd6e56f9b..65886d3ffc 100644
--- a/backend/apps/slack/management/commands/slack_sync_data.py
+++ b/backend/apps/slack/management/commands/slack_sync_data.py
@@ -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"))
diff --git a/backend/apps/slack/migrations/0010_workspace_total_members_count.py b/backend/apps/slack/migrations/0010_workspace_total_members_count.py
new file mode 100644
index 0000000000..5f84304903
--- /dev/null
+++ b/backend/apps/slack/migrations/0010_workspace_total_members_count.py
@@ -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),
+ ),
+ ]
diff --git a/backend/apps/slack/models/workspace.py b/backend/apps/slack/models/workspace.py
index c973006492..62e6ad0a5d 100644
--- a/backend/apps/slack/models/workspace.py
+++ b/backend/apps/slack/models/workspace.py
@@ -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.
diff --git a/backend/tests/apps/common/utils_test.py b/backend/tests/apps/common/utils_test.py
index 69dbbebd2b..8c1da05074 100644
--- a/backend/tests/apps/common/utils_test.py
+++ b/backend/tests/apps/common/utils_test.py
@@ -10,6 +10,7 @@
join_values,
natural_date,
natural_number,
+ round_down,
)
@@ -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
diff --git a/frontend/__tests__/e2e/data/mockHomeData.ts b/frontend/__tests__/e2e/data/mockHomeData.ts
index 74badaf426..d77bc1f6c4 100644
--- a/frontend/__tests__/e2e/data/mockHomeData.ts
+++ b/frontend/__tests__/e2e/data/mockHomeData.ts
@@ -242,7 +242,7 @@ export const mockHomeData = {
activeProjectsStats: 250,
contributorsStats: 11400,
countriesStats: 90,
- __typename: 'StatsNode',
+ slackWorkspaceStats: 35800,
},
upcomingEvents: [
{
diff --git a/frontend/__tests__/e2e/pages/Home.spec.ts b/frontend/__tests__/e2e/pages/Home.spec.ts
index 82f43853aa..5f3fca203e 100644
--- a/frontend/__tests__/e2e/pages/Home.spec.ts
+++ b/frontend/__tests__/e2e/pages/Home.spec.ts
@@ -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()
diff --git a/frontend/__tests__/unit/data/mockHomeData.ts b/frontend/__tests__/unit/data/mockHomeData.ts
index 0536578223..7aaeaf045e 100644
--- a/frontend/__tests__/unit/data/mockHomeData.ts
+++ b/frontend/__tests__/unit/data/mockHomeData.ts
@@ -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: [
{
diff --git a/frontend/__tests__/unit/pages/Home.test.tsx b/frontend/__tests__/unit/pages/Home.test.tsx
index 72ad0ac576..7cdb71d5b3 100644
--- a/frontend/__tests__/unit/pages/Home.test.tsx
+++ b/frontend/__tests__/unit/pages/Home.test.tsx
@@ -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'
@@ -257,6 +258,29 @@ describe('Home', () => {
})
})
+ test('renders stats correctly', async () => {
+ render(