diff --git a/aleksis/core/admin.py b/aleksis/core/admin.py index ae51f4bcfddec26f5b31af1fb79628ac6812476f..45de07f84a8a9fa4a90a621be307e7cf1e730cd9 100644 --- a/aleksis/core/admin.py +++ b/aleksis/core/admin.py @@ -2,6 +2,7 @@ from django.contrib import admin +from guardian.admin import GuardedModelAdminMixin from reversion.admin import VersionAdmin from .mixins import BaseModelAdmin @@ -16,8 +17,6 @@ from .models import ( Person, ) -admin.site.register(Person, VersionAdmin) -admin.site.register(Group, VersionAdmin) admin.site.register(Activity, VersionAdmin) admin.site.register(Notification, VersionAdmin) admin.site.register(CustomMenuItem, VersionAdmin) @@ -33,5 +32,11 @@ class AnnouncementAdmin(BaseModelAdmin, VersionAdmin): ] +class GuardedVersionAdmin(GuardedModelAdminMixin, VersionAdmin): + pass + + admin.site.register(Announcement, AnnouncementAdmin) admin.site.register(DataCheckResult) +admin.site.register(Person, GuardedVersionAdmin) +admin.site.register(Group, GuardedVersionAdmin) diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py index 75feb14c3ed9e6d717d1bbd89bf0ef87070d34b3..3d3f86933acb3b86e87c644bfc92db37832c27cb 100644 --- a/aleksis/core/apps.py +++ b/aleksis/core/apps.py @@ -2,8 +2,6 @@ from typing import Any, List, Optional, Tuple import django.apps from django.apps import apps -from django.conf import settings -from django.db import OperationalError, ProgrammingError from django.http import HttpRequest from django.utils.module_loading import autodiscover_modules @@ -16,7 +14,7 @@ from .registries import ( site_preferences_registry, ) from .util.apps import AppConfig -from .util.core_helpers import get_site_preferences, has_person +from .util.core_helpers import has_person from .util.sass_helpers import clean_scss @@ -52,8 +50,6 @@ class CoreConfig(AppConfig): preference_models.register(personpreferencemodel, person_preferences_registry) preference_models.register(grouppreferencemodel, group_preferences_registry) - self._refresh_authentication_backends() - self._load_data_checks() from .health_checks import DataChecksHealthCheckBackend @@ -70,24 +66,6 @@ class CoreConfig(AppConfig): data_checks += getattr(model, "data_checks", []) DataCheckRegistry.data_checks = data_checks - @classmethod - def _refresh_authentication_backends(cls): - """Refresh config list of enabled authentication backends.""" - from .preferences import AuthenticationBackends # noqa - - idx = settings.AUTHENTICATION_BACKENDS.index("django.contrib.auth.backends.ModelBackend") - - try: - # Don't set array directly in order to keep object reference - settings._wrapped.AUTHENTICATION_BACKENDS.clear() - settings._wrapped.AUTHENTICATION_BACKENDS += settings.ORIGINAL_AUTHENTICATION_BACKENDS - - for backend in get_site_preferences()["auth__backends"]: - settings._wrapped.AUTHENTICATION_BACKENDS.insert(idx, backend) - idx += 1 - except (ProgrammingError, OperationalError): - pass - def preference_updated( self, sender: Any, @@ -97,9 +75,6 @@ class CoreConfig(AppConfig): new_value: Optional[Any] = None, **kwargs, ) -> None: - if section == "auth" and name == "backends": - self._refresh_authentication_backends() - if section == "theme": if name in ("primary", "secondary"): clean_scss() diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py index 74355878aeac456d32b5e9f233e1d0d055210226..78c95d604960ff6413bcae7a1b625110c9052dda 100644 --- a/aleksis/core/menus.py +++ b/aleksis/core/menus.py @@ -1,6 +1,8 @@ from django.conf import settings from django.utils.translation import gettext_lazy as _ +from .util.core_helpers import unread_notifications_badge + MENUS = { "NAV_MENU_CORE": [ { @@ -24,6 +26,15 @@ MENUS = { "icon": "home", "validators": ["menu_generator.validators.is_authenticated"], }, + { + "name": _("Notifications"), + "url": "notifications", + "icon": "notifications", + "badge": unread_notifications_badge, + "validators": [ + ("aleksis.core.util.predicates.permission_validator", "core.view_notifications",), + ], + }, { "name": _("Account"), "url": "#", diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 8927844a47da2f30e030a9b0815e82539940d8ba..85b16b1e23e597efb14a91803f7515cbdc2195ac 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -245,6 +245,16 @@ class Person(ExtensibleModel): ) ] + @property + def unread_notifications(self) -> QuerySet: + """Get all unread notifications for this person.""" + return self.notifications.filter(read=False) + + @property + def unread_notifications_count(self) -> int: + """Return the count of unread notifications for this person.""" + return self.unread_notifications.count() + def save(self, *args, **kwargs): super().save(*args, **kwargs) diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py index 1b1b48c77b7cfda10b37f086f334f1d9aa5cdd67..03542dbefe797ce5cdadc71b1078f258e3eed069 100644 --- a/aleksis/core/preferences.py +++ b/aleksis/core/preferences.py @@ -172,6 +172,24 @@ class PrimaryGroupField(ChoicePreference): return Person.syncable_fields_choices() +@site_preferences_registry.register +class AutoCreatePerson(BooleanPreference): + section = account + name = "auto_create_person" + default = False + required = False + verbose_name = _("Automatically create new persons for new users") + + +@site_preferences_registry.register +class AutoLinkPerson(BooleanPreference): + section = account + name = "auto_link_person" + default = False + required = False + verbose_name = _("Automatically link existing persons to new users by their e-mail address") + + @site_preferences_registry.register class SchoolName(StringPreference): section = school @@ -190,18 +208,6 @@ class SchoolNameOfficial(StringPreference): verbose_name = _("Official name of the school, e.g. as given by supervisory authority") -@site_preferences_registry.register -class AuthenticationBackends(MultipleChoicePreference): - section = auth - name = "backends" - default = None - verbose_name = _("Enabled custom authentication backends") - field_attribute = {"initial": []} - - def get_choices(self): - return [(b, b) for b in settings.CUSTOM_AUTHENTICATION_BACKENDS] - - @site_preferences_registry.register class SignupEnabled(BooleanPreference): section = auth diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py index e72a74e86f74545e80de2971856e0967a0a06ef2..225024084edcf17f89e45a479fd1fec375df4d95 100644 --- a/aleksis/core/rules.py +++ b/aleksis/core/rules.py @@ -17,6 +17,9 @@ rules.add_perm("core", rules.always_allow) # View dashboard rules.add_perm("core.view_dashboard", has_person) +# View notifications +rules.add_perm("core.view_notifications", has_person) + # Use search search_predicate = has_person & has_global_perm("core.search") rules.add_perm("core.search", search_predicate) diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 8f999cf49c39d8b316aa33a669a4f0de73097a74..1be207a2cb2b854d99cdb3603f35db54427aa3d8 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -42,6 +42,7 @@ DEBUG_TOOLBAR_CONFIG = { "SHOW_COLLAPSED": True, "JQUERY_URL": "", "SHOW_TOOLBAR_CALLBACK": "aleksis.core.util.core_helpers.dt_show_toolbar", + "DISABLE_PANELS": {}, } DEBUG_TOOLBAR_PANELS = [ @@ -785,6 +786,4 @@ HEALTH_CHECK = { "MEMORY_MIN": _settings.get("health.memory_min_mb", 500), } -ORIGINAL_AUTHENTICATION_BACKENDS = AUTHENTICATION_BACKENDS[:] - PROMETHEUS_EXPORT_MIGRATIONS = False diff --git a/aleksis/core/static/style.scss b/aleksis/core/static/style.scss index a7b29299653c8e40d7b98304dcf2ea6f68ee4de2..032c28953a9de8c093f037191223c5ddc724faea 100644 --- a/aleksis/core/static/style.scss +++ b/aleksis/core/static/style.scss @@ -136,9 +136,8 @@ ul.sidenav li.logo > a:hover { background-color: lighten($primary-color, 5%); } -li.active > a > .sidenav-badge { - background-color: whitesmoke !important; - color: $primary-color !important; +.sidenav-badge { + min-width: 2rem!important; } .sidenav li.search { @@ -286,6 +285,10 @@ form .row { margin-bottom: 0; } +.help-block { + font-size: 75%; +} + label.chips-checkbox { &.active { outline: none; @@ -344,6 +347,11 @@ span.badge .material-icons { font-size: 0.9rem; } +.chip .material-icons { + line-height: 30px; + margin-right: 10px; + margin-left: -2px; +} /*+++++++++*/ /* Buttons */ @@ -384,6 +392,50 @@ table.striped > tbody > tr:nth-child(odd), table tr.striped, table tbody.striped background-color: rgba(208, 208, 208, 0.5); } +th.orderable > a { + color: rgba(0, 0, 0, 0.6); + display: block; + width: inherit; + height: inherit; +} + +th.orderable > a::after { + @extend i.material-icons; + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; + display: inline-block; + line-height: 1; + text-transform: none; + letter-spacing: normal; + word-wrap: normal; + white-space: nowrap; + direction: ltr; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'liga'; + float: right; + content: "unfold_more"; +} + +th.orderable.asc > a { + color: inherit; + + &::after { + content: "expand_less"; + } +} + +th.orderable.desc > a { + color: inherit; + + &::after { + content: "expand_more"; + } +} + /*+++++++*/ /* Print */ /*+++++++*/ diff --git a/aleksis/core/templates/components/materialize-chips.html b/aleksis/core/templates/components/materialize-chips.html index e8a37bbb456dd47eb1231ecbc7ec541a7944be74..e02662adc354dcb1e3784804fdf46783b282ea39 100644 --- a/aleksis/core/templates/components/materialize-chips.html +++ b/aleksis/core/templates/components/materialize-chips.html @@ -2,6 +2,9 @@ {% if img %} <img class="{{ img_classes }}" src="{{ img }}" alt="{{ alt }}"> {% endif %} + {% if icon %} + <i class="material-icons left">{{ icon }}</i> + {% endif %} {{ content }} {% if close %} <i class="close material-icons"></i> diff --git a/aleksis/core/templates/core/notifications.html b/aleksis/core/templates/core/notifications.html new file mode 100644 index 0000000000000000000000000000000000000000..a49bc9e8e2a8ad87041acbf20e79e0486714cde6 --- /dev/null +++ b/aleksis/core/templates/core/notifications.html @@ -0,0 +1,32 @@ +{% extends 'core/base.html' %} +{% load i18n static dashboard %} + +{% block browser_title %}{% blocktrans %}Notifications{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Notifications{% endblocktrans %}{% endblock %} + + +{% block content %} + {% if object_list %} + <ul class="collection"> + {% for notification in object_list %} + <li class="collection-item"> + <span class="badge new primary-color">{{ notification.sender }}</span> + <span class="title">{{ notification.title }}</span> + <p> + <i class="material-icons left">access_time</i> {{ notification.created }} + </p> + <p> + {{ notification.description }} + </p> + {% if notification.link %} + <p> + <a href="{{ notification.link }}">{% blocktrans %}More information →{% endblocktrans %}</a> + </p> + {% endif %} + </li> + {% endfor %} + </ul> + {% else %} + <p>{% blocktrans %}No notifications available yet.{% endblocktrans %}</p> + {% endif %} +{% endblock %} diff --git a/aleksis/core/templates/core/partials/sidenav.html b/aleksis/core/templates/core/partials/sidenav.html index 4c782285f35a3507a755c4e3d96ddffe007ea83f..54df4fe73e4642a693b63bbad76b7941fe142e76 100644 --- a/aleksis/core/templates/core/partials/sidenav.html +++ b/aleksis/core/templates/core/partials/sidenav.html @@ -1,6 +1,6 @@ {# -*- engine:django -*- #} -{% load menu_generator %} +{% load menu_generator data_helpers %} {% get_menu "NAV_MENU_CORE" as core_menu %} @@ -16,6 +16,10 @@ <i class="material-icons">{{ item.icon }}</i> {% endif %} {{ item.name }} + {% build_badge item as badge %} + {% if badge %} + <span class="new badge sidenav-badge"> {{ badge }}</span> + {% endif %} </a> </li> {% endif %} @@ -28,6 +32,10 @@ <i class="material-icons">{{ item.icon }}</i> {% endif %} {{ item.name }} + {% build_badge item as badge %} + {% if badge %} + <span class="new badge sidenav-badge"> {{ badge }}</span> + {% endif %} </a> <div class="collapsible-body"> <ul> @@ -40,6 +48,10 @@ <i class="material-icons">{{ menu.icon }}</i> {% endif %} {{ menu.name }} + {% build_badge item as badge %} + {% if badge %} + <span class="new badge sidenav-badge"> {{ badge }}</span> + {% endif %} </a> </li> {% endfor %} diff --git a/aleksis/core/templatetags/data_helpers.py b/aleksis/core/templatetags/data_helpers.py index ab2309f260dd6b039cc722bae86fa71637967a1f..5e263ba370bb732a80cb9d6c5d64880842ff5bb5 100644 --- a/aleksis/core/templatetags/data_helpers.py +++ b/aleksis/core/templatetags/data_helpers.py @@ -51,3 +51,14 @@ def parse_json(value: Optional[str] = None) -> Union[dict, None]: if not value: return None return json.loads(value) + + +@register.simple_tag(takes_context=True) +def build_badge(context: dict, item: dict) -> Any: + """Get menu badge content from django-menu-generator dict.""" + request = context["request"] + badge = item.get("badge") + if callable(badge): + return badge(request) + else: + return badge diff --git a/aleksis/core/tests/test_authentication_backends.py b/aleksis/core/tests/test_authentication_backends.py deleted file mode 100644 index 227f48c1469d97f246132a4bfbef2f5af5f4dbb9..0000000000000000000000000000000000000000 --- a/aleksis/core/tests/test_authentication_backends.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Optional - -from django.contrib.auth import authenticate -from django.contrib.auth.backends import BaseBackend -from django.contrib.auth.base_user import AbstractBaseUser -from django.contrib.auth.models import User - -import pytest - -from aleksis.core.apps import CoreConfig -from aleksis.core.util.core_helpers import get_site_preferences - -pytestmark = pytest.mark.django_db - - -class DummyBackend(BaseBackend): - def authenticate( - self, request, username: str, password: str, **kwargs - ) -> Optional[AbstractBaseUser]: - if username == "foo" and password == "baz": - return User.objects.get_or_create(username="foo")[0] - - -backend_name = "aleksis.core.tests.test_authentication_backends.DummyBackend" - - -def test_backends_simple(settings): - - assert not authenticate(username="foo", password="baz") - - assert backend_name not in settings.AUTHENTICATION_BACKENDS - - settings.AUTHENTICATION_BACKENDS.append(backend_name) - assert backend_name in settings.AUTHENTICATION_BACKENDS - - assert authenticate(username="foo", password="baz") - - settings.AUTHENTICATION_BACKENDS.remove(backend_name) - - assert not authenticate(username="foo", password="baz") - - -def test_backends_with_activation(settings): - assert not authenticate(username="foo", password="baz") - - settings.CUSTOM_AUTHENTICATION_BACKENDS.append(backend_name) - - assert backend_name not in get_site_preferences()["auth__backends"] - assert backend_name not in settings.AUTHENTICATION_BACKENDS - assert not authenticate(username="foo", password="baz") - - print(get_site_preferences()["auth__backends"]) - print(get_site_preferences()["auth__backends"].append(backend_name)) - - get_site_preferences()["auth__backends"] = [backend_name] - - assert backend_name in get_site_preferences()["auth__backends"] - assert backend_name in settings.AUTHENTICATION_BACKENDS - assert authenticate(username="foo", password="baz") - - get_site_preferences()["auth__backends"] = [] - - assert backend_name not in get_site_preferences()["auth__backends"] - assert backend_name not in settings.AUTHENTICATION_BACKENDS diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index da034ca3ad83a7c991e7251c19072076d8f52165..be2fb745f005f00960fa9d5ed97fbbfaf8b937bb 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -58,6 +58,7 @@ urlpatterns = [ path("group/<int:id_>/edit", views.edit_group, name="edit_group_by_id"), path("group/<int:id_>/delete", views.delete_group, name="delete_group_by_id"), path("", views.index, name="index"), + path("notifications/", views.NotificationsListView.as_view(), name="notifications"), path("dashboard/edit/", views.EditDashboardView.as_view(), name="edit_dashboard"), path( "notifications/mark-read/<int:id_>", diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index 18fcfc241ea76d96312e1339fbde6fd526218307..9e9a90faf5547b71f3d556d89d6f2459b743c04b 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -176,6 +176,9 @@ def has_person(obj: Union[HttpRequest, Model]) -> bool: else: return False + if obj.is_anonymous: + return False + person = getattr(obj, "person", None) if person is None: return False @@ -401,3 +404,8 @@ def queryset_rules_filter( wanted_objects.add(item.pk) return queryset.filter(pk__in=wanted_objects) + + +def unread_notifications_badge(request: HttpRequest) -> int: + """Generate badge content with the number of unread notifications.""" + return request.user.person.unread_notifications_count diff --git a/aleksis/core/util/middlewares.py b/aleksis/core/util/middlewares.py index 7ac5e5f2cbb710d3c6bb8c1bdda83ca170c96165..c8780e6cc3dcdbcab62f2a139b5cfa26178ee0ac 100644 --- a/aleksis/core/util/middlewares.py +++ b/aleksis/core/util/middlewares.py @@ -2,8 +2,8 @@ from typing import Callable from django.http import HttpRequest, HttpResponse -from ..models import DummyPerson -from .core_helpers import has_person +from ..models import DummyPerson, Person +from .core_helpers import get_site_preferences, has_person class EnsurePersonMiddleware: @@ -12,19 +12,43 @@ class EnsurePersonMiddleware: It is needed to inject a dummy person to a superuser that would otherwise not have an associated person, in order they can get their account set up without external help. + + In addition, if configured in preferences, it auto-creates or links persons + to regular users if they match. """ def __init__(self, get_response: Callable): self.get_response = get_response def __call__(self, request: HttpRequest) -> HttpResponse: - if not has_person(request): - if request.user.is_superuser: - # Super-users get a dummy person linked - dummy_person = DummyPerson( - first_name=request.user.first_name, last_name=request.user.last_name - ) - request.user.person = dummy_person + if not has_person(request) and not request.user.is_anonymous: + prefs = get_site_preferences() + if ( + prefs.get("account__auto_link_person", False) + and request.user.first_name + and request.user.last_name + ): + if prefs.get("account__auto_create_person"): + person, created = Person.objects.get_or_create( + email=request.user.email, + defaults={ + "first_name": request.user.first_name, + "last_name": request.user.last_name, + }, + ) + person.user = request.user + else: + person = Person.objects.filter(email=request.user.email).first() + if person: + person.user = request.user + person.save() + + if request.user.is_superuser and not has_person(request): + # Super-users get a dummy person linked + dummy_person = DummyPerson( + first_name=request.user.first_name, last_name=request.user.last_name + ) + request.user.person = dummy_person response = self.get_response(request) return response diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 41b36acfbcff7573366a2328d2d0f9f2a92fe1df..d110b30f63c8f6748198c3fc8268e81bb0caab9b 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -109,6 +109,18 @@ def index(request: HttpRequest) -> HttpResponse: return render(request, "core/index.html", context) +class NotificationsListView(PermissionRequiredMixin, ListView): + permission_required = "core.view_notifications" + template_name = "core/notifications.html" + + def get_queryset(self) -> QuerySet: + return self.request.user.person.notifications.order_by("-created") + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + self.get_queryset().filter(read=False).update(read=True) + return super().get_context_data(**kwargs) + + def about(request: HttpRequest) -> HttpResponse: """About page listing all apps.""" context = {} diff --git a/tox.ini b/tox.ini index e1283a1b72150383c5c2c9b7fbf43abb6cdf6ea2..f09d243fd2ddcd42c0eae1a6c298c08eb9557a13 100644 --- a/tox.ini +++ b/tox.ini @@ -35,6 +35,7 @@ commands = [testenv:build] commands_pre = + poetry run sh -c "cd aleksis; aleksis-admin compilemessages" commands = poetry build [testenv:docs]