Skip to content
Open
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
3 changes: 2 additions & 1 deletion backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,5 @@ update-data: \
owasp-update-events \
owasp-sync-posts \
owasp-update-sponsors \
slack-sync-data
slack-sync-data \
slack-match-channels
17 changes: 17 additions & 0 deletions backend/apps/owasp/api/internal/nodes/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,21 @@

import strawberry
import strawberry_django
from django.contrib.contenttypes.prefetch import GenericPrefetch
from django.db.models import Prefetch

from apps.github.api.internal.nodes.repository_contributor import RepositoryContributorNode
from apps.owasp.api.internal.nodes.entity_channel import EntityChannelNode
from apps.owasp.api.internal.nodes.entity_member import EntityMemberNode
from apps.owasp.models.entity_channel import EntityChannel
from apps.slack.models.conversation import Conversation

ENTITY_CHANNELS_PREFETCH = Prefetch(
"entity_channels",
queryset=EntityChannel.objects.filter(is_active=True).prefetch_related(
GenericPrefetch("channel", [Conversation.objects.all()]),
),
)


@strawberry.type
Expand All @@ -16,6 +28,11 @@ def entity_leaders(self, root) -> list[EntityMemberNode]:
"""Resolve entity leaders."""
return root.entity_leaders

@strawberry_django.field(prefetch_related=[ENTITY_CHANNELS_PREFETCH])
def entity_channels(self, root) -> list[EntityChannelNode]:
"""Resolve entity channels."""
return list(root.entity_channels.all())

@strawberry_django.field
def leaders(self, root) -> list[str]:
"""Resolve leaders."""
Expand Down
31 changes: 31 additions & 0 deletions backend/apps/owasp/api/internal/nodes/entity_channel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""OWASP app entity channel GraphQL node."""

import strawberry
import strawberry_django

from apps.owasp.models.entity_channel import EntityChannel


@strawberry_django.type(
EntityChannel,
fields=[
"is_active",
"is_default",
"is_reviewed",
"platform",
],
)
class EntityChannelNode(strawberry.relay.Node):
"""Entity channel node."""

@strawberry_django.field
def name(self, root: EntityChannel) -> str:
"""Channel display name from the linked Slack Conversation."""
conv = root.channel
return conv.name if conv else ""

@strawberry_django.field
def slack_channel_id(self, root: EntityChannel) -> str:
"""Slack channel ID for linking (e.g. C123ABC)."""
conv = root.channel
return conv.slack_channel_id if conv else ""
8 changes: 8 additions & 0 deletions backend/apps/owasp/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
GITHUB_USER_RE,
)
from apps.github.utils import get_repository_file_content
from apps.owasp.models.entity_channel import EntityChannel
from apps.owasp.models.entity_member import EntityMember
from apps.owasp.models.enums.project import AudienceChoices

Expand Down Expand Up @@ -100,6 +101,13 @@ class Meta:
related_query_name="entity_member",
)

entity_channels = GenericRelation(
EntityChannel,
content_type_field="entity_type",
object_id_field="entity_id",
related_query_name="entity_channel",
)

@cached_property
def entity_leaders(self) -> list[EntityMember]:
"""Return entity's leaders."""
Expand Down
4 changes: 4 additions & 0 deletions backend/apps/slack/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ slack-set-conversation-sync-messages-flags:
@echo "Setting conversation sync messages flags"
@CMD="python manage.py slack_set_conversation_sync_messages_flags" $(MAKE) exec-backend-command

slack-match-channels:
@echo "Matching Slack channels to OWASP chapters, committees, and projects"
@CMD="python manage.py owasp_match_channels" $(MAKE) exec-backend-command

slack-sync-data:
@echo "Syncing Slack data"
@CMD="python manage.py slack_sync_data" $(MAKE) exec-backend-command
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/apps/owasp/api/internal/nodes/common_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ def test_entity_leaders_resolver(self):

assert result == [mock_leader1, mock_leader2]

def test_entity_channels_resolver_uses_prefetched_all(self):
"""Test entity_channels resolver consumes prefetched relation via .all()."""
mock_entity = Mock()
mock_channels_manager = Mock()
mock_channel = Mock()
mock_channels_manager.all.return_value = [mock_channel]
mock_entity.entity_channels = mock_channels_manager

result = GenericEntityNode.entity_channels(None, mock_entity)

mock_channels_manager.all.assert_called_once()
assert result == [mock_channel]

def test_leaders_resolver(self):
"""Test leaders returns indexed leaders list."""
mock_entity = Mock()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Tests for EntityChannel GraphQL node resolvers."""

from unittest.mock import Mock

from apps.owasp.api.internal.nodes.entity_channel import EntityChannelNode


class TestEntityChannelNodeResolvers:
def _get_resolver(self, field_name):
"""Get the resolver function for a field."""
for field in EntityChannelNode.__strawberry_definition__.fields:
if field.name == field_name:
return field.base_resolver.wrapped_func if field.base_resolver else None
return None

def test_name_and_slack_channel_id_resolvers_return_values_from_channel(self):
"""Return channel name and slack channel id when channel is present."""
mock_channel = Mock(name="mock-conversation")
mock_channel.name = "chapter-general"
mock_channel.slack_channel_id = "C123ABC"

mock_entity_channel = Mock()
mock_entity_channel.channel = mock_channel

name_resolver = self._get_resolver("name")
slack_channel_id_resolver = self._get_resolver("slack_channel_id")

assert name_resolver is not None
assert slack_channel_id_resolver is not None
assert name_resolver(None, mock_entity_channel) == "chapter-general"
assert slack_channel_id_resolver(None, mock_entity_channel) == "C123ABC"

def test_name_and_slack_channel_id_resolvers_return_empty_when_channel_missing(self):
"""Return empty strings when no linked channel exists."""
mock_entity_channel = Mock()
mock_entity_channel.channel = None

name_resolver = self._get_resolver("name")
slack_channel_id_resolver = self._get_resolver("slack_channel_id")

assert name_resolver is not None
assert slack_channel_id_resolver is not None
assert name_resolver(None, mock_entity_channel) == ""
assert slack_channel_id_resolver(None, mock_entity_channel) == ""
7 changes: 7 additions & 0 deletions frontend/__tests__/e2e/pages/ChapterDetails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ test.describe('Chapter Details Page', () => {
await expect(page.getByRole('link', { name: 'https://owasp.org/test-chapter' })).toBeVisible()
})

test('should have Slack channel link', async ({ page }) => {
const slackLink = page.getByRole('link', { name: 'chapter-test' })
await expect(slackLink).toBeVisible()
await expect(slackLink).toHaveAttribute('href', 'https://owasp.slack.com/archives/C123ABC')
await expect(slackLink).toHaveAttribute('target', '_blank')
})

test('should have map with geolocation', async ({ page }) => {
const unlockButton = page.getByRole('button', { name: 'Unlock map' })
await expect(unlockButton).toBeVisible()
Expand Down
7 changes: 7 additions & 0 deletions frontend/__tests__/e2e/pages/ProjectDetails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ test.describe('Project Details Page', () => {
await expect(page.getByText('URL: https://github.com/')).toBeVisible()
})

test('should have Slack channel link', async ({ page }) => {
const slackLink = page.getByRole('link', { name: '#project-security' })
await expect(slackLink).toBeVisible()
await expect(slackLink).toHaveAttribute('href', 'https://owasp.slack.com/archives/C456DEF')
await expect(slackLink).toHaveAttribute('target', '_blank')
})

test('should have project statics block', async ({ page }) => {
await expect(page.getByText('2.2K Stars')).toBeVisible()
await expect(page.getByText('10 Forks')).toBeVisible()
Expand Down
6 changes: 6 additions & 0 deletions frontend/__tests__/mockData/mockChapterDetailsData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export const mockChapterDetailsData = {
},
},
],
entityChannels: [
{
name: 'chapter-test',
slackChannelId: 'C123ABC',
},
],
establishedYear: 2020,
key: 'test-chapter',
},
Expand Down
6 changes: 6 additions & 0 deletions frontend/__tests__/mockData/mockProjectDetailsData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export const mockProjectDetailsData = {
},
},
],
entityChannels: [
{
name: 'project-security',
slackChannelId: 'C456DEF',
},
],
forksCount: 10,
healthMetricsList: [
{
Expand Down
11 changes: 11 additions & 0 deletions frontend/__tests__/unit/pages/ChapterDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ describe('chapterDetailsPage Component', () => {
})
})

test('renders Slack channel link with expected Slack URL', async () => {
render(<ChapterDetailsPage />)

await waitFor(() => {
const slackLink = screen.getByRole('link', { name: 'chapter-test' })
expect(slackLink).toHaveAttribute('href', 'https://owasp.slack.com/archives/C123ABC')
expect(slackLink).toHaveAttribute('target', '_blank')
expect(slackLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})

test('handles contributors with missing names gracefully', async () => {
const chapterDataWithIncompleteContributors = {
...mockChapterDetailsData,
Expand Down
11 changes: 11 additions & 0 deletions frontend/__tests__/unit/pages/ProjectDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,15 @@ describe('ProjectDetailsPage', () => {
expect(screen.getByText('Project Leader')).toBeInTheDocument()
})
})

test('renders Slack channel link with expected Slack URL', async () => {
render(<ProjectDetailsPage />)

await waitFor(() => {
const slackLink = screen.getByRole('link', { name: '#project-security' })
expect(slackLink).toHaveAttribute('href', 'https://owasp.slack.com/archives/C456DEF')
expect(slackLink).toHaveAttribute('target', '_blank')
expect(slackLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})
})
25 changes: 25 additions & 0 deletions frontend/src/app/chapters/[chapterKey]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export default function ChapterDetailsPage() {
)
}

const slackChannelUrl = (slackChannelId: string) =>
`https://owasp.slack.com/archives/${slackChannelId}`

const details = [
{ label: 'Last Updated', value: formatDate(chapter.updatedAt) },
{ label: 'Location', value: chapter.suggestedLocation ?? '' },
Expand All @@ -67,6 +70,28 @@ export default function ChapterDetailsPage() {
</Link>
),
},
...(chapter.entityChannels && chapter.entityChannels.length > 0
? [
{
label: 'Slack',
value: (
<div className="inline-flex flex-wrap gap-3">
{chapter.entityChannels.map((ch) => (
<Link
key={ch.slackChannelId}
href={slackChannelUrl(ch.slackChannelId)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
{ch.name}
</Link>
))}
</div>
),
},
]
: []),
]

const { startDate, endDate } = getDateRange({ years: 1 })
Expand Down
27 changes: 26 additions & 1 deletion frontend/src/app/projects/[projectKey]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ const ProjectDetailsPage = () => {
/>
)
}
const slackChannelUrl = (slackChannelId: string) =>
`https://owasp.slack.com/archives/${slackChannelId}`

const projectDetails = [
{ label: 'Last Updated', value: formatDate(project.updatedAt) },
{ label: 'Leaders', value: project.leaders.join(', ') },
Expand All @@ -73,11 +76,33 @@ const ProjectDetailsPage = () => {
{
label: 'URL',
value: (
<Link href={project.url} className="hover:underline dark:text-sky-600">
<Link href={project.url} className="text-blue-400 hover:underline">
{project.url}
</Link>
),
},
...(project.entityChannels && project.entityChannels.length > 0
? [
{
label: 'Slack',
value: (
<div className="inline-flex flex-wrap gap-3">
{project.entityChannels.map((ch) => (
<Link
key={ch.slackChannelId}
href={slackChannelUrl(ch.slackChannelId)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
#{ch.name}
</Link>
))}
</div>
),
},
]
: []),
]
const projectStats = [
{ icon: FaStar, value: project.starsCount, unit: 'Star' },
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/server/queries/chapterQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export const GET_CHAPTER_DATA = gql`
summary
updatedAt
url
entityChannels {
name
slackChannelId
}
}
topContributors(chapter: $key) {
id
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/server/queries/projectQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ export const GET_PROJECT_DATA = gql`
type
updatedAt
url
entityChannels {
name
slackChannelId
}
recentMilestones(limit: 5) {
author {
id
Expand Down
Loading