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() + + 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(() => { + Object.values(stats).forEach((value) => + expect(screen.getByText(`${millify(value)}+`)).toBeInTheDocument() + ) + }, 2000) + }) + }) + test('renders event details including date range and location', async () => { render() diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index a21da167fd..943af0f67a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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, }, ] @@ -352,12 +356,12 @@ export default function Home() { ))} -
+
{counterData.map((stat, index) => (
- + + +
{stat.label}
diff --git a/frontend/src/server/queries/homeQueries.ts b/frontend/src/server/queries/homeQueries.ts index 13811760f7..49dfc76eae 100644 --- a/frontend/src/server/queries/homeQueries.ts +++ b/frontend/src/server/queries/homeQueries.ts @@ -80,6 +80,7 @@ export const GET_MAIN_PAGE_DATA = gql` activeProjectsStats contributorsStats countriesStats + slackWorkspaceStats } upcomingEvents(limit: 9) { category diff --git a/frontend/src/types/home.ts b/frontend/src/types/home.ts index 0a4e9e3012..81551530b1 100644 --- a/frontend/src/types/home.ts +++ b/frontend/src/types/home.ts @@ -38,6 +38,7 @@ export type MainPageData = { activeProjectsStats: number contributorsStats: number countriesStats: number + slackWorkspaceStats: number } }