Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 64 additions & 15 deletions src/shared/listeners/notify_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,22 @@ 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

# Query maintainers' GitHub usernames directly from the derivations' metadata
maintainers_github = list(
suggestion.derivations.filter(metadata__maintainers__isnull=False)
.values_list("metadata__maintainers__github", flat=True)
.distinct()
)

if not affected_packages:
logger.debug(f"No packages found for suggestion {suggestion.pk}")
Expand All @@ -30,32 +40,71 @@ def create_package_subscription_notifications(
profile__package_subscriptions__overlap=affected_packages
).select_related("profile")

if not subscribed_users.exists():
logger.debug(f"No subscribed users found for packages: {affected_packages}")
return
# Find maintainers of affected packages from cached suggestion
maintainer_users = set()
if maintainers_github:
maintainer_users = set(
User.objects.filter(
username__in=maintainers_github,

Choose a reason for hiding this comment

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

Would it be better to have another JOIN here instead of fetching all GitHub handles into Python just to send them back?

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 82ee088

profile__auto_subscribe_to_maintained_packages=True,
).select_related("profile")
)

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 = set(subscribed_users) | 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)} 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:

Choose a reason for hiding this comment

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

Note that subscribed_users here is a list, which would make this and the ckeck o line 91 a bottleneck for popular packages.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in 7fd96cb

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):
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
59 changes: 59 additions & 0 deletions src/webview/subscriptions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,60 @@ 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":
if profile.auto_subscribe_to_maintained_packages:
return self._handle_error(
request, "Auto-subscription is already enabled."

Choose a reason for hiding this comment

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

I don't think this should be an error: if response to one query was lost and user clicked on the button twice, it should still be fine as long as we reach expected state.

Copy link
Member Author

Choose a reason for hiding this comment

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

Addressed in dcc4cc3

)
profile.auto_subscribe_to_maintained_packages = True
else: # disable
if not profile.auto_subscribe_to_maintained_packages:
return self._handle_error(
request, "Auto-subscription is already disabled."
)
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 +218,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