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
+
| Number |
Letters |
@@ -28,6 +29,8 @@
Modified |
Actions |
+
+
{% for entry in dectregistration_list %}
| {{ entry.number }} |
@@ -44,6 +47,7 @@
{% endfor %}
+
{% 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")