Skip to content

Commit c3edd5c

Browse files
committed
feat: create notifications to subscribed users upon suggestion creation
1 parent 8e6f21a commit c3edd5c

File tree

2 files changed

+138
-0
lines changed

2 files changed

+138
-0
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import logging
2+
3+
import pgpubsub
4+
from django.contrib.auth.models import User
5+
6+
from shared.channels import CVEDerivationClusterProposalChannel
7+
from shared.models.linkage import CVEDerivationClusterProposal
8+
from webview.models import Notification
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
def create_package_subscription_notifications(
14+
suggestion: CVEDerivationClusterProposal,
15+
) -> None:
16+
"""
17+
Create notifications for users subscribed to packages affected by the suggestion.
18+
"""
19+
# Extract all affected package names from the suggestion
20+
affected_packages = list(
21+
suggestion.derivations.values_list("attribute", flat=True).distinct()
22+
)
23+
24+
if not affected_packages:
25+
logger.debug(f"No packages found for suggestion {suggestion.pk}")
26+
return
27+
28+
# Find users subscribed to ANY of these packages
29+
subscribed_users = User.objects.filter(
30+
profile__package_subscriptions__overlap=affected_packages
31+
).select_related("profile")
32+
33+
if not subscribed_users.exists():
34+
logger.debug(f"No subscribed users found for packages: {affected_packages}")
35+
return
36+
37+
logger.info(
38+
f"Creating notifications for {subscribed_users.count()} users for CVE {suggestion.cve.cve_id}"
39+
)
40+
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+
]
48+
49+
# Create notification
50+
try:
51+
Notification.objects.create_for_user(
52+
user=user,
53+
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. "
55+
f"Affected packages: {', '.join(user_affected_packages)}. ",
56+
)
57+
logger.debug(
58+
f"Created notification for user {user.username} for packages: {user_affected_packages}"
59+
)
60+
except Exception as e:
61+
logger.error(f"Failed to create notification for user {user.username}: {e}")
62+
63+
64+
@pgpubsub.post_insert_listener(CVEDerivationClusterProposalChannel)
65+
def notify_subscribed_users_following_suggestion_insert(
66+
old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal
67+
) -> None:
68+
"""
69+
Notify users subscribed to packages when a new security suggestion is created.
70+
"""
71+
try:
72+
create_package_subscription_notifications(new)
73+
except Exception as e:
74+
logger.error(
75+
f"Failed to create package subscription notifications for suggestion {new.pk}: {e}"
76+
)

src/webview/tests/test_subscriptions.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@
33
from django.test import Client, TestCase
44
from django.urls import reverse
55

6+
from shared.listeners.automatic_linkage import build_new_links
7+
from shared.listeners.notify_users import create_package_subscription_notifications
8+
from shared.models.cve import (
9+
AffectedProduct,
10+
CveRecord,
11+
Description,
12+
Metric,
13+
Organization,
14+
Version,
15+
)
16+
from shared.models.linkage import CVEDerivationClusterProposal
617
from shared.models.nix_evaluation import (
718
NixChannel,
819
NixDerivation,
@@ -338,3 +349,54 @@ def test_user_unsubscribes_from_empty_package_name_fails_htmx(self) -> None:
338349
# Verify empty subscriptions in context
339350
self.assertIn("package_subscriptions", response.context)
340351
self.assertEqual(response.context["package_subscriptions"], [])
352+
353+
def test_user_receives_notification_for_subscribed_package_suggestion(self) -> None:
354+
"""Test that users receive notifications when suggestions affect their subscribed packages"""
355+
# User subscribes to firefox package
356+
add_url = reverse("webview:subscriptions:add")
357+
self.client.post(add_url, {"package_name": "firefox"}, HTTP_HX_REQUEST="true")
358+
359+
# Create CVE and container - this should trigger automatic linkage and then notifications
360+
assigner = Organization.objects.create(uuid=1, short_name="test_org")
361+
cve_record = CveRecord.objects.create(
362+
cve_id="CVE-2025-0001",
363+
assigner=assigner,
364+
)
365+
366+
description = Description.objects.create(value="Test firefox vulnerability")
367+
metric = Metric.objects.create(format="cvssV3_1", raw_cvss_json={})
368+
affected_product = AffectedProduct.objects.create(package_name="firefox")
369+
affected_product.versions.add(
370+
Version.objects.create(status=Version.Status.AFFECTED, version="120.0")
371+
)
372+
373+
container = cve_record.container.create(
374+
provider=assigner,
375+
title="Firefox Security Issue",
376+
)
377+
378+
container.affected.set([affected_product])
379+
container.descriptions.set([description])
380+
container.metrics.set([metric])
381+
382+
# Trigger the linkage and notification system manually since pgpubsub triggers won't work in tests
383+
linkage_created = build_new_links(container)
384+
385+
if linkage_created:
386+
# Get the created proposal and trigger notifications
387+
suggestion = CVEDerivationClusterProposal.objects.get(cve=cve_record)
388+
create_package_subscription_notifications(suggestion)
389+
390+
# Verify notification appears in notification center context
391+
response = self.client.get(reverse("webview:notifications:center"))
392+
self.assertEqual(response.status_code, 200)
393+
394+
# Check that notification appears in context
395+
notifications = response.context["notifications"]
396+
self.assertEqual(len(notifications), 1)
397+
398+
notification = notifications[0]
399+
self.assertEqual(notification.user, self.user)
400+
self.assertIn("firefox", notification.title)
401+
self.assertIn("CVE-2025-0001", notification.message)
402+
self.assertFalse(notification.is_read) # Should be unread initially

0 commit comments

Comments
 (0)