diff --git a/.dockerignore b/.dockerignore
index 1eb63a0d..fafd3396 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -8,6 +8,7 @@
!dejacode/
!dejacode_toolkit/
!dje/
+!internal_notifications/
!license_library/
!notification/
!organization/
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 7ea91f3f..bc8fd303 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,12 @@
Release notes
=============
+### Version 6.1.0
+
+**Upgrade prerequisite:** upgrade to DejaCode 6.0.x and run its migrations before upgrading to this version. The internal notifications app replaces the django-notifications-patched dependency and requires the 6.0.x database schema.
+
+This release removes unused columns from the notifications table: back up the database before upgrading, as a rollback to an earlier version requires a database restore.
+
### Version 6.0.1
* fix: relink cloned codebase resources to the cloned inventory #601 by @tdruez in https://github.com/aboutcode-org/dejacode/pull/602
diff --git a/MANIFEST.in b/MANIFEST.in
index aea848c4..579465d2 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -19,6 +19,7 @@ graft dejacode_toolkit
graft dje
graft docs
graft etc
+graft internal_notifications
graft license_library
graft notification
graft organization
diff --git a/component_catalog/tests/test_views.py b/component_catalog/tests/test_views.py
index 186b72de..a621b8bd 100644
--- a/component_catalog/tests/test_views.py
+++ b/component_catalog/tests/test_views.py
@@ -26,7 +26,6 @@
from django.utils.encoding import force_str
from guardian.shortcuts import assign_perm
-from notifications.models import Notification
from component_catalog.forms import ComponentAddToProductForm
from component_catalog.forms import ComponentForm
@@ -59,6 +58,7 @@
from dje.tests import create_superuser
from dje.tests import create_user
from dje.views import PaginationMixin
+from internal_notifications.models import Notification
from license_library.models import License
from license_library.models import LicenseAssignedTag
from license_library.models import LicenseTag
diff --git a/compose.dev.yml b/compose.dev.yml
index 835bc9e5..67508ed8 100644
--- a/compose.dev.yml
+++ b/compose.dev.yml
@@ -41,6 +41,7 @@ services:
- ./dejacode:/opt/dejacode/dejacode
- ./dejacode_toolkit:/opt/dejacode/dejacode_toolkit
- ./dje:/opt/dejacode/dje
+ - ./internal_notifications:/opt/dejacode/internal_notifications
- ./license_library:/opt/dejacode/license_library
- ./notification:/opt/dejacode/notification
- ./organization:/opt/dejacode/organization
diff --git a/dejacode/settings.py b/dejacode/settings.py
index fc8abda2..a6f2162b 100644
--- a/dejacode/settings.py
+++ b/dejacode/settings.py
@@ -329,7 +329,6 @@ def gettext_noop(s):
"crispy_bootstrap5",
"guardian",
"django_filters",
- "notifications",
"axes",
"django_otp",
"django_otp.plugins.otp_totp",
@@ -346,6 +345,7 @@ def gettext_noop(s):
"purldb",
"policy",
"notification",
+ "internal_notifications",
"vulnerabilities",
"vulnerabilities.triage",
]
diff --git a/dje/models.py b/dje/models.py
index 7c19488a..cbd3d7ab 100644
--- a/dje/models.py
+++ b/dje/models.py
@@ -46,11 +46,10 @@
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
-from notifications.models import Notification
-
from aboutcode.api_auth import AbstractAPIToken
from dje.fields import LastModifiedByField
from dje.tasks import send_mail_task
+from internal_notifications.models import Notification
logger = logging.getLogger("dje")
@@ -1881,7 +1880,7 @@ def serialize_hook(self, hook):
}
def send_internal_notification(self, verb, **data):
- """Similar to ``notify.send`` without relying on the Signal system."""
+ """Create an internal Notification sent by and to this user."""
return Notification.objects.create(
recipient=self,
actor=self,
diff --git a/dje/templates/navbar/navbar_header_right_menu.html b/dje/templates/navbar/navbar_header_right_menu.html
index 71593b16..4c6fb262 100644
--- a/dje/templates/navbar/navbar_header_right_menu.html
+++ b/dje/templates/navbar/navbar_header_right_menu.html
@@ -1,5 +1,4 @@
{% load i18n static %}
-{% load has_notification from notifications_tags %}
{% block right-nav %}
@@ -23,7 +22,7 @@
- {% if user|has_notification %}
+ {% if user.notifications.unread.exists %}
diff --git a/dje/templates/notifications/notice_content.html b/dje/templates/notifications/notice_content.html
index ee8bf051..7ece3351 100644
--- a/dje/templates/notifications/notice_content.html
+++ b/dje/templates/notifications/notice_content.html
@@ -9,12 +9,9 @@
{% elif action_object_content_type|slugify == 'package' %}
(object not found)
{% endif %}
- {% if notice.target %}
- of {{ notice.target }}
- {% endif %}
- {{ notice.timesince }} ago
+ {{ notice.timestamp|timesince }} ago
{% if notice.description %}
diff --git a/dje/tests/test_views.py b/dje/tests/test_views.py
index b1fd5c70..ec349e3b 100644
--- a/dje/tests/test_views.py
+++ b/dje/tests/test_views.py
@@ -20,7 +20,6 @@
from django.urls import reverse
from guardian.shortcuts import assign_perm
-from notifications.models import Notification
from dje.models import Dataspace
from dje.models import DataspaceConfiguration
@@ -30,6 +29,7 @@
from dje.tests import create
from dje.tests import create_superuser
from dje.tests import create_user
+from internal_notifications.models import Notification
Owner = apps.get_model("organization", "Owner")
Component = apps.get_model("component_catalog", "Component")
@@ -383,6 +383,49 @@ def test_mark_all_notifications_as_read_view(self):
self.assertRedirects(response, unread_url)
self.assertEqual(0, self.user.notifications.unread().count())
+ def test_notifications_list_views(self):
+ def get_verbs_by_model(grouped_notifications):
+ return {
+ content_type.model: [notice.verb for notice in notices]
+ for content_type, notices in grouped_notifications.items()
+ }
+
+ Notification.objects.create(
+ recipient=self.user,
+ actor=self.user,
+ verb="Read",
+ action_object=self.owner1,
+ unread=False,
+ )
+ Notification.objects.create(
+ recipient=self.user, actor=self.super_user, verb="Unread", action_object=self.owner1
+ )
+ Notification.objects.create(
+ recipient=self.user,
+ actor=self.super_user,
+ verb="Updated",
+ action_object=self.dataspace1,
+ )
+ Notification.objects.create(
+ recipient=self.super_user, actor=self.user, verb="Other", action_object=self.owner1
+ )
+ unread_url = reverse("notifications:unread")
+ all_url = reverse("notifications:all")
+
+ response = self.client.get(unread_url)
+ self.assertRedirects(response, f"{reverse('login')}?next={unread_url}")
+
+ self.client.login(username=self.user.username, password="secret")
+ response = self.client.get(unread_url)
+ expected = {"dataspace": ["Updated"], "owner": ["Unread"]}
+ self.assertEqual(expected, get_verbs_by_model(response.context["grouped_notifications"]))
+ self.assertEqual(3, response.context["all_count"])
+ self.assertEqual(2, response.context["unread_count"])
+
+ response = self.client.get(all_url)
+ expected = {"dataspace": ["Updated"], "owner": ["Unread", "Read"]}
+ self.assertEqual(expected, get_verbs_by_model(response.context["grouped_notifications"]))
+
@override_settings(REFERENCE_DATASPACE="Dataspace")
def test_global_search_list_view(self):
self.client.login(username=self.user.username, password="secret")
diff --git a/dje/views.py b/dje/views.py
index 27872e82..408469e3 100644
--- a/dje/views.py
+++ b/dje/views.py
@@ -74,7 +74,6 @@
from drf_yasg.views import get_schema_view
from grappelli.views.related import AutocompleteLookup
from grappelli.views.related import RelatedLookup
-from notifications import views as notifications_views
from aboutcode.api_auth.views import BaseGenerateAPIKeyView
from aboutcode.api_auth.views import BaseRevokeAPIKeyView
@@ -2290,7 +2289,9 @@ def get_context_data(self, **kwargs):
return context_data
-class NotificationsCountMixin:
+class NotificationsListView(LoginRequiredMixin, ListView):
+ template_name = "notifications/list.html"
+
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user_notifications = self.request.user.notifications
@@ -2306,24 +2307,19 @@ def get_context_data(self, **kwargs):
return context
-class UnreadNotificationsList(
- NotificationsCountMixin,
- notifications_views.UnreadNotificationsList,
-):
- pass
+class UnreadNotificationsList(NotificationsListView):
+ def get_queryset(self):
+ return self.request.user.notifications.unread()
-class AllNotificationsList(
- NotificationsCountMixin,
- notifications_views.AllNotificationsList,
-):
- pass
+class AllNotificationsList(NotificationsListView):
+ def get_queryset(self):
+ return self.request.user.notifications.all()
@login_required
@require_POST
def mark_all_notifications_as_read_view(request):
- """Replace the `notifications` library view, which marks as read on GET requests."""
request.user.notifications.mark_all_as_read()
return redirect("notifications:unread")
diff --git a/etc/scripts/build_deb_docker.py b/etc/scripts/build_deb_docker.py
index 1ca54f6d..34fcf1b7 100644
--- a/etc/scripts/build_deb_docker.py
+++ b/etc/scripts/build_deb_docker.py
@@ -58,10 +58,6 @@ def build_deb_with_docker():
# Get all dependencies
dependencies = project.get("dependencies", [])
- filtered_dependencies = [
- dep for dep in dependencies if "django_notifications_patched" not in dep
- ]
-
docker_cmd = [
"docker",
"run",
@@ -95,11 +91,8 @@ def build_deb_with_docker():
# Clean previous build artifacts
rm -rf build/
- # Install non-PyPI dependencies
- pip install https://github.com/dejacode/django-notifications-patched/archive/refs/tags/2.0.0.tar.gz
-
# Install dependencies directly
- {" && ".join([f'pip install "{dep}"' for dep in filtered_dependencies])}
+ {" && ".join([f'pip install "{dep}"' for dep in dependencies])}
# Install build tool
pip install build
diff --git a/etc/scripts/build_nix_docker.py b/etc/scripts/build_nix_docker.py
index 76cab467..e4c01d3d 100644
--- a/etc/scripts/build_nix_docker.py
+++ b/etc/scripts/build_nix_docker.py
@@ -242,76 +242,55 @@ def create_defualt_nix(dependencies_list, meta_dict):
print("Processing {}/{}: {}".format(idx + 1, deps_size, dep["name"]))
name = dep["name"]
version = dep["version"]
- # Handle 'django_notifications_patched' seperately
- if name == "django_notifications_patched":
- if name == "django_notifications_patched" and version == "2.0.0":
- nix_content += " " + name + " = self.buildPythonPackage rec {\n"
- nix_content += ' pname = "django_notifications_patched";\n'
- nix_content += ' version = "2.0.0";\n'
- nix_content += ' format = "setuptools";\n'
- nix_content += " doCheck = false;\n"
- nix_content += " src = pkgs.fetchFromGitHub {\n"
- nix_content += ' owner = "dejacode";\n'
- nix_content += ' repo = "django-notifications-patched";\n'
- nix_content += ' rev = "2.0.0";\n'
- nix_content += ' url = "https://github.com/dejacode/django-notifications-patched/archive/refs/tags/2.0.0.tar.gz";\n'
- nix_content += (
- ' sha256 = "sha256-RDAp2PKWa2xA5ge25VqkmRm8HCYVS4/fq2xKc80LDX8=";\n'
- )
- nix_content += " };\n"
- nix_content += " };\n"
- else:
- need_review_packages_list.append(dep)
- else:
- url = "https://pypi.org/pypi/{name}/{version}/json".format(name=name, version=version)
- try:
- response = requests.get(url, timeout=30)
- response.raise_for_status()
- data = response.json()
-
- url_section = data.get("urls", [])
- build_from_src = True
- package_added = False
-
+ url = "https://pypi.org/pypi/{name}/{version}/json".format(name=name, version=version)
+ try:
+ response = requests.get(url, timeout=30)
+ response.raise_for_status()
+ data = response.json()
+
+ url_section = data.get("urls", [])
+ build_from_src = True
+ package_added = False
+
+ for component in url_section:
+ if component.get("packagetype") == "bdist_wheel":
+ whl_url = component.get("url")
+ if not is_compatible_wheel(whl_url, py_version_major, py_version_minor):
+ continue
+ whl_sha256 = get_sha256_hash(whl_url)
+ nix_content += " " + name + " = buildCustomPackage {\n"
+ nix_content += ' pname = "' + name + '";\n'
+ nix_content += ' version = "' + version + '";\n'
+ nix_content += ' format = "wheel";\n'
+ nix_content += " src = pkgs.fetchurl {\n"
+ nix_content += ' url = "' + whl_url + '";\n'
+ nix_content += ' sha256 = "' + whl_sha256 + '";\n'
+ nix_content += " };\n"
+ nix_content += " };\n"
+ build_from_src = False
+ package_added = True
+ break
+
+ if build_from_src:
for component in url_section:
- if component.get("packagetype") == "bdist_wheel":
- whl_url = component.get("url")
- if not is_compatible_wheel(whl_url, py_version_major, py_version_minor):
- continue
- whl_sha256 = get_sha256_hash(whl_url)
+ if component.get("packagetype") == "sdist":
+ sdist_url = component.get("url")
+ sdist_sha256 = get_sha256_hash(sdist_url)
nix_content += " " + name + " = buildCustomPackage {\n"
nix_content += ' pname = "' + name + '";\n'
nix_content += ' version = "' + version + '";\n'
- nix_content += ' format = "wheel";\n'
+ nix_content += ' format = "setuptools";\n'
nix_content += " src = pkgs.fetchurl {\n"
- nix_content += ' url = "' + whl_url + '";\n'
- nix_content += ' sha256 = "' + whl_sha256 + '";\n'
+ nix_content += ' url = "' + sdist_url + '";\n'
+ nix_content += ' sha256 = "' + sdist_sha256 + '";\n'
nix_content += " };\n"
nix_content += " };\n"
- build_from_src = False
package_added = True
break
-
- if build_from_src:
- for component in url_section:
- if component.get("packagetype") == "sdist":
- sdist_url = component.get("url")
- sdist_sha256 = get_sha256_hash(sdist_url)
- nix_content += " " + name + " = buildCustomPackage {\n"
- nix_content += ' pname = "' + name + '";\n'
- nix_content += ' version = "' + version + '";\n'
- nix_content += ' format = "setuptools";\n'
- nix_content += " src = pkgs.fetchurl {\n"
- nix_content += ' url = "' + sdist_url + '";\n'
- nix_content += ' sha256 = "' + sdist_sha256 + '";\n'
- nix_content += " };\n"
- nix_content += " };\n"
- package_added = True
- break
- if not package_added:
- need_review_packages_list.append(dep)
- except requests.exceptions.RequestException:
+ if not package_added:
need_review_packages_list.append(dep)
+ except requests.exceptions.RequestException:
+ need_review_packages_list.append(dep)
nix_content += """
};
diff --git a/etc/scripts/build_rpm_docker.py b/etc/scripts/build_rpm_docker.py
index b264dc78..913efc7f 100644
--- a/etc/scripts/build_rpm_docker.py
+++ b/etc/scripts/build_rpm_docker.py
@@ -55,15 +55,11 @@ def build_rpm_with_docker():
rpm_name = f"python3-{pkg_name.lower()}"
rpm_version = project["version"].replace("-dev", "~dev")
- # Generate requirements for RPM - exclude packages installed from GitHub
+ # Generate requirements for RPM
dependencies = project["dependencies"]
- filtered_dependencies = [
- dep for dep in dependencies if "django-notifications-patched" not in dep
- ]
-
# Create a requirements.txt content for installation
- requirements_content = "\n".join(filtered_dependencies)
+ requirements_content = "\n".join(dependencies)
docker_cmd = [
"docker",
@@ -186,10 +182,6 @@ def build_rpm_with_docker():
# Install system dependencies for psycopg
dnf install -y postgresql-devel
-# Install non-PyPI dependencies
-/tmp/venv_build/bin/python -m pip install \\
- https://github.com/dejacode/django-notifications-patched/archive/refs/tags/2.0.0.tar.gz
-
# Install the main package
/tmp/venv_build/bin/python -m pip install -r requirements.txt
diff --git a/internal_notifications/__init__.py b/internal_notifications/__init__.py
new file mode 100644
index 00000000..f61c4aae
--- /dev/null
+++ b/internal_notifications/__init__.py
@@ -0,0 +1,7 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
diff --git a/internal_notifications/apps.py b/internal_notifications/apps.py
new file mode 100644
index 00000000..f0cf496c
--- /dev/null
+++ b/internal_notifications/apps.py
@@ -0,0 +1,18 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.apps import AppConfig
+from django.utils.translation import gettext_lazy as _
+
+
+class InternalNotificationsConfig(AppConfig):
+ name = "internal_notifications"
+ # Label of the former django-notifications-patched app: keeps the table,
+ # the migration history, and the content types unchanged.
+ label = "notifications"
+ verbose_name = _("Notifications")
diff --git a/internal_notifications/migrations/0001_squashed_0009_alter_notification_options_and_more.py b/internal_notifications/migrations/0001_squashed_0009_alter_notification_options_and_more.py
new file mode 100644
index 00000000..4e2cff9e
--- /dev/null
+++ b/internal_notifications/migrations/0001_squashed_0009_alter_notification_options_and_more.py
@@ -0,0 +1,157 @@
+# Replaces the migrations of the former django-notifications-patched package,
+# so existing databases keep their table and data unchanged.
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.conf import settings
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+ replaces = [
+ ("notifications", "0001_initial"),
+ ("notifications", "0002_auto_20150224_1134"),
+ ("notifications", "0003_notification_data"),
+ ("notifications", "0004_auto_20150826_1508"),
+ ("notifications", "0005_auto_20160504_1520"),
+ ("notifications", "0006_indexes"),
+ ("notifications", "0007_add_timestamp_index"),
+ ("notifications", "0008_index_together_recipient_unread"),
+ ("notifications", "0009_alter_notification_options_and_more"),
+ ]
+
+ initial = True
+
+ dependencies = [
+ ("contenttypes", "0002_remove_content_type_name"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Notification",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+ ),
+ ),
+ (
+ "level",
+ models.CharField(
+ choices=[
+ ("success", "success"),
+ ("info", "info"),
+ ("warning", "warning"),
+ ("error", "error"),
+ ],
+ default="info",
+ max_length=20,
+ verbose_name="level",
+ ),
+ ),
+ (
+ "unread",
+ models.BooleanField(db_index=True, default=True, verbose_name="unread"),
+ ),
+ (
+ "actor_object_id",
+ models.CharField(max_length=255, verbose_name="actor object id"),
+ ),
+ ("verb", models.CharField(max_length=255, verbose_name="verb")),
+ (
+ "description",
+ models.TextField(blank=True, null=True, verbose_name="description"),
+ ),
+ (
+ "target_object_id",
+ models.CharField(
+ blank=True, max_length=255, null=True, verbose_name="target object id"
+ ),
+ ),
+ (
+ "action_object_object_id",
+ models.CharField(
+ blank=True,
+ max_length=255,
+ null=True,
+ verbose_name="action object object id",
+ ),
+ ),
+ (
+ "timestamp",
+ models.DateTimeField(
+ db_index=True,
+ default=django.utils.timezone.now,
+ verbose_name="timestamp",
+ ),
+ ),
+ (
+ "public",
+ models.BooleanField(db_index=True, default=True, verbose_name="public"),
+ ),
+ (
+ "deleted",
+ models.BooleanField(db_index=True, default=False, verbose_name="deleted"),
+ ),
+ (
+ "emailed",
+ models.BooleanField(db_index=True, default=False, verbose_name="emailed"),
+ ),
+ # Text column, as created by the former jsonfield.JSONField
+ ("data", models.TextField(blank=True, null=True, verbose_name="data")),
+ (
+ "action_object_content_type",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notify_action_object",
+ to="contenttypes.contenttype",
+ verbose_name="action object content type",
+ ),
+ ),
+ (
+ "actor_content_type",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notify_actor",
+ to="contenttypes.contenttype",
+ verbose_name="actor content type",
+ ),
+ ),
+ (
+ "recipient",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notifications",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="recipient",
+ ),
+ ),
+ (
+ "target_content_type",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notify_target",
+ to="contenttypes.contenttype",
+ verbose_name="target content type",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Notification",
+ "verbose_name_plural": "Notifications",
+ "ordering": ("-timestamp",),
+ "indexes": [
+ models.Index(
+ fields=["recipient", "unread"], name="notificatio_recipie_8bedf2_idx"
+ )
+ ],
+ },
+ ),
+ ]
diff --git a/internal_notifications/migrations/0002_remove_unused_fields.py b/internal_notifications/migrations/0002_remove_unused_fields.py
new file mode 100644
index 00000000..374e0b4c
--- /dev/null
+++ b/internal_notifications/migrations/0002_remove_unused_fields.py
@@ -0,0 +1,38 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("notifications", "0001_squashed_0009_alter_notification_options_and_more"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="notification",
+ name="level",
+ ),
+ migrations.RemoveField(
+ model_name="notification",
+ name="target_content_type",
+ ),
+ migrations.RemoveField(
+ model_name="notification",
+ name="target_object_id",
+ ),
+ migrations.RemoveField(
+ model_name="notification",
+ name="public",
+ ),
+ migrations.RemoveField(
+ model_name="notification",
+ name="deleted",
+ ),
+ migrations.RemoveField(
+ model_name="notification",
+ name="emailed",
+ ),
+ migrations.RemoveField(
+ model_name="notification",
+ name="data",
+ ),
+ ]
diff --git a/internal_notifications/migrations/__init__.py b/internal_notifications/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/internal_notifications/models.py b/internal_notifications/models.py
new file mode 100644
index 00000000..ce2e21a8
--- /dev/null
+++ b/internal_notifications/models.py
@@ -0,0 +1,78 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+
+
+class NotificationQuerySet(models.QuerySet):
+ def unread(self):
+ return self.filter(unread=True)
+
+ def mark_all_as_read(self):
+ return self.unread().update(unread=False)
+
+ def notify(self, recipients, actor, verb, **fields):
+ """Create a Notification from the `actor` for each of the `recipients`."""
+ notifications = [
+ self.model(recipient=recipient, actor=actor, verb=verb, **fields)
+ for recipient in recipients
+ ]
+ return self.bulk_create(notifications)
+
+
+class Notification(models.Model):
+ """An internal notification: `actor` `verb` `action_object`, sent to a `recipient`."""
+
+ recipient = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="notifications",
+ verbose_name=_("recipient"),
+ )
+ unread = models.BooleanField(_("unread"), default=True, db_index=True)
+ actor_content_type = models.ForeignKey(
+ ContentType,
+ on_delete=models.CASCADE,
+ related_name="notify_actor",
+ verbose_name=_("actor content type"),
+ )
+ actor_object_id = models.CharField(_("actor object id"), max_length=255)
+ actor = GenericForeignKey("actor_content_type", "actor_object_id")
+ verb = models.CharField(_("verb"), max_length=255)
+ description = models.TextField(_("description"), blank=True, null=True)
+ action_object_content_type = models.ForeignKey(
+ ContentType,
+ on_delete=models.CASCADE,
+ related_name="notify_action_object",
+ verbose_name=_("action object content type"),
+ blank=True,
+ null=True,
+ )
+ action_object_object_id = models.CharField(
+ _("action object object id"), max_length=255, blank=True, null=True
+ )
+ action_object = GenericForeignKey("action_object_content_type", "action_object_object_id")
+ timestamp = models.DateTimeField(_("timestamp"), default=timezone.now, db_index=True)
+
+ objects = NotificationQuerySet.as_manager()
+
+ class Meta:
+ ordering = ("-timestamp",)
+ indexes = [
+ models.Index(fields=["recipient", "unread"]),
+ ]
+ verbose_name = _("Notification")
+ verbose_name_plural = _("Notifications")
+
+ def __str__(self):
+ return f"{self.actor} {self.verb}"
diff --git a/internal_notifications/tests/__init__.py b/internal_notifications/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/internal_notifications/tests/test_models.py b/internal_notifications/tests/test_models.py
new file mode 100644
index 00000000..3508d4a4
--- /dev/null
+++ b/internal_notifications/tests/test_models.py
@@ -0,0 +1,65 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.test import TestCase
+
+from dje.models import Dataspace
+from dje.tests import create_user
+from internal_notifications.models import Notification
+
+
+class NotificationModelTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.actor = create_user("actor", self.dataspace)
+ self.recipient1 = create_user("recipient1", self.dataspace)
+ self.recipient2 = create_user("recipient2", self.dataspace)
+
+ def test_notification_model_str(self):
+ notification = Notification.objects.create(
+ recipient=self.recipient1, actor=self.actor, verb="commented"
+ )
+ self.assertEqual("actor commented", str(notification))
+
+ def test_notification_queryset_unread_and_mark_all_as_read(self):
+ unread = Notification.objects.create(
+ recipient=self.recipient1, actor=self.actor, verb="unread"
+ )
+ Notification.objects.create(
+ recipient=self.recipient1, actor=self.actor, verb="read", unread=False
+ )
+ other_recipient = Notification.objects.create(
+ recipient=self.recipient2, actor=self.actor, verb="other"
+ )
+
+ self.assertEqual([unread], list(self.recipient1.notifications.unread()))
+
+ self.assertEqual(1, self.recipient1.notifications.mark_all_as_read())
+ self.assertEqual(0, self.recipient1.notifications.unread().count())
+ other_recipient.refresh_from_db()
+ self.assertTrue(other_recipient.unread)
+
+ def test_notification_queryset_notify(self):
+ notifications = Notification.objects.notify(
+ recipients=[self.recipient1, self.recipient2],
+ actor=self.actor,
+ verb="updated",
+ action_object=self.dataspace,
+ description="details",
+ )
+ self.assertEqual(2, len(notifications))
+
+ for recipient in [self.recipient1, self.recipient2]:
+ notification = recipient.notifications.get()
+ self.assertEqual(self.actor, notification.actor)
+ self.assertEqual("updated", notification.verb)
+ self.assertEqual(self.dataspace, notification.action_object)
+ self.assertEqual("details", notification.description)
+ self.assertTrue(notification.unread)
+
+ self.assertEqual([], Notification.objects.notify(recipients=[], actor=self.actor, verb="x"))
diff --git a/product_portfolio/tests/test_tasks.py b/product_portfolio/tests/test_tasks.py
index beb1e59e..c528f373 100644
--- a/product_portfolio/tests/test_tasks.py
+++ b/product_portfolio/tests/test_tasks.py
@@ -14,12 +14,12 @@
from django.test import TestCase
from guardian.shortcuts import assign_perm
-from notifications.models import Notification
from dje.models import Dataspace
from dje.tests import MaxQueryMixin
from dje.tests import create_superuser
from dje.tests import create_user
+from internal_notifications.models import Notification
from product_portfolio.models import ScanCodeProject
from product_portfolio.tasks import improve_packages_from_purldb_task
from product_portfolio.tasks import logger as tasks_logger
diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py
index 26214ca1..11e653e7 100644
--- a/product_portfolio/tests/test_views.py
+++ b/product_portfolio/tests/test_views.py
@@ -24,7 +24,6 @@
from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_user_perms
-from notifications.models import Notification
from component_catalog.models import Component
from component_catalog.models import ComponentAssignedPackage
@@ -40,6 +39,7 @@
from dje.tests import add_perms
from dje.tests import create_superuser
from dje.tests import create_user
+from internal_notifications.models import Notification
from license_library.models import License
from license_library.tests import make_license
from organization.models import Owner
diff --git a/pyproject.toml b/pyproject.toml
index 29473a81..bd84a899 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -103,10 +103,6 @@ dependencies = [
# license expressions
"boolean.py==5.0",
"license-expression==30.4.4",
- # django-notifications
- "django_notifications_patched==2.0.0",
- "jsonfield==3.2.0",
- "swapper==1.5.0",
# YAML
"saneyaml==0.6.1",
# Spreadsheet
diff --git a/thirdparty/dist/django-notifications-patched-2.0.0.tar.gz b/thirdparty/dist/django-notifications-patched-2.0.0.tar.gz
deleted file mode 100644
index 0463865f..00000000
Binary files a/thirdparty/dist/django-notifications-patched-2.0.0.tar.gz and /dev/null differ
diff --git a/thirdparty/dist/jsonfield-3.2.0-py3-none-any.whl b/thirdparty/dist/jsonfield-3.2.0-py3-none-any.whl
deleted file mode 100644
index 77fee8c2..00000000
Binary files a/thirdparty/dist/jsonfield-3.2.0-py3-none-any.whl and /dev/null differ
diff --git a/thirdparty/dist/jsonfield-3.2.0-py3-none-any.whl.ABOUT b/thirdparty/dist/jsonfield-3.2.0-py3-none-any.whl.ABOUT
deleted file mode 100644
index 850d8df0..00000000
--- a/thirdparty/dist/jsonfield-3.2.0-py3-none-any.whl.ABOUT
+++ /dev/null
@@ -1,13 +0,0 @@
-about_resource: jsonfield-3.2.0-py3-none-any.whl
-name: jsonfield
-version: 3.2.0
-download_url: https://files.pythonhosted.org/packages/0a/22/2e08e7b957f50e5eceefde018ce9ee88aceb5126231128d9c1cb8167c1c8/jsonfield-3.2.0-py3-none-any.whl
-package_url: pkg:pypi/jsonfield@3.2.0
-license_expression: unknown-license-reference
-copyright: Copyright jsonfield project contributors
-checksum_md5: f96e7738372863025ae93a4f7bc4988c
-checksum_sha1: 3efe9e6222051aa4d2ebde604e96c2ef761e06d5
-licenses:
- - key: unknown-license-reference
- name: Unknown License file reference
- file: unknown-license-reference.LICENSE
diff --git a/thirdparty/dist/swapper-1.5.0-py2.py3-none-any.whl b/thirdparty/dist/swapper-1.5.0-py2.py3-none-any.whl
deleted file mode 100644
index 7ff35439..00000000
Binary files a/thirdparty/dist/swapper-1.5.0-py2.py3-none-any.whl and /dev/null differ
diff --git a/uv.lock b/uv.lock
index f780282f..2d7812e1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -163,7 +163,6 @@ dependencies = [
{ name = "django-filter" },
{ name = "django-grappelli" },
{ name = "django-guardian" },
- { name = "django-notifications-patched" },
{ name = "django-otp" },
{ name = "django-registration" },
{ name = "django-rq" },
@@ -174,7 +173,6 @@ dependencies = [
{ name = "gunicorn" },
{ name = "idna" },
{ name = "inflection" },
- { name = "jsonfield" },
{ name = "license-expression" },
{ name = "lxml" },
{ name = "markdown" },
@@ -203,7 +201,6 @@ dependencies = [
{ name = "six" },
{ name = "sortedcontainers" },
{ name = "sqlparse" },
- { name = "swapper" },
{ name = "tblib" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
@@ -243,7 +240,6 @@ requires-dist = [
{ name = "django-filter", specifier = "==26.2" },
{ name = "django-grappelli", specifier = "==5.0.1" },
{ name = "django-guardian", specifier = "==3.5.2" },
- { name = "django-notifications-patched", specifier = "==2.0.0" },
{ name = "django-otp", specifier = "==1.7.5" },
{ name = "django-registration", specifier = "==5.2.1" },
{ name = "django-rq", specifier = "==4.2.0" },
@@ -254,7 +250,6 @@ requires-dist = [
{ name = "gunicorn", specifier = "==26.2.0" },
{ name = "idna", specifier = "==3.20" },
{ name = "inflection", specifier = "==0.5.1" },
- { name = "jsonfield", specifier = "==3.2.0" },
{ name = "license-expression", specifier = "==30.4.4" },
{ name = "lxml", specifier = "==6.1.3" },
{ name = "markdown", specifier = "==3.11.2" },
@@ -284,7 +279,6 @@ requires-dist = [
{ name = "six", specifier = "==1.17.0" },
{ name = "sortedcontainers", specifier = "==2.4.0" },
{ name = "sqlparse", specifier = "==0.6.0" },
- { name = "swapper", specifier = "==1.5.0" },
{ name = "tblib", specifier = "==3.2.2" },
{ name = "typing-extensions", specifier = "==4.16.0" },
{ name = "typing-inspection", specifier = "==0.4.4" },
@@ -404,17 +398,6 @@ wheels = [
{ path = "django_guardian-3.5.2-py3-none-any.whl" },
]
-[[package]]
-name = "django-notifications-patched"
-version = "2.0.0"
-source = { registry = "thirdparty/dist" }
-dependencies = [
- { name = "django" },
- { name = "jsonfield" },
- { name = "swapper" },
-]
-sdist = { path = "django-notifications-patched-2.0.0.tar.gz" }
-
[[package]]
name = "django-otp"
version = "1.7.5"
@@ -523,17 +506,6 @@ wheels = [
{ path = "inflection-0.5.1-py2.py3-none-any.whl" },
]
-[[package]]
-name = "jsonfield"
-version = "3.2.0"
-source = { registry = "thirdparty/dist" }
-dependencies = [
- { name = "django" },
-]
-wheels = [
- { path = "jsonfield-3.2.0-py3-none-any.whl" },
-]
-
[[package]]
name = "license-expression"
version = "30.4.4"
@@ -820,14 +792,6 @@ wheels = [
{ path = "sqlparse-0.6.0-py3-none-any.whl" },
]
-[[package]]
-name = "swapper"
-version = "1.5.0"
-source = { registry = "thirdparty/dist" }
-wheels = [
- { path = "swapper-1.5.0-py2.py3-none-any.whl" },
-]
-
[[package]]
name = "tblib"
version = "3.2.2"
diff --git a/vulnerabilities/tests/test_fetch.py b/vulnerabilities/tests/test_fetch.py
index a67acec3..6bfc36cc 100644
--- a/vulnerabilities/tests/test_fetch.py
+++ b/vulnerabilities/tests/test_fetch.py
@@ -14,12 +14,11 @@
from django.test import TestCase
-from notifications.models import Notification
-
from component_catalog.models import Package
from component_catalog.tests import make_package
from dje.models import Dataspace
from dje.tests import create_user
+from internal_notifications.models import Notification
from product_portfolio.tests import make_product
from product_portfolio.tests import make_product_item_purpose
from product_portfolio.tests import make_product_package
diff --git a/workflow/notification.py b/workflow/notification.py
index 0877da00..92fe15e1 100644
--- a/workflow/notification.py
+++ b/workflow/notification.py
@@ -9,9 +9,8 @@
from django.conf import settings
from django.template.loader import render_to_string
-from notifications.signals import notify as internal_notification
-
from dje.tasks import send_mail_task
+from internal_notifications.models import Notification
# WARNING: Using the name `req` in place of `request` for Request instance,
# to avoid any conflict, especially in template contexts,
@@ -59,11 +58,11 @@ def send_request_notification(http_request, req, created, extra=None):
# Remove the `action_user` from the internal notification recipients
recipients.discard(action_user)
- internal_notification.send(
- sender=action_user,
+ Notification.objects.notify(
+ recipients=recipients,
+ actor=action_user,
verb="{} Request".format("submitted" if created else "updated"),
action_object=req,
- recipient=list(recipients),
description=extra.get("description") if extra else "",
)
@@ -100,11 +99,11 @@ def send_request_comment_notification(http_request, comment, closed=False):
emails = get_recipient_emails(recipients, req.cc_emails)
send_mail_task.delay(subject, body, settings.DEFAULT_FROM_EMAIL, emails)
- internal_notification.send(
- sender=comment.user,
+ Notification.objects.notify(
+ recipients=recipients,
+ actor=comment.user,
verb="commented on Request",
action_object=req,
- recipient=list(recipients),
description=comment.text,
)
diff --git a/workflow/tests/test_views.py b/workflow/tests/test_views.py
index d2875ee9..5a5b616b 100644
--- a/workflow/tests/test_views.py
+++ b/workflow/tests/test_views.py
@@ -19,7 +19,6 @@
from django.urls import reverse
from guardian.shortcuts import assign_perm
-from notifications.models import Notification
from component_catalog.models import Component
from component_catalog.models import Package
@@ -30,6 +29,7 @@
from dje.tests import add_perm
from dje.tests import create_superuser
from dje.tests import create_user
+from internal_notifications.models import Notification
from product_portfolio.models import ProductComponent
from product_portfolio.models import ProductPackage
from product_portfolio.models import ProductRelationStatus