From 0426b9e97578bed2d2976434b13a7144bf28821b Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Mon, 12 May 2025 20:19:03 +0200 Subject: [PATCH 01/30] Initial linting and tests for phonebook --- pyproject.toml | 1 + src/phonebook/__init__.py | 1 + src/phonebook/admin.py | 12 +- src/phonebook/apps.py | 4 + src/phonebook/dectutils.py | 15 +- src/phonebook/forms.py | 16 +- src/phonebook/mixins.py | 14 +- src/phonebook/models.py | 30 ++-- .../templates/dectregistration_list.html | 4 + src/phonebook/tests/__init__.py | 1 + src/phonebook/tests/test_views.py | 162 ++++++++++++++++++ src/phonebook/urls.py | 2 + src/phonebook/views.py | 58 +++++-- 13 files changed, 277 insertions(+), 43 deletions(-) create mode 100644 src/phonebook/tests/__init__.py create mode 100644 src/phonebook/tests/test_views.py diff --git a/pyproject.toml b/pyproject.toml index 80b9eeea5..5c59e52ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dev = [ test = [ "hypothesis==6.131.15", + "beautifulsoup4==4.13.4", ] [project.urls] diff --git a/src/phonebook/__init__.py b/src/phonebook/__init__.py index e69de29bb..d6435bcd2 100644 --- a/src/phonebook/__init__.py +++ b/src/phonebook/__init__.py @@ -0,0 +1 @@ +"""Phonebook module.""" diff --git a/src/phonebook/admin.py b/src/phonebook/admin.py index f915dd88d..c818e3fa3 100644 --- a/src/phonebook/admin.py +++ b/src/phonebook/admin.py @@ -1,13 +1,19 @@ +"""Django admin for phonebook application.""" + from __future__ import annotations +from typing import ClassVar + from django.contrib import admin from .models import DectRegistration @admin.register(DectRegistration) -class ProfileAdmin(admin.ModelAdmin): - list_display = [ +class DectRegistrationAdmin(admin.ModelAdmin): + """Django admin for DECT Registrations.""" + + list_display: ClassVar[list[str]] = [ "camp", "user", "number", @@ -16,4 +22,4 @@ class ProfileAdmin(admin.ModelAdmin): "activation_code", "publish_in_phonebook", ] - list_filter = ["camp", "publish_in_phonebook", "user"] + list_filter: ClassVar[list[str]] = ["camp", "publish_in_phonebook", "user"] diff --git a/src/phonebook/apps.py b/src/phonebook/apps.py index d7fcc099f..363eb0668 100644 --- a/src/phonebook/apps.py +++ b/src/phonebook/apps.py @@ -1,7 +1,11 @@ +"""Phonebook APP config.""" + from __future__ import annotations from django.apps import AppConfig class PhonebookConfig(AppConfig): + """Phonebook APP config.""" + name = "phonebook" diff --git a/src/phonebook/dectutils.py b/src/phonebook/dectutils.py index bffa03580..e24d836e9 100644 --- a/src/phonebook/dectutils.py +++ b/src/phonebook/dectutils.py @@ -1,6 +1,9 @@ +"""Util functions used for DECT.""" + from __future__ import annotations import logging +from typing import ClassVar logger = logging.getLogger(f"bornhack.{__name__}") @@ -8,7 +11,7 @@ class DectUtils: """This class contains dect number <> letter related utilities.""" - DECT_MATRIX = { + DECT_MATRIX: ClassVar[dict[str, list[str]]] = { "0": ["0"], "1": ["1"], "2": ["2", "A", "B", "C"], @@ -28,7 +31,7 @@ def __init__(self) -> None: for letter in self.DECT_MATRIX[digit]: self.REVERSE_DECT_MATRIX[letter] = digit - def get_dect_letter_combinations(self, numbers): + def get_dect_letter_combinations(self, numbers: str) -> list: """Generator to recursively get all combinations of letters for this number.""" # loop over the possible letters for the first digit for letter in self.DECT_MATRIX[numbers[0]]: @@ -41,16 +44,16 @@ def get_dect_letter_combinations(self, numbers): # no more digits left, just yield the current letter yield letter - def letters_to_number(self, letters): + def letters_to_number(self, letters: str) -> str: """Coverts "TYKL" to "8955".""" result = "" for letter in letters: result += self.REVERSE_DECT_MATRIX[letter.upper()] return result - def hex_ipui_ipei(self, ipui): + def hex_ipui_ipei(self, ipui: str) -> list[int]: """Convert a hexidecimal IPUI to a IPEI notation.""" - if len(ipui) == 10: + if len(ipui) == 10: # noqa: PLR2004 emc_hex = ipui[:5] psn_hex = ipui[-5:] emc = int(emc_hex, 16) @@ -58,7 +61,7 @@ def hex_ipui_ipei(self, ipui): return [emc, psn] return [] - def format_ipei(self, emc, psn) -> str: + def format_ipei(self, emc: int, psn: int) -> str: """Format the IPEI stored as ints to the standard notation.""" emc_s = str(emc).zfill(5) psn_s = str(psn).zfill(7) diff --git a/src/phonebook/forms.py b/src/phonebook/forms.py index bb65a1abe..10a64db67 100644 --- a/src/phonebook/forms.py +++ b/src/phonebook/forms.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from typing import ClassVar from django import forms from django.core.exceptions import ValidationError @@ -29,15 +30,17 @@ class DectRegistrationForm(forms.ModelForm): ) class Meta: + """Meta info of class.""" + model = DectRegistration - fields = ["number", "letters", "description", "publish_in_phonebook", "ipei"] + fields: ClassVar[list[str]] = ["number", "letters", "description", "publish_in_phonebook", "ipei"] - def clean_ipei(self): + def clean_ipei(self) -> None | list[int]: """Detect IPEI type and convert both IPEI or IPUI to a array of ints.""" ipei_s = self.cleaned_data["ipei"] - if len(ipei_s) == 10: + if len(ipei_s) == 10: # noqa: PLR2004 ipei = dectutil.hex_ipui_ipei(ipei_s) - elif len(ipei_s) == 13: + elif len(ipei_s) == 13: # noqa: PLR2004 if re.match(r"^\d{5} \d{7}$", ipei_s): ipei = [int(a) for a in ipei_s.split(" ")] else: @@ -51,10 +54,11 @@ def clean_ipei(self): raise ValidationError(f"unable to process {ipei}.") return ipei - def clean(self): + def clean(self) -> dict: + """Clean data.""" cleaned_data = super().clean() ipei = cleaned_data.get("ipei") - if ipei and len(ipei) != 2: + if ipei and len(ipei) != 2: # noqa: PLR2004 self.add_error("ipei", f"The ipei is incorrect. {ipei}") return cleaned_data diff --git a/src/phonebook/mixins.py b/src/phonebook/mixins.py index c1ee31e47..f9a0e6bda 100644 --- a/src/phonebook/mixins.py +++ b/src/phonebook/mixins.py @@ -1,10 +1,18 @@ +"""Phonebook Mixins.""" + from __future__ import annotations -from camps.mixins import CampViewMixin +from typing import TYPE_CHECKING -# from .models import DectRegistration +if TYPE_CHECKING: + from django.db.models import QuerySet + +from camps.mixins import CampViewMixin class DectRegistrationViewMixin(CampViewMixin): - def get_object(self, *args, **kwargs): + """Mixin for limiting registrations to self.camp.""" + + def get_object(self, *args, **kwargs) -> QuerySet: + """Get the objects by camp.""" return self.model.objects.get(camp=self.camp, number=self.kwargs["dect_number"]) diff --git a/src/phonebook/models.py b/src/phonebook/models.py index c73dfa48a..3bbadfe4b 100644 --- a/src/phonebook/models.py +++ b/src/phonebook/models.py @@ -1,6 +1,9 @@ +"""Model for phonebook.""" + from __future__ import annotations import logging +from typing import ClassVar from django.contrib.auth.models import User from django.contrib.postgres.fields import ArrayField @@ -23,7 +26,9 @@ class DectRegistration( """This model contains DECT registrations for users and services.""" class Meta: - unique_together = [("camp", "number")] + """Meta.""" + + unique_together: ClassVar[list[tuple[str]]] = [("camp", "number")] camp = models.ForeignKey( "camps.Camp", @@ -82,15 +87,19 @@ def save(self, *args, **kwargs) -> None: super().save(*args, **kwargs) def check_unique_ipei(self) -> None: - if self.ipei and len(self.ipei) == 2: - # check for conflicts with the same IPEI - if DectRegistration.objects.filter(camp=self.camp, ipei=self.ipei).exclude(pk=self.pk).exists(): - raise ValidationError( - f"The IPEI {dectutil.format_ipei(self.ipei[0], self.ipei[1])} is in use", - ) + """Check IPEI is unique.""" + if ( + self.ipei + and len(self.ipei) == 2 # noqa: PLR2004 + and DectRegistration.objects.filter(camp=self.camp, ipei=self.ipei).exclude(pk=self.pk).exists() + ): + raise ValidationError( + f"The IPEI {dectutil.format_ipei(self.ipei[0], self.ipei[1])} is in use", + ) def clean_number(self) -> None: """We call this from the views form_valid() so we have a Camp object available for the validation. + This code really belongs in model.clean(), but that gets called before form_valid() which is where we set the Camp object for the model instance. """ @@ -108,7 +117,7 @@ def clean_number(self) -> None: try: int(self.number) except ValueError: - raise ValidationError("Phonenumber must be numeric!") + raise ValidationError("Phonenumber must be numeric!") from None # check for conflicts with the same number if DectRegistration.objects.filter(camp=self.camp, number=self.number).exclude(pk=self.pk).exists(): @@ -138,6 +147,7 @@ def clean_number(self) -> None: def clean_letters(self) -> None: """We call this from the views form_valid() so we have a Camp object available for the validation. + This code really belongs in model.clean(), but that gets called before form_valid() which is where we set the Camp object for the model instance. """ @@ -157,10 +167,8 @@ def clean_letters(self) -> None: if self.letters.upper() not in list(combinations): # something is fucky, loop over letters to give a better error message - i = 0 - for digit in self.number: + for i, digit in enumerate(self.number): if self.letters[i].upper() not in dectutil.DECT_MATRIX[digit]: raise ValidationError( f"The digit '{digit}' does not match the letter '{self.letters[i]}'. Valid letters for the digit '{digit}' are: {dectutil.DECT_MATRIX[digit]}", ) - i += 1 diff --git a/src/phonebook/templates/dectregistration_list.html b/src/phonebook/templates/dectregistration_list.html index 69e8fcf07..f1743da62 100644 --- a/src/phonebook/templates/dectregistration_list.html +++ b/src/phonebook/templates/dectregistration_list.html @@ -17,6 +17,7 @@ Create DECT Registration

+ @@ -28,6 +29,8 @@ + + {% for entry in dectregistration_list %} @@ -44,6 +47,7 @@ {% endfor %} +
Number LettersModified Actions
{{ entry.number }}
{% else %}

No DECT registrations found. Go create one!

diff --git a/src/phonebook/tests/__init__.py b/src/phonebook/tests/__init__.py new file mode 100644 index 000000000..f6a4601a5 --- /dev/null +++ b/src/phonebook/tests/__init__.py @@ -0,0 +1 @@ +"""Phonebook tests.""" diff --git a/src/phonebook/tests/test_views.py b/src/phonebook/tests/test_views.py new file mode 100644 index 000000000..245c14e46 --- /dev/null +++ b/src/phonebook/tests/test_views.py @@ -0,0 +1,162 @@ +"""Tests for Phonebook views.""" + +from __future__ import annotations + +from bs4 import BeautifulSoup +from django.urls import reverse + +from phonebook.models import DectRegistration +from utils.tests import BornhackTestBase + + +class TestPhonebookViews(BornhackTestBase): + """Test Phonebook view.""" + + @classmethod + def setUpTestData(cls) -> None: + """Add test data.""" + # first add users and other basics + super().setUpTestData() + # then create some albums + DectRegistration( + camp=cls.camp, + user=cls.users[0], + number="1234", + letters="", + description="1234 nr test", + publish_in_phonebook=True, + ipei=[], + ).save() + DectRegistration( + camp=cls.camp, + user=cls.users[0], + number="", + letters="SHIT", + description="SHIT nr test", + publish_in_phonebook=True, + ipei=[], + ).save() + DectRegistration( + camp=cls.camp, + user=cls.users[0], + number="", + letters="HIDE", + description="HIDE nr test", + publish_in_phonebook=False, + ipei=[], + ).save() + + def test_phonebook_list_view(self) -> None: + """Test the basics of the phonebook list view.""" + url = reverse("phonebook:list", kwargs={"camp_slug": self.camp.slug}) + + response = self.client.get(url) + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div#main > table > tbody > tr") + self.assertEqual(len(rows), 2, "phonebook list does not return 2 entries") + + def test_dect_registration_list_view(self) -> None: + """Test the basics of the dect registrations list view.""" + url = reverse("phonebook:dectregistration_list", kwargs={"camp_slug": self.camp.slug}) + self.client.login(username="user0", password="user0") + + response = self.client.get(url) + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div#main > div > div > table > tbody > tr") + self.assertEqual(len(rows), 3, "dect registration list does not return 2 registrations") + + def test_dect_registration_create_view(self) -> None: + """Test the basics of the dect registrations create view.""" + self.client.login(username="user0", password="user0") + + url = reverse("phonebook:dectregistration_create", kwargs={"camp_slug": self.camp.slug}) + + # Test creating 9999 + response = self.client.post( + path=url, + data={ + "number": "9999", + "letters": "", + "description": "Test number", + "publish_in_phonebook": False, + "ipei": "00DEADBEEF", + }, + follow=True, + ) + assert response.status_code == 200 + + # Test if the registration shows up + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div#main > div > div > table > tbody > tr") + self.assertEqual(len(rows), 4, "dect registration create number failed") + + # Test Named number + response = self.client.post( + path=url, + data={ + "number": "", + "letters": "INFO", + "description": "INFO Test number", + "publish_in_phonebook": True, + "ipei": "03562 0900848", + }, + follow=True, + ) + assert response.status_code == 200 + + # Test if the registration shows up + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div#main > div > div > table > tbody > tr") + self.assertEqual(len(rows), 5, "dect registration create INFO failed") + + # Test duplicated number + response = self.client.post( + path=url, + data={ + "number": "", + "letters": "INFO", + "description": "INFO Test number", + "publish_in_phonebook": True, + "ipei": "", + }, + follow=True, + ) + assert response.status_code == 200 + + # Test if the registration does not show up + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select(".invalid-feedback") + self.assertEqual(len(rows), 1, "dect registration create duplicate failed") + + # Test duplicated IPEI + response = self.client.post( + path=url, + data={ + "number": "", + "letters": "FOOD", + "description": "FOOD Test number", + "publish_in_phonebook": True, + "ipei": "03562 0900848", + }, + follow=True, + ) + assert response.status_code == 200 + + # Test if the registration does not show up + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select(".invalid-feedback") + self.assertEqual(len(rows), 1, "dect registration create duplicate ipei failed") + + # Test total numbers in the phonebook (3) + url = reverse("phonebook:list", kwargs={"camp_slug": self.camp.slug}) + response = self.client.get(url) + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div#main > table > tbody > tr") + self.assertEqual(len(rows), 3, "phonebook list does not return 3 entries after create") diff --git a/src/phonebook/urls.py b/src/phonebook/urls.py index 93bc0d4cb..b1d1a29d2 100644 --- a/src/phonebook/urls.py +++ b/src/phonebook/urls.py @@ -1,3 +1,5 @@ +"""URLs for app phonebook.""" + from __future__ import annotations from django.urls import include diff --git a/src/phonebook/views.py b/src/phonebook/views.py index f4942a9bd..30588cee6 100644 --- a/src/phonebook/views.py +++ b/src/phonebook/views.py @@ -1,13 +1,25 @@ +"""All views for the phonebook application.""" + from __future__ import annotations import json import logging import secrets import string +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from django.db.models import QuerySet + from django.forms import BaseForm + from django.http import HttpRequest + +from typing import ClassVar from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import ValidationError +from django.http import HttpResponsePermanentRedirect +from django.http import HttpResponseRedirect from django.http import JsonResponse from django.shortcuts import get_object_or_404 from django.shortcuts import redirect @@ -33,6 +45,8 @@ logger = logging.getLogger(f"bornhack.{__name__}") dectutil = DectUtils() +MIN_DECT_NUMBER_LENGTH = 4 + class DectExportJsonView( CampViewMixin, @@ -41,9 +55,10 @@ class DectExportJsonView( ): """JSON export for the POC team / DECT system.""" - required_scopes = ["phonebook:read"] + required_scopes: ClassVar[list[str]] = ["phonebook:read"] - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> dict: + """Fetch data from the database and add it to context.""" context = super().get_context_data(**kwargs) poc = self.request.user.has_perm( "camps.poc_team_lead", @@ -53,7 +68,7 @@ def get_context_data(self, **kwargs): context["phonebook"] = self.dump_phonebook(poc=poc) return context - def dump_phonebook(self, poc: bool) -> list[DectRegistration]: + def dump_phonebook(self, *, poc: bool) -> list[DectRegistration]: """Dump phonebook for poc and non-poc use.""" phonebook = [] dects = DectRegistration.objects.filter(camp=self.camp) @@ -89,9 +104,10 @@ class ApiDectUpdateIPEI( ): """API endpoint to update IPEI after user registered their phone on the network. Used by the POC system.""" - required_scopes = ["phonebook:admin"] + required_scopes: ClassVar[list[str]] = ["phonebook:admin"] - def post(self, request, dect_number, *args, **kwargs): + def post(self, request: HttpRequest, dect_number: str, *args, **kwargs) -> JsonResponse: + """Accept POST requests and process them.""" if not self.request.user.has_perm( "camps.poc_team_lead", ) and self.request.access_token.is_valid( @@ -117,7 +133,8 @@ def post(self, request, dect_number, *args, **kwargs): class PhonebookListView(CampViewMixin, ListView): - """Our phonebook view currently only shows DectRegistration entries, + """Our phonebook view currently only shows DectRegistration entries. + but could later be extended to show maybe GSM or other kinds of phone numbers. """ @@ -125,27 +142,33 @@ class PhonebookListView(CampViewMixin, ListView): model = DectRegistration template_name = "phonebook.html" - def get_queryset(self, *args, **kwargs): + def get_queryset(self, *args, **kwargs) -> QuerySet: + """Build the QuerySet, show only public numbers.""" qs = super().get_queryset(*args, **kwargs) return qs.filter(publish_in_phonebook=True) class DectRegistrationListView(LoginRequiredMixin, CampViewMixin, ListView): + """View for listing your dect registrations.""" + model = DectRegistration template_name = "dectregistration_list.html" - def get_queryset(self, *args, **kwargs): + def get_queryset(self, *args, **kwargs) -> QuerySet: """Show only DectRegistration entries belonging to the current user.""" qs = super().get_queryset(*args, **kwargs) return qs.filter(user=self.request.user) class DectRegistrationCreateView(LoginRequiredMixin, CampViewMixin, CreateView): + """View for creating a dect registration.""" + model = DectRegistration form_class = DectRegistrationForm template_name = "dectregistration_form.html" - def form_valid(self, form): + def form_valid(self, form: DectRegistrationForm) -> HttpResponsePermanentRedirect | HttpResponseRedirect: + """Check if form is valid.""" dect = form.save(commit=False) dect.camp = self.camp dect.user = self.request.user @@ -168,8 +191,9 @@ def form_valid(self, form): form.add_error("ipei", E) return super().form_invalid(form) - # this check needs to be in this form, but not in model.save(), because then we cant save service numbers from the admin - if len(dect.number) < 4: + # this check needs to be in this form, but not in model.save(), + # because then we cant save service numbers from the admin + if len(dect.number) < MIN_DECT_NUMBER_LENGTH: form.add_error( "number", ValidationError( @@ -202,11 +226,14 @@ class DectRegistrationUpdateView( UserIsObjectOwnerMixin, UpdateView, ): + """View for updating a dect registration.""" + model = DectRegistration - fields = ["letters", "description", "publish_in_phonebook"] + fields: ClassVar[list[str]] = ["letters", "description", "publish_in_phonebook"] template_name = "dectregistration_form.html" - def form_valid(self, form): + def form_valid(self, form: BaseForm) -> HttpResponsePermanentRedirect | HttpResponseRedirect: + """Check if the form is valid.""" dect = form.save(commit=False) # check if the letters match the DECT number @@ -236,10 +263,13 @@ class DectRegistrationDeleteView( UserIsObjectOwnerMixin, DeleteView, ): + """View for deleting a dect registration.""" + model = DectRegistration template_name = "dectregistration_delete.html" - def get_success_url(self): + def get_success_url(self) -> str: + """Provide success URL.""" messages.success( self.request, f"Your DECT registration for number {self.get_object().number} has been deleted successfully", From baf256b7e2ded13b2304966f97f44d78aaceb32a Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Mon, 12 May 2025 22:34:20 +0200 Subject: [PATCH 02/30] Finished ruffing the phonebook, added basic TestCase base class --- pyproject.toml | 2 + src/bornhack/settings.py | 2 + src/phonebook/forms.py | 14 +++-- src/phonebook/models.py | 110 ++++++++++++++++++++++++++++++--------- src/utils/tests.py | 55 ++++++++++++++++++++ 5 files changed, 156 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5c59e52ba..b31850264 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,8 @@ ignore = [ "PLR2004", # Magic value used in comparison, ... "ANN001", # Missing type annotation for function argument ... "ANN201", # Missing return type annotation for public function ... + "S101", # Allow use of assert + "S106", # Allow passwords in tests "S113", # Probable use of requests call without timeout "E501", # Line too long "D104", # Missing docstring in public package diff --git a/src/bornhack/settings.py b/src/bornhack/settings.py index 15432813d..68086edfe 100644 --- a/src/bornhack/settings.py +++ b/src/bornhack/settings.py @@ -280,3 +280,5 @@ "pos": "Team Pos - Point-of-sale report submission", "tasker": "Team Tasker - task management", } + +FIXTURE_DIRS = ["testdata"] diff --git a/src/phonebook/forms.py b/src/phonebook/forms.py index 10a64db67..221dfad15 100644 --- a/src/phonebook/forms.py +++ b/src/phonebook/forms.py @@ -14,6 +14,14 @@ dectutil = DectUtils() +class InvalidIPEIError(ValidationError): + """Exception raised on invalid IPEI.""" + + def __init__(self, ipei: list[int]) -> None: + """Exception raised when an invalid is used.""" + super().__init__(f"unable to process IPEI {ipei}.") + + class DectRegistrationForm(forms.ModelForm): """Dect Registration Form used in the phonebook registration create view.""" @@ -44,14 +52,14 @@ def clean_ipei(self) -> None | list[int]: if re.match(r"^\d{5} \d{7}$", ipei_s): ipei = [int(a) for a in ipei_s.split(" ")] else: - raise ValidationError("Unrecognized IPEI format") + raise InvalidIPEIError(ipei=[]) elif ipei_s == "": return None else: - raise ValidationError("Unable to recognize IPEI/IPUI format") + raise InvalidIPEIError(ipei=[]) if not ipei: - raise ValidationError(f"unable to process {ipei}.") + raise InvalidIPEIError(ipei=ipei) return ipei def clean(self) -> dict: diff --git a/src/phonebook/models.py b/src/phonebook/models.py index 3bbadfe4b..8da822dfa 100644 --- a/src/phonebook/models.py +++ b/src/phonebook/models.py @@ -19,6 +19,81 @@ dectutil = DectUtils() +class PhonebookDuplicateError(ValidationError): + """Exception raised on duplicate number.""" + + def __init__(self, number: str) -> None: + """Exception raised on duplicate number.""" + super().__init__(f"The DECT number {number} is in use") + + +class PhonebookNumberError(ValidationError): + """Exception raised on number error.""" + + def __init__(self) -> None: + """Exception raised on number error.""" + super().__init__("You must enter either a phonenumber or a letter representation of the phonenumber!") + + +class IPEIDuplicateError(ValidationError): + """Exception raised on duplicate ipei.""" + + def __init__(self, ipei: list[int]) -> None: + """Exception raised on duplicate ipei.""" + super().__init__(f"The IPEI {dectutil.format_ipei(ipei[0], ipei[1])} is in use") + + +class PhonebookConflictLongError(ValidationError): + """Exception raised on conflict with longer number.""" + + def __init__(self, number: str) -> None: + """Exception raised on conflict with longer number.""" + super().__init__(f"The DECT number {number} is not available, it conflicts with a longer number.") + + +class PhonebookConflictShortError(ValidationError): + """Exception raised on conflict with shorter number.""" + + def __init__(self, number: str) -> None: + """Exception raised on conflict with shorter number.""" + super().__init__(f"The DECT number {number} is not available, it conflicts with a shorter number.") + + +class NumberNumericError(ValidationError): + """Exception raised when number is not numeric.""" + + def __init__(self) -> None: + """Exception raised when number is not numeric.""" + super().__init__("Phonenumber must be numeric!") + + +class LettersNumberSizeError(ValidationError): + """Exception raised on wrong number of letters or numbers.""" + + def __init__(self, number: str, letters: str) -> None: + """Exception raised on wrong number of letters or numbers.""" + super().__init__(f"Wrong number of letters ({len(letters)}) - should be {len(number)}") + + +class LetterNullOneError(ValidationError): + """Exception raised on when 0 or 1 are used to express letters.""" + + def __init__(self) -> None: + """Exception raised on when 0 or 1 are used to express letters.""" + super().__init__("Numbers with 0 and 1 in them can not be expressed as letters") + + +class DigitError(ValidationError): + """Exception raised on digit does not match the letter.""" + + def __init__(self, digit: str, letter: str) -> None: + """Exception raised on digit does not match the letter.""" + super().__init__( + f"The digit '{digit}' does not match the letter '{letter}'. \ + Valid letters for the digit '{digit}' are: {dectutil.DECT_MATRIX[digit]}" + ) + + class DectRegistration( ExportModelOperationsMixin("dect_registration"), CampRelatedModel, @@ -51,7 +126,8 @@ class Meta: letters = models.CharField( max_length=9, blank=True, - help_text="The letters or numbers chosen to represent this DECT number in the phonebook. Optional if you specify a number.", + help_text="The letters or numbers chosen to represent this DECT number in the phonebook. \ + Optional if you specify a number.", ) description = models.TextField( @@ -93,9 +169,7 @@ def check_unique_ipei(self) -> None: and len(self.ipei) == 2 # noqa: PLR2004 and DectRegistration.objects.filter(camp=self.camp, ipei=self.ipei).exclude(pk=self.pk).exists() ): - raise ValidationError( - f"The IPEI {dectutil.format_ipei(self.ipei[0], self.ipei[1])} is in use", - ) + raise IPEIDuplicateError(ipei=self.ipei) def clean_number(self) -> None: """We call this from the views form_valid() so we have a Camp object available for the validation. @@ -107,9 +181,7 @@ def clean_number(self) -> None: if not self.number: # we have no phonenumber, do we have some letters at least? if not self.letters: - raise ValidationError( - "You must enter either a phonenumber or a letter representation of the phonenumber!", - ) + raise PhonebookNumberError # we have letters but not a number, let's deduce the numbers self.number = dectutil.letters_to_number(self.letters) @@ -117,11 +189,11 @@ def clean_number(self) -> None: try: int(self.number) except ValueError: - raise ValidationError("Phonenumber must be numeric!") from None + raise NumberNumericError from None # check for conflicts with the same number if DectRegistration.objects.filter(camp=self.camp, number=self.number).exclude(pk=self.pk).exists(): - raise ValidationError(f"The DECT number {self.number} is in use") + raise PhonebookDuplicateError(number=self.number) # check for conflicts with a longer number if ( @@ -132,17 +204,13 @@ def clean_number(self) -> None: .exclude(pk=self.pk) .exists() ): - raise ValidationError( - f"The DECT number {self.number} is not available, it conflicts with a longer number.", - ) + raise PhonebookConflictLongError # check if a shorter number is blocking i = len(self.number) - 1 while i: if DectRegistration.objects.filter(camp=self.camp, number=self.number[:i]).exclude(pk=self.pk).exists(): - raise ValidationError( - f"The DECT number {self.number} is not available, it conflicts with a shorter number.", - ) + raise PhonebookConflictShortError i -= 1 def clean_letters(self) -> None: @@ -154,21 +222,15 @@ def clean_letters(self) -> None: # if we have a letter representation of this number they should have the same length if self.letters: if len(self.letters) != len(self.number): - raise ValidationError( - f"Wrong number of letters ({len(self.letters)}) - should be {len(self.number)}", - ) + raise LettersNumberSizeError # loop over the digits in the phonenumber combinations = list(dectutil.get_dect_letter_combinations(self.number)) if not combinations: - raise ValidationError( - "Numbers with 0 and 1 in them can not be expressed as letters", - ) + raise LetterNullOneError if self.letters.upper() not in list(combinations): # something is fucky, loop over letters to give a better error message for i, digit in enumerate(self.number): if self.letters[i].upper() not in dectutil.DECT_MATRIX[digit]: - raise ValidationError( - f"The digit '{digit}' does not match the letter '{self.letters[i]}'. Valid letters for the digit '{digit}' are: {dectutil.DECT_MATRIX[digit]}", - ) + raise DigitError(digit=digit, letter=self.letters[i]) diff --git a/src/utils/tests.py b/src/utils/tests.py index b9ba2b3ed..65f076c02 100644 --- a/src/utils/tests.py +++ b/src/utils/tests.py @@ -1,10 +1,18 @@ +"""Base file for tests.""" from __future__ import annotations +import logging +from datetime import datetime from unittest import skip +import pytz +from django.contrib.auth.models import User from django.core.management import call_command +from django.test import Client from django.test import TestCase +from camps.models import Camp + class TestBootstrapScript(TestCase): """Test bootstrap_devsite script (touching many codepaths)""" @@ -13,3 +21,50 @@ class TestBootstrapScript(TestCase): def test_bootstrap_script(self): """If no orders have been made, the product is still available.""" call_command("bootstrap_devsite") + + +class BornhackTestBase(TestCase): + """Bornhack base TestCase.""" + users: list[User] + camp: Camp + + @classmethod + def setUpTestData(cls) -> None: + """Test setup.""" + # disable logging + logging.disable(logging.WARNING) + + cls.client = Client(enforce_csrf_checks=False) + + tz = pytz.timezone("Europe/Copenhagen") + year = tz.now().year + cls.camp = Camp( + title="Test Camp", + slug="test-camp", + tagline="Such test much wow", + shortslug="test-camp", + buildup=( + datetime(year, 8, 25, 12, 0, tzinfo=tz), + datetime(year, 8, 27, 12, 0, tzinfo=tz), + ), + camp=( + datetime(year, 8, 27, 12, 0, tzinfo=tz), + datetime(year, 9, 3, 12, 0, tzinfo=tz), + ), + teardown=( + datetime(year, 9, 3, 12, 0, tzinfo=tz), + datetime(year, 9, 5, 12, 0, tzinfo=tz), + ), + colour="#ffffff", + light_text=False, + ) + cls.camp.save() + + cls.users = [] + user = User.objects.create_user( + username="user0", + email="user0@example.com", + ) + user.set_password("user0") + user.save() + cls.users.append(user) From 6410c0fb97aa8b7125de47f7548597668706ceb9 Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Mon, 12 May 2025 22:39:23 +0200 Subject: [PATCH 03/30] Run tests before you commit --- src/utils/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/tests.py b/src/utils/tests.py index 65f076c02..875b92e59 100644 --- a/src/utils/tests.py +++ b/src/utils/tests.py @@ -37,7 +37,7 @@ def setUpTestData(cls) -> None: cls.client = Client(enforce_csrf_checks=False) tz = pytz.timezone("Europe/Copenhagen") - year = tz.now().year + year = datetime.now(tz).year cls.camp = Camp( title="Test Camp", slug="test-camp", From fd84e4121539a5dc861c6aa4fc79c6833d2ef2d5 Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Mon, 12 May 2025 22:44:27 +0200 Subject: [PATCH 04/30] Use the right name --- src/phonebook/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/phonebook/__init__.py b/src/phonebook/__init__.py index d6435bcd2..1ea4f2c7e 100644 --- a/src/phonebook/__init__.py +++ b/src/phonebook/__init__.py @@ -1 +1 @@ -"""Phonebook module.""" +"""Phonebook app.""" From 5f135ca818267cd267396c3885b57539e90a315b Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Mon, 12 May 2025 22:48:52 +0200 Subject: [PATCH 05/30] Update src/phonebook/models.py --- src/phonebook/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/phonebook/models.py b/src/phonebook/models.py index 8da822dfa..b59de90f9 100644 --- a/src/phonebook/models.py +++ b/src/phonebook/models.py @@ -126,8 +126,8 @@ class Meta: letters = models.CharField( max_length=9, blank=True, - help_text="The letters or numbers chosen to represent this DECT number in the phonebook. \ - Optional if you specify a number.", + help_text="The letters or numbers chosen to represent this DECT number in the phonebook. " + "Optional if you specify a number.", ) description = models.TextField( From 840b25265c4cd523b86dc965cc50cd6fdd5a0ff8 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Mon, 12 May 2025 22:51:54 +0200 Subject: [PATCH 06/30] Update src/phonebook/views.py --- src/phonebook/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/phonebook/views.py b/src/phonebook/views.py index 30588cee6..3c3ccd07e 100644 --- a/src/phonebook/views.py +++ b/src/phonebook/views.py @@ -167,7 +167,7 @@ class DectRegistrationCreateView(LoginRequiredMixin, CampViewMixin, CreateView): form_class = DectRegistrationForm template_name = "dectregistration_form.html" - def form_valid(self, form: DectRegistrationForm) -> HttpResponsePermanentRedirect | HttpResponseRedirect: + def form_valid(self, form: DectRegistrationForm) -> HttpResponseRedirect: """Check if form is valid.""" dect = form.save(commit=False) dect.camp = self.camp From 20d9d2ebe63a8c29d1e1c9977dd5ee59d3eeda32 Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Mon, 12 May 2025 22:54:17 +0200 Subject: [PATCH 07/30] Move exceptions to there own file --- src/phonebook/exceptions.py | 95 +++++++++++++++++++++++++++++++++++++ src/phonebook/forms.py | 10 +--- src/phonebook/models.py | 85 ++++----------------------------- 3 files changed, 105 insertions(+), 85 deletions(-) create mode 100644 src/phonebook/exceptions.py diff --git a/src/phonebook/exceptions.py b/src/phonebook/exceptions.py new file mode 100644 index 000000000..bbe6d2019 --- /dev/null +++ b/src/phonebook/exceptions.py @@ -0,0 +1,95 @@ +"""Exceptions for phonebook.""" + +from __future__ import annotations + +import logging + +from django.core.exceptions import ValidationError + +from .dectutils import DectUtils + +logger = logging.getLogger(f"bornhack.{__name__}") +dectutil = DectUtils() + + +class PhonebookDuplicateError(ValidationError): + """Exception raised on duplicate number.""" + + def __init__(self, number: str) -> None: + """Exception raised on duplicate number.""" + super().__init__(f"The DECT number {number} is in use") + + +class PhonebookNumberError(ValidationError): + """Exception raised on number error.""" + + def __init__(self) -> None: + """Exception raised on number error.""" + super().__init__("You must enter either a phonenumber or a letter representation of the phonenumber!") + + +class IPEIDuplicateError(ValidationError): + """Exception raised on duplicate ipei.""" + + def __init__(self, ipei: list[int]) -> None: + """Exception raised on duplicate ipei.""" + super().__init__(f"The IPEI {dectutil.format_ipei(ipei[0], ipei[1])} is in use") + + +class PhonebookConflictLongError(ValidationError): + """Exception raised on conflict with longer number.""" + + def __init__(self, number: str) -> None: + """Exception raised on conflict with longer number.""" + super().__init__(f"The DECT number {number} is not available, it conflicts with a longer number.") + + +class PhonebookConflictShortError(ValidationError): + """Exception raised on conflict with shorter number.""" + + def __init__(self, number: str) -> None: + """Exception raised on conflict with shorter number.""" + super().__init__(f"The DECT number {number} is not available, it conflicts with a shorter number.") + + +class NumberNumericError(ValidationError): + """Exception raised when number is not numeric.""" + + def __init__(self) -> None: + """Exception raised when number is not numeric.""" + super().__init__("Phonenumber must be numeric!") + + +class LettersNumberSizeError(ValidationError): + """Exception raised on wrong number of letters or numbers.""" + + def __init__(self, number: str, letters: str) -> None: + """Exception raised on wrong number of letters or numbers.""" + super().__init__(f"Wrong number of letters ({len(letters)}) - should be {len(number)}") + + +class LetterNullOneError(ValidationError): + """Exception raised on when 0 or 1 are used to express letters.""" + + def __init__(self) -> None: + """Exception raised on when 0 or 1 are used to express letters.""" + super().__init__("Numbers with 0 and 1 in them can not be expressed as letters") + + +class DigitError(ValidationError): + """Exception raised on digit does not match the letter.""" + + def __init__(self, digit: str, letter: str) -> None: + """Exception raised on digit does not match the letter.""" + super().__init__( + f"The digit '{digit}' does not match the letter '{letter}'. \ + Valid letters for the digit '{digit}' are: {dectutil.DECT_MATRIX[digit]}", + ) + + +class InvalidIPEIError(ValidationError): + """Exception raised on invalid IPEI.""" + + def __init__(self, ipei: list[int]) -> None: + """Exception raised when an invalid is used.""" + super().__init__(f"unable to process IPEI {ipei}.") diff --git a/src/phonebook/forms.py b/src/phonebook/forms.py index 221dfad15..76a6189fa 100644 --- a/src/phonebook/forms.py +++ b/src/phonebook/forms.py @@ -6,22 +6,14 @@ from typing import ClassVar from django import forms -from django.core.exceptions import ValidationError from .dectutils import DectUtils +from .exceptions import InvalidIPEIError from .models import DectRegistration dectutil = DectUtils() -class InvalidIPEIError(ValidationError): - """Exception raised on invalid IPEI.""" - - def __init__(self, ipei: list[int]) -> None: - """Exception raised when an invalid is used.""" - super().__init__(f"unable to process IPEI {ipei}.") - - class DectRegistrationForm(forms.ModelForm): """Dect Registration Form used in the phonebook registration create view.""" diff --git a/src/phonebook/models.py b/src/phonebook/models.py index b59de90f9..f9cd7e81f 100644 --- a/src/phonebook/models.py +++ b/src/phonebook/models.py @@ -7,93 +7,26 @@ from django.contrib.auth.models import User from django.contrib.postgres.fields import ArrayField -from django.core.exceptions import ValidationError from django.db import models from django_prometheus.models import ExportModelOperationsMixin from utils.models import CampRelatedModel from .dectutils import DectUtils +from .exceptions import DigitError +from .exceptions import IPEIDuplicateError +from .exceptions import LetterNullOneError +from .exceptions import LettersNumberSizeError +from .exceptions import NumberNumericError +from .exceptions import PhonebookConflictLongError +from .exceptions import PhonebookConflictShortError +from .exceptions import PhonebookDuplicateError +from .exceptions import PhonebookNumberError logger = logging.getLogger(f"bornhack.{__name__}") dectutil = DectUtils() -class PhonebookDuplicateError(ValidationError): - """Exception raised on duplicate number.""" - - def __init__(self, number: str) -> None: - """Exception raised on duplicate number.""" - super().__init__(f"The DECT number {number} is in use") - - -class PhonebookNumberError(ValidationError): - """Exception raised on number error.""" - - def __init__(self) -> None: - """Exception raised on number error.""" - super().__init__("You must enter either a phonenumber or a letter representation of the phonenumber!") - - -class IPEIDuplicateError(ValidationError): - """Exception raised on duplicate ipei.""" - - def __init__(self, ipei: list[int]) -> None: - """Exception raised on duplicate ipei.""" - super().__init__(f"The IPEI {dectutil.format_ipei(ipei[0], ipei[1])} is in use") - - -class PhonebookConflictLongError(ValidationError): - """Exception raised on conflict with longer number.""" - - def __init__(self, number: str) -> None: - """Exception raised on conflict with longer number.""" - super().__init__(f"The DECT number {number} is not available, it conflicts with a longer number.") - - -class PhonebookConflictShortError(ValidationError): - """Exception raised on conflict with shorter number.""" - - def __init__(self, number: str) -> None: - """Exception raised on conflict with shorter number.""" - super().__init__(f"The DECT number {number} is not available, it conflicts with a shorter number.") - - -class NumberNumericError(ValidationError): - """Exception raised when number is not numeric.""" - - def __init__(self) -> None: - """Exception raised when number is not numeric.""" - super().__init__("Phonenumber must be numeric!") - - -class LettersNumberSizeError(ValidationError): - """Exception raised on wrong number of letters or numbers.""" - - def __init__(self, number: str, letters: str) -> None: - """Exception raised on wrong number of letters or numbers.""" - super().__init__(f"Wrong number of letters ({len(letters)}) - should be {len(number)}") - - -class LetterNullOneError(ValidationError): - """Exception raised on when 0 or 1 are used to express letters.""" - - def __init__(self) -> None: - """Exception raised on when 0 or 1 are used to express letters.""" - super().__init__("Numbers with 0 and 1 in them can not be expressed as letters") - - -class DigitError(ValidationError): - """Exception raised on digit does not match the letter.""" - - def __init__(self, digit: str, letter: str) -> None: - """Exception raised on digit does not match the letter.""" - super().__init__( - f"The digit '{digit}' does not match the letter '{letter}'. \ - Valid letters for the digit '{digit}' are: {dectutil.DECT_MATRIX[digit]}" - ) - - class DectRegistration( ExportModelOperationsMixin("dect_registration"), CampRelatedModel, From 649d57257ed06e620b901916104d13f831a4d11a Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Tue, 13 May 2025 07:01:50 +0200 Subject: [PATCH 08/30] ruffed maps --- pyproject.toml | 3 +- src/bornhack/environment_settings.py.dist | 3 + src/bornhack/environment_settings.py.dist.dev | 3 + src/maps/__init__.py | 1 + src/maps/admin.py | 34 +++- src/maps/apps.py | 2 + src/maps/mixins.py | 32 +++- src/maps/models.py | 31 +++- src/maps/tests/__init__.py | 0 src/maps/{tests.py => tests/test_views.py} | 9 + src/maps/urls.py | 1 + src/maps/utils.py | 5 +- src/maps/views.py | 159 +++++++++++------- 13 files changed, 203 insertions(+), 80 deletions(-) create mode 100644 src/maps/tests/__init__.py rename src/maps/{tests.py => tests/test_views.py} (86%) diff --git a/pyproject.toml b/pyproject.toml index b31850264..0e1bfdfb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,7 +122,8 @@ ignore = [ "ANN001", # Missing type annotation for function argument ... "ANN201", # Missing return type annotation for public function ... "S101", # Allow use of assert - "S106", # Allow passwords in tests + "S105", # Allow PASSWORD as password in tests + "S106", # Allow passwords in tests "S113", # Probable use of requests call without timeout "E501", # Line too long "D104", # Missing docstring in public package diff --git a/src/bornhack/environment_settings.py.dist b/src/bornhack/environment_settings.py.dist index 21c80f1d1..8afd8462f 100644 --- a/src/bornhack/environment_settings.py.dist +++ b/src/bornhack/environment_settings.py.dist @@ -76,6 +76,9 @@ SCHEDULE_TIMESLOT_LENGTH_MINUTES=30 SCHEDULE_EVENT_NOTIFICATION_MINUTES=10 SPEAKER_AVAILABILITY_DAYCHUNK_HOURS=3 # how many hours per speaker_availability form checkbox +# Map settings +MAPS_USER_LOCATION_MAX = 50 # Maximum number of UserLocations a user can create + # irc bot settings IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS=10 IRCBOT_NICK='{{ django_ircbot_nickname }}' diff --git a/src/bornhack/environment_settings.py.dist.dev b/src/bornhack/environment_settings.py.dist.dev index abae28c51..26fbadaf1 100644 --- a/src/bornhack/environment_settings.py.dist.dev +++ b/src/bornhack/environment_settings.py.dist.dev @@ -39,6 +39,9 @@ SCHEDULE_TIMESLOT_LENGTH_MINUTES = 30 SCHEDULE_EVENT_NOTIFICATION_MINUTES = 10 SPEAKER_AVAILABILITY_DAYCHUNK_HOURS=3 +# Map settings +MAPS_USER_LOCATION_MAX = 50 # Maximum number of UserLocations a user can create + PDF_TEST_MODE = True PDF_ARCHIVE_PATH = os.path.join(MEDIA_ROOT, "pdf_archive") diff --git a/src/maps/__init__.py b/src/maps/__init__.py index e69de29bb..3bfb95a8c 100644 --- a/src/maps/__init__.py +++ b/src/maps/__init__.py @@ -0,0 +1 @@ +"""Maps Application.""" diff --git a/src/maps/admin.py b/src/maps/admin.py index 0a7e0effe..b773c62bc 100644 --- a/src/maps/admin.py +++ b/src/maps/admin.py @@ -1,5 +1,14 @@ +"""Maps Django Admin.""" from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from django.db.models import QuerySet + from django.http import HttpRequest + +from typing import ClassVar + from django.contrib import admin from leaflet.admin import LeafletGeoAdmin @@ -13,47 +22,54 @@ @admin.register(Feature) class FeatureAdmin(LeafletGeoAdmin, admin.ModelAdmin): + """Feature Admin.""" display_raw = True save_as = True - list_display = [ + list_display: ClassVar[list[str]] = [ "name", "description", ] - list_filter = [ + list_filter: ClassVar[list[str]] = [ "layer", ] - def get_queryset(self, request): + def get_queryset(self, request: HttpRequest) -> QuerySet: + """Get the QuerySet.""" self.request = request return super().get_queryset(request) @admin.register(Layer) class LayerAdmin(admin.ModelAdmin): + """Layer admin.""" save_as = True - list_display = ["name", "slug"] + list_display: ClassVar[list[str]] = ["name", "slug"] @admin.register(ExternalLayer) class ExternalLayerAdmin(admin.ModelAdmin): + """Layer admin.""" save_as = True - list_display = ["name"] + list_display: ClassVar[list[str]] = ["name"] @admin.register(Group) class GroupAdmin(admin.ModelAdmin): + """Group admin.""" save_as = True - list_display = ["name"] + list_display: ClassVar[list[str]] = ["name"] @admin.register(UserLocationType) class UserLocationTypeAdmin(admin.ModelAdmin): + """User Location Type admin.""" save_as = True - list_display = ["name"] + list_display: ClassVar[list[str]] = ["name"] @admin.register(UserLocation) class UserLocationAdmin(admin.ModelAdmin): + """User Location admin.""" save_as = True - list_display = ["name", "type", "user", "camp"] - list_filter = ["camp", "user"] + list_display: ClassVar[list[str]] = ["name", "type", "user", "camp"] + list_filter: ClassVar[list[str]] = ["camp", "user"] diff --git a/src/maps/apps.py b/src/maps/apps.py index 4701d6a44..8e280ae1f 100644 --- a/src/maps/apps.py +++ b/src/maps/apps.py @@ -1,7 +1,9 @@ +"""Apps for the Maps app.""" from __future__ import annotations from django.apps import AppConfig class MapsConfig(AppConfig): + """Maps config.""" name = "maps" diff --git a/src/maps/mixins.py b/src/maps/mixins.py index 5b1839eac..e45873136 100644 --- a/src/maps/mixins.py +++ b/src/maps/mixins.py @@ -1,5 +1,11 @@ +"""Mixins for Maps app.""" from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from django.http import HttpRequest + from django.contrib import messages from django.core.exceptions import PermissionDenied from django.shortcuts import get_object_or_404 @@ -14,19 +20,26 @@ class LayerViewMixin: """A mixin to get the Layer object based on layer_slug in url kwargs.""" def setup(self, *args, **kwargs) -> None: + """Setup the mixin.""" super().setup(*args, **kwargs) self.layer = get_object_or_404(Layer, slug=self.kwargs["layer_slug"]) - def get_context_data(self, *args, **kwargs): + def get_context_data(self, *args, **kwargs) -> dict: + """Get context data.""" context = super().get_context_data(*args, **kwargs) context["layer"] = self.layer return context class LayerMapperViewMixin(LayerViewMixin): - """A mixin for views only available to users with mapper permission for the team responsible for the layer and/or Mapper team permission.""" + """A mixin for LayerMapper. - def setup(self, request, *args, **kwargs) -> None: + only available to users with mapper permission for the team responsible + for the layer and/or Mapper team permission. + """ + + def setup(self, request: HttpRequest, *args, **kwargs) -> None: + """Setup the mixin.""" super().setup(request, *args, **kwargs) if ( self.layer.responsible_team @@ -40,7 +53,8 @@ def setup(self, request, *args, **kwargs) -> None: class GisTeamViewMixin: """A mixin for views only available to users with `camps.gis_team_member` permission.""" - def setup(self, request, *args, **kwargs) -> None: + def setup(self, request: HttpRequest, *args, **kwargs) -> None: + """Setup the mixin.""" super().setup(request, *args, **kwargs) if self.request.user.has_perm("camps.gis_team_member"): return @@ -52,6 +66,7 @@ class ExternalLayerViewMixin(CampViewMixin): """A mixin to get the ExternalLayer object based on external_layer_uuid in url kwargs.""" def setup(self, *args, **kwargs) -> None: + """Setup the mixin.""" super().setup(*args, **kwargs) self.layer = get_object_or_404( ExternalLayer, @@ -60,9 +75,14 @@ def setup(self, *args, **kwargs) -> None: class ExternalLayerMapperViewMixin(ExternalLayerViewMixin): - """A mixin for views only available to users with mapper permission for the team responsible for the layer and/or Mapper team permission.""" + """A mixin for views. + + only available to users with mapper permission for the team responsible + for the layer and/or Mapper team permission. + """ - def setup(self, request, *args, **kwargs) -> None: + def setup(self, request: HttpRequest, *args, **kwargs) -> None: + """Setup the mixin.""" super().setup(request, *args, **kwargs) if ( self.layer.responsible_team diff --git a/src/maps/models.py b/src/maps/models.py index 6132b1f1a..41d2c9ef9 100644 --- a/src/maps/models.py +++ b/src/maps/models.py @@ -1,6 +1,13 @@ +"""Maps models.""" from __future__ import annotations import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from camps.models import Camp + +from typing import ClassVar from colorfield.fields import ColorField from django.contrib.auth.models import User @@ -27,6 +34,7 @@ class Group(UUIDModel): ) def __str__(self) -> str: + """String formatter.""" return str(self.name) @@ -73,13 +81,16 @@ class Layer(ExportModelOperationsMixin("layer"), UUIDModel): ) @property - def camp(self): + def camp(self) -> Camp: + """Camp object reference.""" return self.responsible_team.camp def __str__(self) -> str: + """String formatter.""" return str(self.name) def save(self, **kwargs) -> None: + """Model save function.""" self.slug = unique_slugify( str(self.name), slugs_in_use=self.__class__.objects.all().values_list( @@ -134,7 +145,8 @@ class Feature(UUIDModel): ) class Meta: - constraints = [ + """Meta data.""" + constraints: ClassVar[list] = [ models.UniqueConstraint( fields=["layer", "name"], name="layer_and_name_uniq", @@ -142,14 +154,17 @@ class Meta: ] def __str__(self) -> str: + """String formatter.""" return str(self.name) @property - def camp(self): + def camp(self) -> Camp: + """Camp object reference.""" return self.layer.team.camp class ExternalLayer(UUIDModel): + """External layer model.""" name = models.CharField( max_length=100, help_text="Name or description of this layer", @@ -177,13 +192,16 @@ class ExternalLayer(UUIDModel): ) @property - def camp(self): + def camp(self) -> Camp: + """Camp object reference.""" return self.responsible_team.camp def __str__(self) -> str: + """String formatter.""" return str(self.name) def save(self, **kwargs) -> None: + """Model save function.""" self.slug = unique_slugify( str(self.name), slugs_in_use=self.__class__.objects.all().values_list( @@ -195,6 +213,7 @@ def save(self, **kwargs) -> None: class UserLocationType(UUIDModel): + """User Location Type model.""" name = models.CharField( max_length=100, help_text="Name of the user location type", @@ -220,9 +239,11 @@ class UserLocationType(UUIDModel): ) def __str__(self) -> str: + """String formatter.""" return self.name def save(self, **kwargs) -> None: + """Model save function.""" if not self.slug: self.slug = unique_slugify( self.name, @@ -238,6 +259,7 @@ class UserLocation( UUIDModel, CampRelatedModel, ): + """UserLocation model.""" name = models.CharField( max_length=100, help_text="Name of the location", @@ -278,4 +300,5 @@ class UserLocation( ) def __str__(self) -> str: + """String formatter.""" return self.name diff --git a/src/maps/tests/__init__.py b/src/maps/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/maps/tests.py b/src/maps/tests/test_views.py similarity index 86% rename from src/maps/tests.py rename to src/maps/tests/test_views.py index 766f30806..4eba129f0 100644 --- a/src/maps/tests.py +++ b/src/maps/tests/test_views.py @@ -1,3 +1,4 @@ +"""Test cases for the Maps application.""" from __future__ import annotations from unittest import mock @@ -19,7 +20,9 @@ DATAFORDELER_PASSWORD=PASSWORD, ) class MapProxyViewTest(TestCase): + """Test the Proxy view.""" def setUp(self): + """Setup function.""" self.rf = RequestFactory() self.allowed_endpoints = [ @@ -29,12 +32,14 @@ def setUp(self): ] def test_endpoint_not_allowed_raises_perm_denied(self): + """Test endpoint not allowed.""" fix_request = self.rf.get("/maps/kfproxy/not/allowed/endpoint") with self.assertRaises(PermissionDenied): MapProxyView.as_view()(fix_request) def test_all_allowed_endpoints(self): + """Test allowed endpoints.""" for endpoint in self.allowed_endpoints: fix_request = self.rf.get("/maps/kfproxy" + endpoint) with self.subTest(request=fix_request): @@ -45,6 +50,7 @@ def test_all_allowed_endpoints(self): self.assertEqual(result.status_code, 200) def test_sanitizing_path(self): + """Test sanitization of paths.""" fix_path = "/maps/kfproxy/DHMNedboer/dhm/1.0.0/wms?transparent=true" result = MapProxyView().sanitize_path(fix_path) @@ -52,6 +58,7 @@ def test_sanitizing_path(self): self.assertEqual(result, "/DHMNedboer/dhm/1.0.0/wms?transparent=TRUE") def test_sanitizing_path_not_failing_without_query(self): + """Test sanitization of paths without query.""" fix_path = "/maps/kfproxy/DHMNedboer/dhm/1.0.0/wms" result = MapProxyView().sanitize_path(fix_path) @@ -59,6 +66,7 @@ def test_sanitizing_path_not_failing_without_query(self): self.assertEqual(result, "/DHMNedboer/dhm/1.0.0/wms") def test_append_credentials(self): + """Test appending credentials.""" fix_path = "/path" fix_result = fix_path + f"&username={USER}&password={PASSWORD}" @@ -67,6 +75,7 @@ def test_append_credentials(self): self.assertEqual(result, fix_result) def test_append_credentials_raises_perm_denied_if_no_creds_is_set(self): + """Test appending credentials exceptions.""" with self.settings( DATAFORDELER_USER="", DATAFORDELER_PASSWORD="", diff --git a/src/maps/urls.py b/src/maps/urls.py index 0529ea295..16a72e318 100644 --- a/src/maps/urls.py +++ b/src/maps/urls.py @@ -1,3 +1,4 @@ +"""Maps URLS File.""" from __future__ import annotations from django.urls import include diff --git a/src/maps/utils.py b/src/maps/utils.py index cd5c1deff..1c5a1b65f 100644 --- a/src/maps/utils.py +++ b/src/maps/utils.py @@ -1,10 +1,13 @@ +"""Utils for the Maps APP.""" from __future__ import annotations from django.db import models class LeafletMarkerChoices(models.TextChoices): - """Leaflet icon color choices, a models.TextChoices class to use when we want to set + """Leaflet icon color choices. + + a models.TextChoices class to use when we want to set choices for a model field to pick a marker colour for a Leaflet map. These map directly to the L.Icon() objects in static_src/js/leaflet-color-markers.js. """ diff --git a/src/maps/views.py b/src/maps/views.py index 5e718add7..17d972d83 100644 --- a/src/maps/views.py +++ b/src/maps/views.py @@ -1,8 +1,10 @@ +"""Maps view.""" from __future__ import annotations import json import logging import re +from typing import TYPE_CHECKING import requests from django.conf import settings @@ -12,6 +14,7 @@ from django.core.exceptions import PermissionDenied from django.core.serializers import serialize from django.db.models import Q +from django.http import HttpRequest from django.http import HttpResponse from django.http import HttpResponseNotAllowed from django.shortcuts import get_object_or_404 @@ -30,6 +33,13 @@ from leaflet.forms.widgets import LeafletWidget from oauth2_provider.views.generic import ScopedProtectedResourceView +if TYPE_CHECKING: + from django.db.models import QuerySet + from django.forms import BaseForm + from django.http import TemplateResponse + +from typing import ClassVar + from camps.mixins import CampViewMixin from facilities.models import FacilityType from utils.color import adjust_color @@ -45,10 +55,18 @@ logger = logging.getLogger(f"bornhack.{__name__}") +ERROR_COLOR_FORMAT = "Hex color must be in format RRGGBB or RRGGBBAA" + -class MissingCredentials(Exception): - pass +class MissingCredentialsError(Exception): + """Missing Credentials Exception.""" +class MarkerColorError(ValueError): + """Exception raised on a invalid marker color.""" + + def __init__(self) -> None: + """Exception raised on a invalid marker color.""" + super().__init__("Hex color must be in format RRGGBB or RRGGBBAA") class MapMarkerView(TemplateView): """View for generating the coloured marker.""" @@ -56,19 +74,21 @@ class MapMarkerView(TemplateView): template_name = "marker.svg" @property - def color(self): + def color(self) -> tuple: + """Return the color values as ints.""" hex_color = self.kwargs["color"] length = len(hex_color) - if length == 6: # RGB + if length == 6: # RGB # noqa: PLR2004 r, g, b = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) return (r, g, b) - if length == 8: # RGBA + if length == 8: # RGBA # noqa: PLR2004 r, g, b, a = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6)) return (r, g, b, a) - raise ValueError("Hex color must be in format RRGGBB or RRGGBBAA") + raise MarkerColorError - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> dict: + """Get the context data.""" context = super().get_context_data(**kwargs) context["stroke1"] = self.color context["stroke0"] = adjust_color(self.color, -0.4) if is_dark(self.color) else adjust_color(self.color) @@ -76,7 +96,8 @@ def get_context_data(self, **kwargs): context["fill1"] = self.color return context - def render_to_response(self, context, **kwargs): + def render_to_response(self, context: dict, **kwargs) -> TemplateResponse: + """Render the SVG output.""" return super().render_to_response( context, content_type="image/svg+xml", @@ -90,7 +111,8 @@ class MapView(CampViewMixin, TemplateView): template_name = "maps_map.html" context_object_name = "maps_map" - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> dict: + """Get the context data.""" context = super().get_context_data(**kwargs) context["facilitytype_list"] = FacilityType.objects.filter( responsible_team__camp=self.camp, @@ -155,7 +177,8 @@ def get_context_data(self, **kwargs): class LayerGeoJSONView(LayerViewMixin, JsonView): """GeoJSON export view.""" - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> list: + """Return the GeoJSON Data to the client.""" return json.loads( serialize( "geojson", @@ -175,20 +198,22 @@ def get_context_data(self, **kwargs): class MapProxyView(View): - """Proxy for Datafordeler map service. Created so we can show maps without - leaking the IP of our visitors. + """Proxy for Datafordeler map service. + + Created so we can show maps without leaking the IP of our visitors. """ PROXY_URL = "/maps/kfproxy" - VALID_ENDPOINTS = [ + VALID_ENDPOINTS: ClassVar[list[str]] = [ "/GeoDanmarkOrto/orto_foraar_wmts/1.0.0/WMTS", "/GeoDanmarkOrto/orto_foraar/1.0.0/WMS", "/Dkskaermkort/topo_skaermkort/1.0.0/wms", "/DHMNedboer/dhm/1.0.0/wms", ] - def get(self, *args, **kwargs): + def get(self, *args, **kwargs) -> HttpResponse: """Before we make the request we check that the path is in our whitelist. + Before we return the response we copy headers except for a list we dont want. """ # Raise PermissionDenied if endpoint isn't valid @@ -201,7 +226,7 @@ def get(self, *args, **kwargs): path = self.append_credentials(path) # make the request - r = requests.get("https://services.datafordeler.dk" + path) + r = requests.get("https://services.datafordeler.dk" + path, timeout=10) # make the response response = HttpResponse(r.content, status=r.status_code) @@ -237,7 +262,7 @@ def is_endpoint_valid(self, path: str) -> None: endpoint, self.VALID_ENDPOINTS, ) - raise PermissionDenied("No thanks") + raise PermissionDenied def sanitize_path(self, path: str) -> str: """Sanitize path by removing PROXY_URL and set 'transparent' value to upper.""" @@ -256,7 +281,7 @@ def append_credentials(self, path: str) -> str: logger.error( "Missing credentials for 'DATAFORDELER_USER' or 'DATAFORDELER_PASSWORD'", ) - raise MissingCredentials + raise MissingCredentialsError path += f"&username={username}&password={password}" return path @@ -267,7 +292,8 @@ def append_credentials(self, path: str) -> str: class UserLocationLayerView(CampViewMixin, JsonView): """UserLocation geojson view.""" - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> dict: + """Get context data.""" context = {} context["type"] = "FeatureCollection" context["features"] = self.dump_locations() @@ -275,37 +301,36 @@ def get_context_data(self, **kwargs): def dump_locations(self) -> list[object]: """GeoJSON Formatter.""" - output = [] - for location in UserLocation.objects.filter( - camp=self.camp, - type__slug=self.kwargs["user_location_type_slug"], - ): - output.append( - { - "type": "Feature", - "id": location.pk, - "geometry": { - "type": "Point", - "coordinates": [location.location.x, location.location.y], - }, - "properties": { - "name": location.name, - "type": location.type.name, - "icon": location.type.icon, - "marker": location.type.marker, - "user": location.user.profile.get_public_credit_name, - "data": location.data, - }, + return [ + { + "type": "Feature", + "id": location.pk, + "geometry": { + "type": "Point", + "coordinates": [location.location.x, location.location.y], + }, + "properties": { + "name": location.name, + "type": location.type.name, + "icon": location.type.icon, + "marker": location.type.marker, + "user": location.user.profile.get_public_credit_name, + "data": location.data, }, + } + for location in UserLocation.objects.filter( + camp=self.camp, + type__slug=self.kwargs["user_location_type_slug"], ) - return output - + ] class UserLocationListView(LoginRequiredMixin, CampViewMixin, ListView): + """UserLocation view.""" template_name = "user_location_list.html" model = UserLocation - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> dict: + """Get data for the view.""" context = super().get_context_data(**kwargs) context["user_location_types"] = UserLocationType.objects.all().values_list( "slug", @@ -313,19 +338,23 @@ def get_context_data(self, **kwargs): ) return context - def get_queryset(self, *args, **kwargs): + def get_queryset(self, *args, **kwargs) -> QuerySet: """Show only entries belonging to the current user.""" qs = super().get_queryset(*args, **kwargs) return qs.filter(user=self.request.user) class UserLocationCreateView(LoginRequiredMixin, CampViewMixin, CreateView): + """Create view for UserLocation.""" model = UserLocation template_name = "user_location_form.html" - fields = ["name", "type", "location", "data"] + fields: ClassVar[list[str]] = ["name", "type", "location", "data"] - def dispatch(self, *args, **kwargs): - if UserLocation.objects.filter(user=self.request.user, camp=self.camp).count() > 49: + def dispatch(self, *args, **kwargs) -> str: + """Check user limits.""" + if (UserLocation.objects.filter( + user=self.request.user, + camp=self.camp).count() >= settings.MAPS_USER_LOCATION_MAX): messages.error( self.request, "To many User Locations (50), please delete some.", @@ -338,7 +367,8 @@ def dispatch(self, *args, **kwargs): ) return super().dispatch(*args, **kwargs) - def get_form(self, *args, **kwargs): + def get_form(self, *args, **kwargs) -> BaseForm: + """Prepare the form.""" form = super().get_form(*args, **kwargs) form.fields["location"].widget = LeafletWidget( attrs={ @@ -350,7 +380,8 @@ def get_form(self, *args, **kwargs): ) return form - def form_valid(self, form): + def form_valid(self, form: BaseForm) -> str: + """Check if the form is valid.""" location = form.save(commit=False) location.camp = self.camp location.user = self.request.user @@ -373,18 +404,21 @@ class UserLocationUpdateView( UserIsObjectOwnerMixin, UpdateView, ): + """Update view for UserLocation.""" model = UserLocation template_name = "user_location_form.html" - fields = ["name", "type", "location", "data"] + fields: ClassVar[list[str]] = ["name", "type", "location", "data"] slug_url_kwarg = "user_location" slug_field = "pk" - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> dict: + """Get the context data for the view.""" context = super().get_context_data(**kwargs) context["mapData"] = {"grid": static("json/grid.geojson")} return context - def get_form(self, *args, **kwargs): + def get_form(self, *args, **kwargs) -> BaseForm: + """get_form preparing the form.""" form = super().get_form(*args, **kwargs) form.fields["location"].widget = LeafletWidget( attrs={ @@ -396,7 +430,8 @@ def get_form(self, *args, **kwargs): ) return form - def get_success_url(self): + def get_success_url(self) -> str: + """Produce the success url.""" return reverse( "maps_user_location_list", kwargs={"camp_slug": self.camp.slug}, @@ -409,12 +444,14 @@ class UserLocationDeleteView( UserIsObjectOwnerMixin, DeleteView, ): + """Delete view for UserLocation.""" model = UserLocation template_name = "user_location_delete.html" slug_url_kwarg = "user_location" slug_field = "pk" - def get_success_url(self): + def get_success_url(self) -> str: + """Produce the success url.""" messages.success( self.request, f"Your User Location {self.get_object().name} has been deleted successfully.", @@ -433,9 +470,10 @@ class UserLocationApiView( ): """This view has 2 endpoints /create/api (POST) AND //api (GET, PATCH, DELETE).""" - required_scopes = ["location:write"] + required_scopes: ClassVar[list[str]] = ["location:write"] - def get(self, request, **kwargs): + def get(self, request: HttpRequest, **kwargs) -> dict: + """HTTP Method for viewing a user location.""" if "user_location" not in kwargs: return HttpResponseNotAllowed(permitted_methods=["POST"]) location = get_object_or_404( @@ -451,10 +489,11 @@ def get(self, request, **kwargs): "data": location.data, } - def post(self, request, **kwargs): + def post(self, request: HttpRequest, **kwargs) -> dict: + """HTTP Method for creating a user location.""" if "user_location" in kwargs: return HttpResponseNotAllowed(permitted_methods=["GET", "PATCH", "DELETE"]) - if UserLocation.objects.filter(user=request.user, camp=self.camp).count() > 49: + if UserLocation.objects.filter(user=request.user, camp=self.camp).count() >= settings.MAPS_USER_LOCATION_MAX: return {"error": "To many user locations created (50)"} data = json.loads(request.body) try: @@ -481,7 +520,8 @@ def post(self, request, **kwargs): "data": location.data, } - def patch(self, request, **kwargs): + def patch(self, request: HttpRequest, **kwargs) -> dict: + """HTTP Method for updating a user location.""" if "user_location" not in kwargs and "camp_slug" not in kwargs: return HttpResponseNotAllowed(permitted_methods=["POST"]) location = get_object_or_404( @@ -513,7 +553,8 @@ def patch(self, request, **kwargs): "data": location.data, } - def delete(self, request, **kwargs): + def delete(self, request: HttpRequest, **kwargs) -> dict: + """HTTP Method for deleting a user location.""" if "user_location" not in kwargs and "camp_slug" not in kwargs: return HttpResponseNotAllowed(permitted_methods=["POST"]) location = get_object_or_404( From c5923faf891bb9ff6c71a0bb19f852f1fdfcf7eb Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Tue, 13 May 2025 07:03:12 +0200 Subject: [PATCH 09/30] More linting --- src/maps/admin.py | 7 +++++++ src/maps/apps.py | 2 ++ src/maps/mixins.py | 1 + src/maps/models.py | 5 +++++ src/maps/urls.py | 1 + src/maps/utils.py | 1 + src/maps/views.py | 15 ++++++++++++--- 7 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/maps/admin.py b/src/maps/admin.py index b773c62bc..375e29629 100644 --- a/src/maps/admin.py +++ b/src/maps/admin.py @@ -1,4 +1,5 @@ """Maps Django Admin.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -23,6 +24,7 @@ @admin.register(Feature) class FeatureAdmin(LeafletGeoAdmin, admin.ModelAdmin): """Feature Admin.""" + display_raw = True save_as = True list_display: ClassVar[list[str]] = [ @@ -42,6 +44,7 @@ def get_queryset(self, request: HttpRequest) -> QuerySet: @admin.register(Layer) class LayerAdmin(admin.ModelAdmin): """Layer admin.""" + save_as = True list_display: ClassVar[list[str]] = ["name", "slug"] @@ -49,6 +52,7 @@ class LayerAdmin(admin.ModelAdmin): @admin.register(ExternalLayer) class ExternalLayerAdmin(admin.ModelAdmin): """Layer admin.""" + save_as = True list_display: ClassVar[list[str]] = ["name"] @@ -56,6 +60,7 @@ class ExternalLayerAdmin(admin.ModelAdmin): @admin.register(Group) class GroupAdmin(admin.ModelAdmin): """Group admin.""" + save_as = True list_display: ClassVar[list[str]] = ["name"] @@ -63,6 +68,7 @@ class GroupAdmin(admin.ModelAdmin): @admin.register(UserLocationType) class UserLocationTypeAdmin(admin.ModelAdmin): """User Location Type admin.""" + save_as = True list_display: ClassVar[list[str]] = ["name"] @@ -70,6 +76,7 @@ class UserLocationTypeAdmin(admin.ModelAdmin): @admin.register(UserLocation) class UserLocationAdmin(admin.ModelAdmin): """User Location admin.""" + save_as = True list_display: ClassVar[list[str]] = ["name", "type", "user", "camp"] list_filter: ClassVar[list[str]] = ["camp", "user"] diff --git a/src/maps/apps.py b/src/maps/apps.py index 8e280ae1f..6dfda87f1 100644 --- a/src/maps/apps.py +++ b/src/maps/apps.py @@ -1,4 +1,5 @@ """Apps for the Maps app.""" + from __future__ import annotations from django.apps import AppConfig @@ -6,4 +7,5 @@ class MapsConfig(AppConfig): """Maps config.""" + name = "maps" diff --git a/src/maps/mixins.py b/src/maps/mixins.py index e45873136..a740d4098 100644 --- a/src/maps/mixins.py +++ b/src/maps/mixins.py @@ -1,4 +1,5 @@ """Mixins for Maps app.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/src/maps/models.py b/src/maps/models.py index 41d2c9ef9..ea567f8c7 100644 --- a/src/maps/models.py +++ b/src/maps/models.py @@ -1,4 +1,5 @@ """Maps models.""" + from __future__ import annotations import logging @@ -146,6 +147,7 @@ class Feature(UUIDModel): class Meta: """Meta data.""" + constraints: ClassVar[list] = [ models.UniqueConstraint( fields=["layer", "name"], @@ -165,6 +167,7 @@ def camp(self) -> Camp: class ExternalLayer(UUIDModel): """External layer model.""" + name = models.CharField( max_length=100, help_text="Name or description of this layer", @@ -214,6 +217,7 @@ def save(self, **kwargs) -> None: class UserLocationType(UUIDModel): """User Location Type model.""" + name = models.CharField( max_length=100, help_text="Name of the user location type", @@ -260,6 +264,7 @@ class UserLocation( CampRelatedModel, ): """UserLocation model.""" + name = models.CharField( max_length=100, help_text="Name of the location", diff --git a/src/maps/urls.py b/src/maps/urls.py index 16a72e318..963f5789e 100644 --- a/src/maps/urls.py +++ b/src/maps/urls.py @@ -1,4 +1,5 @@ """Maps URLS File.""" + from __future__ import annotations from django.urls import include diff --git a/src/maps/utils.py b/src/maps/utils.py index 1c5a1b65f..510e502a7 100644 --- a/src/maps/utils.py +++ b/src/maps/utils.py @@ -1,4 +1,5 @@ """Utils for the Maps APP.""" + from __future__ import annotations from django.db import models diff --git a/src/maps/views.py b/src/maps/views.py index 17d972d83..862d64762 100644 --- a/src/maps/views.py +++ b/src/maps/views.py @@ -1,4 +1,5 @@ """Maps view.""" + from __future__ import annotations import json @@ -61,6 +62,7 @@ class MissingCredentialsError(Exception): """Missing Credentials Exception.""" + class MarkerColorError(ValueError): """Exception raised on a invalid marker color.""" @@ -68,6 +70,7 @@ def __init__(self) -> None: """Exception raised on a invalid marker color.""" super().__init__("Hex color must be in format RRGGBB or RRGGBBAA") + class MapMarkerView(TemplateView): """View for generating the coloured marker.""" @@ -324,8 +327,10 @@ def dump_locations(self) -> list[object]: ) ] + class UserLocationListView(LoginRequiredMixin, CampViewMixin, ListView): """UserLocation view.""" + template_name = "user_location_list.html" model = UserLocation @@ -346,15 +351,17 @@ def get_queryset(self, *args, **kwargs) -> QuerySet: class UserLocationCreateView(LoginRequiredMixin, CampViewMixin, CreateView): """Create view for UserLocation.""" + model = UserLocation template_name = "user_location_form.html" fields: ClassVar[list[str]] = ["name", "type", "location", "data"] def dispatch(self, *args, **kwargs) -> str: """Check user limits.""" - if (UserLocation.objects.filter( - user=self.request.user, - camp=self.camp).count() >= settings.MAPS_USER_LOCATION_MAX): + if ( + UserLocation.objects.filter(user=self.request.user, camp=self.camp).count() + >= settings.MAPS_USER_LOCATION_MAX + ): messages.error( self.request, "To many User Locations (50), please delete some.", @@ -405,6 +412,7 @@ class UserLocationUpdateView( UpdateView, ): """Update view for UserLocation.""" + model = UserLocation template_name = "user_location_form.html" fields: ClassVar[list[str]] = ["name", "type", "location", "data"] @@ -445,6 +453,7 @@ class UserLocationDeleteView( DeleteView, ): """Delete view for UserLocation.""" + model = UserLocation template_name = "user_location_delete.html" slug_url_kwarg = "user_location" From c8cf8db338a2eea7374729cdf529b8515d85dac7 Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Tue, 13 May 2025 07:04:53 +0200 Subject: [PATCH 10/30] Small change to tests --- src/maps/tests/test_views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/maps/tests/test_views.py b/src/maps/tests/test_views.py index 4eba129f0..2b5f7b6cc 100644 --- a/src/maps/tests/test_views.py +++ b/src/maps/tests/test_views.py @@ -8,8 +8,8 @@ from django.test import override_settings from django.test.client import RequestFactory -from .views import MapProxyView -from .views import MissingCredentials +from maps.views import MapProxyView +from maps.views import MissingCredentialsError USER = "user" PASSWORD = "password" @@ -79,5 +79,5 @@ def test_append_credentials_raises_perm_denied_if_no_creds_is_set(self): with self.settings( DATAFORDELER_USER="", DATAFORDELER_PASSWORD="", - ), self.assertRaises(MissingCredentials): + ), self.assertRaises(MissingCredentialsError): MapProxyView().append_credentials("path") From 4f817ea4302f340b9b63fd377e6b0632f7b3087c Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Tue, 13 May 2025 07:38:45 +0200 Subject: [PATCH 11/30] Small fixes --- src/maps/templates/maps_map.html | 2 +- src/maps/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/maps/templates/maps_map.html b/src/maps/templates/maps_map.html index eb96ec08c..aa77384ba 100644 --- a/src/maps/templates/maps_map.html +++ b/src/maps/templates/maps_map.html @@ -22,7 +22,7 @@ {{ mapData|json_script:"mapData" }} - + {% endblock extra_head %} {% block content %} diff --git a/src/maps/views.py b/src/maps/views.py index 862d64762..1295070ef 100644 --- a/src/maps/views.py +++ b/src/maps/views.py @@ -144,7 +144,7 @@ def get_context_data(self, **kwargs) -> dict: ), "externalLayers": list(context["externalLayers"].values()), "villages": reverse( - "villages_geojson", + "villages:villages_geojson", kwargs={"camp_slug": self.camp.slug}, ), "user_location_types": list( From 6bf3484f945789eac5e76b7cd5d02b521c0ccea3 Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Tue, 13 May 2025 12:25:32 +0200 Subject: [PATCH 12/30] Add more tests to maps --- src/maps/tests/test_views.py | 139 +++++++++++++++++++++++++++++++++++ src/maps/urls.py | 2 - src/maps/views.py | 32 +++++--- src/utils/tests.py | 18 ++++- 4 files changed, 178 insertions(+), 13 deletions(-) diff --git a/src/maps/tests/test_views.py b/src/maps/tests/test_views.py index 2b5f7b6cc..eb7fcf60d 100644 --- a/src/maps/tests/test_views.py +++ b/src/maps/tests/test_views.py @@ -1,15 +1,23 @@ """Test cases for the Maps application.""" from __future__ import annotations +from bs4 import BeautifulSoup from unittest import mock +from django.contrib.gis.geos import Point from django.core.exceptions import PermissionDenied from django.test import TestCase from django.test import override_settings from django.test.client import RequestFactory +from django.urls import reverse +from maps.models import Group +from maps.models import Layer +from maps.models import UserLocation +from maps.models import UserLocationType from maps.views import MapProxyView from maps.views import MissingCredentialsError +from utils.tests import BornhackTestBase USER = "user" PASSWORD = "password" @@ -81,3 +89,134 @@ def test_append_credentials_raises_perm_denied_if_no_creds_is_set(self): DATAFORDELER_PASSWORD="", ), self.assertRaises(MissingCredentialsError): MapProxyView().append_credentials("path") + +class MapsViewTest(BornhackTestBase): + """Test Maps View""" + + layer: Layer + group: Group + + @classmethod + def setUpTestData(cls) -> None: + """Setup test data.""" + # first add users and other basics + super().setUpTestData() + + # Create a layer + cls.group = Group(name="Test Group") + cls.group.save() + cls.layer = Layer( + name="Test layer 1", + slug="test_1", + description="Test Layer", + icon="fas fa-tractor", + group=cls.group, + responsible_team=cls.team, + ) + cls.layer.save() + + def test_geojson_layer_views(self) -> None: + """Test the geojson view.""" + url = reverse("maps:map_layer_geojson", kwargs={"layer_slug": self.layer.slug}) + response = self.client.get(url) + assert response.status_code == 200 + + # Test 404 of geojson layer + url = reverse("maps:map_layer_geojson", kwargs={"layer_slug": "123test"}) + response = self.client.get(url) + assert response.status_code == 404 + + def test_map_views(self) -> None: + """Test the map view.""" + url = reverse("maps_map", kwargs={"camp_slug": self.camp.slug}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_marker_views(self) -> None: + """Test the marker view.""" + good = ["ffffff", "ffffff00"] + bad = ["ffff", "qwerty"] + for color in good: + url = reverse("maps:marker", kwargs={"color": color}) + response = self.client.get(url) + assert response.status_code == 200 + + for color in bad: + url = reverse("maps:marker", kwargs={"color": color}) + response = self.client.get(url, raise_request_exception=True) + assert response.status_code == 400 + +class MapsUserLocationViewTest(BornhackTestBase): + """Test User Location Views""" + + user_location: UserLocation + user_location_type: UserLocationType + + @classmethod + def setUpTestData(cls) -> None: + """Setup test data.""" + super().setUpTestData() + + #Create user location type + cls.user_location_type = UserLocationType( + name="Test Type", + slug="test", + icon="fas fa-tractor", + marker="blueIcon", + ) + cls.user_location_type.save() + + #Create user location + cls.user_location = UserLocation( + name="Test User Location", + type = cls.user_location_type, + camp=cls.camp, + user=cls.users[0], + location=Point([9.940218,55.388329]), + ) + cls.user_location.save() + + def test_user_location_geojson_view(self) -> None: + """Test the user location geojson view.""" + url = reverse("maps_user_location_layer", kwargs={ + "camp_slug": self.camp.slug, + "user_location_type_slug": self.user_location_type.slug, + }) + response = self.client.get(url) + assert response.status_code == 200 + + def test_user_location_view(self) -> None: + """Test the user location list view.""" + self.client.force_login(self.users[0]) + url = reverse("maps_user_location_list", kwargs={ + "camp_slug": self.camp.slug, + }) + response = self.client.get(url) + assert response.status_code == 200 + + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div#main > table > tbody > tr") + self.assertEqual(len(rows), 1, "user location list does not return 1 entries") + + def test_user_location_create(self) -> None: + """Test the user location create view.""" + self.client.force_login(self.users[0]) + url = reverse("maps_user_location_create", kwargs={ + "camp_slug": self.camp.slug, + }) + response = self.client.post( + path=url, + data={ + "name": "Test User Location Create", + "type": self.user_location_type.pk, + "location": '{"type":"Point","coordinates":[9.940218,55.388329]}', + }, + follow=True, + ) + assert response.status_code == 200 + + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div#main > table > tbody > tr") + self.assertEqual(len(rows), 2, "user location list does not return 2 entries after create") diff --git a/src/maps/urls.py b/src/maps/urls.py index 963f5789e..f00104982 100644 --- a/src/maps/urls.py +++ b/src/maps/urls.py @@ -9,12 +9,10 @@ from .views import LayerGeoJSONView from .views import MapMarkerView from .views import MapProxyView -from .views import MapView app_name = "maps" urlpatterns = [ - path("map/", MapView.as_view(), name="map"), path("marker//", MapMarkerView.as_view(), name="marker"), path( "/", diff --git a/src/maps/views.py b/src/maps/views.py index 1295070ef..b7e3f1510 100644 --- a/src/maps/views.py +++ b/src/maps/views.py @@ -12,6 +12,7 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.gis.geos import Point +from django.core.exceptions import BadRequest from django.core.exceptions import PermissionDenied from django.core.serializers import serialize from django.db.models import Q @@ -64,11 +65,13 @@ class MissingCredentialsError(Exception): class MarkerColorError(ValueError): - """Exception raised on a invalid marker color.""" + """Exception raised on invalid color.""" def __init__(self) -> None: - """Exception raised on a invalid marker color.""" - super().__init__("Hex color must be in format RRGGBB or RRGGBBAA") + """Exception raised on invalid color.""" + error = "Hex color must be in format RRGGBB or RRGGBBAA" + logger.exception(error) + super().__init__(error) class MapMarkerView(TemplateView): @@ -83,20 +86,29 @@ def color(self) -> tuple: length = len(hex_color) if length == 6: # RGB # noqa: PLR2004 - r, g, b = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + try: + r, g, b = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + except ValueError as e: + raise MarkerColorError from e return (r, g, b) if length == 8: # RGBA # noqa: PLR2004 - r, g, b, a = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6)) + try: + r, g, b, a = (int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6)) + except ValueError as e: + raise MarkerColorError from e return (r, g, b, a) raise MarkerColorError def get_context_data(self, **kwargs) -> dict: """Get the context data.""" context = super().get_context_data(**kwargs) - context["stroke1"] = self.color - context["stroke0"] = adjust_color(self.color, -0.4) if is_dark(self.color) else adjust_color(self.color) - context["fill0"] = adjust_color(self.color, -0.4) if is_dark(self.color) else adjust_color(self.color) - context["fill1"] = self.color + try: + context["stroke0"] = adjust_color(self.color, -0.4) if is_dark(self.color) else adjust_color(self.color) + context["stroke1"] = self.color + context["fill0"] = adjust_color(self.color, -0.4) if is_dark(self.color) else adjust_color(self.color) + context["fill1"] = self.color + except MarkerColorError as e: + raise BadRequest from e return context def render_to_response(self, context: dict, **kwargs) -> TemplateResponse: @@ -180,7 +192,7 @@ def get_context_data(self, **kwargs) -> dict: class LayerGeoJSONView(LayerViewMixin, JsonView): """GeoJSON export view.""" - def get_context_data(self, **kwargs) -> list: + def get_context_data(self, **kwargs) -> dict: """Return the GeoJSON Data to the client.""" return json.loads( serialize( diff --git a/src/utils/tests.py b/src/utils/tests.py index 875b92e59..96a966462 100644 --- a/src/utils/tests.py +++ b/src/utils/tests.py @@ -7,12 +7,13 @@ import pytz from django.contrib.auth.models import User +from django.contrib.auth.models import Group from django.core.management import call_command from django.test import Client from django.test import TestCase from camps.models import Camp - +from teams.models import Team class TestBootstrapScript(TestCase): """Test bootstrap_devsite script (touching many codepaths)""" @@ -27,6 +28,7 @@ class BornhackTestBase(TestCase): """Bornhack base TestCase.""" users: list[User] camp: Camp + team: Team @classmethod def setUpTestData(cls) -> None: @@ -68,3 +70,17 @@ def setUpTestData(cls) -> None: user.set_password("user0") user.save() cls.users.append(user) + + #Create a team + team_group = Group(name="Test Team Group") + team_group.save() + cls.team = Team( + camp=cls.camp, + name="Test Team", + group=team_group, + slug="test", + shortslug="test", + description="Many test Such Team", + needs_members=True, + ) + cls.team.save() From f6b101871b394c3fc93d9d456982d2b316ab9880 Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Tue, 13 May 2025 12:26:04 +0200 Subject: [PATCH 13/30] Lint some more --- src/maps/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps/tests/test_views.py b/src/maps/tests/test_views.py index eb7fcf60d..0faa4efca 100644 --- a/src/maps/tests/test_views.py +++ b/src/maps/tests/test_views.py @@ -1,9 +1,9 @@ """Test cases for the Maps application.""" from __future__ import annotations -from bs4 import BeautifulSoup from unittest import mock +from bs4 import BeautifulSoup from django.contrib.gis.geos import Point from django.core.exceptions import PermissionDenied from django.test import TestCase From ec1b38996bf71ce7570783f4a38e60988b362b48 Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Tue, 13 May 2025 13:05:47 +0200 Subject: [PATCH 14/30] Remove unused variable --- src/maps/views.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/maps/views.py b/src/maps/views.py index b7e3f1510..5ae9c917e 100644 --- a/src/maps/views.py +++ b/src/maps/views.py @@ -57,9 +57,6 @@ logger = logging.getLogger(f"bornhack.{__name__}") -ERROR_COLOR_FORMAT = "Hex color must be in format RRGGBB or RRGGBBAA" - - class MissingCredentialsError(Exception): """Missing Credentials Exception.""" From da9cfa7f6194953554247e9661b57eff3d5d9918 Mon Sep 17 00:00:00 2001 From: Rudy <387694+zarya@users.noreply.github.com> Date: Tue, 13 May 2025 15:39:38 +0200 Subject: [PATCH 15/30] Update src/maps/mixins.py Co-authored-by: Thomas Steen Rasmussen --- src/maps/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps/mixins.py b/src/maps/mixins.py index a740d4098..71386a144 100644 --- a/src/maps/mixins.py +++ b/src/maps/mixins.py @@ -35,7 +35,7 @@ def get_context_data(self, *args, **kwargs) -> dict: class LayerMapperViewMixin(LayerViewMixin): """A mixin for LayerMapper. - only available to users with mapper permission for the team responsible + For views only available to users with mapper permission for the team responsible for the layer and/or Mapper team permission. """ From 85ecb69f6d36135749641f3141683203cb1eda21 Mon Sep 17 00:00:00 2001 From: Rudy <387694+zarya@users.noreply.github.com> Date: Tue, 13 May 2025 15:40:41 +0200 Subject: [PATCH 16/30] Update src/maps/mixins.py Co-authored-by: Thomas Steen Rasmussen --- src/maps/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps/mixins.py b/src/maps/mixins.py index 71386a144..3b5afb32c 100644 --- a/src/maps/mixins.py +++ b/src/maps/mixins.py @@ -21,7 +21,7 @@ class LayerViewMixin: """A mixin to get the Layer object based on layer_slug in url kwargs.""" def setup(self, *args, **kwargs) -> None: - """Setup the mixin.""" + """Set self.layer based on layer_slug in url kwargs.""" super().setup(*args, **kwargs) self.layer = get_object_or_404(Layer, slug=self.kwargs["layer_slug"]) From 8694a49f02ae1a9e127dad586b51af8a6bbcff3a Mon Sep 17 00:00:00 2001 From: Rudy <387694+zarya@users.noreply.github.com> Date: Tue, 13 May 2025 15:40:53 +0200 Subject: [PATCH 17/30] Update src/maps/admin.py Co-authored-by: Thomas Steen Rasmussen --- src/maps/admin.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/maps/admin.py b/src/maps/admin.py index 375e29629..e48a4f4dc 100644 --- a/src/maps/admin.py +++ b/src/maps/admin.py @@ -35,10 +35,6 @@ class FeatureAdmin(LeafletGeoAdmin, admin.ModelAdmin): "layer", ] - def get_queryset(self, request: HttpRequest) -> QuerySet: - """Get the QuerySet.""" - self.request = request - return super().get_queryset(request) @admin.register(Layer) From 43479edcc53d3e6912aea77e7ab013d7c681df13 Mon Sep 17 00:00:00 2001 From: Rudy <387694+zarya@users.noreply.github.com> Date: Tue, 13 May 2025 15:41:06 +0200 Subject: [PATCH 18/30] Update src/maps/mixins.py Co-authored-by: Thomas Steen Rasmussen --- src/maps/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps/mixins.py b/src/maps/mixins.py index 3b5afb32c..f2b52c6e4 100644 --- a/src/maps/mixins.py +++ b/src/maps/mixins.py @@ -26,7 +26,7 @@ def setup(self, *args, **kwargs) -> None: self.layer = get_object_or_404(Layer, slug=self.kwargs["layer_slug"]) def get_context_data(self, *args, **kwargs) -> dict: - """Get context data.""" + """Add self.layer to context.""" context = super().get_context_data(*args, **kwargs) context["layer"] = self.layer return context From 05c53d4ded3c109555d81a641e2ee30bdb811bc2 Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Tue, 13 May 2025 15:42:24 +0200 Subject: [PATCH 19/30] Remove unused --- src/maps/admin.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/maps/admin.py b/src/maps/admin.py index e48a4f4dc..8d81fe024 100644 --- a/src/maps/admin.py +++ b/src/maps/admin.py @@ -2,12 +2,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from django.db.models import QuerySet - from django.http import HttpRequest - from typing import ClassVar from django.contrib import admin From e2cde8b8d08ca90806303d3f7915b6052fddde54 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Tue, 13 May 2025 15:48:37 +0200 Subject: [PATCH 20/30] Update src/maps/mixins.py --- src/maps/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps/mixins.py b/src/maps/mixins.py index f2b52c6e4..eb8866289 100644 --- a/src/maps/mixins.py +++ b/src/maps/mixins.py @@ -40,7 +40,7 @@ class LayerMapperViewMixin(LayerViewMixin): """ def setup(self, request: HttpRequest, *args, **kwargs) -> None: - """Setup the mixin.""" + """Check permissions.""" super().setup(request, *args, **kwargs) if ( self.layer.responsible_team From 56a51b5529d719831b9a7b8aed08204ab3436b8c Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Tue, 13 May 2025 15:49:05 +0200 Subject: [PATCH 21/30] Update src/maps/mixins.py --- src/maps/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps/mixins.py b/src/maps/mixins.py index eb8866289..2148f2d05 100644 --- a/src/maps/mixins.py +++ b/src/maps/mixins.py @@ -55,7 +55,7 @@ class GisTeamViewMixin: """A mixin for views only available to users with `camps.gis_team_member` permission.""" def setup(self, request: HttpRequest, *args, **kwargs) -> None: - """Setup the mixin.""" + """Check permissions.""" super().setup(request, *args, **kwargs) if self.request.user.has_perm("camps.gis_team_member"): return From 7d46bd9555f57903e4b9201d70433f2b6001ebac Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Tue, 13 May 2025 15:50:01 +0200 Subject: [PATCH 22/30] Update src/maps/mixins.py --- src/maps/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps/mixins.py b/src/maps/mixins.py index 2148f2d05..86b929d48 100644 --- a/src/maps/mixins.py +++ b/src/maps/mixins.py @@ -67,7 +67,7 @@ class ExternalLayerViewMixin(CampViewMixin): """A mixin to get the ExternalLayer object based on external_layer_uuid in url kwargs.""" def setup(self, *args, **kwargs) -> None: - """Setup the mixin.""" + """Set self.layer.""" super().setup(*args, **kwargs) self.layer = get_object_or_404( ExternalLayer, From 4b5f25937acf5173c33717ec1d8de563fa79be42 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Tue, 13 May 2025 15:10:39 +0200 Subject: [PATCH 23/30] add quoting --- .github/workflows/main.yml | 57 +++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bb2ac2ed2..b0b0c3ee9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,27 +1,29 @@ -name: CI +--- +name: "CI" on: push: - branches: [ main ] + branches: + - "main" pull_request: jobs: tests: - - runs-on: ubuntu-latest + runs-on: "ubuntu-latest" strategy: matrix: - python-version: [3.11] + python-version: + - "3.11" services: postgres: - image: postgis/postgis:14-3.3-alpine + image: "postgis/postgis:14-3.3-alpine" env: - POSTGRES_USER: bornhack - POSTGRES_PASSWORD: bornhack + POSTGRES_USER: "bornhack" + POSTGRES_PASSWORD: "bornhack" ports: - - 5432:5432 + - "5432:5432" options: >- --health-cmd pg_isready --health-interval 10s @@ -29,9 +31,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@v3 - with: - submodules: recursive + - uses: "actions/checkout@v3" - name: "Update apt" run: "sudo apt update" @@ -39,34 +39,33 @@ jobs: - name: "Install OS deps" run: "sudo apt -y install libgdal-dev libpq-dev libmagic1 git binutils libproj-dev gdal-bin" - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + - name: "Set up Python ${{ matrix.python-version }}" + uses: "actions/setup-python@v4" with: - python-version: ${{ matrix.python-version }} + python-version: "${{ matrix.python-version }}" cache: 'pip' - - name: Install python dependencies - run: | - python -m pip install .[test] + - name: "Install python dependencies" + run: "python -m pip install .[test]" - - name: Copy settings - run: cp src/bornhack/environment_settings.py.dist.dev src/bornhack/environment_settings.py + - name: "Copy settings" + run: "cp src/bornhack/environment_settings.py.dist.dev src/bornhack/environment_settings.py" - - name: Check migrations - run: | - python src/manage.py makemigrations --check --dry-run + - name: "Check migrations" + run: "python src/manage.py makemigrations --check --dry-run" - - name: Run tests + - name: "Run tests" run: | cd src coverage run --rcfile ../pyproject.toml manage.py test . --noinput --parallel=4 coverage xml --rcfile ../pyproject.toml env: - POSTGRES_HOST: localhost + POSTGRES_HOST: "localhost" POSTGRES_PORT: 5432 - - name: Upload coverage to codecov - uses: codecov/codecov-action@v5 + - name: "Upload coverage to codecov" + uses: "codecov/codecov-action@v5" with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: bornhack/bornhack-website + token: "${{ secrets.CODECOV_TOKEN }}" + slug: "bornhack/bornhack-website" +... From e2eb748651c76aebd63969ab43d5b93c155a5c92 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Tue, 13 May 2025 15:10:58 +0200 Subject: [PATCH 24/30] run pre-commit in GH actions --- .github/workflows/pre-commit.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/pre-commit.yml diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 000000000..78db8486f --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,23 @@ +--- +name: "Run pre-commit" + +on: # yamllint disable-line rule:truthy + pull_request: + push: + branches: + - "main" + +jobs: + pre-commit: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" # v.4.2.2 + - uses: "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065" # v5.6.0 + with: + python-version: "3.12" + - uses: "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020" # v4.4.0 + with: + node-version: 22 + - uses: "pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd" # v3.0.1 +... + From f1b7dc56e73e7e5d26c98c0950aa4d38a9506153 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Tue, 13 May 2025 15:18:33 +0200 Subject: [PATCH 25/30] ruff linting --- src/ircbot/irc3module.py | 5 +- src/maps/tests.py | 77 +++++++++++++++++++ src/phonebook/models.py | 2 +- src/shop/coinify.py | 1 - src/teams/models.py | 8 +- src/utils/tests.py | 2 + src/villages/admin.py | 2 + src/villages/apps.py | 2 + src/villages/email.py | 1 + .../migrations/0015_alter_village_options.py | 1 - src/villages/models.py | 5 +- src/villages/urls.py | 16 ++-- src/villages/views.py | 16 +++- src/wishlist/models.py | 3 + src/wishlist/urls.py | 1 + 15 files changed, 122 insertions(+), 20 deletions(-) create mode 100644 src/maps/tests.py diff --git a/src/ircbot/irc3module.py b/src/ircbot/irc3module.py index 13637780a..02d75eda2 100644 --- a/src/ircbot/irc3module.py +++ b/src/ircbot/irc3module.py @@ -77,8 +77,9 @@ def on_join(self, mask, channel, **kwargs) -> None: """Triggered when a channel is joined by someone, including the bot itself.""" if mask.nick == self.bot.nick: # the bot just joined a channel - if ( - channel in self.get_managed_team_channels() or channel in (settings.IRCBOT_PUBLIC_CHANNEL, settings.IRCBOT_VOLUNTEER_CHANNEL) + if channel in self.get_managed_team_channels() or channel in ( + settings.IRCBOT_PUBLIC_CHANNEL, + settings.IRCBOT_VOLUNTEER_CHANNEL, ): logger.debug( f"Just joined a channel I am supposed to be managing, asking ChanServ for info about {channel}", diff --git a/src/maps/tests.py b/src/maps/tests.py new file mode 100644 index 000000000..c06c46b0e --- /dev/null +++ b/src/maps/tests.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from unittest import mock + +from django.core.exceptions import PermissionDenied +from django.test import TestCase +from django.test import override_settings +from django.test.client import RequestFactory + +from .views import MapProxyView +from .views import MissingCredentials + +USER = "user" +PASSWORD = "password" + + +@override_settings( + DATAFORDELER_USER=USER, + DATAFORDELER_PASSWORD=PASSWORD, +) +class MapProxyViewTest(TestCase): + def setUp(self): + self.rf = RequestFactory() + + self.allowed_endpoints = [ + "/GeoDanmarkOrto/orto_foraar_wmts/1.0.0/WMTS", + "/Dkskaermkort/topo_skaermkort/1.0.0/wms", + "/DHMNedboer/dhm/1.0.0/wms", + ] + + def test_endpoint_not_allowed_raises_perm_denied(self): + fix_request = self.rf.get("/maps/kfproxy/not/allowed/endpoint") + + with self.assertRaises(PermissionDenied): + MapProxyView.as_view()(fix_request) + + def test_all_allowed_endpoints(self): + for endpoint in self.allowed_endpoints: + fix_request = self.rf.get("/maps/kfproxy" + endpoint) + with self.subTest(request=fix_request): + with mock.patch("maps.views.requests") as mock_req: + mock_req.get.return_value.status_code = 200 + result = MapProxyView.as_view()(fix_request) + + self.assertEqual(result.status_code, 200) + + def test_sanitizing_path(self): + fix_path = "/maps/kfproxy/DHMNedboer/dhm/1.0.0/wms?transparent=true" + + result = MapProxyView().sanitize_path(fix_path) + + self.assertEqual(result, "/DHMNedboer/dhm/1.0.0/wms?transparent=TRUE") + + def test_sanitizing_path_not_failing_without_query(self): + fix_path = "/maps/kfproxy/DHMNedboer/dhm/1.0.0/wms" + + result = MapProxyView().sanitize_path(fix_path) + + self.assertEqual(result, "/DHMNedboer/dhm/1.0.0/wms") + + def test_append_credentials(self): + fix_path = "/path" + fix_result = fix_path + f"&username={USER}&password={PASSWORD}" + + result = MapProxyView().append_credentials(fix_path) + + self.assertEqual(result, fix_result) + + def test_append_credentials_raises_perm_denied_if_no_creds_is_set(self): + with ( + self.settings( + DATAFORDELER_USER="", + DATAFORDELER_PASSWORD="", + ), + self.assertRaises(MissingCredentials), + ): + MapProxyView().append_credentials("path") diff --git a/src/phonebook/models.py b/src/phonebook/models.py index f9cd7e81f..d79a05289 100644 --- a/src/phonebook/models.py +++ b/src/phonebook/models.py @@ -60,7 +60,7 @@ class Meta: max_length=9, blank=True, help_text="The letters or numbers chosen to represent this DECT number in the phonebook. " - "Optional if you specify a number.", + "Optional if you specify a number.", ) description = models.TextField( diff --git a/src/shop/coinify.py b/src/shop/coinify.py index 4892b7bac..11448f9cc 100644 --- a/src/shop/coinify.py +++ b/src/shop/coinify.py @@ -50,7 +50,6 @@ def save_coinify_callback(request, order): ) - def coinify_api_request(api_method, order, payload): url = f"{settings.COINIFY_API_URL}{api_method}" headers = { diff --git a/src/teams/models.py b/src/teams/models.py index ea7928c5a..a74ec638c 100644 --- a/src/teams/models.py +++ b/src/teams/models.py @@ -201,13 +201,9 @@ def clean(self) -> None: self.private_irc_channel_name = f"#{self.private_irc_channel_name}" # make sure the channel names are not reserved - if ( - self.public_irc_channel_name in (settings.IRCBOT_PUBLIC_CHANNEL, settings.IRCBOT_VOLUNTEER_CHANNEL) - ): + if self.public_irc_channel_name in (settings.IRCBOT_PUBLIC_CHANNEL, settings.IRCBOT_VOLUNTEER_CHANNEL): raise ValidationError("The public IRC channel name is reserved") - if ( - self.private_irc_channel_name in (settings.IRCBOT_PUBLIC_CHANNEL, settings.IRCBOT_VOLUNTEER_CHANNEL) - ): + if self.private_irc_channel_name in (settings.IRCBOT_PUBLIC_CHANNEL, settings.IRCBOT_VOLUNTEER_CHANNEL): raise ValidationError("The private IRC channel name is reserved") # make sure public_irc_channel_name is not in use as public or private irc channel for another team, case insensitive diff --git a/src/utils/tests.py b/src/utils/tests.py index 96a966462..98b959807 100644 --- a/src/utils/tests.py +++ b/src/utils/tests.py @@ -1,4 +1,5 @@ """Base file for tests.""" + from __future__ import annotations import logging @@ -26,6 +27,7 @@ def test_bootstrap_script(self): class BornhackTestBase(TestCase): """Bornhack base TestCase.""" + users: list[User] camp: Camp team: Team diff --git a/src/villages/admin.py b/src/villages/admin.py index ae0f13af8..04583402a 100644 --- a/src/villages/admin.py +++ b/src/villages/admin.py @@ -1,4 +1,5 @@ """Admin config for Village.""" + from __future__ import annotations from django.contrib import admin @@ -9,5 +10,6 @@ @admin.register(Village) class VillageAdmin(admin.ModelAdmin): """Admin config for Village.""" + list_display = ("name", "camp", "private", "approved", "deleted") list_filter = ("camp", "private", "approved", "deleted") diff --git a/src/villages/apps.py b/src/villages/apps.py index 51ce01389..2e9d3ba56 100644 --- a/src/villages/apps.py +++ b/src/villages/apps.py @@ -1,4 +1,5 @@ """App config for the villages app.""" + from __future__ import annotations from django.apps import AppConfig @@ -6,4 +7,5 @@ class VillagesConfig(AppConfig): """App config for the villages app.""" + name = "villages" diff --git a/src/villages/email.py b/src/villages/email.py index 77091bffd..3525ebf17 100644 --- a/src/villages/email.py +++ b/src/villages/email.py @@ -1,4 +1,5 @@ """Email utilities for villages.""" + from __future__ import annotations import logging diff --git a/src/villages/migrations/0015_alter_village_options.py b/src/villages/migrations/0015_alter_village_options.py index c41b39073..6d77171e9 100644 --- a/src/villages/migrations/0015_alter_village_options.py +++ b/src/villages/migrations/0015_alter_village_options.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("villages", "0014_alter_village_location"), ] diff --git a/src/villages/models.py b/src/villages/models.py index afc19410a..2c976fc7b 100644 --- a/src/villages/models.py +++ b/src/villages/models.py @@ -1,4 +1,5 @@ """The Village model.""" + from __future__ import annotations from django.contrib.gis.db.models import PointField @@ -14,8 +15,10 @@ class Village(ExportModelOperationsMixin("village"), UUIDModel, CampRelatedModel): """The Village model.""" + class Meta: """Model settings.""" + ordering = ("name",) unique_together = ("slug", "camp") @@ -64,7 +67,7 @@ def save(self, **kwargs) -> None: ) super().save(**kwargs) - def delete(self, *, using: str|None=None, keep_parents: bool=False) -> None: + def delete(self, *, using: str | None = None, keep_parents: bool = False) -> None: """Soft-delete villages.""" self.deleted = True self.save() diff --git a/src/villages/urls.py b/src/villages/urls.py index 22bb013cd..81c55ec6e 100644 --- a/src/villages/urls.py +++ b/src/villages/urls.py @@ -1,4 +1,5 @@ """URLs for villages.""" + from __future__ import annotations from django.urls import include @@ -19,9 +20,14 @@ path("create/", VillageCreateView.as_view(), name="village_create"), path("geojson/", VillageListGeoJSONView.as_view(), name="villages_geojson"), path("map/", VillageMapView.as_view(), name="village_map"), - path("/", include([ - path("", VillageDetailView.as_view(), name="village_detail"), - path("update/", VillageUpdateView.as_view(), name="village_update"), - path("delete/", VillageDeleteView.as_view(), name="village_delete"), - ])), + path( + "/", + include( + [ + path("", VillageDetailView.as_view(), name="village_detail"), + path("update/", VillageUpdateView.as_view(), name="village_update"), + path("delete/", VillageDeleteView.as_view(), name="village_delete"), + ] + ), + ), ] diff --git a/src/villages/views.py b/src/villages/views.py index 02687b09a..eb6dba82b 100644 --- a/src/villages/views.py +++ b/src/villages/views.py @@ -1,4 +1,5 @@ """Village related views.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -31,13 +32,15 @@ from django.http import HttpRequest from django.http import HttpResponse + class VillageListView(CampViewMixin, ListView): """List villages.""" + model = Village template_name = "village_list.html" context_object_name = "villages" - def get_context_data(self, **kwargs) -> dict[str, dict[str,str]]: + def get_context_data(self, **kwargs) -> dict[str, dict[str, str]]: """Add village map data to context.""" context = super().get_context_data(**kwargs) context["mapData"] = {"grid": static("json/grid.geojson")} @@ -50,6 +53,7 @@ def get_queryset(self) -> QuerySet[Village]: class VillageMapView(CampViewMixin, ListView): """The village map view.""" + model = Village template_name = "village_map.html" context_object_name = "villages" @@ -71,6 +75,7 @@ def get_queryset(self) -> QuerySet[Village]: class VillageListGeoJSONView(CampViewMixin, JsonView): """GeoJSON view for the village list.""" + def get_context_data(self, **kwargs) -> dict[str, str | dict[str, str]]: """Add type and features to context.""" return {"type": "FeatureCollection", "features": self.dump_features()} @@ -111,6 +116,7 @@ def dump_features(self) -> list[object]: class VillageDetailView(CampViewMixin, DetailView): """DetailView for villages.""" + model = Village template_name = "village_detail.html" context_object_name = "village" @@ -143,11 +149,12 @@ class VillageCreateView( CreateView, ): """Village CreateView.""" + model = Village template_name = "village_form.html" fields = ("name", "description", "private", "location") - def get_context_data(self, **kwargs) -> dict[str, dict[str,str]]: + def get_context_data(self, **kwargs) -> dict[str, dict[str, str]]: """Add village map data to context.""" context = super().get_context_data(**kwargs) context["mapData"] = {"grid": static("json/grid.geojson")} @@ -189,6 +196,7 @@ def get_success_url(self) -> str: class EnsureUserOwnsVillageMixin(SingleObjectMixin): """Mixin to ensure user is contact for the village, or staff.""" + model = Village def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: @@ -205,11 +213,12 @@ class VillageUpdateView( UpdateView, ): """Village update view.""" + model = Village template_name = "village_form.html" fields = ("name", "description", "private", "location") - def get_context_data(self, **kwargs) -> dict[str, dict[str,str]]: + def get_context_data(self, **kwargs) -> dict[str, dict[str, str]]: """Add village map data to context.""" context = super().get_context_data(**kwargs) context["mapData"] = {"grid": static("json/grid.geojson")} @@ -257,6 +266,7 @@ class VillageDeleteView( DeleteView, ): """Village delete view.""" + model = Village template_name = "village_confirm_delete.html" context_object_name = "village" diff --git a/src/wishlist/models.py b/src/wishlist/models.py index 62bd0c07b..980343980 100644 --- a/src/wishlist/models.py +++ b/src/wishlist/models.py @@ -1,4 +1,5 @@ """The Wish model.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -13,6 +14,7 @@ if TYPE_CHECKING: from camps.models import Camp + class Wish(ExportModelOperationsMixin("wish"), CampRelatedModel): """This model contains the stuff BornHack needs. @@ -21,6 +23,7 @@ class Wish(ExportModelOperationsMixin("wish"), CampRelatedModel): class Meta: """Model configuration.""" + verbose_name_plural = "wishes" name = models.CharField( diff --git a/src/wishlist/urls.py b/src/wishlist/urls.py index eb7edd910..f048e26d6 100644 --- a/src/wishlist/urls.py +++ b/src/wishlist/urls.py @@ -1,4 +1,5 @@ """URL config for the wishlist app.""" + from __future__ import annotations from django.urls import path From 582128ce8c9f9c85159e25cc8b27c7ba1d9394ba Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Tue, 13 May 2025 15:53:07 +0200 Subject: [PATCH 26/30] Update src/maps/mixins.py --- src/maps/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps/mixins.py b/src/maps/mixins.py index 86b929d48..f434904f5 100644 --- a/src/maps/mixins.py +++ b/src/maps/mixins.py @@ -83,7 +83,7 @@ class ExternalLayerMapperViewMixin(ExternalLayerViewMixin): """ def setup(self, request: HttpRequest, *args, **kwargs) -> None: - """Setup the mixin.""" + """Check permissions.""" super().setup(request, *args, **kwargs) if ( self.layer.responsible_team From 8c3d138bf0c92146356962ed7fdb87dfbf27a4c8 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Tue, 13 May 2025 15:53:55 +0200 Subject: [PATCH 27/30] Update src/maps/models.py --- src/maps/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps/models.py b/src/maps/models.py index ea567f8c7..dd87755e0 100644 --- a/src/maps/models.py +++ b/src/maps/models.py @@ -91,7 +91,7 @@ def __str__(self) -> str: return str(self.name) def save(self, **kwargs) -> None: - """Model save function.""" + """Set slug and save.""" self.slug = unique_slugify( str(self.name), slugs_in_use=self.__class__.objects.all().values_list( From 688d8bdb0d2409c131d8961caa40ea3627810da3 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Tue, 13 May 2025 15:54:42 +0200 Subject: [PATCH 28/30] Update src/maps/models.py --- src/maps/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps/models.py b/src/maps/models.py index dd87755e0..dfa950a46 100644 --- a/src/maps/models.py +++ b/src/maps/models.py @@ -204,7 +204,7 @@ def __str__(self) -> str: return str(self.name) def save(self, **kwargs) -> None: - """Model save function.""" + """Set slug and save.""" self.slug = unique_slugify( str(self.name), slugs_in_use=self.__class__.objects.all().values_list( From 21028cb27758f7e2f5d11381e27708f0e50974c3 Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Tue, 13 May 2025 15:55:31 +0200 Subject: [PATCH 29/30] Update src/maps/models.py --- src/maps/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/maps/models.py b/src/maps/models.py index dfa950a46..5510f8b0b 100644 --- a/src/maps/models.py +++ b/src/maps/models.py @@ -247,7 +247,7 @@ def __str__(self) -> str: return self.name def save(self, **kwargs) -> None: - """Model save function.""" + """Set slug and save.""" if not self.slug: self.slug = unique_slugify( self.name, From 7e9b0ea3a189b1263191d48b6e55ab9e5f178c1f Mon Sep 17 00:00:00 2001 From: "Rudy (zarya)" Date: Tue, 13 May 2025 16:06:45 +0200 Subject: [PATCH 30/30] Be gone --- src/maps/tests.py | 77 ----------------------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 src/maps/tests.py diff --git a/src/maps/tests.py b/src/maps/tests.py deleted file mode 100644 index c06c46b0e..000000000 --- a/src/maps/tests.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -from unittest import mock - -from django.core.exceptions import PermissionDenied -from django.test import TestCase -from django.test import override_settings -from django.test.client import RequestFactory - -from .views import MapProxyView -from .views import MissingCredentials - -USER = "user" -PASSWORD = "password" - - -@override_settings( - DATAFORDELER_USER=USER, - DATAFORDELER_PASSWORD=PASSWORD, -) -class MapProxyViewTest(TestCase): - def setUp(self): - self.rf = RequestFactory() - - self.allowed_endpoints = [ - "/GeoDanmarkOrto/orto_foraar_wmts/1.0.0/WMTS", - "/Dkskaermkort/topo_skaermkort/1.0.0/wms", - "/DHMNedboer/dhm/1.0.0/wms", - ] - - def test_endpoint_not_allowed_raises_perm_denied(self): - fix_request = self.rf.get("/maps/kfproxy/not/allowed/endpoint") - - with self.assertRaises(PermissionDenied): - MapProxyView.as_view()(fix_request) - - def test_all_allowed_endpoints(self): - for endpoint in self.allowed_endpoints: - fix_request = self.rf.get("/maps/kfproxy" + endpoint) - with self.subTest(request=fix_request): - with mock.patch("maps.views.requests") as mock_req: - mock_req.get.return_value.status_code = 200 - result = MapProxyView.as_view()(fix_request) - - self.assertEqual(result.status_code, 200) - - def test_sanitizing_path(self): - fix_path = "/maps/kfproxy/DHMNedboer/dhm/1.0.0/wms?transparent=true" - - result = MapProxyView().sanitize_path(fix_path) - - self.assertEqual(result, "/DHMNedboer/dhm/1.0.0/wms?transparent=TRUE") - - def test_sanitizing_path_not_failing_without_query(self): - fix_path = "/maps/kfproxy/DHMNedboer/dhm/1.0.0/wms" - - result = MapProxyView().sanitize_path(fix_path) - - self.assertEqual(result, "/DHMNedboer/dhm/1.0.0/wms") - - def test_append_credentials(self): - fix_path = "/path" - fix_result = fix_path + f"&username={USER}&password={PASSWORD}" - - result = MapProxyView().append_credentials(fix_path) - - self.assertEqual(result, fix_result) - - def test_append_credentials_raises_perm_denied_if_no_creds_is_set(self): - with ( - self.settings( - DATAFORDELER_USER="", - DATAFORDELER_PASSWORD="", - ), - self.assertRaises(MissingCredentials), - ): - MapProxyView().append_credentials("path")