From d7da44e73c4a9596fedb7cc9bb58fee0e8e57eb7 Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Wed, 12 Apr 2023 19:44:09 +0200 Subject: [PATCH 01/22] Fix FileNotFoundError in www_generate_dummydata During the generation of dummydata, the script looks at MEDIA_ROOT and removes an extra character. The tutorial specifies setting `MEDIA_ROOT = "/tmp/amelie_uploads/"`, however, this leads to trying to create `/tmp/amelie_uploads/ata/image/small/2023/04/12/1738-e7316016-c5dc-4dc9-99cd-a168cf11f0c3.png.jpg`. The fix is to set `MEDIA_ROOT = "/tmp/amelie_uploads"`and NOT `MEDIA_ROOT = "/tmp/amelie_uploads/"` This issue was found when @00maarten00 was trying to set up amelie on his laptop running Linux. I also verified (and fixed) the issue on MacOS. --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87df887..9716f35 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ Replace the `MEDIA_ROOT` and `DATA_EXPORT_ROOT` settings with proper locations. For example: *(make sure the directory exists!)* - MEDIA_ROOT = "/tmp/amelie_uploads/" + MEDIA_ROOT = "/tmp/amelie_uploads" ##### Populate the database python manage.py migrate From 7e3d93dab515e31d22a4a6a14a0fc757fe7ce4ff Mon Sep 17 00:00:00 2001 From: Maarten Meijer Date: Thu, 13 Apr 2023 12:20:00 +0200 Subject: [PATCH 02/22] Implemented a cleaner preferences update --- amelie/members/forms.py | 13 ++++++++----- .../members/templates/members/profile_changed.mail | 10 +++++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/amelie/members/forms.py b/amelie/members/forms.py index b92c3d1..4648133 100644 --- a/amelie/members/forms.py +++ b/amelie/members/forms.py @@ -44,13 +44,16 @@ def save(self, *args, **kwargs): old_person = Person.objects.get(id=self.instance.id) for field in self.changed_data: if field == "preferences": + old = [preference.name for preference in getattr(old_person, field).all()] + new = [preference.name for preference in self.cleaned_data[field].all()] + [preference.name for preference in self.instance.preferences.filter(adjustable=False)] + + added = list(set(new)-set(old)) + removed = list(set(old)-set(new)) + changes.append({ 'field': 'Preferences', - 'old': ', '.join([preference.name for preference in getattr(old_person, field).all()]), - 'new': ', '.join( - [preference.name for preference in self.cleaned_data[field].all()] + - [preference.name for preference in self.instance.preferences.filter(adjustable=False)] - ) + 'added': ' + ' + '\n + '.join(added) if added else '', + 'removed': ' - ' + '\n - '.join(removed) if removed else '' }) else: changes.append({ diff --git a/amelie/members/templates/members/profile_changed.mail b/amelie/members/templates/members/profile_changed.mail index 3c788b9..7ec1ca0 100644 --- a/amelie/members/templates/members/profile_changed.mail +++ b/amelie/members/templates/members/profile_changed.mail @@ -7,7 +7,15 @@ {% blocktrans %}{{ obj }} edited his/her profile in Amélie.{% endblocktrans %} {% for c in changes %} - * {{ c.field }}: {{ c.old }} --> {{ c.new }}{% endfor %} +{% if c.field == "Preferences" %} + * {{ c.field }}: +{{ c.added }} +{{ c.removed }} +{% else %} + * {{ c.field }}: {{ c.old }} --> {{ c.new }} +{% endif%} + +{% endfor %} {% blocktrans %}For more information, see:{% endblocktrans %} {% onlyhtml %}{% endonlyhtml %}https://www.inter-actief.utwente.nl{{ url }}{% onlyhtml %}{% endonlyhtml %} From bd1b9aa143ba01f37d31460fec8859f916bb6291 Mon Sep 17 00:00:00 2001 From: supertom01 Date: Mon, 17 Apr 2023 20:31:30 +0200 Subject: [PATCH 03/22] Restrict company banners on both the television and website to pictures only --- amelie/companies/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/amelie/companies/forms.py b/amelie/companies/forms.py index 9bdf604..871a3c2 100644 --- a/amelie/companies/forms.py +++ b/amelie/companies/forms.py @@ -62,7 +62,7 @@ def clean_app_logo(self): class BannerForm(forms.ModelForm): - picture = forms.FileField(label=_('Website banner'), widget=AdminFileWidget) + picture = forms.ImageField(label=_('Website banner'), widget=AdminFileWidget) start_date = forms.DateField(widget=DateSelector) end_date = forms.DateField(widget=DateSelector) @@ -72,7 +72,7 @@ class Meta: class TelevisionBannerForm(forms.ModelForm): - picture = forms.FileField(label=_('Television banner'), widget=AdminFileWidget) + picture = forms.ImageField(label=_('Television banner'), widget=AdminFileWidget) start_date = forms.DateField(widget=DateSelector) end_date = forms.DateField(widget=DateSelector) From 6333f828f00ac075145970d043336e399db994a9 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Wed, 19 Apr 2023 14:54:11 +0200 Subject: [PATCH 04/22] Add Study verification to group info endpoint --- amelie/members/views.py | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/amelie/members/views.py b/amelie/members/views.py index a7d96d6..fe05715 100644 --- a/amelie/members/views.py +++ b/amelie/members/views.py @@ -1352,6 +1352,16 @@ def _person_info_request_get_body(request, logger=None): logger.error(f"Permission denied, provided API key is incorrect.") raise PermissionDenied() + # If given, the 'verify' key should be a boolean value + if 'verify' in body and not isinstance(body['verify'], bool): + logger.error("Bad request, verify key was given but was not a boolean value.") + raise BadRequest() + + # If given, the 'departments' key should be a comma separated string value + if 'departments' in body and not isinstance(body['departments'], str): + logger.error("Bad request, departments key was given but was not a comma separated string value.") + raise BadRequest() + return body @@ -1361,11 +1371,14 @@ def _get_ttl_hash(seconds=3600): # noinspection PyUnusedLocal @lru_cache() -def _person_info_get_person(ia_username=None, ut_username=None, local_username=None, verify=False, +def _person_info_get_person(ia_username=None, ut_username=None, local_username=None, verify=False, departments=None, ttl_hash=None, logger=None): if logger is None: logger = logging.getLogger("amelie.members.views._person_info_get_person") + if departments is None: + departments = [] + del ttl_hash # ttl_hash is just to get the lru_cache decorator to keep results for only 1 hour person = None if ia_username is not None: @@ -1380,10 +1393,22 @@ def _person_info_get_person(ia_username=None, ut_username=None, local_username=N if ut_username[0] == 's': person = Person.objects.get(student__number=ut_username[1:]) logger.debug(f"Person found by student ut_username {ut_username}: {person}.") + if verify: - # TODO: Verify studies of person if department data is present in auth request. - logger.debug(f"Verifying study for {person} (unimplemented).") - pass + # Verify studies of person if department data is present in auth request. + logger.debug(f"Verifying study for {person}.") + + # Since UT years are from sept-aug and our year is from jul-jun, there are two months overlap. + # Never verify users in July or August, as this could allow members who are done studying to get an + # extra year as study long. + if not person.membership.is_verified() and datetime.date.today().month not in [7, 8]: + primary_studies = Study.objects.filter(primary_study=True).values_list('abbreviation', + flat=True) + for study in departments: + if study in primary_studies: + person.membership.verify() + break + elif ut_username[0] == 'm': person = Person.objects.get(employee__number=ut_username[1:]) logger.debug(f"Person found by employee ut_username {ut_username}: {person}.") @@ -1454,8 +1479,9 @@ def person_groupinfo(request): Retrieves group info about a person based on either their active member username or UT s- or m-number. Used by our authentication platform to determine permissions for external users (UT or social login). - This also verifies studies, because the UT department info will be passed to this endpoint in the body if - it was received by the authentication platform. This endpoint is called when a user logs in with their UT account. + This also verifies studies if requested, because the UT department info will be passed to this endpoint in the + body if it was received by the authentication platform. This endpoint is called when a user logs in with an + external account (UT, Google, GitHub, etc.). """ log = logging.getLogger("amelie.members.views.person_groupinfo") # Verify request and get the JSON body @@ -1464,7 +1490,8 @@ def person_groupinfo(request): # Access verified. Find the Person associated with the provided username. person = _person_info_get_person( ia_username=body.get('iaUsername', None), ut_username=body.get('utUsername', None), - local_username=body.get('localUsername', None), verify=True, ttl_hash=_get_ttl_hash(), logger=log + local_username=body.get('localUsername', None), verify=body.get('verify', False), + departments=body.get('departments', None), ttl_hash=_get_ttl_hash(), logger=log ) username = body.get('iaUsername', None) or body.get('utUsername', None) or body.get('localUsername', None) From b479bd0f6ac4a44683d5c261488364318bb93875 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Fri, 21 Apr 2023 14:42:27 +0200 Subject: [PATCH 05/22] Add some simple account management functions to profile, and translations --- amelie/settings/generic.py | 2 - amelie/tools/auth.py | 72 ++++++++++++ amelie/urls.py | 1 + amelie/views.py | 26 ++++- locale/nl/LC_MESSAGES/django.mo | Bin 254466 -> 256038 bytes locale/nl/LC_MESSAGES/django.po | 200 +++++++++++++++++++++----------- templates/profile_overview.html | 110 +++++++++++------- 7 files changed, 296 insertions(+), 115 deletions(-) diff --git a/amelie/settings/generic.py b/amelie/settings/generic.py index 17e3e53..b4129b0 100644 --- a/amelie/settings/generic.py +++ b/amelie/settings/generic.py @@ -185,8 +185,6 @@ 'amelie.style.context_processors.theme_context', # Injects the website theme if one is active 'amelie.api.context_processors.absolute_path_to_site', # Injects the absolute path to the site for API 'amelie.tools.context_processors.environment', # Injects environment context - 'social_django.context_processors.backends', - 'social_django.context_processors.login_redirect', ], 'loaders': [ 'django.template.loaders.filesystem.Loader', diff --git a/amelie/tools/auth.py b/amelie/tools/auth.py index 4105fe4..211ea44 100644 --- a/amelie/tools/auth.py +++ b/amelie/tools/auth.py @@ -204,3 +204,75 @@ def send_oauth_link_code_email(request, person, link_code): context={"link_code": link_code} )) task.send() + + +def get_user_info(request, person): + # Login to keycloak API + response = requests.post( + f"{settings.KEYCLOAK_API_AUTHN_ENDPOINT}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={"grant_type": "client_credentials", "client_id": settings.KEYCLOAK_API_CLIENT_ID, "client_secret": settings.KEYCLOAK_API_CLIENT_SECRET} + ) + access_token = response.json()['access_token'] + + # Find all users associated with the current user + all_users = [] + possible_usernames = [f"ia{person.pk}"] + if person.account_name: + possible_usernames.append(person.account_name) + if person.is_student() and person.student.student_number(): + possible_usernames.append(person.student.student_number()) + if person.is_employee() and person.employee.employee_number(): + possible_usernames.append(person.employee.employee_number()) + if person.ut_external_username: + possible_usernames.append(person.ut_external_username) + for username in possible_usernames: + response = requests.get( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users?exact=true&briefRepresentation=true&username={username}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + users = response.json() + if len(users) > 0: + response = requests.get( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users/{users[0]['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + user_data = response.json() + response = requests.get( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users/{users[0]['id']}/credentials", + headers={"Authorization": f"Bearer {access_token}"}, + ) + user_data['credentials'] = response.json() + all_users.append(user_data) + + return all_users + + +def unlink_totp(user_id, totp_id): + # Login to keycloak API + response = requests.post( + f"{settings.KEYCLOAK_API_AUTHN_ENDPOINT}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={"grant_type": "client_credentials", "client_id": settings.KEYCLOAK_API_CLIENT_ID, "client_secret": settings.KEYCLOAK_API_CLIENT_SECRET} + ) + access_token = response.json()['access_token'] + + response = requests.delete( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users/{user_id}/credentials/{totp_id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + +def unlink_acount(user_id, account_id): + # Login to keycloak API + response = requests.post( + f"{settings.KEYCLOAK_API_AUTHN_ENDPOINT}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={"grant_type": "client_credentials", "client_id": settings.KEYCLOAK_API_CLIENT_ID, "client_secret": settings.KEYCLOAK_API_CLIENT_SECRET} + ) + access_token = response.json()['access_token'] + + response = requests.delete( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users/{user_id}/federated-identity/{account_id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) diff --git a/amelie/urls.py b/amelie/urls.py index 3795485..a021566 100644 --- a/amelie/urls.py +++ b/amelie/urls.py @@ -23,6 +23,7 @@ path('i18n/', include('django.conf.urls.i18n')), path('profile/', views.profile_overview, name='profile_overview'), path('profile/edit/', views.profile_edit, name='profile_edit'), + path('profile////', views.profile_actions, name='profile_actions'), path('oidc/', include('mozilla_django_oidc.urls')), # General views diff --git a/amelie/views.py b/amelie/views.py index 09005b0..d3f9617 100644 --- a/amelie/views.py +++ b/amelie/views.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.decorators import login_required +from django.core.exceptions import BadRequest from django.db.models import Q from django.http import HttpResponseRedirect, HttpResponse from django.shortcuts import render, redirect @@ -24,6 +25,7 @@ from amelie.members.models import Person, Committee, StudyPeriod from amelie.education.models import Complaint, EducationEvent from amelie.statistics.decorators import track_hits +from amelie.tools.auth import get_user_info, unlink_totp, unlink_acount from amelie.tools.models import Profile from amelie.videos.models import BaseVideo @@ -133,7 +135,29 @@ def profile_edit(request): @login_required def profile_overview(request): - return render(request, "profile_overview.html") + try: + users = get_user_info(request, request.user.person) + except Exception as e: + logger.exception(e) + users = [] + return render(request, "profile_overview.html", context={'users': users}) + + +@login_required +def profile_actions(request, action, user_id, arg): + users = get_user_info(request, request.user.person) + if user_id not in [x['id'] for x in users] and not request.user.person.is_board(): + raise PermissionError() + if action == "unlink_totp": + if user_id and arg: + unlink_totp(user_id, arg) + elif action == "unlink_social": + if user_id and arg: + unlink_acount(user_id, arg) + else: + raise BadRequest() + + return redirect("profile_overview") @track_hits("Frontpage") diff --git a/locale/nl/LC_MESSAGES/django.mo b/locale/nl/LC_MESSAGES/django.mo index fd35094bdac5d3c1d7331bb6118b350904385acd..fc433dd73a58b285e18be2d1e4da6c2a46fb7a2a 100644 GIT binary patch delta 42813 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\n" "Language: en\n" "MIME-Version: 1.0\n" @@ -343,7 +343,7 @@ msgstr "Aangemeld" msgid "Accepted" msgstr "Geaccepteerd" -#: amelie/activities/models.py:539 amelie/activities/views.py:1347 amelie/activities/views.py:1353 amelie/activities/views.py:1359 amelie/members/models.py:279 amelie/personal_tab/templates/info/rfid_cards.html:45 +#: amelie/activities/models.py:539 amelie/activities/views.py:1347 amelie/activities/views.py:1353 amelie/activities/views.py:1359 amelie/members/models.py:279 amelie/personal_tab/templates/info/rfid_cards.html:45 templates/profile_overview.html:213 msgid "Unknown" msgstr "Onbekend" @@ -556,7 +556,7 @@ msgstr "" "\n" "Weet je zeker dat je inschrijfoptie %(object)s voor %(activity)s wilt verwijderen?" -#: amelie/activities/templates/activities/enrollmentoption_confirm_delete.html:19 amelie/activities/templates/activities/enrollmentoption_list.html:47 amelie/activities/templates/activities/enrollmentoption_list.html:58 amelie/activities/templates/activities/enrollmentoption_list.html:94 amelie/activities/templates/activities/enrollmentoption_list.html:106 amelie/activities/templates/activities/enrollmentoption_list.html:139 amelie/activities/templates/activities/enrollmentoption_list.html:148 amelie/activities/templates/activities/enrollmentoption_list.html:181 amelie/activities/templates/activities/enrollmentoption_list.html:190 amelie/activities/templates/activity.html:38 amelie/claudia/templates/claudia/extrapersonalalias_delete.html:19 amelie/companies/templates/companies/company_event.html:20 amelie/education/templates/education_event.html:19 amelie/members/templates/members/payment_confirm_delete.html:19 amelie/members/templates/person_edit_employee.html:13 amelie/personal_tab/templates/cookie_corner/transaction_delete.html:21 amelie/personal_tab/templates/info/rfid_cards.html:63 amelie/room_duty/templates/room_duty/balcony_duty/balcony_duty.html:26 amelie/room_duty/templates/room_duty/balcony_duty/delete.html:22 amelie/room_duty/templates/room_duty/pool/delete.html:42 amelie/room_duty/templates/room_duty/pool/list.html:24 amelie/room_duty/templates/room_duty/pool/persons_change.html:20 amelie/room_duty/templates/room_duty/table/change.html:41 amelie/room_duty/templates/room_duty/table/change_persons.html:20 amelie/room_duty/templates/room_duty/table/delete.html:22 amelie/room_duty/templates/room_duty/table/list.html:24 amelie/room_duty/templates/room_duty/table/overview.html:89 amelie/room_duty/templates/room_duty/table/room_duty_delete.html:30 amelie/room_duty/templates/room_duty/template/change.html:41 amelie/room_duty/templates/room_duty/template/delete.html:22 amelie/room_duty/templates/room_duty/template/list.html:23 amelie/weekmail/templates/weekmail/weekmail_wizard.html:83 templates/oauth2_provider/authorized-token-delete.html:13 templates/oauth2_provider/includes/user_oauth_table.html:9 templates/profile_overview.html:190 +#: amelie/activities/templates/activities/enrollmentoption_confirm_delete.html:19 amelie/activities/templates/activities/enrollmentoption_list.html:47 amelie/activities/templates/activities/enrollmentoption_list.html:58 amelie/activities/templates/activities/enrollmentoption_list.html:94 amelie/activities/templates/activities/enrollmentoption_list.html:106 amelie/activities/templates/activities/enrollmentoption_list.html:139 amelie/activities/templates/activities/enrollmentoption_list.html:148 amelie/activities/templates/activities/enrollmentoption_list.html:181 amelie/activities/templates/activities/enrollmentoption_list.html:190 amelie/activities/templates/activity.html:38 amelie/claudia/templates/claudia/extrapersonalalias_delete.html:19 amelie/companies/templates/companies/company_event.html:20 amelie/education/templates/education_event.html:19 amelie/members/templates/members/payment_confirm_delete.html:19 amelie/members/templates/person_edit_employee.html:13 amelie/personal_tab/templates/cookie_corner/transaction_delete.html:21 amelie/personal_tab/templates/info/rfid_cards.html:63 amelie/room_duty/templates/room_duty/balcony_duty/balcony_duty.html:26 amelie/room_duty/templates/room_duty/balcony_duty/delete.html:22 amelie/room_duty/templates/room_duty/pool/delete.html:42 amelie/room_duty/templates/room_duty/pool/list.html:24 amelie/room_duty/templates/room_duty/pool/persons_change.html:20 amelie/room_duty/templates/room_duty/table/change.html:41 amelie/room_duty/templates/room_duty/table/change_persons.html:20 amelie/room_duty/templates/room_duty/table/delete.html:22 amelie/room_duty/templates/room_duty/table/list.html:24 amelie/room_duty/templates/room_duty/table/overview.html:89 amelie/room_duty/templates/room_duty/table/room_duty_delete.html:30 amelie/room_duty/templates/room_duty/template/change.html:41 amelie/room_duty/templates/room_duty/template/delete.html:22 amelie/room_duty/templates/room_duty/template/list.html:23 amelie/weekmail/templates/weekmail/weekmail_wizard.html:83 templates/oauth2_provider/authorized-token-delete.html:13 templates/oauth2_provider/includes/user_oauth_table.html:9 msgid "Delete" msgstr "Verwijder" @@ -2454,7 +2454,7 @@ msgstr "Inter-Actief-account error" msgid "Inter-Actief account error" msgstr "Inter-Actief-account error" -#: amelie/claudia/templates/accounts/home.html:4 templates/login.html:44 +#: amelie/claudia/templates/accounts/home.html:4 msgid "Inter-Actief account" msgstr "Inter-Actief-account" @@ -7194,32 +7194,32 @@ msgstr "Betalingen" msgid "Totals" msgstr "Totalen" -#: amelie/members/views.py:181 +#: amelie/members/views.py:183 msgid "6 or more" msgstr "6 of meer" -#: amelie/members/views.py:198 +#: amelie/members/views.py:200 msgid "total" msgstr "Totaal" -#: amelie/members/views.py:269 +#: amelie/members/views.py:271 msgid "This person still has an active (unterminated) membership." msgstr "Deze persoon heeft nog een actief (niet beeindigd) lidmaatschap." -#: amelie/members/views.py:273 +#: amelie/members/views.py:275 #, python-brace-format msgid "This person has not paid one of their memberships ({year}/{next_year} {type})." msgstr "Deze persoon heeft een lidmaatschap nog niet betaald ({year}/{next_year} {type})." -#: amelie/members/views.py:282 +#: amelie/members/views.py:284 msgid "This person still has an outstanding balance on their personal tab." msgstr "Deze persoon heeft nog een openstaand streeplijstsaldo." -#: amelie/members/views.py:285 +#: amelie/members/views.py:287 msgid "This person isn't enrolled for one or more future activities." msgstr "Deze persoon staat ingeschreven voor een of meer toekomstige activiteit(en)." -#: amelie/members/views.py:300 +#: amelie/members/views.py:302 msgid "" "This person cannot be anonymized because of the following reasons:\n" "{}" @@ -7227,21 +7227,20 @@ msgstr "" "Deze persoon kan niet worden geanonimiseerd vanwege de volgende redenen:\n" "{}" -#: amelie/members/views.py:1441 +#: amelie/members/views.py:1520 #, python-brace-format -#| msgid "OAuth link code and instructions were sent to {person.incomplete_name()}." msgid "OAuth link code and instructions were sent to {name}." msgstr "OAuth link code en instructies zijn verzonden naar {name}." -#: amelie/members/views.py:1469 +#: amelie/members/views.py:1548 msgid "You are not allowed to manually delete the payments of free memberships." msgstr "Je mag van gratis lidmaatschappen de betaling niet handmatig verwijderen." -#: amelie/members/views.py:1472 +#: amelie/members/views.py:1551 msgid "You are not allowed to delete payments that are already (being) debited." msgstr "Je mag geen betalingen verwijderen die al geïncasseerd zijn/worden." -#: amelie/members/views.py:1476 +#: amelie/members/views.py:1555 #, python-format msgid "Payment %(object)s has been removed" msgstr "Betaling %(object)s is verwijderd" @@ -7553,7 +7552,7 @@ msgstr "" "\n" "Met vriendelijke groet," -#: amelie/oauth/templates/token_login.html:5 amelie/personal_tab/templates/pos/show_qr.html:19 amelie/personal_tab/templates/register/register_card_index.html:28 templates/basis.html:265 templates/login.html:4 templates/login.html:64 +#: amelie/oauth/templates/token_login.html:5 amelie/personal_tab/templates/pos/show_qr.html:19 amelie/personal_tab/templates/register/register_card_index.html:28 templates/basis.html:265 templates/login.html:4 templates/login.html:47 msgid "Log in" msgstr "Inloggen" @@ -9235,7 +9234,7 @@ msgstr "Log in om een kaart te registreren" msgid "You can log on with your student account (for example: s0123456) or your Inter-Actief account." msgstr "Je kunt inloggen met je studentaccount (bijvoorbeeld s0123456) of je Inter-Actief-account." -#: amelie/personal_tab/templates/register/register_card_index.html:19 +#: amelie/personal_tab/templates/register/register_card_index.html:19 templates/profile_overview.html:181 templates/profile_overview.html:220 msgid "Username" msgstr "Gebruikersnaam" @@ -9793,19 +9792,19 @@ msgstr "Nederlands" msgid "English" msgstr "Engels" -#: amelie/settings/generic.py:663 +#: amelie/settings/generic.py:658 msgid "Access to your name, date of birth, student number, mandate status and committee status." msgstr "Toegang tot je naam, geboortedatum, studentnummer, machtiging- en commissiestatus" -#: amelie/settings/generic.py:664 +#: amelie/settings/generic.py:659 msgid "Access to enrollments for activities and (un)enrolling you for activities." msgstr "Toegang tot inschrijvingen voor activiteiten en het in- en uitschrijven voor activiteiten" -#: amelie/settings/generic.py:665 +#: amelie/settings/generic.py:660 msgid "Access to transactions, direct debit transactions, mandates and RFID-cards." msgstr "Toegang tot transacties, incasso's, machtigingen en RFID-kaarten" -#: amelie/settings/generic.py:666 +#: amelie/settings/generic.py:661 msgid "Access to complaints and sending or supporting complaints in your name." msgstr "Toegang tot onderwijsklachten en het indienen of steunen van onderwijsklachten" @@ -9914,6 +9913,15 @@ msgstr "Populairste snacks:" msgid "Most healthy board member:" msgstr "Gezondste bestuurder:" +#: amelie/tools/auth.py:44 +#, python-brace-format +msgid "You were successfully logged in, but we could not verify your identity. This might be the case if your login session has expired. Please click here to log out completely and try again." +msgstr "Je bent succesvol ingelogd, maar we konden uw identiteit niet verifiëren. Dit kan het geval zijn als uw inlogsessie is verlopen. Klik hier om volledig uit te loggen en probeer het opnieuw." + +#: amelie/tools/auth.py:138 +msgid "You were successfully logged in, but no account could be found. This might be the case if you aren't a member yet, or aren't a member anymore. If you want to become a member you can contact the board." +msgstr "Je bent succesvol ingelogd, maar er kon geen account worden gevonden. Dit kan het geval zijn als je nog geen lid bent of geen lid meer bent. Om lid te worden kun je contact opnemen met het bestuur." + #: amelie/tools/calendar.py:126 msgid "[START] {}" msgstr "[START] {}" @@ -10405,12 +10413,12 @@ msgstr "Het is niet gelukt om een verbinding met Youtube te maken! Neem contact msgid "Could not connect to Streaming.IA! Please contact the WWW committee if this problem persists." msgstr "Het is niet gelukt om een verbinding met Streaming.IA te maken! Neem contact op met de WWW commissie als dit probleem blijft aanhouden." -#: amelie/views.py:360 +#: amelie/views.py:259 #, python-format msgid "CSRF-check failed. Reason: %s" msgstr "CSRF-controle mislukt. Reden: %s" -#: amelie/views.py:362 +#: amelie/views.py:261 msgid "CSRF-check failed." msgstr "CSRF-controle mislukt." @@ -10925,28 +10933,18 @@ msgid "If you do not have either of these accounts, you can link an external acc msgstr "Als je geen van beide accounts hebt, dan kan je een extern account, zoals een Google-, GitHub-, of Facebook-account, laten koppelen. Neem hiervoor contact op met het bestuur." #: templates/login.html:35 -msgid "Log in with your UTwente account" -msgstr "Log in met je UTwente account" +msgid "Log in with Inter-Actief" +msgstr "Log in bij Inter-Actief" #: templates/login.html:37 msgid "- or -" msgstr "- of -" -#: templates/login.html:42 -msgid "Log in with:" -msgstr "Inloggen met:" +#: templates/login.html:40 +msgid "Log in with legacy account:" +msgstr "Log in met een lokaal account:" -#: templates/login.html:52 -msgid "" -"\n" -" Login with your Inter-Actief or University of Twente account to link it to\n" -" your external account.\n" -" " -msgstr "" -"\n" -"Log in met uw Inter-Actief of Universiteit Twente account om deze te koppelen aan uw externe account." - -#: templates/login.html:67 +#: templates/login.html:50 msgid "Forgot password?" msgstr "Wachtwoord vergeten?" @@ -11292,38 +11290,70 @@ msgstr "" "Hieronder staat een overzicht van apps die toestemming hebben gekregen om je gegevens te gebruiken of wijzigen." #: templates/profile_overview.html:170 -msgid "Login providers" -msgstr "Login-providers" +msgid "User accounts and login providers" +msgstr "Gebruikersaccounts en inlogproviders" -#: templates/profile_overview.html:175 -msgid "The following login providers have been linked to your account:" -msgstr "De volgende login-providers zijn al aan jouw account gekoppeld:" +#: templates/profile_overview.html:174 +msgid "The following user account(s) have been found that are related to you." +msgstr "De volgende gebruikersaccount(s) zijn gevonden die aan u gerelateerd zijn." -#: templates/profile_overview.html:179 -msgid "Login provider" -msgstr "Login-provider" +#: templates/profile_overview.html:177 +msgid "You can link another account by logging out, and logging back in with the Google, LinkedIn or GitHub account you want to link." +msgstr "U kunt een ander account koppelen door uit te loggen, en weer in te loggen met het Google-, LinkedIn- of GitHub-account dat u wilt koppelen." -#: templates/profile_overview.html:180 -msgid "User-ID" -msgstr "Gebruikers-id" +#: templates/profile_overview.html:182 +msgid "Account type" +msgstr "Accounttype" -#: templates/profile_overview.html:198 -msgid "You have not linked any login providers yet." -msgstr "Je hebt nog geen login-providers gekoppeld." +#: templates/profile_overview.html:183 +msgid "Linked social accounts" +msgstr "Gekoppelde sociale accounts" -#: templates/profile_overview.html:203 -msgid "You can add another account to the following login providers:" -msgstr "Je kunt hier (nog) een account koppelen aan de volgende login-providers:" +#: templates/profile_overview.html:190 +msgid "Inter-Actief Active Members account" +msgstr "Inter-Actief-actieveledenaccount" -#: templates/profile_overview.html:210 -#, python-format +#: templates/profile_overview.html:193 +msgid "TOTP devices:" +msgstr "TOTP-apparaten:" + +#: templates/profile_overview.html:197 templates/profile_overview.html:227 +msgid "unlink" +msgstr "ontkoppelen" + +#: templates/profile_overview.html:202 +msgid "No TOTP devices" +msgstr "Geen TOTP-apparaten" + +#: templates/profile_overview.html:203 msgid "" "\n" -" Login using %(backend)s\n" -" " +" To configure a TOTP device, log in with your active members account and visit this page.\n" +" " msgstr "" "\n" -"Inloggen met %(backend)s" +"Om een TOTP-apparaat te configureren, log in met uw actieve ledenaccount en ga naar deze pagina." + +#: templates/profile_overview.html:209 +msgid "Integration account" +msgstr "Integratie-account" + +#: templates/profile_overview.html:211 +msgid "University of Twente account" +msgstr "Universiteit Twente account" + +#: templates/profile_overview.html:219 +msgid "Provider" +msgstr "Dienst" + +#: templates/profile_overview.html:230 +msgid "No linked accounts" +msgstr "Geen gekoppelde accounts" + +#: templates/profile_overview.html:237 +#| msgid "Your account should now be linked." +msgid "No user accounts could be retrieved" +msgstr "Er konden geen accounts opgehaald worden" #: templates/profile_unknown.html:5 templates/profile_unknown.html:11 msgid "Unknown user" @@ -11349,15 +11379,50 @@ msgstr "Log uit en ga terug" msgid "Add photos" msgstr "Foto's toevoegen" +#~ msgid "Log in with:" +#~ msgstr "Inloggen met:" + +#~ msgid "" +#~ "\n" +#~ " Login with your Inter-Actief or University of Twente account to link it to\n" +#~ " your external account.\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ "Log in met uw Inter-Actief of Universiteit Twente account om deze te koppelen aan uw externe account." + +#~ msgid "Login providers" +#~ msgstr "Login-providers" + +#~ msgid "The following login providers have been linked to your account:" +#~ msgstr "De volgende login-providers zijn al aan jouw account gekoppeld:" + +#~ msgid "Login provider" +#~ msgstr "Login-provider" + +#~ msgid "User-ID" +#~ msgstr "Gebruikers-id" + +#~ msgid "You have not linked any login providers yet." +#~ msgstr "Je hebt nog geen login-providers gekoppeld." + +#~ msgid "You can add another account to the following login providers:" +#~ msgstr "Je kunt hier (nog) een account koppelen aan de volgende login-providers:" + +#~ msgid "" +#~ "\n" +#~ " Login using %(backend)s\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ "Inloggen met %(backend)s" + #~ msgid "The login token does not exist or is expired." #~ msgstr "Het login-token is verlopen of bestaat niet." #~ msgid "Your account is currently not linked to a user. Please contact the board." #~ msgstr "Je gebruikersaccount is niet gekoppeld aan een user. Neem contact op met het bestuur." -#~ msgid "You were successfully logged in, but no account could be found. This might be the case if you aren't a member yet, or aren't a member anymore. If you want to become a member you can contact the board." -#~ msgstr "Je bent succesvol ingelogd, maar er kon geen account worden gevonden. Dit kan het geval zijn als je nog geen lid bent of geen lid meer bent. Om lid te worden kun je contact opnemen met het bestuur." - #~ msgid "You can log in with your UTwente account, or your Inter-Actief account." #~ msgstr "Je kunt inloggen met je UTwente-account, of met je Inter-Actief-account." @@ -13031,9 +13096,6 @@ msgstr "Foto's toevoegen" #~ msgid "Generated classes." #~ msgstr "Gegenereerde colleges" -#~ msgid "Provided to" -#~ msgstr "Gegeven aan" - #~ msgid "Gender is" #~ msgstr "Geslacht is" diff --git a/templates/profile_overview.html b/templates/profile_overview.html index c85310d..d2213af 100644 --- a/templates/profile_overview.html +++ b/templates/profile_overview.html @@ -1,5 +1,5 @@ {% extends "basis.html" %} -{% load i18n fieldsets provider_name %} +{% load i18n fieldsets %} {% block titel %} {% trans 'Profile overview' %} @@ -167,53 +167,77 @@

{% trans 'Apps' %}

-

{% trans 'Login providers' %}

+

{% trans 'User accounts and login providers' %}

- {% if backends.associated %} -

- {% trans 'The following login providers have been linked to your account:' %} -

- - - - - - - {% for assoc in backends.associated %} - - - - - - {% endfor %} -
{% trans 'Login provider' %}{% trans 'User-ID' %}
{{ assoc.provider|provider_name }}{{ assoc.uid }} -
- {% csrf_token %} - -
-
- {% else %} -

- {% trans 'You have not linked any login providers yet.' %} -

- {% endif %} -

- {% trans 'You can add another account to the following login providers:' %} + {% trans 'The following user account(s) have been found that are related to you.' %}

- -
    - {% for backend in backends.backends %} -
  • - - {% blocktrans with backend|provider_name as backend %} - Login using {{ backend }} - {% endblocktrans %} - -
  • +

    + {% trans 'You can link another account by logging out, and logging back in with the Google, LinkedIn or GitHub account you want to link.' %} +

    + + + + + + + {% for user in users %} + + + + + + {% empty %} + + + {% endfor %} - +
    {% trans 'Username' %}{% trans 'Account type' %}{% trans 'Linked social accounts' %}
    {{ user.username }} + {% if 'LDAP_ENTRY_DN' in user.attributes %} + {% trans "Inter-Actief Active Members account" %}
    +
    + {% if user.totp %} + {% trans 'TOTP devices:' %}
    +
      + {% for credential in user.credentials %} + {% if credential.type == "otp" %} +
    • {{ credential.userLabel }} ({% trans 'unlink' %})
    • + {% endif %} + {% endfor %} +
    + {% else %} + {% trans 'No TOTP devices' %}
    + {% blocktrans %} + To configure a TOTP device, log in with your active members account and visit this page. + {% endblocktrans %} + {% endif %} + + {% elif 'created_by' in user.attributes and 'amelie' in user.attributes.created_by %} + {% trans "Integration account" %} + {% elif 'created_by' in user.attributes and 'ut-saml' in user.attributes.created_by %} + {% trans "University of Twente account" %} + {% else %} + {% trans "Unknown" %} + {% endif %} +
    + + + + + + + {% for identity in user.federatedIdentities %} + + + + + + {% empty %} + + {% endfor %} +
    {% trans 'Provider' %}{% trans 'Username' %}
    {{ identity.identityProvider }}{{ identity.userName }}({% trans 'unlink' %})
    {% trans 'No linked accounts' %}
    +
    {% trans 'No user accounts could be retrieved' %}
From 3bef430aa0d4478779cb2a5cd487638a16d180d1 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Fri, 21 Apr 2023 16:01:50 +0200 Subject: [PATCH 06/22] Remove RADIUS config and refactor keycloak calls to an API class for maintainability --- amelie/settings/generic.py | 7 - amelie/tools/auth.py | 114 +++-------- amelie/tools/keycloak.py | 165 +++++++++++++++ amelie/tools/radius.dict | 404 ------------------------------------- amelie/views.py | 10 +- 5 files changed, 202 insertions(+), 498 deletions(-) create mode 100644 amelie/tools/keycloak.py delete mode 100644 amelie/tools/radius.dict diff --git a/amelie/settings/generic.py b/amelie/settings/generic.py index b4129b0..b95a3b6 100644 --- a/amelie/settings/generic.py +++ b/amelie/settings/generic.py @@ -77,13 +77,6 @@ # The LDAP host that is used to verify login attempts in the LDAP authentication module LDAP_HOST = 'hexia.ia.utwente.nl' -# The RADIUS login details used to verify login attempts in the RADIUS authentication module -RADIUS_HOST = 'radius1.utsp.utwente.nl' -RADIUS_PORT = 1645 -RADIUS_SECRET = b'etisbew_ai_www' -RADIUS_IDENTIFIER = 'interactief.utwente.nl' -RADIUS_DICT_LOCATION = os.path.join(BASE_PATH, 'amelie', 'tools', 'radius.dict') - # Caches that the website can use CACHES = { 'default': { diff --git a/amelie/tools/auth.py b/amelie/tools/auth.py index 211ea44..68e427b 100644 --- a/amelie/tools/auth.py +++ b/amelie/tools/auth.py @@ -14,6 +14,7 @@ from amelie.iamailer import MailTask from amelie.members.models import Person +from amelie.tools.keycloak import KeycloakAPI from amelie.tools.mail import PersonRecipient @@ -144,54 +145,39 @@ def filter_users_by_claims(self, claims): def get_oauth_link_code(person): - # Login to keycloak API - response = requests.post( - f"{settings.KEYCLOAK_API_AUTHN_ENDPOINT}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={"grant_type": "client_credentials", "client_id": settings.KEYCLOAK_API_CLIENT_ID, "client_secret": settings.KEYCLOAK_API_CLIENT_SECRET} - ) - access_token = response.json()['access_token'] + # Get keycloak API + kc = KeycloakAPI() # See if a Keycloak user already exists - response = requests.get( - f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users?username=ia{person.pk}", - headers={"Authorization": f"Bearer {access_token}"}, - ) - users = response.json() - user = users[0] if len(users) > 0 else None + users = kc.get_user_details_by_username(username=f"ia{person.pk}") + user = users[0] if users and len(users) > 0 else None three_days_from_now = round(time.mktime((datetime.datetime.now() + datetime.timedelta(days=3)).timetuple())) link_code = str(uuid.uuid4()) if user is None: # Create user last_name = f"{person.last_name_prefix} {person.last_name}" if person.last_name_prefix else person.last_name - response = requests.post( - f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users", - headers={"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}, - data=json.dumps({ - "username": f"ia{person.pk}", "firstName": person.first_name, "lastName": last_name, - "emailVerified": True, "enabled": True, "attributes": { - "created_by": "amelie", "localUsername": f"ia{person.pk}", "link_code": link_code, - "link_code_valid": f"{three_days_from_now}" - } - }) - ) - if response.status_code != 201: # 201 is user created - raise ValueError(f"Error creating KeyCloak user ia{person.pk} - {response.status_code} {response.content}") + + try: + kc.create_user(username=f"ia{person.pk}", first_name=person.first_name, last_name=last_name, + attributes={ + "created_by": "amelie", "localUsername": f"ia{person.pk}", + "link_code": link_code, + "link_code_valid": f"{three_days_from_now}" + }) + except ConnectionError as e: + raise ConnectionError(f"Error creating KeyCloak user ia{person.pk} - {e}") else: # Generate new link code for user new_attributes = user['attributes'] new_attributes["link_code"] = link_code new_attributes["link_code_valid"] = f"{three_days_from_now}" - response = requests.put( - f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users/{user['id']}", - headers={"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}, - data=json.dumps({ + try: + kc.update_user(user_id=user['id'], data={ "attributes": new_attributes }) - ) - if response.status_code != 204: # 204 is no content (user updated successfully) - raise ValueError(f"Error updating KeyCloak user ia{person.pk} - {response.status_code} {response.content}") + except ConnectionError as e: + raise ConnectionError(f"Error updating KeyCloak user ia{person.pk} - {e}") return link_code @@ -206,14 +192,9 @@ def send_oauth_link_code_email(request, person, link_code): task.send() -def get_user_info(request, person): - # Login to keycloak API - response = requests.post( - f"{settings.KEYCLOAK_API_AUTHN_ENDPOINT}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={"grant_type": "client_credentials", "client_id": settings.KEYCLOAK_API_CLIENT_ID, "client_secret": settings.KEYCLOAK_API_CLIENT_SECRET} - ) - access_token = response.json()['access_token'] +def get_user_info(person): + # Get keycloak API + kc = KeycloakAPI() # Find all users associated with the current user all_users = [] @@ -226,53 +207,22 @@ def get_user_info(request, person): possible_usernames.append(person.employee.employee_number()) if person.ut_external_username: possible_usernames.append(person.ut_external_username) + for username in possible_usernames: - response = requests.get( - f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users?exact=true&briefRepresentation=true&username={username}", - headers={"Authorization": f"Bearer {access_token}"}, - ) - users = response.json() - if len(users) > 0: - response = requests.get( - f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users/{users[0]['id']}", - headers={"Authorization": f"Bearer {access_token}"}, - ) - user_data = response.json() - response = requests.get( - f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users/{users[0]['id']}/credentials", - headers={"Authorization": f"Bearer {access_token}"}, - ) - user_data['credentials'] = response.json() + users = kc.get_brief_user_details_by_username(username=username) + if users and len(users) > 0: + user_data = kc.get_user_details_by_user_id(user_id=users[0]['id']) + user_data['credentials'] = kc.get_user_credentials_by_user_id(user_id=users[0]['id']) all_users.append(user_data) return all_users def unlink_totp(user_id, totp_id): - # Login to keycloak API - response = requests.post( - f"{settings.KEYCLOAK_API_AUTHN_ENDPOINT}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={"grant_type": "client_credentials", "client_id": settings.KEYCLOAK_API_CLIENT_ID, "client_secret": settings.KEYCLOAK_API_CLIENT_SECRET} - ) - access_token = response.json()['access_token'] - - response = requests.delete( - f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users/{user_id}/credentials/{totp_id}", - headers={"Authorization": f"Bearer {access_token}"}, - ) - + # Delete TOTP in Keycloak + KeycloakAPI().delete_credential(user_id=user_id, credential_id=totp_id) -def unlink_acount(user_id, account_id): - # Login to keycloak API - response = requests.post( - f"{settings.KEYCLOAK_API_AUTHN_ENDPOINT}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={"grant_type": "client_credentials", "client_id": settings.KEYCLOAK_API_CLIENT_ID, "client_secret": settings.KEYCLOAK_API_CLIENT_SECRET} - ) - access_token = response.json()['access_token'] - response = requests.delete( - f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users/{user_id}/federated-identity/{account_id}", - headers={"Authorization": f"Bearer {access_token}"}, - ) +def unlink_acount(user_id, provider_name): + # Delete TOTP in Keycloak + KeycloakAPI().delete_federated_identity(user_id=user_id, provider_name=provider_name) diff --git a/amelie/tools/keycloak.py b/amelie/tools/keycloak.py new file mode 100644 index 0000000..1b1a90d --- /dev/null +++ b/amelie/tools/keycloak.py @@ -0,0 +1,165 @@ +import logging +import requests +import json + +from django.conf import settings + + +class KeycloakAPI: + def __init__(self): + self.authn_endpoint = settings.KEYCLOAK_API_AUTHN_ENDPOINT + self.client_id = settings.KEYCLOAK_API_CLIENT_ID + self.client_secret = settings.KEYCLOAK_API_CLIENT_SECRET + self._access_token = None + self.log = logging.getLogger(self.__class__.__name__) + + def login(self): + response = requests.post( + f"{self.authn_endpoint}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={"grant_type": "client_credentials", "client_id": self.client_id, + "client_secret": self.client_secret} + ) + try: + self._access_token = response.json()['access_token'] + except (json.JSONDecodeError, KeyError) as e: + self.log.exception(f"Could not login to Keycloak API - " + f"status={response.status_code} content={response.content}") + raise ConnectionError(f"Could not login to Keycloak API, see logs for details - " + f"status={response.status_code}") + + @staticmethod + def _get_content(response): + if response.content: + try: + return response.json() + except json.JSONDecodeError: + return response.content + else: + return None + + def get(self, path, success_codes=None): + if self._access_token is None: + self.login() + + if success_codes is None: + success_codes = [200, 201, 204] + + response = requests.get( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/{path}", + headers={"Authorization": f"Bearer {self._access_token}"}, + ) + if response.status_code in success_codes: + return self._get_content(response) + else: + self.log.exception(f"Error calling GET on Keycloak API\n" + f"status={response.status_code} path={path}\n" + f"content={response.content}") + raise ConnectionError(f"Error calling GET on Keycloak API, see logs for details - " + f"status={response.status_code} path={path}") + + def post(self, path, data=None, success_codes=None): + if self._access_token is None: + self.login() + + if data is None: + data = {} + + if success_codes is None: + success_codes = [200, 201, 204] + + response = requests.post( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/{path}", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self._access_token}" + }, + data=json.dumps(data) + ) + if response.status_code in success_codes: + return self._get_content(response) + else: + self.log.exception(f"Error calling POST on Keycloak API\n" + f"status={response.status_code} path={path}\n" + f"content={response.content}") + raise ConnectionError(f"Error calling POST on Keycloak API, see logs for details - " + f"status={response.status_code} path={path}") + + def put(self, path, data=None, success_codes=None): + if self._access_token is None: + self.login() + + if data is None: + data = {} + + if success_codes is None: + success_codes = [200, 201, 204] + + response = requests.put( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/{path}", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self._access_token}" + }, + data=json.dumps(data) + ) + if response.status_code in success_codes: + return self._get_content(response) + else: + self.log.exception(f"Error calling PUT on Keycloak API\n" + f"status={response.status_code} path={path}\n" + f"content={response.content}") + raise ConnectionError(f"Error calling PUT on Keycloak API, see logs for details - " + f"status={response.status_code} path={path}") + + def delete(self, path, success_codes=None): + if self._access_token is None: + self.login() + + if success_codes is None: + success_codes = [200, 201, 204] + + response = requests.delete( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/{path}", + headers={"Authorization": f"Bearer {self._access_token}"}, + ) + if response.status_code in success_codes: + return self._get_content(response) + else: + self.log.exception(f"Error calling DELETE on Keycloak API\n" + f"status={response.status_code} path={path}\n" + f"content={response.content}") + raise ConnectionError(f"Error calling DELETE on Keycloak API, see logs for details - " + f"status={response.status_code} path={path}") + + def get_user_details_by_username(self, username): + return self.get(f"users?exact=true&username={username}") + + def create_user(self, username, first_name, last_name, attributes=None): + if attributes is None: + attributes = {} + data = { + "username": username, "firstName": first_name, "lastName": last_name, + "emailVerified": True, "enabled": True, "attributes": attributes + } + return self.post("users", data=data) + + def update_user(self, user_id, data=None): + if data is None: + data = {} + return self.put(f"users/{user_id}", data=data) + + def get_brief_user_details_by_username(self, username): + return self.get(f"users?exact=true&briefRepresentation=true&username={username}") + + def get_user_details_by_user_id(self, user_id): + return self.get(f"users/{user_id}") + + def get_user_credentials_by_user_id(self, user_id): + return self.get(f"users/{user_id}/credentials") + + def delete_credential(self, user_id, credential_id): + return self.delete(f"users/{user_id}/credentials/{credential_id}") + + def delete_federated_identity(self, user_id, provider_name): + return self.delete(f"users/{user_id}/federated-identity/{provider_name}") diff --git a/amelie/tools/radius.dict b/amelie/tools/radius.dict deleted file mode 100644 index 492565e..0000000 --- a/amelie/tools/radius.dict +++ /dev/null @@ -1,404 +0,0 @@ -# -# Version $Id: dictionary,v 1.1.1.1 2002/10/11 12:25:39 wichert Exp $ -# -# This file contains dictionary translations for parsing -# requests and generating responses. All transactions are -# composed of Attribute/Value Pairs. The value of each attribute -# is specified as one of 4 data types. Valid data types are: -# -# string - 0-253 octets -# ipaddr - 4 octets in network byte order -# integer - 32 bit value in big endian order (high byte first) -# date - 32 bit value in big endian order - seconds since -# 00:00:00 GMT, Jan. 1, 1970 -# -# FreeRADIUS includes extended data types which are not defined -# in RFC 2865 or RFC 2866. These data types are: -# -# abinary - Ascend's binary filter format. -# octets - raw octets, printed and input as hex strings. -# e.g.: 0x123456789abcdef -# -# -# Enumerated values are stored in the user file with dictionary -# VALUE translations for easy administration. -# -# Example: -# -# ATTRIBUTE VALUE -# --------------- ----- -# Framed-Protocol = PPP -# 7 = 1 (integer encoding) -# - -# -# Include compatibility dictionary for older users file. Move this -# directive to the end of the file if you want to see the old names -# in the logfiles too. -# -# Includes aren't used. (Jarmo van Lenthe, 23/06/2011) -#$INCLUDE dictionary.compat # compability issues -#$INCLUDE dictionary.acc -#$INCLUDE dictionary.ascend -#$INCLUDE dictionary.bay -#$INCLUDE dictionary.cisco -#$INCLUDE dictionary.livingston -#$INCLUDE dictionary.microsoft -#$INCLUDE dictionary.quintum -#$INCLUDE dictionary.redback -#$INCLUDE dictionary.shasta -#$INCLUDE dictionary.shiva -#$INCLUDE dictionary.tunnel -#$INCLUDE dictionary.usr -#$INCLUDE dictionary.versanet -#$INCLUDE dictionary.erx -#$INCLUDE dictionary.freeradius -#$INCLUDE dictionary.alcatel - -# -# Following are the proper new names. Use these. -# -ATTRIBUTE User-Name 1 string -ATTRIBUTE User-Password 2 string -ATTRIBUTE CHAP-Password 3 octets -ATTRIBUTE NAS-IP-Address 4 ipaddr -ATTRIBUTE NAS-Port 5 integer -ATTRIBUTE Service-Type 6 integer -ATTRIBUTE Framed-Protocol 7 integer -ATTRIBUTE Framed-IP-Address 8 ipaddr -ATTRIBUTE Framed-IP-Netmask 9 ipaddr -ATTRIBUTE Framed-Routing 10 integer -ATTRIBUTE Filter-Id 11 string -ATTRIBUTE Framed-MTU 12 integer -ATTRIBUTE Framed-Compression 13 integer -ATTRIBUTE Login-IP-Host 14 ipaddr -ATTRIBUTE Login-Service 15 integer -ATTRIBUTE Login-TCP-Port 16 integer -ATTRIBUTE Reply-Message 18 string -ATTRIBUTE Callback-Number 19 string -ATTRIBUTE Callback-Id 20 string -ATTRIBUTE Framed-Route 22 string -ATTRIBUTE Framed-IPX-Network 23 ipaddr -ATTRIBUTE State 24 octets -ATTRIBUTE Class 25 octets -ATTRIBUTE Vendor-Specific 26 octets -ATTRIBUTE Session-Timeout 27 integer -ATTRIBUTE Idle-Timeout 28 integer -ATTRIBUTE Termination-Action 29 integer -ATTRIBUTE Called-Station-Id 30 string -ATTRIBUTE Calling-Station-Id 31 string -ATTRIBUTE NAS-Identifier 32 string -ATTRIBUTE Proxy-State 33 octets -ATTRIBUTE Login-LAT-Service 34 string -ATTRIBUTE Login-LAT-Node 35 string -ATTRIBUTE Login-LAT-Group 36 octets -ATTRIBUTE Framed-AppleTalk-Link 37 integer -ATTRIBUTE Framed-AppleTalk-Network 38 integer -ATTRIBUTE Framed-AppleTalk-Zone 39 string - -ATTRIBUTE Acct-Status-Type 40 integer -ATTRIBUTE Acct-Delay-Time 41 integer -ATTRIBUTE Acct-Input-Octets 42 integer -ATTRIBUTE Acct-Output-Octets 43 integer -ATTRIBUTE Acct-Session-Id 44 string -ATTRIBUTE Acct-Authentic 45 integer -ATTRIBUTE Acct-Session-Time 46 integer -ATTRIBUTE Acct-Input-Packets 47 integer -ATTRIBUTE Acct-Output-Packets 48 integer -ATTRIBUTE Acct-Terminate-Cause 49 integer -ATTRIBUTE Acct-Multi-Session-Id 50 string -ATTRIBUTE Acct-Link-Count 51 integer -ATTRIBUTE Acct-Input-Gigawords 52 integer -ATTRIBUTE Acct-Output-Gigawords 53 integer -ATTRIBUTE Event-Timestamp 55 date - -ATTRIBUTE CHAP-Challenge 60 string -ATTRIBUTE NAS-Port-Type 61 integer -ATTRIBUTE Port-Limit 62 integer -ATTRIBUTE Login-LAT-Port 63 integer - -ATTRIBUTE Acct-Tunnel-Connection 68 string - -ATTRIBUTE ARAP-Password 70 string -ATTRIBUTE ARAP-Features 71 string -ATTRIBUTE ARAP-Zone-Access 72 integer -ATTRIBUTE ARAP-Security 73 integer -ATTRIBUTE ARAP-Security-Data 74 string -ATTRIBUTE Password-Retry 75 integer -ATTRIBUTE Prompt 76 integer -ATTRIBUTE Connect-Info 77 string -ATTRIBUTE Configuration-Token 78 string -ATTRIBUTE EAP-Message 79 string -ATTRIBUTE Message-Authenticator 80 octets -ATTRIBUTE ARAP-Challenge-Response 84 string # 10 octets -ATTRIBUTE Acct-Interim-Interval 85 integer -ATTRIBUTE NAS-Port-Id 87 string -ATTRIBUTE Framed-Pool 88 string -ATTRIBUTE NAS-IPv6-Address 95 octets # really IPv6 -ATTRIBUTE Framed-Interface-Id 96 octets # 8 octets -ATTRIBUTE Framed-IPv6-Prefix 97 octets # stupid format -ATTRIBUTE Login-IPv6-Host 98 octets # really IPv6 -ATTRIBUTE Framed-IPv6-Route 99 string -ATTRIBUTE Framed-IPv6-Pool 100 string - -ATTRIBUTE Digest-Response 206 string -ATTRIBUTE Digest-Attributes 207 octets # stupid format - -# -# Experimental Non Protocol Attributes used by Cistron-Radiusd -# - -# These attributes CAN go in the reply item list. -ATTRIBUTE Fall-Through 500 integer -ATTRIBUTE Exec-Program 502 string -ATTRIBUTE Exec-Program-Wait 503 string - -# These attributes CANNOT go in the reply item list. -ATTRIBUTE User-Category 1029 string -ATTRIBUTE Group-Name 1030 string -ATTRIBUTE Huntgroup-Name 1031 string -ATTRIBUTE Simultaneous-Use 1034 integer -ATTRIBUTE Strip-User-Name 1035 integer -ATTRIBUTE Hint 1040 string -ATTRIBUTE Pam-Auth 1041 string -ATTRIBUTE Login-Time 1042 string -ATTRIBUTE Stripped-User-Name 1043 string -ATTRIBUTE Current-Time 1044 string -ATTRIBUTE Realm 1045 string -ATTRIBUTE No-Such-Attribute 1046 string -ATTRIBUTE Packet-Type 1047 integer -ATTRIBUTE Proxy-To-Realm 1048 string -ATTRIBUTE Replicate-To-Realm 1049 string -ATTRIBUTE Acct-Session-Start-Time 1050 date -ATTRIBUTE Acct-Unique-Session-Id 1051 string -ATTRIBUTE Client-IP-Address 1052 ipaddr -ATTRIBUTE Ldap-UserDn 1053 string -ATTRIBUTE NS-MTA-MD5-Password 1054 string -ATTRIBUTE SQL-User-Name 1055 string -ATTRIBUTE LM-Password 1057 octets -ATTRIBUTE NT-Password 1058 octets -ATTRIBUTE SMB-Account-CTRL 1059 integer -ATTRIBUTE SMB-Account-CTRL-TEXT 1061 string -ATTRIBUTE User-Profile 1062 string -ATTRIBUTE Digest-Realm 1063 string -ATTRIBUTE Digest-Nonce 1064 string -ATTRIBUTE Digest-Method 1065 string -ATTRIBUTE Digest-URI 1066 string -ATTRIBUTE Digest-QOP 1067 string -ATTRIBUTE Digest-Algorithm 1068 string -ATTRIBUTE Digest-Body-Digest 1069 string -ATTRIBUTE Digest-CNonce 1070 string -ATTRIBUTE Digest-Nonce-Count 1071 string -ATTRIBUTE Digest-User-Name 1072 string -ATTRIBUTE Pool-Name 1073 string -ATTRIBUTE Ldap-Group 1074 string -ATTRIBUTE Module-Success-Message 1075 string -ATTRIBUTE Module-Failure-Message 1076 string -# X99-Fast 1077 integer - -# -# Non-Protocol Attributes -# These attributes are used internally by the server -# -ATTRIBUTE Auth-Type 1000 integer -ATTRIBUTE Menu 1001 string -ATTRIBUTE Termination-Menu 1002 string -ATTRIBUTE Prefix 1003 string -ATTRIBUTE Suffix 1004 string -ATTRIBUTE Group 1005 string -ATTRIBUTE Crypt-Password 1006 string -ATTRIBUTE Connect-Rate 1007 integer -ATTRIBUTE Add-Prefix 1008 string -ATTRIBUTE Add-Suffix 1009 string -ATTRIBUTE Expiration 1010 date -ATTRIBUTE Autz-Type 1011 integer - -# -# Integer Translations -# - -# User Types - -VALUE Service-Type Login-User 1 -VALUE Service-Type Framed-User 2 -VALUE Service-Type Callback-Login-User 3 -VALUE Service-Type Callback-Framed-User 4 -VALUE Service-Type Outbound-User 5 -VALUE Service-Type Administrative-User 6 -VALUE Service-Type NAS-Prompt-User 7 -VALUE Service-Type Authenticate-Only 8 -VALUE Service-Type Callback-NAS-Prompt 9 -VALUE Service-Type Call-Check 10 -VALUE Service-Type Callback-Administrative 11 - -# Framed Protocols - -VALUE Framed-Protocol PPP 1 -VALUE Framed-Protocol SLIP 2 -VALUE Framed-Protocol ARAP 3 -VALUE Framed-Protocol Gandalf-SLML 4 -VALUE Framed-Protocol Xylogics-IPX-SLIP 5 -VALUE Framed-Protocol X.75-Synchronous 6 - -# Framed Routing Values - -VALUE Framed-Routing None 0 -VALUE Framed-Routing Broadcast 1 -VALUE Framed-Routing Listen 2 -VALUE Framed-Routing Broadcast-Listen 3 - -# Framed Compression Types - -VALUE Framed-Compression None 0 -VALUE Framed-Compression Van-Jacobson-TCP-IP 1 -VALUE Framed-Compression IPX-Header-Compression 2 -VALUE Framed-Compression Stac-LZS 3 - -# Login Services - -VALUE Login-Service Telnet 0 -VALUE Login-Service Rlogin 1 -VALUE Login-Service TCP-Clear 2 -VALUE Login-Service PortMaster 3 -VALUE Login-Service LAT 4 -VALUE Login-Service X25-PAD 5 -VALUE Login-Service X25-T3POS 6 -VALUE Login-Service TCP-Clear-Quiet 7 - -# Login-TCP-Port (see /etc/services for more examples) - -VALUE Login-TCP-Port Telnet 23 -VALUE Login-TCP-Port Rlogin 513 -VALUE Login-TCP-Port Rsh 514 - -# Status Types - -VALUE Acct-Status-Type Start 1 -VALUE Acct-Status-Type Stop 2 -VALUE Acct-Status-Type Interim-Update 3 -VALUE Acct-Status-Type Alive 3 -VALUE Acct-Status-Type Accounting-On 7 -VALUE Acct-Status-Type Accounting-Off 8 -# RFC 2867 Additional Status-Type Values -VALUE Acct-Status-Type Tunnel-Start 9 -VALUE Acct-Status-Type Tunnel-Stop 10 -VALUE Acct-Status-Type Tunnel-Reject 11 -VALUE Acct-Status-Type Tunnel-Link-Start 12 -VALUE Acct-Status-Type Tunnel-Link-Stop 13 -VALUE Acct-Status-Type Tunnel-Link-Reject 14 - -# Authentication Types - -VALUE Acct-Authentic RADIUS 1 -VALUE Acct-Authentic Local 2 - -# Termination Options - -VALUE Termination-Action Default 0 -VALUE Termination-Action RADIUS-Request 1 - -# NAS Port Types - -VALUE NAS-Port-Type Async 0 -VALUE NAS-Port-Type Sync 1 -VALUE NAS-Port-Type ISDN 2 -VALUE NAS-Port-Type ISDN-V120 3 -VALUE NAS-Port-Type ISDN-V110 4 -VALUE NAS-Port-Type Virtual 5 -VALUE NAS-Port-Type PIAFS 6 -VALUE NAS-Port-Type HDLC-Clear-Channel 7 -VALUE NAS-Port-Type X.25 8 -VALUE NAS-Port-Type X.75 9 -VALUE NAS-Port-Type G.3-Fax 10 -VALUE NAS-Port-Type SDSL 11 -VALUE NAS-Port-Type ADSL-CAP 12 -VALUE NAS-Port-Type ADSL-DMT 13 -VALUE NAS-Port-Type IDSL 14 -VALUE NAS-Port-Type Ethernet 15 -VALUE NAS-Port-Type xDSL 16 -VALUE NAS-Port-Type Cable 17 -VALUE NAS-Port-Type Wireless-Other 18 -VALUE NAS-Port-Type Wireless-802.11 19 - -# Acct Terminate Causes, available in 3.3.2 and later - -VALUE Acct-Terminate-Cause User-Request 1 -VALUE Acct-Terminate-Cause Lost-Carrier 2 -VALUE Acct-Terminate-Cause Lost-Service 3 -VALUE Acct-Terminate-Cause Idle-Timeout 4 -VALUE Acct-Terminate-Cause Session-Timeout 5 -VALUE Acct-Terminate-Cause Admin-Reset 6 -VALUE Acct-Terminate-Cause Admin-Reboot 7 -VALUE Acct-Terminate-Cause Port-Error 8 -VALUE Acct-Terminate-Cause NAS-Error 9 -VALUE Acct-Terminate-Cause NAS-Request 10 -VALUE Acct-Terminate-Cause NAS-Reboot 11 -VALUE Acct-Terminate-Cause Port-Unneeded 12 -VALUE Acct-Terminate-Cause Port-Preempted 13 -VALUE Acct-Terminate-Cause Port-Suspended 14 -VALUE Acct-Terminate-Cause Service-Unavailable 15 -VALUE Acct-Terminate-Cause Callback 16 -VALUE Acct-Terminate-Cause User-Error 17 -VALUE Acct-Terminate-Cause Host-Request 18 - -#VALUE Tunnel-Type L2TP 3 -#VALUE Tunnel-Medium-Type IP 1 - -VALUE Prompt No-Echo 0 -VALUE Prompt Echo 1 - -# -# Non-Protocol Integer Translations -# - -VALUE Auth-Type Local 0 -VALUE Auth-Type System 1 -VALUE Auth-Type SecurID 2 -VALUE Auth-Type Crypt-Local 3 -VALUE Auth-Type Reject 4 -VALUE Auth-Type ActivCard 5 -VALUE Auth-Type EAP 6 -VALUE Auth-Type ARAP 7 - -# -# Cistron extensions -# -VALUE Auth-Type Ldap 252 -VALUE Auth-Type Pam 253 -VALUE Auth-Type Accept 254 - -VALUE Auth-Type PAP 1024 -VALUE Auth-Type CHAP 1025 -VALUE Auth-Type LDAP 1026 -VALUE Auth-Type PAM 1027 -VALUE Auth-Type MS-CHAP 1028 -VALUE Auth-Type Kerberos 1029 -VALUE Auth-Type CRAM 1030 -VALUE Auth-Type NS-MTA-MD5 1031 -VALUE Auth-Type CRAM 1032 -VALUE Auth-Type SMB 1033 - -# -# Authorization type, too. -# -VALUE Autz-Type Local 0 - -# -# Experimental Non-Protocol Integer Translations for Cistron-Radiusd -# -VALUE Fall-Through No 0 -VALUE Fall-Through Yes 1 - -VALUE Packet-Type Access-Request 1 -VALUE Packet-Type Access-Accept 2 -VALUE Packet-Type Access-Reject 3 -VALUE Packet-Type Accounting-Request 4 -VALUE Packet-Type Accounting-Response 5 -VALUE Packet-Type Accounting-Status 6 -VALUE Packet-Type Password-Request 7 -VALUE Packet-Type Password-Accept 8 -VALUE Packet-Type Password-Reject 9 -VALUE Packet-Type Accounting-Message 10 -VALUE Packet-Type Access-Challenge 11 -VALUE Packet-Type Status-Server 12 -VALUE Packet-Type Status-Client 13 diff --git a/amelie/views.py b/amelie/views.py index d3f9617..37cd89b 100644 --- a/amelie/views.py +++ b/amelie/views.py @@ -136,7 +136,7 @@ def profile_edit(request): @login_required def profile_overview(request): try: - users = get_user_info(request, request.user.person) + users = get_user_info(request.user.person) except Exception as e: logger.exception(e) users = [] @@ -145,9 +145,9 @@ def profile_overview(request): @login_required def profile_actions(request, action, user_id, arg): - users = get_user_info(request, request.user.person) - if user_id not in [x['id'] for x in users] and not request.user.person.is_board(): - raise PermissionError() + users = get_user_info(request.user.person) + if user_id not in [x['id'] for x in users]: + raise PermissionError("You are not associated with this user.") if action == "unlink_totp": if user_id and arg: unlink_totp(user_id, arg) @@ -155,7 +155,7 @@ def profile_actions(request, action, user_id, arg): if user_id and arg: unlink_acount(user_id, arg) else: - raise BadRequest() + raise BadRequest("Unknown action.") return redirect("profile_overview") From 9470f48f3e55535087e21acaa78d492b900cd9d8 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Fri, 21 Apr 2023 16:36:40 +0200 Subject: [PATCH 07/22] Only allow unlink for social providers --- amelie/settings/generic.py | 1 + amelie/tools/keycloak.py | 5 ++++- amelie/views.py | 5 ++++- templates/profile_overview.html | 6 +++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/amelie/settings/generic.py b/amelie/settings/generic.py index b95a3b6..97f24ae 100644 --- a/amelie/settings/generic.py +++ b/amelie/settings/generic.py @@ -897,3 +897,4 @@ KEYCLOAK_API_CLIENT_ID = "admin-cli" KEYCLOAK_API_CLIENT_SECRET = "" KEYCLOAK_API_AUTHN_ENDPOINT = "https://auth.ia.utwente.nl/realms/inter-actief/protocol/openid-connect/token" +KEYCLOAK_PROVIDERS_UNLINK_ALLOWED = ['github', 'google', 'linkedin'] diff --git a/amelie/tools/keycloak.py b/amelie/tools/keycloak.py index 1b1a90d..8b64f1c 100644 --- a/amelie/tools/keycloak.py +++ b/amelie/tools/keycloak.py @@ -162,4 +162,7 @@ def delete_credential(self, user_id, credential_id): return self.delete(f"users/{user_id}/credentials/{credential_id}") def delete_federated_identity(self, user_id, provider_name): - return self.delete(f"users/{user_id}/federated-identity/{provider_name}") + if provider_name in settings.KEYCLOAK_PROVIDERS_UNLINK_ALLOWED: + return self.delete(f"users/{user_id}/federated-identity/{provider_name}") + else: + raise PermissionError(f"Cannot unlink {provider_name} via the website, contact the System Administrators.") diff --git a/amelie/views.py b/amelie/views.py index 37cd89b..e01ebc6 100644 --- a/amelie/views.py +++ b/amelie/views.py @@ -140,7 +140,10 @@ def profile_overview(request): except Exception as e: logger.exception(e) users = [] - return render(request, "profile_overview.html", context={'users': users}) + return render(request, "profile_overview.html", context={ + 'users': users, + 'providers_unlink_allowed': settings.KEYCLOAK_PROVIDERS_UNLINK_ALLOWED + }) @login_required diff --git a/templates/profile_overview.html b/templates/profile_overview.html index d2213af..1ccddf2 100644 --- a/templates/profile_overview.html +++ b/templates/profile_overview.html @@ -224,7 +224,11 @@

{% trans 'User accounts and login providers' %}

{{ identity.identityProvider }} {{ identity.userName }} - ({% trans 'unlink' %}) + {% if identity.identityProvider in providers_unlink_allowed %} + ({% trans 'unlink' %}) + {% else %} + {% trans 'Cannot unlink' %} + {% endif %} {% empty %} {% trans 'No linked accounts' %} From 38b067a79bb90032bb0323e1f90a5cc3a7088488 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Fri, 21 Apr 2023 16:40:11 +0200 Subject: [PATCH 08/22] Translations --- locale/nl/LC_MESSAGES/django.mo | Bin 256038 -> 256089 bytes locale/nl/LC_MESSAGES/django.po | 30 +++++++++++++++++------------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/locale/nl/LC_MESSAGES/django.mo b/locale/nl/LC_MESSAGES/django.mo index fc433dd73a58b285e18be2d1e4da6c2a46fb7a2a..80007a576e371dabd238c45d97352a332e937b7b 100644 GIT binary patch delta 40473 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

W_wWDB9ui=!mCbINLc z5mRVIYthwl7@dM+=>7kobDjI6uo~*49qNuQ+?DEBqQxA3CR!>`@GMsw)ZgP|j-_c*YpUqru0 zLw^cgMmfI-=0|&80<*Cinj78FoEe2R@OCVZo6s%!0M^BS(W$KSWr%DWq@7gKj{}o$ zB$mPH;X?8V`oMa0u3tx!Yd1P&|3f>T917>pN6XdFjy6W??SdxjP_(1Bpt-UP?{@vK z=D?BE`6`6I8(Qv-eQ+$ARBxaaeu;MAWIUhqa7eanG-3_W23n)_bV74tIQsmwc>Z2Y z|Nj3e4otSq=!oBsdA<%-LX)yRTH#wM3J&Bie!P*buKqm+w+^DpsPov?00` zT@8EC`~E)4`d`jL<~QL_t5>1RV(GUbnbx2Ue2hNu1G*}H#a4JWUT<+MBwcT8%=w$p zk#9n$?oG_bL+EPz4`0Kq?^2=S?caqteHRVY7icJdKr1>Euje@)&X+=yu{xTxUD0Ja z7TtK}p;NRnmS09+yZg}G`3H?q{?zxO!i&*RRYNOihF07g-HNB64?c``Y<(=higsi# zTH(*=-2Q_m;{`v2ji(qoklJWFm!e6T>cxQ-k3w_cX7oG%UbMmo(1@%+r)C4Xu3tv) z{|c@6B>G&|iI6k7(1?^n&$mYFYme5`8+k634CbH?C&r;4l`Erfp^-R_en|X>j=b3a zLIoAk^L1jm3p%2HXpUTmF2ftqNZf)Z?_4yO7Ghr4{}K+|vDTm?*@cGwOEi@Kp-Fh* zkD)^q(2zGrM|36HvC-)LH=;>5H@X^!QhpsT!_q&6h)=+aX+ODx14Fe69lO(VTb!jl?VHKz5@8`2vmDFX({I{u;mk&p#O| zEQyA?IoiWs(Q)V;&POX;6nz|R=y`NxZ=($yj^}?wNB$q0bVYs(Ia4Ouzuq9r=26WV_Iieude18jV1)-$Tb6p(E>tULS;ZcqZC`yU}_UqXSx(;=mEV zfR5z7c*7Uik@C-IQa1V{TRZv}Uw% zv<*5H-O!iL0JNin&|J96^R%B_&w*Jw0Ugm@Xeb{-D_9u*aJsoFn&AmLcFjY?Z`)Hg+HMU{)YD1VNgFPbMSX^E-X9314} zZES=q^Jb<01;jy|Ou6HEjEs=JhKneFdVW^&5aqEKWTpSDNY4CO$=94uq2G?fF3d{* ze(#zBS?Rakc6^%a1q)`S|F-NloK5+{LRtO=g;cVbgYKN@P&kBY5nfCA5WbF`i)1B} zs5p1gtn|0yjAB{ozkpDxco^xc*q{4~m&i*0bNz|<3+1i&B0hUjR{GxwUQ;q8>)Ys* z?Ms(gf4_5(MFaogn_S2$m6iS%3h$yL&MqB7{1Q&3{1#r1&9k%8zl>Uk4&ZBagQ`^~ zEB!Yex?&T`FQKdCBs%A3(a-twFUd+Sb^TZ6!1X#7-CA!!m(3zH2bRb4Psa0G(F%8> zS^p8btPW!p{0$vpsj?wgTBF|$ebEliz%uwSrd&p^a8Q8=>_@ZouX17C7cZZc{sE*0 z`amc2{%g@BorNaf4!i*OqRaFv^uAN*h%cxR>MMl~s4hCdW))cfKG>cU+82$$aCBWy zL%#zSq7^+G-G+{IFLuJeqwOk&^1aam=!nZ!3d_4C8mWMtjh=rLUDuzW&lRj5w(d@t-oT<$(T?AY=iprQ zHJ`eV14p_T9mx~uRBVdpccAa_52Jsg50I9&8Zo&ybOK* zX{_q{f1U#?IE-fVDXff{HAAvgL$kR)I`aN#h2zmAx)`AAPX~BUvc14qG5Sjx^ z(9l1PcJKqVfz#0(%|gQ$qEk~7O}+-v_GrWXVtF_^^2wOqknkec|1u5+aA7mLTnaP~ zugfB6(zHOo?Jh^>ZUWkoJEHev7s`*JJKzbdhUd1(O3GqAG$Q@c2+qL@xERy_{jaSY zROH0RXvJsI9_DEolBY5n;{ND6;s&&#C($H&3$yW;c>cmxVW+H)H8|f6O}@$KbMw$u zu@+N?=sgY`>1XJTzoMbc)jEuz7@Fzk7Q1OTz>0&>r^2#yAe`>62*acA*U&MA!Ep=vy$OP3TZ#^#0E1cfNuG5?a>H5j^6() zy1cf>@<-^1zeOWfxPADnFO}lJbzcSzeM7Y8-O-U=i{`=|SPoaCNw_omCEDPx=<}H! z!bppw9k>+hV_&r4dFV&!18B~r_Hf|xIgO6sUv%zrcMKseicU#c^q0{s(J!Sd(1xd> zIWaF@e+0dMGuqLQ&<*Z5I?%Jxf}PUOrIN}VWOJb@dc&1y4=3VuT#t6Ba_8{UX^77K zwOA#Cw;TRNd1aT-kw?0Q2yQ^9Y!~*xQ`iUFcMGd%DK>Wff53t3F<1Aj^q=3=L`S+2 z&DPy$^8AK&q-2jU!YXLCw~yt1=tw7`9lR6G`h{qOABpEzqe=fPrr-bX#v2Zx4;)5w z;0H96=k^StE{pC1_0Z7vLK_;1HZT$Gz)k3Cxd)x|M`C#m+Tj=C`L{6ToPQ8c96&p8 zB>F$}`YCj3GA;|tt0>xV6SRT$XtMQ)UWF#(l<0hPK&#OEx1syO&dXT;BRM$12}9DW zS7>kydgBzd;+a?n7hqj{6C2>4*cxm04sXE;XouIJTkvz}YxxkmnvP*MCVjHfza1;n zhxKnyhj79X-hgKH{8)Y@x(V&zPITm7V+Z^Zor?N>!}U&R=!c>E$9OcNQ_!tF70dI{ zdKP)Yxm$^D5KqSP0d!d%M(6w_I=A`zg%Os=PLyj!r=k1Ao9IZ7VpTjFuUEc2E15-k z61KwAcqyhD_0LNGo^Lu{$B7f@h5iG=TrWl6Vz1+TJc5pF+!bNulhF|0g-+RhSPLJH z<@eAnc|RJlQ&<^W3`}omsbmxfhVbriB3Xe>!Q1G_KSf9MM=a;NGK9D~+F&zuS++yx zzBd}`!Dz>>jprw!A)kpxb}pv>{r@r!{E~S#-tZz?(GGO(cE|IdqRZzfdjD@|?wmoB zGXJ13MQzdZ1JI6*M+Y<$?eM*^ycpB}{qH9|;KFn9!WOjQ_pkzfgeKMBXs+ZL9PYmm z?ND*_dS!HkjnD|RMH{>ftKb!A$L@&dmto2Z9_PRwzJQMW?dZPf7t!y~k)K36@@G8% zAKGA^A>m7^DB6(;*c)fW@;>zWFVQJDI)wG_#_$IxTwVo+hK^K0mt#{jgqNWa8iGD} z4SN4gXl^V(H@4wvUtN&=t!PN8`y?+ z;7Ig$tVlWMRbiv5fiBb5Xnp^jY+Qx6!%$6z$=$=r7om z@?Yp2Hn=)`4|GH4cp^G=)6wT<$MSt>q?TeM+=M>=KeWA6*6`57Lgpz3JknxPGM59d?K5Dx6oSTsxT!20+s zIu*yz5dV%gRB~j9L>V;c2B94pgT5=Kp!>;PXvmkLIq@`>!#(I!{*LMY4bmdlgwN!b zSd9x~aSSfS5twsS_#n9sZD?(DBN~y{(C>wJFr6E*{5v}L`9_BhRYJGuX6Stbu?pWG z$@Lsm#>MCecAz2u7_ImO8i|}^!iWl?AuJcm_0j9?&<^#E*9W1wF*aVGiYDJ3SOMo_ z`oI6Ri35AI3+=$6=&$J9<+wJ4x*&Qz8=aE6Xs8EbOB{!la09w5KSSRgC($V_a$Oie zS8PxD%IjGF_HY#^?9tO$1NWofewo*Y24|v4wE(N)!{|ox7McU^V`uyfo$In=v(kSe z+8(c^ye5_lj0-<6OvH|ye`*}-zX=DwaiTib7$1HJy%HVC!|3@P=<@poZMfQmkPCIt zBx{aFup1hYA?U~_p&fh(oyyhd6m3OU(asbHR`4;_#c#0<7N3}v{+CaK@Jh;?u@4rW z6sBYh+R#mCMR%if`!JfM%VT*R+JTK|2e!uZpQBTo`kDii?-z9amY5vA?;D|8Xn%By zrl1YYM3Zwa+VSORPCSbZ@C~$Me_}Yk0}c;TM>D(E2OQ2+7qf zN2hWQx-Wc$Rq>}=SpQu)C~<36`VWz>L9_flyc|p47S`)j>_B+~TG46r`lYk7(*N{g z2{xl#`1a6&Uiduar|~Krc1P&Qo9Hq=jJ>erY8g2M` z^!jG3h)2+!Eze!y{#JM&<#E^$lR4p~)(Fj!tFao+#__l*#etzHH#dC$_e48%3z{r% zqi@Im(5WdhFNC%MR-rrw8{>m$^6o)b$xmoU&zm3Kb`8*SfAlSQ8#;j0S`IGaU=KRt z6X?1vad+5~yPm4_iJEreZSsu2_#I)p0Z;T^d!T#tcrV~Q?2l{l1SX3^QvDC@NV$izk~eS& z4#%=fLJr)8iz%DgNchvLzBrKbzql2z zcr@fdiO0fJ)I;}&5opf5fF1E0?1I&ohd=wh30(#6qsu&NMVR}Z==lXJSpO!^PEKUw z*XX*;Tp3nFU$lWc&>VOkr{TBw9S&U;rlRBO@CTCX(46@J-4AL!9zIO2#oCnDVQ%~q z&&Tf`Plb=lKRDqQ`xoZLtTkc8`OxK58PCBEm;<}v71#@3!S#4KTkq(#S?Ry5vi*s0 zUzI0Ay*1H2zY)5{cf|9sM~VY`a%DU*2Ho4I;<-3CdOzl&yac`farC}T=(>L!&6#h} zh-N$$Uc<%F0aZt@H%2?s7wtf51P4|)5sknt=m_Va4c>#^_%IrQHJF7@qYXccR=g*A z0uAxG>q18^j8;acvNbxz{tq&t^gJmgSj_xDV(E&b(KDQMwzynDBvyM4%PX336?zebD-eMh8?E3%dTBao{rSiB^0+I4Q$yKs1Mj;ZPincI-VgcaC7H5C?y7V8eNy3l$bZLsky$ zKpiyn&C%TGfzEj!w1M%+UY^{Dj(7pO0j)rnYm<#(y4~`E6l$sR8$zf z;bL^eRq=Lgf*WuvdjGiRLkK6M4bQ-vaS_^~f-i(5y$GF}(&$HN6U@f$SO&+v!20*~ zx_}d|^C!>>-bNex03GRmbOZSrUH4^P3`uu0nj5RJ32u+)|3P!3+)H7~+M=sw0=gWh zpdGq3#eqG15WBO1Jd39(|F=2(^_$aM!fz_CdpW%Ioa#1Z)>Q(1A2W5vK&*%V;s2dx8VC&Y+LvXLf_yx%GYiWb9E3u zpnMi*;I19v7nYsh2qAtc`VG$K`h{pR0stT&dUJPU8bC$K$M zeUF@R{ZHh;b-fmc~o7e2=2ywcrcbv zpb<%(;lKwn4uuMHVJ78*Xaz;k>t)dqRKZ+W56yw*=zSg02=qnYDZ|i>>IQVDyE&Hc zLnF5c*{D*<1`gbjwxKVX{beeiv>L!Y4)|A2<@Pqd<3hr`G&Lbu$SXvbT{a!2&J z0qE3RgEo8v+TJaAq3eGR2UfTg?Z7kfhAn95cA(4YlX(3{bi{w6dw-6vLqoaI28yB` zDuWKB4myyQXgimo&kx6x4~*x)hNq!(eFtXYd~^rAFP`6s_IwLEhda=U4x%0W4&8wM zMc;}S9|<2EO|U%WUg-53(W!mt2Aq7^no zD{6ywq#xRWA+bCL&4o$P>1aK(;`RH{`n-t9yB*TM?3a4 z+Q9d*{9C;KH`+kvvG9C;bYP{?ddi~>*TT}S|27;L`m50fMxhUkLmQZajxdGJ;k{@@ zE6@%qd{g_Ra^ZWW4lXmCRfh<*HLVyLN*nbsInQ xc)QH(|5tixce~8>H|8IkJ$Cen$=Tz^PQG^B`0*pIyK~CxnJ-OwJ?pv3{|CHnP&NPn delta 40453 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\n" "Language: en\n" "MIME-Version: 1.0\n" @@ -9784,27 +9784,27 @@ msgstr "Er zijn nog geen kamerdienstschema-templates." msgid "Add office duty template" msgstr "Kamerdienst-template toevoegen" -#: amelie/settings/generic.py:128 +#: amelie/settings/generic.py:121 msgid "Dutch" msgstr "Nederlands" -#: amelie/settings/generic.py:129 +#: amelie/settings/generic.py:122 msgid "English" msgstr "Engels" -#: amelie/settings/generic.py:658 +#: amelie/settings/generic.py:651 msgid "Access to your name, date of birth, student number, mandate status and committee status." msgstr "Toegang tot je naam, geboortedatum, studentnummer, machtiging- en commissiestatus" -#: amelie/settings/generic.py:659 +#: amelie/settings/generic.py:652 msgid "Access to enrollments for activities and (un)enrolling you for activities." msgstr "Toegang tot inschrijvingen voor activiteiten en het in- en uitschrijven voor activiteiten" -#: amelie/settings/generic.py:660 +#: amelie/settings/generic.py:653 msgid "Access to transactions, direct debit transactions, mandates and RFID-cards." msgstr "Toegang tot transacties, incasso's, machtigingen en RFID-kaarten" -#: amelie/settings/generic.py:661 +#: amelie/settings/generic.py:654 msgid "Access to complaints and sending or supporting complaints in your name." msgstr "Toegang tot onderwijsklachten en het indienen of steunen van onderwijsklachten" @@ -9913,12 +9913,12 @@ msgstr "Populairste snacks:" msgid "Most healthy board member:" msgstr "Gezondste bestuurder:" -#: amelie/tools/auth.py:44 +#: amelie/tools/auth.py:45 #, python-brace-format msgid "You were successfully logged in, but we could not verify your identity. This might be the case if your login session has expired. Please click here to log out completely and try again." msgstr "Je bent succesvol ingelogd, maar we konden uw identiteit niet verifiëren. Dit kan het geval zijn als uw inlogsessie is verlopen. Klik hier om volledig uit te loggen en probeer het opnieuw." -#: amelie/tools/auth.py:138 +#: amelie/tools/auth.py:139 msgid "You were successfully logged in, but no account could be found. This might be the case if you aren't a member yet, or aren't a member anymore. If you want to become a member you can contact the board." msgstr "Je bent succesvol ingelogd, maar er kon geen account worden gevonden. Dit kan het geval zijn als je nog geen lid bent of geen lid meer bent. Om lid te worden kun je contact opnemen met het bestuur." @@ -11317,7 +11317,7 @@ msgstr "Inter-Actief-actieveledenaccount" msgid "TOTP devices:" msgstr "TOTP-apparaten:" -#: templates/profile_overview.html:197 templates/profile_overview.html:227 +#: templates/profile_overview.html:197 templates/profile_overview.html:228 msgid "unlink" msgstr "ontkoppelen" @@ -11347,11 +11347,15 @@ msgid "Provider" msgstr "Dienst" #: templates/profile_overview.html:230 +#| msgid "unlink" +msgid "Cannot unlink" +msgstr "Kan niet ontkoppelen" + +#: templates/profile_overview.html:234 msgid "No linked accounts" msgstr "Geen gekoppelde accounts" -#: templates/profile_overview.html:237 -#| msgid "Your account should now be linked." +#: templates/profile_overview.html:241 msgid "No user accounts could be retrieved" msgstr "Er konden geen accounts opgehaald worden" From 8bb58cb961fb309dc5cf2169376dca5d4a27db86 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Sun, 23 Apr 2023 12:21:30 +0200 Subject: [PATCH 09/22] Fix incorrect steps in oauth mail + translation --- .../tools/templates/send_oauth_link_code.mail | 1 + locale/nl/LC_MESSAGES/django.mo | Bin 256089 -> 256186 bytes locale/nl/LC_MESSAGES/django.po | 18 +++++++++++------- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/amelie/tools/templates/send_oauth_link_code.mail b/amelie/tools/templates/send_oauth_link_code.mail index ccbc75e..9862644 100644 --- a/amelie/tools/templates/send_oauth_link_code.mail +++ b/amelie/tools/templates/send_oauth_link_code.mail @@ -12,6 +12,7 @@ You can connect an external account through the following steps:{% endblocktrans {% onlyhtml %}

  • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Go to our website and click "Login"' %} {% onlyhtml %}
  • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Choose your preferred login service and log in' %} +{% onlyhtml %}
  • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Choose the option "Try Another Way"' %} {% onlyhtml %}
  • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Choose the option "I have a link code"' %} {% onlyhtml %}
  • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Enter the following link code:' %} {% onlyhtml %}{% endonlyhtml %}{{ link_code }}{% onlyhtml %}{% endonlyhtml %} {% onlyhtml %}
  • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Your account should now be linked.' %}{% onlyhtml %}
{% endonlyhtml %} diff --git a/locale/nl/LC_MESSAGES/django.mo b/locale/nl/LC_MESSAGES/django.mo index 80007a576e371dabd238c45d97352a332e937b7b..30f8c5c413dcad880da64518b7654361e580ab50 100644 GIT binary patch delta 40541 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 Kw=?bjqW=e_)m*y( delta 40462 zcmYM-cifNF|G@FfFBOZ{H2&pD6B@At>=d7W#V@jmZ!uIs+Z_r?AB55Av2b+}-T2NM48?L0|R0@1Hi!+`G=a1uX z%H1|3$vyZa-h?eSCdsAv626A#ZAy|oxF5fu{bbpbR7wNWHz&!$%p_U0!NJ~VXo3rRA9^IxMK?et=Z$dh<8<&rN^7p}m| zu+hs&vegRj;@}2s{R*SP7x5!(_iB>7gT=Qe$=!Gyt$6B=Fp@*~59LR9Cdt*fY*!d* z4u-jaazAX&fWE@}vGki^K+nA$o?o(?0dXxELOf1m8Cn~T7wt`wPjCh{$(1C1-c6D< zl<)qKPBP-U^lmiesUIgvPdtnaI;p`!o$v&X!P=iD3A2(c#Rfdr;b4;7NO>mV{1eZ7 zMnK1L@XMhjX@x^SPm(Kf1(F;|))%42!?8c*_J@2U-71l36$jz2R51qSAkdbvzeaP~MCs@F#Rx zu_9iA-LVK>gRX{~(R#nbLRgqx=R&N6=V2?%?}F~Zfju08hG+p6#h2pw573S!85v1g zxEO0=OSD51u^=vqZo*oWci=_%YdoJXGb71Oxg_Sqidca5lN#|vOSIt*u{;=Wr#uo3 z?GI=Lf8!09BP%1xgEO%c-j0s=eS8f6j@MV_2p!shgE;>=*2hNYWF)C(9E{^&CT_rr zSUG1#I*TtwJ2W3#;3}+xN71=0oGT+?ktE|V7fwV+b|X3!v(eBmjy{3sQhpU}=bc;` zsq~0`PB60E=A^B2pPGH&1ck9QHs*+8?cO8eWXE&@5kqX73KnkDsC;KY=#< z3+BbY(ED-}3>_|kPC-pHf^D%d4o4%Ix;`E}gjV!oyx}!;nS6>icpTf~Y0ScAY=b6c zt7uPjL_^T!IUap}0UD7dXg%A}2=2ptuK&+D@WvD2L~=ToGYf|i6+nA@F**gc(1^7~ z_lNFi$FD*vOva;;m=Vi!(T?61%PZscO<2J7zm0htOPEhu*gtU0vHTWhlSoz-0RlUGL{y7?Py~o8k&A6Ca|+2Q=a z=v*W+sbmWW&eaaI;kVI{@5h!{xO52BW$1{8q8*xwCf&{G$QGd;+>ASL2Rc-L z?kj~xt_r$u)I%fNB$hj)q3?-K^`J_ue?u{n6K)(6(K(zKZ&->}vc)5zUWS+Ael%ywRteA7uaXL(Zo~+X*bpzMnvwoG?u;hsCbR>)(E%SyabSbLqapbZUB|hrg^`!S zW|YgL$#^9ip@-1Wu180@4Q=26I*^~yBs{NrIA0CzcqgoYSD;Cqn!|w=EsCy+7oJ9w za3>mx578bU#TxiOwBwa)ge0qnPf%`#PvN)d^@sSpGJ>nIEItu^4|zAFl2aVmqhhtf zM`25}fveF-EJj1T2_4xMG^^i_eu_491dYT=w8J@The#EQmP3=ZK3Z>YtmOJ1!@)pK z+=pi8k7$qo!s?jR2@TdjL)rkXpf9@quSKWsQS{~YI1a}hXwHw`T;GXK!FIHw53m}3j&>|hz3>A_L-f93=(?VaHgqTY-2G@omdEl2 zv;)s#%7gtJm{dQaH~xzzOTPM{=cUll*25~;6CLSvG^F>T?}E+f#`8LwD~IFxGwA*2 zHVF5ZL$~DC4OstZx=xRy8^>uhM{+j}A-@p4 zuNoTSmS~b*jwb2#@%mk8gjS&SzZlP_K8hDkpcP~`3mqwrj-&?KfhK4}UC^l-j3(Jw zbfiV zZbtWq*U%2XjYjH2Gy=!b4xdH$mAt&^s$yeo=K3GSffcMmL%R;G;5qbxx6zH~3>t|d zEyJ=ZkG{`qqA#JkXgxQfk+~UNT?^v*$8iefO?U%u+cK>(k}p#nENvYcp3ycmFb}`y z#wEB5Z)=y4{^e7r_MzhZ9l}%;M^{00wBv2jj`v1a(MWVku8Y^FU@6LT&jF<1?2Q|^w2Hib4c9}VdeG$+=f6~7qIe}?A7w`j!vz|we5r!bWj(Fir_ z#QJvwY0C*47#4517G1A1(Gadgv-TA;)Fe3gobb>R>G}V4nId9%;*|^qA7<~+y%{zE76Y3j^`glm*Hl# zzW2~bA3-DbKeXNRQr*H_l|e&Q70vdh=!4zRkPnI1uZ!nrqRDm-ruTUKhw^eXqD#BS zeFVMl0Hz}s%SX`Fllq2(Y!3d47mD`?ktl_Bq&nKM7HC9zpj+oOybWjKcbL^Poc|Fk zQ2q^_&;X-#wlfi&k_iI+ypObG!i^`D^j|4`@aI zMDz3sQ&<%3cqw%5Yoj^S3$1rD8qqn?`|%RmPafmIP`wr}yoZkDOEl}hi|2D(7D8AA z?O-`HiJGEw*$bVTk?6?Bq7C1G?gw|Exv?-_e-cyP@G=KJxEJl=2WaTOLU*`-&<-Yj zLq++}=L%yTEQfAHm!loK4xNHq(Ma8k-v0>N&L;H!t$kVl_G~vN4BZFl$iGJC_9rxJ ze?#y4JC<|w3rTe@nj3}C4%S5@)&lLo05lS#(5ajpug^#CU)zuMZx5g5gd=|yUC;Z_ zQ2mHQG4JK!7l>og_4^#!&~9{R`x=c{&i*k6&?Kvk-rp5#;1IN9ccBA%IK@E^4pyN_ z^D7#n0s}%Qi=uN}9c{1;+QGqS2WLbdLOb#-w!u#@8;cJNJ6%(>-pkRB4aW5PpB4}1 zqoIEkU8n2O5N$(y{2KZ)*@w>Y$MO7+=*a&r zKMt(mydj~XQrMhwRdfo*pi?si4dpDf19Q>bxCaO08g$iU4h_Fe%Y}AuEE*6Bx_50S9tbcp_6(>H!6X=}1HX_j~OKQvi$Tpc=`4{h*5^ttkAeKn)?QXE)uWAwq6=<@1-?pTA-xtogS!X4;TEkdVa zDSF?!=yvoA=o2(I{*D$H9de;OT2Ev2`BXa&T$jDjY#of{@jCRzMQD$gqa9g|z3@4- zBe}+e5#~oXq)TGC2^!H(Xii)n%cIeVO+)HSC3kXQL(8x|Nwf~F_{Xs!5`Ul_IQN?H zv)u(~gcnbaVk0as6-Pz)Z17S^xbxcx`q@(h5u5oRR#2gV755 z+!97wY)+W#tI&<(S~Pp_z%zJ1Zo>t)hLN?uE&SeZGny;a=7wMMH^+&Tx1td%e>*+7 zjDvo+hi|nF*pu>m*bqzJ5gO`;R&*tn#>r@IJb-5Nhv@1!j?VpGXmV!W8CF$pG-ryT z9WR5fx*91COp2yh1$&|mOh?~}%i{Sh=*Yi_*Z)G-{l)Wm-Z@3Dcbp&A{RniYyaCJL zBFx6;usVK%ZcM3j7KDRBXoIEE4X7cSls(XmWH36iNodb!#PfHc6+IHm8_)smKs)py zdjBuzeP^)(<|7lkxc=*egJfp(?P#I9!w83<%kp}3gIa=4#S`dz|E1_b%%*$>y|36k z;l4`ffEuG6>xI|hNX+f}|9}JM@DNtQAJLu`y*I4ea@dvfo7e~oE)3&PtTX48_pa|M+%+l4Or9l|1k$PoO4mwKx(4NGXM?wP;>;N!Tp`n|CcIXbQ zjtkKSx1$yB$8z{-ET2V3p5uY=S}%#_L_@UxJ`b?|t#}Y849R$`flJVy??#u)X>`Py zi$ggVnpB0*j%TAe(hwcMAnb>u;`x^`i}De);jhu>e^|`=cLaZN!fem?U?>+yM_LVi zpnfbjk9I(pUr)3HSEKdJ!n}AdI)zKo0j@;rdlua%UPas6pW?uf9ElhHK|`7UpjjsEk=I4H+RnHiN1{{G;E_;oD{N1>6As0@u^Ik^b!k7Tx;%t> zD4L~X(QF@wj$|@g;VrSe01fd{tc)Au`HyiV&TR z4s2jAR>P0c5NE9j4-`Oip#)k{MRckfq4%}M8rTD!+nMOrd>6VbH=y~}%*M~r<#{$*WL4-`b+jYR(E)W_ zl?r=yZ%(*V-Gb$D8QRb;tcXX^(B)YjUZ+LSoM?o8^>#xm9D^?BDQNE8i+1#Rtc35P z&z;6{m^Za1yzgtG8_A_;h)1LE?Hglx89G&)FdN@QEB+Q;W@pjlEbv(9NO`nlwPLwD zx(xfH^pvTTyAZsxFH(y_Gk_b#%!Dy zuWvv{{sLOxyI9Nj|HmBIaISS>onMIAlpCTQ>yIYUcx;D@(9nL0&gFk-DDym?kxalE zXs$epj(81v{}y!Q2homxk3(Glnd`%dhoeb%ZS+R8V{b^XT$<1+8!=THyz1 zM83yTcowtqk|)Ar?K6HZU7= z;tDi5*P$cc8p}J;df!EJ=L8zbjE!N-FOMmcrXdHe*KTOmk42N~PW1YtvHT3WA?-nP zI=l)OqFMbX+F-tCLsAw-J9aU4$I93Qr=dx{1^e3m zaSq%tnmreKHU_Ixo{VPqO0?&X$MdgY3(CKuNm}dq5TOoegO|thC^TZ%qse&(8o5WX zGd__%&-(kF19KqHwh)2RXy~iQa$U6ImS_dtV|gIj!O`dd#-kBhhIVWXntU(D@@Hrc z{erHNzp*RrCoNwH@9X*KPPPN9;5XP53%nTK>)o&&<)^V5p2q3e_N9zu2EK}ZSTuP# zB-=DJ2^Zt-xD}oArmuu4ZiOl5tQQAf7=V><2$~DGqa9j=?Qt7A_y3|(Qs&hV;^t^} z_e7I$92$X{=xSLM-4w6Cg+}7zS6Tmt_9!Q8@MpCAHyX-3+e64NK+l&%=d>EyKnrxL zE<>Lmh*o?Rn#2>)=Wa$<(LLz>E8_LF+f!lgp5ufSe}!iAALvMO?+6uLh%TFov0NMN zVC!gSw1G>}h+c_C@*1?>`{VhwXwGa#?|&{858gtP>r=F$Z=yeARmy*%SzLZ+xZVV< zxE*j1KH?tdE&*hmJLRoAvM9b&Ds4qDe9t z{bIQf-EcNw1>A>Lcp431q21yA-vI5v6*v@U;Xpi$HL>xYFyb-MDd<2`dszP_+pV0i z$ICJOnT>|@O?0G((NLa1Bk&J8C3)Tnwm>(W$>oxTL4^tc% z+ON?bokBZu22Gxvd&BkoXii*$6|p?pKzFo5Bhlw3q7j{q*1IUW67AS#bc%MO15f?N zfgw8Y-Hi0tYIAf>hoGUJf>m%KTH(vs3iqIMn(w{vTtW1{x_B!NK_m7RIBA)B|zmWsyVlFzu#h8t2&^dbp4c+_blpKraPomGAMsp$Of$&ps zQyfBhEDponv0UZDFhzHwx%Cj{b^SlZfuVc}O}1TVi1vB|et^!^7wCOw(7FB>4e5Cw zh2`4--AOM+x90Kai07mA-HT4`VzlEMF=ev6%t0gEht65FE(EZ>;^!_u^8+@}Q9|g4SOO9Z1E`SpTIssK*IIeHmK8P&Cw| zF$>3IN1Tjy^d+>S-RN`sWBGG5SH4Cgdm61T&!I3C1<{w$h3HgNOL34hgWqUiKJ5H? z__LnAUxd)#gEsIfn$2HfHs(JZMo?dUwT-X~)@IT79&<*<(HzY7QTxnU+gja#ugj`}XVR2E?c%Fm*a`5edLx!;HT zW<=Lx2hM+jEwJRtki>(rH0A5jRrU~8#QoUV_5T+KE|0oDge|u>R-rr|-N}~3^1JAL ze`0yO@W-(3n`2$d{m~9CKv%=d=<4_qeeO3jG6jDM9cY0mUm{m=;BuRZhHNp`!WU!t zWHkTJVG8P^S$rLuBe!5SK7_UL1#FKeu`<^FB}8Blnj16Gc6R;3`tQuaaZXgiTEB*o z55RJiZ$+nKJ>GzyU?=Q#DkR;*cq8TS@LC-4TS&t9u?gkkzX$tbL(2E!aC{q$V3j{u z|Ms-kA7STOj3(8$=$zF(osnFFeK8w%qM<$(%SF$Gt+pZhw(Jv~fF|8t_#1wSPR)To zLk?v9m687MhMT20xS9(^{ulP{nfNf}rMLv^|DBOMg72dv9QRLn?;k?f@hNoAuX8pd z*@(T+5uU|Iap=F{`hRHhUGiV}!|i_Ps!2V;K^qPZqLC=UFH&8vSE3I*jm`0UG^CX> zGSff3jzCAe4(-6}=xh2IT3_SL%%m0GgeKi9n4T(hm5sBRX21GpM-9~H(?H(hixndx;~9(_wS#!}b;eW_f9M&L%YBe$awxDSoUQuMhMm>)Nx z9e)M0@HKQlcnj0N|2xis8^zyfi1X*oOz#I5p&hG()vz0O#~abr@fKc&-{DYfkuT)L zaN4I(a5}q*7pS(fiw9sQ|Wb{|J=~ydgz7L=teRC?a+L53LZd5whkT9Gw7VY zhz{VDczu5?AI8d@KZQ12;=C|54bbFmeqJgxIFJ)2%Ov#6NWNNV}twxXvvA+ z(T%5Gk>tV?+wx@=xWlW!Lqv3+P#{eXSgKXMk$OwLjscwrb&|6*YWJ&Y|lf8j-; zWBqUzVoiJn{Sx{S4SnH~ndvW{ zTDXGpG@OmaN@b>hG5Iihf8p%R^gq4Q6em;u53k0_rCI;3^FyUW1Lu_qp=pkJ2|611DiKT!dBd{pjCVjB{N z9!K|w{Wuwa$96cnPS{U2;ZVvuaTu1ao0JBs(4@N+U2f~q4j;xAcos`zvnC-zLvSGF+tKHbp;J(_DbKn7 zOLJi8TcPiOp6CtzVtF{`raU@25wj>ykJo3RFO%EQz5X8b`IYhdM)diu@%+o^fZxKD zAwIx?6&yjg+VAlk%xD&pCl^}21ifAseXa?b-5ulkVd#C+(1^@L>s^MvUDu*-&6m&) zzSoTPZ$$^=iQ|}q@~`MfPNR{@-8_^pL?cug&E{HYMXk{hb&K|o*GHo7fT`$KeHZ$% zx(@B&8_ik&j_7Mn7^1(?hI6zC4HreX-16v1>ZA9y#6s8!9oZG}`b2a9GtiFBM(dl8 zKKBS(&suZ}pGtAyI(!CAzFp{pd!iqq5jcX5_-C|&q-D52FIsUywBd`R*=Y8cN2jDS z`dm+Rzyr{Mr6zFTNM@sRc|W?hZ$jU4yRkeT$80>eRk*J@+F%DX^j+~Tyd3T5H}U#u zwBf(eft=GilnWyR<@f&_IP%KqNNR@*NfY#e_GnKpLn|JMHaHax;mv4AZbKWIheq&z zG_sFkCT@!7pF-<>33K`W-@}0oe}E>}q3E%A{yX%6Q)tB*Z9?{+i#Bi(8sdu4>ga&# zqRHJ9t+x}}@t){F2H^#+|7$t$##!i%^P~5n9eX&s3TprM_GhIS6Re(yq;&kFRp?a?>T``#ymlePh0q7epcU0YuQx>Jx;a`=YixzRu^KMIO1J}! zz;{>$OScdAcSD!yU^HTra99Q(Htkd4HT!*s@Dgg&F}yynM}Pbt*oi%!8~?yzl&f^% zXEvOV<#8=e#l7)-o35GsyHPZRZa5cp<7LD1bc~-A5qMhic*6V-3ohgBh}ZYaO#kQlsmCu5pIm+VhX{Otuk%2G0gQkfKf*qge;ydV5!((5uicxl zCg-=|rFa5oVv{REhqj>|{Tc^j^TC4X@MPcn9Tw z@kP9SSoo4@Gd#@oTpYytt;lcclKfZlL4kjc$V~sHEDFheN~7+)2lO+UEKc` z`re;6I{ZMg0~?;h`nz&WX8LcVFBzAa{*O@386R@u2$tc28WS?p|Mc3WX!&*YJ^w9U zj}<183v}QSG!m~(%1r+Qg+(TZ&+lAQ!gIZ_0oNy^-wjXT3fKRa@xtt>WIGi-k5f4R z%Jre4I@8#}I6o1e%=E9_^V}M;`33adZ~&eApV8%3 z>b5XNm!n@gOVR76u^m>J8?Il29Vu@{bLz}o)_+S5y563d{*~%{>_qv0*ah3(5x#Ek zMsuRVo#AVEGmfMDJ(k7+^TLP940PEo#LBn<$Kyvh3)|0+Q;3&Q{&qega88>o2-!ar zU8j#=4}1}?#yoe0x86kTNqHVR($COkS^w_vp)nfUQl5>S@pW`#I{%)q@iav1?}2vo zniL0y^ft`KmFU*G3(MkBtbpg-8#+)8U1sgkY<>;T$G^}WF89K)bzg${DOW{z!lvjx zaTR*seOM7w>o~9{`_P_#hwf|z?+eSV5&E(jg+4e9eQ*K#Y4uDzzZc!}kD`0~N%Xl> z=$@ZrQ5aZ$^m+}P>-)bx2ae!n%#H7$bNeA$VcYvdD7&Lu=7YEz-#{xK^FWw_DcFzl zIy5r>Vr#6oI5YkC0rIwtCZib9$LObCvz4rWL*JJJ-}fWY z4~a?9S?KF`9y+pn(cD;qwQ&bJqEqNxU$QDhre?GqTHgTlvwIX8!8zzEn74}cUyg%y zoG_Go(GDF!L-sS;@EJ6e8LLCj&&Q&aD`F9Bfrk9Dczzl>MR%jCXf>KEZ=v^p5IwP) z_3sD8zntiV`PYO7`=d8riB@z2+TdMi&)1+G-iZ$2BlN!ip?9(Lc}!FIX3Tl~N7uNH_GpF=$6_Lo0YVo_`*V(1GapXvZ=h4;{D= zeZDq2;OH z;{>z=3uAc`=5_tQ$AKe0h(7Q;+CYvc!bl6E$x{Jsur|6XI-<$d9c^$TnhOijo%A_$ znSF`Z;h$*auG$b*!E`*=^?x%5Hhedl^~>W8YtcE~gobQ8I`@aso$@4_8^5E=ENf%v zP=54!U7U{1urof7b|7ODA0q4vMKINo6WyK+72S_+IIGbSzmHaYD3-rPM|29E>wKF- zq{^W8*Fv9figt7~y56T^YrF|-;j8Et{_|$me`5|7JrzEU-bF*2)Ja{(dxKPof=q4(;H(I0}!W&v)Aretx(Ljm%i|xyf5t|Mq+aCk*9WG#MX2L$wxj z;EU*cemgpXQ?XojYdGHzYjXZhtc@?ANqiD*=wEar%lS}c**O2%uqAKA4wOGY>$~W=5c)FcR8&TvZ-7o|2dsyw-tofi zn8k?)qf4Y7+xwV} z-=huX+YusM44uj{v0QHl>%S={T5!T_oQ~ z>eg7F^PMmoXQ3O}qgW1K!@76^UH2v42$33);=pAw87tv*G?`XMH=uL272OA3MML@_ zI+CN&Q|Q!Xy%{1=0v$*#9EBs$f$T>2hmX(=D|MIy8#w>1(DRGXtZss?`!48%H>07v z3$MXP(N&Z0?T{-)(0Zz%ui5(O^-IwK4MHP20&C$UB*#+8Y7UIRHmri%u_}I#m9gmV z&~Phs#J$lGj6fT>7ERLY(S~k9bK=(Mqi8a}hz?{Q8mU8A$oK#692olCd%|xvYNIz? zhi3C!G`Uv9@-y-Jd+4h87H#l9bmYa~34444v_t*Thz>(LJOLf>bj+82|8wAtccC{v zfL8n%y4+qt*YUY~!;jl#(GCqlBQzfEz{BViJ%cvzCf<(k;w{+s-B91BIFfS3_gMdC z_L0_dGtFp^rz8fl=Jgo z0kq@AF&nF)xzQcXnbBASZ^iPs72T2#VqMq&zZ^K1b-oCpZHqS44^6(Sur$ty=O01u z--OQft7vk)i%!`OXvdSo;rw}Mxf z@5J)wXazr`@BO?-lpWCehM>ti8I9CU(fMe7OOCSsZD>Qh@B$j5x6z({hIZf> z`kT=|Fq`t<$3nzPd=-{uBebEGXp(k9JJ16g;s|v4E=8we6*|S6zeqf@jh zmQycs;A?k3+R#5}hzficD!c>@RW-DNW@yEI(5-kX`rN~4$2P_C%VYitj-yydRCoN_1*AqwD%b^!_i=dQYLx zWt|K;lN*glIn3euZ_R-fc0eoYgFZL}>)?3wqjFXB4Kxxb(C>u*(2*DWAv|9ZJzpo5 zyP^Z?hvvw&Xma0x>3{!sHV0ZcI$iRik%4Gq<5bObxl3O`1t<{Na%{=$lw z=jU*}HaehII21dh9b1o1#oK5SzWX!l-%x(T348Vr+F;RN!d%sj_QdL(zXto_!|0dK z&uGOL{Tf2w2(u}-#ZovDP10M@oOm3K#7pQv-u;#J??^u9gdzI{9nsl%!+EDdg(c8X zH%B|zJ31cC<^^bli=&UB4LySn>`k=(Bk}x?=)nI=abVIF{VgO->1Y!)yZfLO43Fn$ zMDIsOz6l-KUNod%Vm6*ZBT(%3(D6p-z`CQ?2csQM&Emig+=W*35IUj_=!l<1N3thg z{~S9}{uxcmMt_9sozX}RLMt90ofXgDg(me%Y>u0dh@_H}@xq^I!#Pfe4xNvV=pr16 zwb5m>7{}vsG%3$N6GB-IEjL9&eJNgvL(pX0i7w+k=&Je=i@E*_{TW`T)zF^bj6QHL zx*Q)y{|tCVy#CQ&;riD&+KSNEcfbFIKdzgE*7G!)?K|)rd;{y^hQGs~A$_T|pVa&( zj5vjE9P{vbT#YSo(AmuN-|1cw{RgdR$iHEP_n}ku2Rib+|Aok1h(@$#v~jd8Iu+e9 zvgipP~afj%NL@@p?u^R(c;P zg0|N-gYnzr?wm0Ddt*-=g?8ZCcz!3^kq^-de?lAl7mZNC%uue3CT(+cD*K}KjKiAb z&~$W)p2*5d=gjjd4(z}IY>1zt5h#!&oWBSwP%eqpuzfr~3D;AehIY90Ia%pHW}k>g zU>}<8U!WaNa%QF1eR1qcu_<=M)a@L&k?h0n_!Bz9X1PL$x}hEHhv{TQJGcZhaWy`H zYte{YnmaT+3hl@p(ZzTL~`YD^!hK9mHz9pJ8&)!Tu?YG{WlaI!XA`6773wRjMq>;jIUysqFKpgD$a9ZR{C3U zX0fdFUqC2%Q5fmV*q{3@DxQ`8&-EwaFO;|AbNJN7S;-LB|J5Zzw!VqZ+5TAm9kXcQ zKYX3@StYa5|AE5W=!mmRg%CfF(=0etkT@9j$LSn)M%|tLg|=!QapU zmMj}`rFGe?RQkiAFDLBbOe~ELqs!{Huwwr`b{c_=Pp1; zTn@)#9ds%lK{uSoF&m$a<&W`k%Bin7aJen05;mZ{*pG6Ks#)o`-yp0*`AV#h51`lI z!G>6-T2}fer-5i_=c4DAqs#hZ^tnRS!`9sy-A^V3Q^_chy$(yd{%6J$%g_ooVpV(wt>6fn z&8M+4X4VY3QVq@K`sm2}qe(ge&7phH9r0mw>)wR5@G~?=^3`HsuKxxc7?Rd#LmkjL z>y2jZP_&`z(TX2NH)nWZRCo?!< zkM2Y(T#QCyBRVD9(2?&zN3aLYl`qhdeuXC6pI8^`*9{#QgAQaSI_Gzz9axUG_gG!l zzYT8TgbltOFMNoeKZ=I%3>ty+>xB+vqYc-MJcq0j$`Ml5L% zawaF%pqQ$~K|0CMIlCUs&UxqvpG7O)gD3F_IpfKvGEy4xHQWXoxOH=Xe-a!pZ1}m*E85fE}dgh}e zeE^NXQnUjvU@d$T9l#kp&-I_XX&8CYXcaUmTc9^~Lz8PTngdJF&~HRL_#WE8nP`q? zq2UYAsi}!3UxR1|wEljUUH>CFaO6|a4P+*|V=co0xD8z{1)FE3-pO|nFjtGLq%79Mlp*QQfg!vTE8s)uoNvd9_z_z1S+s+BTZZJR zjE1;B`i{6BZD>83LvLU<{u0k$&?@Yd)v*TW+qYuo9_1Xtp;)r=}x1WdqQNjzuFh8GY_9wBAS1DcsOH6_Vy%PWUqU1Z(11bWg9| zCOps{?O-2ljN{Rcu16!c7j5Vey1xHF-+~!!Lx&op_jf_R8?KAaOL1Tlt;0_Eek@#@>kPluu-ydIsB z1+n}{bSv7y-RQ`VVn_TDor?N>!}ZQ+q=%#X#{@K@si_>ex2NKX1!zT!(YafN?hor@ z`5?Nij-YdX3Z2^m{lW;#V`s{>qSMiR;&pVOUtv`|8?L94%9m#)b2u>>Tj3dOgN^!U zrGL*i1Fxlg5L=v(YnT!6>Wk&Pc1Mm_}%@g3-t-HWyG;aJ{-Wqki1;J}cb z#>&`YP}tE%qanO2mRF)v@FqI)PtXzl5zG0n2qCVHHrNbZmhI8G?}J8q2->l0Jn#E| zG6#ly78=@lXv53UFPW#}_2dfE29H!ghrqp z+TNu@SpQWx7|02Gc3Zr#46WcXv_sFLBY!ixKl*v}TXf{7(2o2W&;N%un0ILSlDZJ> z$VBXeGl#PNJ+Yq?R`3Nn1z(~2!XN1JDmW~3qzbwmo1!7S6phqS^tr3i`)@>ZV5pbch?3_UE2hO|1i!}jRQXCB(YwP?jJqY-%% zZSVthKllP&uE)@+N=Aha=En0~|J6A#bj{F)d!#Qgr)Y=9p-Flh*2kyNsW^^?_;<9S z5?6&tltzi{<+0_4a6o`o!yl(cBm}mi6xq(>P)B-G&u#0ouc@XovQq z9XK5Q6-~w**Mv|PLa%3|Q&JZl*dT0)4K&29(8zX=UXE^P!!iB-zmbFIIdLnRT+MC>Bin|KXeYWnKS3)xjW(Rz7($vG zjaVVH!7}Ka*F=-9bv!>3jo3u=`RPdh^B0Uca0{M`MqoSI&_1-{gXkQdh}VBc*ZF^F zg$1UE3NJ!KUK(A#^{`9^Bge`(VMh2GUUF0T1?Ik+;`e{0nIW4SqH{h9?Z`c72Oh;z z_$2nk_pm*doE82gWF)#8{zj9uGQ-mA(DP5@0Q?e7=Ek$bJLJ0AoNzAhon|0c{5ti8T5LaIa%rd^kNA%qg>?H(1G6g4CRe@ zB@R#B7JBkJx{i-vZ!9r4jBq@ft;O(KKt?SW+}{fC zr92)RVsdBL;!}+{FiA#WHJpnRa4Q;#a`VFXe=juTv(a366MZ}WhfYn=`609ounOg| z*ccx`lXoAwN`68+dhUYs%Py5P;J_38(YN3&=m^%~#kdb0@kuli#qSDRa(8sfrl8N= z9nWt?JN_A7h&k^L4VOd*+6>K!p_u;nfA8YJ9&g1~co@yzQul<{aU--t1JRJ)f+p8C ztcCxe?}+O6hFrJ|%Tr#4=FBd1%f4`77-%ar(qpkI`w#yELRsnmKJPUgN_psg;p=oe z4xwCVQC9jtG@gWgDSwCcu;u+>DyE?CicM%zoj@bf^?~rkGy!{1em0t8aTrh^OpWEj z3=W*b<7m%vJ{Uq@7qcn%M7QV(@%()3Pk9}l#N?rnR6n2{Dfe(z@){1skyv&~$bmcX zALsUB=&_5zTlayoN7A29!#w zbKnh)(T?;*J1`2Za1t7U+2{!GL>s&tz3*W(0&6h~H=+$ch1R<-dJ>Irt_`6h7o=tV zRp!9CY>m$G1oXkza1efpF2kl9!}%p>2cAPawiA8b?m;)C&#@eyM)!@2H-!YntL3AW1&?)%=4c%|?dcG&a&vM1lFQJ;~^={}0N21Gl9J+e$ zM&Ekt(fhtYBmD=a|M!2-*&LFo2s)y==%-RMbQ$(SE4~jM*)lW&JJAmBMfZ(ESQT?W z6&k1)ZHErzGIXj2p*b}CDc1im4#sf8p6x-C=NJ~oKhTErJ{>A7jOIi+v;%d}&^JeO zqbE9bm!Sb`aaFt( zo8V^Lj^021nGnG#Xu~t{CR~hmsL-=vYA;5orc{apL)`?ku?LpM@#yPyA=<&m(F)!~ z8+s2N=>c^A_!(XIrJoB)cN3Z$Yp@CKjOYJBbEDkzVaihNI562Jq8rRqv_m(eJ$wLr zuz@^nz7Kt=e2$IqqPN4hWN-BOyKy)^kBz;)JG{jDV5%G^=5TNeK8_u*>Yk7^lhAd& z3A^KG=u}jCCsf=I%TP|C4X?tQcnJGozP;hCIuzZKS70~Xjt=nLcUk{!I2itJR{CG5 zS%VuW4}33lDCfSc^#5S!o%jalFL^&JIe}lHbH8_gNY)EJ2+36*-4`05%e6JSI(nkZ z_GWZd-HS$Y-3O_x!{L!q5!Xil`likP~NgFGBOfQIT(G&K9sx6~i# zoL2oje6b8dm(xX)=Eizx4m3ya>x4$2FBZn(=tgxtrvLlDo8pOk z(a z=fDT&pcO7gvvdVIqUX^Hcc2fxgLddswBqkEKmLi- z(YN9y$HMnUlVhpyvs!OXc*70o+&+lT)w5^^Ucy`OO>_jEzX~H9h<0=+`T;Wr?a0mX z{5p=+Hfr_g>BK$k3bt3jYeWT+Q3wFgei0i??LNX ziFSA+IyKwS4!?}v{|;L3Aw0*|;1Leo*^Z+(eutj_6RTnOZ^KJzIyR-e9;@RwSOg25 z2w7hl&Gt51*Du?===PgxWjudVt;{J!a@EeR-JnU`#t)uppIJI*C_G=XL*_ZD+k3r{ M`FyW8vc{JEKX8vlUjP6A diff --git a/locale/nl/LC_MESSAGES/django.po b/locale/nl/LC_MESSAGES/django.po index 1cb0004..c280c1a 100644 --- a/locale/nl/LC_MESSAGES/django.po +++ b/locale/nl/LC_MESSAGES/django.po @@ -2,8 +2,8 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-04-21 16:39+0200\n" -"PO-Revision-Date: 2023-04-21 16:39+0018\n" +"POT-Creation-Date: 2023-04-23 12:18+0200\n" +"PO-Revision-Date: 2023-04-23 12:19+0018\n" "Last-Translator: Kevin Alberts <>\n" "Language: en\n" "MIME-Version: 1.0\n" @@ -7542,7 +7542,7 @@ msgstr "" "\n" "Je kunt nu een extern account koppelen via de volgende link:" -#: amelie/oauth/templates/send_token.mail:14 amelie/tools/templates/send_oauth_link_code.mail:21 +#: amelie/oauth/templates/send_token.mail:14 amelie/tools/templates/send_oauth_link_code.mail:22 msgid "" "Best of luck!\n" "\n" @@ -10151,18 +10151,23 @@ msgid "Choose your preferred login service and log in" msgstr "Kies een dienst om mee in te loggen en log in bij die dienst" #: amelie/tools/templates/send_oauth_link_code.mail:15 +#| msgid "Choose the option \"I have a link code\"" +msgid "Choose the option \"Try Another Way\"" +msgstr "Kies de optie \"Probeer op een andere manier\"" + +#: amelie/tools/templates/send_oauth_link_code.mail:16 msgid "Choose the option \"I have a link code\"" msgstr "Kies de optie \"Ik heb een link code\"" -#: amelie/tools/templates/send_oauth_link_code.mail:16 +#: amelie/tools/templates/send_oauth_link_code.mail:17 msgid "Enter the following link code:" msgstr "Voer de volgende link code in:" -#: amelie/tools/templates/send_oauth_link_code.mail:17 +#: amelie/tools/templates/send_oauth_link_code.mail:18 msgid "Your account should now be linked." msgstr "Je account is nu gekoppeld" -#: amelie/tools/templates/send_oauth_link_code.mail:19 +#: amelie/tools/templates/send_oauth_link_code.mail:20 msgid "The link code will remain valid for 3 days, please complete the steps before then or request a new code at the Board." msgstr "De link code is 3 dagen geldig, voer a.u.b. deze stappen binnen die tijd uit of vraag een nieuwe code aan bij het Bestuur." @@ -11347,7 +11352,6 @@ msgid "Provider" msgstr "Dienst" #: templates/profile_overview.html:230 -#| msgid "unlink" msgid "Cannot unlink" msgstr "Kan niet ontkoppelen" From b8cb7a89b01efd75afb19c391a4ab934bb54ad4a Mon Sep 17 00:00:00 2001 From: Bram van Dartel Date: Mon, 24 Apr 2023 13:13:29 +0200 Subject: [PATCH 10/22] Fix cookie corner URL to new provider --- amelie/personal_tab/pos_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amelie/personal_tab/pos_views.py b/amelie/personal_tab/pos_views.py index 0ec5aa9..9b4d367 100644 --- a/amelie/personal_tab/pos_views.py +++ b/amelie/personal_tab/pos_views.py @@ -242,7 +242,7 @@ def render_to_response(self, context, **response_kwargs): # If no user is logged in, redirect to login and then back here. if not self.request.user.is_authenticated: redirect_url = reverse('personal_tab:pos_verify', kwargs={'uuid': uuid}) - login_url_with_redirect = reverse('login') + "?next={}".format(redirect_url) + login_url_with_redirect = reverse('oidc_authentication_init') + "?next={}".format(redirect_url) return redirect(login_url_with_redirect) pending_login = None From b1b3aa91402eecf3fc570dc06f9c9a59bac73022 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Mon, 24 Apr 2023 21:04:08 +0200 Subject: [PATCH 11/22] members: Add IA mailaddress to userinfo if active member --- amelie/members/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/amelie/members/views.py b/amelie/members/views.py index fe05715..828646a 100644 --- a/amelie/members/views.py +++ b/amelie/members/views.py @@ -1461,8 +1461,10 @@ def person_userinfo(request): # If a person was found, return the userinfo that auth.ia needs. Else return an empty object. if person is not None: log.info(f"UserInfo retrieved for person {person} using username {username}.") + email = "{}@{}".format(person.get_adname(), settings.CLAUDIA_GSUITE['PRIMARY_DOMAIN']) return HttpJSONResponse({ - 'iaUsername': person.account_name or None, + 'iaUsername': person.get_adname() or None, + 'iaEmail': email if person.get_adname() else None, 'studentNumber': f"s{person.student.number}" if person.is_student() else None, 'employeeNumber': f"m{person.employee.number}" if person.is_employee() else None, 'externalUsername': person.ut_external_username From b76df28c81b6d5d6f8a9fc4a8a126c19ecbf5358 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Tue, 25 Apr 2023 13:55:27 +0200 Subject: [PATCH 12/22] Split received departments by comma --- amelie/members/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/amelie/members/views.py b/amelie/members/views.py index 828646a..4eb2438 100644 --- a/amelie/members/views.py +++ b/amelie/members/views.py @@ -1378,6 +1378,8 @@ def _person_info_get_person(ia_username=None, ut_username=None, local_username=N if departments is None: departments = [] + else: + departments = departments.split(",") del ttl_hash # ttl_hash is just to get the lru_cache decorator to keep results for only 1 hour person = None From 4f69c2a8ede0f0e3143b5508284ad535416c55d2 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Tue, 25 Apr 2023 14:20:17 +0200 Subject: [PATCH 13/22] Improve userinfo logging --- amelie/members/views.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/amelie/members/views.py b/amelie/members/views.py index 4eb2438..ed4f13e 100644 --- a/amelie/members/views.py +++ b/amelie/members/views.py @@ -1437,6 +1437,10 @@ def _person_info_get_person(ia_username=None, ut_username=None, local_username=N else: logger.info(f"Cannot find person with invalid local_username {local_username}.") person = None + else: + logger.warning(f"Person lookup requested but no IA, UT or local username given?! - " + f"Args: ia_username={ia_username}, ut_username={ut_username}, local_username={local_username}, " + f"verify={verify}, departments={departments}.") return person @@ -1511,7 +1515,8 @@ def person_groupinfo(request): else: log.info(f"GroupInfo found no groups for username {username} - User has no mapping.") return HttpJSONResponse({"groups": []}) - log.info(f"GroupInfo not found for username {username}.") + log.info(f"GroupInfo not found for username(s) ia={body.get('iaUsername', None)} " + f"ut={body.get('utUsername', None)} local={body.get('localUsername', None)}.") return HttpJSONResponse({}) From 21747c8f8a791875b6de03b5de679fa1de9a7d2f Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Mon, 1 May 2023 19:56:05 +0200 Subject: [PATCH 14/22] Small cleanup of invalid URLs, old modules, models, views, requirements, etc... --- amelie/activities/templates/gallery.html | 2 +- .../templates/gallery_overview.html | 2 +- .../includes/activity_enrollment.html | 2 +- amelie/claudia/account_views.py | 2 +- .../accounts/password_reset_success.html | 2 +- amelie/data_export/exporters/amelie.py | 7 ---- amelie/members/views.py | 2 -- amelie/oauth/admin.py | 5 --- amelie/oauth/management/__init__.py | 0 amelie/oauth/management/commands/__init__.py | 0 .../oauth/management/commands/clean_oauth.py | 20 ----------- .../migrations/0003_delete_logintoken.py | 16 +++++++++ amelie/oauth/models.py | 24 ------------- amelie/oauth/pipeline.py | 35 ------------------- amelie/oauth/templates/send_token.mail | 17 --------- amelie/oauth/templates/token_login.html | 32 ----------------- amelie/oauth/templatetags/__init__.py | 0 amelie/oauth/templatetags/provider_name.py | 22 ------------ .../templates/publications/publications.html | 2 +- amelie/style/static/robots.txt | 8 ++--- .../management/commands/www_obfuscate.py | 8 ----- amelie/videos/templates/videos/videos.html | 2 +- requirements.txt | 6 ---- templates/basis.html | 2 +- 24 files changed, 28 insertions(+), 190 deletions(-) delete mode 100644 amelie/oauth/admin.py delete mode 100644 amelie/oauth/management/__init__.py delete mode 100644 amelie/oauth/management/commands/__init__.py delete mode 100644 amelie/oauth/management/commands/clean_oauth.py create mode 100644 amelie/oauth/migrations/0003_delete_logintoken.py delete mode 100644 amelie/oauth/pipeline.py delete mode 100644 amelie/oauth/templates/send_token.mail delete mode 100644 amelie/oauth/templates/token_login.html delete mode 100644 amelie/oauth/templatetags/__init__.py delete mode 100644 amelie/oauth/templatetags/provider_name.py diff --git a/amelie/activities/templates/gallery.html b/amelie/activities/templates/gallery.html index 1608a91..ac02e08 100644 --- a/amelie/activities/templates/gallery.html +++ b/amelie/activities/templates/gallery.html @@ -20,7 +20,7 @@

{% if login_for_more %}

- {% url 'login' as login_url %} + {% url 'oidc_authentication_init' as login_url %} {% trans "More photos are available if you" %} {% trans 'logged in' %}.

{% endif %} diff --git a/amelie/activities/templates/gallery_overview.html b/amelie/activities/templates/gallery_overview.html index 91b85e5..e2c091d 100644 --- a/amelie/activities/templates/gallery_overview.html +++ b/amelie/activities/templates/gallery_overview.html @@ -175,7 +175,7 @@

{% if not request.person %}

- {% url 'login' as login_url %} + {% url 'oidc_authentication_init' as login_url %} {% trans "More photos are available if you" %} {% trans 'logged in' %}.

diff --git a/amelie/activities/templates/includes/activity_enrollment.html b/amelie/activities/templates/includes/activity_enrollment.html index 473ca30..6d32532 100644 --- a/amelie/activities/templates/includes/activity_enrollment.html +++ b/amelie/activities/templates/includes/activity_enrollment.html @@ -134,7 +134,7 @@ {% endif %} {% else %}

- {% url 'login' as login_url %} + {% url 'oidc_authentication_init' as login_url %} {% blocktrans with login_url=login_url next_url=obj.get_absolute_url %} To view or alter your enrollment, please log in. {% endblocktrans %} diff --git a/amelie/claudia/account_views.py b/amelie/claudia/account_views.py index 7cc1972..5c58b39 100644 --- a/amelie/claudia/account_views.py +++ b/amelie/claudia/account_views.py @@ -247,7 +247,7 @@ def dispatch(self, request, *args, **kwargs): class AccountPasswordResetLink(FormView): template_name = "accounts/password_reset_link.html" form_class = AccountPasswordResetLinkForm - success_url = reverse_lazy('login') + success_url = reverse_lazy('oidc_authentication_init') def dispatch(self, request, *args, **kwargs): if self.request.user.is_authenticated: diff --git a/amelie/claudia/templates/accounts/password_reset_success.html b/amelie/claudia/templates/accounts/password_reset_success.html index 7dc0b06..9fbd868 100644 --- a/amelie/claudia/templates/accounts/password_reset_success.html +++ b/amelie/claudia/templates/accounts/password_reset_success.html @@ -10,7 +10,7 @@

{% trans "Reset e-mail sent" %}

{% trans 'Check the email address you have registered at Inter-Actief for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder.' %}

- {% trans 'Return to login' %} + {% trans 'Return to login' %}
{% endblock content %} diff --git a/amelie/data_export/exporters/amelie.py b/amelie/data_export/exporters/amelie.py index bc67322..aeae64e 100644 --- a/amelie/data_export/exporters/amelie.py +++ b/amelie/data_export/exporters/amelie.py @@ -5,8 +5,6 @@ from oauth2_provider.models import Application, AccessToken, Grant from zipfile import ZipFile -from social_django.models import UserSocialAuth - from amelie.claudia.models import Mapping, Timeline, Event as ClaudiaEvent from amelie.data_export.exporters.exporter import DataExporter from amelie.personal_tab.models import ReversalTransaction, DebtCollectionTransaction, CustomTransaction, \ @@ -310,11 +308,6 @@ def export_oauth(self): 'expiry_date': str(grant.expires), 'scopes': str(grant.scope), } for grant in Grant.objects.filter(user=person.user)], - 'authentication_providers': [{ - 'provider': auth.provider, - 'uid': auth.uid, - 'extra_data': auth.extra_data, - } for auth in UserSocialAuth.objects.filter(user=person.user)], } return oauth_data, [] diff --git a/amelie/members/views.py b/amelie/members/views.py index ed4f13e..42d68e5 100644 --- a/amelie/members/views.py +++ b/amelie/members/views.py @@ -19,7 +19,6 @@ from fcm_django.models import FCMDevice from formtools.wizard.views import SessionWizardView from oauth2_provider.models import AccessToken, Grant -from social_django.models import UserSocialAuth from wsgiref.util import FileWrapper @@ -334,7 +333,6 @@ def person_anonymize(request, id, slug): if hasattr(person, 'user'): AccessToken.objects.filter(user=person.user).delete() Grant.objects.filter(user=person.user).delete() - UserSocialAuth.objects.filter(user=person.user).delete() FCMDevice.objects.filter(user=person.user).delete() # Anonymize education diff --git a/amelie/oauth/admin.py b/amelie/oauth/admin.py deleted file mode 100644 index 8e1ed13..0000000 --- a/amelie/oauth/admin.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.contrib import admin - -from amelie.oauth.models import LoginToken - -admin.site.register(LoginToken) diff --git a/amelie/oauth/management/__init__.py b/amelie/oauth/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/amelie/oauth/management/commands/__init__.py b/amelie/oauth/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/amelie/oauth/management/commands/clean_oauth.py b/amelie/oauth/management/commands/clean_oauth.py deleted file mode 100644 index c335d9b..0000000 --- a/amelie/oauth/management/commands/clean_oauth.py +++ /dev/null @@ -1,20 +0,0 @@ -from datetime import timedelta - -from django.core.management import BaseCommand -from django.utils import timezone -from social_django.models import Partial - -from amelie.oauth.models import LoginToken - - -class Command(BaseCommand): - EXPIRE_PARTIAL_AFTER = timedelta(hours=24) - - help = 'Delete login tokens and partial pipelines that are older than 24 hours' - - def handle(self, *args, **options): - deadline_token = timezone.now() - LoginToken.EXPIRE_AFTER - LoginToken.objects.filter(date__lt=deadline_token).delete() - - deadline_partial = timezone.now() - Command.EXPIRE_PARTIAL_AFTER - Partial.objects.filter(timestamp__lt=deadline_partial).delete() \ No newline at end of file diff --git a/amelie/oauth/migrations/0003_delete_logintoken.py b/amelie/oauth/migrations/0003_delete_logintoken.py new file mode 100644 index 0000000..c230b84 --- /dev/null +++ b/amelie/oauth/migrations/0003_delete_logintoken.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.18 on 2023-05-01 17:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth', '0002_auto_20201103_2220'), + ] + + operations = [ + migrations.DeleteModel( + name='LoginToken', + ), + ] diff --git a/amelie/oauth/models.py b/amelie/oauth/models.py index 8bba19c..e69de29 100644 --- a/amelie/oauth/models.py +++ b/amelie/oauth/models.py @@ -1,24 +0,0 @@ -import uuid -from datetime import timedelta - -from django.db import models -from django.urls import reverse - -from amelie.members.models import Person - - -class LoginToken(models.Model): - EXPIRE_AFTER = timedelta(hours=24) - - person = models.ForeignKey(Person, on_delete=models.CASCADE) - token = models.CharField(max_length=255) - date = models.DateTimeField(auto_now_add=True) - - def save(self, *args, **kwargs): - if not self.token: - self.token = uuid.uuid4().hex - - super().save(*args, **kwargs) - - def get_absolute_url(self): - return reverse('oauth:token_login', kwargs={"token": self.token}) diff --git a/amelie/oauth/pipeline.py b/amelie/oauth/pipeline.py deleted file mode 100644 index 7a04cfc..0000000 --- a/amelie/oauth/pipeline.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.contrib import messages -from django.urls import reverse -from django.utils.http import urlencode -from social_core.pipeline.partial import partial -from django.utils.translation import gettext_lazy as _ - -from amelie.oauth.templatetags.provider_name import provider_name - - -@partial -def require_login_for_association(strategy, current_partial, user=None, *args, **kwargs): - if user is not None: - return - - # Instruct the login view not to offer an oauth login option. - # Set the next link to resume the social_oauth pipeline, associating the user. - continue_url = reverse('social_auth:complete', kwargs={"backend": current_partial.backend}) - - query = { - 'no_oauth': '1', - 'next': continue_url, - } - - return strategy.redirect(reverse('login') + '?' + urlencode(query)) - - -def message_new_association(request, backend=None, new_association=False, *args, **kwargs): - if new_association: - message = _("Login provider %(provider)s added successfully") % {'provider': provider_name(backend)} - messages.success(request, message) - - -def message_remove_association(strategy=None, backend=None, *args, **kwargs): - message = _("Login provider %(provider)s has been deleted") % {'provider': provider_name(backend)} - messages.success(strategy.request, message) diff --git a/amelie/oauth/templates/send_token.mail b/amelie/oauth/templates/send_token.mail deleted file mode 100644 index 2aacfb9..0000000 --- a/amelie/oauth/templates/send_token.mail +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "iamailer/email_basic.mail" %} -{% load htmlify i18n only %} - -{% block subject %}[Inter-Actief] {% blocktrans %}Connect external account{% endblocktrans %}{% endblock %} - -{% block content %}{% htmlify %}{% blocktrans %}Dear{% endblocktrans %} {{ recipient.first_name }}, - -{% blocktrans %}You receive this e-mail because you can no longer login on the website of{% endblocktrans %} -Inter-{% onlyhtml %}{% endonlyhtml %}Actief{% onlyhtml %}{% endonlyhtml %}. {% blocktrans %}This is because you are not/no longer a student or employee of the University of Twente. - -You can connect an external account through the following link:{% endblocktrans %} -{% onlyhtml %}{% endonlyhtml %}{{url}}{% onlyhtml %}{% endonlyhtml %} - -{% blocktrans %}Best of luck! - -Kind regards,{% endblocktrans %} -Inter-{% onlyhtml %}{% endonlyhtml %}Actief{% onlyhtml %}{% endonlyhtml %}{% endhtmlify %}{% endblock %} diff --git a/amelie/oauth/templates/token_login.html b/amelie/oauth/templates/token_login.html deleted file mode 100644 index 91d4530..0000000 --- a/amelie/oauth/templates/token_login.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends 'basis.html' %} -{% load i18n provider_name %} - -{% block titel %} - {% trans 'Log in' %} -{% endblock %} - -{% block content %} -
-
-

{% trans 'Login on the Inter-Actief website' %}

-
- {% blocktrans %} - You are here because yo do not have a way to log in to the Inter-Actief website (anymore). - While you have been logged in now, it is wise to add an alternative login method below: - {% endblocktrans %} - - -
-
-
-{% endblock %} diff --git a/amelie/oauth/templatetags/__init__.py b/amelie/oauth/templatetags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/amelie/oauth/templatetags/provider_name.py b/amelie/oauth/templatetags/provider_name.py deleted file mode 100644 index 234a6bb..0000000 --- a/amelie/oauth/templatetags/provider_name.py +++ /dev/null @@ -1,22 +0,0 @@ -from django import template - -register = template.Library() - - -@register.filter -def provider_name(provider): - providers = { - 'google-oauth2': 'Google', - 'github': 'GitHub', - 'linkedin-oauth2': 'LinkedIn', - 'facebook': 'Facebook' - } - - if not isinstance(provider, str): - if hasattr(provider, 'title'): - provider = provider.title - - if hasattr(provider, 'name'): - provider = provider.name - - return providers.get(provider.lower(), None) diff --git a/amelie/publications/templates/publications/publications.html b/amelie/publications/templates/publications/publications.html index bcd85b7..6cb72b8 100644 --- a/amelie/publications/templates/publications/publications.html +++ b/amelie/publications/templates/publications/publications.html @@ -26,7 +26,7 @@

{% if login_voor_meer %}

- {% url 'login' as login_url %} + {% url 'oidc_authentication_init' as login_url %} {% trans "More publications are available if you" %} {% trans 'logged in.' %}.

{% endif %} diff --git a/amelie/style/static/robots.txt b/amelie/style/static/robots.txt index eab2bb7..782140d 100644 --- a/amelie/style/static/robots.txt +++ b/amelie/style/static/robots.txt @@ -2,8 +2,8 @@ User-agent: * Disallow: /admin/ Disallow: /leden/ Disallow: /members/ -Disallow: /login/ -Disallow: /logout/ +Disallow: /legacy_login/ +Disallow: /legacy_logout/ Disallow: /autosearch/ Disallow: /gmm/ Disallow: /profile/ @@ -18,8 +18,8 @@ User-agent: Googlebot Disallow: /admin/ Disallow: /leden/ Disallow: /members/ -Disallow: /login/ -Disallow: /logout/ +Disallow: /legacy_login/ +Disallow: /legacy_logout/ Disallow: /autosearch/ Disallow: /gmm/ Disallow: /profile/ diff --git a/amelie/tools/management/commands/www_obfuscate.py b/amelie/tools/management/commands/www_obfuscate.py index 4492a94..b4bfa0d 100644 --- a/amelie/tools/management/commands/www_obfuscate.py +++ b/amelie/tools/management/commands/www_obfuscate.py @@ -5,7 +5,6 @@ from django.contrib.sessions.models import Session from fcm_django.models import FCMDevice from django.contrib.auth.models import User -from social_django.models import UserSocialAuth, Association, Code, Nonce, Partial from django.utils import timezone from amelie.claudia.models import Timeline @@ -102,13 +101,6 @@ def handle(self, *args, **options): # Delete audits LogEntry.objects.all().delete() - # Delete all oauth authorizations - Association.objects.all().delete() - Code.objects.all().delete() - Nonce.objects.all().delete() - Partial.objects.all().delete() - UserSocialAuth.objects.all().delete() - # Delete Claudia Timeline Timeline.objects.all().delete() diff --git a/amelie/videos/templates/videos/videos.html b/amelie/videos/templates/videos/videos.html index 867551d..db660dd 100644 --- a/amelie/videos/templates/videos/videos.html +++ b/amelie/videos/templates/videos/videos.html @@ -30,7 +30,7 @@

{% if login_voor_meer %}

- {% url 'login' as login_url %} + {% url 'oidc_authentication_init' as login_url %} {% trans "More videos are available if you" %} {% trans 'logged in' %}.

{% endif %} diff --git a/requirements.txt b/requirements.txt index 762f135..cfbccb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,9 +60,6 @@ oauth2client>=4.1.3,<4.2 # Mastodon API wrapper Mastodon.py>=1.5.1,<1.6 -social-auth-core[openidconnect] >= 4.1.0 -social-auth-app-django>=5.0.0,<5.1 - # Development django-debug-toolbar>=3.7,<3.8 django-rosetta>=0.9.8,<0.10 @@ -71,9 +68,6 @@ pyinotify>=0.9.6,<0.10 model_bakery>=1.7.0,<1.8 django-silk>=5.0.1,<5.1 -# SAML SP (requires xmlsec1, libssl-dev and libsasl2-dev (on Debian)) -djangosaml2>=1.5.3,<1.6 - # SAML IdP (requires xmlsec1 (on Debian)) pysaml2>=7.2.1,<7.3 djangosaml2idp>=0.7.2,<0.8 diff --git a/templates/basis.html b/templates/basis.html index 2e18ab2..154c380 100644 --- a/templates/basis.html +++ b/templates/basis.html @@ -1,6 +1,6 @@ -{% load i18n banners active_menu compress cached_static provider_name %} +{% load i18n banners active_menu compress cached_static %} {% get_current_language as LANGUAGE_CODE %} From 75c22bc3abe30462c51de1632c24121f0472a85c Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Mon, 1 May 2023 19:59:31 +0200 Subject: [PATCH 15/22] Remove old templatetag --- templates/login.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/login.html b/templates/login.html index 9b8f3d6..396ebb7 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,5 +1,5 @@ {% extends "basis.html" %} -{% load i18n provider_name %} +{% load i18n %} {% block titel %}{% trans 'Log in' %}{% endblock titel %} From 63b6ff5ba3c3a10556844300fc3e6a127758437f Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Tue, 2 May 2023 11:53:56 +0200 Subject: [PATCH 16/22] userinfo: Also return userinfo for ExtraPerson accounts --- amelie/members/views.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/amelie/members/views.py b/amelie/members/views.py index ed4f13e..5b2b7ad 100644 --- a/amelie/members/views.py +++ b/amelie/members/views.py @@ -31,7 +31,7 @@ from django.utils.translation import gettext as _ from django.views.generic.edit import DeleteView, FormView -from amelie.claudia.models import Mapping +from amelie.claudia.models import Mapping, ExtraPerson from amelie.members.forms import PersonDataForm, StudentNumberForm, \ RegistrationFormPersonalDetails, RegistrationFormStepMemberContactDetails, \ RegistrationFormStepParentsContactDetails, RegistrationFormStepFreshmenStudyDetails, \ @@ -1388,8 +1388,17 @@ def _person_info_get_person(ia_username=None, ut_username=None, local_username=N person = Person.objects.get(account_name=ia_username) logger.debug(f"Person found by ia_username {ia_username}: {person}.") except Person.DoesNotExist: - logger.info(f"Person with ia_username {ia_username} does not exist.") - person = None + # It could also be an ExtraPerson + try: + person = ExtraPerson.objects.get(adname=ia_username) + logger.debug(f"ExtraPerson found by ia_username {ia_username}: {person}.") + if not person.is_active(): + person = None + logger.info(f"Account of ExtraPerson {person} is not active (any more), " + f"and thus is no info will be returned for username {ia_username}.") + except ExtraPerson.DoesNotExist: + logger.info(f"Person or ExtraPerson with ia_username {ia_username} does not exist.") + person = None elif ut_username is not None: try: if ut_username[0] == 's': @@ -1465,7 +1474,7 @@ def person_userinfo(request): username = body.get('iaUsername', None) or body.get('utUsername', None) or body.get('localUsername', None) # If a person was found, return the userinfo that auth.ia needs. Else return an empty object. - if person is not None: + if person is not None and isinstance(person, Person): log.info(f"UserInfo retrieved for person {person} using username {username}.") email = "{}@{}".format(person.get_adname(), settings.CLAUDIA_GSUITE['PRIMARY_DOMAIN']) return HttpJSONResponse({ @@ -1475,6 +1484,15 @@ def person_userinfo(request): 'employeeNumber': f"m{person.employee.number}" if person.is_employee() else None, 'externalUsername': person.ut_external_username }) + elif person is not None and isinstance(person, ExtraPerson): + log.info(f"UserInfo retrieved for ExtraPerson {person} using username {username}.") + return HttpJSONResponse({ + 'iaUsername': person.get_adname() or None, + 'iaEmail': person.get_email() or None, + 'studentNumber': None, + 'employeeNumber': None, + 'externalUsername': None + }) else: log.info(f"UserInfo not found for user iaUsername={body.get('iaUsername', None)}, " f"utUsername={body.get('utUsername', None)}, localUsername={body.get('localUsername', None)}.") From 50cbd042e2c9af34e197b6ec3b56635b76c018e1 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Mon, 8 May 2023 11:49:38 +0200 Subject: [PATCH 17/22] Update requirements.txt to add sentry-sdk --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index 762f135..e91ea21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -80,3 +80,6 @@ djangosaml2idp>=0.7.2,<0.8 # Single Sign On - OIDC Client mozilla-django-oidc + +# Sentry error logging +sentry-sdk>=1.22.1,<1.23 From f059a8d076ce8705773882aab3b5bf292d1c9ad5 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Mon, 15 May 2023 19:37:20 +0200 Subject: [PATCH 18/22] Update test_all_urls.py --- amelie/test_all_urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/amelie/test_all_urls.py b/amelie/test_all_urls.py index 7db2738..0d60cef 100644 --- a/amelie/test_all_urls.py +++ b/amelie/test_all_urls.py @@ -37,8 +37,8 @@ "account:activate_forwarding_address", "account:add_forwarding_address", "account:check_forwarding_status", "account:check_forwarding_verification", "account:deactivate_forwarding_address", - # Uses YouTube API credentials, does not need to work in development - "videos:new_yt_video", + # Uses YouTube API credentials / relies on external service, does not need to work in development + "videos:new_yt_video", "videos:new_ia_video", # Room narrowcasting page uses Spotify and Icinga API that is not configured in development. "narrowcasting:room_pcstatus", "narrowcasting:room_spotify_callback", "narrowcasting:room_spotify_now_playing", From 820430f0105f425dcc05f82f2c6f559f10d8e61c Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Tue, 23 May 2023 17:30:18 +0200 Subject: [PATCH 19/22] companies: Add backend to manage banners shown on the vivat website, retrievable via JSON request. --- amelie/companies/forms.py | 14 +++- .../companies/migrations/0006_vivatbanner.py | 28 ++++++++ amelie/companies/models.py | 14 ++++ .../templates/companies/company_banners.html | 41 +++++++++++- amelie/companies/urls.py | 2 + amelie/companies/views.py | 64 +++++++++++++++++-- requirements.txt | 2 +- 7 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 amelie/companies/migrations/0006_vivatbanner.py diff --git a/amelie/companies/forms.py b/amelie/companies/forms.py index 9bdf604..97e3818 100644 --- a/amelie/companies/forms.py +++ b/amelie/companies/forms.py @@ -5,7 +5,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from amelie.companies.models import Company, WebsiteBanner, TelevisionBanner, CompanyEvent +from amelie.companies.models import Company, WebsiteBanner, TelevisionBanner, CompanyEvent, VivatBanner from amelie.style.forms import inject_style from amelie.calendar.forms import EventForm from amelie.tools.widgets import DateTimeSelector, DateSelector @@ -81,6 +81,16 @@ class Meta: fields = ["picture", "name", 'start_date', "end_date", 'active'] +class VivatBannerForm(forms.ModelForm): + picture = forms.FileField(label=_('Vivat banner'), widget=AdminFileWidget) + start_date = forms.DateField(widget=DateSelector) + end_date = forms.DateField(widget=DateSelector) + + class Meta: + model = VivatBanner + fields = ["picture", "name", 'start_date', "end_date", 'active', 'url'] + + class CompanyEventForm(EventForm): company = forms.ModelChoiceField(queryset=Company.objects.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now()), required=False) @@ -130,4 +140,4 @@ class StatisticsForm(forms.Form): end_date = forms.SplitDateTimeField(label=_('End (till)'), widget=DateTimeSelector, required=True) -inject_style(CompanyForm, CompanyEventForm, BannerForm, TelevisionBannerForm, StatisticsForm) +inject_style(CompanyForm, CompanyEventForm, BannerForm, TelevisionBannerForm, VivatBannerForm, StatisticsForm) diff --git a/amelie/companies/migrations/0006_vivatbanner.py b/amelie/companies/migrations/0006_vivatbanner.py new file mode 100644 index 0000000..8012b9b --- /dev/null +++ b/amelie/companies/migrations/0006_vivatbanner.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.17 on 2023-05-23 14:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('companies', '0005_add_default_career_label'), + ] + + operations = [ + migrations.CreateModel( + name='VivatBanner', + fields=[ + ('basebanner_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='companies.basebanner')), + ('url', models.URLField()), + ('views', models.PositiveIntegerField(default=0, editable=False)), + ], + options={ + 'verbose_name': 'I/O Vivat banner', + 'verbose_name_plural': 'I/O Vivat banners', + 'ordering': ['-end_date'], + }, + bases=('companies.basebanner',), + ), + ] diff --git a/amelie/companies/models.py b/amelie/companies/models.py index aeef0c4..4942c8f 100644 --- a/amelie/companies/models.py +++ b/amelie/companies/models.py @@ -178,6 +178,20 @@ class Meta: verbose_name_plural = _('Television banners') +class VivatBanner(BaseBanner): + url = models.URLField() + views = models.PositiveIntegerField(editable=False, default=0) + + def save(self, **kwargs): + self.slug = slugify(self.name) + super(VivatBanner, self).save(**kwargs) + + class Meta: + ordering = ['-end_date'] + verbose_name = _('I/O Vivat banner') + verbose_name_plural = _('I/O Vivat banners') + + class CompanyEvent(Event): objects = EventManager() company = models.ForeignKey('Company', blank=True, null=True, on_delete=models.SET_NULL) diff --git a/amelie/companies/templates/companies/company_banners.html b/amelie/companies/templates/companies/company_banners.html index 0c3f92a..f627113 100644 --- a/amelie/companies/templates/companies/company_banners.html +++ b/amelie/companies/templates/companies/company_banners.html @@ -11,7 +11,7 @@

{% trans 'Website banners' %}

{% blocktrans %} - Click on the website banner to edit it + Click on the website banner to edit it. {% endblocktrans %}

{% trans 'New' %} @@ -47,8 +47,7 @@

{% trans "Television banners" %}

{% blocktrans %} - Clicking on the television banner should allow you to edit it, but this feature has not been - implemented yet. + Click on the television banner to edit it. {% endblocktrans %}

{% trans "New" %} @@ -76,4 +75,40 @@

{% trans "Television banners" %}

{% endfor %} + +
+
+

{% trans "I/O Vivat banners" %}

+ +
+

+ {% blocktrans %} + Click on the vivat banner to edit it. + {% endblocktrans %} +

+ {% trans "New" %} +
+
+
+ {% for vb in vivat_banners %} + + {% endfor %} {% endblock %} diff --git a/amelie/companies/urls.py b/amelie/companies/urls.py index 6966d0a..d82a119 100644 --- a/amelie/companies/urls.py +++ b/amelie/companies/urls.py @@ -11,6 +11,8 @@ path('banners//', views.banner_edit, name='banner_edit'), path('banners/create_web/', views.websitebanner_create, name='websitebanner_create'), path('banners/create_tv/', views.televisionbanner_create, name='televisionbanner_create'), + path('banners/create_vivat/', views.vivatbanner_create, name='vivatbanner_create'), + path('banners/vivat/', views.vivatbanner_get, name='vivatbanner_get'), path('activities/', views.event_list, name='event_list'), path('activities//', views.event_details, name='event_details'), diff --git a/amelie/companies/views.py b/amelie/companies/views.py index 38ace3b..2469741 100644 --- a/amelie/companies/views.py +++ b/amelie/companies/views.py @@ -2,6 +2,7 @@ import logging import random +from django.conf import settings from django.urls import reverse from django.db.models import Q from django.http import HttpResponseRedirect, HttpResponse @@ -10,13 +11,15 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView -from amelie.companies.forms import CompanyForm, BannerForm, TelevisionBannerForm, CompanyEventForm, StatisticsForm -from amelie.companies.models import BaseBanner, Company, CompanyEvent, TelevisionBanner, WebsiteBanner +from amelie.companies.forms import CompanyForm, BannerForm, TelevisionBannerForm, CompanyEventForm, StatisticsForm, \ + VivatBannerForm +from amelie.companies.models import BaseBanner, Company, CompanyEvent, TelevisionBanner, WebsiteBanner, VivatBanner from amelie.members.models import Committee from amelie.statistics.decorators import track_hits from amelie.tools.decorators import require_board from amelie.tools.forms import PeriodForm from amelie.tools.calendar import ical_calendar +from amelie.tools.http import HttpJSONResponse from amelie.tools.mixins import RequireBoardMixin, RequireCommitteeMixin logger = logging.getLogger(__name__) @@ -58,19 +61,25 @@ def banner_list(request): """ List of all banners """ website_banners = WebsiteBanner.objects.all() television_banners = TelevisionBanner.objects.all() + vivat_banners = VivatBanner.objects.all() today = datetime.date.today() - return render(request, 'companies/company_banners.html', {'website_banners': website_banners, 'television_banners': television_banners, 'today': today}) + return render(request, 'companies/company_banners.html', { + 'website_banners': website_banners, 'television_banners': television_banners, 'vivat_banners': vivat_banners, + 'today': today + }) @require_board def banner_edit(request, id): - """ Edit either a website banner or television banner. """ + """ Edit either a website banner, television banner or vivat banner. """ obj = get_object_or_404(BaseBanner, id=id) form = None if request.method == "POST": if hasattr(obj, 'websitebanner'): form = BannerForm(request.POST, request.FILES, instance=obj.websitebanner) + elif hasattr(obj, 'vivatbanner'): + form = VivatBannerForm(request.POST, request.FILES, instance=obj.vivatbanner) else: form = TelevisionBannerForm(request.POST, request.FILES, instance=obj.televisionbanner) @@ -81,6 +90,8 @@ def banner_edit(request, id): else: if hasattr(obj, 'websitebanner'): form = BannerForm(instance=obj.websitebanner) + elif hasattr(obj, 'vivatbanner'): + form = VivatBannerForm(instance=obj.vivatbanner) else: form = TelevisionBannerForm(instance=obj.televisionbanner) @@ -117,6 +128,51 @@ def televisionbanner_create(request): return render(request, 'companies/company_banners_form.html', locals()) +@require_board +def vivatbanner_create(request): + """ Create a vivat banner. """ + if request.method == "POST": + form = VivatBannerForm(request.POST, request.FILES) + if form.is_valid(): + form.save() + return HttpResponseRedirect(reverse('companies:banner_list')) + + else: + form = VivatBannerForm() + + return render(request, 'companies/company_banners_form.html', locals()) + + +def vivatbanner_get(request): + """ Gets a number (3 default) of vivat banners in JSON format for use on the IO Vivat website. """ + try: + # Get amount from argument 'n', default is 3 + amount = int(request.GET.get("n", "3")) + # Clamp amount between 1 and 20 + amount = max(1, min(amount, 20)) + except ValueError: + # Could not parse argument, use default + amount = 3 + + # Get banners (only currently active banners, random order) + banners = VivatBanner.objects.filter( + active=True, start_date__lte=datetime.date.today(), end_date__gte=datetime.date.today() + ).order_by('?')[:amount] + + # Return them as JSON data + return HttpJSONResponse({ + 'amount': amount, + 'banners': [ + { + "picture": "{}{}".format(settings.MEDIA_URL, banner.picture), + "name": banner.name, + "link": banner.url + } + for banner in banners + ] + }) + + @require_board def company_overview_old(request): """ diff --git a/requirements.txt b/requirements.txt index e91ea21..deba188 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django>=3.2.16,<3.3 +Django>=3.2.16,<3.2.18 # Limited because of issue https://github.com/Inter-Actief/amelie/issues/760 importlib-metadata>=1.4.0,<5.0 # importlib-metadata 5+ causes Celery bug: https://github.com/celery/celery/issues/7783 celery>=5.2.7,<5.3 From 8c3e8091ffd2a6bf57a47b83be2f24bdcb2ea685 Mon Sep 17 00:00:00 2001 From: Maarten Meijer Date: Thu, 25 May 2023 13:14:32 +0200 Subject: [PATCH 20/22] Declaration policy update --- amelie/gmm/templates/gmm_overview.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/amelie/gmm/templates/gmm_overview.html b/amelie/gmm/templates/gmm_overview.html index e81357e..f74155d 100644 --- a/amelie/gmm/templates/gmm_overview.html +++ b/amelie/gmm/templates/gmm_overview.html @@ -48,8 +48,8 @@

{% trans 'Articles of Association, Rules and Regulations and other documents English)
  • - Declaration Policy, last modified on the GMM of 1 February 2021 (Dutch / - English) + Declaration Policy, last modified on the GMM of 20 March 2023 (Dutch / + English)
  • Explainer "What is a GMM?" (English) From fff62ffba2a319fa1e18516a86acc02086d0e6fc Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Tue, 30 May 2023 14:13:25 +0200 Subject: [PATCH 21/22] videos: Send exceptions on external requests for video info to Sentry --- amelie/videos/views.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/amelie/videos/views.py b/amelie/videos/views.py index d515b3f..888736a 100644 --- a/amelie/videos/views.py +++ b/amelie/videos/views.py @@ -204,9 +204,9 @@ def get_form(self, form_class=None): def get(self, request, *args, **kwargs): try: return super(YoutubeVideoCreate, self).get(request, *args, **kwargs) - except (googleapiclient_Error, oauth2client_Error): - #from raven.contrib.django.raven_compat.models import client - #client.captureException() + except (googleapiclient_Error, oauth2client_Error) as e: + import sentry_sdk + sentry_sdk.capture_exception(e) messages.error(request, _("Could not connect to Youtube! " "Please contact the WWW committee if this problem persists.")) @@ -264,9 +264,9 @@ def get_form(self, form_class=None): def get(self, request, *args, **kwargs): try: return super(StreamingVideoCreate, self).get(request, *args, **kwargs) - except (RequestsConnectionError, JSONDecodeError): - #from raven.contrib.django.raven_compat.models import client - #client.captureException() + except (RequestsConnectionError, JSONDecodeError) as e: + import sentry_sdk + sentry_sdk.capture_exception(e) messages.error(request, _("Could not connect to Streaming.IA! " "Please contact the WWW committee if this problem persists.")) From 8c6deb9d12935e494f4f11a8c34ce906e2a1bba1 Mon Sep 17 00:00:00 2001 From: Kevin Alberts Date: Thu, 1 Jun 2023 14:29:07 +0200 Subject: [PATCH 22/22] companies/api: Move io vivat banner get endpoint to API namespace to avoid CORS error, add admin page for Vivat banners --- amelie/api/urls.py | 3 +++ amelie/companies/admin.py | 7 ++++++- amelie/companies/urls.py | 1 - 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/amelie/api/urls.py b/amelie/api/urls.py index 0610160..02ded97 100644 --- a/amelie/api/urls.py +++ b/amelie/api/urls.py @@ -3,10 +3,13 @@ from modernrpc.core import Protocol from modernrpc.views import RPCEntryPoint +from amelie.companies.views import vivatbanner_get + app_name = 'api' urlpatterns = [ path('', RPCEntryPoint.as_view(protocol=Protocol.JSON_RPC), name="jsonrpc_mountpoint"), path('docs/', RPCEntryPoint.as_view(enable_doc=True, enable_rpc=False, template_name="api/doc_index.html")), + path('vivat_banners/', vivatbanner_get, name='vivatbanner_get'), ] diff --git a/amelie/companies/admin.py b/amelie/companies/admin.py index 701da7e..4847656 100644 --- a/amelie/companies/admin.py +++ b/amelie/companies/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from amelie.companies.models import Company, TelevisionBanner, WebsiteBanner +from amelie.companies.models import Company, TelevisionBanner, WebsiteBanner, VivatBanner class CompanyAdmin(admin.ModelAdmin): @@ -16,6 +16,11 @@ class TelevisionBannerAdmin(admin.ModelAdmin): list_display = ['name', 'start_date', 'end_date', 'active'] +class VivatBannerAdmin(admin.ModelAdmin): + list_display = ['name', 'start_date', 'end_date', 'active'] + + admin.site.register(Company, CompanyAdmin) admin.site.register(WebsiteBanner, WebsiteBannerAdmin) admin.site.register(TelevisionBanner, TelevisionBannerAdmin) +admin.site.register(VivatBanner, VivatBannerAdmin) diff --git a/amelie/companies/urls.py b/amelie/companies/urls.py index d82a119..587a56c 100644 --- a/amelie/companies/urls.py +++ b/amelie/companies/urls.py @@ -12,7 +12,6 @@ path('banners/create_web/', views.websitebanner_create, name='websitebanner_create'), path('banners/create_tv/', views.televisionbanner_create, name='televisionbanner_create'), path('banners/create_vivat/', views.vivatbanner_create, name='vivatbanner_create'), - path('banners/vivat/', views.vivatbanner_get, name='vivatbanner_get'), path('activities/', views.event_list, name='event_list'), path('activities//', views.event_details, name='event_details'),