Skip to content
Open
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
21 changes: 16 additions & 5 deletions openwisp_notifications/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,10 @@ def notify_handler(**kwargs):
(kwargs.pop(opt, None), opt) for opt in ("target", "action_object")
]

notification_list = []
for recipient in recipients:
notifications_to_create = []
recipients_list = list(recipients)

for recipient in recipients_list:
notification = Notification(
recipient=recipient,
actor=actor,
Expand All @@ -163,9 +165,18 @@ def notify_handler(**kwargs):
)
if kwargs:
notification.data = kwargs
notification.save()
notification_list.append(notification)

notifications_to_create.append(notification)
notification_list = Notification.objects.bulk_create(notifications_to_create)
for notification in notification_list:
send_email_notification(Notification, notification, created=True)
for recipient in recipients_list:
Notification.invalidate_unread_cache(recipient)
notification_map = dict(zip(recipients_list, notification_list))
Comment on lines +169 to +174
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*.py" | grep -E "(handlers|settings|config)" | head -20

Repository: openwisp/openwisp-notifications

Length of output: 389


🏁 Script executed:

git ls-files | head -30

Repository: openwisp/openwisp-notifications

Length of output: 890


🏁 Script executed:

wc -l openwisp_notifications/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 113


🏁 Script executed:

sed -n '160,185p' openwisp_notifications/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 1188


🏁 Script executed:

sed -n '1,50p' openwisp_notifications/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 1999


🏁 Script executed:

rg -n "send_email_notification" openwisp_notifications/handlers.py -B 2 -A 10

Repository: openwisp/openwisp-notifications

Length of output: 1232


🏁 Script executed:

sed -n '183,210p' openwisp_notifications/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 1205


🏁 Script executed:

sed -n '210,240p' openwisp_notifications/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 1255


🏁 Script executed:

cat openwisp_notifications/websockets/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 6626


🏁 Script executed:

rg -n "bulk_create" openwisp_notifications/ -B 2 -A 2

Repository: openwisp/openwisp-notifications

Length of output: 2108


🏁 Script executed:

grep -r "DATABASE" tests/openwisp2/settings.py openwisp_notifications/settings.py

Repository: openwisp/openwisp-notifications

Length of output: 116


🏁 Script executed:

cat tests/openwisp2/settings.py | grep -A 10 "DATABASES"

Repository: openwisp/openwisp-notifications

Length of output: 331


🏁 Script executed:

find . -name "setup.py" -o -name "setup.cfg" -o -name "pyproject.toml" -o -name "requirements*.txt" | xargs grep -l "django\|Django" 2>/dev/null

Repository: openwisp/openwisp-notifications

Length of output: 126


🏁 Script executed:

cat setup.py | grep -A 20 "install_requires\|Django"

Repository: openwisp/openwisp-notifications

Length of output: 1160


🏁 Script executed:

rg -n "bulk_create.*populate_pk|return_pk" openwisp_notifications/ tests/

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

cat requirements.txt

Repository: openwisp/openwisp-notifications

Length of output: 290


🏁 Script executed:

rg -n "\.id" openwisp_notifications/handlers.py | grep -E "(notification|batched_notifications)"

Repository: openwisp/openwisp-notifications

Length of output: 124


🏁 Script executed:

rg -n "notification.id" openwisp_notifications/handlers.py -B 2 -A 2

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

sed -n '215,230p' openwisp_notifications/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 689


🏁 Script executed:

rg -n "instance\.id\|notification\.id" openwisp_notifications/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

python3 << 'EOF'
# Check Django bulk_create behavior
print("Django bulk_create() behavior:")
print("- For Django 3.1+: bulk_create() always populates PKs by default")
print("- For SQLite: PKs are populated (uses lastrowid)")
print("- Exceptions: Custom primary keys, certain edge cases")
print("\nKey point: batch_start_time on line 220 uses instance.id")
print("If instance.id is None, batching could fail silently")
EOF

Repository: openwisp/openwisp-notifications

Length of output: 384


🏁 Script executed:

cat requirements.txt | grep -i django

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

cat setup.py | grep -E "openwisp-users|openwisp-utils"

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

rg -n "return_pk\|populate_pk" . --type py

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

rg -n "test.*bulk_create\|bulk_create.*test" openwisp_notifications/tests/ -B 3 -A 5 | head -40

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

rg -n "def test.*notify" openwisp_notifications/tests/ -B 1 -A 8 | head -60

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

fd "test_.*\.py" openwisp_notifications/tests/ --type f

Repository: openwisp/openwisp-notifications

Length of output: 725


🏁 Script executed:

rg -n "send_email_notification\|bulk_create" openwisp_notifications/tests/test_handlers.py -B 2 -A 5 | head -80

Repository: openwisp/openwisp-notifications

Length of output: 160


🏁 Script executed:

rg -n "notify_handler\|send_email_notification\|bulk_create" openwisp_notifications/tests/test_notifications.py -B 2 -A 5 | head -100

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

rg -n "class.*Test.*Notif" openwisp_notifications/tests/test_notifications.py

Repository: openwisp/openwisp-notifications

Length of output: 231


🏁 Script executed:

sed -n '1,100p' openwisp_notifications/tests/test_notifications.py

Repository: openwisp/openwisp-notifications

Length of output: 3707


🏁 Script executed:

rg -n "batched_notifications\|batch.*email" openwisp_notifications/tests/test_notifications.py -B 3 -A 8 | head -150

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

rg -n "def test.*batch" openwisp_notifications/tests/test_notifications.py

Repository: openwisp/openwisp-notifications

Length of output: 689


🏁 Script executed:

sed -n '1500,1550p' openwisp_notifications/tests/test_notifications.py

Repository: openwisp/openwisp-notifications

Length of output: 2196


🏁 Script executed:

sed -n '1098,1135p' openwisp_notifications/tests/test_notifications.py

Repository: openwisp/openwisp-notifications

Length of output: 1867


🏁 Script executed:

sed -n '1136,1203p' openwisp_notifications/tests/test_notifications.py

Repository: openwisp/openwisp-notifications

Length of output: 3107


🏁 Script executed:

web_search
Django bulk_create PK population SQLite behavior

Repository: openwisp/openwisp-notifications

Length of output: 170


🌐 Web query:

Django bulk_create PK population SQLite backend

💡 Result:

With Django’s bulk_create(), primary keys (AutoField/BigAutoField) are only populated on the in-memory instances if the DB backend supports returning rows from a bulk insert (i.e., Django can use INSERT … RETURNING …). Django’s docs explicitly list SQLite 3.35+ as supported (along with PostgreSQL and MariaDB 10.5+); otherwise, the objects are inserted but their .pk stays None. [1]

For SQLite, PK population after bulk_create() therefore requires:

  • SQLite library ≥ 3.35 (the version your Python sqlite3 module is linked against), and
  • a Django version where this support exists (added in Django 4.0). [1]

Common reasons you still see pk=None on SQLite:

  • Your runtime SQLite is < 3.35 (very common on older OS/Python builds). Check:
    import sqlite3
    sqlite3.sqlite_version
  • You used ignore_conflicts=True, which disables PK-setting even on DBs that otherwise support it. [1]
  • You’re on a backend/path where can_return_rows_from_bulk_insert is false, in which case Django won’t set autoincrement PKs during bulk_create(). [2]

Workarounds if you can’t upgrade SQLite:

  • Don’t rely on returned PKs (query again), or
  • Use non-autoincrement PKs you set yourself (e.g., UUID primary keys) if you must have IDs immediately.

Sources: Django QuerySet bulk_create() docs (PK support + SQLite 3.35+ note, Django 4.0 change) [1]; Django ORM implementation notes on PK setting depending on can_return_rows_from_bulk_insert [2].

[1] (docs.djangoproject.com)
[2] (docs.djangoproject.com)

Citations:


🏁 Script executed:

rg -n "\.id.*None\|None.*\.id" openwisp_notifications/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

rg -n "class Notification" openwisp_notifications/ --type py -A 20 | head -60

Repository: openwisp/openwisp-notifications

Length of output: 4677


🏁 Script executed:

sed -n '183,250p' openwisp_notifications/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 2709


🏁 Script executed:

grep -n "populate_pk\|return_pk\|can_return_rows" openwisp_notifications/ -r

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

rg -n "ignore_conflicts" openwisp_notifications/ --type py

Repository: openwisp/openwisp-notifications

Length of output: 181


🏁 Script executed:

rg -n "post_save.*Notification\|receiver.*send_email" openwisp_notifications/handlers.py -B 2 -A 2

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

sed -n '165,181p' openwisp_notifications/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 758


🏁 Script executed:

rg -n "dispatch_uid.*send_email" openwisp_notifications/ --type py

Repository: openwisp/openwisp-notifications

Length of output: 195


🏁 Script executed:

rg -n "def send_email" openwisp_notifications/models.py -A 15 | head -40

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

rg -n "Django\|django" setup.py requirements.txt pyproject.toml | grep -i version

Repository: openwisp/openwisp-notifications

Length of output: 57


🏁 Script executed:

sed -n '100,150p' openwisp_notifications/handlers.py

Repository: openwisp/openwisp-notifications

Length of output: 1814


Ensure bulk_create() returns populated PKs before using notifications for email batching and WebSocket updates

Line 169's bulk_create() may not populate PKs depending on the database backend and Django version. SQLite requires version 3.35+ and Django 4.0+; older environments will return objects with id=None. Line 220 then appends instance.id to batched_notifications, and if id is None, this silently corrupts the batch tracking list. The WebSocket serialization at line 135 also depends on valid notification IDs.

Explicitly handle the case where PKs are unpopulated, either by:

  • Re-fetching notifications after insertion with values_list('id', flat=True) or similar, or
  • Confirming your deployment guarantees SQLite 3.35+ and Django 4.0+, then verify with a test assertion.
🧰 Tools
🪛 Ruff (0.14.14)

[warning] 174-174: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)

🤖 Prompt for AI Agents
In `@openwisp_notifications/handlers.py` around lines 169 - 174, bulk_create may
return objects without PKs on some DB/Django combos; update the post-create
logic in the block using Notification, notifications_to_create,
notification_list and recipients_list to ensure IDs exist before email batching
and WebSocket updates: after
Notification.objects.bulk_create(notifications_to_create) check whether returned
notification_list items have .id populated and if not re-query the DB for the
created rows (e.g. by filtering on unique fields you set on each Notification or
using created timestamps) to build a list of persisted Notification IDs, then
use that re-fetched list for send_email_notification,
Notification.invalidate_unread_cache, notification_map and any code that appends
instance.id to batched_notifications so you never rely on None IDs;
alternatively add a test/assertion to fail fast if PKs are missing when you
require DB guarantees.

ws_handlers.bulk_notification_update_handler(
recipients=recipients_list,
reload_widget=True,
notification_map=notification_map,
)
return notification_list


Expand Down
133 changes: 113 additions & 20 deletions openwisp_notifications/websockets/handlers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from asgiref.sync import async_to_sync
from channels import layers
from django.core.cache import cache
from django.db.models import Count, Q
from django.utils.timezone import now, timedelta

from openwisp_notifications.api.serializers import NotFound, NotificationListSerializer
from openwisp_notifications.utils import normalize_unread_count

from .. import settings as app_settings
Expand All @@ -12,6 +12,80 @@
Notification = load_model("Notification")


def bulk_check_notification_storm_and_unread_count(recipients):
if not recipients:
return {}
recipient_ids = [recipient.pk for recipient in recipients]
cached_storm_data = cache.get_many([f"ow-noti-storm-{pk}" for pk in recipient_ids])
results = {}
uncached_recipients = []
for recipient in recipients:
cache_key = f"ow-noti-storm-{recipient.pk}"
if cache_key in cached_storm_data:
results[recipient.pk] = [cached_storm_data[cache_key], None]
else:
uncached_recipients.append(recipient)
results[recipient.pk] = [False, None]
if uncached_recipients:
short_term_threshold = now() - timedelta(
seconds=app_settings.NOTIFICATION_STORM_PREVENTION["short_term_time_period"]
)
long_term_threshold = now() - timedelta(
seconds=app_settings.NOTIFICATION_STORM_PREVENTION["long_term_time_period"]
)
uncached_recipient_ids = [recipient.pk for recipient in uncached_recipients]
storm_and_unread_data = list(
Notification.objects.filter(recipient_id__in=uncached_recipient_ids)
.values("recipient_id")
.annotate(
short_term_count=Count(
"id", filter=Q(timestamp__gte=short_term_threshold)
),
long_term_count=Count(
"id", filter=Q(timestamp__gte=long_term_threshold)
),
unread_count=Count("id", filter=Q(unread=True)),
)
)
cache_updates = {}
for data in storm_and_unread_data:
recipient_id = data["recipient_id"]
in_storm = (
data["short_term_count"]
> app_settings.NOTIFICATION_STORM_PREVENTION[
"short_term_notification_count"
]
or data["long_term_count"]
> app_settings.NOTIFICATION_STORM_PREVENTION[
"long_term_notification_count"
]
)
results[recipient_id] = [in_storm, data["unread_count"]]
if in_storm:
cache_updates[f"ow-noti-storm-{recipient_id}"] = True
if cache_updates:
cache.set_many(cache_updates, timeout=60)
fetched_recipient_ids = {data["recipient_id"] for data in storm_and_unread_data}
for recipient in uncached_recipients:
if recipient.pk not in fetched_recipient_ids:
results[recipient.pk] = [False, 0]
if any(results[pk][1] is None for pk in results):
recipients_needing_unread = [pk for pk in results if results[pk][1] is None]
unread_data = (
Notification.objects.filter(
recipient_id__in=recipients_needing_unread, unread=True
)
.values("recipient_id")
.annotate(unread_count=Count("id"))
)
for data in unread_data:
results[data["recipient_id"]][1] = data["unread_count"]
for pk in recipients_needing_unread:
if results[pk][1] is None:
results[pk][1] = 0
return {pk: (storm, unread) for pk, (storm, unread) in results.items()}


def user_in_notification_storm(user):
"""
A user is affected by notifications storm if any of short term
Expand Down Expand Up @@ -52,23 +126,42 @@ def user_in_notification_storm(user):
return in_notification_storm


def notification_update_handler(reload_widget=False, notification=None, recipient=None):
def bulk_notification_update_handler(
recipients, reload_widget=False, notification_map=None
):
if not recipients:
return
channel_layer = layers.get_channel_layer()
try:
assert notification is not None
notification = NotificationListSerializer(notification).data
except (NotFound, AssertionError):
pass
async_to_sync(channel_layer.group_send)(
f"ow-notification-{recipient.pk}",
{
"type": "send.updates",
"reload_widget": reload_widget,
"notification": notification,
"recipient": str(recipient.pk),
"in_notification_storm": user_in_notification_storm(recipient),
"notification_count": normalize_unread_count(
recipient.notifications.unread().count()
),
},
)
bulk_data = bulk_check_notification_storm_and_unread_count(recipients)
for recipient in recipients:
in_storm, unread_count = bulk_data.get(recipient.pk, (False, 0))
serialized_notification = None
if notification_map and recipient in notification_map:
from openwisp_notifications.api.serializers import (
NotificationListSerializer,
)

try:
serialized_notification = NotificationListSerializer(
notification_map[recipient]
).data
except Exception:
pass
async_to_sync(channel_layer.group_send)(
f"ow-notification-{recipient.pk}",
{
"type": "send.updates",
"reload_widget": reload_widget,
"notification": serialized_notification,
"recipient": str(recipient.pk),
"in_notification_storm": in_storm,
"notification_count": normalize_unread_count(unread_count),
},
)


def notification_update_handler(reload_widget=False, notification=None, recipient=None):
if recipient is None:
return
notification_map = {recipient: notification} if notification else None
bulk_notification_update_handler([recipient], reload_widget, notification_map)
Loading