From a84d45cdcce1156807360cdaef61dbbbc4e8a969 Mon Sep 17 00:00:00 2001 From: Dabble Date: Tue, 14 Dec 2021 18:54:13 +0100 Subject: [PATCH 01/20] Updated to Django 4.0 * [test_reservation_extra.py] The `localize()` method has effectively been removed (see https://docs.djangoproject.com/en/4.0/releases/4.0/#zoneinfo-default-timezone-implementation) * [requirements.txt] Also updated some other requirements - most notably `channels` from version 2 to 3, which has been long overdue * Also, replaced underscores in some of the package names with dashes, as that's what's displayed on the packages' PyPI pages * [test_locale_utils.py] `datetime_safe` has been deprecated (see https://docs.djangoproject.com/en/4.0/releases/4.0/#miscellaneous-1) * [routing.py] These changes were based on the `channels` 3.0.0 release notes (https://channels.readthedocs.io/en/stable/releases/3.0.0.html) * [static.py] `ManifestStaticFilesStorage` now uses named regex groups in its `patterns` field (see https://github.com/django/django/commit/781b44240a06f0c868254f40f36ce46c927f56d1) --- .../templatetags/test_reservation_extra.py | 6 ++--- requirements.txt | 24 +++++++++---------- util/tests/test_locale_utils.py | 3 +-- util/url_utils.py | 1 + web/routing.py | 6 +++-- web/static.py | 6 ++--- 6 files changed, 24 insertions(+), 22 deletions(-) diff --git a/make_queue/tests/templatetags/test_reservation_extra.py b/make_queue/tests/templatetags/test_reservation_extra.py index 78c5df768..2a517cc4a 100644 --- a/make_queue/tests/templatetags/test_reservation_extra.py +++ b/make_queue/tests/templatetags/test_reservation_extra.py @@ -7,7 +7,7 @@ from django.utils import timezone from users.models import User -from util.locale_utils import parse_datetime_localized +from util.locale_utils import attempt_as_local, parse_datetime_localized from ...models.course import Printer3DCourse from ...models.machine import Machine, MachineType from ...models.reservation import Quota, Reservation @@ -53,7 +53,7 @@ def test_current_calendar_url(self, now_mock): @mock.patch('django.utils.timezone.now') def test_is_current_data(self, now_mock): date = timezone.datetime(2017, 3, 5, 11, 18, 0) - now_mock.return_value = timezone.get_default_timezone().localize(date) + now_mock.return_value = attempt_as_local(date) self.assertTrue(is_current_date(timezone.now().date())) self.assertTrue(is_current_date((timezone.now() + timedelta(hours=1)).date())) @@ -64,7 +64,7 @@ def test_is_current_data(self, now_mock): def test_get_current_time_of_day(self, now_mock): def set_mock_value(hours, minutes): date = timezone.datetime(2017, 3, 5, hours, minutes, 0) - now_mock.return_value = timezone.get_default_timezone().localize(date) + now_mock.return_value = attempt_as_local(date) set_mock_value(12, 0) self.assertEqual(50, get_current_time_of_day()) diff --git a/requirements.txt b/requirements.txt index b141fddae..37399df2d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,27 +1,27 @@ # Django-related packages -Django==3.2.11 -django-hosts==4.0 +Django==4.0.3 +django-hosts==5.1 django-ckeditor==6.2.0 -django-cleanup==5.2.0 +django-cleanup==6.0.0 django-decorator-include==3.0 -django_ical==1.8.0 +django-ical==1.8.3 django-multiselectfield==0.1.12 -django-phonenumber-field==6.0.0 +django-phonenumber-field==6.1.0 django-simple-history==3.0.0 -sorl-thumbnail==12.7.0 +sorl-thumbnail==12.8.0 # Packages related to authentication and other "social" stuff social-auth-app-django==5.0.0 python-dataporten-auth==2.0.0 -python_ldap==3.4.0 +python-ldap==3.4.0 # Async-related packages (mainly for WebSockets) -channels==2.4.0 -channels_redis==3.3.1 +channels==3.0.4 +channels-redis==3.4.0 # Misc. packages bleach==4.1.0 -phonenumbers==8.12.40 -Pillow==9.0.0 +phonenumbers==8.12.45 +Pillow==9.0.1 uuid==1.30 -XlsxWriter==3.0.2 +XlsxWriter==3.0.3 diff --git a/util/tests/test_locale_utils.py b/util/tests/test_locale_utils.py index 309aa91ac..db50d50a5 100644 --- a/util/tests/test_locale_utils.py +++ b/util/tests/test_locale_utils.py @@ -1,9 +1,8 @@ -from datetime import timedelta +from datetime import datetime, timedelta from django.test import TestCase from django.utils import timezone from django.utils.dateparse import parse_datetime -from django.utils.datetime_safe import datetime from make_queue.models.reservation import ReservationRule from ..locale_utils import ( diff --git a/util/url_utils.py b/util/url_utils.py index 388392832..4611b0617 100644 --- a/util/url_utils.py +++ b/util/url_utils.py @@ -10,6 +10,7 @@ class SpecificObjectConverter(ABC): def to_python(self, value): try: + # TODO: remove in favor of a solution that gets these objects in the view, as this crashes with a SynchronousOnlyOperation error return self.model.objects.get(pk=int(value)) except self.model.DoesNotExist: raise ValueError(f"Unable to find any {self.model._meta.object_name} for the PK '{value}'") diff --git a/web/routing.py b/web/routing.py index 5e58ed178..3e6da324a 100644 --- a/web/routing.py +++ b/web/routing.py @@ -1,5 +1,6 @@ from channels.auth import AuthMiddlewareStack from channels.routing import ChannelNameRouter, ProtocolTypeRouter, URLRouter +from django.core.asgi import get_asgi_application from django.urls import path from mail.email import EmailConsumer @@ -7,14 +8,15 @@ websocket_urlpatterns = [ - path("ws/stream//", StreamConsumer), + path("ws/stream//", StreamConsumer.as_asgi()), ] channel_routes = { - "email": EmailConsumer + 'email': EmailConsumer.as_asgi(), } application = ProtocolTypeRouter({ + 'http': get_asgi_application(), 'websocket': AuthMiddlewareStack( URLRouter( websocket_urlpatterns diff --git a/web/static.py b/web/static.py index 437e07592..ab52833d5 100644 --- a/web/static.py +++ b/web/static.py @@ -13,8 +13,8 @@ INTERPOLATION_PATTERNS = ( ('*.interpolated.*', ( ( - r"""(\{% get_relative_static ["'](.*?)["'] %\})""", - "%s", + r"""(?P\{% get_relative_static ["'](?P.*?)["'] %\})""", + "%(url)s", ), )), ) @@ -67,7 +67,7 @@ def replace(match_obj): registered_relative_static_path = replace_filename( relative_static_path, PurePosixPath(registered_static_path).name ) - return template % registered_relative_static_path + return template % {'url': registered_relative_static_path} content = pattern.sub(replace, content) From 95ec3511cadebf98799ea4b969887c29ea66e64d Mon Sep 17 00:00:00 2001 From: Dabble Date: Fri, 17 Dec 2021 17:49:45 +0100 Subject: [PATCH 02/20] Moved contents of routing.py to asgi.py This follows the convention seen in https://channels.readthedocs.io/en/stable/installation.html - as opposed to the convention seen in https://channels.readthedocs.io/en/2.x/installation.html. (This convention change is also mentioned in https://channels.readthedocs.io/en/stable/releases/3.0.0.html#deprecations.) --- web/asgi.py | 26 ++++++++++++++++++++++---- web/routing.py | 28 ---------------------------- web/settings.py | 2 +- 3 files changed, 23 insertions(+), 33 deletions(-) delete mode 100644 web/routing.py diff --git a/web/asgi.py b/web/asgi.py index 9a4923bdd..22d504622 100644 --- a/web/asgi.py +++ b/web/asgi.py @@ -4,11 +4,29 @@ """ import os -import django -from channels.routing import get_default_application +from channels.auth import AuthMiddlewareStack +from channels.routing import ChannelNameRouter, ProtocolTypeRouter, URLRouter +from django.core.asgi import get_asgi_application +from django.urls import path + +from mail.email import EmailConsumer +from make_queue.views.stream.stream import StreamConsumer os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'web.settings') -django.setup() -application = get_default_application() +websocket_urlpatterns = [ + path("ws/stream//", StreamConsumer.as_asgi()), +] + +channel_routes = { + 'email': EmailConsumer.as_asgi(), +} + +application = ProtocolTypeRouter({ + 'http': get_asgi_application(), + 'websocket': AuthMiddlewareStack( + URLRouter(websocket_urlpatterns) + ), + 'channel': ChannelNameRouter(channel_routes), +}) diff --git a/web/routing.py b/web/routing.py deleted file mode 100644 index 3e6da324a..000000000 --- a/web/routing.py +++ /dev/null @@ -1,28 +0,0 @@ -from channels.auth import AuthMiddlewareStack -from channels.routing import ChannelNameRouter, ProtocolTypeRouter, URLRouter -from django.core.asgi import get_asgi_application -from django.urls import path - -from mail.email import EmailConsumer -from make_queue.views.stream.stream import StreamConsumer - - -websocket_urlpatterns = [ - path("ws/stream//", StreamConsumer.as_asgi()), -] - -channel_routes = { - 'email': EmailConsumer.as_asgi(), -} - -application = ProtocolTypeRouter({ - 'http': get_asgi_application(), - 'websocket': AuthMiddlewareStack( - URLRouter( - websocket_urlpatterns - ) - ), - 'channel': ChannelNameRouter( - channel_routes - ) -}) diff --git a/web/settings.py b/web/settings.py index 3cb38a7ea..2ccc1233f 100644 --- a/web/settings.py +++ b/web/settings.py @@ -172,7 +172,7 @@ }, ] -ASGI_APPLICATION = 'web.routing.application' +ASGI_APPLICATION = 'web.asgi.application' CHANNEL_LAYERS = { 'default': { 'BACKEND': 'channels_redis.core.RedisChannelLayer', From 0423840ce9330b06e782bf778bd8f38b839052e7 Mon Sep 17 00:00:00 2001 From: Dabble Date: Mon, 24 Jan 2022 06:57:43 +0100 Subject: [PATCH 03/20] Made interpolated static files use absolute URLs This fixes an issue with some browsers not being able to properly interpret relative URLs, and it also improves PyCharm's code suggestions in the interpolated XML files (due to it interpreting the `{% static %}` token as a Django template tag). --- .../favicons/browserconfig.interpolated.xml | 2 +- .../favicons/site.interpolated.webmanifest | 4 +- web/static.py | 80 +++++++++++-------- .../favicons/browserconfig.interpolated.xml | 2 +- .../favicons/site.interpolated.webmanifest | 4 +- .../favicons/browserconfig.interpolated.xml | 2 +- .../favicons/site.interpolated.webmanifest | 4 +- web/tests/test_static.py | 18 +++-- 8 files changed, 65 insertions(+), 51 deletions(-) diff --git a/internal/static/internal/img/favicons/browserconfig.interpolated.xml b/internal/static/internal/img/favicons/browserconfig.interpolated.xml index ffa4f8ce0..518c61f57 100644 --- a/internal/static/internal/img/favicons/browserconfig.interpolated.xml +++ b/internal/static/internal/img/favicons/browserconfig.interpolated.xml @@ -2,7 +2,7 @@ - + #F8C811 diff --git a/internal/static/internal/img/favicons/site.interpolated.webmanifest b/internal/static/internal/img/favicons/site.interpolated.webmanifest index 999bfa4c6..cc2463ec9 100644 --- a/internal/static/internal/img/favicons/site.interpolated.webmanifest +++ b/internal/static/internal/img/favicons/site.interpolated.webmanifest @@ -3,12 +3,12 @@ "short_name": "MAKE Internal", "icons": [ { - "src": "{% get_relative_static './android-chrome-192x192.png' %}", + "src": "{% static 'internal/img/favicons/android-chrome-192x192.png' %}", "sizes": "192x192", "type": "image/png" }, { - "src": "{% get_relative_static './android-chrome-512x512.png' %}", + "src": "{% static 'internal/img/favicons/android-chrome-512x512.png' %}", "sizes": "512x512", "type": "image/png" } diff --git a/web/static.py b/web/static.py index ab52833d5..49ddf1508 100644 --- a/web/static.py +++ b/web/static.py @@ -1,19 +1,23 @@ +import re +from fnmatch import fnmatchcase from io import BytesIO -from pathlib import PurePosixPath +from typing import Dict, List, Tuple +from django.conf import settings from django.contrib.staticfiles.storage import HashedFilesMixin, ManifestStaticFilesStorage -from django.contrib.staticfiles.utils import matches_patterns from django.http import FileResponse -from django.templatetags.static import static from django.views.static import serve -# In the files matching the glob patterns, the strings matching the regexes are replaced by the inner capturing group, -# which is first looked up in the static file manifest +# The glob pattern for the files that should have their contents interpolated (as in https://en.wikipedia.org/wiki/String_interpolation) +INTERPOLATION_GLOB_PATTERN = '*.interpolated.*' INTERPOLATION_PATTERNS = ( - ('*.interpolated.*', ( + (INTERPOLATION_GLOB_PATTERN, ( ( - r"""(?P\{% get_relative_static ["'](?P.*?)["'] %\})""", + # The strings matching this regex are replaced by the template below + r"""(?P\{% static ["'](?P.*?)["'] %\})""", + # This simply inserts the string matched by the `url` capturing group (defined in the regex above) as it is; + # when the template is used, the URL in the file has been looked up in the static file manifest, and replaced by its hashed version "%(url)s", ), )), @@ -22,64 +26,70 @@ class InterpolatingManifestStaticFilesStorage(ManifestStaticFilesStorage): """ - This class extends the functionality of ``ManifestStaticFilesStorage`` by replacing custom ``get_relative_static`` - tokens in files whose names end with ``.interpolated`` (not including the extension), with the path of another static file. + This class extends the functionality of ``ManifestStaticFilesStorage`` by replacing ``static`` tags + in files whose names end with ``.interpolated`` (not including the extension), with the path of another static file. For example, :: - {% get_relative_static './android-chrome-192x192.png' %} + {% static 'web/img/favicons/android-chrome-192x192.png' %} is replaced by :: - ./android-chrome-192x192.edcbcb619832.png - - Development note: The paths have to be relative to the file, because of the way ``ManifestStaticFilesStorage`` works. + /static/web/img/favicons/android-chrome-192x192.edcbcb619832.png """ patterns = ( *INTERPOLATION_PATTERNS, *ManifestStaticFilesStorage.patterns, ) + def url_converter(self, name, *args, **kwargs): + base_converter = super().url_converter(name, *args, **kwargs) + if not fnmatchcase(name, INTERPOLATION_GLOB_PATTERN): + return base_converter + + def converter(match_obj: re.Match): + matches = match_obj.groupdict() + matched = matches['matched'] + url = matches['url'] + + # Prefix the URL with `STATIC_URL`, so that it doesn't get ignored by `base_converter` + # (see https://github.com/django/django/blob/dc8bb35e39388d41b1f38b6c5d0181224e075f16/django/contrib/staticfiles/storage.py#L184-L187) + replaced_matched = matched.replace(url, self.get_full_static_url(url)) + return base_converter(match_obj.re.match(replaced_matched)) + + return converter + + @staticmethod + def get_full_static_url(url: str): + return f"{settings.STATIC_URL}{url}" + class _PureInterpolatingFilesMixin(HashedFilesMixin): patterns = INTERPOLATION_PATTERNS -_compiled_interpolation_patterns = _PureInterpolatingFilesMixin()._patterns +_compiled_interpolation_patterns: Dict[str, List[Tuple[re.Pattern, str]]] = _PureInterpolatingFilesMixin()._patterns -def serve_interpolated(request, path, document_root=None, show_indexes=False): +def serve_interpolated(request, path, *args, **kwargs): """ This view extends the functionality of Django's ``serve()`` with the same as ``InterpolatingManifestStaticFilesStorage`` does. """ - response = serve(request, path, document_root=document_root, show_indexes=show_indexes) - if (isinstance(response, FileResponse) and request.path.startswith("/static") - and matches_patterns(path, _compiled_interpolation_patterns)): + response = serve(request, path, *args, **kwargs) + if (isinstance(response, FileResponse) and request.path.startswith(settings.STATIC_URL) + and fnmatchcase(path, INTERPOLATION_GLOB_PATTERN)): - requested_path_static_dir = PurePosixPath(path).parent content = response.getvalue().decode() for extension, patterns in _compiled_interpolation_patterns.items(): for pattern, template in patterns: - def replace(match_obj): - _tag, relative_static_path = match_obj.groups() - registered_static_path = static(str(requested_path_static_dir / relative_static_path)) - registered_relative_static_path = replace_filename( - relative_static_path, PurePosixPath(registered_static_path).name - ) - return template % {'url': registered_relative_static_path} + def replace(match_obj: re.Match): + matches = match_obj.groupdict() + matches['url'] = InterpolatingManifestStaticFilesStorage.get_full_static_url(matches['url']) + return template % matches content = pattern.sub(replace, content) response.streaming_content = BytesIO(content.encode()) return response - - -def replace_filename(path: str, new_filename: str): - """ - Replaces the filename of the given ``path`` with ``new_filename``, - keeping any relative path prefix (like ``./``). - """ - directory, _filename, suffix = path.rpartition(PurePosixPath(path).name) - return f"{directory}{new_filename}{suffix}" diff --git a/web/static/admin/img/favicons/browserconfig.interpolated.xml b/web/static/admin/img/favicons/browserconfig.interpolated.xml index 9fbc12cbb..9c8cd26ac 100644 --- a/web/static/admin/img/favicons/browserconfig.interpolated.xml +++ b/web/static/admin/img/favicons/browserconfig.interpolated.xml @@ -2,7 +2,7 @@ - + #FFFFFF diff --git a/web/static/admin/img/favicons/site.interpolated.webmanifest b/web/static/admin/img/favicons/site.interpolated.webmanifest index 35023fe7f..d4c1c160d 100644 --- a/web/static/admin/img/favicons/site.interpolated.webmanifest +++ b/web/static/admin/img/favicons/site.interpolated.webmanifest @@ -3,12 +3,12 @@ "short_name": "MAKE Admin", "icons": [ { - "src": "{% get_relative_static './android-chrome-192x192.png' %}", + "src": "{% static 'admin/img/favicons/android-chrome-192x192.png' %}", "sizes": "192x192", "type": "image/png" }, { - "src": "{% get_relative_static './android-chrome-512x512.png' %}", + "src": "{% static 'admin/img/favicons/android-chrome-512x512.png' %}", "sizes": "512x512", "type": "image/png" } diff --git a/web/static/web/img/favicons/browserconfig.interpolated.xml b/web/static/web/img/favicons/browserconfig.interpolated.xml index 83ab72bfa..2b2f6f2ad 100644 --- a/web/static/web/img/favicons/browserconfig.interpolated.xml +++ b/web/static/web/img/favicons/browserconfig.interpolated.xml @@ -2,7 +2,7 @@ - + #0C426A diff --git a/web/static/web/img/favicons/site.interpolated.webmanifest b/web/static/web/img/favicons/site.interpolated.webmanifest index 547a191f2..4337b6a65 100644 --- a/web/static/web/img/favicons/site.interpolated.webmanifest +++ b/web/static/web/img/favicons/site.interpolated.webmanifest @@ -3,12 +3,12 @@ "short_name": "MAKE NTNU", "icons": [ { - "src": "{% get_relative_static './android-chrome-192x192.png' %}", + "src": "{% static 'web/img/favicons/android-chrome-192x192.png' %}", "sizes": "192x192", "type": "image/png" }, { - "src": "{% get_relative_static './android-chrome-512x512.png' %}", + "src": "{% static 'web/img/favicons/android-chrome-512x512.png' %}", "sizes": "512x512", "type": "image/png" } diff --git a/web/tests/test_static.py b/web/tests/test_static.py index 12e2e1900..e36164976 100644 --- a/web/tests/test_static.py +++ b/web/tests/test_static.py @@ -10,19 +10,23 @@ class InterpolatedStaticFilesTests(LiveServerTestCase): + favicons_folders = [f"{base_folder}/img/favicons/" for base_folder in ('web', 'internal', 'admin')] interpolated_files_to_before_and_after_strings = { **{ - f'{favicons_base_folder}/img/favicons/browserconfig.interpolated.xml': [ - ("{% get_relative_static './mstile-150x150.png' %}", rf'\./mstile-150x150{MANIFEST_HEX_SUFFIX_REGEX}\.png') + f'{favicons_folder}browserconfig.interpolated.xml': [ + (f"{{% static '{favicons_folder}mstile-150x150.png' %}}", + rf'/static/{favicons_folder}mstile-150x150{MANIFEST_HEX_SUFFIX_REGEX}\.png') ] - for favicons_base_folder in ('web', 'internal', 'admin') + for favicons_folder in favicons_folders }, **{ - f'{favicons_base_folder}/img/favicons/site.interpolated.webmanifest': [ - ("{% get_relative_static './android-chrome-192x192.png' %}", rf'\./android-chrome-192x192{MANIFEST_HEX_SUFFIX_REGEX}\.png'), - ("{% get_relative_static './android-chrome-512x512.png' %}", rf'\./android-chrome-512x512{MANIFEST_HEX_SUFFIX_REGEX}\.png'), + f'{favicons_folder}site.interpolated.webmanifest': [ + (f"{{% static '{favicons_folder}android-chrome-192x192.png' %}}", + rf'/static/{favicons_folder}android-chrome-192x192{MANIFEST_HEX_SUFFIX_REGEX}\.png'), + (f"{{% static '{favicons_folder}android-chrome-512x512.png' %}}", + rf'/static/{favicons_folder}android-chrome-512x512{MANIFEST_HEX_SUFFIX_REGEX}\.png'), ] - for favicons_base_folder in ('web', 'internal', 'admin') + for favicons_folder in favicons_folders }, } From 6c76473012e277c2062578f7f2b9085229008e09 Mon Sep 17 00:00:00 2001 From: Dabble Date: Fri, 17 Dec 2021 17:32:35 +0100 Subject: [PATCH 04/20] Made reservation rules selectively show decimals --- make_queue/templates/make_queue/rule_list.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/make_queue/templates/make_queue/rule_list.html b/make_queue/templates/make_queue/rule_list.html index bd4fc2d02..9981c994b 100644 --- a/make_queue/templates/make_queue/rule_list.html +++ b/make_queue/templates/make_queue/rule_list.html @@ -67,10 +67,10 @@

{% trans "Single period" %}: - {{ rule.max_hours|floatformat:0 }} {% trans "hours" %} + {{ rule.max_hours|floatformat }} {% trans "hours" %}
{% trans "Multi-period" %}: - {{ rule.max_inside_border_crossed|floatformat:0 }} {% trans "hours" %} + {{ rule.max_inside_border_crossed|floatformat }} {% trans "hours" %}

{% trans "Periods" %} From 0855485bae0109f5e635fe37004648738f5a1edd Mon Sep 17 00:00:00 2001 From: Dabble Date: Mon, 24 Jan 2022 02:10:36 +0100 Subject: [PATCH 05/20] Made incomplete cached user details add data from LDAP Or, in other words, if the user data in the database is missing some values, the missing values are replaced with data from LDAP. --- dataporten/ldap_utils.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/dataporten/ldap_utils.py b/dataporten/ldap_utils.py index 6d81fcc26..5c8b958d7 100644 --- a/dataporten/ldap_utils.py +++ b/dataporten/ldap_utils.py @@ -61,15 +61,36 @@ def get_user_details_from_LDAP(search_field: str, search_value: str) -> Dict[str def _get_user_details_from_user_field(field_name: str, field_value: str, use_cached: bool) -> Dict[str, str]: + user_details = {} if use_cached: user = User.objects.filter(**{field_name: field_value}).first() if user: - return { + user_details = { 'username': user.username, 'email': user.email, 'full_name': user.get_full_name(), } - return get_user_details_from_LDAP(field_name, field_value) + + if (user_details + # If `user_details` contains values for all dict keys: + and all(value for value in user_details.values())): + return user_details + + try: + LDAP_user_details = get_user_details_from_LDAP(field_name, field_value) + except ldap.LDAPError: + return user_details + + if not LDAP_user_details: + return user_details + if not user_details: + return LDAP_user_details + + # Update missing values with data from LDAP + for key, value in user_details.items(): + if not value: + user_details[key] = LDAP_user_details[key] + return user_details def get_user_details_from_username(username: str, use_cached=True) -> Dict[str, str]: From a5eaf6246e9ec273fe954df24e88ff035f1de5c6 Mon Sep 17 00:00:00 2001 From: Dabble Date: Thu, 24 Mar 2022 02:26:55 +0100 Subject: [PATCH 06/20] Moved db queries from path converters to views There was a change in Django 4.0 that made path converters run in an async context, which caused a `SynchronousOnlyOperation` error when querying the database from a custom path converter (like subclasses of the previous `url_utils.SpecificObjectConverter`). Using `sync_to_async()` did not work, but getting the view's "focused" object through path converters (instead of e.g. through views' `get_object()` method) was an unconventional hack anyway, and so this was completely removed, and replaced by logic in the views. It can also be noted that the previously mentioned error only happened when querying the database in the path converters' `to_python()` method, not the `to_url()` method - where the former is usually invoked when Django receives a request and tries finding a view with a matching path, and the latter is mainly invoked when reversing path names while constructing a response in views and templates. This is why making database queries in `SpecificPageByTitle.to_url()` does not need to be changed. Also removed the now unused `tests.utility.template_view_get_context_data()`. --- docs/converters.py | 16 +- .../docs/documentation_page_detail.html | 12 +- .../docs/documentation_page_history.html | 4 +- docs/templates/docs/search.html | 2 +- docs/tests/test_urls.py | 14 +- docs/urls.py | 17 +- docs/views.py | 66 +++---- make_queue/converters.py | 44 ----- .../templates/make_queue/machine_detail.html | 2 +- .../templates/make_queue/machine_list.html | 2 +- .../make_queue/reservation_actions.html | 4 +- .../make_queue/reservation_list.html | 4 +- .../templates/make_queue/rule_list.html | 8 +- .../make_queue/usage_rules_detail.html | 4 +- make_queue/templatetags/reservation_extra.py | 7 +- .../templatetags/test_reservation_extra.py | 4 +- make_queue/tests/test_urls.py | 38 ++-- make_queue/tests/utility.py | 6 - make_queue/tests/views/test_quota_views.py | 33 ++-- .../views/test_reservation_reservation.py | 92 +++++----- make_queue/urls.py | 29 ++- make_queue/views/admin/quota.py | 20 ++- make_queue/views/api/calendar.py | 7 +- make_queue/views/api/reservation.py | 5 +- make_queue/views/quota/user.py | 11 +- make_queue/views/reservation/calendar.py | 18 +- make_queue/views/reservation/reservation.py | 51 +++++- make_queue/views/reservation/rules.py | 30 ++-- news/converters.py | 10 -- news/ical.py | 4 +- .../news/admin_timeplace_listing.html | 10 +- news/templates/news/event_detail.html | 12 +- .../news/ticket_card_time_place.html | 2 +- news/tests/test_urls.py | 38 ++-- news/tests/views/test_article_views.py | 22 +-- news/tests/views/test_event_ticket_views.py | 32 ++-- .../views/test_event_time_place_views.py | 44 ++--- news/urls.py | 25 +-- news/views.py | 167 ++++++++++-------- users/converters.py | 19 -- util/url_utils.py | 24 --- web/urls.py | 8 +- 42 files changed, 469 insertions(+), 498 deletions(-) delete mode 100644 news/converters.py delete mode 100644 users/converters.py diff --git a/docs/converters.py b/docs/converters.py index 78ca7c802..48c7f71f7 100644 --- a/docs/converters.py +++ b/docs/converters.py @@ -1,5 +1,4 @@ -from util.url_utils import SpecificObjectConverter -from .models import Content, Page +from .models import Page from .validators import page_title_regex @@ -7,14 +6,7 @@ class SpecificPageByTitle: regex = page_title_regex.strip(r"^$") def to_python(self, value): - try: - return Page.objects.get(title=value).pk - except Page.DoesNotExist: - raise ValueError("No page exists with that title") + return str(value) - def to_url(self, page: Page): - return page.title - - -class SpecificContent(SpecificObjectConverter): - model = Content + def to_url(self, page_pk: int): + return Page.objects.get(pk=page_pk).title diff --git a/docs/templates/docs/documentation_page_detail.html b/docs/templates/docs/documentation_page_detail.html index b4e89e1ef..662f6aaa3 100644 --- a/docs/templates/docs/documentation_page_detail.html +++ b/docs/templates/docs/documentation_page_detail.html @@ -22,7 +22,7 @@ on {{ date }} at {{ time }}. {% endblocktrans %} {% trans "To view the current version, click" %} - {% trans "here" %}. + {% trans "here" %}. {% if perms.docs.change_page %} {% trans "To change the current version to this one, click" %} {% trans "here" %}. @@ -30,13 +30,13 @@ {% endif %}

- {% if not old %} {% if perms.docs.change_page %} - @@ -44,7 +44,7 @@

{% if perms.docs.delete_page and page.title != MAIN_PAGE_TITLE %} + data-url="{% url 'delete_page' page.pk %}" data-obj-name="{{ page }}"> {% endif %} @@ -59,7 +59,7 @@

{{ page.title }}

{% else %} {% if perms.docs.change_page %} - {% url 'edit_page' pk=page as edit_page_url %} + {% url 'edit_page' page.pk as edit_page_url %} {% blocktrans trimmed with link_start='' link_end='' %} No content exists for this page. Do you wish to {{ link_start }}create{{ link_end }} some? {% endblocktrans %} @@ -72,7 +72,7 @@

{{ page.title }}

{% if form and old and perms.docs.change_page %} diff --git a/docs/templates/docs/documentation_page_history.html b/docs/templates/docs/documentation_page_history.html index f8e670882..4a220cfa7 100644 --- a/docs/templates/docs/documentation_page_history.html +++ b/docs/templates/docs/documentation_page_history.html @@ -15,9 +15,9 @@

{% for content in page.content_history.all reversed %} {% if content == page.current_content %} - {% url 'page_detail' pk=page as content_url %} + {% url 'page_detail' page.pk as content_url %} {% else %} - {% url 'old_page_content' pk=page content=content as content_url %} + {% url 'old_page_content' page.pk content.pk as content_url %} {% endif %}
{% if forloop.last %} diff --git a/docs/templates/docs/search.html b/docs/templates/docs/search.html index 985c1f68b..e734ac70a 100644 --- a/docs/templates/docs/search.html +++ b/docs/templates/docs/search.html @@ -22,7 +22,7 @@

{% trans "Search" %}

{% if page.current_content %} diff --git a/docs/tests/test_urls.py b/docs/tests/test_urls.py index 024dcbafb..b6c7dabb2 100644 --- a/docs/tests/test_urls.py +++ b/docs/tests/test_urls.py @@ -33,12 +33,12 @@ def setUp(self): def test_all_get_request_paths_succeed(self): path_predicates = [ Get(self.reverse('home'), public=False), - Get(self.reverse('page_detail', pk=self.page1), public=False), - Get(self.reverse('page_history', pk=self.page1), public=False), - Get(self.reverse('old_page_content', pk=self.page1, content=self.content1), public=False), - Get(self.reverse('old_page_content', pk=self.page1, content=self.content2), public=False), + Get(self.reverse('page_detail', self.page1.pk), public=False), + Get(self.reverse('page_history', self.page1.pk), public=False), + Get(self.reverse('old_page_content', self.page1.pk, self.content1.pk), public=False), + Get(self.reverse('old_page_content', self.page1.pk, self.content2.pk), public=False), Get(self.reverse('create_page'), public=False), - Get(self.reverse('edit_page', pk=self.page1), public=False), + Get(self.reverse('edit_page', self.page1.pk), public=False), Get(self.reverse('search_pages'), public=False), Get('/robots.txt', public=True, translated=False), Get('/.well-known/security.txt', public=True, translated=False), @@ -59,5 +59,5 @@ def test_all_admin_get_request_paths_succeed(self): assert_requesting_paths_succeeds(self, path_predicates, 'admin') @staticmethod - def reverse(viewname: str, **kwargs): - return reverse(viewname, kwargs=kwargs, host='docs') + def reverse(viewname: str, *args): + return reverse(viewname, args=args, host='docs') diff --git a/docs/urls.py b/docs/urls.py index 764d4f0d1..86fd6bfbf 100644 --- a/docs/urls.py +++ b/docs/urls.py @@ -8,18 +8,17 @@ from .models import MAIN_PAGE_TITLE, Page -register_converter(converters.SpecificPageByTitle, 'Page') -register_converter(converters.SpecificContent, 'Content') +register_converter(converters.SpecificPageByTitle, 'PageTitle') unsafe_urlpatterns = [ - path("", views.DocumentationPageDetailView.as_view(), {'pk': Page.objects.get_or_create(title=MAIN_PAGE_TITLE)[0].pk}, name='home'), - path("page//", views.DocumentationPageDetailView.as_view(), name='page_detail'), - path("page//history/", views.HistoryDocumentationPageView.as_view(), name='page_history'), - path("page//history/change/", views.ChangeDocumentationPageVersionView.as_view(), name='change_page_version'), - path("page//history//", views.OldDocumentationPageContentView.as_view(), name='old_page_content'), + path("", views.DocumentationPageDetailView.as_view(), {'title': Page.objects.get_or_create(title=MAIN_PAGE_TITLE)[0].title}, name='home'), + path("page//", views.DocumentationPageDetailView.as_view(), name='page_detail'), + path("page//history/", views.HistoryDocumentationPageView.as_view(), name='page_history'), + path("page//history/change/", views.ChangeDocumentationPageVersionView.as_view(), name='change_page_version'), + path("page//history//", views.OldDocumentationPageContentView.as_view(), name='old_page_content'), path("page/create/", views.CreateDocumentationPageView.as_view(), name='create_page'), - path("page//edit/", views.EditDocumentationPageView.as_view(), name='edit_page'), - path("page//delete/", views.DeleteDocumentationPageView.as_view(), name='delete_page'), + path("page//edit/", views.EditDocumentationPageView.as_view(), name='edit_page'), + path("page//delete/", views.DeleteDocumentationPageView.as_view(), name='delete_page'), path("search/", views.SearchPagesView.as_view(), name='search_pages'), ] diff --git a/docs/views.py b/docs/views.py index 7443d6235..c10e6ce05 100644 --- a/docs/views.py +++ b/docs/views.py @@ -3,6 +3,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Q from django.http import HttpResponseForbidden, HttpResponseRedirect +from django.shortcuts import get_object_or_404 from django.urls import reverse, reverse_lazy from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, DeleteView, DetailView, TemplateView, UpdateView @@ -10,62 +11,67 @@ from util.view_utils import CustomFieldsetFormMixin, PreventGetRequestsMixin, insert_form_field_values from .forms import ChangePageVersionForm, CreatePageForm, PageContentForm -from .models import MAIN_PAGE_TITLE, Page +from .models import Content, MAIN_PAGE_TITLE, Page -class DocumentationPageDetailView(DetailView): +class SpecificPageBasedViewMixin: + """ + Note: When extending this mixin class, it's required to have a ``PageTitle`` path converter named ``title`` as part of the view's path, + which will be used to query the database for the requested page by title. + """ model = Page + # The name of the model field that will be queried using the value of `slug_url_kwarg` + slug_field = 'title' + # The name of the path parameter whose value will be used to query `slug_field` + slug_url_kwarg = 'title' + # PKs will not be used to query objects + pk_url_kwarg = None + + +class DocumentationPageDetailView(SpecificPageBasedViewMixin, DetailView): template_name = 'docs/documentation_page_detail.html' context_object_name = "page" extra_context = {'MAIN_PAGE_TITLE': MAIN_PAGE_TITLE} -class HistoryDocumentationPageView(DetailView): - model = Page +class HistoryDocumentationPageView(SpecificPageBasedViewMixin, DetailView): template_name = 'docs/documentation_page_history.html' context_object_name = "page" -class OldDocumentationPageContentView(DetailView): - model = Page +class OldDocumentationPageContentView(SpecificPageBasedViewMixin, DetailView): template_name = 'docs/documentation_page_detail.html' context_object_name = "page" extra_context = {'MAIN_PAGE_TITLE': MAIN_PAGE_TITLE} - def dispatch(self, request, *args, **kwargs): - # A check to make sure that the given content is related to the given page. As to make sure that the database - # stays in a correct state. - if self.get_object() != self.get_content().page: - return HttpResponseRedirect(reverse('page_detail', kwargs={'pk': self.get_object()})) - else: - return super().dispatch(request, *args, **kwargs) + content: Content - def get_content(self): - return self.kwargs.get('content') + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + content_pk = self.kwargs['content_pk'] + self.content = get_object_or_404(self.get_object().content_history, pk=content_pk) def get_context_data(self, **kwargs): context_data = super().get_context_data(**kwargs) - content = self.get_content() context_data.update({ - "old": True, - "content": content, - "last_edit_name": content.made_by.get_full_name() if content.made_by else _("Anonymous"), - "form": ChangePageVersionForm(initial={"current_content": content}), + 'old': True, + 'content': self.content, + 'last_edit_name': self.content.made_by.get_full_name() if self.content.made_by else _("Anonymous"), + 'form': ChangePageVersionForm(initial={'current_content': self.content}), }) return context_data -class ChangeDocumentationPageVersionView(PermissionRequiredMixin, UpdateView): +class ChangeDocumentationPageVersionView(PermissionRequiredMixin, SpecificPageBasedViewMixin, UpdateView): permission_required = ('docs.change_page',) - model = Page form_class = ChangePageVersionForm def get(self, request, *args, **kwargs): - return HttpResponseRedirect(reverse('page_history', kwargs={'pk': self.get_object()})) + return HttpResponseRedirect(reverse('page_history', args=[self.get_object().pk])) def get_success_url(self): - return reverse('page_detail', kwargs={'pk': self.get_object()}) + return reverse('page_detail', args=[self.get_object().pk]) def form_invalid(self, form): return HttpResponseForbidden() @@ -94,16 +100,15 @@ def form_invalid(self, form): except Page.DoesNotExist: existing_page = None if existing_page: - return HttpResponseRedirect(reverse('page_detail', kwargs={'pk': existing_page})) + return HttpResponseRedirect(reverse('page_detail', args=[existing_page.pk])) return super().form_invalid(form) def get_success_url(self): - return reverse('edit_page', kwargs={'pk': self.object}) + return reverse('edit_page', args=[self.object.pk]) -class EditDocumentationPageView(PermissionRequiredMixin, CustomFieldsetFormMixin, UpdateView): +class EditDocumentationPageView(PermissionRequiredMixin, CustomFieldsetFormMixin, SpecificPageBasedViewMixin, UpdateView): permission_required = ('docs.change_page',) - model = Page form_class = PageContentForm template_name = 'docs/documentation_page_edit.html' @@ -142,12 +147,11 @@ def form_valid(self, form): return super(ModelFormMixin, self).form_valid(form) def get_success_url(self): - return reverse('page_detail', kwargs={'pk': self.object}) + return reverse('page_detail', args=[self.object.pk]) -class DeleteDocumentationPageView(PermissionRequiredMixin, PreventGetRequestsMixin, DeleteView): +class DeleteDocumentationPageView(PermissionRequiredMixin, PreventGetRequestsMixin, SpecificPageBasedViewMixin, DeleteView): permission_required = ('docs.delete_page',) - model = Page queryset = Page.objects.exclude(title=MAIN_PAGE_TITLE) success_url = reverse_lazy('home') diff --git a/make_queue/converters.py b/make_queue/converters.py index 7727eaa32..361f561a2 100644 --- a/make_queue/converters.py +++ b/make_queue/converters.py @@ -1,34 +1,3 @@ -from users.models import User -from util.url_utils import SpecificObjectConverter -from .models.machine import Machine, MachineType -from .models.reservation import Reservation - - -class SpecificMachineType(SpecificObjectConverter): - model = MachineType - - -class SpecificMachine(SpecificObjectConverter): - model = Machine - - -class SpecificReservation(SpecificObjectConverter): - model = Reservation - - -class UserByUsername: - regex = r"([-0-9A-Za-z.]*)" - - def to_python(self, value): - try: - return User.objects.get(username=value) - except User.DoesNotExist: - raise ValueError("No user with that username") - - def to_url(self, user: User): - return user.username - - class Year: regex = "([0-9]{4})" @@ -47,16 +16,3 @@ def to_python(self, value): def to_url(self, week: int): return str(week) - - -class MachineReservation: - regex = "([0-9]+)" - - def to_python(self, value): - try: - return Reservation.objects.get(pk=int(value)) - except Reservation.DoesNotExist: - raise ValueError("No reservation for that key") - - def to_url(self, reservation: Reservation): - return str(reservation.pk) diff --git a/make_queue/templates/make_queue/machine_detail.html b/make_queue/templates/make_queue/machine_detail.html index d3bc2e589..c97e85582 100644 --- a/make_queue/templates/make_queue/machine_detail.html +++ b/make_queue/templates/make_queue/machine_detail.html @@ -21,7 +21,7 @@ {{ machine.name }}
diff --git a/make_queue/templates/make_queue/machine_list.html b/make_queue/templates/make_queue/machine_list.html index 10849fdf9..9c8a2c056 100644 --- a/make_queue/templates/make_queue/machine_list.html +++ b/make_queue/templates/make_queue/machine_list.html @@ -36,7 +36,7 @@ {{ machine_type.name }}
diff --git a/make_queue/templates/make_queue/reservation_actions.html b/make_queue/templates/make_queue/reservation_actions.html index 5d2d2303a..6084356cc 100644 --- a/make_queue/templates/make_queue/reservation_actions.html +++ b/make_queue/templates/make_queue/reservation_actions.html @@ -11,7 +11,7 @@

{% trans "Actions" %}

{# and it should lead to the `create_reservation` page when the user is logged in and reservation is allowed. #}

{% for other_machine in other_machines %} + href="{% url 'machine_detail' pk=other_machine.pk year=year week=week %}"> {{ other_machine.name }} {% endfor %} diff --git a/make_queue/templates/make_queue/reservation_list.html b/make_queue/templates/make_queue/reservation_list.html index 5ea072ca6..f452e0657 100644 --- a/make_queue/templates/make_queue/reservation_list.html +++ b/make_queue/templates/make_queue/reservation_list.html @@ -120,7 +120,7 @@ {% can_change_reservation reservation user as can_change %} {% if can_change %} - +
{% trans "Change" %}
@@ -198,7 +198,7 @@
{% can_change_reservation reservation user as can_change %} {% if can_change %} - + {% trans "Change" %} {% endif %} diff --git a/make_queue/templates/make_queue/rule_list.html b/make_queue/templates/make_queue/rule_list.html index 9981c994b..2236e04e2 100644 --- a/make_queue/templates/make_queue/rule_list.html +++ b/make_queue/templates/make_queue/rule_list.html @@ -17,13 +17,13 @@

{{ title }} {% if perms.make_queue.add_reservationrule %} - + {% endif %}

@@ -54,13 +54,13 @@

{% if perms.make_queue.change_reservationrule %} - + {% endif %} {% if perms.make_queue.delete_reservationrule %} + data-url="{% url 'delete_reservation_rule' machine_type.pk rule.pk %}"> {% endif %} diff --git a/make_queue/templates/make_queue/usage_rules_detail.html b/make_queue/templates/make_queue/usage_rules_detail.html index abf7b5ccc..52252f557 100644 --- a/make_queue/templates/make_queue/usage_rules_detail.html +++ b/make_queue/templates/make_queue/usage_rules_detail.html @@ -14,13 +14,13 @@

{{ title }}

- + {% trans "Quota and detailed rules for reservation duration" %} {% if perms.make_queue.change_machineusagerule %} + href="{% url 'edit_machine_usage_rules' machine_type.pk %}"> {% trans "Edit" %} {% endif %} diff --git a/make_queue/templatetags/reservation_extra.py b/make_queue/templatetags/reservation_extra.py index f46deb8ea..0d22e921a 100644 --- a/make_queue/templatetags/reservation_extra.py +++ b/make_queue/templatetags/reservation_extra.py @@ -18,21 +18,20 @@ @register.simple_tag def calendar_url_reservation(reservation: Reservation): return reverse('machine_detail', - kwargs={'year': reservation.start_time.year, 'week': reservation.start_time.isocalendar()[1], - 'machine': reservation.machine}) + kwargs={'year': reservation.start_time.year, 'week': reservation.start_time.isocalendar()[1], 'pk': reservation.machine.pk}) @register.simple_tag def current_calendar_url(machine: Machine): current_time = timezone.localtime() return reverse('machine_detail', - kwargs={'year': current_time.year, 'week': current_time.isocalendar()[1], 'machine': machine}) + kwargs={'year': current_time.year, 'week': current_time.isocalendar()[1], 'pk': machine.pk}) @register.simple_tag def calendar_url_timestamp(machine: Machine, time: datetime): return reverse('machine_detail', - kwargs={'year': time.year, 'week': time.isocalendar()[1], 'machine': machine}) + kwargs={'year': time.year, 'week': time.isocalendar()[1], 'pk': machine.pk}) @register.simple_tag diff --git a/make_queue/tests/templatetags/test_reservation_extra.py b/make_queue/tests/templatetags/test_reservation_extra.py index 2a517cc4a..507a67afd 100644 --- a/make_queue/tests/templatetags/test_reservation_extra.py +++ b/make_queue/tests/templatetags/test_reservation_extra.py @@ -46,8 +46,8 @@ def test_current_calendar_url(self, now_mock): ) self.assertEqual( - reverse('machine_detail', kwargs={'year': 2017, 'week': 52, 'machine': printer}), - current_calendar_url(printer) + current_calendar_url(printer), + reverse('machine_detail', kwargs={'year': 2017, 'week': 52, 'pk': printer.pk}), ) @mock.patch('django.utils.timezone.now') diff --git a/make_queue/tests/test_urls.py b/make_queue/tests/test_urls.py index b21c9a2f6..a6c0702e1 100644 --- a/make_queue/tests/test_urls.py +++ b/make_queue/tests/test_urls.py @@ -112,21 +112,21 @@ def test_all_get_request_paths_succeed(self): ], # Back to urlpatterns - Get(reverse('machine_detail', kwargs={'year': year, 'week': week_number, 'machine': self.printer1}), public=True), + Get(reverse('machine_detail', kwargs={'year': year, 'week': week_number, 'pk': self.printer1.pk}), public=True), # calendar_urlpatterns *[ - Get(reverse('api_reservation_rules', kwargs={'machine': machine}), public=True) + Get(reverse('api_reservation_rules', args=[machine.pk]), public=True) for machine in self.machines ], # json_urlpatterns *[ - Get(reverse('reservation_json', kwargs={'machine': machine}), public=False) + Get(reverse('reservation_json', args=[machine.pk]), public=False) for machine in self.machines ], *[ - Get(reverse('reservation_json', kwargs={'machine': reservation.machine, 'reservation': reservation}), public=False) + Get(reverse('reservation_json', args=[reservation.machine.pk, reservation.pk]), public=False) for reservation in self.reservations ], Get(reverse('user_json', kwargs={'username': self.user1.username}), public=False), @@ -134,11 +134,11 @@ def test_all_get_request_paths_succeed(self): # Back to urlpatterns *[ - Get(reverse('create_reservation', kwargs={'machine': machine}), public=False) + Get(reverse('create_reservation', args=[machine.pk]), public=False) for machine in self.machines ], *[ - Get(reverse('edit_reservation', kwargs={'reservation': reservation}), public=False) + Get(reverse('edit_reservation', args=[reservation.pk]), public=False) for reservation in self.reservations if reservation != self.reservation2 # `reservation2` starts in the future ], Get(reverse('my_reservations_list'), public=False), @@ -146,18 +146,18 @@ def test_all_get_request_paths_succeed(self): Get(reverse('find_free_slot'), public=False), # rules_urlpatterns - Get(reverse('reservation_rule_list', kwargs={'machine_type': self.printer_machine_type}), public=True), - Get(reverse('reservation_rule_list', kwargs={'machine_type': self.sewing_machine_type}), public=True), - Get(reverse('create_reservation_rule', kwargs={'machine_type': self.printer_machine_type}), public=False), - Get(reverse('create_reservation_rule', kwargs={'machine_type': self.sewing_machine_type}), public=False), + Get(reverse('reservation_rule_list', args=[self.printer_machine_type.pk]), public=True), + Get(reverse('reservation_rule_list', args=[self.sewing_machine_type.pk]), public=True), + Get(reverse('create_reservation_rule', args=[self.printer_machine_type.pk]), public=False), + Get(reverse('create_reservation_rule', args=[self.sewing_machine_type.pk]), public=False), *[ - Get(reverse('edit_reservation_rule', kwargs={'machine_type': rule.machine_type, 'pk': rule.pk}), public=False) + Get(reverse('edit_reservation_rule', args=[rule.machine_type.pk, rule.pk]), public=False) for rule in self.rules ], - Get(reverse('machine_usage_rules_detail', kwargs={'machine_type': self.printer_machine_type}), public=True), - Get(reverse('machine_usage_rules_detail', kwargs={'machine_type': self.sewing_machine_type}), public=True), - Get(reverse('edit_machine_usage_rules', kwargs={'machine_type': self.printer_machine_type}), public=False), - Get(reverse('edit_machine_usage_rules', kwargs={'machine_type': self.sewing_machine_type}), public=False), + Get(reverse('machine_usage_rules_detail', args=[self.printer_machine_type.pk]), public=True), + Get(reverse('machine_usage_rules_detail', args=[self.sewing_machine_type.pk]), public=True), + Get(reverse('edit_machine_usage_rules', args=[self.printer_machine_type.pk]), public=False), + Get(reverse('edit_machine_usage_rules', args=[self.sewing_machine_type.pk]), public=False), # quota_urlpatterns Get(reverse('quota_panel'), public=False), @@ -166,10 +166,10 @@ def test_all_get_request_paths_succeed(self): Get(reverse('edit_quota', kwargs={'pk': quota.pk}), public=False) for quota in self.quotas ], - Get(reverse('user_quota_list', kwargs={'user': self.user1}), public=False), - Get(reverse('user_quota_list', kwargs={'user': self.user2}), public=False), - Get(reverse('quota_panel', kwargs={'user': self.user1}), public=False), - Get(reverse('quota_panel', kwargs={'user': self.user2}), public=False), + Get(reverse('user_quota_list', args=[self.user1.pk]), public=False), + Get(reverse('user_quota_list', args=[self.user2.pk]), public=False), + Get(reverse('quota_panel', args=[self.user1.pk]), public=False), + Get(reverse('quota_panel', args=[self.user2.pk]), public=False), # course_urlpatterns Get(reverse('course_registration_list'), public=False), diff --git a/make_queue/tests/utility.py b/make_queue/tests/utility.py index 4bfbec8b4..bc1f2ad1c 100644 --- a/make_queue/tests/utility.py +++ b/make_queue/tests/utility.py @@ -1,12 +1,6 @@ from django.test import RequestFactory -def template_view_get_context_data(view_class, *args, request_user=None, **kwargs): - view = view_class() - view.request = request_with_user(request_user) - return view.get_context_data(*args, **kwargs) - - def request_with_user(user): request = RequestFactory().get("/ignored_path") request.user = user diff --git a/make_queue/tests/views/test_quota_views.py b/make_queue/tests/views/test_quota_views.py index 2752ea7a5..5daad0e91 100644 --- a/make_queue/tests/views/test_quota_views.py +++ b/make_queue/tests/views/test_quota_views.py @@ -1,8 +1,9 @@ -from django.test import TestCase +from typing import Optional + +from django.test import Client, TestCase from django_hosts import reverse from users.models import User -from ..utility import template_view_get_context_data from ...models.machine import MachineType from ...models.reservation import Quota from ...views.admin.quota import QuotaPanelView @@ -20,7 +21,7 @@ def test_get_user_quota(self): Quota.objects.create(user=user2, machine_type=machine_type, number_of_reservations=2) self.client.force_login(user) - context_data = self.client.get(reverse('user_quota_list', args=[user])).context + context_data = self.client.get(reverse('user_quota_list', args=[user.pk])).context self.assertListEqual(list(context_data['user_quotas']), [quota2]) @@ -39,14 +40,18 @@ def setUp(self): self.quota4 = Quota.objects.create(user=self.user2, machine_type=self.sewing_machine_type, number_of_reservations=1) - def test_without_user(self): - context_data = template_view_get_context_data(QuotaPanelView, request_user=self.user) - self.assertListEqual(list(context_data["users"]), [self.user, self.user2]) - self.assertListEqual(list(context_data["global_quotas"]), [self.quota1, self.quota2]) - self.assertEqual(context_data["requested_user"], None) - - def test_with_user(self): - context_data = template_view_get_context_data(QuotaPanelView, request_user=self.user, user=self.user) - self.assertListEqual(list(context_data["users"]), [self.user, self.user2]) - self.assertListEqual(list(context_data["global_quotas"]), [self.quota1, self.quota2]) - self.assertEqual(context_data["requested_user"], self.user) + self.superuser = User.objects.create_user("superuser", is_superuser=True) + self.superuser_client = Client() + self.superuser_client.force_login(self.superuser) + + def test_quota_panel_responds_with_expected_context(self): + def assert_response_contains_expected_context(url: str, expected_requested_user: Optional[User]): + response = self.superuser_client.get(url) + context = response.context + self.assertIsInstance(context['view'], QuotaPanelView) + self.assertListEqual(list(context['users']), [self.user, self.user2, self.superuser]) + self.assertListEqual(list(context['global_quotas']), [self.quota1, self.quota2]) + self.assertEqual(context['requested_user'], expected_requested_user) + + assert_response_contains_expected_context(reverse('quota_panel'), None) + assert_response_contains_expected_context(reverse('quota_panel', args=[self.user.pk]), self.user) diff --git a/make_queue/tests/views/test_reservation_reservation.py b/make_queue/tests/views/test_reservation_reservation.py index 3561f168b..ee221265c 100644 --- a/make_queue/tests/views/test_reservation_reservation.py +++ b/make_queue/tests/views/test_reservation_reservation.py @@ -1,6 +1,7 @@ from abc import ABC from datetime import timedelta from http import HTTPStatus +from typing import Type, Union from unittest.mock import patch from django.http import HttpResponse @@ -18,7 +19,7 @@ from ...models.machine import Machine, MachineType from ...models.reservation import Quota, Reservation, ReservationRule from ...views.admin.reservation import MAKEReservationsListView -from ...views.reservation.reservation import CreateOrEditReservationView, CreateReservationView, EditReservationView +from ...views.reservation.reservation import CreateReservationView, EditReservationView Day = ReservationRule.Day @@ -39,16 +40,16 @@ def setUp(self): start_time=timezone.localtime() + timedelta(hours=1), end_time=timezone.localtime() + timedelta(hours=2)) - def get_view(self): - view = CreateOrEditReservationView() - view.request = request_with_user(self.user) + def get_view(self, view_class: Union[Type[CreateReservationView], Type[EditReservationView]], **kwargs): + view = view_class() + view.setup(request_with_user(self.user), **kwargs) return view def create_form(self, *, start_time_diff, end_time_diff, event=None, special=False, special_text=""): return ReservationForm(data=self.create_form_data(start_time_diff, end_time_diff, event, special, special_text)) def create_form_data(self, start_time_diff, end_time_diff, event=None, special=False, special_text=""): - now = timezone.localtime() + now = timezone.now() return { 'start_time': iso_datetime_format(now + timedelta(hours=start_time_diff)), 'end_time': iso_datetime_format(now + timedelta(hours=end_time_diff)), @@ -60,7 +61,6 @@ def create_form_data(self, start_time_diff, end_time_diff, event=None, special=F class TestCreateOrEditReservationView(CreateOrEditReservationViewTestBase): def test_get_error_message_non_event(self): - view = self.get_view() form = self.create_form(start_time_diff=1, end_time_diff=2) self.assertTrue(form.is_valid()) reservation = Reservation( @@ -68,12 +68,12 @@ def test_get_error_message_non_event(self): start_time=form.cleaned_data["start_time"], end_time=form.cleaned_data["end_time"], ) + view = self.get_view(CreateReservationView, pk=self.machine.pk) self.assertEqual(view.get_error_message(form, reservation), "Det er ikke mulig å reservere maskinen på dette tidspunktet. Sjekk reglene for hvilke " "perioder det er mulig å reservere maskinen i") def test_get_error_message_event(self): - view = self.get_view() form = self.create_form(start_time_diff=1, end_time_diff=2, event=self.event) self.assertTrue(form.is_valid()) reservation = Reservation( @@ -82,11 +82,11 @@ def test_get_error_message_event(self): end_time=form.cleaned_data["end_time"], ) self.user.add_perms('make_queue.can_create_event_reservation') + view = self.get_view(CreateReservationView, pk=self.machine.pk) self.assertEqual(view.get_error_message(form, reservation), "Tidspunktet eller arrangementet er ikke lenger tilgjengelig") def test_get_error_message_too_far_in_the_future(self): - view = self.get_view() form = self.create_form(start_time_diff=24 * 7, end_time_diff=24 * 7 + 1) self.assertTrue(form.is_valid()) reservation = Reservation( @@ -94,11 +94,11 @@ def test_get_error_message_too_far_in_the_future(self): start_time=form.cleaned_data["start_time"], end_time=form.cleaned_data["end_time"], ) + view = self.get_view(CreateReservationView, pk=self.machine.pk) self.assertEqual(view.get_error_message(form, reservation), "Reservasjoner kan bare lages 7 dager fram i tid") def test_get_error_message_machine_out_of_order(self): - view = self.get_view() form = self.create_form(start_time_diff=1, end_time_diff=2) self.assertTrue(form.is_valid()) machine = Machine.objects.create(machine_model="Test", machine_type=self.sewing_machine_type, @@ -108,11 +108,11 @@ def test_get_error_message_machine_out_of_order(self): start_time=form.cleaned_data["start_time"], end_time=form.cleaned_data["end_time"], ) + view = self.get_view(CreateReservationView, pk=machine.pk) self.assertEqual(view.get_error_message(form, reservation), "Maskinen er i ustand") def test_validate_and_save_valid_reservation(self): - view = self.get_view() form = self.create_form(start_time_diff=1, end_time_diff=2) self.assertTrue(form.is_valid()) reservation = Reservation( @@ -120,15 +120,12 @@ def test_validate_and_save_valid_reservation(self): start_time=form.cleaned_data["start_time"], end_time=form.cleaned_data["end_time"], ) + view = self.get_view(CreateReservationView, pk=self.machine.pk) response = view.validate_and_save(reservation, form) self.assertEqual(Reservation.objects.count(), 1) self.assertEqual(response.status_code, HTTPStatus.FOUND) def test_validate_and_save_non_valid_reservation(self): - view = self.get_view() - # Set values to allow for context to be created - view.new_reservation = False - form = self.create_form(start_time_diff=1, end_time_diff=2) self.assertTrue(form.is_valid()) # Test with collision @@ -142,6 +139,7 @@ def test_validate_and_save_non_valid_reservation(self): start_time=form.cleaned_data["start_time"], end_time=form.cleaned_data["end_time"], ) + view = self.get_view(CreateReservationView, pk=self.machine.pk) response = view.validate_and_save(reservation, form) # Second reservation should not be saved self.assertEqual(Reservation.objects.count(), 1) @@ -149,8 +147,6 @@ def test_validate_and_save_non_valid_reservation(self): self.assertEqual(response.status_code, HTTPStatus.OK) def test_get_context_data_reservation(self): - view = self.get_view() - view.new_reservation = False self.user.add_perms('make_queue.can_create_event_reservation') now = timezone.localtime() reservation = Reservation.objects.create( @@ -159,7 +155,8 @@ def test_get_context_data_reservation(self): end_time=now + timedelta(hours=2), event=self.timeplace, comment="Comment", ) - context_data = view.get_context_data(reservation=reservation) + view = self.get_view(EditReservationView, reservation_pk=reservation.pk) + context_data = view.get_context_data(reservation_pk=reservation.pk) context_data["machine_types"] = set(context_data["machine_types"]) self.assertDictEqual(context_data, { @@ -175,10 +172,9 @@ def test_get_context_data_reservation(self): }) def test_get_context_data_non_reservation(self): - view = self.get_view() - view.new_reservation = True start_time = timezone.localtime() + timedelta(hours=1) - context_data = view.get_context_data(machine=self.machine, start_time=start_time) + view = self.get_view(CreateReservationView, pk=self.machine.pk) + context_data = view.get_context_data(machine_pk=self.machine.pk, start_time=start_time) context_data["machine_types"] = set(context_data["machine_types"]) self.assertDictEqual(context_data, { @@ -193,10 +189,8 @@ def test_get_context_data_non_reservation(self): }) def test_post_valid_form(self): - view = self.get_view() + view = self.get_view(CreateReservationView, pk=self.machine.pk) view.request = post_request_with_user(self.user, data=self.create_form_data(1, 2)) - # Set values to allow for context to be created - view.new_reservation = False # Need to handle the valid form function valid_form_calls = {"calls": 0} view.form_valid = lambda _form, **_kwargs: ( @@ -212,36 +206,36 @@ def test_post_valid_form(self): class TestCreateReservationView(CreateOrEditReservationViewTestBase): - def get_view(self): - view = CreateReservationView() - view.request = request_with_user(self.user) - return view + def get_view(self, view_class=CreateReservationView, **kwargs): + return super().get_view(view_class, **{ + 'pk': self.machine.pk, + **kwargs, + }) def test_form_valid_normal_reservation(self): - view = self.get_view() form = self.create_form(start_time_diff=1, end_time_diff=2) self.assertTrue(form.is_valid()) + view = self.get_view() view.form_valid(form) self.assertEqual(Machine.objects.count(), 1) def test_form_valid_event_reservation(self): - view = self.get_view() form = self.create_form(start_time_diff=1, end_time_diff=2, event=self.timeplace) self.assertTrue(form.is_valid()) self.user.add_perms('make_queue.can_create_event_reservation') + view = self.get_view() view.form_valid(form) self.assertEqual(Machine.objects.count(), 1) def test_form_valid_special_reservation(self): - view = self.get_view() form = self.create_form(start_time_diff=1, end_time_diff=2, special=True, special_text="Test special") self.assertTrue(form.is_valid()) self.user.add_perms('make_queue.can_create_event_reservation') + view = self.get_view() view.form_valid(form) self.assertEqual(Machine.objects.count(), 1) def test_form_valid_invalid_reservation(self): - view = self.get_view() form = self.create_form(start_time_diff=1, end_time_diff=2) self.assertTrue(form.is_valid()) Reservation.objects.create( @@ -249,6 +243,7 @@ def test_form_valid_invalid_reservation(self): start_time=form.cleaned_data["start_time"], end_time=form.cleaned_data["end_time"], ) + view = self.get_view() response = view.form_valid(form) # Second reservation should not have been saved self.assertEqual(Machine.objects.count(), 1) @@ -258,10 +253,8 @@ def test_form_valid_invalid_reservation(self): class TestEditReservationView(CreateOrEditReservationViewTestBase): - def get_view(self): - view = EditReservationView() - view.request = request_with_user(self.user) - return view + def get_view(self, view_class=EditReservationView, **kwargs): + return super().get_view(view_class, **kwargs) def create_reservation(self, form): self.assertTrue(form.is_valid()) @@ -272,10 +265,10 @@ def create_reservation(self, form): ) def test_post_changeable_reservation(self): - view = self.get_view() - view.request.method = "POST" reservation = self.create_reservation(self.create_form(start_time_diff=1, end_time_diff=2)) - response = view.dispatch(view.request, reservation=reservation) + view = self.get_view(reservation_pk=reservation.pk) + view.request.method = 'POST' + response = view.dispatch(view.request, reservation_pk=reservation.pk) # Response should be the edit page for the reservation, as no form is posted with the data self.assertEqual(response.status_code, HTTPStatus.OK) self.assertListEqual(response.template_name, ['make_queue/reservation_edit.html']) @@ -284,12 +277,13 @@ def test_post_changeable_reservation(self): def test_post_unchangeable_reservation(self, now_mock): now_mock.return_value = parse_datetime_localized("2018-08-12 12:00") - view = self.get_view() - view.request.method = "POST" reservation = self.create_reservation(self.create_form(start_time_diff=1, end_time_diff=2)) now_mock.return_value = timezone.localtime() + timedelta(hours=2, minutes=1) - response = view.dispatch(view.request, reservation=reservation) + + view = self.get_view(reservation_pk=reservation.pk) + view.request.method = 'POST' + response = view.dispatch(view.request, reservation_pk=reservation.pk) # An unchangeable reservation should have redirect self.assertEqual(response.status_code, HTTPStatus.FOUND) @@ -297,11 +291,11 @@ def test_post_unchangeable_reservation(self, now_mock): def test_form_valid_normal_reservation(self, now_mock): now_mock.return_value = parse_datetime_localized("2018-08-12 12:00") - view = self.get_view() reservation = self.create_reservation(self.create_form(start_time_diff=1, end_time_diff=2)) form = self.create_form(start_time_diff=1, end_time_diff=3) self.assertTrue(form.is_valid()) - response = view.form_valid(form, reservation=reservation) + view = self.get_view(reservation_pk=reservation.pk) + response = view.form_valid(form, reservation_pk=reservation.pk) self.assertEqual(Reservation.objects.count(), 1) self.assertEqual(response.status_code, HTTPStatus.FOUND) self.assertEqual(Reservation.objects.first().end_time, timezone.localtime() + timedelta(hours=3)) @@ -310,13 +304,13 @@ def test_form_valid_normal_reservation(self, now_mock): def test_form_valid_changed_machine(self, now_mock): now_mock.return_value = parse_datetime_localized("2018-08-12 12:00") - view = self.get_view() reservation = self.create_reservation(self.create_form(start_time_diff=1, end_time_diff=2)) old_machine = self.machine self.machine = Machine.objects.create(name="M1", machine_model="Generic", machine_type=self.sewing_machine_type) form = self.create_form(start_time_diff=1, end_time_diff=3) self.assertTrue(form.is_valid()) - response = view.form_valid(form, reservation=reservation) + view = self.get_view(reservation_pk=reservation.pk) + response = view.form_valid(form, reservation_pk=reservation.pk) self.assertEqual(response.status_code, HTTPStatus.FOUND) self.assertEqual(Reservation.objects.count(), 1) self.assertEqual(Reservation.objects.first().end_time, timezone.localtime() + timedelta(hours=2)) @@ -327,7 +321,6 @@ def test_form_valid_event_reservation(self, now_mock): now_mock.return_value = parse_datetime_localized("2018-08-12 12:00") self.user.add_perms('make_queue.can_create_event_reservation') - view = self.get_view() now = timezone.localtime() reservation = Reservation.objects.create( machine=self.machine, user=self.user, @@ -340,7 +333,8 @@ def test_form_valid_event_reservation(self, now_mock): end_time=now + timedelta(hours=2)) form = self.create_form(start_time_diff=1, end_time_diff=2, event=self.timeplace) self.assertTrue(form.is_valid()) - response = view.form_valid(form, reservation=reservation) + view = self.get_view(reservation_pk=reservation.pk) + response = view.form_valid(form, reservation_pk=reservation.pk) self.assertEqual(Reservation.objects.count(), 1) self.assertEqual(response.status_code, HTTPStatus.FOUND) self.assertEqual(Reservation.objects.first().event, self.timeplace) @@ -350,7 +344,6 @@ def test_form_valid_special_reservation(self, now_mock): now_mock.return_value = parse_datetime_localized("2018-08-12 12:00") self.user.add_perms('make_queue.can_create_event_reservation') - view = self.get_view() now = timezone.localtime() reservation = Reservation.objects.create( machine=self.machine, user=self.user, @@ -360,7 +353,8 @@ def test_form_valid_special_reservation(self, now_mock): ) form = self.create_form(start_time_diff=1, end_time_diff=2, special=True, special_text="Test2") self.assertTrue(form.is_valid()) - response = view.form_valid(form, reservation=reservation) + view = self.get_view(reservation_pk=reservation.pk) + response = view.form_valid(form, reservation_pk=reservation.pk) self.assertEqual(Reservation.objects.count(), 1) self.assertEqual(response.status_code, HTTPStatus.FOUND) self.assertEqual(Reservation.objects.first().special_text, "Test2") diff --git a/make_queue/urls.py b/make_queue/urls.py index 44f851960..1045f654e 100644 --- a/make_queue/urls.py +++ b/make_queue/urls.py @@ -1,15 +1,10 @@ from django.contrib.auth.decorators import login_required, permission_required from django.urls import include, path, register_converter -from users import converters as user_converters from . import converters from .views import admin, api, quota, reservation -register_converter(converters.SpecificMachineType, 'MachineType') -register_converter(converters.SpecificMachine, 'Machine') -register_converter(converters.SpecificReservation, 'Reservation') -register_converter(user_converters.SpecificUser, 'User') register_converter(converters.Year, 'year') register_converter(converters.Week, 'week') @@ -21,21 +16,21 @@ ] calendar_urlpatterns = [ - path("/reservations/", api.calendar.get_reservations, name='api_reservations'), - path("/rules/", api.calendar.get_reservation_rules, name='api_reservation_rules'), + path("/reservations/", api.calendar.get_reservations, name='api_reservations'), + path("/rules/", api.calendar.get_reservation_rules, name='api_reservation_rules'), ] json_urlpatterns = [ - path("/", login_required(api.reservation.get_machine_data), name='reservation_json'), - path("//", login_required(api.reservation.get_machine_data), name='reservation_json'), + path("/", login_required(api.reservation.get_machine_data), name='reservation_json'), + path("//", login_required(api.reservation.get_machine_data), name='reservation_json'), path("/", permission_required('make_queue.add_printer3dcourse')(api.user_info.get_user_info_from_username), name='user_json'), ] rules_urlpatterns = [ path("", reservation.rules.ReservationRuleListView.as_view(), name='reservation_rule_list'), path("create/", reservation.rules.CreateReservationRuleView.as_view(), name='create_reservation_rule'), - path("/edit/", reservation.rules.EditReservationRuleView.as_view(), name='edit_reservation_rule'), - path("/delete/", reservation.rules.DeleteReservationRuleView.as_view(), name='delete_reservation_rule'), + path("/edit/", reservation.rules.EditReservationRuleView.as_view(), name='edit_reservation_rule'), + path("/delete/", reservation.rules.DeleteReservationRuleView.as_view(), name='delete_reservation_rule'), path("usage/", reservation.rules.MachineUsageRulesDetailView.as_view(), name='machine_usage_rules_detail'), path("usage/edit/", reservation.rules.EditUsageRulesView.as_view(), name='edit_machine_usage_rules'), ] @@ -49,9 +44,9 @@ path("create/", permission_required('make_queue.add_quota')(admin.quota.CreateQuotaView.as_view()), name='create_quota'), path("/update/", permission_required('make_queue.change_quota')(admin.quota.EditQuotaView.as_view()), name='edit_quota'), path("/delete/", permission_required('make_queue.delete_quota')(admin.quota.DeleteQuotaView.as_view()), name='delete_quota'), - path("user//", permission_required('make_queue.change_quota', raise_exception=True)(quota.user.UserQuotaListView.as_view()), + path("user//", permission_required('make_queue.change_quota', raise_exception=True)(quota.user.UserQuotaListView.as_view()), name='user_quota_list'), - path("/", permission_required('make_queue.change_quota', raise_exception=True)(admin.quota.QuotaPanelView.as_view()), + path("/", permission_required('make_queue.change_quota', raise_exception=True)(admin.quota.QuotaPanelView.as_view()), name='quota_panel'), ] @@ -74,11 +69,11 @@ urlpatterns = [ path("", reservation.machine.MachineListView.as_view(), name='machine_list'), path("machine/", include(machine_urlpatterns)), - path("///", reservation.calendar.MachineDetailView.as_view(), name='machine_detail'), + path("///", reservation.calendar.MachineDetailView.as_view(), name='machine_detail'), path("calendar/", include(calendar_urlpatterns)), path("json/", include(json_urlpatterns)), - path("create//", login_required(reservation.reservation.CreateReservationView.as_view()), name='create_reservation'), - path("/edit/", login_required(reservation.reservation.EditReservationView.as_view()), name='edit_reservation'), + path("create//", login_required(reservation.reservation.CreateReservationView.as_view()), name='create_reservation'), + path("/edit/", login_required(reservation.reservation.EditReservationView.as_view()), name='edit_reservation'), path("/finish/", login_required(reservation.reservation.MarkReservationFinishedView.as_view()), name='mark_reservation_finished'), path("/", login_required(reservation.reservation.DeleteReservationView.as_view()), name='delete_reservation'), path("me/", reservation.reservation.MyReservationsListView.as_view(), name='my_reservations_list'), @@ -86,7 +81,7 @@ permission_required('make_queue.can_create_event_reservation', raise_exception=True)(admin.reservation.MAKEReservationsListView.as_view()), name='MAKE_reservations_list'), path("slot/", reservation.reservation.FindFreeSlotView.as_view(), name='find_free_slot'), - path("machinetypes//", include(specific_machinetype_urlpatterns)), + path("machinetypes//", include(specific_machinetype_urlpatterns)), path("quota/", include(quota_urlpatterns)), path("course/", include(course_urlpatterns)), ] diff --git a/make_queue/views/admin/quota.py b/make_queue/views/admin/quota.py index 4856fce21..945a17ac3 100644 --- a/make_queue/views/admin/quota.py +++ b/make_queue/views/admin/quota.py @@ -1,6 +1,8 @@ from abc import ABC +from typing import Optional from django.contrib.auth.mixins import PermissionRequiredMixin +from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.generic import CreateView, DeleteView, TemplateView, UpdateView @@ -16,7 +18,17 @@ class QuotaPanelView(TemplateView): """View for the quota admin panel that allows users to control the quotas of people.""" template_name = 'make_queue/quota/quota_panel.html' - def get_context_data(self, user=None, **kwargs): + user: Optional[User] + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + if 'pk' in self.kwargs: + user_pk = self.kwargs['pk'] + self.user = get_object_or_404(User, pk=user_pk) + else: + self.user = None + + def get_context_data(self, **kwargs): """ Creates the required context for the quota panel. @@ -25,7 +37,7 @@ def get_context_data(self, user=None, **kwargs): return super().get_context_data(**{ 'users': User.objects.all(), 'global_quotas': Quota.objects.filter(all=True), - 'requested_user': user, + 'requested_user': self.user, **kwargs, }) @@ -52,7 +64,7 @@ def get_success_url(self): if self.object.all: return reverse('quota_panel') else: - return reverse('quota_panel', kwargs={'user': self.object.user}) + return reverse('quota_panel', args=[self.object.user.pk]) class CreateQuotaView(PermissionRequiredMixin, QuotaFormMixin, CreateView): @@ -87,4 +99,4 @@ def get_success_url(self): if self.object.all: return reverse('quota_panel') else: - return reverse('quota_panel', kwargs={'user': self.object.user}) + return reverse('quota_panel', args=[self.object.user.pk]) diff --git a/make_queue/views/api/calendar.py b/make_queue/views/api/calendar.py index 239e87943..ca4e93c6d 100644 --- a/make_queue/views/api/calendar.py +++ b/make_queue/views/api/calendar.py @@ -1,4 +1,5 @@ from django.http import JsonResponse +from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.dateparse import parse_datetime @@ -15,7 +16,8 @@ def reservation_type(reservation, user): return "normal" -def get_reservations(request, machine: Machine): +def get_reservations(request, pk: int): + machine = get_object_or_404(Machine, pk=pk) start_date = parse_datetime(request.GET.get("startDate")) end_date = parse_datetime(request.GET.get("endDate")) @@ -48,7 +50,8 @@ def get_reservations(request, machine: Machine): return JsonResponse({"reservations": reservations}) -def get_reservation_rules(request, machine: Machine): +def get_reservation_rules(request, pk: int): + machine = get_object_or_404(Machine, pk=pk) return JsonResponse({ "rules": [ { diff --git a/make_queue/views/api/reservation.py b/make_queue/views/api/reservation.py index 8525fa43c..04f05ad47 100644 --- a/make_queue/views/api/reservation.py +++ b/make_queue/views/api/reservation.py @@ -1,11 +1,14 @@ from django.http import JsonResponse +from django.shortcuts import get_object_or_404 from django.utils import timezone from ...models.machine import Machine from ...models.reservation import Quota -def get_machine_data(request, machine: Machine, reservation=None): +def get_machine_data(request, pk: int, reservation_pk: int = None): + machine = get_object_or_404(Machine, pk=pk) + reservation = get_object_or_404(machine.reservations, pk=reservation_pk) if reservation_pk is not None else None return JsonResponse({ "reservations": [ {"start_time": c_reservation.start_time, "end_time": c_reservation.end_time} diff --git a/make_queue/views/quota/user.py b/make_queue/views/quota/user.py index 6759739f5..e4e4400be 100644 --- a/make_queue/views/quota/user.py +++ b/make_queue/views/quota/user.py @@ -1,3 +1,4 @@ +from django.shortcuts import get_object_or_404 from django.views.generic import ListView from users.models import User @@ -10,6 +11,12 @@ class UserQuotaListView(ListView): template_name = 'make_queue/quota/quota_user.html' context_object_name = 'user_quotas' + user: User + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + user_pk = self.kwargs['pk'] + self.user = get_object_or_404(User, pk=user_pk) + def get_queryset(self): - user: User = self.kwargs['user'] - return user.quotas.filter(all=False) + return self.user.quotas.filter(all=False) diff --git a/make_queue/views/reservation/calendar.py b/make_queue/views/reservation/calendar.py index cb65e7b9b..8e6f22ade 100644 --- a/make_queue/views/reservation/calendar.py +++ b/make_queue/views/reservation/calendar.py @@ -1,4 +1,4 @@ -from django.views.generic import TemplateView +from django.views.generic import DetailView from util.locale_utils import year_and_week_to_monday from ...models.machine import Machine @@ -6,25 +6,27 @@ from ...templatetags.reservation_extra import reservation_denied_message -class MachineDetailView(TemplateView): +class MachineDetailView(DetailView): """Main view for showing the reservation calendar for a machine.""" + model = Machine template_name = 'make_queue/machine_detail.html' + context_object_name = 'machine' - def get_context_data(self, year, week, machine): + def get_context_data(self, **kwargs): """ Create the context required for the controls and the information to be displayed. - :param year: The year to show the calendar for - :param week: The week to show the calendar for - :param machine: The machine object to show the calendar for :return: context required to show the reservation calendar with controls """ - context = super().get_context_data() + context = super().get_context_data(**kwargs) + year = self.kwargs['year'] + week = self.kwargs['week'] + machine = self.object + context.update({ 'reservation_denied_message': reservation_denied_message(self.request.user, machine), 'can_ignore_rules': False, 'other_machines': Machine.objects.exclude(pk=machine.pk).filter(machine_type=machine.machine_type).default_order_by(), - 'machine': machine, 'year': year, 'week': week, 'date': year_and_week_to_monday(year, week), diff --git a/make_queue/views/reservation/reservation.py b/make_queue/views/reservation/reservation.py index 4197d142b..6bbd3c36b 100644 --- a/make_queue/views/reservation/reservation.py +++ b/make_queue/views/reservation/reservation.py @@ -6,7 +6,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.db.models import Q from django.http import HttpResponse, JsonResponse -from django.shortcuts import redirect, render +from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.utils.translation import gettext_lazy as _, ngettext from django.views.generic import DeleteView, FormView, ListView, TemplateView, UpdateView @@ -21,6 +21,8 @@ from ...templatetags.reservation_extra import calendar_url_reservation, can_delete_reservation, can_mark_reservation_finished +# TODO: rewrite this whole view (and everything that uses it), +# so that it's more extendable, and makes more use of the functionality of forms and Django's `CreateView` and `UpdateView` class CreateOrEditReservationView(TemplateView, ABC): """Base abstract class for the reservation create or change view.""" @@ -70,7 +72,9 @@ def validate_and_save(self, reservation, form): the reservation cannot be validated """ if not reservation.validate(): - context_data = self.get_context_data(reservation=reservation) + # Hack to "simulate" `EditReservationView` + self.reservation = reservation + context_data = self.get_context_data(reservation_pk=reservation.pk) context_data["error"] = self.get_error_message(form, reservation) return render(self.request, self.template_name, context_data) @@ -100,8 +104,9 @@ def get_context_data(self, **kwargs): } # If we are given a reservation, populate the information relevant to that reservation - if "reservation" in kwargs: - reservation = kwargs["reservation"] + if 'reservation_pk' in kwargs: + # noinspection PyUnresolvedReferences + reservation = self.reservation # defined in `EditReservationView` context_data["start_time"] = reservation.start_time context_data["reservation_pk"] = reservation.pk context_data["end_time"] = reservation.end_time @@ -113,7 +118,13 @@ def get_context_data(self, **kwargs): context_data["can_change_start_time"] = reservation.can_change(self.request.user) # Otherwise populate with default information given to the view else: - context_data["selected_machine"] = kwargs["machine"] + if hasattr(self, 'machine'): + # Set in `CreateReservationView` + selected_machine = self.machine + else: + # `machine_pk` is only set in `test_get_context_data_non_reservation()` 🙃🔥 + selected_machine = get_object_or_404(Machine, pk=kwargs['machine_pk']) + context_data["selected_machine"] = selected_machine if "start_time" in kwargs: context_data["start_time"] = kwargs["start_time"] context_data["can_change_start_time"] = True @@ -147,12 +158,27 @@ def handle_post(self, request, **kwargs): return self.get(request, **kwargs) -class CreateReservationView(PermissionRequiredMixin, CreateOrEditReservationView): +# noinspection PyUnresolvedReferences +class MachineRelatedViewMixin: + """ + Note: When extending this mixin class, it's required to have an ``int`` path converter named ``pk`` as part of the view's path, + which will be used to query the database for the machine that the object(s) are related to. + If found, the machine will be assigned to a ``machine`` field on the view, otherwise, a 404 error will be raised. + """ + machine: Machine + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + machine_pk = self.kwargs['pk'] + self.machine = get_object_or_404(Machine, pk=machine_pk) + + +class CreateReservationView(PermissionRequiredMixin, MachineRelatedViewMixin, CreateOrEditReservationView): """View for creating a new reservation.""" new_reservation = True def has_permission(self): - return self.kwargs['machine'].can_user_use(self.request.user) + return self.machine.can_user_use(self.request.user) def form_valid(self, form, **kwargs): """ @@ -208,14 +234,21 @@ class EditReservationView(CreateOrEditReservationView): """View for changing a reservation (Cannot be UpdateView due to the abstract inheritance of reservations).""" new_reservation = False + reservation: Reservation + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + reservation_pk = self.kwargs['reservation_pk'] + self.reservation = get_object_or_404(Reservation, pk=reservation_pk) + def dispatch(self, request, *args, **kwargs): """ Redirects the user to its reservation page if the given reservation cannot be changed. :param request: The HTTP request """ + reservation = self.reservation # User must be able to change the given reservation - reservation = kwargs["reservation"] if reservation.can_change(request.user) or reservation.can_change_end_time(request.user): return super().dispatch(request, *args, **kwargs) else: @@ -228,7 +261,7 @@ def form_valid(self, form, **kwargs): :param form: The valid form :return: HTTP Response """ - reservation = kwargs["reservation"] + reservation = self.reservation # The user is not allowed to change the machine for a reservation if reservation.machine != form.cleaned_data["machine"]: return redirect('my_reservations_list') diff --git a/make_queue/views/reservation/rules.py b/make_queue/views/reservation/rules.py index 0fd6b0eda..45b36af1e 100644 --- a/make_queue/views/reservation/rules.py +++ b/make_queue/views/reservation/rules.py @@ -1,11 +1,10 @@ from abc import ABC from django.contrib.auth.mixins import PermissionRequiredMixin +from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django.views import View from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView -from django.views.generic.base import ContextMixin from django.views.generic.edit import ModelFormMixin from util.view_utils import CustomFieldsetFormMixin, PreventGetRequestsMixin, insert_form_field_values @@ -14,12 +13,19 @@ from ...models.reservation import Quota, ReservationRule -class MachineTypeBasedView(ContextMixin, View, ABC): +# noinspection PyUnresolvedReferences +class MachineTypeRelatedViewMixin: + """ + Note: When extending this mixin class, it's required to have an ``int`` path converter named ``pk`` as part of the view's path, + which will be used to query the database for the machine type that the object(s) are related to. + If found, the machine type will be assigned to a ``machine_type`` field on the view, otherwise, a 404 error will be raised. + """ machine_type: MachineType def setup(self, request, *args, **kwargs): super().setup(request, *args, **kwargs) - self.machine_type = kwargs['machine_type'] + machine_type_pk = self.kwargs['pk'] + self.machine_type = get_object_or_404(MachineType, pk=machine_type_pk) def get_context_data(self, **kwargs): return super().get_context_data(**{ @@ -28,7 +34,7 @@ def get_context_data(self, **kwargs): }) -class ReservationRuleListView(MachineTypeBasedView, ListView): +class ReservationRuleListView(MachineTypeRelatedViewMixin, ListView): model = ReservationRule template_name = 'make_queue/rule_list.html' context_object_name = 'rules' @@ -50,7 +56,7 @@ def get_context_data(self, **kwargs): return context_data -class BaseReservationRuleEditView(MachineTypeBasedView, CustomFieldsetFormMixin, ModelFormMixin, ABC): +class BaseReservationRuleEditView(MachineTypeRelatedViewMixin, CustomFieldsetFormMixin, ModelFormMixin, ABC): model = ReservationRule form_class = ReservationRuleForm @@ -75,7 +81,7 @@ def get_back_button_text(self): return _("Reservation rules for {machine_type}").format(machine_type=self.machine_type) def get_success_url(self): - return reverse('reservation_rule_list', args=[self.machine_type]) + return reverse('reservation_rule_list', args=[self.machine_type.pk]) class CreateReservationRuleView(PermissionRequiredMixin, BaseReservationRuleEditView, CreateView): @@ -87,6 +93,7 @@ def get_form_title(self): class EditReservationRuleView(PermissionRequiredMixin, BaseReservationRuleEditView, UpdateView): permission_required = ('make_queue.change_reservation_rule',) + pk_url_kwarg = 'reservation_rule_pk' def get_form_title(self): return _("Rule for {machine_type}").format(machine_type=self.machine_type) @@ -95,12 +102,13 @@ def get_form_title(self): class DeleteReservationRuleView(PermissionRequiredMixin, PreventGetRequestsMixin, DeleteView): permission_required = ('make_queue.delete_reservation_rule',) model = ReservationRule + pk_url_kwarg = 'reservation_rule_pk' def get_success_url(self): - return reverse('reservation_rule_list', args=[self.object.machine_type]) + return reverse('reservation_rule_list', args=[self.object.machine_type.pk]) -class MachineUsageRulesDetailView(MachineTypeBasedView, DetailView): +class MachineUsageRulesDetailView(MachineTypeRelatedViewMixin, DetailView): model = MachineUsageRule template_name = 'make_queue/usage_rules_detail.html' context_object_name = 'usage_rules' @@ -118,7 +126,7 @@ def get_context_data(self, **kwargs): }) -class EditUsageRulesView(PermissionRequiredMixin, CustomFieldsetFormMixin, MachineTypeBasedView, UpdateView): +class EditUsageRulesView(PermissionRequiredMixin, CustomFieldsetFormMixin, MachineTypeRelatedViewMixin, UpdateView): permission_required = ('make_queue.change_machineusagerule',) model = MachineUsageRule fields = ('content',) @@ -139,4 +147,4 @@ def get_back_button_text(self): return _("View usage rules for {machine_type}").format(machine_type=self.machine_type) def get_success_url(self): - return reverse('machine_usage_rules_detail', args=[self.machine_type]) + return reverse('machine_usage_rules_detail', args=[self.machine_type.pk]) diff --git a/news/converters.py b/news/converters.py deleted file mode 100644 index a07f98646..000000000 --- a/news/converters.py +++ /dev/null @@ -1,10 +0,0 @@ -from util.url_utils import SpecificObjectConverter -from .models import Article, Event - - -class SpecificArticle(SpecificObjectConverter): - model = Article - - -class SpecificEvent(SpecificObjectConverter): - model = Event diff --git a/news/ical.py b/news/ical.py index a9e0522fe..129f20140 100644 --- a/news/ical.py +++ b/news/ical.py @@ -28,7 +28,7 @@ def items(self, attrs): return items def item_link(self, item: TimePlace): - return reverse('event_detail', kwargs={'event': item.event}) + return reverse('event_detail', args=[item.event.pk]) def item_title(self, item: TimePlace): return item.event.title @@ -72,6 +72,6 @@ def file_name(self, attrs): def get_object(self, request, *args, **kwargs): attrs = super().get_object(request, *args, **kwargs) - attrs['query_kwargs']['id'] = int(kwargs['pk']) + attrs['query_kwargs']['id'] = int(kwargs['time_place_pk']) return attrs diff --git a/news/templates/news/admin_timeplace_listing.html b/news/templates/news/admin_timeplace_listing.html index 4701d1650..f326626d0 100644 --- a/news/templates/news/admin_timeplace_listing.html +++ b/news/templates/news/admin_timeplace_listing.html @@ -8,7 +8,7 @@ {% if timeplace.event.repeating %} {% if timeplace.number_of_tickets %} - + {% blocktrans trimmed with num_active_tickets=timeplace.number_of_active_tickets num_tickets=timeplace.number_of_tickets %} {{ num_active_tickets }}/{{ num_tickets }} tickets reserved {% endblocktrans %} @@ -25,21 +25,21 @@       {% if perms.news.change_timeplace %} + href="{% url 'timeplace_edit' timeplace.event.pk timeplace.pk %}"> {% endif %} {% if perms.news.delete_timeplace %} + data-url="{% url 'timeplace_delete' timeplace.event.pk timeplace.pk %}" data-obj-name="{{ timeplace }}"> {% endif %} @@ -51,7 +51,7 @@
+ method="POST" action="{% url 'timeplace_duplicate' timeplace.event.pk timeplace.pk %}"> {% csrf_token %}
{% endif %} diff --git a/news/templates/news/event_detail.html b/news/templates/news/event_detail.html index dd397e01f..2159b994c 100644 --- a/news/templates/news/event_detail.html +++ b/news/templates/news/event_detail.html @@ -76,13 +76,13 @@ {% endif %} {% endif %} - + {% if occurrence.number_of_tickets and perms.news.change_event %}
- + {{ occurrence.number_of_active_tickets }} / {{ occurrence.number_of_tickets }} @@ -99,7 +99,7 @@ {% elif occurrence.number_of_active_tickets < occurrence.number_of_tickets %} @@ -219,13 +219,13 @@ {% endif %} {% endif %} - + {% if occurrence.number_of_tickets and perms.news.change_event %}
- + {{ occurrence.number_of_active_tickets }} / {{ occurrence.number_of_tickets }} @@ -242,7 +242,7 @@ {% elif occurrence.number_of_active_tickets < occurrence.number_of_tickets %} + href="{% url 'register_timeplace' news_obj.pk occurrence.pk %}"> {% trans "Registration" %} {% else %} diff --git a/news/templates/news/ticket_card_time_place.html b/news/templates/news/ticket_card_time_place.html index 981457a89..ab365f482 100644 --- a/news/templates/news/ticket_card_time_place.html +++ b/news/templates/news/ticket_card_time_place.html @@ -4,7 +4,7 @@ {# Linking `ticket_card.css` is required when including this template #}
- + diff --git a/news/tests/test_urls.py b/news/tests/test_urls.py index 44fe9a0f8..f47bd1384 100644 --- a/news/tests/test_urls.py +++ b/news/tests/test_urls.py @@ -78,39 +78,39 @@ def test_all_get_request_paths_succeed(self): path_predicates = [ Get(reverse('admin_article_list'), public=False), Get(reverse('admin_event_list'), public=False), - Get(reverse('admin_event_detail', kwargs={'event': self.event1}), public=False), - Get(reverse('admin_event_detail', kwargs={'event': self.event2}), public=False), + Get(reverse('admin_event_detail', args=[self.event1.pk]), public=False), + Get(reverse('admin_event_detail', args=[self.event2.pk]), public=False), Get(reverse('article_list'), public=True), Get(reverse('article_create'), public=False), - Get(reverse('article_edit', kwargs={'article': self.article1}), public=False), - Get(reverse('article_edit', kwargs={'article': self.article2}), public=False), - Get(reverse('article_detail', kwargs={'article': self.article1}), public=True), - Get(reverse('article_detail', kwargs={'article': self.article2}), public=False), # this article is private + Get(reverse('article_edit', args=[self.article1.pk]), public=False), + Get(reverse('article_edit', args=[self.article2.pk]), public=False), + Get(reverse('article_detail', args=[self.article1.pk]), public=True), + Get(reverse('article_detail', args=[self.article2.pk]), public=False), # this article is private Get(reverse('event_list'), public=True), Get(reverse('event_create'), public=False), - Get(reverse('event_edit', kwargs={'event': self.event1}), public=False), - Get(reverse('event_edit', kwargs={'event': self.event2}), public=False), - Get(reverse('event_ticket_list', kwargs={'event': self.event2}), public=False), # can't test `event1`, as it has no tickets - Get(reverse('event_detail', kwargs={'event': self.event1}), public=True), - Get(reverse('event_detail', kwargs={'event': self.event2}), public=False), # this event is private - Get(reverse('register_event', kwargs={'event': self.event1}), public=False), - Get(reverse('register_event', kwargs={'event': self.event2}), public=False), + Get(reverse('event_edit', args=[self.event1.pk]), public=False), + Get(reverse('event_edit', args=[self.event2.pk]), public=False), + Get(reverse('event_ticket_list', args=[self.event2.pk]), public=False), # can't test `event1`, as it has no tickets + Get(reverse('event_detail', args=[self.event1.pk]), public=True), + Get(reverse('event_detail', args=[self.event2.pk]), public=False), # this event is private + Get(reverse('register_event', args=[self.event1.pk]), public=False), + Get(reverse('register_event', args=[self.event2.pk]), public=False), *[ - Get(reverse('timeplace_edit', kwargs={'event': time_place.event, 'pk': time_place.pk}), public=False) + Get(reverse('timeplace_edit', args=[time_place.event.pk, time_place.pk]), public=False) for time_place in self.time_places ], - Get(reverse('timeplace_create', kwargs={'event': self.event1}), public=False), - Get(reverse('timeplace_create', kwargs={'event': self.event2}), public=False), + Get(reverse('timeplace_create', args=[self.event1.pk]), public=False), + Get(reverse('timeplace_create', args=[self.event2.pk]), public=False), *[ - Get(reverse('timeplace_ticket_list', kwargs={'event': time_place.event, 'pk': time_place.pk}), public=False) + Get(reverse('timeplace_ticket_list', args=[time_place.event.pk, time_place.pk]), public=False) for time_place in self.time_places if time_place != self.time_place3 # can't test `time_place3`, as it has no tickets ], *[ - Get(reverse('timeplace_ical', kwargs={'event': time_place.event, 'pk': time_place.pk}), public=True) + Get(reverse('timeplace_ical', args=[time_place.event.pk, time_place.pk]), public=True) for time_place in self.time_places ], *[ - Get(reverse('register_timeplace', kwargs={'event': time_place.event, 'time_place_pk': time_place.pk}), public=False) + Get(reverse('register_timeplace', args=[time_place.event.pk, time_place.pk]), public=False) for time_place in self.time_places if time_place != self.time_place3 # can't test `time_place3`, as it has no tickets ], *[ diff --git a/news/tests/views/test_article_views.py b/news/tests/views/test_article_views.py index 32f4b3103..b81066817 100644 --- a/news/tests/views/test_article_views.py +++ b/news/tests/views/test_article_views.py @@ -38,7 +38,7 @@ def test_articles(self): self.assertEqual(response.status_code, HTTPStatus.OK) def test_article(self): - response = self.client.get(reverse('article_detail', kwargs={'article': self.article})) + response = self.client.get(reverse('article_detail', args=[self.article.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) def test_article_create(self): @@ -50,11 +50,11 @@ def test_article_create(self): self.assertEqual(response.status_code, HTTPStatus.OK) def test_article_edit(self): - response = self.client.get(reverse('article_edit', kwargs={'article': self.article})) + response = self.client.get(reverse('article_edit', args=[self.article.pk])) self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) self.user.add_perms('news.change_article') - response = self.client.get(reverse('article_edit', kwargs={'article': self.article})) + response = self.client.get(reverse('article_edit', args=[self.article.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) def test_admin_article_toggle_view(self): @@ -71,39 +71,39 @@ def toggle(pk, attr): self.assertEquals(toggle(self.article.pk, 'hidden'), {'color': 'yellow' if hidden else 'grey'}) def test_private_article(self): - response = self.client.get(reverse('article_detail', kwargs={'article': self.article})) + response = self.client.get(reverse('article_detail', args=[self.article.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) self.article.private = True self.article.save() - response = self.client.get(reverse('article_detail', kwargs={'article': self.article})) + response = self.client.get(reverse('article_detail', args=[self.article.pk])) self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) self.user.add_perms('news.can_view_private') - response = self.client.get(reverse('article_detail', kwargs={'article': self.article})) + response = self.client.get(reverse('article_detail', args=[self.article.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) def test_not_published_article(self): self.article.publication_time = timezone.now() - timedelta(days=1) self.article.save() - response = self.client.get(reverse('article_detail', kwargs={'article': self.article})) + response = self.client.get(reverse('article_detail', args=[self.article.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) self.article.publication_time = timezone.now() + timedelta(days=1) self.article.save() - response = self.client.get(reverse('article_detail', kwargs={'article': self.article})) + response = self.client.get(reverse('article_detail', args=[self.article.pk])) self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) self.user.add_perms('news.change_article') - response = self.client.get(reverse('article_detail', kwargs={'article': self.article})) + response = self.client.get(reverse('article_detail', args=[self.article.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) def test_hidden_article(self): self.article.hidden = True self.article.save() - response = self.client.get(reverse('article_detail', kwargs={'article': self.article})) + response = self.client.get(reverse('article_detail', args=[self.article.pk])) self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) self.user.add_perms('news.change_article') - response = self.client.get(reverse('article_detail', kwargs={'article': self.article})) + response = self.client.get(reverse('article_detail', args=[self.article.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) diff --git a/news/tests/views/test_event_ticket_views.py b/news/tests/views/test_event_ticket_views.py index c3db8199c..e14e6408a 100644 --- a/news/tests/views/test_event_ticket_views.py +++ b/news/tests/views/test_event_ticket_views.py @@ -37,10 +37,10 @@ def assert_response_status_code(url: str, status_code: int): response = self.client1.get(url) self.assertEqual(response.status_code, status_code) - assert_response_status_code(reverse('register_event', args=[self.repeating_event]), HTTPStatus.FORBIDDEN) - assert_response_status_code(reverse('register_timeplace', args=[self.repeating_event, self.repeating_time_place.pk]), HTTPStatus.OK) - assert_response_status_code(reverse('register_event', args=[self.standalone_event]), HTTPStatus.OK) - assert_response_status_code(reverse('register_timeplace', args=[self.standalone_event, self.standalone_time_place.pk]), HTTPStatus.FORBIDDEN) + assert_response_status_code(reverse('register_event', args=[self.repeating_event.pk]), HTTPStatus.FORBIDDEN) + assert_response_status_code(reverse('register_timeplace', args=[self.repeating_event.pk, self.repeating_time_place.pk]), HTTPStatus.OK) + assert_response_status_code(reverse('register_event', args=[self.standalone_event.pk]), HTTPStatus.OK) + assert_response_status_code(reverse('register_timeplace', args=[self.standalone_event.pk, self.standalone_time_place.pk]), HTTPStatus.FORBIDDEN) def test__event_registration_view__can_only_be_viewed_with_correct_combination_of_event_and_time_place(self): repeating_event1 = self.repeating_event @@ -53,23 +53,23 @@ def assert_response_status_code(url_args: list, status_code: int): response = self.client1.get(url) self.assertEqual(response.status_code, status_code) - assert_response_status_code([repeating_event1, repeating_time_place1.pk], HTTPStatus.OK) - assert_response_status_code([repeating_event1, repeating_time_place2.pk], HTTPStatus.NOT_FOUND) - assert_response_status_code([repeating_event1, self.standalone_time_place.pk], HTTPStatus.NOT_FOUND) - assert_response_status_code([repeating_event2, repeating_time_place1.pk], HTTPStatus.NOT_FOUND) - assert_response_status_code([repeating_event2, repeating_time_place2.pk], HTTPStatus.OK) - assert_response_status_code([repeating_event2, self.standalone_time_place.pk], HTTPStatus.NOT_FOUND) - assert_response_status_code([self.standalone_event, repeating_time_place1.pk], HTTPStatus.NOT_FOUND) - assert_response_status_code([self.standalone_event, repeating_time_place2.pk], HTTPStatus.NOT_FOUND) - assert_response_status_code([self.standalone_event, self.standalone_time_place.pk], HTTPStatus.FORBIDDEN) + assert_response_status_code([repeating_event1.pk, repeating_time_place1.pk], HTTPStatus.OK) + assert_response_status_code([repeating_event1.pk, repeating_time_place2.pk], HTTPStatus.NOT_FOUND) + assert_response_status_code([repeating_event1.pk, self.standalone_time_place.pk], HTTPStatus.NOT_FOUND) + assert_response_status_code([repeating_event2.pk, repeating_time_place1.pk], HTTPStatus.NOT_FOUND) + assert_response_status_code([repeating_event2.pk, repeating_time_place2.pk], HTTPStatus.OK) + assert_response_status_code([repeating_event2.pk, self.standalone_time_place.pk], HTTPStatus.NOT_FOUND) + assert_response_status_code([self.standalone_event.pk, repeating_time_place1.pk], HTTPStatus.NOT_FOUND) + assert_response_status_code([self.standalone_event.pk, repeating_time_place2.pk], HTTPStatus.NOT_FOUND) + assert_response_status_code([self.standalone_event.pk, self.standalone_time_place.pk], HTTPStatus.FORBIDDEN) def test__event_registration_view__creates_and_reactivates_tickets_as_expected(self): for time_place_or_event in [self.repeating_time_place, self.standalone_event]: with self.subTest(time_place_or_event=time_place_or_event): if isinstance(time_place_or_event, TimePlace): - registration_url = reverse('register_timeplace', args=[time_place_or_event.event, time_place_or_event.pk]) + registration_url = reverse('register_timeplace', args=[time_place_or_event.event.pk, time_place_or_event.pk]) else: - registration_url = reverse('register_event', args=[time_place_or_event]) + registration_url = reverse('register_event', args=[time_place_or_event.pk]) self.assertEqual(time_place_or_event.tickets.count(), 0) @@ -164,6 +164,6 @@ def assert_next_param_is_valid(next_param: str, valid: bool): assert_next_param_is_valid(f"google.com{ticket_detail_url}", False) assert_next_param_is_valid(ticket_detail_url, True) assert_next_param_is_valid(urlparse(reverse('my_tickets_list')).path, True) - assert_next_param_is_valid(urlparse(reverse('event_detail', args=[ticket.registered_event])).path, True) + assert_next_param_is_valid(urlparse(reverse('event_detail', args=[ticket.registered_event.pk])).path, True) assert_next_param_is_valid("/", False) assert_next_param_is_valid(urlparse(reverse('front_page')).path, False) diff --git a/news/tests/views/test_event_time_place_views.py b/news/tests/views/test_event_time_place_views.py index 30d774a62..3640a9631 100644 --- a/news/tests/views/test_event_time_place_views.py +++ b/news/tests/views/test_event_time_place_views.py @@ -36,7 +36,7 @@ def test_events(self): self.assertEqual(response.status_code, HTTPStatus.OK) def test_event(self): - response = self.client.get(reverse('event_detail', kwargs={'event': self.event})) + response = self.client.get(reverse('event_detail', args=[self.event.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) def test_event_create(self): @@ -48,30 +48,30 @@ def test_event_create(self): self.assertEqual(response.status_code, HTTPStatus.OK) def test_event_edit(self): - response = self.client.get(reverse('event_edit', kwargs={'event': self.event})) + response = self.client.get(reverse('event_edit', args=[self.event.pk])) self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) self.user.add_perms('news.change_event') - response = self.client.get(reverse('event_edit', kwargs={'event': self.event})) + response = self.client.get(reverse('event_edit', args=[self.event.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) def test_timeplace_duplicate(self): - tp = TimePlace.objects.create(event=self.event, start_time=timezone.localtime() + timedelta(minutes=5), - end_time=timezone.localtime() + timedelta(minutes=10)) - response = self.client.post(reverse('timeplace_duplicate', args=[self.event, tp.pk])) + time_place = TimePlace.objects.create(event=self.event, start_time=timezone.localtime() + timedelta(minutes=5), + end_time=timezone.localtime() + timedelta(minutes=10)) + response = self.client.post(reverse('timeplace_duplicate', args=[self.event.pk, time_place.pk])) self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) self.user.add_perms('news.add_timeplace', 'news.change_timeplace') - response = self.client.post(reverse('timeplace_duplicate', args=[self.event, tp.pk])) + response = self.client.post(reverse('timeplace_duplicate', args=[self.event.pk, time_place.pk])) - new = TimePlace.objects.exclude(pk=tp.pk).latest('pk') - self.assertRedirects(response, reverse('timeplace_edit', args=[self.event, new.pk])) + duplicated_time_place = TimePlace.objects.exclude(pk=time_place.pk).latest('pk') + self.assertRedirects(response, reverse('timeplace_edit', args=[self.event.pk, duplicated_time_place.pk])) - new_start_time = tp.start_time + timedelta(weeks=1) - new_end_time = tp.end_time + timedelta(weeks=1) - self.assertTrue(new.hidden) - self.assertEqual(new.start_time, new_start_time) - self.assertEqual(new.end_time, new_end_time) + new_start_time = time_place.start_time + timedelta(weeks=1) + new_end_time = time_place.end_time + timedelta(weeks=1) + self.assertTrue(duplicated_time_place.hidden) + self.assertEqual(duplicated_time_place.start_time, new_start_time) + self.assertEqual(duplicated_time_place.end_time, new_end_time) def test_timplace_duplicate_old(self): self.user.add_perms('news.add_timeplace', 'news.change_timeplace') @@ -82,10 +82,10 @@ def test_timplace_duplicate_old(self): new_end_time = end_time + timedelta(weeks=3) time_place = TimePlace.objects.create(event=self.event, start_time=start_time, end_time=end_time, hidden=False) - response = self.client.post(reverse('timeplace_duplicate', args=[self.event, time_place.pk])) + response = self.client.post(reverse('timeplace_duplicate', args=[self.event.pk, time_place.pk])) duplicated_time_place = TimePlace.objects.exclude(pk=time_place.pk).latest('pk') - self.assertRedirects(response, reverse('timeplace_edit', args=[self.event, duplicated_time_place.pk])) + self.assertRedirects(response, reverse('timeplace_edit', args=[self.event.pk, duplicated_time_place.pk])) self.assertEqual(duplicated_time_place.start_time, new_start_time) self.assertEqual(duplicated_time_place.end_time, new_end_time) @@ -93,24 +93,24 @@ def test_hidden_event(self): self.event.hidden = True self.event.save() - response = self.client.get(reverse('event_detail', kwargs={'event': self.event})) + response = self.client.get(reverse('event_detail', args=[self.event.pk])) self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) self.user.add_perms('news.change_event') - response = self.client.get(reverse('event_detail', kwargs={'event': self.event})) + response = self.client.get(reverse('event_detail', args=[self.event.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) def test_private_event(self): - response = self.client.get(reverse('event_detail', kwargs={'event': self.event})) + response = self.client.get(reverse('event_detail', args=[self.event.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) self.event.private = True self.event.save() - response = self.client.get(reverse('event_detail', kwargs={'event': self.event})) + response = self.client.get(reverse('event_detail', args=[self.event.pk])) self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) self.user.add_perms('news.can_view_private') - response = self.client.get(reverse('event_detail', kwargs={'event': self.event})) + response = self.client.get(reverse('event_detail', args=[self.event.pk])) self.assertEqual(response.status_code, HTTPStatus.OK) def test_event_context_ticket_emails_only_returns_active_tickets_emails(self): @@ -171,7 +171,7 @@ def assert_context_ticket_emails(self, url_name: str, event: Union[Event, TimePl self.create_tickets_for(event, username_and_ticket_state_tuples) self.user.add_perms('news.change_event') - url_args = [event.event, event.pk] if isinstance(event, TimePlace) else [event] + url_args = [event.event.pk, event.pk] if isinstance(event, TimePlace) else [event.pk] response = self.client.get(reverse(url_name, args=url_args)) self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(expected_context_ticket_emails, response.context["ticket_emails"]) diff --git a/news/urls.py b/news/urls.py index 9e9ee5c5b..c4cfabea3 100644 --- a/news/urls.py +++ b/news/urls.py @@ -1,21 +1,22 @@ from django.contrib.auth.decorators import login_required -from django.urls import include, path, register_converter +from django.urls import include, path -from . import converters, views +from . import views from .ical import SingleTimePlaceFeed -register_converter(converters.SpecificArticle, 'Article') -register_converter(converters.SpecificEvent, 'Event') - article_urlpatterns = [ path("", views.ArticleListView.as_view(), name='article_list'), - path("/", views.ArticleDetailView.as_view(), name='article_detail'), + path("/", views.ArticleDetailView.as_view(), name='article_detail'), +] + +specific_time_place_urlpatterns = [ + path("register/", login_required(views.EventRegistrationView.as_view()), name='register_timeplace'), + path("ical/", SingleTimePlaceFeed(), name='timeplace_ical'), ] time_place_urlpatterns = [ - path("/register/", login_required(views.EventRegistrationView.as_view()), name='register_timeplace'), - path("/ical/", SingleTimePlaceFeed(), name='timeplace_ical'), + path("/", include(specific_time_place_urlpatterns)), ] specific_event_urlpatterns = [ @@ -26,7 +27,7 @@ event_urlpatterns = [ path("", views.EventListView.as_view(), name='event_list'), - path("/", include(specific_event_urlpatterns)), + path("/", include(specific_event_urlpatterns)), ] ticket_urlpatterns = [ @@ -52,7 +53,7 @@ article_adminpatterns = [ path("", views.AdminArticleListView.as_view(), name='admin_article_list'), path("create/", views.CreateArticleView.as_view(), name='article_create'), - path("/", include(specific_article_adminpatterns)), + path("/", include(specific_article_adminpatterns)), ] specific_time_place_adminpatterns = [ @@ -65,7 +66,7 @@ time_place_adminpatterns = [ path("create/", views.CreateTimePlaceView.as_view(), name='timeplace_create'), - path("/", include(specific_time_place_adminpatterns)), + path("/", include(specific_time_place_adminpatterns)), ] specific_event_adminpatterns = [ @@ -80,7 +81,7 @@ event_adminpatterns = [ path("", views.AdminEventListView.as_view(), name='admin_event_list'), path("create/", views.CreateEventView.as_view(), name='event_create'), - path("/", include(specific_event_adminpatterns)), + path("/", include(specific_event_adminpatterns)), ] adminpatterns = [ diff --git a/news/views.py b/news/views.py index 1b88fb133..730a25691 100644 --- a/news/views.py +++ b/news/views.py @@ -13,7 +13,6 @@ from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.translation import get_language, gettext_lazy as _ -from django.views import View from django.views.generic import CreateView, DeleteView, DetailView, FormView, ListView, UpdateView from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import ModelFormMixin @@ -26,42 +25,6 @@ from .models import Article, Event, EventQuerySet, EventTicket, NewsBase, TimePlace -class SpecificArticleView(SingleObjectMixin, View, ABC): - article: Article - - def setup(self, request, *args, **kwargs): - super().setup(request, *args, **kwargs) - self.article = kwargs['article'] - - def get_object(self, queryset=None): - return self.article - - -class EventBasedView(View, ABC): - event: Event - - def setup(self, request, *args, **kwargs): - super().setup(request, *args, **kwargs) - self.event = kwargs['event'] - - -class SpecificEventView(SingleObjectMixin, EventBasedView, ABC): - - def get_object(self, queryset=None): - return self.event - - -class SpecificTimePlaceView(SingleObjectMixin, EventBasedView, ABC): - time_place: TimePlace - - def setup(self, request, *args, **kwargs): - super().setup(request, *args, **kwargs) - self.time_place = self.get_object() - - def get_queryset(self): - return self.event.timeplaces.all() - - class EventListView(ListView): template_name = 'news/event_list.html' @@ -124,38 +87,41 @@ def get_queryset(self): return Article.objects.published().visible_to(self.request.user).order_by('-publication_time') -class EventDetailView(PermissionRequiredMixin, SpecificEventView, DetailView): +class EventDetailView(PermissionRequiredMixin, DetailView): model = Event template_name = 'news/event_detail.html' context_object_name = 'news_obj' def has_permission(self): - if self.event.hidden and not self.request.user.has_perm('news.change_event'): + event = self.get_object() + if event.hidden and not self.request.user.has_perm('news.change_event'): return False - elif self.event.private and not self.request.user.has_perm('news.can_view_private'): + elif event.private and not self.request.user.has_perm('news.can_view_private'): return False else: return True def get_context_data(self, **kwargs): - future_timeplaces = self.event.timeplaces.published().future() + event = self.object + future_timeplaces = event.timeplaces.published().future() return super().get_context_data(**{ - 'timeplaces': self.event.timeplaces.all() if self.event.standalone else future_timeplaces, + 'timeplaces': event.timeplaces.all() if event.standalone else future_timeplaces, 'is_old': not future_timeplaces.exists(), - 'last_occurrence': self.event.get_past_occurrences().first(), + 'last_occurrence': event.get_past_occurrences().first(), **kwargs, }) -class ArticleDetailView(PermissionRequiredMixin, SpecificArticleView, DetailView): +class ArticleDetailView(PermissionRequiredMixin, DetailView): model = Article template_name = 'news/article_detail.html' context_object_name = 'news_obj' def has_permission(self): - if self.article not in Article.objects.published() and not self.request.user.has_perm('news.change_article'): + article = self.get_object() + if article not in Article.objects.published() and not self.request.user.has_perm('news.change_article'): return False - elif self.article.private and not self.request.user.has_perm('news.can_view_private'): + elif article.private and not self.request.user.has_perm('news.can_view_private'): return False else: return True @@ -186,16 +152,17 @@ def get_queryset(self): ).order_by('-latest_occurrence').prefetch_related('timeplaces') -class AdminEventDetailView(PermissionRequiredMixin, SpecificEventView, DetailView): +class AdminEventDetailView(PermissionRequiredMixin, DetailView): permission_required = ('news.change_event',) model = Event template_name = 'news/admin_event_detail.html' context_object_name = 'event' def get_context_data(self, **kwargs): + event = self.object return super().get_context_data(**{ - 'future_timeplaces': self.event.timeplaces.future().order_by('start_time'), - 'past_timeplaces': self.event.timeplaces.past().order_by('-start_time'), + 'future_timeplaces': event.timeplaces.future().order_by('start_time'), + 'past_timeplaces': event.timeplaces.past().order_by('-start_time'), **kwargs, }) @@ -233,7 +200,7 @@ def get_custom_news_fieldset(self) -> dict: return {'fields': ('publication_time',)} -class EditArticleView(PermissionRequiredMixin, ArticleFormMixin, SpecificArticleView, UpdateView): +class EditArticleView(PermissionRequiredMixin, ArticleFormMixin, UpdateView): permission_required = ('news.change_article',) form_title = _("Edit Article") @@ -260,16 +227,16 @@ def get_back_button_link(self): return self.get_success_url() def get_success_url(self): - return reverse('admin_event_detail', args=[self.object]) + return reverse('admin_event_detail', args=[self.object.pk]) -class EditEventView(PermissionRequiredMixin, EventFormMixin, SpecificEventView, UpdateView): +class EditEventView(PermissionRequiredMixin, EventFormMixin, UpdateView): permission_required = ('news.change_event',) form_title = _("Edit Event") def get_back_button_text(self): - return _("Admin page for “{event_title}”").format(event_title=self.event.title) + return _("Admin page for “{event_title}”").format(event_title=self.object.title) class CreateEventView(PermissionRequiredMixin, EventFormMixin, CreateView): @@ -282,7 +249,40 @@ def get_back_button_link(self): return reverse('admin_event_list') -class BaseTimePlaceEditView(CustomFieldsetFormMixin, EventBasedView, ABC): +class EventRelatedViewMixin: + """ + Note: When extending this mixin class, it's required to have an ``int`` path converter named ``pk`` as part of the view's path, + which will be used to query the database for the event that the object(s) are related to. + If found, the event will be assigned to an ``event`` field on the view, otherwise, a 404 error will be raised. + """ + event: Event + + # noinspection PyUnresolvedReferences + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + event_pk = self.kwargs['pk'] + self.event = get_object_or_404(Event, pk=event_pk) + + +class TimePlaceRelatedViewMixin(EventRelatedViewMixin): + """ + Note: When extending this mixin class, it's required to have an ``int`` path converter named ``time_place_pk`` as part of the view's path, + which will be used to query the database for the time place that the object(s) are related to. + If either the time place's PK does not exist, or if the time place is not related to the event found by the parent class + ``EventRelatedViewMixin``, a 404 error will be raised. Otherwise, the time place will be assigned to a ``time_place`` field on the view. + + See the docstring of the mentioned parent class for additional details. + """ + time_place: TimePlace + + # noinspection PyUnresolvedReferences + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + time_place_pk = self.kwargs['time_place_pk'] + self.time_place = get_object_or_404(self.event.timeplaces, pk=time_place_pk) + + +class BaseTimePlaceEditView(CustomFieldsetFormMixin, EventRelatedViewMixin, ABC): model = TimePlace form_class = TimePlaceForm template_name = 'news/timeplace_edit.html' @@ -314,14 +314,17 @@ def get_custom_fieldsets(self): ] def get_success_url(self): - return reverse('admin_event_detail', args=[self.event]) + return reverse('admin_event_detail', args=[self.event.pk]) -class EditTimePlaceView(PermissionRequiredMixin, SpecificTimePlaceView, BaseTimePlaceEditView, UpdateView): +class EditTimePlaceView(PermissionRequiredMixin, TimePlaceRelatedViewMixin, BaseTimePlaceEditView, UpdateView): permission_required = ('news.change_timeplace',) form_title = _("Edit Occurrence") + def get_object(self, queryset=None): + return self.time_place + class CreateTimePlaceView(PermissionRequiredMixin, BaseTimePlaceEditView, CreateView): permission_required = ('news.add_timeplace',) @@ -329,8 +332,9 @@ class CreateTimePlaceView(PermissionRequiredMixin, BaseTimePlaceEditView, Create form_title = _("New Occurrence") -class DuplicateTimePlaceView(PermissionRequiredMixin, PreventGetRequestsMixin, SpecificTimePlaceView, CreateView): +class DuplicateTimePlaceView(PermissionRequiredMixin, PreventGetRequestsMixin, TimePlaceRelatedViewMixin, CreateView): permission_required = ('news.add_timeplace',) + model = TimePlace fields = () def form_valid(self, form): @@ -344,10 +348,14 @@ def form_valid(self, form): self.time_place.end_time += timedelta(weeks=weeks) self.time_place.hidden = True self.time_place.save() + + # noinspection PyAttributeOutsideInit + # Setting the `object` field, as is done in the super class `ModelFormMixin` + self.object = self.time_place return HttpResponseRedirect(self.get_success_url()) def get_success_url(self): - return reverse('timeplace_edit', args=[self.event, self.time_place.pk]) + return reverse('timeplace_edit', args=[self.event.pk, self.time_place.pk]) class AdminNewsBaseToggleView(PreventGetRequestsMixin, SingleObjectMixin, FormView, ABC): @@ -375,39 +383,48 @@ def form_invalid(self, form): return JsonResponse({}) -class AdminArticleToggleView(PermissionRequiredMixin, SpecificArticleView, AdminNewsBaseToggleView): +class AdminArticleToggleView(PermissionRequiredMixin, AdminNewsBaseToggleView): permission_required = ('news.change_article',) + model = Article -class AdminEventToggleView(PermissionRequiredMixin, SpecificEventView, AdminNewsBaseToggleView): +class AdminEventToggleView(PermissionRequiredMixin, AdminNewsBaseToggleView): permission_required = ('news.change_event',) + model = Event -class AdminTimeplaceToggleView(PermissionRequiredMixin, SpecificTimePlaceView, AdminNewsBaseToggleView): +class AdminTimeplaceToggleView(PermissionRequiredMixin, TimePlaceRelatedViewMixin, AdminNewsBaseToggleView): permission_required = ('news.change_timeplace',) + model = TimePlace + + def get_object(self, queryset=None): + return self.time_place -class DeleteArticleView(PermissionRequiredMixin, PreventGetRequestsMixin, SpecificArticleView, DeleteView): +class DeleteArticleView(PermissionRequiredMixin, PreventGetRequestsMixin, DeleteView): permission_required = ('news.delete_article',) model = Article success_url = reverse_lazy('admin_article_list') -class DeleteEventView(PermissionRequiredMixin, PreventGetRequestsMixin, SpecificEventView, DeleteView): +class DeleteEventView(PermissionRequiredMixin, PreventGetRequestsMixin, DeleteView): permission_required = ('news.delete_event',) model = Event success_url = reverse_lazy('admin_event_list') -class DeleteTimePlaceView(PermissionRequiredMixin, PreventGetRequestsMixin, SpecificTimePlaceView, DeleteView): +class DeleteTimePlaceView(PermissionRequiredMixin, PreventGetRequestsMixin, TimePlaceRelatedViewMixin, DeleteView): permission_required = ('news.delete_timeplace',) model = TimePlace + def get_object(self, queryset=None): + return self.time_place + def get_success_url(self): - return reverse('admin_event_detail', args=[self.object.event]) + return reverse('admin_event_detail', args=[self.object.event.pk]) -class EventRegistrationView(PermissionRequiredMixin, CreateView): +class EventRegistrationView(PermissionRequiredMixin, EventRelatedViewMixin, CreateView): model = EventTicket form_class = EventRegistrationForm template_name = 'news/event_registration.html' @@ -418,7 +435,6 @@ class EventRegistrationView(PermissionRequiredMixin, CreateView): def setup(self, request, *args, **kwargs): super().setup(request, *args, **kwargs) - self.event = self.kwargs.get('event') time_place_pk = self.kwargs.get('time_place_pk') if time_place_pk is None: self.ticket_time_place = None @@ -464,6 +480,8 @@ def get_form_kwargs(self): def form_valid(self, form): form.instance.active = True # this is done mainly for reactivating an existing ticket ticket = form.save() + # noinspection PyAttributeOutsideInit + # Setting the `object` field, as is done in the super class `ModelFormMixin` self.object = ticket try: @@ -508,7 +526,7 @@ def get_queryset(self): ) -class AdminEventTicketListView(PermissionRequiredMixin, EventBasedView, ListView): +class AdminEventTicketListView(PermissionRequiredMixin, EventRelatedViewMixin, ListView): model = EventTicket template_name = 'news/admin_event_ticket_list.html' context_object_name = 'tickets' @@ -532,14 +550,9 @@ def get_context_data(self, **kwargs): }) -class AdminTimeplaceTicketListView(AdminEventTicketListView): - time_place_pk_url_kwarg = 'pk' +class AdminTimeplaceTicketListView(TimePlaceRelatedViewMixin, AdminEventTicketListView): time_place: TimePlace - def setup(self, request, *args, **kwargs): - super().setup(request, *args, **kwargs) - self.time_place = get_object_or_404(self.event.timeplaces, pk=self.kwargs[self.time_place_pk_url_kwarg]) - @property def focused_object(self): return self.time_place @@ -567,7 +580,7 @@ def get_allowed_next_params(self) -> Set[str]: return { reverse('ticket_detail', args=[self.ticket.pk]), reverse('my_tickets_list'), - reverse('event_detail', args=[self.ticket.registered_event]), + reverse('event_detail', args=[self.ticket.registered_event.pk]), } def get_queryset(self): @@ -602,6 +615,10 @@ def form_valid(self, form): elif self.request.user == self.ticket.user and self.ticket.active: self.ticket.active = False self.ticket.save() + + # noinspection PyAttributeOutsideInit + # Setting the `object` field, as is done in the super class `ModelFormMixin` + self.object = self.ticket return HttpResponseRedirect(self.get_success_url()) def get_success_url(self): diff --git a/users/converters.py b/users/converters.py deleted file mode 100644 index 8bd72c3e8..000000000 --- a/users/converters.py +++ /dev/null @@ -1,19 +0,0 @@ -from .models import User - - -class SpecificUser: - regex = r"([0-9]+)" - - def to_python(self, value): - try: - return User.objects.get(pk=int(value)) - except User.DoesNotExist: - raise ValueError(f"Unable to find any user for the PK '{value}'") - - def to_url(self, obj): - if type(obj) is int: - return str(obj) - elif isinstance(obj, User): - return str(obj.pk) - else: - raise ValueError(f"Unable to convert '{obj}' to be used in a URL") diff --git a/util/url_utils.py b/util/url_utils.py index 4611b0617..e69de29bb 100644 --- a/util/url_utils.py +++ b/util/url_utils.py @@ -1,24 +0,0 @@ -from abc import ABC - -from django.db.models import Model - - -class SpecificObjectConverter(ABC): - regex = r"([0-9]+)" - - model: Model - - def to_python(self, value): - try: - # TODO: remove in favor of a solution that gets these objects in the view, as this crashes with a SynchronousOnlyOperation error - return self.model.objects.get(pk=int(value)) - except self.model.DoesNotExist: - raise ValueError(f"Unable to find any {self.model._meta.object_name} for the PK '{value}'") - - def to_url(self, obj): - if type(obj) is int: - return str(obj) - elif isinstance(obj, self.model): - return str(obj.pk) - else: - raise ValueError(f"Unable to convert '{obj}' to be used in a URL") diff --git a/web/urls.py b/web/urls.py index cfedeb0d4..393130081 100644 --- a/web/urls.py +++ b/web/urls.py @@ -95,11 +95,11 @@ # URLs kept for "backward-compatibility" after paths were changed, so that users are simply redirected to the new URLs urlpatterns += i18n_patterns( path("rules/", RedirectView.as_view(url=reverse_lazy('rules'), permanent=True)), - path("reservation/rules//", RedirectView.as_view(pattern_name='reservation_rule_list', permanent=True)), - path("reservation/rules/usage//", RedirectView.as_view(pattern_name='machine_usage_rules_detail', permanent=True)), + path("reservation/rules//", RedirectView.as_view(pattern_name='reservation_rule_list', permanent=True)), + path("reservation/rules/usage//", RedirectView.as_view(pattern_name='machine_usage_rules_detail', permanent=True)), - path("news/article//", RedirectView.as_view(pattern_name='article_detail', permanent=True)), - path("news/event//", RedirectView.as_view(pattern_name='event_detail', permanent=True)), + path("news/article//", RedirectView.as_view(pattern_name='article_detail', permanent=True)), + path("news/event//", RedirectView.as_view(pattern_name='event_detail', permanent=True)), path("news/ticket//", RedirectView.as_view(pattern_name='ticket_detail', permanent=True)), path("news/ticket/me/", RedirectView.as_view(pattern_name='my_tickets_list', permanent=True)), From b64ded3f612bd476a1dbb977094a4fe71c999cdb Mon Sep 17 00:00:00 2001 From: Dabble Date: Thu, 24 Mar 2022 02:30:12 +0100 Subject: [PATCH 07/20] Fixed 404 for CreateDocumentationPageView This was caused by the `create` part of request URLs being matched by the `` path parameter of `DocumentationPageDetailView`. --- docs/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/urls.py b/docs/urls.py index 86fd6bfbf..f4532af80 100644 --- a/docs/urls.py +++ b/docs/urls.py @@ -12,11 +12,11 @@ unsafe_urlpatterns = [ path("", views.DocumentationPageDetailView.as_view(), {'title': Page.objects.get_or_create(title=MAIN_PAGE_TITLE)[0].title}, name='home'), + path("page/create/", views.CreateDocumentationPageView.as_view(), name='create_page'), path("page//", views.DocumentationPageDetailView.as_view(), name='page_detail'), path("page//history/", views.HistoryDocumentationPageView.as_view(), name='page_history'), path("page//history/change/", views.ChangeDocumentationPageVersionView.as_view(), name='change_page_version'), path("page//history//", views.OldDocumentationPageContentView.as_view(), name='old_page_content'), - path("page/create/", views.CreateDocumentationPageView.as_view(), name='create_page'), path("page//edit/", views.EditDocumentationPageView.as_view(), name='edit_page'), path("page//delete/", views.DeleteDocumentationPageView.as_view(), name='delete_page'), path("search/", views.SearchPagesView.as_view(), name='search_pages'), From bb0778c07b62b87f40914cb03447360c2b947766 Mon Sep 17 00:00:00 2001 From: Dabble Date: Thu, 24 Mar 2022 02:53:18 +0100 Subject: [PATCH 08/20] Fixed 404 for docs main page in tests This could be reproduced by running both `contentbox/tests/test_urls.py` (but not `test_views.py`) and the docs tests in the same test run. Not entirely sure where and why the tests failed, but it's possible it had something to do with the previous code in `docs/urls.py` being run when the module was imported in an earlier run test, and `Page.objects.get_or_create()` not being re-invoked when running the docs tests. --- docs/models.py | 5 +++++ docs/urls.py | 3 +-- docs/views.py | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/models.py b/docs/models.py index 7f97e9fa1..7c20b9895 100644 --- a/docs/models.py +++ b/docs/models.py @@ -32,6 +32,11 @@ class Page(models.Model): def __str__(self): return self.title + @classmethod + def get_main_page(cls) -> 'Page': + main_page, _created = Page.objects.get_or_create(title=MAIN_PAGE_TITLE) + return main_page + class Content(models.Model): """The content of a documentation page. All versions are kept for editing history.""" diff --git a/docs/urls.py b/docs/urls.py index f4532af80..dce68bddc 100644 --- a/docs/urls.py +++ b/docs/urls.py @@ -5,13 +5,12 @@ from django.views.generic import TemplateView from . import converters, views -from .models import MAIN_PAGE_TITLE, Page register_converter(converters.SpecificPageByTitle, 'PageTitle') unsafe_urlpatterns = [ - path("", views.DocumentationPageDetailView.as_view(), {'title': Page.objects.get_or_create(title=MAIN_PAGE_TITLE)[0].title}, name='home'), + path("", views.DocumentationPageDetailView.as_view(is_main_page=True), name='home'), path("page/create/", views.CreateDocumentationPageView.as_view(), name='create_page'), path("page//", views.DocumentationPageDetailView.as_view(), name='page_detail'), path("page//history/", views.HistoryDocumentationPageView.as_view(), name='page_history'), diff --git a/docs/views.py b/docs/views.py index c10e6ce05..db6553617 100644 --- a/docs/views.py +++ b/docs/views.py @@ -33,6 +33,13 @@ class DocumentationPageDetailView(SpecificPageBasedViewMixin, DetailView): context_object_name = "page" extra_context = {'MAIN_PAGE_TITLE': MAIN_PAGE_TITLE} + is_main_page = False + + def get_object(self, *args, **kwargs): + if self.is_main_page: + return Page.get_main_page() + return super().get_object(*args, **kwargs) + class HistoryDocumentationPageView(SpecificPageBasedViewMixin, DetailView): template_name = 'docs/documentation_page_history.html' From b879e2573075a5a10bd0f6a0c75126c06855bb58 Mon Sep 17 00:00:00 2001 From: Dabble Date: Thu, 24 Mar 2022 03:03:46 +0100 Subject: [PATCH 09/20] Removed reversing URLs using kwargs Passing positional instead of keyword arguments, makes reversing the URL less error-prone, as one doesn't have to search through the whole codebase to update the keyword argument names when the URL's path parameter is changed. Exceptions to this include reversing e.g. `machine_detail`, as the URL contains multiple (and unconventionally named) parameters, and it's easier to understand what the passed arguments are, if passed as keyword arguments. --- .../announcements/announcement_admin.html | 4 ++-- announcements/tests/test_urls.py | 2 +- contentbox/tests/test_views.py | 4 ++-- faq/tests/test_urls.py | 4 ++-- groups/tests/test_urls.py | 4 ++-- internal/tests/test_urls.py | 24 +++++++++---------- internal/tests/test_views.py | 4 ++-- make_queue/tests/test_urls.py | 12 +++++----- makerspace/tests/test_urls.py | 4 ++-- news/templates/news/event_detail.html | 8 +++---- news/templates/news/ticket_card.html | 2 +- news/templates/news/ticket_detail.html | 2 +- news/tests/test_urls.py | 2 +- 13 files changed, 38 insertions(+), 38 deletions(-) diff --git a/announcements/templates/announcements/announcement_admin.html b/announcements/templates/announcements/announcement_admin.html index 45a643601..0a986b2d4 100644 --- a/announcements/templates/announcements/announcement_admin.html +++ b/announcements/templates/announcements/announcement_admin.html @@ -20,13 +20,13 @@

{{ announcement.get_classification_display }} {% if perms.announcements.change_announcement %} - + {% endif %} {% if perms.announcements.delete_announcement %} diff --git a/announcements/tests/test_urls.py b/announcements/tests/test_urls.py index 694e2fe9b..40836dedf 100644 --- a/announcements/tests/test_urls.py +++ b/announcements/tests/test_urls.py @@ -22,7 +22,7 @@ def test_all_get_request_paths_succeed(self): path_predicates = [ Get(reverse('announcement_admin'), public=False), Get(reverse('create_announcement'), public=False), - Get(reverse('edit_announcement', kwargs={'pk': self.announcement1.pk}), public=False), + Get(reverse('edit_announcement', args=[self.announcement1.pk]), public=False), ] assert_requesting_paths_succeeds(self, path_predicates) diff --git a/contentbox/tests/test_views.py b/contentbox/tests/test_views.py index 6d127c818..ec641c4fa 100644 --- a/contentbox/tests/test_views.py +++ b/contentbox/tests/test_views.py @@ -141,9 +141,9 @@ def setUp(self): self.public_content_box = ContentBox.objects.get(url_name=TEST_URL_NAME) self.internal_content_box = ContentBox.objects.get(url_name=INTERNAL_TEST_URL_NAME) - self.public_edit_url = reverse('contentbox_edit', kwargs={'pk': self.public_content_box.pk}) + self.public_edit_url = reverse('contentbox_edit', args=[self.public_content_box.pk]) self.public_admin_edit_url = reverse('admin:contentbox_contentbox_change', args=[self.public_content_box.pk], host='admin') - self.internal_edit_url = reverse('contentbox_edit', kwargs={'pk': self.internal_content_box.pk}, host='test_internal') + self.internal_edit_url = reverse('contentbox_edit', args=[self.internal_content_box.pk], host='test_internal') self.internal_admin_edit_url = reverse('admin:contentbox_contentbox_change', args=[self.internal_content_box.pk], host='admin') def test_content_box_edit_urls_are_only_accessible_with_required_permissions(self): diff --git a/faq/tests/test_urls.py b/faq/tests/test_urls.py index fa13c69df..d5aeb53df 100644 --- a/faq/tests/test_urls.py +++ b/faq/tests/test_urls.py @@ -26,13 +26,13 @@ def test_all_get_request_paths_succeed(self): Get(reverse('admin_question_list'), public=False), Get(reverse('question_create'), public=False), *[ - Get(reverse('question_update', kwargs={'pk': question.pk}), public=False) + Get(reverse('question_update', args=[question.pk]), public=False) for question in self.questions ], Get(reverse('admin_category_list'), public=False), Get(reverse('category_create'), public=False), *[ - Get(reverse('category_update', kwargs={'pk': category.pk}), public=False) + Get(reverse('category_update', args=[category.pk]), public=False) for category in self.categories ], ] diff --git a/groups/tests/test_urls.py b/groups/tests/test_urls.py index ee10fe722..6e26b269e 100644 --- a/groups/tests/test_urls.py +++ b/groups/tests/test_urls.py @@ -23,8 +23,8 @@ def setUp(self): def test_all_get_request_paths_succeed(self): path_predicates = [ Get(reverse('committee_list'), public=True), - Get(reverse('committee_detail', kwargs={'pk': self.committee1.pk}), public=True), - Get(reverse('committee_edit', kwargs={'pk': self.committee1.pk}), public=False), + Get(reverse('committee_detail', args=[self.committee1.pk]), public=True), + Get(reverse('committee_edit', args=[self.committee1.pk]), public=False), Get(reverse('committee_admin'), public=False), ] assert_requesting_paths_succeeds(self, path_predicates) diff --git a/internal/tests/test_urls.py b/internal/tests/test_urls.py index bdb7ca0b3..33ba7a071 100644 --- a/internal/tests/test_urls.py +++ b/internal/tests/test_urls.py @@ -19,8 +19,8 @@ INTERNAL_CLIENT_DEFAULTS = {'SERVER_NAME': 'internal.testserver'} -def reverse_internal(viewname: str, **kwargs): - return reverse(viewname, kwargs=kwargs, host='internal', host_args=['internal']) +def reverse_internal(viewname: str, *args): + return reverse(viewname, args=args, host='internal', host_args=['internal']) class UrlTests(TestCase): @@ -92,14 +92,14 @@ def _test_editor_url(self, method: str, path: str, data: dict = None, *, expecte def test_permissions(self): self._test_internal_url('GET', reverse_internal('member_list')) - self._test_internal_url('GET', reverse_internal('member_list', pk=self.member.pk)) + self._test_internal_url('GET', reverse_internal('member_list', self.member.pk)) self._test_editor_url('GET', reverse_internal('create_member')) # All members can edit themselves, but only editors can edit other members - self._test_internal_url('GET', reverse_internal('edit_member', pk=self.member.pk)) - self._test_editor_url('GET', reverse_internal('edit_member', pk=self.member_editor.pk)) + self._test_internal_url('GET', reverse_internal('edit_member', self.member.pk)) + self._test_editor_url('GET', reverse_internal('edit_member', self.member_editor.pk)) - self._test_editor_url('GET', reverse_internal('member_quit', pk=self.member.pk)) + self._test_editor_url('GET', reverse_internal('member_quit', self.member.pk)) path_data_assertion_tuples = ( ('member_quit', {'date_quit_or_retired': "2000-01-01", 'reason_quit': "Whatever."}, lambda member: member.quit), @@ -109,7 +109,7 @@ def test_permissions(self): ) for path, data, assertion in path_data_assertion_tuples: with self.subTest(path=path, data=data): - self._test_editor_url('POST', reverse_internal(path, pk=self.member.pk), data, + self._test_editor_url('POST', reverse_internal(path, self.member.pk), data, expected_redirect_url=f"/members/{self.member.pk}/") self.member.refresh_from_db() self.assertTrue(assertion(self.member)) @@ -119,7 +119,7 @@ def test_permissions(self): # No one is allowed to change their `WEBSITE` access. Other than that, # all members can edit their own accesses, but only editors can edit other members'. allowed_clients = {self.member_client, self.member_editor_client} if system_access.name != SystemAccess.WEBSITE else set() - self._test_url_permissions('POST', reverse_internal('edit_system_access', member_pk=self.member.pk, pk=system_access.pk), + self._test_url_permissions('POST', reverse_internal('edit_system_access', self.member.pk, system_access.pk), {'value': True}, allowed_clients=allowed_clients, expected_redirect_url=f"/members/{self.member.pk}/") @@ -127,7 +127,7 @@ def test_permissions(self): with self.subTest(system_access=system_access): # No one is allowed to change their `WEBSITE` access allowed_clients = {self.member_editor_client} if system_access.name != SystemAccess.WEBSITE else set() - self._test_url_permissions('POST', reverse_internal('edit_system_access', member_pk=self.member_editor.pk, pk=system_access.pk), + self._test_url_permissions('POST', reverse_internal('edit_system_access', self.member_editor.pk, system_access.pk), {'value': True}, allowed_clients=allowed_clients, expected_redirect_url=f"/members/{self.member_editor.pk}/") @@ -139,11 +139,11 @@ def test_permissions(self): def test_all_non_member_get_request_paths_succeed(self): path_predicates = [ Get(reverse_internal(self.home_content_box.url_name), public=False), - Get(reverse_internal('contentbox_edit', pk=self.home_content_box.pk), public=False), + Get(reverse_internal('contentbox_edit', self.home_content_box.pk), public=False), Get(reverse_internal('secret_list'), public=False), Get(reverse_internal('create_secret'), public=False), - Get(reverse_internal('edit_secret', pk=self.secret1.pk), public=False), - Get(reverse_internal('edit_secret', pk=self.secret2.pk), public=False), + Get(reverse_internal('edit_secret', self.secret1.pk), public=False), + Get(reverse_internal('edit_secret', self.secret2.pk), public=False), Get('/robots.txt', public=True, translated=False), Get('/.well-known/security.txt', public=True, translated=False), ] diff --git a/internal/tests/test_views.py b/internal/tests/test_views.py index 6059fc9b5..00f283b3c 100644 --- a/internal/tests/test_views.py +++ b/internal/tests/test_views.py @@ -36,7 +36,7 @@ def setUp(self): # Add these extra change perms (mainly `internal.can_change_rich_text_source`), # so that the content box uses the form that allows editing the HTML source code self.home_content_box.extra_change_permissions.add(*get_perms(*internal_admin_perms)) - self.home_edit_url = reverse_internal('contentbox_edit', pk=self.home_content_box.pk) + self.home_edit_url = reverse_internal('contentbox_edit', self.home_content_box.pk) self.internal_content_boxes = (self.home_content_box,) @@ -93,7 +93,7 @@ def _test_internal_content_boxes_accept_posting_just_one_language(self): for content_box in self.internal_content_boxes: with self.subTest(content_box=content_box): - edit_url = reverse_internal('contentbox_edit', pk=content_box.pk) + edit_url = reverse_internal('contentbox_edit', content_box.pk) response = self.internal_admin_client.post(edit_url, { f'title_{settings.LANGUAGE_CODE}': "Mock Title", f'content_{settings.LANGUAGE_CODE}': mock_content, diff --git a/make_queue/tests/test_urls.py b/make_queue/tests/test_urls.py index a6c0702e1..a90f74b98 100644 --- a/make_queue/tests/test_urls.py +++ b/make_queue/tests/test_urls.py @@ -107,7 +107,7 @@ def test_all_get_request_paths_succeed(self): # machine_urlpatterns Get(reverse('create_machine'), public=False), *[ - Get(reverse('edit_machine', kwargs={'pk': machine.pk}), public=False) + Get(reverse('edit_machine', args=[machine.pk]), public=False) for machine in self.machines ], @@ -129,8 +129,8 @@ def test_all_get_request_paths_succeed(self): Get(reverse('reservation_json', args=[reservation.machine.pk, reservation.pk]), public=False) for reservation in self.reservations ], - Get(reverse('user_json', kwargs={'username': self.user1.username}), public=False), - Get(reverse('user_json', kwargs={'username': self.user2.username}), public=False), + Get(reverse('user_json', args=[self.user1.username]), public=False), + Get(reverse('user_json', args=[self.user2.username]), public=False), # Back to urlpatterns *[ @@ -163,7 +163,7 @@ def test_all_get_request_paths_succeed(self): Get(reverse('quota_panel'), public=False), Get(reverse('create_quota'), public=False), *[ - Get(reverse('edit_quota', kwargs={'pk': quota.pk}), public=False) + Get(reverse('edit_quota', args=[quota.pk]), public=False) for quota in self.quotas ], Get(reverse('user_quota_list', args=[self.user1.pk]), public=False), @@ -175,8 +175,8 @@ def test_all_get_request_paths_succeed(self): Get(reverse('course_registration_list'), public=False), Get(reverse('create_course_registration'), public=False), Get(reverse('create_course_registration_success'), public=False), - Get(reverse('edit_course_registration', kwargs={'pk': self.course1.pk}), public=False), - Get(reverse('edit_course_registration', kwargs={'pk': self.course2.pk}), public=False), + Get(reverse('edit_course_registration', args=[self.course1.pk]), public=False), + Get(reverse('edit_course_registration', args=[self.course2.pk]), public=False), ] assert_requesting_paths_succeeds(self, path_predicates) diff --git a/makerspace/tests/test_urls.py b/makerspace/tests/test_urls.py index d3af9482e..4c1aee7db 100644 --- a/makerspace/tests/test_urls.py +++ b/makerspace/tests/test_urls.py @@ -22,8 +22,8 @@ def test_all_get_request_paths_succeed(self): Get(reverse('makerspace_equipment_list'), public=True), Get(reverse('makerspace_admin_equipment_list'), public=False), Get(reverse('makerspace_equipment_create'), public=False), - Get(reverse('makerspace_equipment_edit', kwargs={'pk': self.equipment1.pk}), public=False), - Get(reverse('makerspace_equipment_detail', kwargs={'pk': self.equipment1.pk}), public=True), + Get(reverse('makerspace_equipment_edit', args=[self.equipment1.pk]), public=False), + Get(reverse('makerspace_equipment_detail', args=[self.equipment1.pk]), public=True), Get(reverse('rules'), public=True), ] assert_requesting_paths_succeeds(self, path_predicates) diff --git a/news/templates/news/event_detail.html b/news/templates/news/event_detail.html index 2159b994c..b744bb582 100644 --- a/news/templates/news/event_detail.html +++ b/news/templates/news/event_detail.html @@ -93,7 +93,7 @@ {% if occurrence.number_of_tickets %} {% get_ticket occurrence user as ticket %} {% if ticket %} - + {% trans "You are registered for this event" %} {% elif occurrence.number_of_active_tickets < occurrence.number_of_tickets %} @@ -121,7 +121,7 @@ {% if news_obj.number_of_tickets %} {% get_ticket news_obj user as ticket %} {% if ticket %} - + {% trans "You are registered for this event" %} {% elif news_obj.number_of_active_tickets < news_obj.number_of_tickets %} @@ -161,7 +161,7 @@ {% if news_obj.number_of_tickets %} {% get_ticket news_obj user as ticket %} {% if ticket %} - + {% trans "You are registered for this event" %} {% elif news_obj.number_of_active_tickets < news_obj.number_of_tickets %} @@ -237,7 +237,7 @@ {% if occurrence.number_of_tickets %} {% get_ticket occurrence user as ticket %} {% if ticket %} - + {% trans "You are registered for this event" %} {% elif occurrence.number_of_active_tickets < occurrence.number_of_tickets %} diff --git a/news/templates/news/ticket_card.html b/news/templates/news/ticket_card.html index da27b19e8..ae22e1f55 100644 --- a/news/templates/news/ticket_card.html +++ b/news/templates/news/ticket_card.html @@ -45,7 +45,7 @@
{% trans "Ref #" %}: - + {{ ticket.uuid }}
diff --git a/news/templates/news/ticket_detail.html b/news/templates/news/ticket_detail.html index facc383e9..eb686611d 100644 --- a/news/templates/news/ticket_detail.html +++ b/news/templates/news/ticket_detail.html @@ -24,7 +24,7 @@

{% blocktrans with title=ticket.registered_event.title %}Ticket for “{{ ti {% else %}
{% trans "This ticket is registered to another account. Please" %} - {% trans "log in" %} + {% trans "log in" %} {% trans "to the correct account to see your ticket." %}
{% endif %} diff --git a/news/tests/test_urls.py b/news/tests/test_urls.py index f47bd1384..e967f691c 100644 --- a/news/tests/test_urls.py +++ b/news/tests/test_urls.py @@ -114,7 +114,7 @@ def test_all_get_request_paths_succeed(self): for time_place in self.time_places if time_place != self.time_place3 # can't test `time_place3`, as it has no tickets ], *[ - Get(reverse('ticket_detail', kwargs={'pk': ticket.pk}), public=False) + Get(reverse('ticket_detail', args=[ticket.pk]), public=False) for ticket in self.tickets ], Get(reverse('my_tickets_list'), public=False), From bcc6b1d3fe99f36f3a94955c42b2c61992a85994 Mon Sep 17 00:00:00 2001 From: Dabble Date: Thu, 24 Mar 2022 03:09:54 +0100 Subject: [PATCH 10/20] Refactored calendar and reservation API views Mainly that they're now class-based instead of function-based. Also moved and refactored some common code between the previous `get_reservation_rules()` and `get_machine_data()` views to a method on `ReservationRule` named `get_exact_start_and_end_times_list()`. --- make_queue/models/reservation.py | 13 ++++ make_queue/urls.py | 8 +- make_queue/views/api/calendar.py | 111 +++++++++++++++------------- make_queue/views/api/reservation.py | 57 +++++++------- 4 files changed, 110 insertions(+), 79 deletions(-) diff --git a/make_queue/models/reservation.py b/make_queue/models/reservation.py index 3298b9222..2feb4fa21 100644 --- a/make_queue/models/reservation.py +++ b/make_queue/models/reservation.py @@ -278,6 +278,19 @@ def __str__(self): def time_periods(self) -> List['Period']: return self.Period.list_from_start_weekdays(self.get_start_day_indices(), self.start_time, self.end_time, self.days_changed) + def get_exact_start_and_end_times_list(self, *, iso=True, wrap_using_modulo=False) -> List[Tuple[float, float]]: + mod_divisor = 8 if iso else 7 + + def mod(exact_weekday: float) -> float: + if not wrap_using_modulo: + return exact_weekday + return exact_weekday % mod_divisor + + return [ + (mod(p.exact_start_weekday), mod(p.exact_end_weekday)) + for p in self.Period.list_from_start_weekdays(self.get_start_day_indices(iso=iso), self.start_time, self.end_time, self.days_changed) + ] + def get_start_day_indices(self, *, iso=True): shift = 0 if iso else -1 return [int(day_index_str) + shift for day_index_str in self.start_days] diff --git a/make_queue/urls.py b/make_queue/urls.py index 1045f654e..0df36b353 100644 --- a/make_queue/urls.py +++ b/make_queue/urls.py @@ -16,13 +16,13 @@ ] calendar_urlpatterns = [ - path("/reservations/", api.calendar.get_reservations, name='api_reservations'), - path("/rules/", api.calendar.get_reservation_rules, name='api_reservation_rules'), + path("/reservations/", api.calendar.APIReservationListView.as_view(), name='api_reservations'), + path("/rules/", api.calendar.APIReservationRuleListView.as_view(), name='api_reservation_rules'), ] json_urlpatterns = [ - path("/", login_required(api.reservation.get_machine_data), name='reservation_json'), - path("//", login_required(api.reservation.get_machine_data), name='reservation_json'), + path("/", login_required(api.reservation.APIMachineDataView.as_view()), name='reservation_json'), + path("//", login_required(api.reservation.APIMachineDataView.as_view()), name='reservation_json'), path("/", permission_required('make_queue.add_printer3dcourse')(api.user_info.get_user_info_from_username), name='user_json'), ] diff --git a/make_queue/views/api/calendar.py b/make_queue/views/api/calendar.py index ca4e93c6d..830fd5540 100644 --- a/make_queue/views/api/calendar.py +++ b/make_queue/views/api/calendar.py @@ -1,9 +1,10 @@ from django.http import JsonResponse -from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.dateparse import parse_datetime +from django.views.generic import ListView -from ...models.machine import Machine +from ..reservation.reservation import MachineRelatedViewMixin +from ...models.reservation import Reservation, ReservationRule def reservation_type(reservation, user): @@ -16,54 +17,64 @@ def reservation_type(reservation, user): return "normal" -def get_reservations(request, pk: int): - machine = get_object_or_404(Machine, pk=pk) - start_date = parse_datetime(request.GET.get("startDate")) - end_date = parse_datetime(request.GET.get("endDate")) +class APIReservationListView(MachineRelatedViewMixin, ListView): + model = Reservation - reservations = [] - for reservation in machine.reservations.filter(start_time__lt=end_date, end_time__gt=start_date): - reservation_data = { - "start": reservation.start_time, - "end": reservation.end_time, - "type": reservation_type(reservation, request.user), + def get_queryset(self): + # TODO: use a form instead of manually parsing the URL parameters + start_time = parse_datetime(self.request.GET.get('startDate')) + end_time = parse_datetime(self.request.GET.get('endDate')) + return self.machine.reservations.filter(start_time__lt=end_time, end_time__gt=start_time) + + def get_context_data(self, **kwargs): + reservations = [] + for reservation in self.object_list: + reservation_data = { + 'start': reservation.start_time, + 'end': reservation.end_time, + 'type': reservation_type(reservation, self.request.user), + } + + if reservation.event: + reservation_data.update({ + 'eventLink': reverse('event_detail', args=[reservation.event.event.pk]), + 'displayText': str(reservation.event.event.title), + }) + elif reservation.special: + reservation_data.update({ + 'displayText': reservation.special_text, + }) + elif self.request.user.has_perm('make_queue.can_view_reservation_user'): + reservation_data.update({ + 'user': reservation.user.get_full_name(), + 'email': reservation.user.email, + 'displayText': reservation.comment, + }) + + reservations.append(reservation_data) + + return {'reservations': reservations} + + def render_to_response(self, context, **response_kwargs): + return JsonResponse(context) + + +class APIReservationRuleListView(MachineRelatedViewMixin, ListView): + model = ReservationRule + + def get_queryset(self): + return self.machine.machine_type.reservation_rules.all() + + def get_context_data(self, **kwargs): + return { + 'rules': [ + { + 'periods': rule.get_exact_start_and_end_times_list(iso=False, wrap_using_modulo=True), + 'max_inside': rule.max_hours, + 'max_crossed': rule.max_inside_border_crossed, + } for rule in self.object_list + ], } - if reservation.event: - reservation_data.update({ - "eventLink": reverse('event_detail', kwargs={'event': reservation.event.event}), - "displayText": str(reservation.event.event.title), - }) - elif reservation.special: - reservation_data.update({ - "displayText": reservation.special_text, - }) - elif request.user.has_perm('make_queue.can_view_reservation_user'): - reservation_data.update({ - "user": reservation.user.get_full_name(), - "email": reservation.user.email, - "displayText": reservation.comment, - }) - - reservations.append(reservation_data) - - return JsonResponse({"reservations": reservations}) - - -def get_reservation_rules(request, pk: int): - machine = get_object_or_404(Machine, pk=pk) - return JsonResponse({ - "rules": [ - { - "periods": [ - [ - day + rule.start_time.hour / 24 + rule.start_time.minute / (24 * 60), - (day + rule.days_changed + rule.end_time.hour / 24 + rule.end_time.minute / (24 * 60)) % 7 - ] - for day in rule.get_start_day_indices(iso=False) - ], - "max_inside": rule.max_hours, - "max_crossed": rule.max_inside_border_crossed, - } for rule in machine.machine_type.reservation_rules.all() - ], - }) + def render_to_response(self, context, **response_kwargs): + return JsonResponse(context) diff --git a/make_queue/views/api/reservation.py b/make_queue/views/api/reservation.py index 04f05ad47..86432c206 100644 --- a/make_queue/views/api/reservation.py +++ b/make_queue/views/api/reservation.py @@ -1,32 +1,39 @@ from django.http import JsonResponse from django.shortcuts import get_object_or_404 from django.utils import timezone +from django.views.generic import TemplateView -from ...models.machine import Machine +from ..reservation.reservation import MachineRelatedViewMixin from ...models.reservation import Quota -def get_machine_data(request, pk: int, reservation_pk: int = None): - machine = get_object_or_404(Machine, pk=pk) - reservation = get_object_or_404(machine.reservations, pk=reservation_pk) if reservation_pk is not None else None - return JsonResponse({ - "reservations": [ - {"start_time": c_reservation.start_time, "end_time": c_reservation.end_time} - for c_reservation in machine.reservations.filter(end_time__gte=timezone.now()) - if c_reservation != reservation - ], - "canIgnoreRules": any(quota.ignore_rules and quota.can_create_more_reservations(request.user) for quota in - Quota.get_user_quotas(request.user, machine.machine_type)), - "rules": [ - { - "periods": [ - [ - day + rule.start_time.hour / 24 + rule.start_time.minute / (24 * 60), - (day + rule.days_changed + rule.end_time.hour / 24 + rule.end_time.minute / (24 * 60)) % 7 - ] - for day in rule.get_start_day_indices(iso=False) - ], - "max_hours": rule.max_hours, - "max_hours_crossed": rule.max_inside_border_crossed, - } for rule in machine.machine_type.reservation_rules.all()], - }) +class APIMachineDataView(MachineRelatedViewMixin, TemplateView): + + def get_context_data(self, **kwargs): + if 'reservation_pk' in kwargs: + reservation_pk = kwargs['reservation_pk'] + # Check that it exists on the machine + _reservation = get_object_or_404(self.machine.reservations, pk=reservation_pk) + else: + reservation_pk = None + return { + 'reservations': [ + { + 'start_time': r.start_time, + 'end_time': r.end_time, + } for r in self.machine.reservations.filter(end_time__gte=timezone.now()).exclude(pk=reservation_pk) + ], + 'canIgnoreRules': any( + quota.ignore_rules and quota.can_create_more_reservations(self.request.user) + for quota in Quota.get_user_quotas(self.request.user, self.machine.machine_type) + ), + 'rules': [ + { + 'periods': rule.get_exact_start_and_end_times_list(iso=False, wrap_using_modulo=True), + 'max_hours': rule.max_hours, + 'max_hours_crossed': rule.max_inside_border_crossed, + } for rule in self.machine.machine_type.reservation_rules.all()], + } + + def render_to_response(self, context, **response_kwargs): + return JsonResponse(context) From 4ab631d9ba16270d77a1868205fe891981d68d61 Mon Sep 17 00:00:00 2001 From: Dabble Date: Thu, 24 Mar 2022 03:11:19 +0100 Subject: [PATCH 11/20] Added some missing tests to make_queue/test_urls --- make_queue/tests/test_urls.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/make_queue/tests/test_urls.py b/make_queue/tests/test_urls.py index a90f74b98..ed02124a6 100644 --- a/make_queue/tests/test_urls.py +++ b/make_queue/tests/test_urls.py @@ -4,6 +4,7 @@ from django.test import TestCase from django.utils import timezone from django.utils.dateparse import parse_time +from django.utils.http import urlencode from django_hosts import reverse from news.models import Event, TimePlace @@ -100,6 +101,12 @@ def setUp(self): def test_all_get_request_paths_succeed(self): year, week_number, _weekday = timezone.localtime().isocalendar() + # Create URL params to query all reservations + api_reservation_list_url_params = urlencode({ + 'startDate': Reservation.objects.earliest('start_time').start_time.isoformat(), + 'endDate': Reservation.objects.latest('end_time').end_time.isoformat(), + }) + path_predicates = [ # urlpatterns Get(reverse('machine_list'), public=True), @@ -112,9 +119,19 @@ def test_all_get_request_paths_succeed(self): ], # Back to urlpatterns - Get(reverse('machine_detail', kwargs={'year': year, 'week': week_number, 'pk': self.printer1.pk}), public=True), + *[ + Get(reverse('machine_detail', kwargs={'year': year, 'week': week_number, 'pk': machine.pk}), public=True) + for machine in self.machines + ], # calendar_urlpatterns + *[ + Get( + f"{reverse('api_reservations', args=[machine.pk])}?{api_reservation_list_url_params}", + public=True, + ) + for machine in self.machines + ], *[ Get(reverse('api_reservation_rules', args=[machine.pk]), public=True) for machine in self.machines From 3af9a2b9c95bcf0c414cd42730431ffa8804e878 Mon Sep 17 00:00:00 2001 From: Dabble Date: Thu, 24 Mar 2022 03:37:15 +0100 Subject: [PATCH 12/20] Fixed docs CKEditor form widgets not rendering This was caused by a JavaScript error, due to the `CKEDITOR_CONFIG_FROM_DJANGO` variable not being defined, because `config_from_django.js` is not linked when the widget is a `ckeditor_uploader/widgets/CKEditorUploadingWidget` instead of a `web/widgets/CKEditorUploadingWidget` (as `Content.content` is a `RichTextUploadingField`, which is defined in the `django-ckeditor` package). --- docs/forms.py | 7 +++++++ web/static/ckeditor/ckeditor/config.js | 3 +++ 2 files changed, 10 insertions(+) diff --git a/docs/forms.py b/docs/forms.py index b819b5fb1..24ee8857d 100644 --- a/docs/forms.py +++ b/docs/forms.py @@ -1,6 +1,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ +from web.widgets import CKEditorUploadingWidget from .models import Content, Page @@ -25,6 +26,12 @@ class Meta: 'content': {'required': _("The page is currently empty; please add some content.")}, } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Have to set the widget here instead of in `Meta.widgets` above, + # as the `widget` kwarg is always overwritten in `RichTextUploadingFormField` + self.fields['content'].widget = CKEditorUploadingWidget() + def clean(self): cleaned_data = super().clean() page: Page = cleaned_data.get('page') diff --git a/web/static/ckeditor/ckeditor/config.js b/web/static/ckeditor/ckeditor/config.js index c659a8230..4e0d485c1 100644 --- a/web/static/ckeditor/ckeditor/config.js +++ b/web/static/ckeditor/ckeditor/config.js @@ -1,6 +1,9 @@ /* Note: To make this configuration file apply, the `ckeditor` app must be listed after `web` in `INSTALLED_APPS`. */ +// `CKEDITOR_CONFIG_FROM_DJANGO` is defined in `config_from_django.js` +var CKEDITOR_CONFIG_FROM_DJANGO = (typeof CKEDITOR_CONFIG_FROM_DJANGO !== "undefined") ? CKEDITOR_CONFIG_FROM_DJANGO : {}; + const customStylesName = "my_styles"; CKEDITOR.stylesSet.add(customStylesName, [ {name: gettext("Big"), element: "big"}, From 5d61cb82203a08c50ab93640ca73d38a8f9f2bf1 Mon Sep 17 00:00:00 2001 From: Dabble Date: Thu, 24 Mar 2022 03:45:06 +0100 Subject: [PATCH 13/20] Fixed some tests failing across DST change DST (daylight saving time) starts in Norway in less than a week, and so some of the tests comparing local datetimes with UTC datetimes (which is what the database provides when getting model objects with datetime fields) fail. --- news/tests/views/test_event_time_place_views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/news/tests/views/test_event_time_place_views.py b/news/tests/views/test_event_time_place_views.py index 3640a9631..a349bb004 100644 --- a/news/tests/views/test_event_time_place_views.py +++ b/news/tests/views/test_event_time_place_views.py @@ -56,8 +56,8 @@ def test_event_edit(self): self.assertEqual(response.status_code, HTTPStatus.OK) def test_timeplace_duplicate(self): - time_place = TimePlace.objects.create(event=self.event, start_time=timezone.localtime() + timedelta(minutes=5), - end_time=timezone.localtime() + timedelta(minutes=10)) + time_place = TimePlace.objects.create(event=self.event, start_time=timezone.now() + timedelta(minutes=5), + end_time=timezone.now() + timedelta(minutes=10)) response = self.client.post(reverse('timeplace_duplicate', args=[self.event.pk, time_place.pk])) self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) @@ -76,7 +76,7 @@ def test_timeplace_duplicate(self): def test_timplace_duplicate_old(self): self.user.add_perms('news.add_timeplace', 'news.change_timeplace') - start_time = timezone.localtime() - timedelta(weeks=2, days=3) + start_time = timezone.now() - timedelta(weeks=2, days=3) end_time = start_time + timedelta(days=1) new_start_time = start_time + timedelta(weeks=3) new_end_time = end_time + timedelta(weeks=3) From a098cf06cf8db052b619451e0286b98b5cc32d32 Mon Sep 17 00:00:00 2001 From: Dabble Date: Thu, 24 Mar 2022 03:57:41 +0100 Subject: [PATCH 14/20] Code cleanup --- docs/models.py | 4 +- docs/views.py | 25 ++++----- groups/apps.py | 1 - internal/apps.py | 1 - internal/signals.py | 2 +- make_queue/converters.py | 4 +- .../templatetags/test_reservation_extra.py | 53 ++++++++----------- make_queue/views/api/calendar.py | 13 ++--- make_queue/views/reservation/machine.py | 2 +- make_queue/views/reservation/reservation.py | 3 +- news/ical.py | 4 +- news/views.py | 5 +- util/test_utils.py | 2 +- 13 files changed, 54 insertions(+), 65 deletions(-) diff --git a/docs/models.py b/docs/models.py index 7c20b9895..0290249ec 100644 --- a/docs/models.py +++ b/docs/models.py @@ -11,7 +11,6 @@ class Page(models.Model): """Model for each individual documentation page.""" - title = models.CharField(max_length=64, unique=True, verbose_name=_("title"), validators=[page_title_validator]) created_by = models.ForeignKey( to=User, @@ -21,7 +20,7 @@ class Page(models.Model): related_name='doc_pages_created', ) current_content = models.OneToOneField( - to="Content", + to='Content', on_delete=models.SET_NULL, null=True, blank=True, @@ -40,7 +39,6 @@ def get_main_page(cls) -> 'Page': class Content(models.Model): """The content of a documentation page. All versions are kept for editing history.""" - page = models.ForeignKey( to=Page, on_delete=models.CASCADE, diff --git a/docs/views.py b/docs/views.py index db6553617..d3f512324 100644 --- a/docs/views.py +++ b/docs/views.py @@ -30,7 +30,7 @@ class SpecificPageBasedViewMixin: class DocumentationPageDetailView(SpecificPageBasedViewMixin, DetailView): template_name = 'docs/documentation_page_detail.html' - context_object_name = "page" + context_object_name = 'page' extra_context = {'MAIN_PAGE_TITLE': MAIN_PAGE_TITLE} is_main_page = False @@ -43,12 +43,12 @@ def get_object(self, *args, **kwargs): class HistoryDocumentationPageView(SpecificPageBasedViewMixin, DetailView): template_name = 'docs/documentation_page_history.html' - context_object_name = "page" + context_object_name = 'page' class OldDocumentationPageContentView(SpecificPageBasedViewMixin, DetailView): template_name = 'docs/documentation_page_detail.html' - context_object_name = "page" + context_object_name = 'page' extra_context = {'MAIN_PAGE_TITLE': MAIN_PAGE_TITLE} content: Content @@ -103,7 +103,7 @@ def get_form_kwargs(self): def form_invalid(self, form): try: - existing_page = Page.objects.get(title=form.data["title"]) + existing_page = Page.objects.get(title=form.data['title']) except Page.DoesNotExist: existing_page = None if existing_page: @@ -125,7 +125,7 @@ class EditDocumentationPageView(PermissionRequiredMixin, CustomFieldsetFormMixin def get_initial(self): return { - "content": self.object.current_content.content if self.object.current_content else "", + 'content': self.object.current_content.content if self.object.current_content else "", } def get_form_kwargs(self): @@ -167,7 +167,8 @@ class SearchPagesView(TemplateView): template_name = 'docs/search.html' page_size = 10 - def pages_to_show(self, current_page, n_pages): + @staticmethod + def pages_to_show(current_page, n_pages): if current_page <= 3: return range(1, min(n_pages + 1, 8)) if current_page >= n_pages - 3: @@ -177,15 +178,15 @@ def pages_to_show(self, current_page, n_pages): def get_context_data(self, **kwargs): context_data = super().get_context_data(**kwargs) - query = self.request.GET.get("query", "") - page = int(self.request.GET.get("page", 1)) + query = self.request.GET.get('query', "") + page = int(self.request.GET.get('page', 1)) pages = Page.objects.filter(Q(title__icontains=query) | Q(current_content__content__icontains=query)) n_pages = ceil(pages.count() / self.page_size) context_data.update({ - "pages": pages[(page - 1) * self.page_size:page * self.page_size], - "page": page, - "pages_to_show": self.pages_to_show(page, n_pages), - "query": query, + 'pages': pages[(page - 1) * self.page_size:page * self.page_size], + 'page': page, + 'pages_to_show': self.pages_to_show(page, n_pages), + 'query': query, }) return context_data diff --git a/groups/apps.py b/groups/apps.py index d27fde564..7d51f6371 100644 --- a/groups/apps.py +++ b/groups/apps.py @@ -7,7 +7,6 @@ class GroupsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'groups' - # noinspection PyUnresolvedReferences def ready(self): # Register / connect to the signals here when the app starts signals.connect() diff --git a/internal/apps.py b/internal/apps.py index 68310eeec..0b979c31a 100644 --- a/internal/apps.py +++ b/internal/apps.py @@ -7,7 +7,6 @@ class InternalConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'internal' - # noinspection PyUnresolvedReferences def ready(self): # Register / connect to the signals here when the app starts signals.connect() diff --git a/internal/signals.py b/internal/signals.py index 5e5f28de6..c8cb569c0 100644 --- a/internal/signals.py +++ b/internal/signals.py @@ -8,7 +8,7 @@ def connect(): from .models import Member @receiver(m2m_changed, sender=Member.committees.through) - def member_update_user_groups(sender, instance: Member, action='', pk_set=None, **kwargs): + def member_update_user_groups(sender, instance: Member, action, pk_set=None, **kwargs): """ Makes sure that the member is added/removed from the correct groups as their committee membership changes. """ diff --git a/make_queue/converters.py b/make_queue/converters.py index 361f561a2..f007ae478 100644 --- a/make_queue/converters.py +++ b/make_queue/converters.py @@ -1,5 +1,5 @@ class Year: - regex = "([0-9]{4})" + regex = r"([0-9]{4})" def to_python(self, value): return int(value) @@ -9,7 +9,7 @@ def to_url(self, year: int): class Week: - regex = "([0-9]|[1-4][0-9]|5[0-3])" + regex = r"([0-9]|[1-4][0-9]|5[0-3])" def to_python(self, value): return int(value) diff --git a/make_queue/tests/templatetags/test_reservation_extra.py b/make_queue/tests/templatetags/test_reservation_extra.py index 507a67afd..2e9da71d4 100644 --- a/make_queue/tests/templatetags/test_reservation_extra.py +++ b/make_queue/tests/templatetags/test_reservation_extra.py @@ -7,7 +7,7 @@ from django.utils import timezone from users.models import User -from util.locale_utils import attempt_as_local, parse_datetime_localized +from util.locale_utils import parse_datetime_localized from ...models.course import Printer3DCourse from ...models.machine import Machine, MachineType from ...models.reservation import Quota, Reservation @@ -35,7 +35,7 @@ def test_calendar_reservation_url(self, now_mock): start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=2)) - self.assertEqual(current_calendar_url(printer), calendar_url_reservation(reservation)) + self.assertEqual(calendar_url_reservation(reservation), current_calendar_url(printer)) @mock.patch('django.utils.timezone.now') def test_current_calendar_url(self, now_mock): @@ -52,8 +52,7 @@ def test_current_calendar_url(self, now_mock): @mock.patch('django.utils.timezone.now') def test_is_current_data(self, now_mock): - date = timezone.datetime(2017, 3, 5, 11, 18, 0) - now_mock.return_value = attempt_as_local(date) + now_mock.return_value = parse_datetime_localized("2017-03-05 11:18") self.assertTrue(is_current_date(timezone.now().date())) self.assertTrue(is_current_date((timezone.now() + timedelta(hours=1)).date())) @@ -62,40 +61,32 @@ def test_is_current_data(self, now_mock): @mock.patch('django.utils.timezone.now') def test_get_current_time_of_day(self, now_mock): - def set_mock_value(hours, minutes): - date = timezone.datetime(2017, 3, 5, hours, minutes, 0) - now_mock.return_value = attempt_as_local(date) + def set_mock_value(time: str): + now_mock.return_value = parse_datetime_localized(f"2017-03-05 {time}") - set_mock_value(12, 0) - self.assertEqual(50, get_current_time_of_day()) + set_mock_value("12:00") + self.assertEqual(get_current_time_of_day(), 50) - set_mock_value(0, 0) - self.assertEqual(0, get_current_time_of_day()) + set_mock_value("00:00") + self.assertEqual(get_current_time_of_day(), 0) - set_mock_value(13, 0) - self.assertEqual((13 / 24) * 100, get_current_time_of_day()) + set_mock_value("13:00") + self.assertEqual(get_current_time_of_day(), (13 / 24) * 100) def test_date_to_percentage(self): - date = timezone.datetime(2017, 3, 5, 12, 0, 0) - self.assertEqual(50, date_to_percentage(date)) - - date = timezone.datetime(2017, 3, 5, 0, 0, 0) - self.assertEqual(0, date_to_percentage(date)) - - date = timezone.datetime(2017, 3, 5, 17, 0, 0) - self.assertEqual((17 / 24) * 100, date_to_percentage(date)) - - date = timezone.datetime(2017, 3, 5, 17, 25, 0) - self.assertEqual((17 / 24 + 25 / (24 * 60)) * 100, date_to_percentage(date)) + self.assertEqual(date_to_percentage(parse_datetime_localized("2017-03-05 12:00")), 50) + self.assertEqual(date_to_percentage(parse_datetime_localized("2017-03-05 00:00")), 0) + self.assertEqual(date_to_percentage(parse_datetime_localized("2017-03-05 17:00")), (17 / 24) * 100) + self.assertEqual(date_to_percentage(parse_datetime_localized("2017-03-05 17:25")), (17 / 24 + 25 / (24 * 60)) * 100) def test_invert(self): - self.assertEqual("true", invert(0)) - self.assertEqual("false", invert(1)) - self.assertEqual("true", invert("")) - self.assertEqual("false", invert("true")) - self.assertEqual("false", invert("test")) - self.assertEqual("true", invert(False)) - self.assertEqual("false", invert(True)) + self.assertEqual(invert(0), "true") + self.assertEqual(invert(1), "false") + self.assertEqual(invert(""), "true") + self.assertEqual(invert("true"), "false") + self.assertEqual(invert("test"), "false") + self.assertEqual(invert(False), "true") + self.assertEqual(invert(True), "false") def test_get_stream_image_path_returns_correct_image_path(self): no_stream_image_path = static('make_queue/img/no_stream.svg') diff --git a/make_queue/views/api/calendar.py b/make_queue/views/api/calendar.py index 830fd5540..5eec39e6f 100644 --- a/make_queue/views/api/calendar.py +++ b/make_queue/views/api/calendar.py @@ -3,18 +3,19 @@ from django.utils.dateparse import parse_datetime from django.views.generic import ListView +from users.models import User from ..reservation.reservation import MachineRelatedViewMixin from ...models.reservation import Reservation, ReservationRule -def reservation_type(reservation, user): +def reservation_type(reservation: Reservation, user: User): if reservation.special: - return "make" + return 'make' if reservation.event: - return "event" - if user is not None and reservation.user == user: - return "own" - return "normal" + return 'event' + if user == reservation.user: + return 'own' + return 'normal' class APIReservationListView(MachineRelatedViewMixin, ListView): diff --git a/make_queue/views/reservation/machine.py b/make_queue/views/reservation/machine.py index b4f7ab9a3..3649575e3 100644 --- a/make_queue/views/reservation/machine.py +++ b/make_queue/views/reservation/machine.py @@ -11,7 +11,7 @@ class MachineListView(ListView): - """View that shows all the machines - listed per machine type.""" + """View that shows all the machines -- listed per machine type.""" model = MachineType queryset = ( # Retrieves all machine types that have at least one existing machine diff --git a/make_queue/views/reservation/reservation.py b/make_queue/views/reservation/reservation.py index 6bbd3c36b..df6c42c32 100644 --- a/make_queue/views/reservation/reservation.py +++ b/make_queue/views/reservation/reservation.py @@ -25,7 +25,6 @@ # so that it's more extendable, and makes more use of the functionality of forms and Django's `CreateView` and `UpdateView` class CreateOrEditReservationView(TemplateView, ABC): """Base abstract class for the reservation create or change view.""" - template_name = 'make_queue/reservation_edit.html' def get_error_message(self, form, reservation): @@ -139,7 +138,7 @@ def dispatch(self, request, *args, **kwargs): :param request: The HTTP request :return: HTTP response """ - if request.method == "POST": + if request.method == 'POST': return self.handle_post(request, **kwargs) return super().dispatch(request, *args, **kwargs) diff --git a/news/ical.py b/news/ical.py index 129f20140..07464e00d 100644 --- a/news/ical.py +++ b/news/ical.py @@ -50,7 +50,7 @@ def product_id(self): class SingleEventFeed(EventFeed): - """An iCal feed of all occurences of a single event.""" + """An iCal feed of all occurrences of a single event.""" def file_name(self, attrs): title = self.items(attrs).values_list('event__title', flat=True).first() @@ -64,7 +64,7 @@ def get_object(self, request, *args, **kwargs): class SingleTimePlaceFeed(EventFeed): - """An iCal feed of a single occurences of an event.""" + """An iCal feed of a single occurrences of an event.""" def file_name(self, attrs): title = self.items(attrs).values_list('event__title', flat=True).first() diff --git a/news/views.py b/news/views.py index 730a25691..e6aadbedc 100644 --- a/news/views.py +++ b/news/views.py @@ -338,8 +338,9 @@ class DuplicateTimePlaceView(PermissionRequiredMixin, PreventGetRequestsMixin, T fields = () def form_valid(self, form): - if timezone.localtime() > self.time_place.start_time: - delta_days = (timezone.localtime() - self.time_place.start_time).days + now = timezone.now() + if now > self.time_place.start_time: + delta_days = (now - self.time_place.start_time).days weeks = math.ceil(delta_days / 7) else: weeks = 1 diff --git a/util/test_utils.py b/util/test_utils.py index 086f55765..ea95aef22 100644 --- a/util/test_utils.py +++ b/util/test_utils.py @@ -170,7 +170,7 @@ def do_request_assertion(self, client: Client, is_superuser: bool, test_case: Si language_prefixes = self.LANGUAGE_PREFIXES if self.translated else [""] for prefix in language_prefixes: path = self._prepend_url_path(prefix, self.path) - with test_case.subTest(path=path): + with test_case.subTest(path=path, is_superuser=is_superuser): self._do_request_assertion_for_path(path, prefix, client, is_superuser, test_case) def _do_request_assertion_for_path(self, path: str, language_prefix: str, client: Client, is_superuser: bool, test_case: SimpleTestCase): From fa8083700b5f9d0d2adaca9cc84841ed2049f4a2 Mon Sep 17 00:00:00 2001 From: Dabble Date: Thu, 24 Mar 2022 03:58:32 +0100 Subject: [PATCH 15/20] Compiled translations and updated comments Also added a missing translation for "shown". --- locale/nb/LC_MESSAGES/django.mo | Bin 45930 -> 45957 bytes locale/nb/LC_MESSAGES/django.po | 253 +++++++++++++++--------------- locale/nb/LC_MESSAGES/djangojs.po | 8 +- 3 files changed, 133 insertions(+), 128 deletions(-) diff --git a/locale/nb/LC_MESSAGES/django.mo b/locale/nb/LC_MESSAGES/django.mo index 364433761c4b8e4063c1a8293fa8dee32eda2d8e..33613b6707dc29153a2dafabc9121dafc0897e19 100644 GIT binary patch delta 9946 zcmXZh34G5-{>Sm}FOftdl1Lm$kV8Zgk;@gt5g~}gRY%-MT|pgnMP1*nIM!ACe<^ho zZCBk&E&f<-`M0Htbu9f?RlEKzx^)!Q(rwrO^)}N-yU&^V&UfZBpPBjowEM4{J_~O7 zxYxrgyzcN)UPp1g6pc zzgP$To0<%ypaO4#3b;L1#ZH*4`3F%@WD7A6%TX)bi0Sw)zJQmpJtmX)I2@1h_!=rx zdr>Ptj2h=-tcT}NnRtxq7tqWYhOQ=#qM(jRNLHM#*b~dJHGYA)SiQO9L}M}5#>q%b z&PuF{CovZ9U={|mum0E?m63c@CQ7h6mbM`OitIHS$TuiOV&Ir$6eRyQl$nq9Q$j zn($*(>Mo-Kx@POQQ5m?8{Ox$!nroJaT2KenIDJt4M%sEQM(X}Aq!7)6O;`($U`_l2 zm7+(eE%*=WOjOS^r#BV#ULNXR4?+bv47CL%sQ1U)_8F*vTw7m(e$4Nzrl5g0U|-ya zd_$a|c8*%ikx-*I|ldo03rsIzbj75LBS zYEMEtn1&jdO}!ze;8@g_l%pp82)TJqaz``Za8&=j$i;AOBV#&=+))KE&bk9Rx6Tj9 z+MJ{U$LWP*3&?*gg%4@a2jLsk>Hh`&F}SnY>j=~y#-h$bI!?ki7>|2V8N7%Icnbrt zQlZ&`2-KmD!9Z+)8n0;~`PYv_J`H=YFDj4#@}-GlFbFeHscV6iup??Od!jNh9Aj|3 zeSQQ7QU4T&V%;ugYZu@^>eo>L=D1zWgzZp)^hZrR9({2xDiiZi6EDVKd>ystZ(}-s zhYB>Jn^|EDD!^o0Z-#oFhZ?^NYCLx^1r0bBm7-EqiWXo9uCUKH+V-8O+i?Ij;03IR z*Dw^n#|roueegF_zdul0U9r2l|H)XN`5l)+G7om3GH}Uy6GN##MSXYzdzc@QI;a)r zqxw%p1-uBW;40MNdk6LY4b(~>VmIRz9nVL#Lsl%NKhj+%Hb zX5mt-gU3(V;#d6n}~e@N?9Jmr=LjChAPwMXk`E&!#d~6%|k|tcEGJ-UgN7BGk$Upx&FeG7`m7$0MCg5mHq@IS#U{6%0 zN1*z-B@}8>n26fjrAStty{G{z^0}hubVZ$k9XJKAU>bJjGpAcH0~_H%)P(=SI1C$X z7LtW(?|{07FJVpHe=h}fd=H!8d0Y4AF6y)=VMFYUO5q$-zj92%)7FQ!J$k5luRH3n zO~Dx4hzj@^7T}NAi}{@)XM zi$m>ML(~L$sKZ)l>jO~%kHlb{h?;mds(%^k`7+cvt5I9=E-KK!VnyBm!xVyPIDs1A z@2HMfZ2Nbp0Ux0D@HbRHpYbN(K-7St$QRA2iaNZRs1H^L)OTVK2H^};KxJ4#_uorF z1HXZla5GlMT^Nie; z|M#Y#0EVMp7>9ac5^A97sFl5nTEPZ<{zXG&;y7yHQ&<`QVcTz@0{p=~e~KEIrsDS6AGPcgP z??PqtxUHYWdepCB9J;>K%&%2lR6|G9&t(ZF;v)New{1UKGx3S=AV*K!YPZ!cmEypGzcUr>kEf0lV(6@zsD<0wR7Dk_Ef*b9512Hb9+ zpGEE6EmUSgW}91)g$=0>L~Y#)RKR;M62C)j#UH2)gw8Sdzd5>!u!w?A?FiI0d&zcK zgNl3yDzGD{lzxWle-R__Dk{bI?DHq6jClCdNjL_e>d~mf9f#F0Z7%tbrqGUtS~wV0 zpM`p1HEO~QsC&HyHQ_;20H?7kUbN3|p#ptk4IsVhUmew6AC-ybsPPJ23filIsE#FA z9cQCDdQmCgVxOO}ev5kVXB>&nt7ZYCP=S?UKU|7!@Ow&G$8t7F_ z#AT>M@;)k6AE5?3iwfv6w#QrOiw)+PJ
v;eiTA*dBkLY*xaV{t9U<6+d+%eBSc1w-N)s@KdNWi;iqRJ*pdz1a>#v};Xbx(eg{VMRq58dp+R7cM@%Lg+{0v*` z{@0#wKCuH)DO-a>@d4_zzOcZgY&N!|zRlL}pi&#Tkh_h2QSbi^b?B-uGXJKOf;yZX zu_pFFWnw%g=>9LK5J$s49E=ySEoS|hmv9PZ;!(`SJJz(tCV&a3_C1)7_puu`S;C)i zaRF+=Tc}J1EHzsdgZ|9#G^U_4kYnwLI*dJ$MLMG~4)3D&IAoa_ur3BrZ;wiS5o+&? zF%U~o*LyPRy(PF4H{152<>X)Ydjy3_IMw>9bs6gU8<>sTFcH7SVDx{@aYkVTY71s! zU%ZaWWcCUZ$ZFKeH>0lWAylAeR*-*R3SZOUkN-ri{5EQW-!TsZSDJ5j5o)CiPyw$* z?ezu>!}l-*Kf+M_JF4F|)*r3EVi@hdtH{45s=CU&5RVn9XQ2*D3)FypP^lhi>*LXn z`fSt!=Ai~$h#9yB$KpxM!G`~34*3MEO?^8m6DM5?dhr@6MR&0tR`8k^Qc+*VQK$*a zQ2iHUGhBxX@DggJH!unBq54%XH)kaY+feU_Q*aULJ@?-fA}9pCZdM*+O+p=_Ow<;% zzyh3OpP#hP&to^*uc9WbzuFwO98|`7pblX_tcovVI4(rC+;!Gc(2JWf3-@6H{vRrU zfHh|2RZ#UBs0_uU&O#^DN_yJ*iT4mSJVwh!b%a#$v6tCSxs8f#qW)cEd(kf}L;!>TrLL74Qx!GY|0jfB%0d#Q)okPuY1r_KW9ExEZ z+5fH-Uf#$bI`AWGg)KMn+l(_Y6@NzUY20RWzk6Xi_4yc$`%x=9hnnC9#-smRCX>ln zn|dqE!U0$dm%9{VDeS;zcpA0WzHggAs-afY6LqS`VGfpK9XyXI_z2^$_B-ZzYwSyX zCicNgwqE})j6uB&yP$iYf>zpUi}_>tA@rsG0F}DOsKfFHKEHnNn%{zWtWSGC^uzg> zh)YoY_M$%?L5+7DHQpzvtvid=b^otW(EYuOI#j=-4qe5qCdEm}RdxE{a=eGlarQRz zA1n`|GWHcV!MhlM3ERyVH5GMs@=&*>JGQ}Ln8*CiTNL7H_zo3_{|<9_s-ZviXsn3w z*7~RwHL}mM(T93#R3L3J1$&~lXbx)p&8V|<0w>`;tj7G#kew!_(@r92L`qI#$Rvr#L}#bE4;{c!;5HXOu2Jc>d13A!qrw+&yR2D)kM_pv?o z-)z0jZZkkDB?oAzghx>UokUG= z(RvlN(i^D$k5B_V#mcDv?@sTBpnhDUQD-O(bv=tv0gOhC|1#>j&fY`*Ifc$j8Wh=9 z)WFA4dwB{ycoj9lHPqhUM)kX6+aI9+maLj~Rd)!qu#ue)vU<5CErVKi#R z(@`Dg+WIO~iq~Q=zHQszLj`ci`T=U9Pf&qgK}~c6wFS3P(32FgaIwk0ai0jS@K30M=$P?_6=VYmvFFj2hr3>cyW>nfMhoVZeU#0Sd>q)T^PM7o)x-qfv)AmVXNPQq7_=jB++* z?_UzbJYBp85-)l3yo>7{_2hcDG7umgGWDU>k~>q1gL0 zrO@+&H^2T;Ph0PW`dfWk`gmui-iYc3ETU`gqT#H}Z7#dNLMydUzLSwDar8)7IV#8UOaQ^PbJj@O1M2 rky+~L=AD*R<|*3$G^?a#g_4P-6ZQ|8(6n8J>0_o$-Cw@%is%0UWWiHS delta 9905 zcmXZg34G5-{>Sm}FBftjB9SCCtMrSddBn71<0LlIvi_Yv8`{yH0mc%OY3BrC9009=Vp6a zcTpWbMBS%#ihywR!6a0Bebm6&sHN_LYK-mn_ABp+fl?nXua zE!M(Iw(iy19J2^iU`aRx(@;xQg34qWK97G#wujpZZDTf17t}c~M0Kzd73l`lfV)wt zJAw-6n5~~f&Gbj)$GOKpI%XMd&4ikw`sswaub-_KV6@Kv6bdn1D8XvD8DsHFREn;m zmf#L*Px!SnyEhi~Tn1{uZm0lzp_U*I_55JlJ_?oLLR)vEFXKCNDX8NGn1^eTcZc&k z@=|ot+Vi@_;aGs5Vna;gWsr(}k=UHsn1V-8c1^#bzYfYZn zh6DA~~Z9V4!scvQM4!$ef(0 z9D>FHIpn`4g&j2Lg>VA3`>&%P{ui~@-d)TZhM@LB4IGcD7>}i>41SKa@GMrqr|6H~ zxn@%bVJ` zIGFk#%*V>z%+gNALDY|<0<7=uZU$_G3Zx5a;KAsP<4~EHfExG}48%F8HD8P=_&t`- zw1=5tFe<=kTTez^&p`E`jq1U3;Cb@&-p#A6tO zr_l$mp%>ml-FF+c)Q?f;Ke{K`)cG%@kW9l0R0h7Vp1~06H&HL1CzycYz08c8pza@z z3V14p;VjhV^PrwTg_`LVOu{>;iADBSzpQ_43fh&~I2321Zukkq@fTEvy!c4eOhZsJ zj7IJ1>X?fIQER#pb>9wTEN35vp+8$b0;{2}r(p==J1r<^z#P;P zF^GlJ04*>ShoCwx#3}eHYK`xsCJ@oz1Qv@?)N3Pa?qngK$WB3j@~;`Jqd~iU6KW>g zP$}Mn3h)4Gz$2*Ba0ay}E}~}m5H+9=uTKROh>=*;)>Ba#&O*&R7xmoGJo2xZjFG4kWI;}ZMI=p z1K&mkybW{k0`}GUPkhmFy3;TXHPe5h-Uo?);kApSP^aPoDs`d5%u-aw80ytgo3%Nn zVKyeJOqf<2zqa(C+;bwZjtlp=6FP42kHsf4hygrZbt3?5p2fZo*qKpk6GfvE?ec6$hFk5tEa>{USib$lk$pv|=q$KgTLDX2fnyeQhD z*6uZ|iCa*a`37~K&!Hdw$9f-i-hao6SZTD$XgDgc1k@&PFq-`9!Pzuujpm~s*nmp; zcGQ~fx9vx4{WxmZpT|lVHO6Ej9#yZ4dae;_=B=;-c188u7nR9;w`~}KIwmip)@ZK1 zVS&BA$hsOe^G)cFdr;4PhU)kX>iH|EiTrHq_fXG0LS@Y7uVxRpqbcadbX4S-s2e(< z);tfR@UN(ji){Ow=%T&~_56FN8Er&mbgOOOg?jEFs=v=s{hvlM<92S^3y)AY`oCm8 z-9k~PAOST%25PgmwDqp2fcs$}4n+<866*emsOzty`k9MbilwMP*JDMU|4kGEY1oPC z;2)?PkJ|R{Q5{}Jt>G=yeZQgteuC=o8S+MRe8!s18;5#fHATHAx}iUgLIpGteRTed zD5&E%FaQ@~5U#>N+>A=yUR3J7Mg?>U)zJ-9W**u0iX0)$JPZ|BEGp1ARR2k+{xY!q z_y3L*6hI%;0|QYVhdVI{Keg@0P=Wo+UcZX!?-%P6)Og;7 z?Q7@h!F%utPb4;6HIvj2Nn{^)YIpr)x&FBHP#XnF3x1MO; z6I0QP`p2m8_MtL(*iE4!g>O)6{REY2ze#2$VW{&RgGzlWY9=|h{RLFO<1h?o+xDfX zHQ!?EWtc?$D8}J!)Q6Nic(Q3oM}4^T#zcG>b;G;1eLps)eje*$@DvkRb5yFkp_XV6 zDv%d387JE7tFU~}pq6MSw$S-MK!I;j&I8nkPWP#1;6n%|CcR=l}Zm59!V{@E^e0Op7V>_Mys8`H?Dh)twj&;^AunG06sDY|ZGZ|`& z>D2R4fh;9k_) zeTQ0-$EZ^g`>Oe7)Csk8ub={6hS7K&wG=l{8F+%>SnD+tU{fsr{l7Z}b@)8$hFPe{ zm!JY$k4ot-)Gj}W5qKDt;&b-;6;ww4AJxx2Tle8xiZ-`DMq(7kV8a>YzZ!+kwqXRS zgPEuS=c3Ma32MMnQ~oDN`s2bW=cS1q%C@59xC!dwmuBCLQ!!2(WF>_mG#tdX82bjlx8Yz+!wuL5&sd}0Gy&wH+LvKRJdZuFW-)I+ zEJO|X9V(OeP)k*DzR6Gx3}AdGQ3Xs#ZN_Y5l1?v-!?UOdAEP=9USRe{3i?xTidy@& zSP6ThUOQXAD_8?1mmY`s7F zQXh$$!AqzP$73qa!ZBEeEwS2S^F1ODwJANQOq8LXJL;yO6rIH+yn~q->9H>s)PQ4A z4@^X5WHu_mPf#;GhIQ~9>ONu#FtUeUBd`;|3;xAg^KSO!%&+j2DJpW zF$W9m^)h>XKlY&gFlxZcOU-6WL}jccY7@4{$~X|iaXf0ti;(BsPBDda8djpt<+rE+ z?xAM>)Ye_^nhXV?c6|nFCfT;$6_cs=N6l;|DlFVq;L7H6L}~XzS~!39LkAa2Kk-eW-yBp#nXF`S?3_ z$ARyW|LGJqzh}OuC#~UY6!qa)A1|TS)W6i6?^c*XeH_N%YShg3p$0gH@pu=t>%-TY zb6*Fw$va{-oQgGZiJL-m3cFEjeH*j!8EQt^@0(rS4_i{7feE-D>*7U>!wT!n^<>PW zJ{+INPi(#NdVYncJ{G&-dDKMRbvBfL4|mq0Hw_n1srwPNH*TQT(Di}&EC|4Q)Z3#k zj>ANpgt~79`r&$1e_K%f?LaNvUaW$LkmKui&Qj2(x{lg(zoJqcwvk^xunoS2=dcBi z{LuVIWhp9SN3aQ=MGX|R$qW#Q+B*$UrzI0xV=lJC`53SBf1Co5ICoK-=NbB;&t?;G zfVDDeM%7W*qyiloVK0(E3!p2 zsN*fDwcLp=Jd7IPC~ECbq3%0l+b^O5yNL?m7c76%?J$8?LABRG-IrgTNWCscnoQ5kVRv=>|- znH&9414LM3P#wjij%N}o(2l4#Tpq^aSXAcT#86y`dTuK!fHG8o`%%09D6)iZ=LZVv z@D{3rKTvBHQf3~giXqe!Q4u$>wn1es7d7KPr~zI;4KM}1Y1$)D0o6iXPeBEcg_=NTYj0G4gD@0_p#qzP>G>3j zC@8{@Q8(;Ib?^=9!PBTfen1WQAJmKI5w^wO?e(l(=KawXwRvmux3gzMVog_9&ymD` zx!QV`CmnFL_VlYW-_^x)xsJ!x+cP=2rEhyW>%Z&c\n" "Language-Team: Norwegian Bokmål \n" @@ -14,6 +14,10 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 2.2.1\n" +#: announcements/admin.py:14 announcements/admin.py:23 +msgid "shown" +msgstr "vist" + #: announcements/admin.py:30 announcements/models.py:54 msgid "link" msgstr "lenke" @@ -48,7 +52,7 @@ msgstr "" "Hvis valgt vil kunngjøringen vises på hele nettsiden, ellers vil den kun " "vises på forsiden." -#: announcements/models.py:53 contentbox/models.py:19 docs/models.py:45 +#: announcements/models.py:53 contentbox/models.py:19 docs/models.py:48 #: make_queue/models/machine.py:191 news/models.py:39 msgid "content" msgstr "innhold" @@ -194,12 +198,12 @@ msgstr "Legg til ferdigheter" msgid "Select/search" msgstr "Velg/søk" -#: checkin/templates/checkin/profile.html:97 docs/views.py:85 +#: checkin/templates/checkin/profile.html:97 docs/views.py:98 #: internal/views.py:110 #: make_queue/templates/make_queue/course/course_registration_create.html:10 #: make_queue/templates/make_queue/course/course_registration_list.html:16 #: make_queue/templates/make_queue/machine_list.html:19 -#: make_queue/views/admin/quota.py:62 +#: make_queue/views/admin/quota.py:74 #: make_queue/views/reservation/machine.py:54 msgid "Add" msgstr "Legg til" @@ -305,7 +309,7 @@ msgstr "Ferdigheten finnes allerede!" msgid "Skill added!" msgstr "Ferdighet lagt til!" -#: contentbox/admin.py:19 contentbox/models.py:24 +#: contentbox/admin.py:22 contentbox/models.py:24 msgid "extra change permissions" msgstr "ekstra endringsrettigheter" @@ -313,7 +317,7 @@ msgstr "ekstra endringsrettigheter" msgid "URL name" msgstr "URL-navn" -#: contentbox/models.py:18 docs/models.py:15 internal/models.py:245 +#: contentbox/models.py:18 docs/models.py:14 internal/models.py:245 #: makerspace/models.py:22 news/models.py:38 msgid "title" msgstr "tittel" @@ -322,21 +326,21 @@ msgstr "tittel" msgid "Extra permissions that are required for editing the content box." msgstr "Ekstra rettigheter som kreves for å kunne endre på innholdsboksen." -#: contentbox/models.py:27 docs/models.py:54 faq/models.py:42 +#: contentbox/models.py:27 docs/models.py:57 faq/models.py:42 #: groups/models.py:38 groups/models.py:107 internal/models.py:60 #: internal/models.py:201 internal/models.py:254 -#: internal/templates/internal/secret_list.html:43 +#: internal/templates/internal/secret_list.html:44 #: make_queue/models/course.py:31 make_queue/models/machine.py:142 #: make_queue/models/machine.py:192 make_queue/models/reservation.py:268 -#: makerspace/models.py:32 news/models.py:50 news/models.py:195 +#: makerspace/models.py:32 news/models.py:50 news/models.py:196 msgid "last modified" msgstr "sist endret" -#: contentbox/templates/contentbox/display.html:17 contentbox/views.py:70 +#: contentbox/templates/contentbox/display.html:18 contentbox/views.py:70 #: docs/templates/docs/documentation_page_detail.html:39 -#: faq/templates/faq/faq_list.html:38 -#: make_queue/templates/make_queue/usage_rules_detail.html:20 -#: makerspace/templates/makerspace/equipment/equipment_detail.html:33 +#: faq/templates/faq/faq_list.html:39 +#: make_queue/templates/make_queue/usage_rules_detail.html:24 +#: makerspace/templates/makerspace/equipment/equipment_detail.html:37 #: news/templates/news/admin_article_list.html:49 #: news/templates/news/admin_event_detail.html:38 #: news/templates/news/admin_timeplace_listing.html:33 @@ -353,23 +357,23 @@ msgctxt "view page" msgid "View" msgstr "Se" -#: docs/forms.py:25 +#: docs/forms.py:26 msgid "The page is currently empty; please add some content." msgstr "Siden er for øyeblikket tom; vennligst legg til noe innhold." -#: docs/forms.py:36 +#: docs/forms.py:43 msgid "The content must be changed in order to create a new version." msgstr "Innholdet må endres på for å lage en ny versjon." -#: docs/forms.py:70 +#: docs/forms.py:77 msgid "The content does not belong to the given page" msgstr "Dette innholdet hører ikke til denne siden" -#: docs/models.py:43 +#: docs/models.py:46 msgid "page" msgstr "side" -#: docs/models.py:52 +#: docs/models.py:55 msgid "made by" msgstr "gjort av" @@ -446,7 +450,7 @@ msgstr "Laget av" msgid "Edited by" msgstr "Redigert av" -#: docs/templates/docs/documentation_page_history.html:39 docs/views.py:53 +#: docs/templates/docs/documentation_page_history.html:39 docs/views.py:67 msgid "Anonymous" msgstr "Anonym" @@ -487,20 +491,20 @@ msgstr "" msgid "Only numbers, letters, spaces, parentheses and colons are allowed." msgstr "Kun tall, bokstaver, mellomrom, parenteser og kolon er tillatt." -#: docs/views.py:80 +#: docs/views.py:93 msgid "Create a New Page" msgstr "Lag en ny side" -#: docs/views.py:84 +#: docs/views.py:97 msgid "Documentation home page" msgstr "Hjemmeside for dokumentasjon" -#: docs/views.py:130 +#: docs/views.py:142 #, python-brace-format msgid "Edit “{title}”" msgstr "Rediger «{title}»" -#: docs/views.py:136 +#: docs/views.py:148 #, python-brace-format msgid "View “{title}”" msgstr "Se «{title}»" @@ -547,7 +551,7 @@ msgstr "Her kan du lage, endre og slette spørsmål" msgid "Here you can create, edit and delete categories" msgstr "Her kan du lage, endre og slette kategorier" -#: faq/templates/faq/faq_list.html:6 faq/templates/faq/faq_list.html:15 +#: faq/templates/faq/faq_list.html:6 faq/templates/faq/faq_list.html:16 msgid "Frequently Asked Questions" msgstr "Ofte stilte spørsmål" @@ -595,14 +599,14 @@ msgstr "lokketekst" msgid "description" msgstr "beskrivelse" -#: groups/models.py:104 internal/models.py:189 news/models.py:251 +#: groups/models.py:104 internal/models.py:189 news/admin.py:310 #: news/templates/admin/news/event/change_form_ticket_table.html:15 #: users/admin.py:36 msgid "email" msgstr "e-post" #: groups/models.py:106 makerspace/admin.py:17 makerspace/models.py:25 -#: news/admin.py:36 news/models.py:42 +#: news/admin.py:40 news/models.py:42 msgid "image" msgstr "bilde" @@ -628,7 +632,7 @@ msgstr "Administrasjonsside for komiteer" msgid "Edit {committee}" msgstr "Rediger {committee}" -#: internal/admin.py:18 make_queue/models/course.py:28 +#: internal/admin.py:20 make_queue/models/course.py:28 msgid "full name" msgstr "fullt navn" @@ -674,7 +678,7 @@ msgstr "" #: internal/models.py:30 make_queue/forms.py:126 #: make_queue/models/course.py:23 make_queue/models/reservation.py:28 -#: news/models.py:229 +#: news/admin.py:303 news/models.py:239 #: news/templates/admin/news/event/change_form_ticket_table.html:14 msgid "user" msgstr "bruker" @@ -729,9 +733,10 @@ msgstr "sluttårsak" #: internal/models.py:50 internal/templates/internal/member_list.html:333 #: make_queue/templates/make_queue/reservation_edit.html:106 #: make_queue/templates/make_queue/reservation_edit.html:108 -#: news/models.py:253 +#: news/models.py:260 #: news/templates/admin/news/event/change_form_ticket_table.html:16 #: news/templates/news/admin_event_ticket_list.html:62 +#: news/templates/news/ticket_card.html:42 msgid "comment" msgstr "kommentar" @@ -873,7 +878,7 @@ msgstr "Filtrer medlemskapsstatus" #: internal/templates/internal/member_list.html:41 #: internal/templates/internal/member_list.html:42 #: internal/templates/internal/member_list.html:411 -#: internal/templatetags/member.py:25 news/models.py:252 +#: internal/templatetags/member.py:25 news/models.py:259 #: news/templates/news/admin_event_ticket_list.html:42 msgid "active" msgstr "aktiv" @@ -898,7 +903,6 @@ msgstr "Filtrer komiteer" #: internal/templates/internal/member_list.html:227 #: make_queue/models/machine.py:117 #: make_queue/templates/make_queue/course/course_registration_list.html:91 -#: news/models.py:250 msgid "name" msgstr "navn" @@ -932,14 +936,14 @@ msgstr "Sluttdato" #: internal/templatetags/member.py:45 #: make_queue/templates/make_queue/course/course_registration_edit.html:104 #: make_queue/templates/make_queue/course/course_registration_list.html:146 -#: news/admin.py:258 util/admin_utils.py:118 +#: news/admin.py:262 util/admin_utils.py:118 msgid "Yes" msgstr "Ja" #: internal/templates/internal/member_list.html:151 #: internal/templatetags/member.py:45 #: make_queue/templates/make_queue/course/course_registration_list.html:148 -#: news/admin.py:260 util/admin_utils.py:119 +#: news/admin.py:264 util/admin_utils.py:119 msgid "No" msgstr "Nei" @@ -989,15 +993,15 @@ msgstr "Sett medlem som pang" msgid "Set member as not retired" msgstr "Sett medlem som ikke pang" -#: internal/templates/internal/secret_list.html:15 +#: internal/templates/internal/secret_list.html:16 msgid "Internal secrets" msgstr "Internhemmeligheter" -#: internal/templates/internal/secret_list.html:48 +#: internal/templates/internal/secret_list.html:49 msgid "Click to show - only if you're alone!" msgstr "Klikk for å vise - bare hvis du er alene!" -#: internal/templates/internal/secret_list.html:53 +#: internal/templates/internal/secret_list.html:54 msgid "Close - someone's behind me!" msgstr "Skjul - noen er bak meg!" @@ -1063,13 +1067,13 @@ msgstr "Sett medlem {name} som sluttet" msgid "machines" msgstr "maskiner" -#: make_queue/admin.py:43 make_queue/models/machine.py:133 -#: make_queue/templates/make_queue/machine_detail.html:38 news/admin.py:233 -#: news/models.py:190 +#: make_queue/admin.py:46 make_queue/models/machine.py:133 +#: make_queue/templates/make_queue/machine_detail.html:38 news/admin.py:237 +#: news/models.py:191 msgid "location" msgstr "sted" -#: make_queue/admin.py:50 make_queue/models/machine.py:123 +#: make_queue/admin.py:53 make_queue/models/machine.py:123 msgid "stream name" msgstr "videostrømnavn" @@ -1213,7 +1217,7 @@ msgstr "Brukes til å koble til maskinens videostrøm." msgid "machine model" msgstr "maskinmodell" -#: make_queue/models/machine.py:134 news/models.py:191 +#: make_queue/models/machine.py:134 news/models.py:192 msgid "location URL" msgstr "sted-URL" @@ -1222,7 +1226,7 @@ msgid "If specified, the machines are sorted ascending by this value." msgstr "Hvis spesifisert, sorteres maskinene stigende på denne verdien." #: make_queue/models/machine.py:197 -#: make_queue/views/reservation/rules.py:116 +#: make_queue/views/reservation/rules.py:124 #, python-brace-format msgid "Usage rules for {machine_type}" msgstr "Bruksregler for {machine_type}" @@ -1290,14 +1294,14 @@ msgstr "Søndag" #: make_queue/models/reservation.py:262 #: make_queue/templates/make_queue/reservation_edit.html:75 #: make_queue/templates/make_queue/reservation_edit.html:79 -#: news/models.py:188 +#: news/models.py:189 msgid "start time" msgstr "starttidspunkt" #: make_queue/models/reservation.py:263 #: make_queue/templates/make_queue/reservation_edit.html:90 #: make_queue/templates/make_queue/reservation_edit.html:94 -#: news/models.py:189 +#: news/models.py:190 msgid "end time" msgstr "sluttidspunkt" @@ -1631,7 +1635,7 @@ msgstr "Velg maskin" #: make_queue/templates/make_queue/reservation_edit.html:119 #: make_queue/templates/make_queue/reservation_edit.html:132 -#: news/admin.py:226 news/models.py:247 +#: news/admin.py:230 news/admin.py:324 news/models.py:257 msgid "event" msgstr "arrangement" @@ -1758,69 +1762,69 @@ msgstr "%(machine_name)s på tidspunktet %(time)s" msgid "No reservations connected to quota" msgstr "Ingen reservasjoner knyttet til kvoten" -#: make_queue/templates/make_queue/usage_rules_detail.html:14 +#: make_queue/templates/make_queue/usage_rules_detail.html:18 msgid "Quota and detailed rules for reservation duration" msgstr "Kvoter og detaljerte regler rundt reservasjonstid" -#: make_queue/templatetags/reservation_extra.py:62 +#: make_queue/templatetags/reservation_extra.py:61 msgid "Mon" msgstr "Man" -#: make_queue/templatetags/reservation_extra.py:62 +#: make_queue/templatetags/reservation_extra.py:61 msgid "Tue" msgstr "Tir" -#: make_queue/templatetags/reservation_extra.py:62 +#: make_queue/templatetags/reservation_extra.py:61 msgid "Wed" msgstr "Ons" -#: make_queue/templatetags/reservation_extra.py:62 +#: make_queue/templatetags/reservation_extra.py:61 msgid "Thu" msgstr "Tor" -#: make_queue/templatetags/reservation_extra.py:62 +#: make_queue/templatetags/reservation_extra.py:61 msgid "Fri" msgstr "Fre" -#: make_queue/templatetags/reservation_extra.py:62 +#: make_queue/templatetags/reservation_extra.py:61 msgid "Sat" msgstr "Lør" -#: make_queue/templatetags/reservation_extra.py:62 +#: make_queue/templatetags/reservation_extra.py:61 msgid "Sun" msgstr "Søn" -#: make_queue/templatetags/reservation_extra.py:74 +#: make_queue/templatetags/reservation_extra.py:73 #, python-brace-format msgid "{machine_status} for {duration}" msgstr "{machine_status} i {duration}" -#: make_queue/templatetags/reservation_extra.py:95 +#: make_queue/templatetags/reservation_extra.py:94 msgid "You must be logged in to create reservations." msgstr "Du må være logget inn for å kunne lage reservasjoner." -#: make_queue/templatetags/reservation_extra.py:100 +#: make_queue/templatetags/reservation_extra.py:99 #, python-brace-format msgid "The machine has status “{status}”." msgstr "Maskinen har status «{status}»." -#: make_queue/templatetags/reservation_extra.py:102 +#: make_queue/templatetags/reservation_extra.py:101 msgid "You have reached the maximum number of future reservations." msgstr "Du har nådd maksimalt antall framtidige reservasjoner." -#: make_queue/views/admin/quota.py:49 make_queue/views/admin/quota.py:75 +#: make_queue/views/admin/quota.py:61 make_queue/views/admin/quota.py:87 msgid "Admin page for quotas" msgstr "Administrasjonsside for kvoter" -#: make_queue/views/admin/quota.py:61 +#: make_queue/views/admin/quota.py:73 msgid "New Quota" msgstr "Ny kvote" -#: make_queue/views/admin/quota.py:68 +#: make_queue/views/admin/quota.py:80 msgid "Edit Quota" msgstr "Endre kvote" -#: make_queue/views/admin/quota.py:77 +#: make_queue/views/admin/quota.py:89 #, python-brace-format msgid "Admin page for the quotas of {user_full_name}" msgstr "Administrasjonsside for kvotene til {user_full_name}" @@ -1837,30 +1841,30 @@ msgstr "Legg til maskin" msgid "Edit Machine" msgstr "Rediger maskin" -#: make_queue/views/reservation/reservation.py:40 +#: make_queue/views/reservation/reservation.py:41 #, python-brace-format msgid "Reservations can only be made {num_days} day ahead of time" msgid_plural "Reservations can only be made {num_days} days ahead of time" msgstr[0] "Reservasjoner kan bare lages {num_days} dag fram i tid" msgstr[1] "Reservasjoner kan bare lages {num_days} dager fram i tid" -#: make_queue/views/reservation/reservation.py:45 +#: make_queue/views/reservation/reservation.py:46 msgid "The time slot or event is no longer available" msgstr "Tidspunktet eller arrangementet er ikke lenger tilgjengelig" -#: make_queue/views/reservation/reservation.py:47 +#: make_queue/views/reservation/reservation.py:48 msgid "The machine is out of order" msgstr "Maskinen er i ustand" -#: make_queue/views/reservation/reservation.py:49 +#: make_queue/views/reservation/reservation.py:50 msgid "The machine is under maintenance" msgstr "Maskinen er under vedlikehold" -#: make_queue/views/reservation/reservation.py:51 +#: make_queue/views/reservation/reservation.py:52 msgid "The reservation cannot start and end at the same time" msgstr "Reservasjonen kan ikke starte og slutte på samme tidspunkt" -#: make_queue/views/reservation/reservation.py:54 +#: make_queue/views/reservation/reservation.py:55 msgid "" "It is not possible to reserve the machine during these hours. Check the " "rules for when the machine is reservable" @@ -1868,23 +1872,23 @@ msgstr "" "Det er ikke mulig å reservere maskinen på dette tidspunktet. Sjekk reglene " "for hvilke perioder det er mulig å reservere maskinen i" -#: make_queue/views/reservation/reservation.py:56 +#: make_queue/views/reservation/reservation.py:57 msgid "The reservation exceeds your quota" msgstr "Reservasjonen går over kvoten din" -#: make_queue/views/reservation/reservation.py:58 +#: make_queue/views/reservation/reservation.py:59 msgid "The start time can't be after the end time" msgstr "Starttiden kan ikke være etter sluttiden" -#: make_queue/views/reservation/reservation.py:60 +#: make_queue/views/reservation/reservation.py:61 msgid "The reservation can't start in the past" msgstr "Reservasjonen kan ikke starte i fortiden" -#: make_queue/views/reservation/reservation.py:61 +#: make_queue/views/reservation/reservation.py:62 msgid "The time slot is not available" msgstr "Tidspunktet er ikke tilgjengelig" -#: make_queue/views/reservation/reservation.py:196 +#: make_queue/views/reservation/reservation.py:221 msgid "" "Cannot delete reservation when it has already started. Mark it as finished " "instead." @@ -1892,40 +1896,40 @@ msgstr "" "Kan ikke slette reservasjonen når den allerede har startet. Sett den som " "ferdig istedenfor." -#: make_queue/views/reservation/reservation.py:198 +#: make_queue/views/reservation/reservation.py:223 msgid "Cannot delete reservation when it has already ended." msgstr "Kan ikke slette reservasjonen når den allerede er over." -#: make_queue/views/reservation/reservation.py:266 +#: make_queue/views/reservation/reservation.py:298 msgid "Cannot mark reservation as finished when it has not started yet." msgstr "Kan ikke sette reservasjonen som ferdig når den ikke har startet ennå." -#: make_queue/views/reservation/reservation.py:268 +#: make_queue/views/reservation/reservation.py:300 msgid "Cannot mark reservation as finished when it has already ended." msgstr "Kan ikke sette reservasjonen som ferdig når den allerede er over." -#: make_queue/views/reservation/rules.py:43 -#: make_queue/views/reservation/rules.py:75 +#: make_queue/views/reservation/rules.py:49 +#: make_queue/views/reservation/rules.py:81 #, python-brace-format msgid "Reservation rules for {machine_type}" msgstr "Reservasjonsregler for {machine_type}" -#: make_queue/views/reservation/rules.py:85 +#: make_queue/views/reservation/rules.py:91 #, python-brace-format msgid "New Rule for {machine_type}" msgstr "Ny regel for {machine_type}" -#: make_queue/views/reservation/rules.py:92 +#: make_queue/views/reservation/rules.py:99 #, python-brace-format msgid "Rule for {machine_type}" msgstr "Regel for {machine_type}" -#: make_queue/views/reservation/rules.py:133 +#: make_queue/views/reservation/rules.py:141 #, python-brace-format msgid "Edit usage rules for {machine_type}" msgstr "Rediger bruksregler for {machine_type}" -#: make_queue/views/reservation/rules.py:139 +#: make_queue/views/reservation/rules.py:147 #, python-brace-format msgid "View usage rules for {machine_type}" msgstr "Se bruksregler for {machine_type}" @@ -1945,7 +1949,7 @@ msgstr "Legg til nytt utstyr" #: makerspace/templates/makerspace/equipment/admin_equipment_list.html:32 #: makerspace/templates/makerspace/equipment/equipment_detail.html:14 -#: makerspace/templates/makerspace/equipment/equipment_detail.html:27 +#: makerspace/templates/makerspace/equipment/equipment_detail.html:31 #: makerspace/templates/makerspace/equipment/equipment_list.html:28 #, python-format msgid "Image of %(equipment)s" @@ -1984,64 +1988,68 @@ msgstr "Nytt utstyr" msgid "Edit Equipment" msgstr "Rediger utstyr" -#: news/admin.py:82 news/admin.py:139 news/admin.py:240 +#: news/admin.py:86 news/admin.py:143 news/admin.py:244 msgid "number of reserved tickets" msgstr "antall reserverte billetter" -#: news/admin.py:108 news/admin.py:216 +#: news/admin.py:112 news/admin.py:220 #: news/templates/news/admin_event_detail.html:69 #: news/templates/news/admin_event_ticket_list.html:31 msgid "tickets" msgstr "billetter" -#: news/admin.py:117 +#: news/admin.py:121 msgid "number of occurrences" msgstr "antall forekomster" -#: news/admin.py:122 +#: news/admin.py:126 msgid "future occurrences" msgstr "framtidige forekomster" -#: news/admin.py:130 +#: news/admin.py:134 msgid "previous occurrence" msgstr "forrige forekomst" -#: news/admin.py:154 +#: news/admin.py:158 #, python-brace-format msgid "{count} more..." msgstr "{count} fler..." -#: news/admin.py:158 news/admin.py:263 +#: news/admin.py:162 news/admin.py:267 msgid "active tickets" msgstr "aktive billetter" -#: news/admin.py:162 news/admin.py:267 +#: news/admin.py:166 news/admin.py:271 msgid "inactive tickets" msgstr "inaktive billetter" -#: news/admin.py:198 news/admin.py:249 +#: news/admin.py:202 news/admin.py:253 msgid "is published" msgstr "er publisert" -#: news/admin.py:244 +#: news/admin.py:248 msgid "event is standalone" msgstr "arrangementet er frittstående" -#: news/admin.py:252 +#: news/admin.py:256 msgid "event hidden" msgstr "arrangement skjult" -#: news/admin.py:254 news/models.py:48 news/models.py:192 +#: news/admin.py:258 news/models.py:48 news/models.py:193 #: news/templates/news/admin_article_list.html:35 #: news/templates/news/admin_event_list.html:43 #: news/templates/news/admin_timeplace_listing.html:27 msgid "hidden" msgstr "skjult" -#: news/admin.py:256 +#: news/admin.py:260 msgid "future publication time" msgstr "framtidig publiseringstid" +#: news/admin.py:317 news/models.py:249 +msgid "timeplace" +msgstr "tidspunkt" + #: news/forms.py:31 msgid "The event cannot end before it starts" msgstr "Arrangementet kan ikke slutte før det starter" @@ -2082,7 +2090,7 @@ msgstr "Hvis valgt vil {the_type} bare være synlig for administratorer." msgid "If selected, {the_type} will only be visible to members of MAKE NTNU." msgstr "Hvis valgt vil {the_type} bare vises for medlemmer av MAKE NTNU." -#: news/forms.py:89 +#: news/forms.py:88 msgid "" "Here you can enter any requests or information you want to provide to the " "organizers." @@ -2116,7 +2124,7 @@ msgstr "fremhevet" msgid "internal" msgstr "internt" -#: news/models.py:70 news/models.py:186 +#: news/models.py:70 news/models.py:187 msgid "publication time" msgstr "publiseringstid" @@ -2136,32 +2144,28 @@ msgstr "Frittstående" msgid "type of event" msgstr "arrangementstype" -#: news/models.py:115 news/models.py:194 +#: news/models.py:115 news/models.py:195 msgid "number of available tickets" msgstr "antall tilgjengelige billetter" -#: news/models.py:187 +#: news/models.py:188 msgid "The occurrence will not be shown before this date." msgstr "Forekomsten vil ikke vises før publiseringstidspunktet." -#: news/models.py:193 +#: news/models.py:194 msgid "" "If selected, the occurrence will be hidden, even after the publication date." msgstr "Hvis valgt vil forekomsten være skjult, selv etter publikasjonsdatoen." -#: news/models.py:220 util/templatetags/language_tags.py:19 +#: news/models.py:231 util/templatetags/language_tags.py:19 msgid "English" msgstr "Engelsk" -#: news/models.py:221 util/templatetags/language_tags.py:18 +#: news/models.py:232 util/templatetags/language_tags.py:18 msgid "Norwegian" msgstr "Norsk" -#: news/models.py:239 -msgid "timeplace" -msgstr "tidspunkt" - -#: news/models.py:255 +#: news/models.py:262 #: news/templates/admin/news/event/change_form_ticket_table.html:17 msgid "preferred language" msgstr "foretrukket språk" @@ -2172,7 +2176,7 @@ msgstr "Antall billetter" #: news/templates/admin/news/event/change_form_ticket_table.html:13 #: news/templates/news/admin_event_ticket_list.html:54 -#: news/templates/news/ticket_card.html:57 +#: news/templates/news/ticket_card.html:47 msgid "Ref #" msgstr "Ref #" @@ -2227,7 +2231,7 @@ msgstr "Artikler" msgid "View the article “%(title)s”; image description: %(description)s" msgstr "Vis artikkelen «%(title)s»; bildebeskrivelse: %(description)s" -#: news/templates/news/admin_event_detail.html:21 news/views.py:279 +#: news/templates/news/admin_event_detail.html:21 news/views.py:246 msgid "Admin page for events" msgstr "Administrasjonsside for arrangementer" @@ -2342,6 +2346,7 @@ msgid "canceled" msgstr "kansellert" #: news/templates/news/admin_event_ticket_list.html:58 +#: news/templates/news/ticket_card.html:38 #: web/templates/web/forms/widgets/multi_lingual_text_field.html:98 msgid "language" msgstr "språk" @@ -2482,11 +2487,11 @@ msgstr "Kanseller billetten din for «%(event)s»" msgid "Yes, I'm sure" msgstr "Ja, jeg er sikker" -#: news/templates/news/ticket_card.html:67 +#: news/templates/news/ticket_card.html:56 msgid "Cancel ticket" msgstr "Kanseller" -#: news/templates/news/ticket_card.html:73 +#: news/templates/news/ticket_card.html:62 msgid "Reactivate ticket" msgstr "Reaktiver" @@ -2512,32 +2517,32 @@ msgstr "logg inn" msgid "to the correct account to see your ticket." msgstr "til den riktige brukeren for å se billetten din." -#: news/views.py:215 news/views.py:312 +#: news/views.py:182 news/views.py:312 msgid "Attributes" msgstr "Egenskaper" -#: news/views.py:230 +#: news/views.py:197 msgid "Admin page for articles" msgstr "Administrasjonsside for artikler" -#: news/views.py:239 +#: news/views.py:206 msgid "Edit Article" msgstr "Rediger artikkel" -#: news/views.py:245 +#: news/views.py:212 msgid "New Article" msgstr "Ny artikkel" -#: news/views.py:269 +#: news/views.py:236 msgid "Edit Event" msgstr "Rediger arrangement" -#: news/views.py:272 news/views.py:304 +#: news/views.py:239 news/views.py:304 #, python-brace-format msgid "Admin page for “{event_title}”" msgstr "Administrasjonsside for «{event_title}»" -#: news/views.py:278 +#: news/views.py:245 msgid "New Event" msgstr "Nytt arrangement" @@ -2545,25 +2550,25 @@ msgstr "Nytt arrangement" msgid "Edit Occurrence" msgstr "Rediger forekomst" -#: news/views.py:329 +#: news/views.py:332 msgid "New Occurrence" msgstr "Ny forekomst" -#: news/views.py:450 +#: news/views.py:494 msgid "Your ticket!" msgstr "Din billett!" -#: news/views.py:464 +#: news/views.py:506 #, python-brace-format msgid "Register for the event “{title}”" msgstr "Meld deg på arrangementet «{title}»" -#: news/views.py:560 +#: news/views.py:592 #, python-brace-format msgid " at {time}" msgstr " den {time}" -#: news/views.py:563 +#: news/views.py:595 #, python-brace-format msgid "" "Are you sure you want to cancel your ticket for
Date: Thu, 24 Mar 2022 14:00:38 +0100 Subject: [PATCH 16/20] Fixed missing renames of `master` branch to `main` --- .github/workflows/codeql-analysis.yml | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ceab3ccbc..e26c37249 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,10 +2,10 @@ name: "CodeQL" on: push: - branches: [ master, dev ] + branches: [ main, dev ] pull_request: # The branches below must be a subset of the branches above - branches: [ master, dev ] + branches: [ main, dev ] schedule: # Runs at 03:45 UTC every day - cron: '45 3 * * *' diff --git a/README.md b/README.md index 07fbb680b..c5c18e0fb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # web [![build](https://github.com/MAKENTNU/web/workflows/build/badge.svg)](https://github.com/MAKENTNU/web/actions) -[![codecov](https://codecov.io/gh/MAKENTNU/web/branch/master/graph/badge.svg)](https://codecov.io/gh/MAKENTNU/web) +[![codecov](https://codecov.io/gh/MAKENTNU/web/branch/main/graph/badge.svg)](https://codecov.io/gh/MAKENTNU/web) ## Contribution guidelines See [CONTRIBUTING.md](CONTRIBUTING.md) for the following topics: From 1a80d62aefbbbbd44d740aebad8ad9586e3e5fda Mon Sep 17 00:00:00 2001 From: Dabble Date: Mon, 17 Jan 2022 16:30:56 +0100 Subject: [PATCH 17/20] Fixed slow loading of course registration list --- make_queue/views/admin/course.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/make_queue/views/admin/course.py b/make_queue/views/admin/course.py index fae6f25c0..85f2a0d65 100644 --- a/make_queue/views/admin/course.py +++ b/make_queue/views/admin/course.py @@ -15,7 +15,7 @@ class Printer3DCourseListView(ListView): model = Printer3DCourse - queryset = Printer3DCourse.objects.order_by('name') + queryset = Printer3DCourse.objects.select_related('user').order_by('name') template_name = 'make_queue/course/course_registration_list.html' context_object_name = 'registrations' extra_context = { @@ -79,6 +79,7 @@ def post(self, request): course_registrations = Printer3DCourse.objects.filter( Q(username__icontains=search_string) | Q(name__icontains=search_string), status__icontains=status_filter) + course_registrations = course_registrations.select_related('user') # Use an in-memory output file, to avoid having to clean up the disk output_file = io.BytesIO() From 0041dc143037b07df46d5626257e0a30d3af396b Mon Sep 17 00:00:00 2001 From: Dabble Date: Tue, 29 Mar 2022 11:22:26 +0200 Subject: [PATCH 18/20] Added django-extensions --- requirements.txt | 2 ++ web/settings.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/requirements.txt b/requirements.txt index 37399df2d..5884840cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,8 @@ django-hosts==5.1 django-ckeditor==6.2.0 django-cleanup==6.0.0 django-decorator-include==3.0 +# (See this page for a list of all management commands: https://django-extensions.readthedocs.io/en/latest/command_extensions.html) +django-extensions==3.1.5 django-ical==1.8.3 django-multiselectfield==0.1.12 django-phonenumber-field==6.1.0 diff --git a/web/settings.py b/web/settings.py index 2ccc1233f..64a1eb9f6 100644 --- a/web/settings.py +++ b/web/settings.py @@ -107,11 +107,16 @@ 'util', + # Contains a lot of useful management commands, but is not strictly necessary for the project. + # See this page for a list of all management commands: https://django-extensions.readthedocs.io/en/latest/command_extensions.html + 'django_extensions', + # Should be placed last, # "to ensure that exceptions inside other apps' signal handlers do not affect the integrity of file deletions within transactions" 'django_cleanup.apps.CleanupConfig', ] + MIDDLEWARE = [ # Must be the first entry (see https://django-hosts.readthedocs.io/en/latest/#installation) 'django_hosts.middleware.HostsRequestMiddleware', From 6644401f64b65d71051b5cc90e920942e855389a Mon Sep 17 00:00:00 2001 From: Dabble Date: Tue, 29 Mar 2022 11:24:05 +0200 Subject: [PATCH 19/20] Changed the ordering of some urlpatterns --- docs/urls.py | 19 ++++++++++--------- internal/urls.py | 21 +++++++++++---------- web/urls.py | 3 ++- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/urls.py b/docs/urls.py index dce68bddc..317b84761 100644 --- a/docs/urls.py +++ b/docs/urls.py @@ -9,6 +9,16 @@ register_converter(converters.SpecificPageByTitle, 'PageTitle') +urlpatterns = [ + path("robots.txt", TemplateView.as_view(template_name='docs/robots.txt', content_type='text/plain')), + path(".well-known/security.txt", TemplateView.as_view(template_name='web/security.txt', content_type='text/plain')), + + path("i18n/", decorator_include( + permission_required('docs.view_page'), + 'django.conf.urls.i18n' + )), +] + unsafe_urlpatterns = [ path("", views.DocumentationPageDetailView.as_view(is_main_page=True), name='home'), path("page/create/", views.CreateDocumentationPageView.as_view(), name='create_page'), @@ -21,15 +31,6 @@ path("search/", views.SearchPagesView.as_view(), name='search_pages'), ] -urlpatterns = [ - path("robots.txt", TemplateView.as_view(template_name='docs/robots.txt', content_type='text/plain')), - path(".well-known/security.txt", TemplateView.as_view(template_name='web/security.txt', content_type='text/plain')), - path("i18n/", decorator_include( - permission_required('docs.view_page'), - 'django.conf.urls.i18n' - )), -] - urlpatterns += i18n_patterns( path("", decorator_include( permission_required('docs.view_page'), diff --git a/internal/urls.py b/internal/urls.py index f95e080a1..71aaf5a16 100644 --- a/internal/urls.py +++ b/internal/urls.py @@ -7,6 +7,16 @@ from . import views +urlpatterns = [ + path("robots.txt", TemplateView.as_view(template_name='internal/robots.txt', content_type='text/plain')), + path(".well-known/security.txt", TemplateView.as_view(template_name='web/security.txt', content_type='text/plain')), + + path("i18n/", decorator_include( + permission_required('internal.is_internal'), + 'django.conf.urls.i18n' + )), +] + internal_contentbox_urlpatterns = [ path("/edit/", views.EditInternalContentBoxView.as_view(), name='contentbox_edit'), ] @@ -34,7 +44,7 @@ path("secrets//delete/", views.DeleteSecretView.as_view(), name='delete_secret'), ] -urlpatterns = i18n_patterns( +urlpatterns += i18n_patterns( path("", decorator_include( permission_required('internal.is_internal'), internal_urlpatterns @@ -50,12 +60,3 @@ prefix_default_language=False, ) - -urlpatterns += [ - path("robots.txt", TemplateView.as_view(template_name='internal/robots.txt', content_type='text/plain')), - path(".well-known/security.txt", TemplateView.as_view(template_name='web/security.txt', content_type='text/plain')), - path("i18n/", decorator_include( - permission_required('internal.is_internal'), - 'django.conf.urls.i18n' - )), -] diff --git a/web/urls.py b/web/urls.py index 393130081..93930f826 100644 --- a/web/urls.py +++ b/web/urls.py @@ -21,9 +21,10 @@ extra = "/" if getattr(settings, setting_name('TRAILING_SLASH'), True) else "" urlpatterns = [ - path("i18n/", include('django.conf.urls.i18n')), path("robots.txt", TemplateView.as_view(template_name='web/robots.txt', content_type='text/plain')), path(".well-known/security.txt", TemplateView.as_view(template_name='web/security.txt', content_type='text/plain')), + + path("i18n/", include('django.conf.urls.i18n')), ] admin_urlpatterns = [ From 95102f0acd26e83807608a4349df9567d38fe68b Mon Sep 17 00:00:00 2001 From: Dabble Date: Tue, 29 Mar 2022 11:30:45 +0200 Subject: [PATCH 20/20] Added django-debug-toolbar This will only be active if installed - either manually or through `requirements_dev.txt`. --- docs/urls.py | 2 ++ internal/urls.py | 2 ++ requirements_dev.txt | 1 + util/url_utils.py | 10 ++++++++++ web/admin_urls.py | 4 ++++ web/settings.py | 31 +++++++++++++++++++++++++++++++ web/urls.py | 2 ++ 7 files changed, 52 insertions(+) create mode 100644 requirements_dev.txt diff --git a/docs/urls.py b/docs/urls.py index 317b84761..c88f1dad4 100644 --- a/docs/urls.py +++ b/docs/urls.py @@ -4,6 +4,7 @@ from django.urls import path, register_converter from django.views.generic import TemplateView +from util.url_utils import debug_toolbar_urls from . import converters, views @@ -13,6 +14,7 @@ path("robots.txt", TemplateView.as_view(template_name='docs/robots.txt', content_type='text/plain')), path(".well-known/security.txt", TemplateView.as_view(template_name='web/security.txt', content_type='text/plain')), + *debug_toolbar_urls(), path("i18n/", decorator_include( permission_required('docs.view_page'), 'django.conf.urls.i18n' diff --git a/internal/urls.py b/internal/urls.py index 71aaf5a16..1515d75fe 100644 --- a/internal/urls.py +++ b/internal/urls.py @@ -4,6 +4,7 @@ from django.urls import include, path from django.views.generic import TemplateView +from util.url_utils import debug_toolbar_urls from . import views @@ -11,6 +12,7 @@ path("robots.txt", TemplateView.as_view(template_name='internal/robots.txt', content_type='text/plain')), path(".well-known/security.txt", TemplateView.as_view(template_name='web/security.txt', content_type='text/plain')), + *debug_toolbar_urls(), path("i18n/", decorator_include( permission_required('internal.is_internal'), 'django.conf.urls.i18n' diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 000000000..aec394f4b --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1 @@ +django-debug-toolbar diff --git a/util/url_utils.py b/util/url_utils.py index e69de29bb..a4e6ca0de 100644 --- a/util/url_utils.py +++ b/util/url_utils.py @@ -0,0 +1,10 @@ +from django.conf import settings +from django.urls import include, path + + +def debug_toolbar_urls(): + if not settings.USE_DEBUG_TOOLBAR: + return [] + return [ + path("__debug__/", include('debug_toolbar.urls')), + ] diff --git a/web/admin_urls.py b/web/admin_urls.py index faaa9dab7..256cda202 100644 --- a/web/admin_urls.py +++ b/web/admin_urls.py @@ -8,6 +8,8 @@ from django.views.generic import RedirectView, TemplateView from django_hosts import reverse +from util.url_utils import debug_toolbar_urls + # Updates the "View site" link to this url admin.site.site_url = f"//{settings.PARENT_HOST}/" @@ -15,6 +17,8 @@ urlpatterns = [ path("robots.txt", TemplateView.as_view(template_name='admin/robots.txt', content_type='text/plain')), path(".well-known/security.txt", TemplateView.as_view(template_name='web/security.txt', content_type='text/plain')), + + *debug_toolbar_urls(), path("i18n/", decorator_include( staff_member_required, 'django.conf.urls.i18n' diff --git a/web/settings.py b/web/settings.py index 64a1eb9f6..a33b9f4a6 100644 --- a/web/settings.py +++ b/web/settings.py @@ -1,6 +1,7 @@ import copy import logging import sys +from importlib.util import find_spec from pathlib import Path import django.views.static @@ -32,6 +33,7 @@ SECRET_KEY = ' ' DEBUG = True ALLOWED_HOSTS = ['*'] +INTERNAL_IPS = ['127.0.0.1'] MEDIA_ROOT = BASE_DIR.parent / 'media' MEDIA_URL = '/media/' SOCIAL_AUTH_DATAPORTEN_KEY = '' @@ -56,6 +58,9 @@ # be changed in production PARENT_HOST = "makentnu.localhost:8000" +# Is `True` if `django-debug-toolbar` is installed +USE_DEBUG_TOOLBAR = find_spec('debug_toolbar') is not None # (custom setting) + EVENT_TICKET_EMAIL = "ticket@makentnu.no" # (custom setting) EMAIL_SITE_URL = "https://makentnu.no" # (custom setting) @@ -111,6 +116,8 @@ # See this page for a list of all management commands: https://django-extensions.readthedocs.io/en/latest/command_extensions.html 'django_extensions', + *(['debug_toolbar'] if USE_DEBUG_TOOLBAR else []), + # Should be placed last, # "to ensure that exceptions inside other apps' signal handlers do not affect the integrity of file deletions within transactions" 'django_cleanup.apps.CleanupConfig', @@ -121,6 +128,8 @@ # Must be the first entry (see https://django-hosts.readthedocs.io/en/latest/#installation) 'django_hosts.middleware.HostsRequestMiddleware', + *(['debug_toolbar.middleware.DebugToolbarMiddleware'] if USE_DEBUG_TOOLBAR else []), + # (See hints for ordering at https://docs.djangoproject.com/en/stable/ref/middleware/#middleware-ordering) 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -355,6 +364,28 @@ def static_lazy(path): SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD = True +if USE_DEBUG_TOOLBAR: + DEBUG_TOOLBAR_CONFIG = { + 'RENDER_PANELS': False, + 'DISABLE_PANELS': { + # 'debug_toolbar.panels.history.HistoryPanel', + 'debug_toolbar.panels.versions.VersionsPanel', + # 'debug_toolbar.panels.timer.TimerPanel', + # 'debug_toolbar.panels.settings.SettingsPanel', + # 'debug_toolbar.panels.headers.HeadersPanel', + # 'debug_toolbar.panels.request.RequestPanel', + 'debug_toolbar.panels.sql.SQLPanel', + 'debug_toolbar.panels.staticfiles.StaticFilesPanel', + 'debug_toolbar.panels.templates.TemplatesPanel', + 'debug_toolbar.panels.cache.CachePanel', + 'debug_toolbar.panels.signals.SignalsPanel', + # 'debug_toolbar.panels.logging.LoggingPanel', + 'debug_toolbar.panels.redirects.RedirectsPanel', + 'debug_toolbar.panels.profiling.ProfilingPanel', + }, + } + + # See https://docs.djangoproject.com/en/stable/topics/logging/ for # more details on how to customize your logging configuration. LOGGING = { diff --git a/web/urls.py b/web/urls.py index 93930f826..519d45458 100644 --- a/web/urls.py +++ b/web/urls.py @@ -15,6 +15,7 @@ from contentbox.views import DisplayContentBoxView, EditContentBoxView from dataporten.views import Logout, login_wrapper from news import urls as news_urls +from util.url_utils import debug_toolbar_urls from . import views @@ -24,6 +25,7 @@ path("robots.txt", TemplateView.as_view(template_name='web/robots.txt', content_type='text/plain')), path(".well-known/security.txt", TemplateView.as_view(template_name='web/security.txt', content_type='text/plain')), + *debug_toolbar_urls(), path("i18n/", include('django.conf.urls.i18n')), ]