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: 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/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]: 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/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/docs/models.py b/docs/models.py index 7f97e9fa1..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, @@ -32,10 +31,14 @@ 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.""" - page = models.ForeignKey( to=Page, on_delete=models.CASCADE, 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..c88f1dad4 100644 --- a/docs/urls.py +++ b/docs/urls.py @@ -4,34 +4,35 @@ 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 -from .models import MAIN_PAGE_TITLE, Page -register_converter(converters.SpecificPageByTitle, 'Page') -register_converter(converters.SpecificContent, 'Content') - -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("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'), -] +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')), + + *debug_toolbar_urls(), 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'), + 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//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'), +] + urlpatterns += i18n_patterns( path("", decorator_include( permission_required('docs.view_page'), diff --git a/docs/views.py b/docs/views.py index 7443d6235..d3f512324 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,74 @@ 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" + context_object_name = 'page' extra_context = {'MAIN_PAGE_TITLE': MAIN_PAGE_TITLE} + is_main_page = False -class HistoryDocumentationPageView(DetailView): - model = Page + 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' - context_object_name = "page" + context_object_name = 'page' -class OldDocumentationPageContentView(DetailView): - model = 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} - 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() @@ -90,20 +103,19 @@ 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: - 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' @@ -113,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): @@ -142,12 +154,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') @@ -156,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: @@ -166,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/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/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/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/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/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/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/internal/urls.py b/internal/urls.py index f95e080a1..1515d75fe 100644 --- a/internal/urls.py +++ b/internal/urls.py @@ -4,9 +4,21 @@ from django.urls import include, path from django.views.generic import TemplateView +from util.url_utils import debug_toolbar_urls 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')), + + *debug_toolbar_urls(), + 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 +46,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 +62,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/locale/nb/LC_MESSAGES/django.mo b/locale/nb/LC_MESSAGES/django.mo index 364433761..33613b670 100644 Binary files a/locale/nb/LC_MESSAGES/django.mo and b/locale/nb/LC_MESSAGES/django.mo differ diff --git a/locale/nb/LC_MESSAGES/django.po b/locale/nb/LC_MESSAGES/django.po index 68750dc22..d5b2c0169 100644 --- a/locale/nb/LC_MESSAGES/django.po +++ b/locale/nb/LC_MESSAGES/django.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: MAKE NTNU website\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-03-12 02:16+0100\n" +"POT-Creation-Date: 2022-03-24 13:44+0100\n" "PO-Revision-Date: 2018-10-09 14:05+0200\n" "Last-Translator: Sindre Stephansen \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
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/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 bd4fc2d02..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 %}

- + {% trans "Usage rules" %}
@@ -54,23 +54,23 @@

{% 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 %}
{% 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" %} 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 78c5df768..2e9da71d4 100644 --- a/make_queue/tests/templatetags/test_reservation_extra.py +++ b/make_queue/tests/templatetags/test_reservation_extra.py @@ -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): @@ -46,14 +46,13 @@ 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') 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 = 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 = timezone.get_default_timezone().localize(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/tests/test_urls.py b/make_queue/tests/test_urls.py index b21c9a2f6..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), @@ -107,38 +114,48 @@ 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 ], # 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': machine.pk}), public=True) + for machine in self.machines + ], # calendar_urlpatterns *[ - Get(reverse('api_reservation_rules', kwargs={'machine': machine}), public=True) + 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 ], # 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), - 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 *[ - 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,37 +163,37 @@ 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), 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', 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), 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/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..0df36b353 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.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'), ] 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/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() 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..5eec39e6f 100644 --- a/make_queue/views/api/calendar.py +++ b/make_queue/views/api/calendar.py @@ -1,66 +1,81 @@ from django.http import JsonResponse from django.urls import reverse from django.utils.dateparse import parse_datetime +from django.views.generic import ListView -from ...models.machine import Machine +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" - - -def get_reservations(request, machine: Machine): - start_date = parse_datetime(request.GET.get("startDate")) - end_date = parse_datetime(request.GET.get("endDate")) - - 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), + return 'event' + if user == reservation.user: + return 'own' + return 'normal' + + +class APIReservationListView(MachineRelatedViewMixin, ListView): + model = Reservation + + 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, machine: Machine): - 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 8525fa43c..86432c206 100644 --- a/make_queue/views/api/reservation.py +++ b/make_queue/views/api/reservation.py @@ -1,29 +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, machine: Machine, reservation=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) 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/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 4197d142b..df6c42c32 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,9 +21,10 @@ 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.""" - template_name = 'make_queue/reservation_edit.html' def get_error_message(self, form, reservation): @@ -70,7 +71,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 +103,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 +117,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 @@ -128,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) @@ -147,12 +157,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 +233,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 +260,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/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/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..07464e00d 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 @@ -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() @@ -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..b744bb582 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 }} @@ -93,13 +93,13 @@ {% 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 %} @@ -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 }} @@ -237,12 +237,12 @@ {% 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 %} + href="{% url 'register_timeplace' news_obj.pk occurrence.pk %}"> {% trans "Registration" %} {% else %} 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_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/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 44fe9a0f8..e967f691c 100644 --- a/news/tests/test_urls.py +++ b/news/tests/test_urls.py @@ -78,43 +78,43 @@ 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 ], *[ - 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), 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..a349bb004 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,44 +48,44 @@ 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.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) 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') - 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) 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..e6aadbedc 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,13 +332,15 @@ 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): - 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 @@ -344,10 +349,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 +384,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 +436,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 +481,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 +527,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 +551,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 +581,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 +616,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/requirements.txt b/requirements.txt index b141fddae..5884840cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,27 +1,29 @@ # 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 +# (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.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/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/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/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): 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..a4e6ca0de 100644 --- a/util/url_utils.py +++ b/util/url_utils.py @@ -1,23 +1,10 @@ -from abc import ABC +from django.conf import settings +from django.urls import include, path -from django.db.models import Model - -class SpecificObjectConverter(ABC): - regex = r"([0-9]+)" - - model: Model - - def to_python(self, value): - try: - 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") +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/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 5e58ed178..000000000 --- a/web/routing.py +++ /dev/null @@ -1,26 +0,0 @@ -from channels.auth import AuthMiddlewareStack -from channels.routing import ChannelNameRouter, ProtocolTypeRouter, URLRouter -from django.urls import path - -from mail.email import EmailConsumer -from make_queue.views.stream.stream import StreamConsumer - - -websocket_urlpatterns = [ - path("ws/stream//", StreamConsumer), -] - -channel_routes = { - "email": EmailConsumer -} - -application = ProtocolTypeRouter({ - 'websocket': AuthMiddlewareStack( - URLRouter( - websocket_urlpatterns - ) - ), - 'channel': ChannelNameRouter( - channel_routes - ) -}) diff --git a/web/settings.py b/web/settings.py index 3cb38a7ea..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) @@ -107,15 +112,24 @@ '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', + + *(['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', ] + MIDDLEWARE = [ # 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', @@ -172,7 +186,7 @@ }, ] -ASGI_APPLICATION = 'web.routing.application' +ASGI_APPLICATION = 'web.asgi.application' CHANNEL_LAYERS = { 'default': { 'BACKEND': 'channels_redis.core.RedisChannelLayer', @@ -350,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/static.py b/web/static.py index 437e07592..49ddf1508 100644 --- a/web/static.py +++ b/web/static.py @@ -1,20 +1,24 @@ +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"""(\{% get_relative_static ["'](.*?)["'] %\})""", - "%s", + # 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 % 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/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"}, 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 }, } diff --git a/web/urls.py b/web/urls.py index cfedeb0d4..519d45458 100644 --- a/web/urls.py +++ b/web/urls.py @@ -15,15 +15,18 @@ 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 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')), + + *debug_toolbar_urls(), + path("i18n/", include('django.conf.urls.i18n')), ] admin_urlpatterns = [ @@ -95,11 +98,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)),