diff --git a/backend/apps/slack/admin/__init__.py b/backend/apps/slack/admin/__init__.py index b3193ffb04..14cf601bfc 100644 --- a/backend/apps/slack/admin/__init__.py +++ b/backend/apps/slack/admin/__init__.py @@ -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 diff --git a/backend/apps/slack/admin/entity_channel.py b/backend/apps/slack/admin/entity_channel.py new file mode 100644 index 0000000000..4b114539c1 --- /dev/null +++ b/backend/apps/slack/admin/entity_channel.py @@ -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") diff --git a/backend/apps/slack/management/commands/owasp_match_channels.py b/backend/apps/slack/management/commands/owasp_match_channels.py new file mode 100644 index 0000000000..c049296562 --- /dev/null +++ b/backend/apps/slack/management/commands/owasp_match_channels.py @@ -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.")) diff --git a/backend/apps/slack/migrations/0019_entitychannel.py b/backend/apps/slack/migrations/0019_entitychannel.py new file mode 100644 index 0000000000..49ae3a335c --- /dev/null +++ b/backend/apps/slack/migrations/0019_entitychannel.py @@ -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")}, + }, + ), + ] diff --git a/backend/apps/slack/models/__init__.py b/backend/apps/slack/models/__init__.py index 3bbe0878de..7a0db8c927 100644 --- a/backend/apps/slack/models/__init__.py +++ b/backend/apps/slack/models/__init__.py @@ -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 diff --git a/backend/apps/slack/models/entity_channel.py b/backend/apps/slack/models/entity_channel.py new file mode 100644 index 0000000000..42bcd21d5b --- /dev/null +++ b/backend/apps/slack/models/entity_channel.py @@ -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})"