diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1a7340fd9d033b40aa5f52df459b0c4315864e87..ba6a9d2aa109df57123e8cc05e4dadbebbf7f852 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,14 @@ and this project adheres to `Semantic Versioning`_. Unreleased ---------- +Added +~~~~~ + +* Use identicons where avatars are missing. +* Display personal photos instead of avatars based on a site preference. +* Add an account menu in the top navbar. +* Create a reusable snippet for avatar content. + Changed ~~~~~~~ diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py index 7938539c53327936d86d6c08334834e8e2a4fb0a..da2d102535b5daa91c8484475ec843c16e4d66aa 100644 --- a/aleksis/core/menus.py +++ b/aleksis/core/menus.py @@ -49,86 +49,6 @@ MENUS = { ), ], }, - { - "name": _("Account"), - "url": "#", - "icon": "person", - "root": True, - "validators": ["menu_generator.validators.is_authenticated"], - "submenu": [ - { - "name": _("Stop impersonation"), - "url": "impersonate-stop", - "icon": "stop", - "validators": [ - "menu_generator.validators.is_authenticated", - "aleksis.core.util.core_helpers.is_impersonate", - ], - }, - { - "name": _("Logout"), - "url": "logout", - "icon": "exit_to_app", - "validators": ["menu_generator.validators.is_authenticated"], - }, - { - "name": _("2FA"), - "url": "two_factor:profile", - "icon": "phonelink_lock", - "validators": [ - "menu_generator.validators.is_authenticated", - ], - }, - { - "name": _("Change password"), - "url": "account_change_password", - "icon": "lock", - "validators": [ - "menu_generator.validators.is_authenticated", - ( - "aleksis.core.util.predicates.permission_validator", - "core.can_change_password", - ), - ], - }, - { - "name": _("Me"), - "url": "person", - "icon": "insert_emoticon", - "validators": [ - "menu_generator.validators.is_authenticated", - "aleksis.core.util.core_helpers.has_person", - ], - }, - { - "name": _("Preferences"), - "url": "preferences_person", - "icon": "settings", - "validators": [ - "menu_generator.validators.is_authenticated", - "aleksis.core.util.core_helpers.has_person", - ], - }, - { - "name": _("Third-party accounts"), - "url": "socialaccount_connections", - "icon": "public", - "validators": [ - "menu_generator.validators.is_authenticated", - "aleksis.core.util.core_helpers.has_person", - ], - }, - { - "name": _("Authorized applications"), - "url": "oauth2_provider:authorized-token-list", - "icon": "touch_app", - "validators": [ - "menu_generator.validators.is_authenticated", - "aleksis.core.util.core_helpers.has_person", - ], - }, - ], - }, { "name": _("Admin"), "url": "#", @@ -329,4 +249,78 @@ MENUS = { ], }, ], + "NAVBAR_ACCOUNT_MENU": [ + { + "name": _("Stop impersonation"), + "url": "impersonate-stop", + "icon": "stop", + "validators": [ + "menu_generator.validators.is_authenticated", + "aleksis.core.util.core_helpers.is_impersonate", + ], + }, + { + "name": _("Account"), + "url": "person", + "icon": "person", + "validators": [ + "menu_generator.validators.is_authenticated", + "aleksis.core.util.core_helpers.has_person", + ], + }, + { + "name": _("Preferences"), + "url": "preferences_person", + "icon": "settings", + "validators": [ + "menu_generator.validators.is_authenticated", + "aleksis.core.util.core_helpers.has_person", + ], + }, + { + "name": _("2FA"), + "url": "two_factor:profile", + "icon": "phonelink_lock", + "validators": [ + "menu_generator.validators.is_authenticated", + ], + }, + { + "name": _("Change password"), + "url": "account_change_password", + "icon": "lock", + "validators": [ + "menu_generator.validators.is_authenticated", + ( + "aleksis.core.util.predicates.permission_validator", + "core.can_change_password", + ), + ], + }, + { + "name": _("Third-party accounts"), + "url": "socialaccount_connections", + "icon": "public", + "validators": [ + "menu_generator.validators.is_authenticated", + "aleksis.core.util.core_helpers.has_person", + ], + }, + { + "name": _("Authorized applications"), + "url": "oauth2_provider:authorized-token-list", + "icon": "touch_app", + "validators": [ + "menu_generator.validators.is_authenticated", + "aleksis.core.util.core_helpers.has_person", + ], + }, + { + "divider": True, + "name": _("Logout"), + "url": "logout", + "icon": "exit_to_app", + "validators": ["menu_generator.validators.is_authenticated"], + }, + ], } diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 812a9d254ca4bff2e9372ed7969d0cb2b811c176..67867a6cd211b55eaf8e9321a750af66d65c30eb 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -1,4 +1,5 @@ # flake8: noqa: DJ01 +import base64 import hmac from datetime import date, datetime, timedelta from typing import Any, Iterable, List, Optional, Sequence, Union @@ -24,6 +25,7 @@ from django.utils.functional import classproperty from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ +import customidenticon import jsonstore from cachalot.api import cachalot_disabled from cache_memoize import cache_memoize @@ -314,7 +316,12 @@ class Person(ExtensibleModel): @property def initials(self): - return f"{self.first_name[0]}{self.last_name[0]}".upper() + initials = "" + if self.first_name: + initials += self.first_name[0] + if self.last_name: + initials += self.last_name[0] + return initials.upper() or "?" user_info_tracker = FieldTracker(fields=("first_name", "last_name", "email", "user_id")) @@ -334,6 +341,20 @@ class Person(ExtensibleModel): q = q.union(group.child_groups_recursive) return q + @property + @cache_memoize(60 * 60) + def identicon_url(self): + identicon = customidenticon.create(self.full_name, border=35) + base64_data = base64.b64encode(identicon).decode("ascii") + return f"data:image/png;base64,{base64_data}" + + @property + def avatar_url(self): + if self.avatar: + return self.avatar.url + else: + return self.identicon_url + def save(self, *args, **kwargs): # Determine all fields that were changed since last load changed = self.user_info_tracker.changed() diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py index f1fb0227eab16a152f29918e91b37d0808471812..c7ad53cec6e0d09406a02da80c43f72fc7865e30 100644 --- a/aleksis/core/preferences.py +++ b/aleksis/core/preferences.py @@ -422,6 +422,16 @@ class PersonChangeNotificationContact(StringPreference): required = False +@site_preferences_registry.register +class PersonPreferPhoto(BooleanPreference): + """Preference, whether personal photos should be displayed instead of avatars.""" + + section = account + name = "person_prefer_photo" + default = False + verbose_name = _("Prefer personal photos over avatars") + + @site_preferences_registry.register class PDFFileExpirationDuration(IntegerPreference): """PDF file expiration duration.""" diff --git a/aleksis/core/static/js/main.js b/aleksis/core/static/js/main.js index 9b4c133e1ae7db5906645963cfd7182da313ca01..afc7b50fd530574753bc71f3c81cdd53c769e0a4 100644 --- a/aleksis/core/static/js/main.js +++ b/aleksis/core/static/js/main.js @@ -121,6 +121,10 @@ $(document).ready(function () { // Initialize dropdown [MAT] $('.dropdown-trigger').dropdown(); + $('.navbar-dropdown-trigger').dropdown({ + "coverTrigger": false, + "constrainWidth": false, + }); // If JS is activated, the language form will be auto-submitted $('.language-field select').change(function () { diff --git a/aleksis/core/static/public/style.scss b/aleksis/core/static/public/style.scss index 3fb108dad7c03c66791fb86ebb51e599764bc747..888dad2b0bdaa3b6832c41c039c21bc084954696 100644 --- a/aleksis/core/static/public/style.scss +++ b/aleksis/core/static/public/style.scss @@ -77,6 +77,9 @@ header, main, footer { .materialize-circle { @extend .circle; } +.collection .collection-item.avatar > .materialize-circle > .materialize-circle { + left: 0; +} /**********/ /* HEADER */ @@ -211,25 +214,6 @@ div#search-results { } -// Sidenav trigger - -header a.sidenav-trigger { - position: absolute; - left: 7.5%; - top: 0; - - height: 64px; - font-size: 38px; - - float: none; - - text-align: center; - color: white; - - z-index: 2; -} - - // Footer .footer-icon { @@ -828,8 +812,8 @@ $person-logo-size: 20vh; & img { border-radius: 50%; - width: 20vh; - height: 20vh; + width: 100%; + height: 100%; object-fit: cover; } } @@ -844,6 +828,64 @@ $person-logo-size: 20vh; user-select: none; cursor: default; border-radius: 50%; + height: unset; +} + +.nav-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1rem; + > a { + position: static!important; + transform: none!important; + } + & .nav-spacer { + width: 60px; + } + & ul.account-nav { + display: flex; + margin-inline: 7.5px; + & > li > a { + padding: 0 7.5px; + } + } +} + +.nav-wrapper .navbar-dropdown-trigger { + cursor: pointer; + height: 100%; + display: grid; +} + +.navbar-dropdown-trigger .clip-circle { + margin: auto; + width: $navbar-height*0.75; + height: $navbar-height*0.75; + cursor: pointer; + + &.no-image, &.no-image > i.material-icons { + font-size: calc(#{$navbar-height} * 0.75 * 0.5); + color: #6f6f6f; + background: #f2f2f2; + line-height: $navbar-height*0.75; + width: $navbar-height*0.75; + cursor: pointer; + } +} + +i.material-icons.new-notification { + position: relative; + &:after { + content: ""; + position: absolute; + width: 12px; + height: 12px; + bottom: 27%; + right: -4%; + background-color: $secondary-color; + border-radius: 50%; + } } #hero-bg { diff --git a/aleksis/core/templates/core/base.html b/aleksis/core/templates/core/base.html index 745a6668cdc42eb265536950a0acbecfb57ee537..d3a1ada6436769ab7a449b27051823ee49897c44 100644 --- a/aleksis/core/templates/core/base.html +++ b/aleksis/core/templates/core/base.html @@ -61,32 +61,59 @@ <body {% if no_menu %}class="without-menu"{% endif %}> <header> - <!-- Menu button (sidenav) --> - <div class="container"> - <a href="#" data-target="slide-out" class="top-nav sidenav-trigger hide-on-large-only"> - <i class="material-icons">menu</i> - </a> - </div> - <!-- Nav bar (logged in as, logout) --> <nav class="nav-extended"> <div class="nav-wrapper"> + <a href="#" data-target="slide-out" class="top-nav sidenav-trigger hide-on-large-only"> + <i class="material-icons">menu</i> + </a> + <a class="brand-logo" href="/">{{ request.site.preferences.general__title }}</a> - <ul id="nav-mobile" class="right hide-on-med-and-down"> - {% if user.is_authenticated %} - <li>{% trans "Logged in as" %} {{ user.get_username }}</li> + {% if user.is_authenticated %} + <ul class="account-nav"> + {% trans "Notifications" as notifications_text %} <li> - <a href="{% url 'logout' %}">{% trans "Logout" %} <i class="material-icons right">exit_to_app</i></a> + <a href="{% url "notifications" %}" class="tooltipped" data-position="bottom" + data-tooltip="{{ notifications_text }}" aria-label="{{ notifications_text }}"> + <i class="material-icons {% if request.user.person.unread_notifications_count > 0 %}new-notification{% endif %}"> + notifications + </i> + </a> </li> - {% endif %} - </ul> + <li> + <a href="#!" class="navbar-dropdown-trigger" data-target="account-dropdown"> + {{ request.user.person.identicon }} + {% include "core/partials/avatar_content.html" with person_or_user=request.user.person %} + </a> + </li> + </ul> + {% else %} + <span class="nav-spacer"></span> + {% endif %} </div> <div class="nav-content"> {% block nav_content %}{% endblock %} </div> </nav> + {% get_menu "NAVBAR_ACCOUNT_MENU" as account_menu %} + <ul id="account-dropdown" class="dropdown-content"> + {% for item in account_menu %} + {% if item.divider %} + <li class="divider"></li> + {% endif %} + <li> + <a href="{{ item.url }}"> + {% if item.icon %} + <i class="material-icons">{{ item.icon }}</i> + {% endif %} + {{ item.name }} + </a> + </li> + {% endfor %} + </ul> + <!-- Main nav (sidenav) --> {% if not no_menu %} <ul id="slide-out" class="sidenav sidenav-fixed"> diff --git a/aleksis/core/templates/core/partials/avatar_content.html b/aleksis/core/templates/core/partials/avatar_content.html new file mode 100644 index 0000000000000000000000000000000000000000..deef48489f4ed118a4420f05ea226f87d7c0b895 --- /dev/null +++ b/aleksis/core/templates/core/partials/avatar_content.html @@ -0,0 +1,29 @@ +{% load rules i18n %} +{% has_perm 'core.view_avatar_rule' request.user person_or_user as can_view_avatar %} +{% has_perm 'core.view_photo_rule' request.user person_or_user as can_view_photo %} +{% if SITE_PREFERENCES.account__person_prefer_photo and person_or_user.photo and can_view_photo %} + <div class="{% firstof class "clip-circle" %}"> + <img class="{% firstof img_class "hundred-percent" %}" src="{{ person_or_user.photo.url }}" + alt="{{ person_or_user.full_name }}" {% if title %} title="{{ person_or_user.full_name }}"{% endif %}/> + </div> +{% elif person_or_user.identicon_url %} + {# If this is a person #} + <div class="{% firstof class "clip-circle" %}"> + {% if can_view_avatar %} + <img class="{% firstof img_class "hundred-percent" %}" src="{{ person_or_user.avatar_url }}" + alt="{{ person_or_user.full_name }} ({% trans "Avatar" %})" {% if title %} + title="{{ person_or_user.full_name }} ({% trans "Avatar" %})"{% endif %}/> + {% else %} + + <img class="{% firstof img_class "hundred-percent" %}" src="{{ person_or_user.identicon_url }}" + alt="{{ person_or_user.full_name }} ({% trans "Identicon" %})" {% if title %} + title="{{ person_or_user.full_name }} ({% trans "Identicon" %})"{% endif %} /> + {% endif %} + </div> + +{% else %} + {# There is a user without a person #} + <div class="{% firstof class "clip-circle" %} no-image"> + <i class="material-icons">person</i> + </div> +{% endif %} diff --git a/aleksis/core/templates/core/person/collection.html b/aleksis/core/templates/core/person/collection.html index fb8518e97e3e8d770260c8e6b004df420d8efa15..c86da61e43ae19ceaa0f601c2f3ec012357bc04d 100644 --- a/aleksis/core/templates/core/person/collection.html +++ b/aleksis/core/templates/core/person/collection.html @@ -3,13 +3,7 @@ <div class="collection person-collection"> {% for person in persons %} <a class="collection-item avatar waves-effect" href="{% url "person_by_id" person.pk %}"> - {% has_perm 'core.view_photo_rule' user person as can_view_photo %} - {% if person.photo and can_view_photo %} - <img class="circle" src="{{ person.photo.url }}" - alt="{{ person.first_name }} {{ person.last_name }}"/> - {% else %} - <i class="material-icons materialize-circle">person</i> - {% endif %} + {% include "core/partials/avatar_content.html" with person_or_user=person class="materialize-circle" img_class="materialize-circle" %} {{ person }} </a> {% endfor %} diff --git a/aleksis/core/templates/core/person/full.html b/aleksis/core/templates/core/person/full.html index 23f8ba29b30188dd4cdf5b76af23991a0f618119..3972661fa81828e12b47fa606a3fc59b37ba9e69 100644 --- a/aleksis/core/templates/core/person/full.html +++ b/aleksis/core/templates/core/person/full.html @@ -59,19 +59,7 @@ <header class="person-container"> <div class="image-wrapper"> - {% has_perm 'core.view_avatar_rule' user person as can_view_avatar %} - {% if person.avatar and can_view_avatar %} - <div class="clip-circle materialboxed z-depth-2"> - <img class="hundred-percent" src="{{ person.avatar.url }}" - alt="{{ person.first_name }} {{ person.last_name }}"/> - </div> - - {% else %} - - <div class="clip-circle no-image z-depth-2"> - {{ person.initials }} - </div> - {% endif %} + {% include "core/partials/avatar_content.html" with class="clip-circle materialboxed z-depth-2" person_or_user=person title=True %} </div> <h1> {{ person.first_name }} {{ person.last_name }} @@ -209,19 +197,22 @@ {% endif %} </table> </div> + {% has_perm 'core.view_avatar_rule' user person as can_view_avatar %} {% has_perm 'core.view_photo_rule' user person as can_view_photo %} - {% if person.photo and can_view_photo %} + {% if person.photo and can_view_photo and not SITE_PREFERENCES.account__person_prefer_photo %} <div class="card"> <div class="card-image"> <img src="{{ person.photo.url }}" alt="{{ person.first_name }} {{ person.last_name }}" class="materialboxed"> <span class="card-title">{{ person.first_name }} {{ person.last_name }}</span> </div> </div> - - {% else %} - <div class="card-panel"> - <i class="material-icons left">image_not_supported</i> - {% trans "This person didn't upload a personal photo." %} + {% elif person.avatr and can_view_avatar %} + <div class="card"> + <div class="card-image"> + <img src="{{ person.avatar.url }}" + alt="{{ person.first_name }} {{ person.last_name }} ({% trans "Avatar" %})" class="materialboxed"> + <span class="card-title">{{ person.first_name }} {{ person.last_name }} ({% trans "Avatar" %})</span> + </div> </div> {% endif %} </div> diff --git a/pyproject.toml b/pyproject.toml index 12199f73cd28724dffa7087dcb6628ea68943fe1..e74135d61fd57aa8c53b16d8342b55eb81c825bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ python-gnupg = "^0.4.7" sentry-sdk = {version = "^1.4.3", optional = true} django-cte = "^1.1.5" pycountry = "^22.0.0" +customidenticon = "^0.1.5" [tool.poetry.extras] ldap = ["django-auth-ldap"]