diff --git a/backend/apps/owasp/admin/__init__.py b/backend/apps/owasp/admin/__init__.py index 261225145a..7baa31603e 100644 --- a/backend/apps/owasp/admin/__init__.py +++ b/backend/apps/owasp/admin/__init__.py @@ -16,6 +16,7 @@ from .project import ProjectAdmin from .project_health_metrics import ProjectHealthMetricsAdmin from .snapshot import SnapshotAdmin +from .snapshot_subscription import SnapshotSubscriptionAdmin from .sponsor import SponsorAdmin admin.site.register(ProjectHealthRequirements) diff --git a/backend/apps/owasp/admin/snapshot_subscription.py b/backend/apps/owasp/admin/snapshot_subscription.py new file mode 100644 index 0000000000..c0784bd961 --- /dev/null +++ b/backend/apps/owasp/admin/snapshot_subscription.py @@ -0,0 +1,44 @@ +"""Admin registration for SnapshotSubscription model.""" + +from django.contrib import admin + +from apps.owasp.models.snapshot_subscription import SnapshotSubscription + + +class SnapshotSubscriptionAdmin(admin.ModelAdmin): + """Admin for SnapshotSubscription model.""" + + list_display = ("user", "frequency", "is_active", "created_at", "updated_at") + list_filter = ("frequency", "is_active", "created_at") + search_fields = ("user__email", "user__username") + raw_id_fields = ("user",) + readonly_fields = ("unsubscribe_token", "created_at", "updated_at") + + fieldsets = ( + (None, {"fields": ("user", "frequency", "is_active")}), + ( + "Content Preferences", + { + "fields": ( + "include_chapters", + "include_events", + "include_issues", + "include_posts", + "include_projects", + "include_pull_requests", + "include_releases", + "include_users", + ), + }, + ), + ( + "System", + { + "fields": ("unsubscribe_token", "created_at", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + + +admin.site.register(SnapshotSubscription, SnapshotSubscriptionAdmin) diff --git a/backend/apps/owasp/migrations/0075_snapshotsubscription.py b/backend/apps/owasp/migrations/0075_snapshotsubscription.py new file mode 100644 index 0000000000..d8e8563720 --- /dev/null +++ b/backend/apps/owasp/migrations/0075_snapshotsubscription.py @@ -0,0 +1,66 @@ +# Generated by Django 6.0.6 on 2026-06-16 14:45 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0074_rename_snapshot_m2m_fields"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SnapshotSubscription", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "frequency", + models.CharField( + choices=[("weekly", "Weekly"), ("monthly", "Monthly")], + default="weekly", + max_length=10, + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "unsubscribe_token", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("include_chapters", models.BooleanField(default=True)), + ("include_events", models.BooleanField(default=True)), + ("include_issues", models.BooleanField(default=True)), + ("include_posts", models.BooleanField(default=True)), + ("include_projects", models.BooleanField(default=True)), + ("include_pull_requests", models.BooleanField(default=True)), + ("include_releases", models.BooleanField(default=True)), + ("include_users", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="snapshot_subscription", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name_plural": "Snapshot Subscriptions", + "db_table": "owasp_snapshot_subscriptions", + "indexes": [ + models.Index(fields=["is_active"], name="owasp_sub_active_idx"), + ], + }, + ), + ] diff --git a/backend/apps/owasp/models/__init__.py b/backend/apps/owasp/models/__init__.py index 3cbb120b8b..caa983642d 100644 --- a/backend/apps/owasp/models/__init__.py +++ b/backend/apps/owasp/models/__init__.py @@ -11,4 +11,5 @@ from .project_health_metrics import ProjectHealthMetrics from .project_health_requirements import ProjectHealthRequirements from .snapshot import Snapshot +from .snapshot_subscription import SnapshotSubscription from .sponsor import Sponsor diff --git a/backend/apps/owasp/models/snapshot_subscription.py b/backend/apps/owasp/models/snapshot_subscription.py new file mode 100644 index 0000000000..45c727042c --- /dev/null +++ b/backend/apps/owasp/models/snapshot_subscription.py @@ -0,0 +1,85 @@ +"""OWASP app snapshot subscription model.""" + +import uuid + +from django.db import models + +from apps.nest.models import User + + +class SnapshotSubscription(models.Model): + """Model representing a user's subscription to snapshot digest emails.""" + + class Meta: + """Model options.""" + + db_table = "owasp_snapshot_subscriptions" + verbose_name_plural = "Snapshot Subscriptions" + indexes = [ + models.Index(fields=["is_active"], name="owasp_sub_active_idx"), + ] + + class Frequency(models.TextChoices): + """Subscription frequency choices.""" + + WEEKLY = "weekly", "Weekly" + MONTHLY = "monthly", "Monthly" + + class Status(models.TextChoices): + """Subscription status choices.""" + + ACTIVE = "active", "Active" + INACTIVE = "inactive", "Inactive" + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name="snapshot_subscription", + ) + frequency = models.CharField( + max_length=10, + choices=Frequency.choices, + default=Frequency.WEEKLY, + ) + is_active = models.BooleanField(default=True) + unsubscribe_token = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + ) + + # Content preferences + include_chapters = models.BooleanField(default=True) + include_events = models.BooleanField(default=True) + include_issues = models.BooleanField(default=True) + include_posts = models.BooleanField(default=True) + include_projects = models.BooleanField(default=True) + include_pull_requests = models.BooleanField(default=True) + include_releases = models.BooleanField(default=True) + include_users = models.BooleanField(default=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + """Return a string representation.""" + status = ( + SnapshotSubscription.Status.ACTIVE + if self.is_active + else SnapshotSubscription.Status.INACTIVE + ) + return f"{self.user} ({self.frequency}, {status})" + + @property + def content_preferences(self): + """Return a dictionary of content preference settings.""" + return { + "chapters": self.include_chapters, + "events": self.include_events, + "issues": self.include_issues, + "posts": self.include_posts, + "projects": self.include_projects, + "pull_requests": self.include_pull_requests, + "releases": self.include_releases, + "users": self.include_users, + } diff --git a/backend/data/nest.dump b/backend/data/nest.dump index 2c0903bb20..429b81683c 100644 Binary files a/backend/data/nest.dump and b/backend/data/nest.dump differ diff --git a/backend/tests/unit/apps/owasp/admin/snapshot_subscription_test.py b/backend/tests/unit/apps/owasp/admin/snapshot_subscription_test.py new file mode 100644 index 0000000000..b71c7983a8 --- /dev/null +++ b/backend/tests/unit/apps/owasp/admin/snapshot_subscription_test.py @@ -0,0 +1,45 @@ +"""Tests for snapshot subscription admin.""" + +from django.contrib.admin.sites import AdminSite + +from apps.owasp.admin.snapshot_subscription import SnapshotSubscriptionAdmin +from apps.owasp.models.snapshot_subscription import SnapshotSubscription + + +class TestSnapshotSubscriptionAdmin: + """Test SnapshotSubscriptionAdmin configuration.""" + + def test_admin_configuration(self): + """Test admin configuration matches expected setup.""" + site = AdminSite() + admin = SnapshotSubscriptionAdmin(SnapshotSubscription, site) + + assert admin.list_display == ( + "user", + "frequency", + "is_active", + "created_at", + "updated_at", + ) + assert admin.list_filter == ("frequency", "is_active", "created_at") + assert admin.search_fields == ("user__email", "user__username") + assert admin.raw_id_fields == ("user",) + assert admin.readonly_fields == ("unsubscribe_token", "created_at", "updated_at") + + # Check fieldsets structure + assert len(admin.fieldsets) == 3 + + # Check Content Preferences fieldset + preferences_fieldset = admin.fieldsets[1] + assert preferences_fieldset[0] == "Content Preferences" + + assert preferences_fieldset[1]["fields"] == ( + "include_chapters", + "include_events", + "include_issues", + "include_posts", + "include_projects", + "include_pull_requests", + "include_releases", + "include_users", + ) diff --git a/backend/tests/unit/apps/owasp/models/snapshot_subscription_test.py b/backend/tests/unit/apps/owasp/models/snapshot_subscription_test.py new file mode 100644 index 0000000000..60906f4360 --- /dev/null +++ b/backend/tests/unit/apps/owasp/models/snapshot_subscription_test.py @@ -0,0 +1,85 @@ +"""Tests for snapshot subscription model.""" + +import uuid +from unittest.mock import MagicMock + +from django.test import SimpleTestCase + +from apps.owasp.models.snapshot_subscription import SnapshotSubscription + + +class TestSnapshotSubscription(SimpleTestCase): + """Test SnapshotSubscription model.""" + + def test_str_representation_active(self): + """Test string representation for active subscription.""" + sub = MagicMock(spec=SnapshotSubscription) + sub.user = MagicMock() + sub.frequency = SnapshotSubscription.Frequency.WEEKLY + sub.is_active = True + + result = SnapshotSubscription.__str__(sub) + assert result == f"{sub.user} (weekly, active)" + + def test_str_representation_inactive(self): + """Test string representation for inactive subscription.""" + sub = MagicMock(spec=SnapshotSubscription) + sub.user = MagicMock() + sub.frequency = SnapshotSubscription.Frequency.MONTHLY + sub.is_active = False + + result = SnapshotSubscription.__str__(sub) + assert result == f"{sub.user} (monthly, inactive)" + + def test_content_preferences_all_defaults(self): + """Test that content_preferences returns all True by default.""" + sub = SnapshotSubscription() + prefs = sub.content_preferences + assert prefs == { + "chapters": True, + "events": True, + "issues": True, + "posts": True, + "projects": True, + "pull_requests": True, + "releases": True, + "users": True, + } + + def test_content_preferences_custom(self): + """Test content_preferences with custom values.""" + sub = MagicMock(spec=SnapshotSubscription) + sub.include_chapters = False + sub.include_events = True + sub.include_issues = True + sub.include_posts = False + sub.include_projects = False + sub.include_pull_requests = True + sub.include_releases = True + sub.include_users = True + + prefs = SnapshotSubscription.content_preferences.fget(sub) + assert prefs == { + "chapters": False, + "events": True, + "issues": True, + "posts": False, + "projects": False, + "pull_requests": True, + "releases": True, + "users": True, + } + + def test_frequency_choices(self): + """Test frequency choices are correctly defined.""" + assert SnapshotSubscription.Frequency.WEEKLY == "weekly" + assert SnapshotSubscription.Frequency.MONTHLY == "monthly" + + def test_unsubscribe_token_defaults(self): + """Test that unsubscribe_token is a unique UUID for each instance.""" + first = SnapshotSubscription() + second = SnapshotSubscription() + + assert isinstance(first.unsubscribe_token, uuid.UUID) + assert isinstance(second.unsubscribe_token, uuid.UUID) + assert first.unsubscribe_token != second.unsubscribe_token