Skip to content

Commit 08bafbd

Browse files
committed
feat: automatic subscription to maintained packages
1 parent 48bcc0d commit 08bafbd

File tree

12 files changed

+363
-33
lines changed

12 files changed

+363
-33
lines changed

src/shared/listeners/notify_users.py

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,22 @@ def create_package_subscription_notifications(
1414
suggestion: CVEDerivationClusterProposal,
1515
) -> None:
1616
"""
17-
Create notifications for users subscribed to packages affected by the suggestion.
17+
Create notifications for users subscribed to packages affected by the suggestion
18+
and for maintainers of those packages (if they have auto-subscribe enabled).
1819
"""
19-
# Extract all affected package names from the suggestion
20+
21+
# Query package attributes directly from the suggestion's derivations
2022
affected_packages = list(
2123
suggestion.derivations.values_list("attribute", flat=True).distinct()
2224
)
25+
cve_id = suggestion.cve.cve_id
26+
27+
# Query maintainers' GitHub usernames directly from the derivations' metadata
28+
maintainers_github = list(
29+
suggestion.derivations.filter(metadata__maintainers__isnull=False)
30+
.values_list("metadata__maintainers__github", flat=True)
31+
.distinct()
32+
)
2333

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

33-
if not subscribed_users.exists():
34-
logger.debug(f"No subscribed users found for packages: {affected_packages}")
35-
return
43+
# Find maintainers of affected packages from cached suggestion
44+
maintainer_users = set()
45+
if maintainers_github:
46+
maintainer_users = set(
47+
User.objects.filter(
48+
username__in=maintainers_github,
49+
profile__auto_subscribe_to_maintained_packages=True,
50+
).select_related("profile")
51+
)
52+
53+
logger.debug(
54+
f"Found {len(maintainer_users)} maintainers with auto-subscribe enabled for suggestion {suggestion.pk}"
55+
)
56+
57+
# Combine both sets of users, avoiding duplicates
58+
all_users_to_notify = set(subscribed_users) | maintainer_users
59+
60+
logger.debug(f"About to notify users about packages: {affected_packages}")
61+
logger.debug(f"Users to notify: {all_users_to_notify}")
3662

3763
logger.info(
38-
f"Creating notifications for {subscribed_users.count()} users for CVE {suggestion.cve.cve_id}"
64+
f"Creating notifications for {len(all_users_to_notify)} users for CVE {cve_id} "
65+
f"({len(subscribed_users)} subscribed, {len(maintainer_users)} maintainers)"
3966
)
4067

41-
for user in subscribed_users:
42-
# Find which of their subscribed packages are actually affected
43-
user_affected_packages = [
44-
pkg
45-
for pkg in user.profile.package_subscriptions
46-
if pkg in affected_packages
47-
]
68+
for user in all_users_to_notify:
69+
# Determine notification reason and affected packages for this user
70+
user_affected_packages = []
71+
notification_reason = []
72+
73+
# Check if user is subscribed to any affected packages
74+
if user in subscribed_users:
75+
user_subscribed_packages = [
76+
pkg
77+
for pkg in user.profile.package_subscriptions
78+
if pkg in affected_packages
79+
]
80+
user_affected_packages.extend(user_subscribed_packages)
81+
if user_subscribed_packages:
82+
notification_reason.append("subscribed to")
83+
84+
# Check if user is a maintainer with auto-subscribe enabled
85+
if user in maintainer_users:
86+
# For maintainers, all affected packages are relevant
87+
maintainer_packages = [
88+
pkg for pkg in affected_packages if pkg not in user_affected_packages
89+
]
90+
user_affected_packages.extend(maintainer_packages)
91+
if maintainer_packages or (user not in subscribed_users):
92+
notification_reason.append("maintainer of")
93+
94+
if not user_affected_packages:
95+
continue
4896

4997
# Create notification
5098
try:
99+
reason_text = " and ".join(notification_reason)
51100
Notification.objects.create_for_user(
52101
user=user,
53102
title=f"New security suggestion affects: {', '.join(user_affected_packages)}",
54-
message=f"CVE {suggestion.cve.cve_id} may affect packages you're subscribed to. "
103+
message=f"CVE {cve_id} may affect packages you're {reason_text}. "
55104
f"Affected packages: {', '.join(user_affected_packages)}. ",
56105
)
57106
logger.debug(
58-
f"Created notification for user {user.username} for packages: {user_affected_packages}"
107+
f"Created notification for user {user.username} ({reason_text}) for packages: {user_affected_packages}"
59108
)
60109
except Exception as e:
61110
logger.error(f"Failed to create notification for user {user.username}: {e}")
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 4.2.24 on 2025-11-05 14:27
2+
3+
import django.contrib.postgres.fields
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('webview', '0004_remove_profile_subscriptions_and_more'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='profile',
16+
name='auto_subscribe_to_maintained_packages',
17+
field=models.BooleanField(default=True, help_text='Automatically subscribe to notifications for packages this user maintains'),
18+
),
19+
migrations.AlterField(
20+
model_name='profile',
21+
name='package_subscriptions',
22+
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),
23+
),
24+
]

src/webview/models.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ class Profile(models.Model):
2020
models.CharField(max_length=255),
2121
default=list,
2222
blank=True,
23-
help_text="Package attribute names this user has subscribed to (e.g., 'firefox', 'chromium')",
23+
help_text="Package attribute names this user has subscribed to manually (e.g., 'firefox', 'chromium')",
24+
)
25+
auto_subscribe_to_maintained_packages = models.BooleanField(
26+
default=True,
27+
help_text="Automatically subscribe to notifications for packages this user maintains",
2428
)
2529

2630
def recalculate_unread_notifications_count(self) -> None:

src/webview/static/subscriptions.css

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,9 @@
128128
font-weight: bold;
129129
}
130130

131-
.package-subscription .subscription-panel {
131+
/* Togglers */
132+
133+
.toggler {
132134
display: flex;
133135
justify-content: space-between;
134136
align-items: center;
@@ -137,50 +139,50 @@
137139
border-radius: 0.2em;
138140
}
139141

140-
.package-subscription .subscription-panel.subscribed {
142+
.toggler-on {
141143
background: var(--subscription-subscribe-background-color);
142144
}
143145

144-
.package-subscription .subscription-panel.unsubscribed {
146+
.toggler-off {
145147
background: #eee;
146148
}
147149

148-
.package-subscription .subscription-panel button {
150+
.toggler button {
149151
border: none;
150152
border-radius: 0.2em;
151153
padding: 0.4em 1em;
152154
cursor: pointer;
153155
}
154156

155-
.package-subscription .subscribed button {
157+
.toggler-on button {
156158
color: var(--subscription-unsubscribe-color);
157159
background: white;
158160
border: solid 1px var(--subscription-unsubscribe-color);
159161
font-weight: bold;
160162
}
161163

162-
.package-subscription .unsubscribed button {
164+
.toggler-off button {
163165
background: var(--subscription-subscribe-color);
164166
color: white;
165167
font-weight: bold;
166168
}
167169

168-
.package-subscription .subscription-status {
170+
.toggler-status {
169171
display: flex;
170172
justify-content: space-between;
171173
align-items: center;
172174
gap: 1em;
173175
font-weight: bold;
174176
}
175177

176-
.package-subscription .subscription-status .status-icon {
178+
.toggler-icon {
177179
font-size: 3em;
178180
}
179181

180-
.package-subscription .subscribed .subscription-status {
182+
.toggler-on .toggler-status {
181183
color: var(--subscription-subscribe-color);
182184
}
183185

184-
.package-subscription .unsubscribed .subscription-status {
186+
.toggler-off .toggler-status {
185187
color: #777;
186188
}

src/webview/subscriptions/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
PackageSubscriptionView,
66
RemoveSubscriptionView,
77
SubscriptionCenterView,
8+
ToggleAutoSubscribeView,
89
)
910

1011
app_name = "subscriptions"
@@ -13,6 +14,11 @@
1314
path("", SubscriptionCenterView.as_view(), name="center"),
1415
path("add/", AddSubscriptionView.as_view(), name="add"),
1516
path("remove/", RemoveSubscriptionView.as_view(), name="remove"),
17+
path(
18+
"toggle-auto-subscribe/",
19+
ToggleAutoSubscribeView.as_view(),
20+
name="toggle_auto_subscribe",
21+
),
1622
path(
1723
"package/<str:package_name>/", PackageSubscriptionView.as_view(), name="package"
1824
),

src/webview/subscriptions/views.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,60 @@ def _handle_error(self, request: HttpRequest, error_message: str) -> HttpRespons
141141
return redirect(reverse("webview:subscriptions:center"))
142142

143143

144+
class ToggleAutoSubscribeView(LoginRequiredMixin, TemplateView):
145+
"""Toggle auto-subscription to maintained packages."""
146+
147+
template_name = "subscriptions/components/auto_subscribe.html"
148+
149+
def post(self, request: HttpRequest) -> HttpResponse:
150+
"""Toggle auto-subscription setting."""
151+
action = request.POST.get("action", "")
152+
153+
if action not in ["enable", "disable"]:
154+
return self._handle_error(request, "Invalid action.")
155+
156+
profile = request.user.profile
157+
158+
if action == "enable":
159+
if profile.auto_subscribe_to_maintained_packages:
160+
return self._handle_error(
161+
request, "Auto-subscription is already enabled."
162+
)
163+
profile.auto_subscribe_to_maintained_packages = True
164+
else: # disable
165+
if not profile.auto_subscribe_to_maintained_packages:
166+
return self._handle_error(
167+
request, "Auto-subscription is already disabled."
168+
)
169+
profile.auto_subscribe_to_maintained_packages = False
170+
171+
profile.save(update_fields=["auto_subscribe_to_maintained_packages"])
172+
173+
# Handle HTMX vs standard request
174+
if request.headers.get("HX-Request"):
175+
return self.render_to_response(
176+
{
177+
"auto_subscribe_enabled": profile.auto_subscribe_to_maintained_packages,
178+
}
179+
)
180+
else:
181+
return redirect(reverse("webview:subscriptions:center"))
182+
183+
def _handle_error(self, request: HttpRequest, error_message: str) -> HttpResponse:
184+
"""Handle error responses for both HTMX and standard requests."""
185+
if request.headers.get("HX-Request"):
186+
return self.render_to_response(
187+
{
188+
"auto_subscribe_enabled": request.user.profile.auto_subscribe_to_maintained_packages,
189+
"error_message": error_message,
190+
}
191+
)
192+
else:
193+
# Without javascript, we use Django messages for the errors
194+
messages.error(request, error_message)
195+
return redirect(reverse("webview:subscriptions:center"))
196+
197+
144198
class PackageSubscriptionView(LoginRequiredMixin, TemplateView):
145199
"""Display a package subscription page for a specific package."""
146200

@@ -164,6 +218,11 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
164218
else:
165219
context["is_subscribed"] = False
166220

221+
# Check if user maintains this package and has automatic subscription enabled
222+
context["auto_subscribe_enabled"] = (
223+
self.request.user.profile.auto_subscribe_to_maintained_packages
224+
)
225+
167226
return context
168227

169228
def post(self, request: HttpRequest, **kwargs: Any) -> HttpResponse:
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{% load viewutils %}
2+
3+
<div class="auto-subscribe-toggler toggler {% if auto_subscribe_enabled %}toggler-on{% else %}toggler-off{% endif %}">
4+
<div class="toggler-status">
5+
<div class="toggler-icon">{% if auto_subscribe_enabled %}✓{% else %}✕{% endif %}</div>
6+
<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>
7+
</div>
8+
<form method="post"
9+
action="{% url 'webview:subscriptions:toggle_auto_subscribe' %}"
10+
hx-post="{% url 'webview:subscriptions:toggle_auto_subscribe' %}"
11+
hx-target="closest .auto-subscribe-toggler"
12+
hx-swap="outerHTML">
13+
{% csrf_token %}
14+
{% if auto_subscribe_enabled %}
15+
<input type="hidden" name="action" value="disable">
16+
<button type="submit">Disable</button>
17+
{% else %}
18+
<input type="hidden" name="action" value="enable">
19+
<button type="submit">Enable</button>
20+
{% endif %}
21+
</form>
22+
</div>

src/webview/templates/subscriptions/components/packages.html

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
{% load viewutils %}
22
<div class="package-subscriptions">
33

4-
<h2>Packages</h2>
5-
64
<!-- Error messages specific to package subscriptions -->
75
{% if error_message %}
86
<div class="message">{{ error_message }}</div>

src/webview/templates/subscriptions/package_subscription.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ <h2>Package Not Found</h2>
2828
<div class="package-detail">
2929
<h2>{{ package_name }}</h2>
3030
<p>Subscribe to receive notifications about security alerts suggestions that may affect this package.</p>
31-
<div class="subscription-panel {% if is_subscribed %}subscribed{% else %}unsubscribed{% endif %}">
32-
<div class="subscription-status">
33-
<div class="status-icon">{% if is_subscribed %}✓{% else %}✕{% endif %}</div>
34-
<div class="status-text">You are {% if not is_subscribed %}not{% endif %} subscribed to this package</div>
31+
<div class="toggler {% if is_subscribed %}toggler-on{% else %}toggler-off{% endif %}">
32+
<div class="toggler-status">
33+
<div class="toggler-icon">{% if is_subscribed %}✓{% else %}✕{% endif %}</div>
34+
<div>You are {% if not is_subscribed %}not{% endif %} subscribed to this package</div>
3535
</div>
3636
<form method="post" class="subscription-form">
3737
{% csrf_token %}

src/webview/templates/subscriptions/subscriptions_center.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,19 @@ <h1>Subscriptions</h1>
1414
{% endfor %}
1515
{% endif %}
1616

17+
<p>When a new CVE is suspected to affect packages you have subscribed to, you will receive a notification.</p>
18+
19+
<div id="auto-subscribe-container">
20+
<h2>Auto-subscription to maintained packages</h2>
21+
22+
{% auto_subscribe_toggle user.profile.auto_subscribe_to_maintained_packages %}
23+
</div>
24+
1725
<div id="package-subscriptions-container">
26+
<h2>Additional packages</h2>
27+
28+
<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>
29+
1830
{% package_subscriptions package_subscriptions %}
1931
</div>
2032

0 commit comments

Comments
 (0)