diff --git a/backend/Makefile b/backend/Makefile index 9ab56adc91..73d0859186 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -156,6 +156,9 @@ run-backend-fuzz: @COMPOSE_BAKE=true DOCKER_BUILDKIT=1 \ docker compose --project-name nest-fuzz -f docker-compose/fuzz/compose.yaml up --build --remove-orphans --abort-on-container-exit backend db cache +run-notification-worker: + @CMD="python manage.py owasp_run_notification_worker" $(MAKE) exec-backend-command-it + save-backup: @echo "Saving Nest backup" @CMD="python manage.py dumpdata --natural-primary --natural-foreign --indent=2" $(MAKE) exec-backend-command > backend/data/backup.json diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 7c5f7733eb..a717cfe06e 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -11,6 +11,10 @@ owasp-aggregate-member-contributions: @echo "Aggregating OWASP community member contributions" @CMD="python manage.py owasp_aggregate_member_contributions" $(MAKE) exec-backend-command +owasp-check-event-deadlines: + @echo "Checking OWASP event deadlines" + @CMD="python manage.py owasp_check_event_deadlines" $(MAKE) exec-backend-command + owasp-create-project-metadata-file: @echo "Generating metadata" @CMD="python manage.py owasp_create_project_metadata_file $(entity_key)" $(MAKE) exec-backend-command diff --git a/backend/apps/owasp/admin/notification.py b/backend/apps/owasp/admin/notification.py new file mode 100644 index 0000000000..9f84a25d63 --- /dev/null +++ b/backend/apps/owasp/admin/notification.py @@ -0,0 +1,24 @@ +"""Admin registration for notification models.""" + +from django.contrib import admin + +from apps.owasp.models.notification import Notification, Subscription + + +@admin.register(Subscription) +class SubscriptionAdmin(admin.ModelAdmin): + """Admin for Subscription model.""" + + list_display = ("user", "content_type", "object_id", "created_at") + list_filter = ("content_type", "created_at") + search_fields = ("user__email", "user__username") + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + """Admin for Notification model.""" + + list_display = ("recipient", "type", "title", "is_read", "created_at") + list_filter = ("type", "is_read", "created_at") + search_fields = ("recipient__email", "recipient__username", "title", "message") + readonly_fields = ("created_at",) diff --git a/backend/apps/owasp/apps.py b/backend/apps/owasp/apps.py index c004421c5b..403184215f 100644 --- a/backend/apps/owasp/apps.py +++ b/backend/apps/owasp/apps.py @@ -7,3 +7,9 @@ class OwaspConfig(AppConfig): """Owasp app config.""" name = "apps.owasp" + + def ready(self): + """Import signals when app is ready.""" + import apps.owasp.signals.chapter + import apps.owasp.signals.event + import apps.owasp.signals.snapshot # noqa: F401 diff --git a/backend/apps/owasp/management/commands/owasp_check_event_deadlines.py b/backend/apps/owasp/management/commands/owasp_check_event_deadlines.py new file mode 100644 index 0000000000..56ff89f315 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_check_event_deadlines.py @@ -0,0 +1,36 @@ +"""Management command to check for approaching event deadlines.""" + +import logging + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.owasp.models.event import Event +from apps.owasp.utils.notifications import publish_event_notification + +logger = logging.getLogger(__name__) + +REMINDER_DAYS = (7, 3, 1) + + +class Command(BaseCommand): + """Check for events with approaching deadlines and queue reminder notifications.""" + + help = "Check for events with approaching deadlines and send reminder notifications." + + def handle(self, *args, **options): + """Handle execution.""" + self.stdout.write("Checking for approaching event deadlines...") + today = timezone.now().date() + total_reminders = 0 + + for days in REMINDER_DAYS: + target_date = today + timezone.timedelta(days=days) + events = Event.objects.filter(start_date=target_date) + + for event in events: + self.stdout.write(f" Event '{event.name}' starts in {days} days ({target_date})") + publish_event_notification(event, "deadline_reminder", days_remaining=days) + total_reminders += 1 + + self.stdout.write(self.style.SUCCESS(f"Queued {total_reminders} deadline reminder(s).")) diff --git a/backend/apps/owasp/management/commands/owasp_notification_dlq.py b/backend/apps/owasp/management/commands/owasp_notification_dlq.py new file mode 100644 index 0000000000..25fcb24d82 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_notification_dlq.py @@ -0,0 +1,202 @@ +"""Management command to manage notification DLQ.""" + +from django.core.mail import send_mail +from django.core.management.base import BaseCommand, CommandError +from django_redis import get_redis_connection + +from apps.nest.models import User +from apps.owasp.utils.notifications import send_notification + + +class Command(BaseCommand): + """Manage notification DLQ manually.""" + + help = "Manage notification DLQ: list, retry, or remove failed notifications" + + DLQ_STREAM_KEY = "owasp_notifications_dlq" + + def add_arguments(self, parser): + parser.add_argument( + "action", + type=str, + choices=["list", "retry", "remove"], + help="Action to perform: list, retry, or remove", + ) + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--id", + type=str, + help="Specific message ID to act on (required for retry/remove unless --all is used)", + ) + group.add_argument( + "--all", + action="store_true", + help="Apply action to all messages", + ) + + def handle(self, *args, **options): + action = options["action"] + message_id = options.get("id") + all_messages = options.get("all") + + redis_conn = get_redis_connection("default") + + if action == "list": + self.list_dlq(redis_conn) + elif action == "retry": + if not message_id and not all_messages: + msg = "Error: --id or --all is required for retry" + raise CommandError(msg) + self.retry_dlq(redis_conn, message_id, all_messages) + elif action == "remove": + if not message_id and not all_messages: + msg = "Error: --id or --all is required for remove" + raise CommandError(msg) + self.remove_dlq(redis_conn, message_id, all_messages) + + def list_dlq(self, redis_conn): + """List all failed notifications in DLQ.""" + messages = redis_conn.xrange(self.DLQ_STREAM_KEY, "-", "+") + + if not messages: + self.stdout.write(self.style.SUCCESS("DLQ is empty - no failed notifications")) + return + + self.stdout.write("\n" + "=" * 100) + self.stdout.write( + f"{'ID':<20} | {'Email':<25} | {'Type':<18} | {'Entity':<15} | {'Retries':<8}" + ) + self.stdout.write("=" * 100) + + for msg_id, data in messages: + msg_id_str = msg_id.decode("utf-8") if isinstance(msg_id, bytes) else msg_id + user_email = self._get_value(data, b"user_email", "unknown") + notif_type = self._get_value(data, b"notification_type", "unknown") + entity_name = self._get_value(data, b"entity_name", "unknown")[:15] + retries = self._get_value(data, b"dlq_retries", "0") + + self.stdout.write( + f"{msg_id_str:<20} | {user_email:<25} | " + f"{notif_type:<18} | {entity_name:<15} | {retries:<8}" + ) + + self.stdout.write("=" * 100) + self.stdout.write(f"Total: {len(messages)} failed notification(s)\n") + + def retry_dlq(self, redis_conn, message_id, all_messages): + """Retry failed notification(s).""" + if all_messages: + messages = redis_conn.xrange(self.DLQ_STREAM_KEY, "-", "+") + else: + result = redis_conn.xrange(self.DLQ_STREAM_KEY, message_id, message_id) + messages = result or [] + + if not messages: + msg = "Message(s) not found" + raise CommandError(msg) + + success_count = 0 + error_count = 0 + + for msg_id, data in messages: + if not data: + continue + + try: + user_email = self._get_value(data, b"user_email") + title = self._get_value(data, b"title") + message = self._get_value(data, b"message") + related_link = self._get_value(data, b"related_link") + + user_id = self._get_value(data, b"user_id") + notification_type = self._get_value(data, b"notification_type") + + if user_email and title and message: + if user_id and notification_type: + try: + user = User.objects.get(id=int(user_id)) + send_notification( + user=user, + title=title, + message=message, + notification_type=notification_type, + related_link=related_link or "", + ) + except User.DoesNotExist: + self.stdout.write( + self.style.WARNING(f"User {user_id} not found: {msg_id}") + ) + error_count += 1 + continue + else: + # Fallback for old DLQ format + full_message = ( + f"{message}\n\nView: {related_link}" if related_link else message + ) + send_mail( + subject=title, + message=full_message, + from_email="noreply@owasp.org", + recipient_list=[user_email], + fail_silently=False, + ) + + redis_conn.xdel(self.DLQ_STREAM_KEY, msg_id) + success_count += 1 + self.stdout.write(f"Retried: {msg_id} -> {user_email}") + else: + msg_type = self._get_value(data, b"type") + if msg_type in ["processing_failed", "recovery_failed"]: + self.stdout.write( + self.style.WARNING( + f"Skipped: {msg_id} is a system processing failure. " + "Requires manual admin review." + ) + ) + else: + self.stdout.write(self.style.WARNING(f"Skipped (missing data): {msg_id}")) + error_count += 1 + + except Exception as e: # noqa: BLE001 + error_count += 1 + retries = int(self._get_value(data, b"dlq_retries", "0")) + new_retries = str(retries + 1) + data[b"dlq_retries"] = new_retries.encode() + new_msg = {k.decode(): v.decode() for k, v in data.items()} + redis_conn.xdel(self.DLQ_STREAM_KEY, msg_id) + redis_conn.xadd(self.DLQ_STREAM_KEY, new_msg) + self.stdout.write( + self.style.ERROR(f"Failed to retry {msg_id}: {e}, incremented retries") + ) + + self.stdout.write( + self.style.SUCCESS( + f"\nRetry complete: {success_count} succeeded, {error_count} failed/retried" + ) + ) + + def remove_dlq(self, redis_conn, message_id, all_messages): + """Remove failed notification(s) from DLQ.""" + if all_messages: + messages = redis_conn.xrange(self.DLQ_STREAM_KEY, "-", "+") + else: + messages = redis_conn.xrange(self.DLQ_STREAM_KEY, message_id, message_id) + + if not messages: + msg = "No messages found" + raise CommandError(msg) + + count = 0 + for msg_id, _ in messages: + redis_conn.xdel(self.DLQ_STREAM_KEY, msg_id) + count += 1 + self.stdout.write(f"Removed: {msg_id}") + + self.stdout.write(self.style.SUCCESS(f"\nRemoved {count} message(s) from DLQ")) + + def _get_value(self, data, key, default=None): + """Get value from message data.""" + value = data.get(key.encode() if isinstance(key, str) else key) + if value: + return value.decode("utf-8") + return default diff --git a/backend/apps/owasp/management/commands/owasp_run_notification_worker.py b/backend/apps/owasp/management/commands/owasp_run_notification_worker.py new file mode 100644 index 0000000000..07d6254f3d --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_run_notification_worker.py @@ -0,0 +1,427 @@ +"""Management command to run notification worker.""" + +import json +import logging +import os +import socket +import time + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand +from django_redis import get_redis_connection + +from apps.owasp.models.chapter import Chapter +from apps.owasp.models.event import Event +from apps.owasp.models.notification import Subscription +from apps.owasp.models.snapshot import Snapshot +from apps.owasp.utils.notifications import send_notification + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Run notification worker.""" + + help = "Run notification worker to process Redis stream messages." + + # Retry configuration + MAX_RETRIES = 5 + BASE_DELAY = 2 # seconds + DELAY_MULTIPLIER = 2 + DLQ_STREAM_KEY = "owasp_notifications_dlq" + + def handle(self, *args, **options): + """Handle execution.""" + self.stdout.write("Starting notification worker...") + redis_conn = get_redis_connection("default") + stream_key = "owasp_notifications" + group_name = "notification_group" + consumer_name = f"{socket.gethostname()}_{os.getpid()}" + + self.ensure_consumer_group(redis_conn, stream_key, group_name) + + self.recover_pending_messages(redis_conn, stream_key, group_name, consumer_name) + + while True: + try: + # Read new messages specifically for this group + # ">" means "messages never delivered to other consumers so far" + events = redis_conn.xreadgroup( + group_name, + consumer_name, + {stream_key: ">"}, + count=1, + block=5000, + ) + # Process main stream messages + logger.info("event: %s", events) + if events: + for _, messages in events: + for message_id, data in messages: + try: + self.process_message(data) + # Explicitly acknowledge the message + redis_conn.xack(stream_key, group_name, message_id) + logger.info("Message processed successfully.") + except Exception as exc: + logger.exception("Error processing message %s", message_id) + try: + dlq_entry = {k.decode(): v.decode() for k, v in data.items()} + dlq_entry.update( + { + "type": "processing_failed", + "original_message_id": message_id.decode() + if isinstance(message_id, bytes) + else str(message_id), + "error": str(exc), + "dlq_retries": "0", + } + ) + redis_conn.xadd(self.DLQ_STREAM_KEY, dlq_entry) + # ACK so it doesn't stay stranded in PEL + redis_conn.xack(stream_key, group_name, message_id) + except Exception: + logger.exception( + "Failed to send stranded message %s to DLQ", message_id + ) + + except Exception as e: + if "NOGROUP" in str(e): + logger.warning("Consumer group missing, attempting to recreate...") + self.ensure_consumer_group(redis_conn, stream_key, group_name) + else: + logger.exception("Error reading from stream group") + time.sleep(1) + + def ensure_consumer_group(self, redis_conn, stream_key, group_name): + """Ensure the consumer group exists.""" + try: + redis_conn.xgroup_create(stream_key, group_name, id="0", mkstream=True) + self.stdout.write(self.style.SUCCESS(f"Consumer group '{group_name}' created.")) + except Exception as e: # noqa: BLE001 + if "BUSYGROUP" in str(e): + self.stdout.write(f"Consumer group '{group_name}' already exists.") + else: + self.stdout.write(self.style.ERROR(f"Error creating group: {e}")) + + def process_message(self, data): + """Process a single message from the stream.""" + msg_type = data.get(b"type", b"").decode("utf-8") + + handlers = { + "snapshot_published": self.handle_snapshot_published, + "chapter_created": self.handle_chapter_created, + "chapter_updated": self.handle_chapter_updated, + "event_created": self.handle_event_created, + "event_updated": self.handle_event_updated, + "event_deadline_reminder": self.handle_event_deadline_reminder, + } + + handler = handlers.get(msg_type) + if handler: + handler(data) + else: + logger.warning("Unknown message type: %s", msg_type) + + def send_notification_with_retry( + self, *, user, title, message, notification_type, related_link + ): + """Send notification with exponential backoff retry logic.""" + retry_count = 0 + last_error = None + + while retry_count <= self.MAX_RETRIES: + try: + send_notification( + user=user, + title=title, + message=message, + notification_type=notification_type, + related_link=related_link, + ) + except Exception as e: + retry_count += 1 + last_error = e + if retry_count <= self.MAX_RETRIES: + delay = self.BASE_DELAY * (self.DELAY_MULTIPLIER ** (retry_count - 1)) + logger.warning( + "Email to %s failed (attempt %d/%d). Retrying in %ds. Error: %s", + user.email, + retry_count, + self.MAX_RETRIES, + delay, + last_error, + ) + time.sleep(delay) + else: + logger.exception( + "Email to %s failed after %d retries", + user.email, + self.MAX_RETRIES, + ) + return False + else: + if retry_count > 0: + logger.info( + "Email to %s succeeded after %d retries", + user.email, + retry_count, + ) + return True + + return False + + def handle_snapshot_published(self, data): + """Handle snapshot published event.""" + self._handle_entity_notification( + data=data, + id_field=b"snapshot_id", + model_class=Snapshot, + notification_type="snapshot_published", + global_subscription=True, + ) + + def handle_chapter_created(self, data): + """Handle chapter created event — notify 'all chapters' subscribers.""" + self._handle_entity_notification( + data=data, + id_field=b"chapter_id", + model_class=Chapter, + notification_type="chapter_created", + global_subscription=True, + ) + + def handle_chapter_updated(self, data): + """Handle chapter updated event — notify specific chapter subscribers.""" + self._handle_entity_notification( + data=data, + id_field=b"chapter_id", + model_class=Chapter, + notification_type="chapter_updated", + global_subscription=False, + ) + + def handle_event_created(self, data): + """Handle event created — notify 'all events' subscribers.""" + self._handle_entity_notification( + data=data, + id_field=b"event_id", + model_class=Event, + notification_type="event_created", + global_subscription=True, + ) + + def handle_event_updated(self, data): + """Handle event updated — notify specific event subscribers.""" + self._handle_entity_notification( + data=data, + id_field=b"event_id", + model_class=Event, + notification_type="event_updated", + global_subscription=False, + ) + + def handle_event_deadline_reminder(self, data): + """Handle event deadline reminder — notify specific event subscribers.""" + self._handle_entity_notification( + data=data, + id_field=b"event_id", + model_class=Event, + notification_type="event_deadline_reminder", + global_subscription=False, + ) + + def _handle_entity_notification( + self, *, data, id_field, model_class, notification_type, global_subscription=False + ): + """Handle entity notification for chapters, events, and snapshots. + + Args: + data: Redis stream message data. + id_field: The byte field name for the entity ID. + model_class: The Django model class. + notification_type: The notification type string. + global_subscription: If True, query subscribers with object_id=0 (all entities). + If False, query subscribers with object_id=entity.id (specific entity). + + """ + redis_conn = get_redis_connection("default") + + try: + raw_id = data.get(id_field) + if not raw_id: + return + entity_id = int(raw_id.decode("utf-8")) + entity = model_class.objects.get(id=entity_id) + + content_type = ContentType.objects.get_for_model(model_class) + subscription_filter = { + "content_type": content_type, + "object_id": 0 if global_subscription else entity_id, + } + subscriptions = Subscription.objects.filter(**subscription_filter).select_related( + "user" + ) + users = [sub.user for sub in subscriptions if sub.user.is_active] + + if not users: + logger.info("No recipients found for %s.", notification_type) + return + + logger.info("Sending %s notification to %d users", notification_type, len(users)) + + entity_name = str(entity) + entity_type = model_class.__name__.lower() + + days_bytes = data.get(b"days_remaining") + days_info = "" + if days_bytes: + days = days_bytes.decode() + days_info = f" ({days} days left)" + + changed_fields_bytes = data.get(b"changed_fields") + changes_description = "" + if changed_fields_bytes: + changed_fields = json.loads(changed_fields_bytes.decode()) + changes_list = [] + for field, values in changed_fields.items(): + old_val = values.get("old") or "empty" + new_val = values.get("new") or "empty" + field_display = field.replace("_", " ").title() + changes_list.append(f"{field_display}: {old_val} → {new_val}") + changes_description = " | ".join(changes_list) + + entity_title = entity.title if hasattr(entity, "title") else entity_name + + titles = { + "snapshot_published": f"New Snapshot Published: {entity_title}", + "chapter_created": f"New Chapter Created: {entity_name}", + "chapter_updated": f"Chapter Updated: {entity_name}", + "event_created": f"New Event Published: {entity_name}", + "event_updated": f"Event Updated: {entity_name}", + "event_deadline_reminder": f"Event Deadline Approaching{days_info}: {entity_name}", + } + entity_messages = { + "snapshot_published": f"Check out the latest OWASP snapshot: {entity_title}", + "chapter_created": f"A new OWASP chapter has been created: {entity_name}", + "chapter_updated": ( + f"The OWASP chapter '{entity_name}' has been updated. " + f"Changes: {changes_description}" + if changes_description + else f"The OWASP chapter '{entity_name}' has been updated." + ), + "event_created": f"A new OWASP event has been published: {entity_name}", + "event_updated": ( + f"The OWASP event '{entity_name}' has been updated. " + f"Changes: {changes_description}" + if changes_description + else f"The OWASP event '{entity_name}' has been updated." + ), + "event_deadline_reminder": ( + f"Reminder: The OWASP event '{entity_name}' " + f"deadline is approaching{days_info}." + ), + } + url_builders = { + "snapshot": lambda e: f"community/snapshots/{e.key}", + "chapter": lambda e: f"chapters/{e.id}", + "event": lambda e: f"events/{e.id}", + } + + title = titles.get(notification_type, f"Notification: {entity_name}") + message = entity_messages.get(notification_type, f"Update for {entity_name}") + + url_builder = url_builders.get(entity_type) + if url_builder: + related_link = f"{settings.SITE_URL}/{url_builder(entity)}" + else: + related_link = f"{settings.SITE_URL}" + + failed_users = [] + + for user in users: + success = self.send_notification_with_retry( + user=user, + title=title, + message=message, + notification_type=notification_type, + related_link=related_link, + ) + if not success: + failed_users.append( + { + "user": user, + "user_id": str(user.id), + "entity_type": entity_type, + "entity_id": str(entity_id), + } + ) + + if failed_users: + for failed_user in failed_users: + user_obj = failed_user.get("user") + dlq_message = { + "type": "failed_notification", + "notification_type": notification_type, + "user_id": failed_user["user_id"], + "user_email": user_obj.email if user_obj else "unknown", + "entity_type": entity_type, + "entity_id": str(entity_id), + "entity_name": entity_name, + "title": title, + "message": message, + "related_link": related_link, + "timestamp": str(time.time()), + "dlq_retries": "0", + } + redis_conn.xadd(self.DLQ_STREAM_KEY, dlq_message) + + logger.warning("Sent %d failed notifications to DLQ", len(failed_users)) + + except model_class.DoesNotExist: + logger.exception("%s matching ID not found.", model_class.__name__) + raise + except Exception: + logger.exception("Error handling %s event", notification_type) + raise + + def recover_pending_messages(self, redis_conn, stream_key, group_name, consumer_name): + """Recover and reprocess stuck messages from PEL.""" + self.stdout.write("Checking for stuck messages in PEL...") + try: + # Claim messages idle for more than 5 minutes (300000 ms) + result = redis_conn.xautoclaim( + stream_key, + group_name, + consumer_name, + min_idle_time=300000, # 5 minutes + start_id="0-0", + count=10, + ) + if result and result[1]: + for message_id, data in result[1]: + self.stdout.write(f"Recovering stuck message: {message_id}") + try: + self.process_message(data) + redis_conn.xack(stream_key, group_name, message_id) + self.stdout.write(f"Successfully recovered message {message_id}") + except Exception as exc: + logger.exception("Failed to recover message %s", message_id) + dlq_entry = {k.decode(): v.decode() for k, v in data.items()} + dlq_entry.update( + { + "type": "recovery_failed", + "original_message_id": message_id.decode() + if isinstance(message_id, bytes) + else str(message_id), + "error": str(exc), + "dlq_retries": "0", + } + ) + redis_conn.xadd(self.DLQ_STREAM_KEY, dlq_entry) + redis_conn.xack(stream_key, group_name, message_id) + else: + self.stdout.write("No stuck messages found.") + except Exception: + logger.exception("Error checking PEL for stuck messages") diff --git a/backend/apps/owasp/migrations/0073_notification_subscription.py b/backend/apps/owasp/migrations/0073_notification_subscription.py new file mode 100644 index 0000000000..f86ac760a1 --- /dev/null +++ b/backend/apps/owasp/migrations/0073_notification_subscription.py @@ -0,0 +1,82 @@ +# Generated by Django 6.0.1 on 2026-01-28 23:49 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("owasp", "0072_project_project_name_gin_idx_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("type", models.CharField(max_length=50)), + ("title", models.CharField(max_length=255)), + ("message", models.TextField()), + ("related_link", models.URLField(blank=True, null=True)), + ("is_read", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "owasp_notifications", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="Subscription", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("object_id", models.PositiveBigIntegerField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscriptions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "owasp_subscriptions", + "indexes": [ + models.Index( + fields=["user", "content_type", "object_id"], + name="owasp_subsc_user_id_33ae6d_idx", + ) + ], + "unique_together": {("user", "content_type", "object_id")}, + }, + ), + ] diff --git a/backend/apps/owasp/migrations/0074_remove_subscription_owasp_subsc_user_id_33ae6d_idx_and_more.py b/backend/apps/owasp/migrations/0074_remove_subscription_owasp_subsc_user_id_33ae6d_idx_and_more.py new file mode 100644 index 0000000000..a7f7232d35 --- /dev/null +++ b/backend/apps/owasp/migrations/0074_remove_subscription_owasp_subsc_user_id_33ae6d_idx_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.1 on 2026-02-04 04:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0073_notification_subscription"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="subscription", + name="owasp_subsc_user_id_33ae6d_idx", + ), + migrations.AlterField( + model_name="notification", + name="related_link", + field=models.URLField(blank=True, default=""), + ), + ] diff --git a/backend/apps/owasp/migrations/0075_alter_subscription_object_id.py b/backend/apps/owasp/migrations/0075_alter_subscription_object_id.py new file mode 100644 index 0000000000..c54a10ca93 --- /dev/null +++ b/backend/apps/owasp/migrations/0075_alter_subscription_object_id.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.1 on 2026-02-05 05:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0074_remove_subscription_owasp_subsc_user_id_33ae6d_idx_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="subscription", + name="object_id", + field=models.PositiveBigIntegerField(), + ), + ] diff --git a/backend/apps/owasp/models/__init__.py b/backend/apps/owasp/models/__init__.py index 3cbb120b8b..6fbe39b8b6 100644 --- a/backend/apps/owasp/models/__init__.py +++ b/backend/apps/owasp/models/__init__.py @@ -6,6 +6,7 @@ from .event import Event from .member_profile import MemberProfile from .member_snapshot import MemberSnapshot +from .notification import Notification, Subscription from .post import Post from .project import Project from .project_health_metrics import ProjectHealthMetrics diff --git a/backend/apps/owasp/models/notification.py b/backend/apps/owasp/models/notification.py new file mode 100644 index 0000000000..1d8353d2dd --- /dev/null +++ b/backend/apps/owasp/models/notification.py @@ -0,0 +1,43 @@ +"""Notification and Subscription models.""" + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from apps.nest.models import User + + +class Subscription(models.Model): + """Model representing a user's subscription to a specific entity.""" + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="subscriptions") + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveBigIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "owasp_subscriptions" + unique_together = ("user", "content_type", "object_id") + + def __str__(self): # noqa: D105 + return f"{self.user} -> {self.content_object}" + + +class Notification(models.Model): + """Model representing a notification sent to a user.""" + + recipient = models.ForeignKey(User, on_delete=models.CASCADE, related_name="notifications") + type = models.CharField(max_length=50) # e.g., 'snapshot_published', 'release', etc. + title = models.CharField(max_length=255) + message = models.TextField() + related_link = models.URLField(blank=True, default="") + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "owasp_notifications" + ordering = ["-created_at"] + + def __str__(self): # noqa: D105 + return f"{self.title} -> {self.recipient}" diff --git a/backend/apps/owasp/signals/__init__.py b/backend/apps/owasp/signals/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/owasp/signals/chapter.py b/backend/apps/owasp/signals/chapter.py new file mode 100644 index 0000000000..5069b75c7f --- /dev/null +++ b/backend/apps/owasp/signals/chapter.py @@ -0,0 +1,48 @@ +"""Chapter signals.""" + +from django.db import transaction +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from apps.owasp.models.chapter import Chapter +from apps.owasp.utils.notifications import publish_chapter_notification + +MEANINGFUL_FIELDS = ("name", "country", "region", "suggested_location", "description") + + +@receiver(pre_save, sender=Chapter) +def chapter_pre_save(sender, instance, **kwargs): # noqa: ARG001 + """Store the previous values before saving.""" + if instance.pk: + db_instance = Chapter.objects.filter(pk=instance.pk).values(*MEANINGFUL_FIELDS).first() + if db_instance: + instance._previous_values = { # noqa: SLF001 + field: db_instance.get(field) for field in MEANINGFUL_FIELDS + } + else: + instance._previous_values = {} # noqa: SLF001 + + +@receiver(post_save, sender=Chapter) +def chapter_post_save(sender, instance, created, **kwargs): # noqa: ARG001 + """Signal handler for chapter creation and updates.""" + if created: + transaction.on_commit(lambda inst=instance: publish_chapter_notification(inst, "created")) + else: + changed_fields = {} + previous_values = getattr(instance, "_previous_values", {}) + for field in MEANINGFUL_FIELDS: + old_value = previous_values.get(field) + new_value = getattr(instance, field) + if old_value != new_value: + changed_fields[field] = { + "old": str(old_value) if old_value is not None else None, + "new": str(new_value) if new_value is not None else None, + } + + if changed_fields: + transaction.on_commit( + lambda inst=instance, cf=changed_fields: publish_chapter_notification( + inst, "updated", cf + ) + ) diff --git a/backend/apps/owasp/signals/event.py b/backend/apps/owasp/signals/event.py new file mode 100644 index 0000000000..dbacfa91ef --- /dev/null +++ b/backend/apps/owasp/signals/event.py @@ -0,0 +1,55 @@ +"""Event signals.""" + +from django.db import transaction +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from apps.owasp.models.event import Event +from apps.owasp.utils.notifications import publish_event_notification + +MEANINGFUL_FIELDS = ( + "name", + "start_date", + "end_date", + "suggested_location", + "url", + "description", +) + + +@receiver(pre_save, sender=Event) +def event_pre_save(sender, instance, **kwargs): # noqa: ARG001 + """Store the previous values before saving.""" + if instance.pk: + db_instance = Event.objects.filter(pk=instance.pk).values(*MEANINGFUL_FIELDS).first() + if db_instance: + instance._previous_values = { # noqa: SLF001 + field: db_instance.get(field) for field in MEANINGFUL_FIELDS + } + else: + instance._previous_values = {} # noqa: SLF001 + + +@receiver(post_save, sender=Event) +def event_post_save(sender, instance, created, **kwargs): # noqa: ARG001 + """Signal handler for event creation and updates.""" + if created: + transaction.on_commit(lambda inst=instance: publish_event_notification(inst, "created")) + else: + changed_fields = {} + previous_values = getattr(instance, "_previous_values", {}) + for field in MEANINGFUL_FIELDS: + old_value = previous_values.get(field) + new_value = getattr(instance, field) + if old_value != new_value: + changed_fields[field] = { + "old": str(old_value) if old_value is not None else None, + "new": str(new_value) if new_value is not None else None, + } + + if changed_fields: + transaction.on_commit( + lambda inst=instance, cf=changed_fields: publish_event_notification( + inst, "updated", changed_fields=cf + ) + ) diff --git a/backend/apps/owasp/signals/snapshot.py b/backend/apps/owasp/signals/snapshot.py new file mode 100644 index 0000000000..69d49dc95b --- /dev/null +++ b/backend/apps/owasp/signals/snapshot.py @@ -0,0 +1,28 @@ +"""Snapshot signals.""" + +from django.db import transaction +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from apps.owasp.models.snapshot import Snapshot +from apps.owasp.utils.notifications import publish_snapshot_notification + + +@receiver(pre_save, sender=Snapshot) +def snapshot_pre_save(sender, instance, **kwargs): # noqa: ARG001 + """Store the previous status before saving.""" + if instance.pk: + instance._previous_status = ( # noqa: SLF001 + Snapshot.objects.filter(pk=instance.pk).values_list("status", flat=True).first() + ) + else: + instance._previous_status = None # noqa: SLF001 + + +@receiver(post_save, sender=Snapshot) +def snapshot_published(sender, instance, created, **kwargs): # noqa: ARG001 + """Signal handler for snapshot publication.""" + if instance.status == Snapshot.Status.COMPLETED and ( + created or instance._previous_status != Snapshot.Status.COMPLETED # noqa: SLF001 + ): + transaction.on_commit(lambda inst=instance: publish_snapshot_notification(inst)) diff --git a/backend/apps/owasp/utils/notifications.py b/backend/apps/owasp/utils/notifications.py new file mode 100644 index 0000000000..633f5b2725 --- /dev/null +++ b/backend/apps/owasp/utils/notifications.py @@ -0,0 +1,135 @@ +"""Notification utils.""" + +import json +import logging + +from django.core.mail import send_mail +from django.utils.timezone import now +from django_redis import get_redis_connection + +from apps.owasp.models.chapter import Chapter +from apps.owasp.models.event import Event +from apps.owasp.models.notification import Notification +from apps.owasp.models.snapshot import Snapshot + +logger = logging.getLogger(__name__) + +STREAM_KEY = "owasp_notifications" + + +def publish_snapshot_notification(snapshot: Snapshot) -> None: + """Publish a notification for a published snapshot.""" + try: + redis_conn = get_redis_connection("default") + message = { + "type": "snapshot_published", + "snapshot_id": str(snapshot.id), + "timestamp": str(now().timestamp()), + } + redis_conn.xadd(STREAM_KEY, message) + logger.info("Published snapshot notification for snapshot %s", snapshot.id) + except Exception: + logger.exception( + "Failed to publish snapshot notification for snapshot %s", + snapshot.id, + ) + + +def publish_chapter_notification( + chapter: Chapter, trigger: str, changed_fields: dict | None = None +) -> None: + """Publish a notification for a chapter creation or update. + + Args: + chapter: The Chapter instance. + trigger: Either "created" or "updated". + changed_fields: Dict of changed fields with old/new values (only for updates). + + """ + msg_type = f"chapter_{trigger}" + try: + redis_conn = get_redis_connection("default") + message = { + "type": msg_type, + "chapter_id": str(chapter.id), + "timestamp": str(now().timestamp()), + } + if changed_fields: + message["changed_fields"] = json.dumps(changed_fields) + + redis_conn.xadd(STREAM_KEY, message) + logger.info("Published %s notification for chapter %s", msg_type, chapter.id) + except Exception: + logger.exception( + "Failed to publish %s notification for chapter %s", + msg_type, + chapter.id, + ) + + +def publish_event_notification( + event: Event, + trigger: str, + days_remaining: int | None = None, + changed_fields: dict | None = None, +) -> None: + """Publish a notification for an event creation, update, or deadline reminder. + + Args: + event: The Event instance. + trigger: Either "created", "updated", or "deadline_reminder". + days_remaining: Days until event (only for deadline_reminder). + changed_fields: Dict of changed fields with old/new values (only for updates). + + """ + msg_type = f"event_{trigger}" + try: + redis_conn = get_redis_connection("default") + message = { + "type": msg_type, + "event_id": str(event.id), + "timestamp": str(now().timestamp()), + } + if days_remaining is not None: + message["days_remaining"] = str(days_remaining) + if changed_fields: + message["changed_fields"] = json.dumps(changed_fields) + + redis_conn.xadd(STREAM_KEY, message) + logger.info("Published %s notification for event %s", msg_type, event.id) + except Exception: + logger.exception( + "Failed to publish %s notification for event %s", + msg_type, + event.id, + ) + + +def send_notification(*, user, title, message, notification_type, related_link): + """Send notification to user and persist to DB.""" + if Notification.objects.filter( + recipient_id=user.id, + type=notification_type, + related_link=related_link, + message=message, + ).exists(): + logger.info("Already notified %s for %s, skipping", user.email, notification_type) + return + + full_message = f"{message}\n\nView: {related_link}" if related_link else message + send_mail( + subject=title, + message=full_message, + from_email="noreply@owasp.org", + recipient_list=[user.email], + fail_silently=False, + ) + logger.info("Sent %s email to %s", notification_type, user.email) + + Notification.objects.create( + recipient=user, + type=notification_type, + title=title, + message=message, + related_link=related_link, + ) diff --git a/backend/settings/base.py b/backend/settings/base.py index 007a94b3b6..ca96c28917 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -229,3 +229,7 @@ class Base(Configuration): SLACK_COMMANDS_ENABLED = True SLACK_EVENTS_ENABLED = True SLACK_SIGNING_SECRET = values.SecretValue() + + EMAIL_BACKEND = values.Value( + environ_name="EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend" + ) diff --git a/backend/tests/apps/owasp/management/commands/owasp_check_event_deadlines_test.py b/backend/tests/apps/owasp/management/commands/owasp_check_event_deadlines_test.py new file mode 100644 index 0000000000..e03a9f1636 --- /dev/null +++ b/backend/tests/apps/owasp/management/commands/owasp_check_event_deadlines_test.py @@ -0,0 +1,83 @@ +"""Tests for event deadline check management command.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from django.utils import timezone + +from apps.owasp.management.commands.owasp_check_event_deadlines import Command + + +class TestCheckEventDeadlines: + """Test owasp_check_event_deadlines management command.""" + + @patch("apps.owasp.management.commands.owasp_check_event_deadlines.publish_event_notification") + @patch("apps.owasp.management.commands.owasp_check_event_deadlines.Event") + @patch("apps.owasp.management.commands.owasp_check_event_deadlines.timezone") + def test_finds_events_at_reminder_days(self, mock_tz, mock_event_cls, mock_publish): + """Test that the command checks for events at 7, 3, and 1 day thresholds.""" + today = timezone.now().date() + mock_tz.now.return_value.date.return_value = today + mock_tz.timedelta = timedelta + + mock_event_cls.objects.filter.return_value = [] + + command = Command() + command.stdout = MagicMock() + command.style = MagicMock() + command.style.SUCCESS = lambda x: x + + command.handle() + + # Should query for 3 different dates (7, 3, 1 days from now) + assert mock_event_cls.objects.filter.call_count == 3 + + expected_dates = [today + timedelta(days=d) for d in (7, 3, 1)] + actual_dates = [ + call.kwargs["start_date"] for call in mock_event_cls.objects.filter.call_args_list + ] + assert actual_dates == expected_dates + + @patch("apps.owasp.management.commands.owasp_check_event_deadlines.publish_event_notification") + @patch("apps.owasp.management.commands.owasp_check_event_deadlines.Event") + @patch("apps.owasp.management.commands.owasp_check_event_deadlines.timezone") + def test_publishes_reminder_for_matching_events(self, mock_tz, mock_event_cls, mock_publish): + """Test that matching events trigger deadline_reminder notifications.""" + today = timezone.now().date() + mock_tz.now.return_value.date.return_value = today + mock_tz.timedelta = timedelta + + mock_event = MagicMock() + mock_event.name = "AppSec Days" + + # Only the 7-day query returns an event + mock_event_cls.objects.filter.side_effect = [[mock_event], [], []] + + command = Command() + command.stdout = MagicMock() + command.style = MagicMock() + command.style.SUCCESS = lambda x: x + + command.handle() + + mock_publish.assert_called_once_with(mock_event, "deadline_reminder", days_remaining=7) + + @patch("apps.owasp.management.commands.owasp_check_event_deadlines.publish_event_notification") + @patch("apps.owasp.management.commands.owasp_check_event_deadlines.Event") + @patch("apps.owasp.management.commands.owasp_check_event_deadlines.timezone") + def test_no_events_found(self, mock_tz, mock_event_cls, mock_publish): + """Test that no notifications are sent when no events match.""" + today = timezone.now().date() + mock_tz.now.return_value.date.return_value = today + mock_tz.timedelta = timedelta + + mock_event_cls.objects.filter.return_value = [] + + command = Command() + command.stdout = MagicMock() + command.style = MagicMock() + command.style.SUCCESS = lambda x: x + + command.handle() + + mock_publish.assert_not_called() diff --git a/backend/tests/apps/owasp/management/commands/owasp_run_notification_worker_test.py b/backend/tests/apps/owasp/management/commands/owasp_run_notification_worker_test.py new file mode 100644 index 0000000000..4e33b20f7a --- /dev/null +++ b/backend/tests/apps/owasp/management/commands/owasp_run_notification_worker_test.py @@ -0,0 +1,327 @@ +from unittest import mock + +import pytest + +from apps.nest.models import User +from apps.owasp.management.commands.owasp_run_notification_worker import Command +from apps.owasp.models.snapshot import Snapshot + + +class TestOwaspRunNotificationWorker: + @pytest.fixture + def command(self): + return Command() + + @pytest.fixture + def mock_user(self): + user = mock.MagicMock(spec=User) + user.email = "test@example.com" + user.id = 123 + return user + + @pytest.fixture + def mock_snapshot(self): + snapshot = mock.MagicMock(spec=Snapshot) + snapshot.title = "Test Snapshot" + snapshot.id = 456 + snapshot.key = "2025-02" + return snapshot + + @mock.patch("apps.owasp.utils.notifications.send_mail") + @mock.patch("apps.owasp.utils.notifications.Notification") + def test_send_notification_success( + self, mock_notification, mock_send_mail, command, mock_user, mock_snapshot + ): + """Test successful notification sending.""" + mock_notification.objects.filter.return_value.exists.return_value = False + + from apps.owasp.utils.notifications import send_notification + + send_notification( + user=mock_user, + title=f"New Snapshot Published: {mock_snapshot.title}", + message=f"Check out the latest OWASP snapshot: {mock_snapshot.title}", + notification_type="snapshot_published", + related_link=f"https://example.com/community/snapshots/{mock_snapshot.key}", + ) + + mock_send_mail.assert_called_once() + mock_notification.objects.create.assert_called_once_with( + recipient=mock_user, + type="snapshot_published", + title=f"New Snapshot Published: {mock_snapshot.title}", + message=f"Check out the latest OWASP snapshot: {mock_snapshot.title}", + related_link=f"https://example.com/community/snapshots/{mock_snapshot.key}", + ) + + @mock.patch("apps.owasp.utils.notifications.send_mail") + @mock.patch("apps.owasp.utils.notifications.Notification") + def test_send_notification_idempotency( + self, mock_notification, mock_send_mail, command, mock_user, mock_snapshot + ): + """Test that notification is skipped if it already exists.""" + mock_notification.objects.filter.return_value.exists.return_value = True + + from apps.owasp.utils.notifications import send_notification + + send_notification( + user=mock_user, + title=f"New Snapshot Published: {mock_snapshot.title}", + message=f"Check out the latest OWASP snapshot: {mock_snapshot.title}", + notification_type="snapshot_published", + related_link=f"https://example.com/community/snapshots/{mock_snapshot.key}", + ) + + mock_send_mail.assert_not_called() + mock_notification.objects.create.assert_not_called() + + @mock.patch.object(Command, "process_message") + def test_recover_pending_messages(self, mock_process, command): + """Test recovery of pending messages.""" + redis_conn = mock.Mock() + redis_conn.xautoclaim.return_value = (b"0-0", [(b"123-0", {b"data": b"test"})], []) + + command.recover_pending_messages(redis_conn, "stream", "group", "consumer") + + mock_process.assert_called_once() + redis_conn.xack.assert_called_once_with("stream", "group", b"123-0") + + @mock.patch.object(Command, "process_message") + def test_recover_pending_messages_failure(self, mock_process, command): + """Test recovery failure moves message to DLQ.""" + redis_conn = mock.Mock() + redis_conn.xautoclaim.return_value = (b"0-0", [(b"123-0", {b"data": b"test"})], []) + + mock_process.side_effect = Exception("Boom") + + command.recover_pending_messages(redis_conn, "stream", "group", "consumer") + + assert redis_conn.xadd.called + assert redis_conn.xadd.call_args[0][0] == command.DLQ_STREAM_KEY + redis_conn.xack.assert_called_once() + + +class TestProcessMessageRouting: + """Test process_message routes new entity message types correctly.""" + + @pytest.fixture + def command(self): + return Command() + + @pytest.mark.parametrize( + ("msg_type", "handler_name"), + [ + ("snapshot_published", "handle_snapshot_published"), + ("chapter_created", "handle_chapter_created"), + ("chapter_updated", "handle_chapter_updated"), + ("event_created", "handle_event_created"), + ("event_updated", "handle_event_updated"), + ("event_deadline_reminder", "handle_event_deadline_reminder"), + ], + ) + def test_routes_to_correct_handler(self, command, msg_type, handler_name): + """Test that each message type routes to the correct handler.""" + data = {b"type": msg_type.encode()} + with mock.patch.object(command, handler_name) as mock_handler: + command.process_message(data) + mock_handler.assert_called_once_with(data) + + def test_unknown_message_type_does_not_raise(self, command): + """Test that unknown message types are handled gracefully.""" + data = {b"type": b"unknown_type"} + command.process_message(data) # Should not raise + + +class TestEntityNotificationHandlers: + """Test entity notification handler methods.""" + + @pytest.fixture + def command(self): + return Command() + + @mock.patch( + "apps.owasp.management.commands.owasp_run_notification_worker.get_redis_connection" + ) + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Subscription") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.ContentType") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Snapshot") + def test_snapshot_published_queries_global_subscribers( + self, mock_snapshot_cls, mock_ct, mock_sub, mock_redis + ): + """Test that snapshot_published queries global subscribers (object_id=0).""" + command = Command() + mock_redis.return_value = mock.MagicMock() + mock_snapshot = mock.MagicMock() + mock_snapshot.title = "Test Snapshot" + mock_snapshot.key = "test-key" + mock_snapshot_cls.objects.get.return_value = mock_snapshot + mock_snapshot_cls.DoesNotExist = Exception + mock_snapshot_cls.__name__ = "Snapshot" + + mock_content_type = mock.MagicMock() + mock_ct.objects.get_for_model.return_value = mock_content_type + + # Mock subscriptions + mock_sub.objects.filter.return_value.select_related.return_value = [] + + data = {b"snapshot_id": b"99"} + + command.handle_snapshot_published(data) + + # Verify subscriptions queried with object_id=0 + mock_sub.objects.filter.assert_called_once_with( + content_type=mock_content_type, object_id=0 + ) + + @mock.patch( + "apps.owasp.management.commands.owasp_run_notification_worker.get_redis_connection" + ) + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Subscription") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.ContentType") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Chapter") + def test_chapter_created_queries_global_subscribers( + self, mock_chapter_cls, mock_ct, mock_sub, mock_redis, command + ): + """Test that chapter_created queries subscribers with object_id=0.""" + mock_redis.return_value = mock.MagicMock() + mock_chapter = mock.MagicMock() + mock_chapter_cls.objects.get.return_value = mock_chapter + mock_chapter_cls.DoesNotExist = Exception + mock_chapter_cls.__name__ = "Chapter" + + mock_content_type = mock.MagicMock() + mock_ct.objects.get_for_model.return_value = mock_content_type + mock_sub.objects.filter.return_value.select_related.return_value = [] + + data = {b"chapter_id": b"1"} + command.handle_chapter_created(data) + + mock_sub.objects.filter.assert_called_once_with( + content_type=mock_content_type, object_id=0 + ) + + @mock.patch( + "apps.owasp.management.commands.owasp_run_notification_worker.get_redis_connection" + ) + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Subscription") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.ContentType") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Chapter") + def test_chapter_updated_queries_specific_subscribers( + self, mock_chapter_cls, mock_ct, mock_sub, mock_redis, command + ): + """Test that chapter_updated queries subscribers with specific object_id.""" + mock_redis.return_value = mock.MagicMock() + mock_chapter = mock.MagicMock() + mock_chapter_cls.objects.get.return_value = mock_chapter + mock_chapter_cls.DoesNotExist = Exception + mock_chapter_cls.__name__ = "Chapter" + + mock_content_type = mock.MagicMock() + mock_ct.objects.get_for_model.return_value = mock_content_type + mock_sub.objects.filter.return_value.select_related.return_value = [] + + data = {b"chapter_id": b"42"} + command.handle_chapter_updated(data) + + mock_sub.objects.filter.assert_called_once_with( + content_type=mock_content_type, object_id=42 + ) + + @mock.patch( + "apps.owasp.management.commands.owasp_run_notification_worker.get_redis_connection" + ) + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Subscription") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.ContentType") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Event") + def test_event_created_queries_global_subscribers( + self, mock_event_cls, mock_ct, mock_sub, mock_redis, command + ): + """Test that event_created queries subscribers with object_id=0.""" + mock_redis.return_value = mock.MagicMock() + mock_event = mock.MagicMock() + mock_event_cls.objects.get.return_value = mock_event + mock_event_cls.DoesNotExist = Exception + mock_event_cls.__name__ = "Event" + + mock_content_type = mock.MagicMock() + mock_ct.objects.get_for_model.return_value = mock_content_type + mock_sub.objects.filter.return_value.select_related.return_value = [] + + data = {b"event_id": b"10"} + command.handle_event_created(data) + + mock_sub.objects.filter.assert_called_once_with( + content_type=mock_content_type, object_id=0 + ) + + @mock.patch( + "apps.owasp.management.commands.owasp_run_notification_worker.get_redis_connection" + ) + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Subscription") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.ContentType") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Event") + def test_event_deadline_reminder_queries_specific_subscribers( + self, mock_event_cls, mock_ct, mock_sub, mock_redis, command + ): + """Test that event_deadline_reminder queries subscribers with specific object_id.""" + mock_redis.return_value = mock.MagicMock() + mock_event = mock.MagicMock() + mock_event_cls.objects.get.return_value = mock_event + mock_event_cls.DoesNotExist = Exception + mock_event_cls.__name__ = "Event" + + mock_content_type = mock.MagicMock() + mock_ct.objects.get_for_model.return_value = mock_content_type + mock_sub.objects.filter.return_value.select_related.return_value = [] + + data = {b"event_id": b"10"} + command.handle_event_deadline_reminder(data) + + mock_sub.objects.filter.assert_called_once_with( + content_type=mock_content_type, object_id=10 + ) + + @mock.patch( + "apps.owasp.management.commands.owasp_run_notification_worker.get_redis_connection" + ) + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Subscription") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.ContentType") + @mock.patch("apps.owasp.management.commands.owasp_run_notification_worker.Event") + def test_event_deadline_reminder_includes_days_remaining( + self, mock_event_cls, mock_ct, mock_sub, mock_redis, command + ): + """Test that event_deadline_reminder includes days remaining in title/message.""" + mock_redis.return_value = mock.MagicMock() + mock_event = mock.MagicMock() + mock_event.name = "Test Event" + mock_event_cls.objects.get.return_value = mock_event + mock_event_cls.DoesNotExist = Exception + mock_event_cls.__name__ = "Event" + + mock_content_type = mock.MagicMock() + mock_ct.objects.get_for_model.return_value = mock_content_type + + # Mock a subscriber + mock_user = mock.MagicMock(is_active=True) + mock_sub_obj = mock.MagicMock() + mock_sub_obj.user = mock_user + mock_sub.objects.filter.return_value.select_related.return_value = [mock_sub_obj] + + data = {b"event_id": b"10", b"days_remaining": b"3"} + + with mock.patch.object(command, "send_notification_with_retry") as mock_send: + command.handle_event_deadline_reminder(data) + + mock_send.assert_called_once() + kwargs = mock_send.call_args[1] + assert "(3 days left)" in kwargs["title"] + assert "(3 days left)" in kwargs["message"] + + @mock.patch( + "apps.owasp.management.commands.owasp_run_notification_worker.get_redis_connection" + ) + def test_missing_entity_id_returns_early(self, mock_redis, command): + """Test that messages without entity ID are handled gracefully.""" + mock_redis.return_value = mock.MagicMock() + data = {b"type": b"chapter_created"} + command.handle_chapter_created(data) # Should not raise diff --git a/backend/tests/apps/owasp/signals/__init__.py b/backend/tests/apps/owasp/signals/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/owasp/signals/chapter_test.py b/backend/tests/apps/owasp/signals/chapter_test.py new file mode 100644 index 0000000000..1edfe754ea --- /dev/null +++ b/backend/tests/apps/owasp/signals/chapter_test.py @@ -0,0 +1,72 @@ +"""Tests for chapter signal handlers.""" + +from unittest.mock import MagicMock, patch + +from apps.owasp.signals.chapter import chapter_post_save + + +class TestChapterSignals: + """Test chapter post_save signal handler.""" + + @patch("apps.owasp.signals.chapter.transaction.on_commit", side_effect=lambda f: f()) + @patch("apps.owasp.signals.chapter.publish_chapter_notification") + def test_chapter_created_publishes_created_notification(self, mock_publish, mock_on_commit): + """Test that creating a chapter publishes a 'created' notification.""" + chapter = MagicMock() + chapter_post_save(sender=None, instance=chapter, created=True) + mock_publish.assert_called_once_with(chapter, "created") + mock_on_commit.assert_called_once() + + @patch("apps.owasp.signals.chapter.transaction.on_commit", side_effect=lambda f: f()) + @patch("apps.owasp.signals.chapter.publish_chapter_notification") + def test_chapter_updated_no_changes_skips_notification(self, mock_publish, mock_on_commit): + """Test that updating a chapter with no changes skips notification.""" + chapter = MagicMock() + # Set up previous values that match current values - no changes, no notification + chapter._previous_values = { + "name": "Test Chapter", + "country": "Test Country", + "region": "Test Region", + "suggested_location": "Test Location", + "description": "Test description", + } + # Set current values to be the same - no notification should be sent + chapter.name = "Test Chapter" + chapter.country = "Test Country" + chapter.region = "Test Region" + chapter.suggested_location = "Test Location" + chapter.description = "Test description" + chapter_post_save(sender=None, instance=chapter, created=False) + # No changes, so no notification should be published + mock_publish.assert_not_called() + mock_on_commit.assert_not_called() + + @patch("apps.owasp.signals.chapter.transaction.on_commit", side_effect=lambda f: f()) + @patch("apps.owasp.signals.chapter.publish_chapter_notification") + def test_chapter_updated_publishes_updated_notification(self, mock_publish, mock_on_commit): + """Test that updating a chapter publishes an 'updated' notification.""" + chapter = MagicMock() + chapter._previous_values = { + "name": "Test Chapter", + "country": "Test Country", + "region": "Test Region", + "suggested_location": "Test Location", + "description": "Test description", + } + # Change a meaningful field + chapter.name = "New Test Chapter Name" + chapter.country = "Test Country" + chapter.region = "Test Region" + chapter.suggested_location = "Test Location" + chapter.description = "Test description" + + chapter_post_save(sender=None, instance=chapter, created=False) + + expected_changed_fields = { + "name": { + "old": "Test Chapter", + "new": "New Test Chapter Name", + } + } + mock_publish.assert_called_once_with(chapter, "updated", expected_changed_fields) + mock_on_commit.assert_called_once() diff --git a/backend/tests/apps/owasp/signals/event_test.py b/backend/tests/apps/owasp/signals/event_test.py new file mode 100644 index 0000000000..f45b949eab --- /dev/null +++ b/backend/tests/apps/owasp/signals/event_test.py @@ -0,0 +1,78 @@ +"""Tests for event signal handlers.""" + +from unittest.mock import MagicMock, patch + +from apps.owasp.signals.event import event_post_save + + +class TestEventSignals: + """Test event post_save signal handler.""" + + @patch("apps.owasp.signals.event.transaction.on_commit", side_effect=lambda f: f()) + @patch("apps.owasp.signals.event.publish_event_notification") + def test_event_created_publishes_created_notification(self, mock_publish, mock_on_commit): + """Test that creating an event publishes a 'created' notification.""" + event = MagicMock() + event_post_save(sender=None, instance=event, created=True) + mock_publish.assert_called_once_with(event, "created") + mock_on_commit.assert_called_once() + + @patch("apps.owasp.signals.event.transaction.on_commit", side_effect=lambda f: f()) + @patch("apps.owasp.signals.event.publish_event_notification") + def test_event_updated_no_changes_skips_notification(self, mock_publish, mock_on_commit): + """Test that updating an event with no meaningful changes skips notification.""" + event = MagicMock() + # Set up previous values that match current values - no changes, no notification + event._previous_values = { + "name": "Test Event", + "start_date": "2026-01-01", + "end_date": "2026-01-02", + "suggested_location": "Test Location", + "url": "https://test.com", + "description": "Test description", + } + # Set current values to be the same - no notification should be sent + event.name = "Test Event" + event.start_date = "2026-01-01" + event.end_date = "2026-01-02" + event.suggested_location = "Test Location" + event.url = "https://test.com" + event.description = "Test description" + event_post_save(sender=None, instance=event, created=False) + # No changes, so no notification should be published + mock_publish.assert_not_called() + mock_on_commit.assert_not_called() + + @patch("apps.owasp.signals.event.transaction.on_commit", side_effect=lambda f: f()) + @patch("apps.owasp.signals.event.publish_event_notification") + def test_event_updated_publishes_updated_notification(self, mock_publish, mock_on_commit): + """Test that updating an event with meaningful changes publishes a notification.""" + event = MagicMock() + event._previous_values = { + "name": "Test Event", + "start_date": "2026-01-01", + "end_date": "2026-01-02", + "suggested_location": "Test Location", + "url": "https://test.com", + "description": "Test description", + } + # Change a meaningful field + event.name = "New Test Event Name" + event.start_date = "2026-01-01" + event.end_date = "2026-01-02" + event.suggested_location = "Test Location" + event.url = "https://test.com" + event.description = "Test description" + + event_post_save(sender=None, instance=event, created=False) + + expected_changed_fields = { + "name": { + "old": "Test Event", + "new": "New Test Event Name", + } + } + mock_publish.assert_called_once_with( + event, "updated", changed_fields=expected_changed_fields + ) + mock_on_commit.assert_called_once() diff --git a/backend/tests/apps/owasp/utils/notifications_test.py b/backend/tests/apps/owasp/utils/notifications_test.py new file mode 100644 index 0000000000..2028616c76 --- /dev/null +++ b/backend/tests/apps/owasp/utils/notifications_test.py @@ -0,0 +1,133 @@ +"""Tests for notification utils.""" + +from unittest.mock import MagicMock, patch + +from apps.owasp.utils.notifications import ( + publish_chapter_notification, + publish_event_notification, + publish_snapshot_notification, +) + + +class TestPublishSnapshotNotification: + """Test publish_snapshot_notification.""" + + @patch("apps.owasp.utils.notifications.get_redis_connection") + def test_publishes_to_redis_stream(self, mock_redis): + """Test that snapshot notification is published to Redis stream.""" + mock_conn = MagicMock() + mock_redis.return_value = mock_conn + snapshot = MagicMock() + snapshot.id = 1 + + publish_snapshot_notification(snapshot) + + mock_conn.xadd.assert_called_once() + call_args = mock_conn.xadd.call_args + assert call_args[0][0] == "owasp_notifications" + assert call_args[0][1]["type"] == "snapshot_published" + assert call_args[0][1]["snapshot_id"] == "1" + + @patch("apps.owasp.utils.notifications.get_redis_connection") + def test_handles_redis_error(self, mock_redis): + """Test that Redis connection errors are handled gracefully.""" + mock_redis.side_effect = Exception("Redis connection failed") + snapshot = MagicMock() + snapshot.id = 1 + + publish_snapshot_notification(snapshot) # Should not raise + + +class TestPublishChapterNotification: + """Test publish_chapter_notification.""" + + @patch("apps.owasp.utils.notifications.get_redis_connection") + def test_publishes_created_notification(self, mock_redis): + """Test that chapter created notification is published.""" + mock_conn = MagicMock() + mock_redis.return_value = mock_conn + chapter = MagicMock() + chapter.id = 5 + + publish_chapter_notification(chapter, "created") + + mock_conn.xadd.assert_called_once() + call_args = mock_conn.xadd.call_args + assert call_args[0][1]["type"] == "chapter_created" + assert call_args[0][1]["chapter_id"] == "5" + + @patch("apps.owasp.utils.notifications.get_redis_connection") + def test_publishes_updated_notification(self, mock_redis): + """Test that chapter updated notification is published.""" + mock_conn = MagicMock() + mock_redis.return_value = mock_conn + chapter = MagicMock() + chapter.id = 5 + + publish_chapter_notification(chapter, "updated") + + call_args = mock_conn.xadd.call_args + assert call_args[0][1]["type"] == "chapter_updated" + + @patch("apps.owasp.utils.notifications.get_redis_connection") + def test_handles_redis_error(self, mock_redis): + """Test that Redis errors are handled gracefully.""" + mock_redis.side_effect = Exception("Redis down") + chapter = MagicMock() + chapter.id = 5 + + publish_chapter_notification(chapter, "created") # Should not raise + + +class TestPublishEventNotification: + """Test publish_event_notification.""" + + @patch("apps.owasp.utils.notifications.get_redis_connection") + def test_publishes_created_notification(self, mock_redis): + """Test that event created notification is published.""" + mock_conn = MagicMock() + mock_redis.return_value = mock_conn + event = MagicMock() + event.id = 10 + + publish_event_notification(event, "created") + + call_args = mock_conn.xadd.call_args + assert call_args[0][1]["type"] == "event_created" + assert call_args[0][1]["event_id"] == "10" + + @patch("apps.owasp.utils.notifications.get_redis_connection") + def test_publishes_updated_notification(self, mock_redis): + """Test that event updated notification is published.""" + mock_conn = MagicMock() + mock_redis.return_value = mock_conn + event = MagicMock() + event.id = 10 + + publish_event_notification(event, "updated") + + call_args = mock_conn.xadd.call_args + assert call_args[0][1]["type"] == "event_updated" + + @patch("apps.owasp.utils.notifications.get_redis_connection") + def test_publishes_deadline_reminder_notification(self, mock_redis): + """Test that event deadline reminder notification is published.""" + mock_conn = MagicMock() + mock_redis.return_value = mock_conn + event = MagicMock() + event.id = 10 + + publish_event_notification(event, "deadline_reminder", days_remaining=5) + + call_args = mock_conn.xadd.call_args + assert call_args[0][1]["type"] == "event_deadline_reminder" + assert call_args[0][1]["days_remaining"] == "5" + + @patch("apps.owasp.utils.notifications.get_redis_connection") + def test_handles_redis_error(self, mock_redis): + """Test that Redis errors are handled gracefully.""" + mock_redis.side_effect = Exception("Redis down") + event = MagicMock() + event.id = 10 + + publish_event_notification(event, "created") # Should not raise diff --git a/cron/production b/cron/production index 9bbff5aed8..94a2acfffe 100644 --- a/cron/production +++ b/cron/production @@ -2,3 +2,4 @@ 17 05 * * * cd /home/production; make sync-data > /var/log/nest/production/sync-data.log 2>&1 17 17 * * * cd /home/production; make owasp-update-project-health-requirements && make owasp-update-project-health-metrics > /var/log/nest/production/update-project-health-metrics 2>&1 22 17 * * * cd /home/production; make owasp-update-project-health-scores > /var/log/nest/production/update-project-health-scores 2>&1 +00 06 * * * cd /home/production; make owasp-check-event-deadlines > /var/log/nest/production/check-event-deadlines.log 2>&1 diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 32133a9f1f..49c85cdaf4 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -10,6 +10,7 @@ Birda CCSP CISSP Cañón +DLQ DRF ELEVENLABS FOSS @@ -82,6 +83,7 @@ cva defectdojo demojize dismissable +dlq dockerhub dsn elevenlabs @@ -144,6 +146,7 @@ owasppcitoolkit owtf pdfgen pdfium +pel pentest pentesting pgvector @@ -184,11 +187,16 @@ vcodec webgoat winsrdf wsgi +xack xapp +xautoclaim +xdel xdg xdist +xgroup xoxb xoxp +xreadgroup xsser xzf zapconfig