Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
79 changes: 63 additions & 16 deletions src/shared/listeners/notify_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,48 +14,95 @@ def create_package_subscription_notifications(
suggestion: CVEDerivationClusterProposal,
) -> None:
"""
Create notifications for users subscribed to packages affected by the suggestion.
Create notifications for users subscribed to packages affected by the suggestion
and for maintainers of those packages (if they have auto-subscribe enabled).
"""
# Extract all affected package names from the suggestion

# Query package attributes directly from the suggestion's derivations
affected_packages = list(
suggestion.derivations.values_list("attribute", flat=True).distinct()
)
cve_id = suggestion.cve.cve_id

if not affected_packages:
logger.debug(f"No packages found for suggestion {suggestion.pk}")
return

# Find users subscribed to ANY of these packages
subscribed_users = User.objects.filter(
subscribed_users_qs = User.objects.filter(
profile__package_subscriptions__overlap=affected_packages
).select_related("profile")
subscribed_users_set = set(subscribed_users_qs)

if not subscribed_users.exists():
logger.debug(f"No subscribed users found for packages: {affected_packages}")
return
# Find maintainers of affected packages with auto-subscribe enabled
maintainer_users_qs = (
User.objects.filter(
username__in=suggestion.derivations.filter(
metadata__maintainers__isnull=False
).values_list("metadata__maintainers__github", flat=True),
profile__auto_subscribe_to_maintained_packages=True,
)
.select_related("profile")
.distinct()
)

maintainer_users = set(maintainer_users_qs)

logger.debug(
f"Found {len(maintainer_users)} maintainers with auto-subscribe enabled for suggestion {suggestion.pk}"
)

# Combine both sets of users, avoiding duplicates
all_users_to_notify = subscribed_users_set | maintainer_users

logger.debug(f"About to notify users about packages: {affected_packages}")
logger.debug(f"Users to notify: {all_users_to_notify}")

logger.info(
f"Creating notifications for {subscribed_users.count()} users for CVE {suggestion.cve.cve_id}"
f"Creating notifications for {len(all_users_to_notify)} users for CVE {cve_id} "
f"({len(subscribed_users_set)} subscribed, {len(maintainer_users)} maintainers)"
)

for user in subscribed_users:
# Find which of their subscribed packages are actually affected
user_affected_packages = [
pkg
for pkg in user.profile.package_subscriptions
if pkg in affected_packages
]
for user in all_users_to_notify:
# Determine notification reason and affected packages for this user
user_affected_packages = []
notification_reason = []

# Check if user is subscribed to any affected packages
if user in subscribed_users_set:
user_subscribed_packages = [
pkg
for pkg in user.profile.package_subscriptions
if pkg in affected_packages
]
user_affected_packages.extend(user_subscribed_packages)
if user_subscribed_packages:
notification_reason.append("subscribed to")

# Check if user is a maintainer with auto-subscribe enabled
if user in maintainer_users:
# For maintainers, all affected packages are relevant
maintainer_packages = [
pkg for pkg in affected_packages if pkg not in user_affected_packages
]
user_affected_packages.extend(maintainer_packages)
if maintainer_packages or (user not in subscribed_users_set):
notification_reason.append("maintainer of")

if not user_affected_packages:
continue

# Create notification
try:
reason_text = " and ".join(notification_reason)
Notification.objects.create_for_user(
user=user,
title=f"New security suggestion affects: {', '.join(user_affected_packages)}",
message=f"CVE {suggestion.cve.cve_id} may affect packages you're subscribed to. "
message=f"CVE {cve_id} may affect packages you're {reason_text}. "
f"Affected packages: {', '.join(user_affected_packages)}. ",
)
logger.debug(
f"Created notification for user {user.username} for packages: {user_affected_packages}"
f"Created notification for user {user.username} ({reason_text}) for packages: {user_affected_packages}"
)
except Exception as e:
logger.error(f"Failed to create notification for user {user.username}: {e}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 4.2.24 on 2025-11-05 14:27

import django.contrib.postgres.fields
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('webview', '0004_remove_profile_subscriptions_and_more'),
]

operations = [
migrations.AddField(
model_name='profile',
name='auto_subscribe_to_maintained_packages',
field=models.BooleanField(default=True, help_text='Automatically subscribe to notifications for packages this user maintains'),
),
migrations.AlterField(
model_name='profile',
name='package_subscriptions',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, help_text="Package attribute names this user has subscribed to manually (e.g., 'firefox', 'chromium')", size=None),
),
]
6 changes: 5 additions & 1 deletion src/webview/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ class Profile(models.Model):
models.CharField(max_length=255),
default=list,
blank=True,
help_text="Package attribute names this user has subscribed to (e.g., 'firefox', 'chromium')",
help_text="Package attribute names this user has subscribed to manually (e.g., 'firefox', 'chromium')",
)
auto_subscribe_to_maintained_packages = models.BooleanField(
default=True,
help_text="Automatically subscribe to notifications for packages this user maintains",
)

def recalculate_unread_notifications_count(self) -> None:
Expand Down
22 changes: 12 additions & 10 deletions src/webview/static/subscriptions.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@
font-weight: bold;
}

.package-subscription .subscription-panel {
/* Togglers */

.toggler {
display: flex;
justify-content: space-between;
align-items: center;
Expand All @@ -137,50 +139,50 @@
border-radius: 0.2em;
}

.package-subscription .subscription-panel.subscribed {
.toggler-on {
background: var(--subscription-subscribe-background-color);
}

.package-subscription .subscription-panel.unsubscribed {
.toggler-off {
background: #eee;
}

.package-subscription .subscription-panel button {
.toggler button {
border: none;
border-radius: 0.2em;
padding: 0.4em 1em;
cursor: pointer;
}

.package-subscription .subscribed button {
.toggler-on button {
color: var(--subscription-unsubscribe-color);
background: white;
border: solid 1px var(--subscription-unsubscribe-color);
font-weight: bold;
}

.package-subscription .unsubscribed button {
.toggler-off button {
background: var(--subscription-subscribe-color);
color: white;
font-weight: bold;
}

.package-subscription .subscription-status {
.toggler-status {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1em;
font-weight: bold;
}

.package-subscription .subscription-status .status-icon {
.toggler-icon {
font-size: 3em;
}

.package-subscription .subscribed .subscription-status {
.toggler-on .toggler-status {
color: var(--subscription-subscribe-color);
}

.package-subscription .unsubscribed .subscription-status {
.toggler-off .toggler-status {
color: #777;
}
6 changes: 6 additions & 0 deletions src/webview/subscriptions/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
PackageSubscriptionView,
RemoveSubscriptionView,
SubscriptionCenterView,
ToggleAutoSubscribeView,
)

app_name = "subscriptions"
Expand All @@ -13,6 +14,11 @@
path("", SubscriptionCenterView.as_view(), name="center"),
path("add/", AddSubscriptionView.as_view(), name="add"),
path("remove/", RemoveSubscriptionView.as_view(), name="remove"),
path(
"toggle-auto-subscribe/",
ToggleAutoSubscribeView.as_view(),
name="toggle_auto_subscribe",
),
path(
"package/<str:package_name>/", PackageSubscriptionView.as_view(), name="package"
),
Expand Down
51 changes: 51 additions & 0 deletions src/webview/subscriptions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,52 @@ def _handle_error(self, request: HttpRequest, error_message: str) -> HttpRespons
return redirect(reverse("webview:subscriptions:center"))


class ToggleAutoSubscribeView(LoginRequiredMixin, TemplateView):
"""Toggle auto-subscription to maintained packages."""

template_name = "subscriptions/components/auto_subscribe.html"

def post(self, request: HttpRequest) -> HttpResponse:
"""Toggle auto-subscription setting."""
action = request.POST.get("action", "")

if action not in ["enable", "disable"]:
return self._handle_error(request, "Invalid action.")

profile = request.user.profile

if action == "enable":
profile.auto_subscribe_to_maintained_packages = True
else: # disable
profile.auto_subscribe_to_maintained_packages = False

profile.save(update_fields=["auto_subscribe_to_maintained_packages"])

# Handle HTMX vs standard request
if request.headers.get("HX-Request"):
return self.render_to_response(
{
"auto_subscribe_enabled": profile.auto_subscribe_to_maintained_packages,
}
)
else:
return redirect(reverse("webview:subscriptions:center"))

def _handle_error(self, request: HttpRequest, error_message: str) -> HttpResponse:
"""Handle error responses for both HTMX and standard requests."""
if request.headers.get("HX-Request"):
return self.render_to_response(
{
"auto_subscribe_enabled": request.user.profile.auto_subscribe_to_maintained_packages,
"error_message": error_message,
}
)
else:
# Without javascript, we use Django messages for the errors
messages.error(request, error_message)
return redirect(reverse("webview:subscriptions:center"))


class PackageSubscriptionView(LoginRequiredMixin, TemplateView):
"""Display a package subscription page for a specific package."""

Expand All @@ -164,6 +210,11 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
else:
context["is_subscribed"] = False

# Check if user maintains this package and has automatic subscription enabled
context["auto_subscribe_enabled"] = (
self.request.user.profile.auto_subscribe_to_maintained_packages
)

return context

def post(self, request: HttpRequest, **kwargs: Any) -> HttpResponse:
Expand Down
22 changes: 22 additions & 0 deletions src/webview/templates/subscriptions/components/auto_subscribe.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% load viewutils %}

<div class="auto-subscribe-toggler toggler {% if auto_subscribe_enabled %}toggler-on{% else %}toggler-off{% endif %}">
<div class="toggler-status">
<div class="toggler-icon">{% if auto_subscribe_enabled %}✓{% else %}✕{% endif %}</div>
<div>{% if auto_subscribe_enabled %}You will automatically receive notifications about packages you maintain.{% else %}By default, you won't receive notifications for the packages you maintain.{% endif %}</div>
</div>
<form method="post"
action="{% url 'webview:subscriptions:toggle_auto_subscribe' %}"
hx-post="{% url 'webview:subscriptions:toggle_auto_subscribe' %}"
hx-target="closest .auto-subscribe-toggler"
hx-swap="outerHTML">
{% csrf_token %}
{% if auto_subscribe_enabled %}
<input type="hidden" name="action" value="disable">
<button type="submit">Disable</button>
{% else %}
<input type="hidden" name="action" value="enable">
<button type="submit">Enable</button>
{% endif %}
</form>
</div>
2 changes: 0 additions & 2 deletions src/webview/templates/subscriptions/components/packages.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
{% load viewutils %}
<div class="package-subscriptions">

<h2>Packages</h2>

<!-- Error messages specific to package subscriptions -->
{% if error_message %}
<div class="message">{{ error_message }}</div>
Expand Down
8 changes: 4 additions & 4 deletions src/webview/templates/subscriptions/package_subscription.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ <h2>Package Not Found</h2>
<div class="package-detail">
<h2>{{ package_name }}</h2>
<p>Subscribe to receive notifications about security alerts suggestions that may affect this package.</p>
<div class="subscription-panel {% if is_subscribed %}subscribed{% else %}unsubscribed{% endif %}">
<div class="subscription-status">
<div class="status-icon">{% if is_subscribed %}✓{% else %}✕{% endif %}</div>
<div class="status-text">You are {% if not is_subscribed %}not{% endif %} subscribed to this package</div>
<div class="toggler {% if is_subscribed %}toggler-on{% else %}toggler-off{% endif %}">
<div class="toggler-status">
<div class="toggler-icon">{% if is_subscribed %}✓{% else %}✕{% endif %}</div>
<div>You are {% if not is_subscribed %}not{% endif %} subscribed to this package</div>
</div>
<form method="post" class="subscription-form">
{% csrf_token %}
Expand Down
12 changes: 12 additions & 0 deletions src/webview/templates/subscriptions/subscriptions_center.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,19 @@ <h1>Subscriptions</h1>
{% endfor %}
{% endif %}

<p>When a new CVE is suspected to affect packages you have subscribed to, you will receive a notification.</p>

<div id="auto-subscribe-container">
<h2>Auto-subscription to maintained packages</h2>

{% auto_subscribe_toggle user.profile.auto_subscribe_to_maintained_packages %}
</div>

<div id="package-subscriptions-container">
<h2>Additional packages</h2>

<p>You may subscribe to additional packages here. Those could also be packages you maintain in case you decided to opt out of automatic subscription.</p>

{% package_subscriptions package_subscriptions %}
</div>

Expand Down
Loading
Loading