Closes #15621: User notifications (#16800)

* Initial work on #15621

* Signal receiver should ignore models which don't support notifications

* Flesh out NotificationGroup functionality

* Add NotificationGroup filters for users & groups

* Separate read & dimiss actions

* Enable one-click dismissals from notifications list

* Include total notification count in dropdown

* Drop 'kind' field from Notification model

* Register event types in the registry; add colors & icons

* Enable event rules to target notification groups

* Define dynamic choices for Notification.event_name

* Move event registration to core

* Add more job events

* Misc cleanup

* Misc cleanup

* Correct absolute URLs for notifications & subscriptions

* Optimize subscriber notifications

* Use core event types when queuing events

* Standardize queued event attribute to event_type; change content_type to object_type

* Rename Notification.event_name to event_type

* Restore NotificationGroupBulkEditView

* Add API tests

* Add view & filterset tests

* Add model documentation

* Fix tests

* Update notification bell when notifications have been cleared

* Ensure subscribe button appears only on relevant models

* Notifications/subscriptions cannot be ordered by object

* Misc cleanup

* Add event icon & type to notifications table

* Adjust icon sizing

* Mute color of read notifications

* Misc cleanup
This commit is contained in:
Jeremy Stretch
2024-07-15 14:24:11 -04:00
committed by GitHub
parent 1c2336be60
commit b0e7294bc1
59 changed files with 1913 additions and 90 deletions

View File

@@ -9,6 +9,12 @@
<li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'account:bookmarks' %}">{% trans "Bookmarks" %}</a>
</li>
<li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'notifications' %} active{% endif %}" href="{% url 'account:notifications' %}">{% trans "Notifications" %}</a>
</li>
<li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'subscriptions' %} active{% endif %}" href="{% url 'account:subscriptions' %}">{% trans "Subscriptions" %}</a>
</li>
<li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'account:preferences' %}">{% trans "Preferences" %}</a>
</li>

View File

@@ -0,0 +1,32 @@
{% extends 'account/base.html' %}
{% load buttons %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block title %}{% trans "Notifications" %}{% endblock %}
{% block content %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% url 'account:notifications' %}" />
{# Table #}
<div class="row">
<div class="col col-md-12">
<div class="card">
<div class="htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</div>
</div>
{# Form buttons #}
<div class="btn-list d-print-none mt-2">
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends 'account/base.html' %}
{% load buttons %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block title %}{% trans "Subscriptions" %}{% endblock %}
{% block content %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% url 'account:subscriptions' %}" />
{# Table #}
<div class="row">
<div class="col col-md-12">
<div class="card">
<div class="htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</div>
</div>
{# Form buttons #}
<div class="btn-list d-print-none mt-2">
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Notification Group" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>
{{ object.name }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>
{{ object.description|placeholder }}
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Groups" %}</h5>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{{ group.get_absolute_url }}" class="list-group-item list-group-item-action">{{ group }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
{% endfor %}
</div>
</div>
<div class="card">
<h5 class="card-header">{% trans "Users" %}</h5>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{{ user.get_absolute_url }}" class="list-group-item list-group-item-action">{{ user }}</a>
{% empty %}
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
{% endfor %}
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -77,6 +77,9 @@ Context:
{% if perms.extras.add_bookmark and object.bookmarks %}
{% bookmark_button object %}
{% endif %}
{% if perms.extras.add_subscription and object.subscriptions %}
{% subscribe_button object %}
{% endif %}
{% if request.user|can_add:object %}
{% clone_button object %}
{% endif %}

View File

@@ -0,0 +1,33 @@
{% load i18n %}
<div class="list-group list-group-flush list-group-hoverable" style="min-width: 300px">
{% for notification in notifications %}
<div class="list-group-item p-2">
<div class="row align-items-center">
<div class="col-auto text-{{ notification.event.color }} fs-2 pe-0">
<i class="{{ notification.event.icon }}"></i>
</div>
<div class="col text-truncate">
<a href="{% url 'extras:notification_read' pk=notification.pk %}" class="text-body d-block">{{ notification.object }}</a>
<div class="d-block text-secondary fs-5">{{ notification.event }} {{ notification.created|timesince }} {% trans "ago" %}</div>
</div>
<div class="col-auto">
<a href="#" hx-get="{% url 'extras:notification_dismiss' pk=notification.pk %}" hx-target="closest .notifications" class="list-group-item-actions text-secondary" title="{% trans "Dismiss" %}">
<i class="mdi mdi-close"></i>
</a>
</div>
</div>
</div>
{% empty %}
<div class="dropdown-item text-muted">
{% trans "No unread notifications" %}
</div>
{% endfor %}
{% if total_count %}
<a href="{% url 'account:notifications' %}" class="list-group-item list-group-item-action d-flex justify-content-between p-2">
{% trans "All notifications" %}
{% badge total_count %}
</a>
{% endif %}
</div>
{% include 'inc/notification_bell.html' %}

View File

@@ -0,0 +1,9 @@
{% if notifications %}
<span class="text-primary" id="notifications-alert" hx-swap-oob="true">
<i class="mdi mdi-bell-badge"></i>
</span>
{% else %}
<span class="text-muted" id="notifications-alert" hx-swap-oob="true">
<i class="mdi mdi-bell"></i>
</span>
{% endif %}

View File

@@ -2,6 +2,17 @@
{% load navigation %}
{% if request.user.is_authenticated %}
{# Notifications #}
{% with notifications=request.user.notifications.unread.exists %}
<div class="nav-item dropdown">
<a href="#" class="nav-link" data-bs-toggle="dropdown" hx-get="{% url 'extras:notifications' %}" hx-target="next .notifications" aria-label="Notifications">
{% include 'inc/notification_bell.html' %}
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow notifications"></div>
</div>
{% endwith %}
{# User menu #}
<div class="nav-item dropdown">
<a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
<div class="d-xl-block ps-2">
@@ -29,6 +40,9 @@
<a href="{% url 'account:bookmarks' %}" class="dropdown-item">
<i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
</a>
<a href="{% url 'account:subscriptions' %}" class="dropdown-item">
<i class="mdi mdi-bell"></i> {% trans "Subscriptions" %}
</a>
<a href="{% url 'account:preferences' %}" class="dropdown-item">
<i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
</a>