diff --git a/src/tokens/__init__.py b/src/tokens/__init__.py index e69de29bb..901660801 100644 --- a/src/tokens/__init__.py +++ b/src/tokens/__init__.py @@ -0,0 +1 @@ +"""Init for application token.""" diff --git a/src/tokens/admin.py b/src/tokens/admin.py index d7c02c961..f6c3355d7 100644 --- a/src/tokens/admin.py +++ b/src/tokens/admin.py @@ -1,5 +1,12 @@ +"""All Django admin views for application token.""" + from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import ClassVar + from django.contrib import admin from .models import Token @@ -8,13 +15,17 @@ @admin.register(Token) class TokenAdmin(admin.ModelAdmin): - list_filter = ["camp", "category", "active"] - list_display = ["token", "description", "camp", "category", "active", "valid_when"] - search_fields = ["token", "description", "category"] + """Django admin for tokens.""" + + list_filter: ClassVar[list[str]] = ["camp", "category", "active"] + list_display: ClassVar[list[str]] = ["token", "description", "camp", "category", "active", "valid_when"] + search_fields: ClassVar[list[str]] = ["token", "description", "category"] @admin.register(TokenFind) class TokenFindAdmin(admin.ModelAdmin): - list_filter = ["token__camp", "user"] - list_display = ["token", "user", "created"] - search_fields = ["user", "token"] + """Django admin for token finds.""" + + list_filter: ClassVar[list[str]] = ["token__camp", "user"] + list_display: ClassVar[list[str]] = ["token", "user", "created"] + search_fields: ClassVar[list[str]] = ["user", "token"] diff --git a/src/tokens/apps.py b/src/tokens/apps.py index e36c9d430..d8db00707 100644 --- a/src/tokens/apps.py +++ b/src/tokens/apps.py @@ -1,7 +1,11 @@ +"""All apps for token application.""" + from __future__ import annotations from django.apps import AppConfig class TokensConfig(AppConfig): + """TokensConfig.""" + name = "tokens" diff --git a/src/tokens/models.py b/src/tokens/models.py index 8c2081c00..078731712 100644 --- a/src/tokens/models.py +++ b/src/tokens/models.py @@ -1,5 +1,9 @@ +"""All models for the token application.""" + from __future__ import annotations +from typing import TYPE_CHECKING + from django.contrib.postgres.fields import DateTimeRangeField from django.db import models from django.urls import reverse @@ -8,8 +12,15 @@ from utils.models import CampRelatedModel +if TYPE_CHECKING: + from typing import ClassVar + + from camps.models import Camp + class Token(ExportModelOperationsMixin("token"), CampRelatedModel): + """Token model.""" + camp = models.ForeignKey("camps.Camp", on_delete=models.PROTECT) token = models.CharField(max_length=32, help_text="The secret token") @@ -22,7 +33,9 @@ class Token(ExportModelOperationsMixin("token"), CampRelatedModel): active = models.BooleanField( default=False, - help_text="An active token is listed and can be found by players, an inactive token is unlisted and returns an error saying 'valid but inactive token found' to players who find it.", + help_text="An active token is listed and can be found by players, " + "an inactive token is unlisted and returns an error saying 'valid " + "but inactive token found' to players who find it.", ) valid_when = DateTimeRangeField( @@ -35,12 +48,16 @@ class Token(ExportModelOperationsMixin("token"), CampRelatedModel): camp_filter = "camp" def __str__(self) -> str: + """String formatter.""" return f"{self.description} ({self.camp})" class Meta: - ordering = ["camp"] + """Meta.""" + + ordering: ClassVar[list[str]] = ["camp"] - def get_absolute_url(self): + def get_absolute_url(self) -> str: + """Get absolute url.""" return reverse( "backoffice:token_detail", kwargs={"camp_slug": self.camp.slug, "pk": self.pk}, @@ -48,20 +65,26 @@ def get_absolute_url(self): @property def valid_now(self) -> bool: + """Returns if the token is valid 'now'.""" + valid = False if not self.valid_when: # no time limit - return True - if self.valid_when.lower and self.valid_when.lower > timezone.now(): + valid = True + elif self.valid_when.lower and self.valid_when.lower > timezone.now(): # not valid yet - return False - if self.valid_when.upper and self.valid_when.upper < timezone.now(): + valid = False + elif self.valid_when.upper and self.valid_when.upper < timezone.now(): # not valid anymore - return False - return True + valid = False + return valid class TokenFind(ExportModelOperationsMixin("token_find"), CampRelatedModel): + """Model for submitting the found token.""" + class Meta: + """Meta.""" + unique_together = ("user", "token") token = models.ForeignKey("tokens.Token", on_delete=models.PROTECT) @@ -75,8 +98,10 @@ class Meta: camp_filter = "token__camp" def __str__(self) -> str: + """String formatter.""" return f"{self.token} found by {self.user}" @property - def camp(self): + def camp(self) -> Camp: + """Property camp linked to this token find via Token.""" return self.token.camp diff --git a/src/tokens/templatetags/__init__.py b/src/tokens/templatetags/__init__.py index e69de29bb..ad524c89a 100644 --- a/src/tokens/templatetags/__init__.py +++ b/src/tokens/templatetags/__init__.py @@ -0,0 +1 @@ +"""Template tags for the Token application.""" diff --git a/src/tokens/templatetags/token_tags.py b/src/tokens/templatetags/token_tags.py index df6deb10e..1db4b8407 100644 --- a/src/tokens/templatetags/token_tags.py +++ b/src/tokens/templatetags/token_tags.py @@ -1,16 +1,24 @@ +"""Token template tags for the Token application.""" from __future__ import annotations +from typing import TYPE_CHECKING + from django import template from tokens.models import TokenFind +if TYPE_CHECKING: + from django.contrib.auth.models import User + register = template.Library() @register.filter -def found_by_user(token, user): +def found_by_user(token: str, user: User) -> bool: + """Template tag to show if the token is found.""" try: tokenfind = TokenFind.objects.get(token=token, user=user) - return tokenfind.created except TokenFind.DoesNotExist: return False + else: + return tokenfind.created diff --git a/src/tokens/tests/__init__.py b/src/tokens/tests/__init__.py new file mode 100644 index 000000000..a9aaa4aca --- /dev/null +++ b/src/tokens/tests/__init__.py @@ -0,0 +1 @@ +"""Init for tests of application tokens.""" diff --git a/src/tokens/tests/test_views.py b/src/tokens/tests/test_views.py new file mode 100644 index 000000000..f96aeda6c --- /dev/null +++ b/src/tokens/tests/test_views.py @@ -0,0 +1,163 @@ +"""Tests for token views.""" + +from __future__ import annotations + +from datetime import datetime + +import pytz +from bs4 import BeautifulSoup +from django.urls import reverse + +from tokens.models import Token +from utils.tests import BornhackTestBase + + +class TestTokenViews(BornhackTestBase): + """Test Token view.""" + + token: Token + token_inactive: Token + token_timed: Token + token_timed_old: Token + token_timed_current: Token + token_timed_new: Token + + @classmethod + def setUpTestData(cls) -> None: + """Add test data.""" + # first add users and other basics + super().setUpTestData() + + tz = pytz.timezone("Europe/Copenhagen") + now = datetime.now(tz) + year = now.year + + # then create some tokens + cls.token = Token( + camp=cls.camp, + token="DEADBEAF1234", + description="Test token", + category="Test", + active=True, + ) + cls.token.save() + + cls.token_inactive = Token( + camp=cls.camp, + token="1234DEADBEAF", + description="Test Inactive token", + category="Test", + active=False, + ) + cls.token_inactive.save() + + cls.token_timed_old = Token( + camp=cls.camp, + token="12341337F01F", + description="Test timed token", + category="Test", + active=True, + valid_when=["2000-01-01 00:00:00", "2000-10-10 10:10:10"], + ) + cls.token_timed_old.save() + + cls.token_timed_current = Token( + camp=cls.camp, + token="12341337F02F", + description="Test timed token", + category="Test", + active=True, + valid_when=[now, datetime(year + 1, 1, 1, 1, 1, tzinfo=tz)], + ) + cls.token_timed_current.save() + + cls.token_timed_new = Token( + camp=cls.camp, + token="12341337F03F", + description="Test timed token", + category="Test", + active=True, + valid_when=[datetime(year + 1, 1, 1, 1, 1, tzinfo=tz), datetime(year + 1, 12, 1, 1, 1, tzinfo=tz)], + ) + cls.token_timed_new.save() + + def test_token_list_view(self) -> None: + """Test the basics of the token list view.""" + self.client.login(username="user0", password="user0") + url = reverse("tokens:tokenfind_list") + + response = self.client.get(url) + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div#main > div > div > div > div > table > tbody > tr") + self.assertEqual(len(rows), 6, "token list does not return 6 entries (camp name, header and tokens)") + + def test_token_find_view(self) -> None: + """Test the basics of the token find view.""" + self.client.login(username="user0", password="user0") + + url = reverse("tokens:details", kwargs={"token": self.token.token}) + + # Test finding the test token + response = self.client.get(path=url, follow=True) + assert response.status_code == 200 + + # Test if the page returned a success + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div.alert.alert-success") + matches = [s for s in rows if "You found a secret token:" in str(s)] + self.assertEqual(len(matches), 1, "token find does not return the found msg.") + + # Test finding a false token + url = reverse("tokens:details", kwargs={"token": "F00000001234"}) + response = self.client.get(path=url, follow=True) + self.assertEqual(response.status_code, 404, "Did not get 404 for a non-existing token") + + # Test finding a inactive token + url = reverse("tokens:details", kwargs={"token": self.token_inactive.token}) + response = self.client.get(path=url, follow=True) + assert response.status_code == 200 + + # Test if the page returned a warning + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div.alert.alert-warning") + matches = [s for s in rows if "Patience!" in str(s)] + self.assertEqual(len(matches), 1, "inactive token find does not return the Patience msg.") + + # Test finding a time expired token + url = reverse("tokens:details", kwargs={"token": self.token_timed_old.token}) + response = self.client.get(path=url, follow=True) + assert response.status_code == 200 + + # Test if the page returned a warning + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div.alert.alert-warning") + matches = [s for s in rows if "This token is not valid after" in str(s)] + self.assertEqual(len(matches), 1, "inactive token find does not return the not valid after msg.") + + # Test finding a timed token + url = reverse("tokens:details", kwargs={"token": self.token_timed_current.token}) + response = self.client.get(path=url, follow=True) + assert response.status_code == 200 + + # Test if the page returned a success + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div.alert.alert-success") + matches = [s for s in rows if "You found a secret token:" in str(s)] + self.assertEqual(len(matches), 1, "token find does not return the found msg.") + + # Test finding a timed not active yet token + url = reverse("tokens:details", kwargs={"token": self.token_timed_new.token}) + response = self.client.get(path=url, follow=True) + assert response.status_code == 200 + + # Test if the page returned a warning + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div.alert.alert-warning") + matches = [s for s in rows if "This token is not valid yet" in str(s)] + self.assertEqual(len(matches), 1, "inactive token find does not return the not valid yet.") diff --git a/src/tokens/urls.py b/src/tokens/urls.py index 29f4931e3..495bdfb50 100644 --- a/src/tokens/urls.py +++ b/src/tokens/urls.py @@ -1,3 +1,5 @@ +"""All URLs for the Token application.""" + from __future__ import annotations from django.urls import path diff --git a/src/tokens/views.py b/src/tokens/views.py index 135bb4b99..4fa02b858 100644 --- a/src/tokens/views.py +++ b/src/tokens/views.py @@ -1,5 +1,9 @@ +"""All views for the Token application.""" + from __future__ import annotations +from typing import TYPE_CHECKING + from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.http import Http404 @@ -10,6 +14,11 @@ from django.views.generic import ListView from prometheus_client import Gauge +if TYPE_CHECKING: + from django.db.models import QuerySet + from django.http import HttpRequest + from django.http import HttpResponseRedirect + from utils.models import CampReadOnlyModeError from .models import Token @@ -28,11 +37,14 @@ class TokenFindView(LoginRequiredMixin, DetailView): + """View for submitting the token found.""" + model = Token slug_field = "token" slug_url_kwarg = "token" - def get(self, request, *args, **kwargs): + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponseRedirect: + """Method for submitting the token found.""" if not self.get_object().active: messages.warning( self.request, @@ -61,8 +73,8 @@ def get(self, request, *args, **kwargs): token=self.get_object(), user=request.user, ) - except CampReadOnlyModeError: - raise Http404 + except CampReadOnlyModeError as e: + raise Http404 from e if created: # user found a new token @@ -89,15 +101,19 @@ def get(self, request, *args, **kwargs): # message for the user messages.success( self.request, - f"Congratulations! You found a secret token: '{self.get_object().description}' - Your visit has been registered! Keep hunting, there might be more tokens out there.", + f"Congratulations! You found a secret token: '{self.get_object().description}' " + "- Your visit has been registered! Keep hunting, there might be more tokens out there.", ) return redirect(reverse("tokens:tokenfind_list")) class TokenFindListView(LoginRequiredMixin, ListView): + """A View with a list of active tokens one can find.""" + model = Token template_name = "tokenfind_list.html" - def get_queryset(self): + def get_queryset(self) -> QuerySet: + """Get QuerySet of active tokens.""" qs = super().get_queryset() return qs.filter(active=True)