Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/apps/owasp/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
51 changes: 51 additions & 0 deletions backend/apps/owasp/admin/snapshot_subscription.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""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")
Comment thread
HarshitVerma109 marked this conversation as resolved.
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")
autocomplete_fields = ("subscribed_projects", "subscribed_chapters")

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",
),
},
),
(
"Subscription Filters",
{
"fields": ("subscribed_projects", "subscribed_chapters"),
},
),
(
"System",
{
"fields": ("unsubscribe_token", "created_at", "updated_at"),
"classes": ("collapse",),
},
),
)


admin.site.register(SnapshotSubscription, SnapshotSubscriptionAdmin)
76 changes: 76 additions & 0 deletions backend/apps/owasp/migrations/0075_snapshotsubscription.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Generated by Django 6.0.6 on 2026-06-29 09:22

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)),
(
"subscribed_chapters",
models.ManyToManyField(
blank=True, related_name="snapshot_subscriptions", to="owasp.chapter"
),
),
(
"subscribed_projects",
models.ManyToManyField(
blank=True, related_name="snapshot_subscriptions", to="owasp.project"
),
),
(
"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")],
},
),
]
1 change: 1 addition & 0 deletions backend/apps/owasp/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
97 changes: 97 additions & 0 deletions backend/apps/owasp/models/snapshot_subscription.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""OWASP app snapshot subscription model."""

import uuid

from django.db import models

from apps.nest.models import User


class SnapshotSubscription(models.Model):
Comment thread
HarshitVerma109 marked this conversation as resolved.
"""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"),
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

class Frequency(models.TextChoices):
"""Subscription frequency choices."""

WEEKLY = "weekly", "Weekly"
MONTHLY = "monthly", "Monthly"

class Status(models.TextChoices):
Comment thread
HarshitVerma109 marked this conversation as resolved.
"""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)

# Specific project/chapter subscriptions
subscribed_projects = models.ManyToManyField(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You already have the subscription context indication in the model name -- chapters/projects would be enough.

"owasp.Project",
blank=True,
related_name="snapshot_subscriptions",
)
subscribed_chapters = models.ManyToManyField(
"owasp.Chapter",
blank=True,
related_name="snapshot_subscriptions",
)

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,
}
Binary file modified backend/data/nest.dump
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""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")
assert admin.autocomplete_fields == ("subscribed_projects", "subscribed_chapters")

# Check fieldsets structure
assert len(admin.fieldsets) == 4

# 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",
)

# Check Subscription Filters fieldset
filters_fieldset = admin.fieldsets[2]
assert filters_fieldset[0] == "Subscription Filters"
assert filters_fieldset[1]["fields"] == (
"subscribed_projects",
"subscribed_chapters",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Tests for snapshot subscription model."""

import uuid
from unittest.mock import MagicMock

from apps.owasp.models.chapter import Chapter
from apps.owasp.models.project import Project
from apps.owasp.models.snapshot_subscription import SnapshotSubscription


class TestSnapshotSubscription:
"""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,
Comment thread
HarshitVerma109 marked this conversation as resolved.
"projects": True,
"pull_requests": True,
"releases": True,
"users": True,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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"
Comment thread
HarshitVerma109 marked this conversation as resolved.

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

def test_subscribed_projects_m2m_field(self):
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"""Test subscribed_projects M2M field is correctly configured."""
field = SnapshotSubscription._meta.get_field("subscribed_projects")
assert field.many_to_many
assert field.related_model is Project

def test_subscribed_chapters_m2m_field(self):
"""Test subscribed_chapters M2M field is correctly configured."""
field = SnapshotSubscription._meta.get_field("subscribed_chapters")
assert field.many_to_many
assert field.related_model is Chapter
Loading