diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d009032fe7601c866668a032b1043e0dd18c2418..76450ed9e6fdccb574a17ca23fde70e7ed0dbe7b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,21 +12,25 @@ Unreleased Added ~~~~~ +* Add option to open entry in new tab for sidebar navigation menu. * Add preference for configuring the default phone number country code. - -Added -~~~~~ - +* Persons and groups now have two image fields: official photo and public avatar +* Admins recieve an mail for celery tasks with status "FAILURE" * OpenID Connect RSA keys can now be passed as string in config files * Views filtering for person names now also search the username of a linked user +* OAuth2 applications now take an icon which is shown in the authorization progress. +* Add support for hiding the main side nav in ``base.html``. Fixed ~~~~~ +* Changing the favicon did not result in all icons being replaced in some cases * GroupManager.get_queryset() returned an incomplete QuerySet * OAuth was broken by a non-semver-adhering django-oauth-toolkit update * Too long texts in chips didn't result in a larger chip. +* The ``Person`` model had an ``is_active`` flag that was used in unclear ways; it is now removed * The data check results list view didn't work if a related object had been deleted in the meanwhile. +* Socialaccount login template was not overriden Changed ~~~~~~~ @@ -41,6 +45,9 @@ Changed * [Docker] Base image now contains curl, grep, less, sed, and pspg * Views raising a 404 error can now customise the message that is displayed on the error page * OpenID Connect is enabled by default now, without RSA support +* Login and authorization pages for OAuth2/OpenID Connect now indicate that the user is in progress + to authorize an external application. +* Tables can be scrolled horizontally. `2.5`_ – 2022-01-02 ------------------- diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py index 520a5fc435fe5cd8ef55435d3c680f978250abb0..47df1dbda191a75339a20fc6e1d86c032835a216 100644 --- a/aleksis/core/apps.py +++ b/aleksis/core/apps.py @@ -97,15 +97,16 @@ class CoreConfig(AppConfig): if name in ("primary", "secondary"): clean_scss() elif name in ("favicon", "pwa_icon"): - from favicon.models import Favicon # noqa + from favicon.models import Favicon, FaviconImg # noqa is_favicon = name == "favicon" if new_value: - Favicon.on_site.update_or_create( + favicon_id = Favicon.on_site.update_or_create( title=name, defaults={"isFavicon": is_favicon, "faviconImage": new_value}, - ) + )[0] + FaviconImg.objects.filter(faviconFK=favicon_id).delete() else: Favicon.on_site.filter(title=name, isFavicon=is_favicon).delete() if name in settings.DEFAULT_FAVICON_PATHS: diff --git a/aleksis/core/celery.py b/aleksis/core/celery.py index 27a67c539babfc61701cc9521b42187ebebc5787..10457ea9b84871da3038cf4712babb601456bfcd 100644 --- a/aleksis/core/celery.py +++ b/aleksis/core/celery.py @@ -1,9 +1,36 @@ import os +from django.conf import settings + from celery import Celery +from celery.signals import task_failure +from templated_email import send_templated_mail + +from .util.core_helpers import get_site_preferences os.environ.setdefault("DJANGO_SETTINGS_MODULE", "aleksis.core.settings") app = Celery("aleksis") # noqa app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() + + +@task_failure.connect +def task_failure_notifier( + sender=None, task_id=None, exception=None, args=None, traceback=None, **kwargs +): + recipient_list = [e[1] for e in settings.ADMINS] + send_templated_mail( + template_name="celery_failure", + from_email=get_site_preferences()["mail__address"], + recipient_list=recipient_list, + context={ + "task_name": sender.name, + "task": str(sender), + "task_id": str(task_id), + "exception": str(exception), + "args": str(args), + "kwargs": str(kwargs), + "traceback": str(traceback), + }, + ) diff --git a/aleksis/core/filters.py b/aleksis/core/filters.py index 288e1f899d7b930edb86fab73cf4661bfdd3ce8d..0bccc2664489f3698fc77bbe7299a9210ed81dac 100644 --- a/aleksis/core/filters.py +++ b/aleksis/core/filters.py @@ -73,11 +73,11 @@ class PersonFilter(FilterSet): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.form.layout = Layout(Row("name", "contact"), Row("is_active", "sex", "primary_group")) + self.form.layout = Layout(Row("name", "contact"), Row("sex", "primary_group")) class Meta: model = Person - fields = ["sex", "is_active", "primary_group"] + fields = ["sex", "primary_group"] class PermissionFilter(FilterSet): diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index 60a1058636dd23a2c93c94aec3ced4d1eed6e98c..4f4414b1b64219d95bfc4669f5f99cef670cc7c8 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -48,7 +48,6 @@ class PersonForm(ExtensibleForm): _("Base data"), "short_name", Row("user", "primary_group"), - "is_active", Row("first_name", "additional_name", "last_name"), ), Fieldset(_("Address"), Row("street", "housenumber"), Row("postal_code", "place")), @@ -57,8 +56,7 @@ class PersonForm(ExtensibleForm): _("Advanced personal data"), Row("date_of_birth", "place_of_birth"), Row("sex"), - Row("photo", "photo_rect", "photo_square"), - Row("avatar", "avatar_rect", "avatar_square"), + Row("photo", "avatar"), "guardians", ), ) @@ -67,7 +65,6 @@ class PersonForm(ExtensibleForm): model = Person fields = [ "user", - "is_active", "first_name", "last_name", "additional_name", @@ -83,11 +80,7 @@ class PersonForm(ExtensibleForm): "place_of_birth", "sex", "photo", - "photo_rect", - "photo_square", "avatar", - "avatar_rect", - "avatar_square", "guardians", "primary_group", ] @@ -164,8 +157,7 @@ class EditGroupForm(SchoolTermRelatedExtensibleForm): Fieldset(_("Common data"), "name", "short_name", "group_type"), Fieldset(_("Persons"), "members", "owners", "parent_groups"), Fieldset(_("Additional data"), "additional_fields"), - Fieldset(_("Photo"), "photo", "photo_rect", "photo_square"), - Fieldset(_("Avatar"), "avatar", "avatar_rect", "avatar_square"), + Fieldset(_("Photo"), "photo", "avatar"), ) class Meta: @@ -783,6 +775,7 @@ class OAuthApplicationForm(forms.ModelForm): model = OAuthApplication fields = ( "name", + "icon", "client_id", "client_secret", "client_type", diff --git a/aleksis/core/migrations/0029_photo_avatar_croppable.py b/aleksis/core/migrations/0029_photo_avatar_croppable.py deleted file mode 100644 index 42deecf9283a2febbaa62689a4074ec14d2ac9d9..0000000000000000000000000000000000000000 --- a/aleksis/core/migrations/0029_photo_avatar_croppable.py +++ /dev/null @@ -1,64 +0,0 @@ -# Generated by Django 3.2.9 on 2021-12-09 14:56 - -from django.db import migrations -import image_cropping.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0028_update_photo_avatar'), - ] - - operations = [ - migrations.AddField( - model_name='group', - name='avatar_rect', - field=image_cropping.fields.ImageRatioField('photo', '800x600', adapt_rotation=False, allow_fullsize=False, free_crop=False, help_text=None, hide_image_field=False, size_warning=False, verbose_name='avatar rect'), - ), - migrations.AddField( - model_name='group', - name='avatar_square', - field=image_cropping.fields.ImageRatioField('photo', '400x400', adapt_rotation=False, allow_fullsize=False, free_crop=False, help_text=None, hide_image_field=False, size_warning=False, verbose_name='avatar square'), - ), - migrations.AddField( - model_name='group', - name='photo_rect', - field=image_cropping.fields.ImageRatioField('photo', '800x600', adapt_rotation=False, allow_fullsize=False, free_crop=False, help_text=None, hide_image_field=False, size_warning=False, verbose_name='photo rect'), - ), - migrations.AddField( - model_name='group', - name='photo_square', - field=image_cropping.fields.ImageRatioField('photo', '400x400', adapt_rotation=False, allow_fullsize=False, free_crop=False, help_text=None, hide_image_field=False, size_warning=False, verbose_name='photo square'), - ), - migrations.AddField( - model_name='person', - name='avatar_rect', - field=image_cropping.fields.ImageRatioField('photo', '600x800', adapt_rotation=False, allow_fullsize=False, free_crop=False, help_text=None, hide_image_field=False, size_warning=False, verbose_name='avatar rect'), - ), - migrations.AddField( - model_name='person', - name='avatar_square', - field=image_cropping.fields.ImageRatioField('photo', '400x400', adapt_rotation=False, allow_fullsize=False, free_crop=False, help_text=None, hide_image_field=False, size_warning=False, verbose_name='avatar square'), - ), - migrations.AddField( - model_name='person', - name='photo_rect', - field=image_cropping.fields.ImageRatioField('photo', '600x800', adapt_rotation=False, allow_fullsize=False, free_crop=False, help_text=None, hide_image_field=False, size_warning=False, verbose_name='photo rect'), - ), - migrations.AddField( - model_name='person', - name='photo_square', - field=image_cropping.fields.ImageRatioField('photo', '400x400', adapt_rotation=False, allow_fullsize=False, free_crop=False, help_text=None, hide_image_field=False, size_warning=False, verbose_name='photo square'), - ), - migrations.AlterField( - model_name='group', - name='photo', - field=image_cropping.fields.ImageCropField(blank=True, help_text='This is an official photo, used for official documents and for internal use cases.', null=True, upload_to='', verbose_name='Photo'), - ), - migrations.AlterField( - model_name='person', - name='photo', - field=image_cropping.fields.ImageCropField(blank=True, help_text='This is an official photo, used for official documents and for internal use cases.', null=True, upload_to='', verbose_name='Photo'), - ), - ] diff --git a/aleksis/core/migrations/0031_oauthapplication_icon.py b/aleksis/core/migrations/0031_oauthapplication_icon.py new file mode 100644 index 0000000000000000000000000000000000000000..26f2d6575e768578e653088c582fd3dadd147dc5 --- /dev/null +++ b/aleksis/core/migrations/0031_oauthapplication_icon.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-01-08 13:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0030_user_attributes'), + ] + + operations = [ + migrations.AddField( + model_name='oauthapplication', + name='icon', + field=models.ImageField(blank=True, help_text='This image will be shown as icon in the authorization flow. It should be squared.', null=True, upload_to='', verbose_name='Icon'), + ), + ] diff --git a/aleksis/core/migrations/0032_remove_person_is_active.py b/aleksis/core/migrations/0032_remove_person_is_active.py new file mode 100644 index 0000000000000000000000000000000000000000..927913f3febb7ec5e791aab26bf56cd7b8aab2df --- /dev/null +++ b/aleksis/core/migrations/0032_remove_person_is_active.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.11 on 2022-01-08 10:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0031_oauthapplication_icon'), + ] + + operations = [ + migrations.RemoveField( + model_name='person', + name='is_active', + ), + ] diff --git a/aleksis/core/migrations/0028_update_photo_avatar.py b/aleksis/core/migrations/0033_update_photo_avatar.py similarity index 96% rename from aleksis/core/migrations/0028_update_photo_avatar.py rename to aleksis/core/migrations/0033_update_photo_avatar.py index 7dd0dba29c41d027e434e07b3f513b96393ab2d8..12dfa4fbc50a0119ed0e7fec0ce73a5e5cbc0f45 100644 --- a/aleksis/core/migrations/0028_update_photo_avatar.py +++ b/aleksis/core/migrations/0033_update_photo_avatar.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('core', '0027_person_place_of_birth'), + ('core', '0032_remove_person_is_active'), ] operations = [ diff --git a/aleksis/core/models.py b/aleksis/core/models.py index a3909266b0bdc85d1168be59d3106a051607f24b..9c78211eac9dfabe6e1d0b8de8e2034f0dc9da2c 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -30,7 +30,6 @@ from cache_memoize import cache_memoize from django_celery_results.models import TaskResult from django_cte import CTEQuerySet, With from dynamic_preferences.models import PerInstancePreferenceModel -from image_cropping import ImageCropField, ImageRatioField from invitations import signals from invitations.adapters import get_invitations_adapter from invitations.base_invitation import AbstractBaseInvitation @@ -178,7 +177,6 @@ class Person(ExtensibleModel): related_name="person", verbose_name=_("Linked user"), ) - is_active = models.BooleanField(verbose_name=_("Is person active?"), default=True) first_name = models.CharField(verbose_name=_("First name"), max_length=255) last_name = models.CharField(verbose_name=_("Last name"), max_length=255) @@ -204,7 +202,7 @@ class Person(ExtensibleModel): place_of_birth = models.CharField(verbose_name=_("Place of birth"), max_length=255, blank=True) sex = models.CharField(verbose_name=_("Sex"), max_length=1, choices=SEX_CHOICES, blank=True) - photo = ImageCropField( + photo = models.ImageField( verbose_name=_("Photo"), blank=True, null=True, @@ -212,8 +210,6 @@ class Person(ExtensibleModel): "This is an official photo, used for official documents and for internal use cases." ), ) - photo_rect = ImageRatioField("photo", "600x800") - photo_square = ImageRatioField("photo", "400x400") avatar = models.ImageField( verbose_name=_("Display picture / Avatar"), @@ -221,8 +217,6 @@ class Person(ExtensibleModel): null=True, help_text=_("This is a picture or an avatar for public display."), ) - avatar_rect = ImageRatioField("photo", "600x800") - avatar_square = ImageRatioField("photo", "400x400") guardians = models.ManyToManyField( "self", @@ -503,7 +497,7 @@ class Group(SchoolTermRelatedExtensibleModel): AdditionalField, verbose_name=_("Additional fields"), blank=True ) - photo = ImageCropField( + photo = models.ImageField( verbose_name=_("Photo"), blank=True, null=True, @@ -511,17 +505,12 @@ class Group(SchoolTermRelatedExtensibleModel): "This is an official photo, used for official documents and for internal use cases." ), ) - photo_rect = ImageRatioField("photo", "800x600") - photo_square = ImageRatioField("photo", "400x400") - avatar = models.ImageField( verbose_name=_("Display picture / Avatar"), blank=True, null=True, help_text=_("This is a picture or an avatar for public display."), ) - avatar_rect = ImageRatioField("photo", "800x600") - avatar_square = ImageRatioField("photo", "400x400") def get_absolute_url(self) -> str: return reverse("group_by_id", args=[self.id]) @@ -1292,6 +1281,15 @@ class OAuthApplication(AbstractApplication): blank=True, ) + icon = models.ImageField( + verbose_name=_("Icon"), + blank=True, + null=True, + help_text=_( + "This image will be shown as icon in the authorization flow. It should be squared." + ), + ) + def allows_grant_type(self, *grant_types: set[str]) -> bool: allowed_grants = get_site_preferences()["auth__oauth_allowed_grants"] diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 8e1207d490b839bbbdc9a7bda328451724779a83..05a460d13da4560a2aa8d07b18647e962800b925 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -6,7 +6,6 @@ from socket import getfqdn from django.utils.translation import gettext_lazy as _ from dynaconf import LazySettings -from easy_thumbnails.conf import Settings as thumbnail_settings from .util.core_helpers import get_app_packages, merge_app_settings, monkey_patch @@ -147,8 +146,6 @@ INSTALLED_APPS = [ "django_filters", "oauth2_provider", "rest_framework", - "easy_thumbnails", - "image_cropping", ] merge_app_settings("INSTALLED_APPS", INSTALLED_APPS, True) @@ -978,12 +975,6 @@ if SENTRY_ENABLED: **SENTRY_SETTINGS, ) -THUMBNAIL_PROCESSORS = ( - "image_cropping.thumbnail_processors.crop_corners", -) + thumbnail_settings.THUMBNAIL_PROCESSORS - -IMAGE_CROPPING_JQUERY_URL = None - SHELL_PLUS_MODEL_IMPORTS_RESOLVER = "django_extensions.collision_resolvers.AppLabelPrefixCR" SHELL_PLUS_APP_PREFIXES = { "auth": "auth", diff --git a/aleksis/core/static/public/style.scss b/aleksis/core/static/public/style.scss index f7d5b4ebc1f8a750e7caf913c6aa3d4c8e4ede9c..9d3a7f5609c20400d3addbd86e4e0e6efe965393 100644 --- a/aleksis/core/static/public/style.scss +++ b/aleksis/core/static/public/style.scss @@ -64,6 +64,10 @@ header, main, footer { margin-left: 300px; } +.without-menu header, .without-menu main, .without-menu footer { + margin-left: 0; +} + @media only screen and (max-width: 992px) { header, main, footer { margin-left: 0; @@ -421,6 +425,10 @@ span.badge .material-icons { /* Table*/ +.table-container { + overflow-x: auto; +} + table.striped > tbody > tr:nth-child(odd), table tr.striped, table tbody.striped tr { background-color: rgba(208, 208, 208, 0.5); } @@ -909,4 +917,15 @@ $person-logo-size: 20vh; object-fit: contain; } } + +.application-circle { + border-radius: 50%; + width: 20vh; + height: 20vh; +} + + +.application-circle img { + @extend .application-circle; + object-fit: cover; } diff --git a/aleksis/core/templates/core/base.html b/aleksis/core/templates/core/base.html index 06aebc4533369e9b7af39051dcb80713193f50d5..7fd4e38f8449b0a649fcd01e08051a2332070a15 100644 --- a/aleksis/core/templates/core/base.html +++ b/aleksis/core/templates/core/base.html @@ -58,7 +58,7 @@ {% block extra_head %}{% endblock %} </head> -<body> +<body {% if no_menu %}class="without-menu"{% endif %}> <header> <!-- Menu button (sidenav) --> @@ -88,32 +88,36 @@ </nav> <!-- Main nav (sidenav) --> - <ul id="slide-out" class="sidenav sidenav-fixed"> - <li class="logo"> - {% static "img/aleksis-banner.svg" as aleksis_banner %} - <a id="logo-container" href="/" class="brand-logo"> - <img src="{% firstof request.site.preferences.theme__logo.url aleksis_banner %}" - alt="{{ request.site.preferences.general__title }} – Logo"> - </a> - </li> - {% has_perm 'core.search_rule' user as search %} - {% if search %} - <li class="search"> - <form method="get" action="{% url "haystack_search" %}" id="search-form" class="autocomplete"> - <div class="search-wrapper"> - <input id="search" name="q" type="search" enterkeyhint="search" placeholder="{% trans "Search" %}"> - <button class="btn btn-flat search-button" type="submit" aria-label="{% trans "Search" %}"> - <i class="material-icons">search</i> - </button> - <div class="progress" id="search-loader"><div class="indeterminate"></div></div> - </div> - </form> + {% if not no_menu %} + <ul id="slide-out" class="sidenav sidenav-fixed"> + <li class="logo"> + {% static "img/aleksis-banner.svg" as aleksis_banner %} + <a id="logo-container" href="/" class="brand-logo"> + <img src="{% firstof request.site.preferences.theme__logo.url aleksis_banner %}" + alt="{{ request.site.preferences.general__title }} – Logo"> + </a> </li> - {% endif %} - <li class="no-padding"> - {% include "core/partials/sidenav.html" %} - </li> - </ul> + {% has_perm 'core.search_rule' user as search %} + {% if search %} + <li class="search"> + <form method="get" action="{% url "haystack_search" %}" id="search-form" class="autocomplete"> + <div class="search-wrapper"> + <input id="search" name="q" type="search" enterkeyhint="search" placeholder="{% trans "Search" %}"> + <button class="btn btn-flat search-button" type="submit" aria-label="{% trans "Search" %}"> + <i class="material-icons">search</i> + </button> + <div class="progress" id="search-loader"> + <div class="indeterminate"></div> + </div> + </div> + </form> + </li> + {% endif %} + <li class="no-padding"> + {% include "core/partials/sidenav.html" %} + </li> + </ul> + {% endif %} </header> diff --git a/aleksis/core/templates/core/partials/sidenav.html b/aleksis/core/templates/core/partials/sidenav.html index 54df4fe73e4642a693b63bbad76b7941fe142e76..9b3be97059ca834238d39a4dd6f6bfe35205a9d2 100644 --- a/aleksis/core/templates/core/partials/sidenav.html +++ b/aleksis/core/templates/core/partials/sidenav.html @@ -9,7 +9,7 @@ {% for item in core_menu %} {% if not item.submenu %} <li class="{% if item.selected %} active {% endif %}"> - <a class="truncate" href="{{ item.url }}"> + <a class="truncate" {% if item.new_tab %} target="_blank" {% endif %} href="{{ item.url }}"> {% if item.icon_class %} <i class="{{ item.icon_class }}"></i> {% elif item.icon %} @@ -25,7 +25,7 @@ {% endif %} {% if item.submenu %} <li class="bold {% if item.selected %} active {% endif %}"> - <a class="collapsible-header waves-effect waves-primary truncate" href="{{ item.url|default:"#" }}"> + <a class="collapsible-header waves-effect waves-primary truncate" {% if item.new_tab %} target="_blank" {% endif %} href="{{ item.url|default:"#" }}"> {% if item.icon_class %} <i class="{{ item.icon_class }}"></i> {% elif item.icon %} diff --git a/aleksis/core/templates/oauth2_provider/application/create.html b/aleksis/core/templates/oauth2_provider/application/create.html index d81489e922a76de8d7a8e92a7f48686714a3b3a7..73b94677206d38fca163858551536d575b2dce3d 100644 --- a/aleksis/core/templates/oauth2_provider/application/create.html +++ b/aleksis/core/templates/oauth2_provider/application/create.html @@ -6,7 +6,7 @@ {% block page_title %}{% blocktrans %}Register OAuth2 Application{% endblocktrans %}{% endblock %} {% block content %} - <form method="post"> + <form method="post" enctype="multipart/form-data"> {% csrf_token %} {% form form=form %}{% endform %} {% include "core/partials/save_button.html" %} diff --git a/aleksis/core/templates/oauth2_provider/application/detail.html b/aleksis/core/templates/oauth2_provider/application/detail.html index da6b8abf81ddc1916de7b0c7feb5a7262f453037..28e2af7d70ff4b6dd5d93b46b8ec52d31bc36538 100644 --- a/aleksis/core/templates/oauth2_provider/application/detail.html +++ b/aleksis/core/templates/oauth2_provider/application/detail.html @@ -22,6 +22,18 @@ </a> <table class="responsive-table"> <tbody> + <tr> + <th>{% trans "Icon" %}</th> + <td> + {% if application.icon %} + <div class="application-circle materialboxed z-depth-2"> + <img src="{{ application.icon.url }}" alt="{{ oauth_application.name }}" class="hundred-percent"> + </div> + {% else %} + – + {% endif %} + </td> + </tr> <tr> <th> {% trans "Client id" %} diff --git a/aleksis/core/templates/oauth2_provider/application/edit.html b/aleksis/core/templates/oauth2_provider/application/edit.html index 6755d2420fb6a181f671b825475afb4ec9581521..30f50fff94e330e941d7b4730fe7d875b039d74e 100644 --- a/aleksis/core/templates/oauth2_provider/application/edit.html +++ b/aleksis/core/templates/oauth2_provider/application/edit.html @@ -6,7 +6,7 @@ {% block page_title %}{% blocktrans %}Edit OAuth2 Application{% endblocktrans %}{% endblock %} {% block content %} - <form method="post"> + <form method="post" enctype="multipart/form-data"> {% csrf_token %} {% form form=form %}{% endform %} {% include "core/partials/save_button.html" %} diff --git a/aleksis/core/templates/oauth2_provider/application/list.html b/aleksis/core/templates/oauth2_provider/application/list.html index 06f1a95c4e05c14dcb5efe69f2416040d184f0bb..ced7d718dfe1b561c2ea8253e25658a9f449024d 100644 --- a/aleksis/core/templates/oauth2_provider/application/list.html +++ b/aleksis/core/templates/oauth2_provider/application/list.html @@ -12,8 +12,13 @@ </a> <div class="collection"> {% for application in applications %} - <a class="collection-item" href="{% url "oauth2_application" application.id %}"> - {{ application.name }} + <a class="collection-item avatar" href="{% url "oauth2_application" application.id %}"> + {% if application.icon %} + <img src="{{ application.icon.url }}" alt="{{ application.name }}" class="circle"> + {% endif %} + <span class="title"> + {{ application.name }} + </span> </a> {% empty %} <div class="collection-item flow-text"> diff --git a/aleksis/core/templates/oauth2_provider/authorize.html b/aleksis/core/templates/oauth2_provider/authorize.html index 48b996837b8721ef6ba74337d286236e91729f5b..c90d5e8dd9d3d72cff9e19ce167487900e904636 100644 --- a/aleksis/core/templates/oauth2_provider/authorize.html +++ b/aleksis/core/templates/oauth2_provider/authorize.html @@ -12,8 +12,15 @@ <div class="col s12 m10 l8 xl6"> <div class="card"> <div class="card-content"> - <div class="card-title"> - {% trans "Authorize" %} {{ application.name }} + {% if application.icon %} + <div class="center-via-flex margin-bottom"> + <div class="application-circle materialboxed z-depth-2"> + <img src="{{ application.icon.url }}" alt="{{ application.name }}" class="hundred-percent"> + </div> + </div> + {% endif %} + <div class="card-title {% if application.icon %}center{% endif %}"> + {% blocktrans with name=application.name %}Authorize {{ name }}{% endblocktrans %} </div> <p class="margin-bottom">{% trans "The application requests access to the following scopes:" %}</p> {% for scope in scopes_descriptions %} diff --git a/aleksis/core/templates/socialaccount/login.html b/aleksis/core/templates/socialaccount/login.html new file mode 100644 index 0000000000000000000000000000000000000000..1630b01fa4104f563a92663bc7fd386a2abd5266 --- /dev/null +++ b/aleksis/core/templates/socialaccount/login.html @@ -0,0 +1,33 @@ +{% extends "core/base.html" %} + +{% load i18n material_form account %} + +{% block browser_title %}{% trans "Authorize" %}{% endblock %} +{% block page_title %}{% trans "Authorize" %}{% endblock %} + +{% block content %} +{% if process == "connect" %} + <p class="flow-text"> + <i class="material-icons left">info</i> + {% blocktrans with provider.name as provider %}You are about to connect a new third party account from {{ provider }}.{% endblocktrans %} + </p> + <form method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + {% trans "Confirm" as caption %} + {% include "core/partials/save_button.html" with caption=caption icon="how_to_reg" %} + </form> +{% else %} + <p class="flow-text"> + <i class="material-icons left small">info</i> + {% blocktrans with provider.name as provider %}You are about to sign in using a third party account from {{ provider }}.{% endblocktrans %} + </p> + <form method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + {% trans "Continue" as caption %} + {% include "core/partials/save_button.html" with caption=caption icon="how_to_reg" %} + </form> + +{% endif %} +{% endblock %} diff --git a/aleksis/core/templates/templated_email/celery_failure.email b/aleksis/core/templates/templated_email/celery_failure.email new file mode 100644 index 0000000000000000000000000000000000000000..d355823eb70e58f393c6a9006ed2045ac1882171 --- /dev/null +++ b/aleksis/core/templates/templated_email/celery_failure.email @@ -0,0 +1,44 @@ +{% load i18n %} + +{% block subject %} + {% blocktrans with task_name=task_name%} Celery task {{ task_name }} failed!{% endblocktrans %} +{% endblock %} + +{% block plain %} + {% trans "Hello," %} + {% blocktrans with task_name=task_name %} + the celery task {{ task_name }} failed with following information: + {% endblocktrans %} + + +{% blocktrans with task_name=task_name task=task task_id=task_id exception=exception args=args kwargs=kwargs traceback=traceback %} + * Task name: {{task_name}} + * Task: {{task}} + * Id of the task: {{task_id}} + * Exception instance raised: {{ exception }} + * Positional arguments the task was called with: {{ args }} + * Keyword arguments the task was called with: {{ kwargs }} + * Stack trace object: {{ traceback }} +{% endblocktrans %} +{% endblock %} + +{% block html %} + <p>{% trans "Hello," %}</p> + <p> + {% blocktrans with task_name=task_name %} + the celery task {{ task_name }} failed with following information: + {% endblocktrans %} + </p> + + <ul> + {% blocktrans with task_name=task_name task=task task_id=task_id exception=exception args=args kwargs=kwargs traceback=traceback %} + <li>Task name: {{task_name}}</li> + <li>Task: {{task}}</li> + <li>Id of the task: {{task_id}}</li> + <li>Exception instance raised: {{ exception }}</li> + <li>Positional arguments the task was called with: {{ args }}</li> + <li>Keyword arguments the task was called with: {{ kwargs }}</li> + <li>Stack trace object: {{ traceback }}</li> + </ul> + {% endblocktrans %} +{% endblock %} diff --git a/aleksis/core/templates/two_factor/core/login.html b/aleksis/core/templates/two_factor/core/login.html index 9ea4bc6c24b7d9e764742bbddf75ceb86b785868..834c4b98b543bd994542b105aec1b99932f7d186 100644 --- a/aleksis/core/templates/two_factor/core/login.html +++ b/aleksis/core/templates/two_factor/core/login.html @@ -16,7 +16,17 @@ <div class="col s12 m10 l8 xl6"> <div class="card"> <div class="card-content"> - {% if wizard.steps.current == 'auth' and socialaccount_providers %} + {% if oauth and oauth_application.icon %} + <div class="center-via-flex margin-bottom"> + <div class="application-circle materialboxed z-depth-2"> + <img src="{{ oauth_application.icon.url }}" alt="{{ oauth_application.name }}" + class="hundred-percent"> + </div> + </div> + <div class="card-title center"> + {% blocktrans with name=oauth_application.name %}Login for {{ name }}{% endblocktrans %} + </div> + {% elif wizard.steps.current == 'auth' and socialaccount_providers %} <div class="card-title">{% trans "Login with username and password" %}</div> {% else %} <div class="card-title">{% trans "Login" %}</div> @@ -30,12 +40,21 @@ </p> </div> {% elif wizard.steps.current == 'auth' %} - <div class="alert primary"> - <p> - <i class="material-icons left">info</i> - {% blocktrans %}Please login to see this page.{% endblocktrans %} - </p> - </div> + {% if oauth %} + <div class="alert primary"> + <p> + <i class="material-icons left">info</i> + {% blocktrans %}Please login with your account to use the external application.{% endblocktrans %} + </p> + </div> + {% else %} + <div class="alert primary"> + <p> + <i class="material-icons left">info</i> + {% blocktrans %}Please login to see this page.{% endblocktrans %} + </p> + </div> + {% endif %} {% endif %} {% if not wizard.steps.current == "auth" %} <div class="alert primary"> diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 753d95085b6c8812927f2db376a6f3e2e6429c4b..b5dcb004d040b64b22242b977702a104fb2ea33a 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -134,6 +134,11 @@ urlpatterns = [ views.OAuth2EditView.as_view(), name="edit_oauth2_application", ), + path( + "oauth/authorize/", + views.CustomAuthorizationView.as_view(), + name="oauth2_provider:authorize", + ), path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")), path("__i18n__/", include("django.conf.urls.i18n")), path( diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 23a4f35ded4e425a3ff0aa8342ec3266766f4a6f..2b261536d4bc2716fd082ae722284244ed8382cd 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -1,6 +1,6 @@ from textwrap import wrap from typing import Any, Dict, Optional, Type -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse, urlunparse from django.apps import apps from django.conf import settings @@ -18,6 +18,7 @@ from django.http import ( HttpResponseRedirect, HttpResponseServerError, JsonResponse, + QueryDict, ) from django.shortcuts import get_object_or_404, redirect, render from django.template import loader @@ -50,6 +51,9 @@ from haystack.query import SearchQuerySet from haystack.utils.loading import UnifiedIndex from health_check.views import MainView from invitations.views import SendInvite, accept_invitation +from oauth2_provider.exceptions import OAuthToolkitError +from oauth2_provider.models import get_application_model +from oauth2_provider.views import AuthorizationView from reversion import set_user from reversion.views import RevisionMixin from rules import test_rule @@ -297,9 +301,7 @@ def persons(request: HttpRequest) -> HttpResponse: context = {} # Get all persons - persons = get_objects_for_user( - request.user, "core.view_person", Person.objects.filter(is_active=True) - ) + persons = get_objects_for_user(request.user, "core.view_person", Person.objects.all()) # Get filter persons_filter = PersonFilter(request.GET, queryset=persons) @@ -341,7 +343,7 @@ def group(request: HttpRequest, id_: int) -> HttpResponse: group = Group.objects.get(pk=id_) # Get members - members = group.members.filter(is_active=True) + members = group.members.all() # Build table members_table = PersonsTable(members) @@ -349,7 +351,7 @@ def group(request: HttpRequest, id_: int) -> HttpResponse: context["members_table"] = members_table # Get owners - owners = group.owners.filter(is_active=True) + owners = group.owners.all() # Build table owners_table = PersonsTable(owners) @@ -1455,3 +1457,42 @@ class LoginView(AllAuthLoginView): return render(self.request, "account/verification_sent.html") return super().done(form_list, **kwargs) + + def get_context_data(self, form, **kwargs): + """Override context data to hide side menu and include OAuth2 application if given.""" + context = super().get_context_data(form, **kwargs) + if self.request.GET.get("oauth"): + context["no_menu"] = True + + if self.request.GET.get("client_id"): + application = get_application_model().objects.get( + client_id=self.request.GET["client_id"] + ) + context["oauth_application"] = application + return context + + +class CustomAuthorizationView(AuthorizationView): + def handle_no_permission(self): + """Override handle_no_permission to provide OAuth2 information to login page.""" + redirect_obj = super().handle_no_permission() + + try: + scopes, credentials = self.validate_authorization_request(self.request) + except OAuthToolkitError as error: + # Application is not available at this time. + return self.error_response(error, application=None) + + login_url_parts = list(urlparse(redirect_obj.url)) + querystring = QueryDict(login_url_parts[4], mutable=True) + querystring["oauth"] = "yes" + querystring["client_id"] = credentials["client_id"] + login_url_parts[4] = querystring.urlencode(safe="/") + + return HttpResponseRedirect(urlunparse(login_url_parts)) + + def get_context_data(self, **kwargs): + """Override context data to hide side menu.""" + context = super().get_context_data(**kwargs) + context["no_menu"] = True + return context diff --git a/docs/_static/create_social_application.png b/docs/_static/create_social_application.png new file mode 100644 index 0000000000000000000000000000000000000000..c28c5c30a6d71f8aa0f1177b92048449c688d113 Binary files /dev/null and b/docs/_static/create_social_application.png differ diff --git a/docs/admin/03_socialaccounts.rst b/docs/admin/03_socialaccounts.rst new file mode 100644 index 0000000000000000000000000000000000000000..97b023947a27768b825c693b7c5cd8bff938a49e --- /dev/null +++ b/docs/admin/03_socialaccounts.rst @@ -0,0 +1,34 @@ +Social accounts +=============== + +AlekSIS can authenticate users against third party applications using OAuth2 +or OpenID. + + +.. warning:: + Social accounts are **not** working with two factor authentication! If a user + authenticates with a social account, the two factor authentication is + ignored on login (but enforced for views that require two factor authentication later). + +Configuring social account provider +----------------------------------- + +For available providers, see documentation of `django-allauth +<https://django-allauth.readthedocs.io/en/latest/providers.html>`_. + +A new social account provider can be configured in your configuration file +(located in ``/etc/aleksis/``). + +Configuration example:: + + [auth.providers.gitlab] + GITLAB_URL = "https://gitlab.exmaple.com" + +After configuring a new auth provider, you have to restart AlekSIS and configure client id and secret in the Backend Admin interface. +Click "Social applications" and add a new application. Choose your +provider and enter client id and secret from your application and choose +your site: + +.. image:: ../_static/create_social_application.png + :width: 400 + :alt: Create social application diff --git a/docs/dev/04_materialize_templates.rst b/docs/dev/04_materialize_templates.rst index 3e7979cf1d39c317bb02832d43c84ab970b9d7a1..882604168d04ae6fd6085626305844a8648405e7 100644 --- a/docs/dev/04_materialize_templates.rst +++ b/docs/dev/04_materialize_templates.rst @@ -111,7 +111,6 @@ In your ``forms.py`` you can configure the layout of the fields like in the Edit _("Base data"), "short_name", Row("user", "primary_group"), - "is_active", Row("first_name", "additional_name", "last_name"), ), Fieldset(_("Address"), Row("street", "housenumber"), Row("postal_code", "place")), @@ -136,4 +135,4 @@ After you've loaded the template tag, you can simply generate the table like thi .. _MaterializeCSS: https://materializecss.com/ .. _django-material: https://pypi.org/project/django-material/ -.. _Extended Navbar with Tabs: https://materializecss.com/navbar.html#navbar-tabs \ No newline at end of file +.. _Extended Navbar with Tabs: https://materializecss.com/navbar.html#navbar-tabs diff --git a/pyproject.toml b/pyproject.toml index 70390be32989431319a6a0970e45966b608252c3..cd6804b8e8f908de61abea814e7ed27087f2594d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,7 @@ django-dbbackup = "^3.3.0" spdx-license-list = "^0.5.0" license-expression = "^21.6" django-reversion = "^4.0.0" -django-favicon-plus-reloaded = "^1.1.2" +django-favicon-plus-reloaded = "^1.1.5" django-health-check = "^3.12.1" psutil = "^5.7.0" celery-progress = "^0.1.0" @@ -125,8 +125,6 @@ python-gnupg = "^0.4.7" sentry-sdk = {version = "^1.4.3", optional = true} django-cte = "^1.1.5" pycountry = "^20.7.3" -django-image-cropping = "^1.6.1" -easy-thumbnails = "^2.8" [tool.poetry.extras] ldap = ["django-auth-ldap"]