From 6b4c7949130b3df43d6977cd9dbe9896d3418b3b Mon Sep 17 00:00:00 2001 From: SanttuA Date: Tue, 18 Jun 2024 08:50:17 +0300 Subject: [PATCH 01/12] Added extra prefs for user (#367) Changes: - new model to store extra preferences and admin resource order for a user - new api endpoint `user/set_admin_resource_order` to set user's admin resource order - user get endpoint includes extra prefs in data --- resources/tests/test_user_api.py | 89 +++++++++++++++++++ users/api.py | 57 +++++++++++- .../0017_user_extra_prefs_resource_order.py | 24 +++++ users/models.py | 39 +++++++- 4 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 users/migrations/0017_user_extra_prefs_resource_order.py diff --git a/resources/tests/test_user_api.py b/resources/tests/test_user_api.py index 98ee8dda5..2c3b606a6 100644 --- a/resources/tests/test_user_api.py +++ b/resources/tests/test_user_api.py @@ -4,6 +4,8 @@ from django.urls import reverse from guardian.shortcuts import assign_perm +from users.models import ExtraPrefs + from .utils import check_only_safe_methods_allowed from resources.tests.test_api import JWTMixin @@ -19,6 +21,11 @@ def detail_url(user): return reverse('user-detail', kwargs={'pk': user.pk}) +@pytest.fixture +def extra_prefs(user, db): + return ExtraPrefs.objects.create(user=user, admin_resource_order=['res1id', 'res2id', 'res3id']) + + @pytest.mark.django_db def test_disallowed_methods(all_user_types_api_client, list_url, detail_url): """ @@ -65,3 +72,85 @@ def test_inactive_user(api_client, detail_url, user, test_unit): user.save() response = api_client.get(detail_url, HTTP_AUTHORIZATION=auth) assert response.status_code == 401 + + +@pytest.mark.django_db +def test_get_user_without_extra_prefs(api_client, list_url, user): + api_client.force_authenticate(user=user) + response = api_client.get(list_url) + assert response.status_code == 200 + assert response.data['count'] == 1 + user_data = response.data['results'][0] + assert user_data['extra_prefs'] == None + + +@pytest.mark.django_db +def test_get_user_with_extra_prefs(api_client, list_url, user, extra_prefs): + api_client.force_authenticate(user=user) + response = api_client.get(list_url) + assert response.status_code == 200 + assert response.data['count'] == 1 + user_data = response.data['results'][0] + assert user_data['extra_prefs'] == {'admin_resource_order': ['res1id', 'res2id', 'res3id']} + + +@pytest.mark.django_db +@pytest.mark.parametrize('admin_resource_order, expected', ( + (['res9id', 'res8id', 'res7id'], ['res9id', 'res8id', 'res7id']), + ([], []), + ('', []), +)) +def test_set_admin_resource_order_for_user_without_extra_prefs(api_client, user, list_url, admin_resource_order, expected): + url = '%sset_admin_resource_order/' % list_url + api_client.force_authenticate(user=user) + response = api_client.post(url, data={'admin_resource_order': admin_resource_order}, format='json') + assert response.status_code == 200 + extra_prefs = ExtraPrefs.objects.get(user=user) + assert extra_prefs.admin_resource_order == expected + + +@pytest.mark.django_db +@pytest.mark.parametrize('admin_resource_order, expected', ( + (['123', '456'], ['123', '456']), + ([], []), + ('', []), +)) +def test_set_admin_resource_order_for_user_with_extra_prefs(api_client, user, list_url, extra_prefs, admin_resource_order, expected): + url = '%sset_admin_resource_order/' % list_url + api_client.force_authenticate(user=user) + response = api_client.post(url, data={'admin_resource_order': admin_resource_order}, format='json') + assert response.status_code == 200 + extra_prefs = ExtraPrefs.objects.get(user=user) + assert extra_prefs.admin_resource_order == expected + + +@pytest.mark.django_db +def test_set_admin_resource_order_without_data(api_client, user, list_url, extra_prefs): + url = '%sset_admin_resource_order/' % list_url + api_client.force_authenticate(user=user) + + response = api_client.post(url, data={'admin_resource_order': None}, format='json') + assert response.status_code == 400 + extra_prefs = ExtraPrefs.objects.get(user=user) + assert extra_prefs.admin_resource_order == ['res1id', 'res2id', 'res3id'] + + response = api_client.post(url, data={}, format='json') + assert response.status_code == 400 + extra_prefs = ExtraPrefs.objects.get(user=user) + assert extra_prefs.admin_resource_order == ['res1id', 'res2id', 'res3id'] + + +@pytest.mark.django_db +def test_set_admin_resource_order_with_invalid_data(api_client, user, list_url, extra_prefs): + url = '%sset_admin_resource_order/' % list_url + api_client.force_authenticate(user=user) + + response = api_client.post(url, data={'admin_resource_order': 1}, format='json') + assert response.status_code == 400 + extra_prefs = ExtraPrefs.objects.get(user=user) + assert extra_prefs.admin_resource_order == ['res1id', 'res2id', 'res3id'] + + response = api_client.post(url, data={'admin_resource_order': {}}, format='json') + assert response.status_code == 400 + extra_prefs = ExtraPrefs.objects.get(user=user) + assert extra_prefs.admin_resource_order == ['res1id', 'res2id', 'res3id'] diff --git a/users/api.py b/users/api.py index b10060de9..82e662023 100644 --- a/users/api.py +++ b/users/api.py @@ -1,9 +1,12 @@ from django.conf import settings from django.contrib.auth import get_user_model -from rest_framework import permissions, serializers, generics, mixins, viewsets +from django.core.exceptions import ValidationError, ObjectDoesNotExist +from rest_framework import permissions, serializers, generics, mixins, viewsets, response, status +from rest_framework.decorators import action from resources.models.utils import build_ical_feed_url from resources.models import Unit +from users.models import ExtraPrefs all_views = [] @@ -16,6 +19,31 @@ def register_view(klass, name, base_name=None): all_views.append(entry) +class ResourceOrderSerializer(serializers.Field): + def to_representation(self, value): + if isinstance(value, list): + return value + return value.split(',') + + def to_internal_value(self, data): + if isinstance(data, str): + if not data: + return [] + return data.split(',') + elif isinstance(data, list): + return data + else: + raise ValidationError("Value must be a list or a comma-separated string") + + +class ExtraPrefsSerializer(serializers.ModelSerializer): + admin_resource_order = ResourceOrderSerializer(required=False) + + class Meta: + model = ExtraPrefs + fields = ['admin_resource_order'] + + class UserSerializer(serializers.ModelSerializer): display_name = serializers.ReadOnlyField(source='get_display_name') ical_feed_url = serializers.SerializerMethodField() @@ -86,6 +114,11 @@ def get_staff_perms(self, obj): def to_representation(self, instance): data = super(UserSerializer, self).to_representation(instance) user = self.context['request'].user + try: + extra_prefs = ExtraPrefs.objects.get(user=user) + data['extra_prefs'] = ExtraPrefsSerializer(extra_prefs).data + except ObjectDoesNotExist: + data['extra_prefs'] = None if user.id != instance.id: data.pop('birthdate', None) @@ -111,6 +144,28 @@ def get_object(self): obj = self.request.user return obj + def _set_admin_resource_order(self, request): + user = self.request.user + value = request.data.get('admin_resource_order', None) + if isinstance(value, str): + value = value.split(',') + + if not isinstance(value, list): + return response.Response({'detail': 'Invalid input format. Value must be a list of resource IDs'}, status=status.HTTP_400_BAD_REQUEST) + + if value or value == []: + extra_prefs, created = ExtraPrefs.objects.get_or_create(user=user) + + extra_prefs.admin_resource_order = value + extra_prefs.save() + return response.Response(status=status.HTTP_200_OK) + + return response.Response(status=status.HTTP_304_NOT_MODIFIED) + + @action(detail=False, methods=['post']) + def set_admin_resource_order(self, request, pk=None): + return self._set_admin_resource_order(request) + permission_classes = [permissions.IsAuthenticated] queryset = get_user_model().objects.all() serializer_class = UserSerializer diff --git a/users/migrations/0017_user_extra_prefs_resource_order.py b/users/migrations/0017_user_extra_prefs_resource_order.py new file mode 100644 index 000000000..ec371a17b --- /dev/null +++ b/users/migrations/0017_user_extra_prefs_resource_order.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.25 on 2024-06-07 05:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import users.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0016_add_user_oid_field'), + ] + + operations = [ + migrations.CreateModel( + name='ExtraPrefs', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('admin_resource_order', users.models.ResourceOrder()), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/users/models.py b/users/models.py index a50e9f79b..a7b57be3f 100644 --- a/users/models.py +++ b/users/models.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from django.conf import settings from django.contrib import admin, messages +from django.core.exceptions import ValidationError from resources.models import Resource import datetime @@ -39,7 +40,7 @@ class User(AbstractUser): "Designates whether the user is a General Administrator " "with special permissions to many objects within Respa. " "This is almost as powerful as superuser.")) - + @property def is_strong_auth(self): return self.amr in settings.STRONG_AUTH_CLAIMS @@ -74,3 +75,39 @@ def get_user_age(self): def has_outlook_link(self): return getattr(self, 'outlookcalendarlink', False) + + +class ResourceOrder(models.TextField): + description = "A custom field to store a comma-separated list of resource IDs" + + def to_python(self, value): + if not value: + return [] + if isinstance(value, list): + return value + return value.split(',') + + def from_db_value(self, value, expression, connection): + if value is None: + return value + return self.to_python(value) + + def get_prep_value(self, value): + if not value: + return '' + if isinstance(value, list): + return ','.join(map(str, value)) + raise ValidationError("Value must be a list") + + def validate(self, value, model_instance): + if not isinstance(value, list): + raise ValidationError("Value must be a list") + super().validate(value, model_instance) + + +class ExtraPrefs(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + admin_resource_order = ResourceOrder() + + def __str__(self): + return f"{_('Extra preferences')} ({self.id})" From 872cbc1b77cfca31840d2204b524bae74b57f4b5 Mon Sep 17 00:00:00 2001 From: ezkat <50319957+ezkat@users.noreply.github.com> Date: Wed, 19 Jun 2024 11:30:34 +0300 Subject: [PATCH 02/12] Enforce overnight resource opening and closing times (#370) * Enforce overnight resource opening and closing times (#370) --- resources/models/availability.py | 4 ++ respa_admin/forms.py | 44 ++++++++++++++++--- respa_admin/static_src/js/resourceForm.js | 12 +++++ .../respa_admin/common/_period_day.html | 8 +++- 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/resources/models/availability.py b/resources/models/availability.py index 29f2fb3a3..73f8cf747 100644 --- a/resources/models/availability.py +++ b/resources/models/availability.py @@ -212,6 +212,10 @@ def __str__(self): def save(self, *args, **kwargs): if self.opens and self.closes: + resource = getattr(self.period, 'resource', None) + if resource and resource.overnight_reservations: + self.opens = '00:00' + self.closes = '23:59' try: opens = int(self.opens.isoformat().replace(":", "")) closes = int(self.closes.isoformat().replace(":", "")) diff --git a/respa_admin/forms.py b/respa_admin/forms.py index 095600187..a7ba9ed14 100644 --- a/respa_admin/forms.py +++ b/respa_admin/forms.py @@ -1,3 +1,4 @@ +import datetime from django.utils.translation import gettext_lazy as _ from django.db.models import Q from django import forms @@ -127,6 +128,23 @@ class Meta: model = Day fields = ['weekday', 'opens', 'closes', 'closed'] + + def has_overnight_reservations(self): + resource = getattr(self, 'resource', None) + return resource and isinstance(resource, Resource) and resource.overnight_reservations + + def clean_opens(self): + opens = self.cleaned_data.get('opens', None) + if self.has_overnight_reservations(): + return datetime.time(hour=0, minute=0) + return opens + + def clean_closes(self): + closes = self.cleaned_data.get('closes', None) + if self.has_overnight_reservations(): + return datetime.time(hour=23, minute=59) + return closes + def clean(self): cleaned_data = super().clean() opens = cleaned_data.get('opens', None) @@ -141,6 +159,13 @@ def clean(self): return cleaned_data + def set_hidden(self): + if self.has_overnight_reservations(): + self.fields['opens'].widget.attrs['style'] = 'display: none;' + self.fields['closes'].widget.attrs['style'] = 'display: none;' + + def has_changed(self): + return True class PeriodForm(forms.ModelForm): name = forms.CharField( @@ -460,6 +485,12 @@ def _get_days_formset(self, form, extra_days=1): form=DaysForm, extra=extra_days, validate_max=True + )( + instance=form.instance, + data=form.data if form.is_bound else None, + prefix='days-%s' % ( + form.prefix, + ), ) if self.instance and self.instance.pk: @@ -469,13 +500,12 @@ def _get_days_formset(self, form, extra_days=1): if field.disabled: field.required = False - return days_formset( - instance=form.instance, - data=form.data if form.is_bound else None, - prefix='days-%s' % ( - form.prefix, - ), - ) + for day in days_formset.forms: + setattr(day, 'resource', self.instance) + day.set_hidden() + + return days_formset + def add_fields(self, form, index): super(PeriodFormset, self).add_fields(form, index) diff --git a/respa_admin/static_src/js/resourceForm.js b/respa_admin/static_src/js/resourceForm.js index b6eca522f..7cbb6eaee 100644 --- a/respa_admin/static_src/js/resourceForm.js +++ b/respa_admin/static_src/js/resourceForm.js @@ -563,12 +563,24 @@ function bindOvernightReservations() { const handle = () => { if ($(checkbox).is(':checked')) { + let periodDays = $("[id^=period-day-]"); + $(periodDays).each((_, day) => { + $(day).find("input[id$=opens").hide(); + $(day).find("span[id^=span-day-]").hide(); + $(day).find("input[id$=closes").hide(); + }); $(overnight).show(); $(overnightTimeFields).show(); $(regular).hide(); $(regular).find('select').attr('disabled', 'disabled'); $(overnight).find('input').removeAttr('disabled'); } else { + let periodDays = $("[id^=period-day-]"); + $(periodDays).each((_, day) => { + $(day).find("input[id$=opens").show(); + $(day).find("span[id^=span-day-]").show(); + $(day).find("input[id$=closes").show(); + }); $(regular).show(); $(overnightTimeFields).hide(); $(overnight).hide(); diff --git a/respa_admin/templates/respa_admin/common/_period_day.html b/respa_admin/templates/respa_admin/common/_period_day.html index 1909e87f8..925b1e613 100644 --- a/respa_admin/templates/respa_admin/common/_period_day.html +++ b/respa_admin/templates/respa_admin/common/_period_day.html @@ -17,7 +17,13 @@
{{ day.opens }} - + {{ day.closes }}
From b334e9a1295465e11f421cc06dc46be7477ec6ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Aug 2024 07:33:45 +0300 Subject: [PATCH 03/12] Bump urllib3 from 1.26.18 to 1.26.19 (#369) Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.18 to 1.26.19. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/1.26.19/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.26.18...1.26.19) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 163f77dd3..f44a1c740 100644 --- a/requirements.txt +++ b/requirements.txt @@ -522,7 +522,7 @@ uritemplate==4.1.1 # drf-yasg url-normalize==1.4.3 # via requests-cache -urllib3==1.26.18 +urllib3==1.26.19 # via # -r requirements.in # requests From a54fbdb93d89cca3a85c1cf4727d43f23eaa7850 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2024 09:12:53 +0300 Subject: [PATCH 04/12] Bump braces from 3.0.2 to 3.0.3 in /respa_admin (#368) Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3. - [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3) --- updated-dependencies: - dependency-name: braces dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- respa_admin/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/respa_admin/package-lock.json b/respa_admin/package-lock.json index 6295e4cde..5253c2399 100644 --- a/respa_admin/package-lock.json +++ b/respa_admin/package-lock.json @@ -786,11 +786,11 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1530,9 +1530,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": { "to-regex-range": "^5.0.1" }, From 4ed92758de42c5ec0dc67707f2856fc101433359 Mon Sep 17 00:00:00 2001 From: ezkat <50319957+ezkat@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:32:27 +0300 Subject: [PATCH 05/12] Upgrade to Django 4 (#373) * Upgrade to Django 4 --------- Co-authored-by: SanttuA --- .github/workflows/respa.yml | 2 +- payments/api/order.py | 2 ++ requirements.in | 6 ++--- requirements.txt | 17 ++++++------ resources/admin/__init__.py | 2 +- resources/fields.py | 13 ++++++++++ resources/models/resource.py | 4 +-- respa/providers/turku_oidc/admin_site.py | 12 ++++----- respa/providers/turku_oidc/jwt.py | 33 ++++++++++++++++++++++++ respa/providers/turku_oidc/oidc.py | 24 ++++++++++++++++- respa/settings.py | 18 ++++++++----- respa_admin/auth.py | 2 +- respa_admin/urls.py | 4 +-- respa_admin/views/reports.py | 2 +- respa_exchange/tests/conftest.py | 6 ++--- respa_exchange/tests/session.py | 2 +- respa_exchange/tests/test_download.py | 8 +++--- respa_exchange/tests/test_listener.py | 10 +++---- respa_exchange/tests/test_upload.py | 6 ++--- respa_o365/urls.py | 3 ++- 20 files changed, 125 insertions(+), 51 deletions(-) diff --git a/.github/workflows/respa.yml b/.github/workflows/respa.yml index cad7b019b..5f64d2eae 100644 --- a/.github/workflows/respa.yml +++ b/.github/workflows/respa.yml @@ -14,7 +14,7 @@ jobs: runs-on: [ ubuntu-20.04 ] services: postgres: - image: postgis/postgis:11-2.5 + image: postgis/postgis:14-3.4 env: POSTGRES_USER: respa POSTGRES_PASSWORD: respa diff --git a/payments/api/order.py b/payments/api/order.py index 29e5f05e5..adfa254a2 100644 --- a/payments/api/order.py +++ b/payments/api/order.py @@ -6,6 +6,7 @@ from resources.api.base import register_view from resources.models import Reservation +from random import randint from ..api.base import OrderLineSerializer, OrderSerializerBase from ..models import CustomerGroup, Order, OrderCustomerGroupData, OrderLine, Product, ProductCustomerGroup @@ -67,6 +68,7 @@ def check_price(self, request): end = order_data.pop('end') order_data['state'] = 'price_check' order = Order(**order_data) + order.id = randint(99999999, 999999999) order_lines = [OrderLine(order=order, **data) for data in order_lines_data] # store the OrderLine objects in the Order object so that we can use diff --git a/requirements.in b/requirements.in index 6493cc787..976920abe 100644 --- a/requirements.in +++ b/requirements.in @@ -17,7 +17,7 @@ daemonize database-sanitizer>=0.4.0 defusedxml Delorean -Django==3.2.25 +Django>4 django-admin-json-editor==0.2.3 django-allauth django-anymail @@ -28,7 +28,7 @@ django-environ django-filter>=2.4.0 django-grappelli django-guardian -django-helusers-turku +django-helusers django-hstore django-image-cropping django-jinja @@ -37,7 +37,7 @@ django-jsonform django-modeltranslation django-mptt django-taggit -django-multi-email-field +django-multi-email-field==0.7.0 django-munigeo-turku==0.3 django-parler==2.3 django-parler-rest==2.2 diff --git a/requirements.txt b/requirements.txt index f44a1c740..568b4b8ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ beautifulsoup4==4.11.2 cached-property==1.5.2 # via exchangelib cachetools==5.3.0 - # via django-helusers-turku + # via django-helusers cattrs==22.2.0 # via requests-cache certifi==2023.7.22 @@ -94,10 +94,10 @@ defusedxml==0.7.1 delorean==1.0.0 # via -r requirements.in deprecation==2.1.0 - # via django-helusers-turku + # via django-helusers dill==0.3.6 # via pylint -django==3.2.25 +django==4.2.13 # via # -r requirements.in # django-admin-json-editor @@ -107,7 +107,7 @@ django==3.2.25 # django-cors-headers # django-filter # django-guardian - # django-helusers-turku + # django-helusers # django-jinja # django-js-asset # django-jsonform @@ -146,7 +146,7 @@ django-grappelli==3.0.4 # via -r requirements.in django-guardian==2.4.0 # via -r requirements.in -django-helusers-turku==1.0.0 +django-helusers==0.13.0 # via -r requirements.in django-hstore==1.4.2 # via -r requirements.in @@ -166,7 +166,7 @@ django-mptt==0.14.0 # via # -r requirements.in # django-munigeo-turku -django-multi-email-field==0.6.2 +django-multi-email-field==0.7.0 # via -r requirements.in django-munigeo-turku==0.3 # via -r requirements.in @@ -393,7 +393,7 @@ python-docx==0.8.11 python-jose==3.3.0 # via # -r requirements.in - # django-helusers-turku + # django-helusers python3-openid==3.2.0 # via # -r requirements.in @@ -404,7 +404,6 @@ pytz==2022.7.1 # -r requirements.in # babel # delorean - # django # djangorestframework # drf-yasg # icalendar @@ -425,7 +424,7 @@ requests==2.32.0 # coreapi # django-allauth # django-anymail - # django-helusers-turku + # django-helusers # django-munigeo-turku # exchangelib # pyjwkest diff --git a/resources/admin/__init__.py b/resources/admin/__init__.py index f8bcb871a..0b47800b7 100644 --- a/resources/admin/__init__.py +++ b/resources/admin/__init__.py @@ -2,7 +2,7 @@ from io import StringIO from contextlib import redirect_stdout from django.conf import settings -from django.conf.urls import re_path +from django.urls import re_path from django.contrib import admin from django.contrib.admin import site as admin_site from django.contrib.admin.utils import unquote diff --git a/resources/fields.py b/resources/fields.py index a2e3914da..16a139748 100644 --- a/resources/fields.py +++ b/resources/fields.py @@ -57,3 +57,16 @@ def get_language(self) -> str: if language not in ['fi', 'sv', 'en']: return settings.LANGUAGE_CODE # Fallback return language + +class MultiEmailField(models.TextField): + def to_python(self, value): + if not value: + return [] + if isinstance(value, list): + return value + return [val.strip() for val in value.splitlines() if val] + + def get_db_prep_value(self, value, connection, prepared): + if isinstance(value, list): + return '\n'.join(value) + return value diff --git a/resources/models/resource.py b/resources/models/resource.py index ec3c59864..007420de4 100644 --- a/resources/models/resource.py +++ b/resources/models/resource.py @@ -23,7 +23,6 @@ from django.utils.text import format_lazy from django.utils.translation import pgettext_lazy, gettext_lazy as _ from django.contrib.postgres.fields import DateTimeRangeField -from multi_email_field.fields import MultiEmailField from .gistindex import GistIndex from image_cropping import ImageRatioField from PIL import Image @@ -41,7 +40,8 @@ from ..errors import InvalidImage from ..fields import ( EquipmentField, - TranslatedCharField, TranslatedTextField + TranslatedCharField, TranslatedTextField, + MultiEmailField ) from .base import ( AutoIdentifiedModel, NameIdentifiedModel, diff --git a/respa/providers/turku_oidc/admin_site.py b/respa/providers/turku_oidc/admin_site.py index 297022d28..33f6cb1d2 100644 --- a/respa/providers/turku_oidc/admin_site.py +++ b/respa/providers/turku_oidc/admin_site.py @@ -1,14 +1,14 @@ from django.conf import settings -from django.contrib import admin -from django.contrib.admin.apps import AdminConfig as DjangoAdminConfig -from helusers.admin_site import reverse, AdminSite as HelAdminSite +import django.contrib.admin.apps as django_apps +from django.urls import reverse +import helusers.admin_site as hel_apps PROVIDERS = ( ('respa.providers.turku_oidc', 'turku_oidc_login'), ) -class AdminSite(HelAdminSite): +class AdminSite(hel_apps.AdminSite): login_template = 'admin/tku_login.html' def __init__(self, *args, **kwargs): @@ -44,5 +44,5 @@ def each_context(self, request): return ret -class AdminConfig(DjangoAdminConfig): - default_site = 'respa.providers.turku_oidc.admin_site.AdminSite' \ No newline at end of file +class AdminConfig(django_apps.AdminConfig): + default_site = 'respa.providers.turku_oidc.admin_site.AdminSite' diff --git a/respa/providers/turku_oidc/jwt.py b/respa/providers/turku_oidc/jwt.py index 9704f8451..76f5725ea 100644 --- a/respa/providers/turku_oidc/jwt.py +++ b/respa/providers/turku_oidc/jwt.py @@ -2,6 +2,39 @@ JWTAuthentication as SimpleJWTAuthentication ) +from helusers._oidc_auth_impl import JWT as _JWT, ValidationError +from jose import jwt + + +class JWT(_JWT): + def validate(self, keys, audience, **args): + options = { + "verify_at_hash": False + } + for required_claim in ["aud", "exp"]: + options[f"require_{required_claim}"] = True + + jwt.decode( + self._encoded_jwt, + keys, + algorithms=self.settings.ALLOWED_ALGORITHMS, + options=options, + audience=self.settings.AUDIENCE + ) + + claims = self.claims + if "aud" not in claims: + raise ValidationError("Missing required 'aud' claim.") + + if "aud" in claims: + claim_audiences = claims["aud"] + if isinstance(claim_audiences, str): + claim_audiences = {claim_audiences} + if isinstance(audience, str): + audience = {audience} + if len(set(audience).intersection(claim_audiences)) == 0: + raise ValidationError("Invalid audience.") + class JWTAuthentication(SimpleJWTAuthentication): def authenticate(self, request): return super().authenticate(request) diff --git a/respa/providers/turku_oidc/oidc.py b/respa/providers/turku_oidc/oidc.py index c20a9898c..355082067 100644 --- a/respa/providers/turku_oidc/oidc.py +++ b/respa/providers/turku_oidc/oidc.py @@ -1,11 +1,12 @@ from helusers.oidc import resolve_user, ApiTokenAuthentication as HelusersApiTokenAuthentication from helusers.authz import UserAuthorization from helusers.user_utils import _try_create_or_update -from rest_framework.exceptions import AuthenticationFailed +from rest_framework.exceptions import AuthenticationFailed, ValidationError from django.utils.translation import gettext as _ from django.conf import settings from rest_framework import exceptions from django.db import transaction, IntegrityError +from .jwt import JWT class ApiTokenAuthentication(HelusersApiTokenAuthentication): def authenticate(self, request): @@ -30,6 +31,27 @@ def authenticate(self, request): user.amr = payload['amr'] return (user, auth) + def decode_jwt(self, jwt_value): + jwt = JWT(jwt_value, settings=self.settings) + + try: + jwt.validate_issuer() + except ValidationError as e: + raise AuthenticationFailed(str(e)) from e + + keys = self.get_oidc_config(jwt.issuer).keys() + try: + jwt.validate(keys, self.settings.AUDIENCE) + jwt.validate_api_scope() + jwt.validate_session() + self.validate_claims(jwt.claims) + except ValidationError as e: + raise AuthenticationFailed(str(e)) from e + except Exception as e: + raise AuthenticationFailed("JWT verification failed.") + + return jwt.claims + def get_or_create_user(payload, oidc=False): user_id = payload.get('sub') if not user_id: diff --git a/respa/settings.py b/respa/settings.py index b9c1f062d..f6a48e290 100644 --- a/respa/settings.py +++ b/respa/settings.py @@ -182,7 +182,6 @@ # Application definition INSTALLED_APPS = [ - 'helusers', 'resources', 'modeltranslation', 'grappelli', @@ -237,14 +236,17 @@ 'sanitized_dump', 'drf_yasg', ] + if env('HELUSERS_PROVIDER') == 'respa.providers.turku_oidc': - INSTALLED_APPS.append( - 'respa.providers.turku_oidc.admin_site.AdminConfig' - ) + INSTALLED_APPS.extend([ + 'respa.providers.turku_oidc.admin_site.AdminConfig', + 'helusers.apps.HelusersConfig' + ]) else: - INSTALLED_APPS.append( - 'helusers.apps.HelusersAdminConfig' - ) + INSTALLED_APPS.extend([ + "helusers.apps.HelusersConfig", + "helusers.apps.HelusersAdminConfig", + ]) if env('SENTRY_DSN'): RAVEN_CONFIG = { @@ -322,6 +324,8 @@ USE_TZ = True +USE_DEPRECATED_PYTZ = True + LOCALE_PATHS = ( os.path.join(BASE_DIR, 'locale'), ) diff --git a/respa_admin/auth.py b/respa_admin/auth.py index dde4015d1..29a88f712 100644 --- a/respa_admin/auth.py +++ b/respa_admin/auth.py @@ -1,4 +1,4 @@ -from django.conf.urls import re_path +from django.urls import re_path from django.contrib.auth.decorators import user_passes_test from .permissions import can_login_to_respa_admin diff --git a/respa_admin/urls.py b/respa_admin/urls.py index ce0a2a4a4..4937dcf5d 100644 --- a/respa_admin/urls.py +++ b/respa_admin/urls.py @@ -1,6 +1,6 @@ from django.conf import settings -from django.conf.urls import re_path as unauthorized_url -from django.urls import include +from django.conf.urls import include +from django.urls import re_path as unauthorized_url from . import views from .auth import admin_url as url diff --git a/respa_admin/views/reports.py b/respa_admin/views/reports.py index 59a1392d7..0ec1034d5 100644 --- a/respa_admin/views/reports.py +++ b/respa_admin/views/reports.py @@ -1,4 +1,4 @@ -from django.utils.translation import override as translation_override, ugettext as _ +from django.utils.translation import override as translation_override, gettext as _ from django.views.generic.base import TemplateView from resources.models import Unit, UnitAuthorization, Resource, Day from resources.auth import is_general_admin diff --git a/respa_exchange/tests/conftest.py b/respa_exchange/tests/conftest.py index 4fdb287ab..cbcff33e3 100644 --- a/respa_exchange/tests/conftest.py +++ b/respa_exchange/tests/conftest.py @@ -33,7 +33,7 @@ def exchange(): An Exchange configuration for testing """ return ExchangeConfiguration.objects.create( - url="https://127.0.0.1:8000/%s.asmx" % get_random_string(), - password=get_random_string(), - username=get_random_string(), + url="https://127.0.0.1:8000/%s.asmx" % get_random_string(14), + password=get_random_string(16), + username=get_random_string(6), ) diff --git a/respa_exchange/tests/session.py b/respa_exchange/tests/session.py index 4228b3904..d2941ad5b 100644 --- a/respa_exchange/tests/session.py +++ b/respa_exchange/tests/session.py @@ -80,7 +80,7 @@ def wire(cls, settings, handler_delegate): :param settings: Settings monkeypatch object """ - id = "get_wired_soap_seller_%s" % get_random_string() + id = "get_wired_soap_seller_%s" % get_random_string(8) def getter(**kwargs): return cls(handler_delegate) diff --git a/respa_exchange/tests/test_download.py b/respa_exchange/tests/test_download.py index 77edaa782..9ee2ce683 100644 --- a/respa_exchange/tests/test_download.py +++ b/respa_exchange/tests/test_download.py @@ -13,10 +13,10 @@ def _generate_item_dict(): - item_id = ItemID(get_random_string(), get_random_string()) + item_id = ItemID(get_random_string(8), get_random_string(8)) item_dict = { 'id': item_id, - 'subject': get_random_string(), + 'subject': get_random_string(8), 'start': now(), 'end': now() + timedelta(hours=1), 'organizer_name': 'Bob Dummy' @@ -30,8 +30,8 @@ def test_download( settings, space_resource, exchange, sync_enabled ): - email = "%s@example.com" % get_random_string() - other_email = "%s@example.com" % get_random_string() + email = "%s@example.com" % get_random_string(8) + other_email = "%s@example.com" % get_random_string(8) item_dict = _generate_item_dict() other_item_dict = _generate_item_dict() item_id = item_dict["id"] diff --git a/respa_exchange/tests/test_listener.py b/respa_exchange/tests/test_listener.py index c048b1439..20e434a3a 100644 --- a/respa_exchange/tests/test_listener.py +++ b/respa_exchange/tests/test_listener.py @@ -39,12 +39,12 @@ def _generate_event(self, type): return getattr(T, type)( T.TimeStamp(now().isoformat()), T.ItemId( - Id=get_random_string(), - ChangeKey=get_random_string(), + Id=get_random_string(8), + ChangeKey=get_random_string(8), ), T.ParentFolderId( - Id=get_random_string(), - ChangeKey=get_random_string(), + Id=get_random_string(8), + ChangeKey=get_random_string(8), ), ) @@ -86,7 +86,7 @@ def handle_unsubscribe(self, request): @pytest.mark.django_db def test_listener(settings, space_resource, exchange, monkeypatch): - email = '%s@example.com' % get_random_string() + email = '%s@example.com' % get_random_string(8) ex_resource = ExchangeResource.objects.create( resource=space_resource, principal_email=email, diff --git a/respa_exchange/tests/test_upload.py b/respa_exchange/tests/test_upload.py index a23a021f8..720df7f4e 100644 --- a/respa_exchange/tests/test_upload.py +++ b/respa_exchange/tests/test_upload.py @@ -22,9 +22,9 @@ def test_crud_reservation( ): settings.RESPA_EXCHANGE_ENABLED = master_switch delegate = CRUDItemHandlers( - item_id=get_random_string(), - change_key=get_random_string(), - update_change_key=get_random_string(), + item_id=get_random_string(8), + change_key=get_random_string(8), + update_change_key=get_random_string(8), ) SoapSeller.wire(settings, delegate) if is_exchange_resource: diff --git a/respa_o365/urls.py b/respa_o365/urls.py index 35d5de0b9..e7054bbae 100644 --- a/respa_o365/urls.py +++ b/respa_o365/urls.py @@ -1,4 +1,5 @@ -from django.urls import include, path +from django.urls import path +from django.conf.urls import include from rest_framework import routers from respa_o365 import views from respa_o365.calendar_login import LoginCallBackView, LoginStartView From 51f2c4d977112205bf8be4a43abe8965f8f51c76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 11:34:58 +0000 Subject: [PATCH 06/12] Bump django from 4.2.13 to 4.2.15 Bumps [django](https://github.com/django/django) from 4.2.13 to 4.2.15. - [Commits](https://github.com/django/django/compare/4.2.13...4.2.15) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 568b4b8ae..5697a3c07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -97,7 +97,7 @@ deprecation==2.1.0 # via django-helusers dill==0.3.6 # via pylint -django==4.2.13 +django==4.2.15 # via # -r requirements.in # django-admin-json-editor From 7e53f182ea7adc60a9755eee07cc868c32972908 Mon Sep 17 00:00:00 2001 From: ezkat <50319957+ezkat@users.noreply.github.com> Date: Wed, 14 Aug 2024 12:07:20 +0300 Subject: [PATCH 07/12] Add missing alterfield migration files (#376) --- .../migrations/0004_missing_migrations.py | 56 +++++ .../migrations/0004_missing_migrations.py | 21 ++ .../migrations/0002_missing_migrations.py | 36 +++ .../migrations/0020_missing_migrations.py | 26 ++ .../migrations/0004_missing_migrations.py | 49 ++++ .../migrations/0157_missing_migrations.py | 224 ++++++++++++++++++ 6 files changed, 412 insertions(+) create mode 100644 accessibility/migrations/0004_missing_migrations.py create mode 100644 comments/migrations/0004_missing_migrations.py create mode 100644 maintenance/migrations/0002_missing_migrations.py create mode 100644 notifications/migrations/0020_missing_migrations.py create mode 100644 qualitytool/migrations/0004_missing_migrations.py create mode 100644 resources/migrations/0157_missing_migrations.py diff --git a/accessibility/migrations/0004_missing_migrations.py b/accessibility/migrations/0004_missing_migrations.py new file mode 100644 index 000000000..bf33986e0 --- /dev/null +++ b/accessibility/migrations/0004_missing_migrations.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.13 on 2024-08-14 06:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('accessibility', '0003_django_3_update'), + ] + + operations = [ + migrations.AlterField( + model_name='serviceentrance', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='serviceentrance', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='servicerequirement', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='servicerequirement', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='servicesentence', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='servicesentence', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='serviceshortage', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='serviceshortage', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + ] diff --git a/comments/migrations/0004_missing_migrations.py b/comments/migrations/0004_missing_migrations.py new file mode 100644 index 000000000..f71bb7df0 --- /dev/null +++ b/comments/migrations/0004_missing_migrations.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.13 on 2024-08-14 06:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('comments', '0003_refactor_content_type_choices'), + ] + + operations = [ + migrations.AlterField( + model_name='comment', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + ] diff --git a/maintenance/migrations/0002_missing_migrations.py b/maintenance/migrations/0002_missing_migrations.py new file mode 100644 index 000000000..6d49b1530 --- /dev/null +++ b/maintenance/migrations/0002_missing_migrations.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.13 on 2024-08-14 06:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('maintenance', '0001_create_maintenance_app'), + ] + + operations = [ + migrations.AlterField( + model_name='maintenancemessage', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='maintenancemessage', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='maintenancemode', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='maintenancemode', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + ] diff --git a/notifications/migrations/0020_missing_migrations.py b/notifications/migrations/0020_missing_migrations.py new file mode 100644 index 000000000..a4dbfd7e3 --- /dev/null +++ b/notifications/migrations/0020_missing_migrations.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.13 on 2024-08-14 06:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications', '0019_add_type_reservation_reminder'), + ] + + operations = [ + migrations.AlterField( + model_name='notificationtemplategroup', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='notificationtemplategroup', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + ] diff --git a/qualitytool/migrations/0004_missing_migrations.py b/qualitytool/migrations/0004_missing_migrations.py new file mode 100644 index 000000000..7d7ea7b62 --- /dev/null +++ b/qualitytool/migrations/0004_missing_migrations.py @@ -0,0 +1,49 @@ +# Generated by Django 4.2.13 on 2024-08-14 06:52 + +from django.db import migrations, models +import django_jsonform.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0156_overnight_reservation_fields'), + ('qualitytool', '0003_add_qualitytool_emails_field'), + ] + + operations = [ + migrations.AlterModelOptions( + name='resourcequalitytool', + options={'verbose_name': 'resurssi laatutyökalu', 'verbose_name_plural': 'resurssi laatutyökalut'}, + ), + migrations.AlterField( + model_name='resourcequalitytool', + name='emails', + field=django_jsonform.models.fields.ArrayField(base_field=models.EmailField(max_length=255, verbose_name='Sähköpostiosoite'), blank=True, null=True, size=None), + ), + migrations.AlterField( + model_name='resourcequalitytool', + name='name', + field=models.CharField(max_length=255, verbose_name='Nimi'), + ), + migrations.AlterField( + model_name='resourcequalitytool', + name='name_en', + field=models.CharField(max_length=255, null=True, verbose_name='Nimi'), + ), + migrations.AlterField( + model_name='resourcequalitytool', + name='name_fi', + field=models.CharField(max_length=255, null=True, verbose_name='Nimi'), + ), + migrations.AlterField( + model_name='resourcequalitytool', + name='name_sv', + field=models.CharField(max_length=255, null=True, verbose_name='Nimi'), + ), + migrations.AlterField( + model_name='resourcequalitytool', + name='resources', + field=models.ManyToManyField(related_name='qualitytool', to='resources.resource', verbose_name='Resurssit'), + ), + ] diff --git a/resources/migrations/0157_missing_migrations.py b/resources/migrations/0157_missing_migrations.py new file mode 100644 index 000000000..1af965e6e --- /dev/null +++ b/resources/migrations/0157_missing_migrations.py @@ -0,0 +1,224 @@ +# Generated by Django 4.2.13 on 2024-08-14 06:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import resources.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('taggit', '0005_auto_20220424_2025'), + ('resources', '0156_overnight_reservation_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='cleanresourceid', + name='content_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_tagged_items', to='contenttypes.contenttype', verbose_name='content type'), + ), + migrations.AlterField( + model_name='cleanresourceid', + name='tag', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='taggit.tag'), + ), + migrations.AlterField( + model_name='equipment', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='equipment', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='equipmentalias', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='equipmentalias', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='equipmentcategory', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='equipmentcategory', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='purpose', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='purpose', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='reservation', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='reservation', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='reservationbulk', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='reservationbulk', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='reservationhomemunicipalityset', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='reservationhomemunicipalityset', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='reservationmetadataset', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='reservationmetadataset', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='resource', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='resource', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='resource', + name='resource_staff_emails', + field=resources.fields.MultiEmailField(blank=True, null=True, verbose_name='E-mail addresses for client correspondence'), + ), + migrations.AlterField( + model_name='resourceequipment', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='resourceequipment', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='resourcegroup', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='resourcegroup', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='resourceimage', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='resourceimage', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='resourcetype', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='resourcetype', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='resourceuniversalfield', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='resourceuniversalfield', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='resourceuniversalformoption', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='resourceuniversalformoption', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='termsofuse', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='termsofuse', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='unit', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='unit', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='unitgroup', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='unitgroup', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + migrations.AlterField( + model_name='universalformfieldtype', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + migrations.AlterField( + model_name='universalformfieldtype', + name='modified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL, verbose_name='Modified by'), + ), + ] From ca331befddc72a511e2d662d94964f08017bed88 Mon Sep 17 00:00:00 2001 From: ezkat <50319957+ezkat@users.noreply.github.com> Date: Wed, 14 Aug 2024 12:07:32 +0300 Subject: [PATCH 08/12] FIx RA oidc login (#377) --- respa/providers/turku_oidc/tunnistamo_oidc.py | 2 +- respa/providers/turku_oidc/views.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/respa/providers/turku_oidc/tunnistamo_oidc.py b/respa/providers/turku_oidc/tunnistamo_oidc.py index c01683426..6b4415380 100644 --- a/respa/providers/turku_oidc/tunnistamo_oidc.py +++ b/respa/providers/turku_oidc/tunnistamo_oidc.py @@ -5,7 +5,7 @@ class TunnistamoOIDCAuth(HelusersTunnistamoOIDCAuth): name = 'tunnistamo' - OIDC_ENDPOINT = '%s/openid' % getattr(settings, 'OIDC_API_TOKEN_AUTH')['ISSUER'] + OIDC_ENDPOINT = getattr(settings, 'OIDC_API_TOKEN_AUTH')['ISSUER'] END_SESSION_URL = '' def __init__(self, *args, **kwargs): diff --git a/respa/providers/turku_oidc/views.py b/respa/providers/turku_oidc/views.py index 6668c7dc5..91da13876 100644 --- a/respa/providers/turku_oidc/views.py +++ b/respa/providers/turku_oidc/views.py @@ -9,9 +9,9 @@ class OIDCOAuth2Adapter(OAuth2Adapter): provider_id = TurkuOIDCProvider.id - access_token_url = '%s/openid/token/' % getattr(settings, 'OIDC_API_TOKEN_AUTH')['ISSUER'] - authorize_url = '%s/openid/authorize/' % getattr(settings, 'OIDC_API_TOKEN_AUTH')['ISSUER'] - profile_url = '%s/openid/userinfo/' % getattr(settings, 'OIDC_API_TOKEN_AUTH')['ISSUER'] + access_token_url = '%s/token/' % getattr(settings, 'OIDC_API_TOKEN_AUTH')['ISSUER'] + authorize_url = '%s/authorize/' % getattr(settings, 'OIDC_API_TOKEN_AUTH')['ISSUER'] + profile_url = '%s/userinfo/' % getattr(settings, 'OIDC_API_TOKEN_AUTH')['ISSUER'] def complete_login(self, request, app, token, **kwargs): headers = {'Authorization': 'Bearer {0}'.format(token.token)} From c2d616ab6a808b1d6c41c9b50f27450d683fea62 Mon Sep 17 00:00:00 2001 From: SanttuA Date: Thu, 15 Aug 2024 12:04:54 +0300 Subject: [PATCH 09/12] New vat and decimal support (#378) Changes: - added new product tax percentage option 25.50 - updated tax handling to support decimals - updated Turku payment provider to send tax with decimals instead of int --- .../migrations/0015_new_tax_percentage.py | 19 +++++++++++++ payments/models.py | 1 + .../providers/turku_payment_provider_v3.py | 4 +-- .../productcustomergroup/change_form.html | 10 +++---- .../templates/admin/products/change_form.html | 27 +++++++++---------- .../admin/time_slot_prices/change_form.html | 18 ++++++------- 6 files changed, 48 insertions(+), 31 deletions(-) create mode 100644 payments/migrations/0015_new_tax_percentage.py diff --git a/payments/migrations/0015_new_tax_percentage.py b/payments/migrations/0015_new_tax_percentage.py new file mode 100644 index 000000000..064ecb57d --- /dev/null +++ b/payments/migrations/0015_new_tax_percentage.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.15 on 2024-08-14 10:01 + +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0014_customer_group_login_methods'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='tax_percentage', + field=models.DecimalField(choices=[(Decimal('0.00'), '0.00'), (Decimal('10.00'), '10.00'), (Decimal('14.00'), '14.00'), (Decimal('24.00'), '24.00'), (Decimal('25.50'), '25.50')], decimal_places=2, default=Decimal('24.00'), max_digits=5, verbose_name='tax percentage'), + ), + ] diff --git a/payments/models.py b/payments/models.py index 01b7850ad..a2f1a81e1 100644 --- a/payments/models.py +++ b/payments/models.py @@ -39,6 +39,7 @@ '10.00', '14.00', '24.00', + '25.50', )] DEFAULT_TAX_PERCENTAGE = Decimal('24.00') diff --git a/payments/providers/turku_payment_provider_v3.py b/payments/providers/turku_payment_provider_v3.py index 7d41cb84f..515e4c9c7 100644 --- a/payments/providers/turku_payment_provider_v3.py +++ b/payments/providers/turku_payment_provider_v3.py @@ -222,12 +222,10 @@ def payload_add_products(self, payload, order): order_line.order._in_memory_customer_group_id = order.customer_group.id product = order_line.product - int_tax = int(product.tax_percentage) - assert int_tax == product.tax_percentage product_data = { 'unitPrice': self.convert_price_to_cents(round_price(order_line.get_unit_price())), 'units': str(order_line.quantity), - 'vatPercentage': str(int_tax), + 'vatPercentage': str(product.tax_percentage), 'productCode': product.sku, 'description': product.name, } diff --git a/payments/templates/admin/productcustomergroup/change_form.html b/payments/templates/admin/productcustomergroup/change_form.html index 8f9eb796a..d184ab7f5 100644 --- a/payments/templates/admin/productcustomergroup/change_form.html +++ b/payments/templates/admin/productcustomergroup/change_form.html @@ -5,7 +5,7 @@ (function ($) { function changeTaxfreeValue(edit = false) { // value of the read-only field that contains tax % when editing a pcg. - const percValue = parseInt($("div[class='grp-readonly']").text()); + const percValue = parseFloat($("div[class='grp-readonly']").text()); // stop if percValue doesn't exist because this only works when editing a pcg. if (!percValue) { return; } // current price_tax_free value @@ -16,12 +16,12 @@ // unrounded taxfree price value. const sum = 100 * priceValue / (100 + percValue); // set price_tax_free input value to a rounded version of sum - $("#id_price_tax_free").val(Math.round(sum * 100) / 100); + $("#id_price_tax_free").val((Math.round((sum + Number.EPSILON) * 100) / 100).toFixed(2)); } }; function changePriceValue(edit = false) { // value of the read-only field that contains tax % when editing a pcg. - const percValue = parseInt($("div[class='grp-readonly']").text()); + const percValue = parseFloat($("div[class='grp-readonly']").text()); // stop if percValue doesn't exist because this only works when editing a pcg. if (!percValue) { return; } // current price_tax_free value @@ -32,7 +32,7 @@ // unrounded taxfree price value. const sum = (taxValue * percValue / 100) + taxValue; // set price_tax_free input value to a rounded version of sum - $("#id_price").val(Math.round(sum * 100) / 100); + $("#id_price").val((Math.round((sum + Number.EPSILON) * 100) / 100).toFixed(2)); } }; $(document).ready(function () { @@ -49,4 +49,4 @@ })(grp.jQuery); {{ block.super }} -{% endblock%} \ No newline at end of file +{% endblock%} diff --git a/payments/templates/admin/products/change_form.html b/payments/templates/admin/products/change_form.html index 4ee159122..294fb32e1 100644 --- a/payments/templates/admin/products/change_form.html +++ b/payments/templates/admin/products/change_form.html @@ -12,14 +12,13 @@ if (taxFreeValue === 0 || edit) { // value of the id_price input const priceValue = parseFloat($("#id_price").val()); - // int value of the id_tax_percentage input - const percValue = parseInt($("#id_tax_percentage").val()); + // float value of the id_tax_percentage input + const percValue = parseFloat($("#id_tax_percentage").val()); // unrounded taxfree price value. // example with 15e and 24%, 100 * 15 / (100+24) = 12.096774193548388 const sum = 100 * priceValue / (100 + percValue); // set price_tax_free input value to a rounded version of sum - // Math.round(12.096774193548388 * 100) / 100 = 12.1 - $("#id_price_tax_free").val(Math.round(sum * 100) / 100); + $("#id_price_tax_free").val((Math.round((sum + Number.EPSILON) * 100) / 100).toFixed(2)); } }; function changeProductPrice(edit = false) { @@ -28,13 +27,13 @@ if (taxFreeValue || edit) { // value of the id_price_tax_free input const taxFreeValue = parseFloat($("#id_price_tax_free").val()); - // int value of the id_tax_percentage input - const percValue = parseInt($("#id_tax_percentage").val()); + // float value of the id_tax_percentage input + const percValue = parseFloat($("#id_tax_percentage").val()); // unrounded price value. // example with VAT-free price 15e and 24%, (15*24 / 100) + 15 = 18.6 const sum = (taxFreeValue * percValue / 100) + taxFreeValue; // set price input value to a rounded version of sum - $("#id_price").val(Math.round(sum * 100) / 100); + $("#id_price").val((Math.round((sum + Number.EPSILON) * 100) / 100).toFixed(2)); } }; function changeInlineTaxfree(price = 0, root = '', id = '') { @@ -46,12 +45,12 @@ if (!taxfreeIndex && taxfreeIndex !== 0) { return; } // find correct taxfree input that corresponds to this input. const vatFreeInput = $(root).find(".grp-table").find("div[id]").find("div[class='grp-tr']").find(".grp-td.price_tax_free").find("input")[taxfreeIndex]; - // int value of the id_tax_percentage input - const percValue = parseInt($("#id_tax_percentage").val()); + // float value of the id_tax_percentage input + const percValue = parseFloat($("#id_tax_percentage").val()); // unrounded taxfree price value. const sum = 100 * price / (100 + percValue); // set rounded taxfree price to the correct input. - $(vatFreeInput).val(Math.round(sum * 100) / 100); + $(vatFreeInput).val((Math.round((sum + Number.EPSILON) * 100) / 100).toFixed(2)); }; function changeInlinePrice(taxFreePrice = 0, root = '', id = '') { // for some reason one of the inputs is in a hidden div, immediately return if this is it. @@ -62,12 +61,12 @@ if (!priceIndex && priceIndex !== 0) { return; } // find correct price input that corresponds to this input. const priceInput = $(root).find(".grp-table").find("div[id]").find("div[class='grp-tr']").find(".grp-td.price").find("input")[priceIndex]; - // int value of the id_tax_percentage input - const percValue = parseInt($("#id_tax_percentage").val()); + // float value of the id_tax_percentage input + const percValue = parseFloat($("#id_tax_percentage").val()); // unrounded price value. const sum = (taxFreePrice * percValue / 100) + taxFreePrice; // set rounded price to the correct input. - $(priceInput).val(Math.round(sum * 100) / 100); + $(priceInput).val((Math.round((sum + Number.EPSILON) * 100) / 100).toFixed(2)); }; $(document).ready(function () { changeProductTaxfree(); @@ -113,4 +112,4 @@ })(grp.jQuery); {{ block.super }} -{% endblock%} \ No newline at end of file +{% endblock%} diff --git a/payments/templates/admin/time_slot_prices/change_form.html b/payments/templates/admin/time_slot_prices/change_form.html index 231bb774c..dfd076858 100644 --- a/payments/templates/admin/time_slot_prices/change_form.html +++ b/payments/templates/admin/time_slot_prices/change_form.html @@ -5,7 +5,7 @@ (function ($) { function changeTaxfreeValue(edit = false) { // value of the read-only field that contains tax % when editing a pcg. - const percValue = parseInt($(".product_tax_percentage").find("div[class='grp-readonly']").text()); + const percValue = parseFloat($(".product_tax_percentage").find("div[class='grp-readonly']").text()); // stop if perc doesn't exist because this only works when editing a pcg. if (!percValue) { return; } @@ -17,12 +17,12 @@ // unrounded taxfree price value. const sum = 100 * priceValue / (100 + percValue); // set price_tax_free input value to a rounded version of sum - $("#id_price_tax_free").val(Math.round(sum * 100) / 100); + $("#id_price_tax_free").val((Math.round((sum + Number.EPSILON) * 100) / 100).toFixed(2)); } }; function changePrice(edit = false) { // value of the read-only field that contains tax % when editing a pcg. - const percValue = parseInt($(".product_tax_percentage").find("div[class='grp-readonly']").text()); + const percValue = parseFloat($(".product_tax_percentage").find("div[class='grp-readonly']").text()); // stop if perc doesn't exist because this only works when editing a pcg. if (!percValue) { return; } // current price value @@ -34,7 +34,7 @@ // example with VAT-free price 15e and 24%, (15*24 / 100) + 15 = 18.6 const sum = (taxFreeValue * percValue / 100) + taxFreeValue; // set price input value to a rounded version of sum - $("#id_price").val(Math.round(sum * 100) / 100); + $("#id_price").val((Math.round((sum + Number.EPSILON) * 100) / 100).toFixed(2)); } }; $(document).ready(function () { @@ -52,7 +52,7 @@ // add event listener to all customergroup timeslot price input elements. $(cgTimeslotPriceElements).on("change", function (event) { // current tax % - const percValue = parseInt($(".product_tax_percentage").find("div[class='grp-readonly']").text()); + const percValue = parseFloat($(".product_tax_percentage").find("div[class='grp-readonly']").text()); if (!percValue) { return; } // cg time slot price value const priceValue = parseFloat($(this).val()); @@ -65,10 +65,10 @@ // calculate new unrounded taxfree price value. const sum = 100 * priceValue / (100 + percValue); // set rounded tax free value to the correct taxfree input. - $(vatFreeInput).val(Math.round(sum * 100) / 100); + $(vatFreeInput).val((Math.round((sum + Number.EPSILON) * 100) / 100).toFixed(2)); }); $(cgTimeslotVATfreeElements).on("change", function (event) { - const percValue = parseInt($(".product_tax_percentage").find("div[class='grp-readonly']").text()); + const percValue = parseFloat($(".product_tax_percentage").find("div[class='grp-readonly']").text()); if (!percValue) { return; } const taxFreeValue = parseFloat($(this).val()); const id = event.target.id; @@ -78,10 +78,10 @@ const priceInput = $("#customer_group_time_slot_prices-group").find("div[id]").find("div[class='grp-tr']").find(".grp-td.price").find("input")[cgIndex]; const sum = (taxFreeValue * percValue / 100) + taxFreeValue; // set rounded tax free value to the correct taxfree input. - $(priceInput).val(Math.round(sum * 100) / 100); + $(priceInput).val((Math.round((sum + Number.EPSILON) * 100) / 100).toFixed(2)); }); }); })(grp.jQuery); {{ block.super }} -{% endblock %} \ No newline at end of file +{% endblock %} From 4b5f32ec0a410a13a2863c9c1c09949d2f564001 Mon Sep 17 00:00:00 2001 From: ezkat <50319957+ezkat@users.noreply.github.com> Date: Fri, 23 Aug 2024 13:53:20 +0300 Subject: [PATCH 10/12] RA: Users page login method icons (#379) * Save user login method --- locale/en/LC_MESSAGES/django.po | 35 ++++++++++--- locale/fi/LC_MESSAGES/django.po | 30 +++++++++-- locale/sv/LC_MESSAGES/django.po | 24 +++++++++ payments/api/reservation.py | 2 +- payments/tests/test_reservation_api.py | 13 +++-- resources/tests/conftest.py | 18 +++++-- respa/providers/turku_oidc/oidc.py | 10 +++- respa/templates/admin/base_site.html | 23 +++++++++ respa_admin/forms.py | 29 +++++++++++ .../resources/_unit_user_list.html | 16 ++++-- .../respa_admin/resources/_user_list.html | 9 +++- .../resources/form/_user_info.html | 2 + respa_admin/templatetags/templatetags.py | 19 +++++++ respa_admin/widgets.py | 37 +++++++++++++- users/admin.py | 51 ++++++++++++++++++- users/apps.py | 7 +++ .../0018_create_login_method_model.py | 36 +++++++++++++ users/models.py | 27 ++++++++-- 18 files changed, 355 insertions(+), 33 deletions(-) create mode 100644 users/apps.py create mode 100644 users/migrations/0018_create_login_method_model.py diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index e92e38ac5..5ac3749e0 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -2374,14 +2374,6 @@ msgstr "" msgid "Change password: %s" msgstr "" -#: users/models.py:11 -msgid "First name" -msgstr "" - -#: users/models.py:12 -msgid "Last name" -msgstr "" - #: users/models.py:14 msgid "Birthdate" msgstr "" @@ -2913,3 +2905,30 @@ msgstr "" msgid "The reservation deadline is the start time. For example, if the start time is 6:00 PM, same-day bookings must be made before 6:00 PM." msgstr "" + +msgid "Users" +msgstr "" + +msgid "Login method" +msgstr "" + +msgid "Login methods" +msgstr "" + +msgid "Icon" +msgstr "" + +msgid "Strong authentication" +msgstr "" + +msgid "First name" +msgstr "" + +msgid "Last name" +msgstr "" + +msgid "Unknown login method" +msgstr "" + +msgid "Upload SVG file or paste SVG code." +msgstr "" diff --git a/locale/fi/LC_MESSAGES/django.po b/locale/fi/LC_MESSAGES/django.po index 9f2f3a241..3f0261e19 100644 --- a/locale/fi/LC_MESSAGES/django.po +++ b/locale/fi/LC_MESSAGES/django.po @@ -1725,9 +1725,6 @@ msgstr "Tarvitsee avustusta" msgid "Require workstation" msgstr "Tarvitsee työaseman" -msgid "Users" -msgstr "Käyttäjät" - msgid "Turku Users" msgstr "Turku käyttäjät" @@ -2298,3 +2295,30 @@ msgstr "Varauksen alkamisajan ja päättymisajan on vastattava annettuja yön yl msgid "The reservation deadline is the start time. For example, if the start time is 6:00 PM, same-day bookings must be made before 6:00 PM." msgstr "Varausten takaraja on aloitusaika. Esim. jos aloitusaika on klo 18.00, saman päivän varaus on tehtävä ennen klo 18.00." + +msgid "Users" +msgstr "Käyttäjät" + +msgid "Login method" +msgstr "Kirjautumistapa" + +msgid "Login methods" +msgstr "Kirjautumistavat" + +msgid "Icon" +msgstr "Kuvake" + +msgid "Strong authentication" +msgstr "Vahva tunnistautuminen" + +msgid "First name" +msgstr "Etunimi" + +msgid "Last name" +msgstr "Sukunimi" + +msgid "Unknown login method" +msgstr "Tuntematon kirjautumistapa" + +msgid "Upload SVG file or paste SVG code." +msgstr "Lataa SVG-tiedosto tai liitä SVG-koodi." diff --git a/locale/sv/LC_MESSAGES/django.po b/locale/sv/LC_MESSAGES/django.po index 71e2e87d4..93601b91e 100644 --- a/locale/sv/LC_MESSAGES/django.po +++ b/locale/sv/LC_MESSAGES/django.po @@ -2239,3 +2239,27 @@ msgstr "Reservationsstart och reservationsslut måste överensstämma med de ang msgid "The reservation deadline is the start time. For example, if the start time is 6:00 PM, same-day bookings must be made before 6:00 PM." msgstr "Bokningsfristen är starttiden. Till exempel, om starttiden är 18.00, måste bokningar för samma dag göras före 18.00." + +msgid "Users" +msgstr "Användare" + +msgid "Login method" +msgstr "Inloggningsmetod" + +msgid "Login methods" +msgstr "Inloggningsmetoder" + +msgid "Icon" +msgstr "Ikon" + +msgid "First name" +msgstr "Förnamn" + +msgid "Last name" +msgstr "Efternamn" + +msgid "Unknown login method" +msgstr "Okänd inloggningsmetod" + +msgid "Upload SVG file or paste SVG code." +msgstr "Ladda upp SVG-fil eller klistra in SVG-kod." diff --git a/payments/api/reservation.py b/payments/api/reservation.py index 6304604a1..855113dab 100644 --- a/payments/api/reservation.py +++ b/payments/api/reservation.py @@ -162,7 +162,7 @@ def validate(self, attrs): customer_group = attrs.get('customer_group', None) resource = self.context.get('resource') - login_method = getattr(request.user, 'amr', None) + login_method = request.user.amr.id if request.user.amr else None if login_method: for order_line in attrs.get('order_lines', []): product = order_line.get('product') diff --git a/payments/tests/test_reservation_api.py b/payments/tests/test_reservation_api.py index 49ddea2d3..f17919c4d 100644 --- a/payments/tests/test_reservation_api.py +++ b/payments/tests/test_reservation_api.py @@ -18,6 +18,7 @@ from resources.models.utils import generate_id, get_translated_fields from resources.tests.conftest import resource_in_unit, user_api_client # noqa from resources.tests.test_reservation_api import day_and_period # noqa +from users.models import LoginMethod from ..factories import ProductFactory from ..models import CustomerGroup, Order, OrderCustomerGroupData, OrderLine, Product, ProductCustomerGroup @@ -851,6 +852,7 @@ def test_regular_user_manual_confirmation_reservation_with_product( def test_reservation_raises_validation_error_when_order_cg_is_not_allowed_for_a_product( api_client, user, resource_in_unit, product, product_with_cg_login_restrictions, + weak_auth_login_method, customer_group_login_method_internals, customer_group_with_login_method_restrictions): """ Tests that a validation error is raised when reservation order product has customer group @@ -867,7 +869,7 @@ def test_reservation_raises_validation_error_when_order_cg_is_not_allowed_for_a_ 'reserver_name': 'Test Tester', 'order': order_data }) - user.amr = 'test-wont-work' + user.amr = weak_auth_login_method user.save() api_client.force_authenticate(user=user) response = api_client.post(LIST_URL, data=reservation_data) @@ -877,6 +879,7 @@ def test_reservation_raises_validation_error_when_order_cg_is_not_allowed_for_a_ def test_reservation_does_not_raise_validation_error_when_order_products_do_not_contain_login_method_restrictions( api_client, user, resource_in_unit, product, product_with_cg_login_restrictions, + weak_auth_login_method, customer_group_login_method_internals, customer_group_with_login_method_restrictions): """ Tests that validation errors are not raised when reservation order does not contain products with @@ -892,7 +895,7 @@ def test_reservation_does_not_raise_validation_error_when_order_products_do_not_ 'reserver_name': 'Test Tester', 'order': order_data }) - user.amr = 'test-some-other-amr' + user.amr = weak_auth_login_method user.save() api_client.force_authenticate(user=user) response = api_client.post(LIST_URL, data=reservation_data) @@ -916,7 +919,7 @@ def test_reservation_does_not_raise_validation_error_when_order_has_restricted_c 'reserver_name': 'Test Tester', 'order': order_data }) - user.amr = customer_group_login_method_internals.login_method_id + user.amr, _ = LoginMethod.objects.get_or_create(id=customer_group_login_method_internals.login_method_id, name='Internal AMR') user.save() api_client.force_authenticate(user=user) response = api_client.post(LIST_URL, data=reservation_data) @@ -924,7 +927,7 @@ def test_reservation_does_not_raise_validation_error_when_order_has_restricted_c def test_reservation_does_not_raise_validation_error_when_prods_have_only_restricted_cgs( - api_client, user, resource_in_unit, product_with_cg_login_restrictions): + api_client, user, resource_in_unit, product_with_cg_login_restrictions, weak_auth_login_method): """ Tests that validation errors are not raised when reserved resource has only restricted products for the user @@ -939,7 +942,7 @@ def test_reservation_does_not_raise_validation_error_when_prods_have_only_restri 'reserver_name': 'Test Tester', 'order': order_data }) - user.amr = 'test-amr-123' + user.amr = weak_auth_login_method user.save() api_client.force_authenticate(user=user) response = api_client.post(LIST_URL, data=reservation_data) diff --git a/resources/tests/conftest.py b/resources/tests/conftest.py index d79a3cfd0..645af0067 100644 --- a/resources/tests/conftest.py +++ b/resources/tests/conftest.py @@ -13,6 +13,7 @@ from resources.models import AccessibilityValue, AccessibilityViewpoint, ResourceAccessibility, UnitAccessibility from resources.models import ResourceUniversalFormOption, ResourceUniversalField, UniversalFormFieldType from resources.models import ReservationMetadataSet, ReservationMetadataField +from users.models import LoginMethod from munigeo.models import Municipality from maintenance.models import MaintenanceMessage, MaintenanceMode from .utils import get_test_image_data, get_test_image_payload @@ -350,15 +351,26 @@ def resource_equipment(resource_in_unit, equipment): @pytest.mark.django_db @pytest.fixture -def strong_user(): +def strong_auth_login_method(): + return LoginMethod.objects.create(id='very_strong_auth', name='Very Strong Auth') + +@pytest.mark.django_db +@pytest.fixture +def weak_auth_login_method(): + return LoginMethod.objects.create(id='very_weak_auth', name='Very Weak Auth') + + +@pytest.mark.django_db +@pytest.fixture +def strong_user(strong_auth_login_method): user = get_user_model().objects.create( username='test_user_super_strong', first_name='Evert', last_name='Bäckström', email='cem@kaner.com', - preferred_language='en' + preferred_language='en', + amr=strong_auth_login_method ) - user.amr = 'very_strong_auth' return user @pytest.mark.django_db diff --git a/respa/providers/turku_oidc/oidc.py b/respa/providers/turku_oidc/oidc.py index 355082067..dd5ab6466 100644 --- a/respa/providers/turku_oidc/oidc.py +++ b/respa/providers/turku_oidc/oidc.py @@ -1,8 +1,10 @@ from helusers.oidc import resolve_user, ApiTokenAuthentication as HelusersApiTokenAuthentication from helusers.authz import UserAuthorization from helusers.user_utils import _try_create_or_update +from users.models import LoginMethod from rest_framework.exceptions import AuthenticationFailed, ValidationError from django.utils.translation import gettext as _ +from django.utils import timezone from django.conf import settings from rest_framework import exceptions from django.db import transaction, IntegrityError @@ -28,7 +30,7 @@ def authenticate(self, request): raise AuthenticationFailed( _("Not authorized for API scope \"{api_scope}\"") .format(api_scope=api_scope)) - user.amr = payload['amr'] + return (user, auth) def decode_jwt(self, jwt_value): @@ -54,6 +56,7 @@ def decode_jwt(self, jwt_value): def get_or_create_user(payload, oidc=False): user_id = payload.get('sub') + amr = payload.pop('amr') if not user_id: msg = _('Invalid payload.') raise exceptions.AuthenticationFailed(msg) @@ -64,4 +67,9 @@ def get_or_create_user(payload, oidc=False): try_again = True if try_again: user = _try_create_or_update(user_id, payload, oidc) + + user.amr, _ = LoginMethod.objects.get_or_create(id=amr) + user.last_login = timezone.now() + user.save() + return user diff --git a/respa/templates/admin/base_site.html b/respa/templates/admin/base_site.html index ab05f3247..778f94818 100644 --- a/respa/templates/admin/base_site.html +++ b/respa/templates/admin/base_site.html @@ -25,6 +25,29 @@ display: flex; justify-content: center; } + + /* Bootstrap glyphicons */ + @font-face { + font-family: 'Glyphicons Halflings'; + src: url('//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot'), + url('//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), + url('//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.woff2') format('woff2'), + url('//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.woff') format('woff'), + url('//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.ttf') format('truetype'), + url('//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); + } + .glyphicon { + position: relative; + display: inline-block; + font: normal; + font-family: 'Glyphicons Halflings'; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + } + i.glyphicon-question-sign:before { + content: "\e085"; + } + {% endblock %} diff --git a/respa_admin/forms.py b/respa_admin/forms.py index a7ba9ed14..d7fc8adad 100644 --- a/respa_admin/forms.py +++ b/respa_admin/forms.py @@ -3,6 +3,8 @@ from django.db.models import Q from django import forms from django.core.exceptions import ValidationError +from django.core.validators import FileExtensionValidator +from django.core.files.uploadedfile import InMemoryUploadedFile from django.forms import inlineformset_factory from django.forms.formsets import DELETION_FIELD_NAME from guardian.core import ObjectPermissionChecker @@ -12,6 +14,7 @@ RespaCheckboxInput, RespaGenericCheckboxInput, RespaRadioSelect, + RespaSVGWidget ) from resources.models import ( @@ -880,3 +883,29 @@ def get_unit_authorization_formset(request=None, extra=1, instance=None): return unit_authorization_formset(request=request, instance=instance) else: return unit_authorization_formset(request=request, data=request.POST, instance=instance) + + +def _validate_svg(value): + if isinstance(value, str) and not value.startswith('{% trans 'unit authorizations'|capfirst %} class="row panel management-list list-item" data-paginator-item="true" data-paginator-filter-value="{{ unit_user.first_name }} {{ unit_user.last_name }} {{ unit_user.email }}"> -
- {{ unit_user.first_name }} {{ unit_user.last_name }} + +
+
+ {{ unit_user|get_login_method }} +
+
+ {{ unit_user.first_name }} {{ unit_user.last_name }} +
-
+ + +
{{ unit_user.email }}
@@ -121,7 +129,7 @@

{% trans 'unit authorizations'|capfirst %}

{% if unit_user != request.user or request.user.is_superuser %} -
+
diff --git a/respa_admin/templates/respa_admin/resources/_user_list.html b/respa_admin/templates/respa_admin/resources/_user_list.html index 32f8da475..17c32e8ef 100644 --- a/respa_admin/templates/respa_admin/resources/_user_list.html +++ b/respa_admin/templates/respa_admin/resources/_user_list.html @@ -19,8 +19,13 @@

{% trans 'Users by search term' %}: {{ search_query }} ({{ {{ unit.name }}
-
- {{ user.first_name }} {{ user.last_name }} +
+
+ {{ user|get_login_method }} +
+
+ {{ user.first_name }} {{ user.last_name }} +
{{ user.email }} diff --git a/respa_admin/templates/respa_admin/resources/form/_user_info.html b/respa_admin/templates/respa_admin/resources/form/_user_info.html index 6433a38b5..e24caad25 100644 --- a/respa_admin/templates/respa_admin/resources/form/_user_info.html +++ b/respa_admin/templates/respa_admin/resources/form/_user_info.html @@ -1,11 +1,13 @@ {% load i18n %} {% load static %} +{% load templatetags %}

{% trans "User profile" %}

{{ form.non_field_errors }}