Skip to content
1 change: 1 addition & 0 deletions backend/apps/slack/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Slack app admin."""

from .conversation import ConversationAdmin
from .entity_channel import EntityChannelAdmin
from .event import EventAdmin
from .member import MemberAdmin
from .message import MessageAdmin
Expand Down
36 changes: 36 additions & 0 deletions backend/apps/slack/admin/entity_channel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Admin configuration for the EntityChannel model."""

from django.contrib import admin, messages

from apps.slack.models import EntityChannel


@admin.action(description="Mark selected EntityChannels as reviewed")
def mark_as_reviewed(_modeladmin, request, queryset):
"""Admin action to mark selected EntityChannels as reviewed."""
messages.success(
request,
f"Marked {queryset.update(is_reviewed=True)} EntityChannel(s) as reviewed.",
)


@admin.register(EntityChannel)
class EntityChannelAdmin(admin.ModelAdmin):
"""Admin interface for the EntityChannel model."""

actions = (mark_as_reviewed,)
list_display = (
"entity",
"channel",
"is_default",
"is_reviewed",
"platform",
)
list_filter = (
"is_default",
"is_reviewed",
"platform",
"entity_type",
"channel_type",
)
search_fields = ("channel_id", "entity_id")
42 changes: 42 additions & 0 deletions backend/apps/slack/management/commands/owasp_match_channels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""A command to populate EntityChannel records from Slack data."""

from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from django.utils.text import slugify

from apps.owasp.models.chapter import Chapter
from apps.owasp.models.committee import Committee
from apps.owasp.models.project import Project
from apps.slack.models import Conversation, EntityChannel


class Command(BaseCommand):
help = "Populate EntityChannel links for Chapters, Committees, and Projects."

def handle(self, *args, **options):
created = 0
for model in (Chapter, Committee, Project):
content_type = ContentType.objects.get_for_model(model)
# Use .only and .iterator for memory efficiency
for entity in model.objects.all().only("id", "name").iterator():
# Normalize the name for matching (e.g., "OWASP Lima" -> "owasp-lima")
needle = slugify(entity.name or "")
if not needle:
continue
qs = Conversation.objects.all()
conversations = qs.filter(name__icontains=needle)
for conv in conversations:
_, was_created = EntityChannel.objects.get_or_create(
entity_id=entity.pk,
entity_type=content_type,
channel_id=conv.pk,
channel_type=ContentType.objects.get_for_model(Conversation),
defaults={
"is_default": False,
"is_reviewed": False,
"platform": EntityChannel.Platform.SLACK,
},
)
if was_created:
created += 1
self.stdout.write(self.style.SUCCESS(f"Created {created} EntityChannel records."))
70 changes: 70 additions & 0 deletions backend/apps/slack/migrations/0019_entitychannel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Generated by Django 5.2.5 on 2025-08-15 22:35

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("slack", "0018_conversation_sync_messages"),
]

operations = [
migrations.CreateModel(
name="EntityChannel",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("channel_id", models.PositiveBigIntegerField()),
("entity_id", models.PositiveBigIntegerField()),
(
"is_default",
models.BooleanField(
default=False,
help_text="Indicates if this is the main channel for the entity",
),
),
(
"is_reviewed",
models.BooleanField(
default=False, help_text="Indicates if the channel has been reviewed"
),
),
(
"platform",
models.CharField(
choices=[("slack", "Slack")],
default="slack",
help_text="Platform of the channel (e.g., Slack)",
max_length=32,
),
),
(
"channel_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="contenttypes.contenttype",
),
),
(
"entity_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="contenttypes.contenttype",
),
),
],
options={
"verbose_name": "Entity channel",
"verbose_name_plural": "Entity channels",
"unique_together": {("channel_id", "channel_type", "entity_id", "entity_type")},
},
),
]
1 change: 1 addition & 0 deletions backend/apps/slack/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .conversation import Conversation
from .entity_channel import EntityChannel
from .event import Event
from .member import Member
from .message import Message
Expand Down
59 changes: 59 additions & 0 deletions backend/apps/slack/models/entity_channel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Model for linking OWASP entities (chapter, committee, project) to communication channels."""

from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models


class EntityChannel(models.Model):
"""Model representing a link between an entity and a channel."""

class Platform(models.TextChoices):
SLACK = "slack", "Slack"

class Meta:
unique_together = (
"channel_id",
"channel_type",
"entity_id",
"entity_type",
)
verbose_name = "Entity channel"
verbose_name_plural = "Entity channels"

# Channel.
channel = GenericForeignKey("channel_type", "channel_id")
channel_id = models.PositiveBigIntegerField()
channel_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
related_name="+",
)

# Entity.
entity = GenericForeignKey("entity_type", "entity_id")
entity_id = models.PositiveBigIntegerField()
entity_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
related_name="+",
)

is_default = models.BooleanField(
default=False,
help_text="Indicates if this is the main channel for the entity",
)
is_reviewed = models.BooleanField(
default=False,
help_text="Indicates if the channel has been reviewed",
)
platform = models.CharField(
max_length=32,
default=Platform.SLACK,
choices=Platform.choices,
help_text="Platform of the channel (e.g., Slack)",
)

def __str__(self):
"""Return a readable string representation of the EntityChannel."""
return f"{self.entity} - {self.channel} ({self.platform})"