diff --git a/src/backoffice/templates/includes/index_facilities.html b/src/backoffice/templates/includes/index_facilities.html index c13a5d1ac..6795fad62 100644 --- a/src/backoffice/templates/includes/index_facilities.html +++ b/src/backoffice/templates/includes/index_facilities.html @@ -1,6 +1,5 @@ {% if perms.camps.orga_team_member or facilityfeedback_teams or is_team_facilitator or perms.camps.gis_team_member %}

Facilities

-
{% if perms.camps.orga_team_member or perms.camps.gis_team_member %}

@@ -35,5 +34,4 @@

{% endif %} {% endfor %} -

{% endif %} diff --git a/src/backoffice/templates/includes/index_game.html b/src/backoffice/templates/includes/index_game.html index 889b48a17..99fc8fd43 100644 --- a/src/backoffice/templates/includes/index_game.html +++ b/src/backoffice/templates/includes/index_game.html @@ -1,11 +1,14 @@ {% if perms.camps.game_team_member %} -

Secret Tokens

+

Game Team

Tokens

-

Use this view to see a list of Tokens, and to create, update and delete them

-
+

Use this view to see a list of Tokens, and to create, update and delete them

Token Stats

Use this view to see token find stats

+ +

Token categories

+

Use this view to see a list of Token Categories, and to create, update and delete them

+
{% endif %} diff --git a/src/backoffice/templates/index.html b/src/backoffice/templates/index.html index 1f059e515..e86ec4986 100644 --- a/src/backoffice/templates/index.html +++ b/src/backoffice/templates/index.html @@ -27,7 +27,9 @@

{{ camp.title }} Backoffice

{% for item, value in backoffice_tabs.items %}
+
{% include "includes/index_"|add:item|add:".html" %} +
{% endfor %}
diff --git a/src/backoffice/templates/token_category_delete.html b/src/backoffice/templates/token_category_delete.html new file mode 100644 index 000000000..c7e3c897a --- /dev/null +++ b/src/backoffice/templates/token_category_delete.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} +{% load django_bootstrap5 %} + +{% block content %} +
+
+

Delete Token category {{ object.name }}?

+
+
+

This Category has {{ object.token_set.count }} Tokens related

+
+ {% csrf_token %} + + Cancel +
+
+
+{% endblock content %} diff --git a/src/backoffice/templates/token_category_detail.html b/src/backoffice/templates/token_category_detail.html new file mode 100644 index 000000000..361a20976 --- /dev/null +++ b/src/backoffice/templates/token_category_detail.html @@ -0,0 +1,65 @@ +{% extends 'base.html' %} + +{% block title %} + Token Category Details | BackOffice | {{ block.super }} +{% endblock %} + +{% block content %} +
+
+

Token Category Details | BackOffice

+
+
+

+ Update Category + Delete Category + Category List +

+ + + + + + + + + + + + + + + + + + + +
Category:{{ object.name }}

+
Description:{{ object.description }}

+
Used by:{{ object.token_set.count }}
Created:{{ object.created }}

+
Last updated:{{ object.updated }}

+
+

Related tokens

+ + + + + + + + + + + {% for token in object.token_set.all %} + + + + + + + {% endfor %} + +
TokenCampHintDescription
{{ token.token }}{{ token.camp }}{{ token.hint }}{{ token.description }}
+
+
+{% endblock %} diff --git a/src/backoffice/templates/token_category_form.html b/src/backoffice/templates/token_category_form.html new file mode 100644 index 000000000..f5e4929c6 --- /dev/null +++ b/src/backoffice/templates/token_category_form.html @@ -0,0 +1,21 @@ +{% extends 'base.html' %} +{% load django_bootstrap5 %} +{% load static %} + +{% block content %} +
+
+

{% if form.instance.pk %}Update{% else %}Create new{% endif %} Token Category

+
+
+
+ {% csrf_token %} + {% bootstrap_form form %} + + + + Cancel +
+
+
+{% endblock content %} diff --git a/src/backoffice/templates/token_category_list.html b/src/backoffice/templates/token_category_list.html new file mode 100644 index 000000000..33380ac08 --- /dev/null +++ b/src/backoffice/templates/token_category_list.html @@ -0,0 +1,53 @@ +{% extends 'base.html' %} +{% load bornhack %} + +{% block title %} + Token Category List | Backoffice | {{ block.super }} +{% endblock %} + +{% block content %} +
+
+

Token Category List - BackOffice

+
+
+

This is a list of all token categories.

+ {% if not object_list %} +

No categories found.

+ {% else %} +

+ Create category + Backoffice + + + + + + + + + + + {% for category in object_list %} + + + + + + + {% endfor %} + +
NameDescriptionUsed byActions
{{ category.name }}{{ category.description }}{{ category.token_set.count }} + + + + +
+

+ {% endif %} +

+ Backoffice +

+
+
+{% endblock content %} diff --git a/src/backoffice/templates/token_detail.html b/src/backoffice/templates/token_detail.html index 9343a222e..8a4dcc4a5 100644 --- a/src/backoffice/templates/token_detail.html +++ b/src/backoffice/templates/token_detail.html @@ -18,35 +18,49 @@

Token Details | Tokens | BackOffice

- - + + - - + - + + + + - + + + + + + +
Token{{ token.tokene }}Token:{{ token.token }}
Category{{ token.category }}

+
Category:{{ token.category.name }}

DescriptionHint:{{ token.hint }}

+
Description: {{ token.description }}

FindsFinds: {{ token.tokenfind_set.count }} times

Created:{{ token.created }}

+
Last updated:{{ token.updated }}

+

Token Finds

- + + {% for tf in token.tokenfind_set.all %} - + + {% endfor %} diff --git a/src/backoffice/templates/token_form.html b/src/backoffice/templates/token_form.html index f77360435..576e22980 100644 --- a/src/backoffice/templates/token_form.html +++ b/src/backoffice/templates/token_form.html @@ -12,7 +12,7 @@

{% if request.resolver_match.url_name == "token_update" % {% csrf_token %} {% bootstrap_form form %} - Cancel + Cancel diff --git a/src/backoffice/templates/token_list.html b/src/backoffice/templates/token_list.html index b94564d82..60d66e63b 100644 --- a/src/backoffice/templates/token_list.html +++ b/src/backoffice/templates/token_list.html @@ -14,13 +14,16 @@

No tokens found.

{% else %}

- Backoffice + Create Token + Backoffice

EmailPublic credit nameUsername Timestamp
{{ tf.user.email }}{{ tf.user.profile.public_credit_name }}{{ tf.user.username }} {{ tf.created }}
+ - + + @@ -29,6 +32,7 @@ {% for token in token_list %} + - + + + + + + {% endfor %} @@ -59,8 +72,7 @@

{% endif %}

- Create Token - Backoffice + Backoffice

diff --git a/src/backoffice/templates/token_stats.html b/src/backoffice/templates/token_stats.html index 4758acbff..5fe8230b4 100644 --- a/src/backoffice/templates/token_stats.html +++ b/src/backoffice/templates/token_stats.html @@ -11,11 +11,12 @@

A list of users and the number of tokens they found

- Backoffice + Backoffice

Category Token ActiveCategoryTime LimitsHint Description Finds Actions
{{ token.category.name }} {{ token.token }} {% if token.active %} @@ -42,16 +46,25 @@ {% endif %} {{ token.category }} + {% if token.valid_when.lower %} + From {{ token.valid_when.lower }}
+ {% endif %} + {% if token.valid_when.upper %} + To {{ token.valid_when.upper }} + {% endif %} + {% if not token.valid_when %} + - + {% endif %} +
{{ token.hint }} {{ token.description }} {{ token.tokenfind_set.count }} - -
- + + @@ -23,7 +24,8 @@ {% for user in user_list %} - + + @@ -32,7 +34,7 @@
NamePublic credit nameUsername Finds Last token found
{{ user.profile.public_credit_name|default:user.username }}{{ user.profile.public_credit_name }}{{ user.username }} {{ user.token_find_count }} {{ user.last_token_find }}

- Backoffice + Backoffice

diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index f526678a9..5bb80a822 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -70,8 +70,8 @@ from .views import EventTypeListView from .views import EventUpdateView from .views import ExpenseDetailView -from .views import ExpenseUpdateView from .views import ExpenseListView +from .views import ExpenseUpdateView from .views import FacilityCreateView from .views import FacilityDeleteView from .views import FacilityDetailView @@ -149,8 +149,8 @@ from .views import ReimbursementListView from .views import ReimbursementUpdateView from .views import RevenueDetailView -from .views import RevenueUpdateView from .views import RevenueListView +from .views import RevenueUpdateView from .views import ScanTicketsPosSelectView from .views import ScanTicketsView from .views import ShopTicketOverview @@ -165,6 +165,11 @@ from .views import SpeakerUpdateView from .views import TeamPermissionIndexView from .views import TeamPermissionManageView +from .views import TokenCategoryCreateView +from .views import TokenCategoryDeleteView +from .views import TokenCategoryDetailView +from .views import TokenCategoryListView +from .views import TokenCategoryUpdateView from .views import TokenCreateView from .views import TokenDeleteView from .views import TokenDetailView @@ -944,9 +949,9 @@ "/", include( [ - path("", ExpenseDetailView.as_view(), name="expense_detail",), - path("update/", ExpenseUpdateView.as_view(), name="expense_update",), - ] + path("", ExpenseDetailView.as_view(), name="expense_detail"), + path("update/", ExpenseUpdateView.as_view(), name="expense_update"), + ], ), ), ], @@ -972,7 +977,7 @@ RevenueUpdateView.as_view(), name="revenue_update", ), - ] + ], ), ), ], @@ -1437,6 +1442,45 @@ ], ), ), + path( + "categories/", + include( + [ + path( + "", + TokenCategoryListView.as_view(), + name="token_category_list", + ), + path( + "create/", + TokenCategoryCreateView.as_view(), + name="token_category_create", + ), + path( + "/", + include( + [ + path( + "", + TokenCategoryDetailView.as_view(), + name="token_category_detail", + ), + path( + "update/", + TokenCategoryUpdateView.as_view(), + name="token_category_update", + ), + path( + "delete/", + TokenCategoryDeleteView.as_view(), + name="token_category_delete", + ), + ], + ), + ), + ], + ), + ), ], ), ), diff --git a/src/backoffice/views/backoffice.py b/src/backoffice/views/backoffice.py index c2db0254b..3e857ef64 100644 --- a/src/backoffice/views/backoffice.py +++ b/src/backoffice/views/backoffice.py @@ -46,7 +46,7 @@ def get_index_tabs(self, perms, context): if "camps.orga_team_member" in perms or context["is_team_pos"]: tabs["pos"] = {"name": "Pos"} if "camps.game_team_member" in perms: - tabs["game"] = {"name": "Game Team"} + tabs["game"] = {"name": "Games"} if "camps.gis_team_member" in perms or context["is_team_mapper"]: tabs["map"] = {"name": "Maps"} if "camps.orga_team_member" in perms or context["is_team_lead"]: diff --git a/src/backoffice/views/game.py b/src/backoffice/views/game.py index 0268cbe7c..761deec07 100644 --- a/src/backoffice/views/game.py +++ b/src/backoffice/views/game.py @@ -19,6 +19,7 @@ from backoffice.mixins import RaisePermissionRequiredMixin from camps.mixins import CampViewMixin from tokens.models import Token +from tokens.models import TokenCategory from tokens.models import TokenFind logger = logging.getLogger(f"bornhack.{__name__}") @@ -50,7 +51,7 @@ class TokenCreateView(CampViewMixin, RaisePermissionRequiredMixin, CreateView): permission_required = "camps.game_team_member" model = Token template_name = "token_form.html" - fields = ["token", "category", "description", "active", "valid_when"] + fields = ["token", "category", "hint", "description", "active", "valid_when"] def form_valid(self, form): token = form.save(commit=False) @@ -74,6 +75,8 @@ class TokenUpdateView(CampViewMixin, RaisePermissionRequiredMixin, UpdateView): class TokenDeleteView(CampViewMixin, RaisePermissionRequiredMixin, DeleteView): + """Delete a token.""" + permission_required = "camps.game_team_member" model = Token template_name = "token_delete.html" @@ -118,3 +121,77 @@ def get_queryset(self, **kwargs): ) .exclude(token_find_count=0) ) + + +class TokenCategoryListView(CampViewMixin, RaisePermissionRequiredMixin, ListView): + """Show list of token categories""" + + permission_required = "camps.game_team_member" + model = TokenCategory + template_name = "token_category_list.html" + + +class TokenCategoryCreateView(CampViewMixin, RaisePermissionRequiredMixin, CreateView): + """Create a new token category.""" + + permission_required = "camps.game_team_member" + model = TokenCategory + template_name = "token_category_form.html" + fields = ["name", "description"] + + def get_success_url(self): + messages.success( + self.request, + f"{self.object.name} was created successfully", + ) + if "_addanother" in self.request.POST: + return reverse("backoffice:token_category_create", kwargs=self.kwargs) + if "_addtoken" in self.request.POST: + return reverse("backoffice:token_create", kwargs=self.kwargs) + return reverse("backoffice:token_category_list", kwargs=self.kwargs) + + +class TokenCategoryDetailView(CampViewMixin, RaisePermissionRequiredMixin, DetailView): + """Show details for a token category.""" + + permission_required = "camps.game_team_member" + model = TokenCategory + template_name = "token_category_detail.html" + + +class TokenCategoryDeleteView(CampViewMixin, RaisePermissionRequiredMixin, DeleteView): + """Delete a token category""" + + permission_required = "camps.game_team_member" + model = TokenCategory + template_name = "token_category_delete.html" + + def get_success_url(self): + """Return to category list after deletion""" + messages.success( + self.request, + "The Token category has been deleted", + ) + return reverse( + "backoffice:token_category_list", + kwargs={"camp_slug": self.camp.slug}, + ) + + +class TokenCategoryUpdateView(CampViewMixin, RaisePermissionRequiredMixin, UpdateView): + """Update a token category.""" + + permission_required = "camps.game_team_member" + model = TokenCategory + template_name = "token_category_form.html" + fields = ["name", "description"] + + def get_success_url(self): + messages.success( + self.request, + f"{self.object.name} was updated successfully", + ) + return reverse( + "backoffice:token_category_list", + kwargs={"camp_slug": self.camp.slug}, + ) diff --git a/src/backoffice/views/pos.py b/src/backoffice/views/pos.py index fef1372a7..a2c0ea6dc 100644 --- a/src/backoffice/views/pos.py +++ b/src/backoffice/views/pos.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING from django.contrib import messages -from django.core.exceptions import PermissionDenied from django.db import models from django.shortcuts import redirect from django.urls import reverse diff --git a/src/bornhack/settings.py b/src/bornhack/settings.py index 84586746d..698462ca8 100644 --- a/src/bornhack/settings.py +++ b/src/bornhack/settings.py @@ -286,5 +286,5 @@ "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "tile-cache", - } + }, } diff --git a/src/bornhack/urls.py b/src/bornhack/urls.py index 7a8cd4a3e..012fa0d88 100644 --- a/src/bornhack/urls.py +++ b/src/bornhack/urls.py @@ -58,7 +58,6 @@ ), path("admin/", admin.site.urls), path("camps/", CampListView.as_view(), name="camp_list"), - path("token/", include("tokens.urls", namespace="tokens")), path("maps/", include("maps.urls", namespace="maps")), path("", include("django_prometheus.urls")), # camp redirect views here @@ -145,6 +144,12 @@ kwargs={"page": "maps_user_location_list"}, name="maps_user_location_redirect", ), + path( + "token/", + CampRedirectView.as_view(), + kwargs={"page": "tokens:token_find_list"}, + name="token_find_list_redirect", + ), path("people/", PeopleView.as_view(), name="people"), # camp specific urls below here path( @@ -221,6 +226,7 @@ path("wishlist/", include("wishlist.urls", namespace="wishlist")), path("facilities/", include("facilities.urls", namespace="facilities")), path("phonebook/", include("phonebook.urls", namespace="phonebook")), + path("token/", include("tokens.urls", namespace="tokens")), ], ), ), diff --git a/src/camps/migrations/0039_camp_kickoff.py b/src/camps/migrations/0039_camp_kickoff.py index 476560237..45d5b6147 100644 --- a/src/camps/migrations/0039_camp_kickoff.py +++ b/src/camps/migrations/0039_camp_kickoff.py @@ -15,7 +15,10 @@ class Migration(migrations.Migration): model_name="camp", name="kickoff", field=django.contrib.postgres.fields.ranges.DateTimeRangeField( - blank=True, help_text="The camp kickoff period.", null=True, verbose_name="Camp Kickoff" + blank=True, + help_text="The camp kickoff period.", + null=True, + verbose_name="Camp Kickoff", ), ), ] diff --git a/src/camps/views.py b/src/camps/views.py index 8873a13f6..6d3ae3327 100644 --- a/src/camps/views.py +++ b/src/camps/views.py @@ -2,7 +2,6 @@ import logging -from django.conf import settings from django.shortcuts import redirect from django.utils import timezone from django.views import View diff --git a/src/profiles/templates/profile_base.html b/src/profiles/templates/profile_base.html index e87cc2a3f..8fc79b228 100644 --- a/src/profiles/templates/profile_base.html +++ b/src/profiles/templates/profile_base.html @@ -77,12 +77,6 @@

Your BornHack Account

{% endif %} - {% url 'tokens:tokenfind_list' as tokenfind_list_url %} - {% url 'profiles:permissions_list' as profile_permissions_url %} - {% if request.user.is_staff %} diff --git a/src/templates/includes/menuitems.html b/src/templates/includes/menuitems.html index 921d042fe..eac2408ce 100644 --- a/src/templates/includes/menuitems.html +++ b/src/templates/includes/menuitems.html @@ -39,5 +39,5 @@
  • Rideshare
  • {% endif %} -
  • Token Game
  • +
  • Token Game
  • diff --git a/src/tokens/admin.py b/src/tokens/admin.py index f6c3355d7..c8134f68e 100644 --- a/src/tokens/admin.py +++ b/src/tokens/admin.py @@ -10,16 +10,26 @@ from django.contrib import admin from .models import Token +from .models import TokenCategory from .models import TokenFind +@admin.register(TokenCategory) +class TokenCategoryAdmin(admin.ModelAdmin): + """Django admin for token categories.""" + + list_filter: ClassVar[list[str]] = ["name", "description"] + list_display: ClassVar[list[str]] = ["name", "description"] + search_fields: ClassVar[list[str]] = ["name", "description"] + + @admin.register(Token) class TokenAdmin(admin.ModelAdmin): """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"] + list_filter: ClassVar[list[str]] = ["camp", "hint", "active", "category"] + list_display: ClassVar[list[str]] = ["token", "description", "camp", "hint", "active", "category", "valid_when"] + search_fields: ClassVar[list[str]] = ["token", "description", "hint", "category"] @admin.register(TokenFind) diff --git a/src/tokens/forms.py b/src/tokens/forms.py new file mode 100644 index 000000000..17db2f608 --- /dev/null +++ b/src/tokens/forms.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from django import forms + + +class TokenFindSubmitForm(forms.Form): + """Form definition for TokenFindSubmitForm.""" + + token = forms.CharField() diff --git a/src/tokens/migrations/0010_alter_token_unique_together.py b/src/tokens/migrations/0010_alter_token_unique_together.py new file mode 100644 index 000000000..15efc5b59 --- /dev/null +++ b/src/tokens/migrations/0010_alter_token_unique_together.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2025-06-05 20:02 +from __future__ import annotations + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("camps", "0038_alter_permission_options"), + ("tokens", "0009_token_valid_when"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="token", + unique_together={("camp", "token")}, + ), + ] diff --git a/src/tokens/migrations/0011_tokencategory_rename_category_token_hint.py b/src/tokens/migrations/0011_tokencategory_rename_category_token_hint.py new file mode 100644 index 000000000..5b0e2546d --- /dev/null +++ b/src/tokens/migrations/0011_tokencategory_rename_category_token_hint.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.21 on 2025-06-12 13:10 +from __future__ import annotations + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("tokens", "0010_alter_token_unique_together"), + ] + + operations = [ + migrations.CreateModel( + name="TokenCategory", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + help_text="Name of the category", + max_length=64, + unique=True, + ), + ), + ( + "description", + models.TextField( + blank=True, + help_text="Description of the category", + null=True, + ), + ), + ], + options={ + "verbose_name": "TokenCategory", + "verbose_name_plural": "TokenCategories", + }, + ), + migrations.RenameField( + model_name="token", + old_name="category", + new_name="hint", + ), + ] diff --git a/src/tokens/migrations/0012_token_category_alter_token_hint.py b/src/tokens/migrations/0012_token_category_alter_token_hint.py new file mode 100644 index 000000000..89955ad30 --- /dev/null +++ b/src/tokens/migrations/0012_token_category_alter_token_hint.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.21 on 2025-06-12 13:13 +from __future__ import annotations + +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("tokens", "0011_tokencategory_rename_category_token_hint"), + ] + + operations = [ + migrations.AddField( + model_name="token", + name="category", + field=models.ForeignKey( + help_text="Token category", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="tokens.tokencategory", + ), + ), + migrations.AlterField( + model_name="token", + name="hint", + field=models.TextField(help_text="The hint for this token"), + ), + ] diff --git a/src/tokens/migrations/0013_alter_tokencategory_options_tokencategory_created_and_more.py b/src/tokens/migrations/0013_alter_tokencategory_options_tokencategory_created_and_more.py new file mode 100644 index 000000000..68bff5b01 --- /dev/null +++ b/src/tokens/migrations/0013_alter_tokencategory_options_tokencategory_created_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.21 on 2025-07-07 12:57 +from __future__ import annotations + +import django.core.validators +import django.utils.timezone +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("tokens", "0012_token_category_alter_token_hint"), + ] + + operations = [ + migrations.AlterModelOptions( + name="tokencategory", + options={ + "verbose_name": "Token category", + "verbose_name_plural": "Token categories", + }, + ), + migrations.AddField( + model_name="tokencategory", + name="created", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="tokencategory", + name="updated", + field=models.DateTimeField(auto_now=True), + ), + migrations.AlterField( + model_name="token", + name="description", + field=models.TextField( + help_text="The description of the token (not visible to players until they find the token)", + ), + ), + migrations.AlterField( + model_name="token", + name="hint", + field=models.TextField( + help_text="The hint for this token (always visible to players)", + ), + ), + migrations.AlterField( + model_name="token", + name="token", + field=models.CharField( + help_text="The secret token (^[0-9a-zA-Z\\.@]{12,32}$)", + max_length=32, + validators=[ + django.core.validators.RegexValidator( + "^[0-9a-zA-Z\\.@]{12,32}$", + "The token did not match the regex of 12-32 characters, with (letters, numbers, dot(s) and @)", + ), + ], + ), + ), + ] diff --git a/src/tokens/migrations/0014_create_and_populate_category_fk_for_existing_tokens.py b/src/tokens/migrations/0014_create_and_populate_category_fk_for_existing_tokens.py new file mode 100644 index 000000000..656303cfe --- /dev/null +++ b/src/tokens/migrations/0014_create_and_populate_category_fk_for_existing_tokens.py @@ -0,0 +1,72 @@ +# Generated by Django 4.2.21 on 2025-07-07 13:37 +from __future__ import annotations + +from django.db import migrations + +CATEGORIES = { + "Unknown": "Unknown category for unrecognized token hints/categories", + "Roads": "Auto created, make a new description", + "Website": "Auto created, make a new description", + "Physical": "Auto created, make a new description", + "Ether": "Auto created, make a new description", + "DNS": "Auto created, make a new description", + "Internet": "Auto created, make a new description", + "Git": "Auto created, make a new description", + "Invisible": "Auto created, make a new description", + "Web": "Auto created, make a new description", + "Phone": "Auto created, make a new description", + "Electrical": "Auto created, make a new description", + "Aural": "Auto created, make a new description", + "Virtual": "Auto created, make a new description", + "ASN": "Auto created, make a new description", + "Visual": "Auto created, make a new description", + "Network": "Auto created, make a new description", + "IRC": "Auto created, make a new description", + "Audio": "Auto created, make a new description", + "Minecraft": "Auto created, make a new description", + "Lights": "Auto created, make a new description", + "Badge": "Auto created, make a new description", + "Video": "Auto created, make a new description", + "Talk": "Auto created, make a new description", + "SSH": "Auto created, make a new description", +} + + +def create_and_populate_category_fk_for_tokens(apps, schema_editor): + """ + Create categories and check if a hint contains a match with + a token category, the category FK gets added to the token. + """ + Token = apps.get_model("tokens", "Token") + Category = apps.get_model("tokens", "TokenCategory") + + created_categories = {} + for key, value in CATEGORIES.items(): + defaults = {"description": value} + category, _ = Category.objects.get_or_create(name=key, defaults=defaults) + created_categories[key.lower()] = category + + for token in Token.objects.all(): + if not token.hint: + token.category = created_categories["unknown"] + token.save() + continue + + for word in token.hint.split(" "): + word = word.replace(",", "").replace(".", "").strip() + if word.lower() in created_categories: + token.category = created_categories[word.lower()] + break + token.category = created_categories["unknown"] + + token.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("tokens", "0013_alter_tokencategory_options_tokencategory_created_and_more"), + ] + + operations = [ + migrations.RunPython(create_and_populate_category_fk_for_tokens), + ] diff --git a/src/tokens/models.py b/src/tokens/models.py index 078731712..e828777a3 100644 --- a/src/tokens/models.py +++ b/src/tokens/models.py @@ -5,12 +5,14 @@ from typing import TYPE_CHECKING from django.contrib.postgres.fields import DateTimeRangeField +from django.core.validators import RegexValidator from django.db import models from django.urls import reverse from django.utils import timezone from django_prometheus.models import ExportModelOperationsMixin from utils.models import CampRelatedModel +from utils.models import CreatedUpdatedModel if TYPE_CHECKING: from typing import ClassVar @@ -18,18 +20,68 @@ from camps.models import Camp +class TokenCategory(CreatedUpdatedModel): + """Model definition for TokenCategory.""" + + name = models.CharField( + max_length=64, + unique=True, + help_text="Name of the category", + ) + + description = models.TextField( + null=True, + blank=True, + help_text="Description of the category", + ) + + class Meta: + """Meta definition for TokenCategory.""" + + verbose_name = "Token category" + verbose_name_plural = "Token categories" + + def __str__(self): + """Unicode representation of TokenCategory.""" + return f"{self.name} ({self.description})" + + class Token(ExportModelOperationsMixin("token"), CampRelatedModel): """Token model.""" + class Meta: + """Meta class definition for Token model""" + + unique_together = ("camp", "token") + ordering: ClassVar[list[str]] = ["camp"] + + category = models.ForeignKey( + TokenCategory, + on_delete=models.PROTECT, + null=True, + help_text="Token category", + ) + camp = models.ForeignKey("camps.Camp", on_delete=models.PROTECT) - token = models.CharField(max_length=32, help_text="The secret token") + token = models.CharField( + max_length=32, + validators=[ + RegexValidator( + r"^[0-9a-zA-Z\.@]{12,32}$", + ("The token did not match the regex of 12-32 characters, with (letters, numbers, dot(s) and @)"), + ), + ], + help_text=r"The secret token (^[0-9a-zA-Z\.@]{12,32}$)", + ) - category = models.TextField( - help_text="The category/hint for this token (physical, website, whatever)", + hint = models.TextField( + help_text="The hint for this token (always visible to players)", ) - description = models.TextField(help_text="The description of the token") + description = models.TextField( + help_text="The description of the token (not visible to players until they find the token)", + ) active = models.BooleanField( default=False, @@ -51,11 +103,6 @@ def __str__(self) -> str: """String formatter.""" return f"{self.description} ({self.camp})" - class Meta: - """Meta.""" - - ordering: ClassVar[list[str]] = ["camp"] - def get_absolute_url(self) -> str: """Get absolute url.""" return reverse( @@ -76,6 +123,9 @@ def valid_now(self) -> bool: elif self.valid_when.upper and self.valid_when.upper < timezone.now(): # not valid anymore valid = False + elif self.valid_when.lower and self.valid_when.lower < timezone.now(): + # is valid + valid = True return valid diff --git a/src/tokens/templates/token_dashboard.html b/src/tokens/templates/token_dashboard.html new file mode 100644 index 000000000..a08ed352b --- /dev/null +++ b/src/tokens/templates/token_dashboard.html @@ -0,0 +1,308 @@ +{% extends 'base.html' %} +{% load static %} +{% load commonmark %}$ +{% load token_tags %} +{% load humanize %} + +{% block title %} + Token game | {{ block.super }} +{% endblock %} + +{% block extra_head %} + +{{ widgets|json_script:"widgets" }} + +{% endblock extra_head %} + +{% block content %} + +
    +
    +
    +

    Token game dashboard

    +
    +
    +
    +
    + + 🏆 + + +
    +
    {{ player_stats.tokens_found }} found
    +
    +
    +
    +
    +
    + + 🔍 + + +
    +
    {{ player_stats.tokens_missing }} missing
    +
    +
    +
    +
    +
    + + 📄 + + +
    +
    {{ player_stats.tokens_count }} total
    +
    +
    +
    +
    +
    +
    +
    +

    How to play?

    +

    The token game lasts the whole event and is about finding little text strings matching the regular expression:

    +

    [0-9a-zA-Z\.@]{12,32}

    +

    Token examples:

    +

    +

      +
    • BornHack{{ camp.camp.lower.year }}
    • +
    • cm9sbGllczIwMjR5b3V3aW4K
    • +
    • Ly5caCRLKRHrpiF6SQwe9geUKAxSsLQE
    • +
    +

    +

    Tokens are hidden or in plain sight physically or virtually on the BornHack venue, online and offline.

    +

    Submit the tokens you find in the field below. You can start with this one: HelloTokenHunters{{ camp.camp.lower.year }}

    +
    +
    +
    + {% csrf_token %} +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Total players
    +

    Last join: {{ widgets.total_players.last_join_time | timesince }} ago.

    +
    +

    {{ widgets.total_players.count }}

    +
    +
    +
    +
    + + + + + + + + + {% for key, value in widgets.total_players.no_js.items %} + + + + + {% endfor %} + +
    StatusCount
    {{ key }}{{ value.value }} ({{ value.pct|floatformat:'0' }}%)
    +

    + no-js widget +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Total finds
    +

    Latest find: {{ widgets.total_finds.latest_find | timesince }} ago.

    +
    +

    {{ widgets.total_finds.count }}

    +
    +
    +
    +
    + + + + + + + + + {% for key, value in widgets.total_finds.no_js.items %} + + + + + {% endfor %} + +
    StatusCount
    {{ key }}{{ value.value }} ({{ value.pct|floatformat:'0' }}%)
    +

    + no-js widget +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Token categories
    +
    +

    {{ widgets.token_categories.count }}

    +
    +
    +
    +
    + + + + + + + + + {% for key, value in widgets.token_categories.no_js.items %} + + + + + {% endfor %} + +
    CategoryFound
    {{ key }} + {{ value|floatformat:'0' }}% +
    +
    +

    + no-js widget +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Token activity
    +

    Finds in last 60 min.

    +
    +

    {{ widgets.token_activity.last_60m_count }}

    +
    +
    +
    +
    + + + + + + + + + {% for key, value in widgets.token_activity.no_js.items %} + + + + + {% endfor %} + +
    HourTokens found
    {{ key }}{{ value }}
    +
    +

    + no-js widget +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Your submitted tokens

    +
    +
    +
    + + + + + + + + + + + + + + {% for token in object_list %} + {% with token|found_by_user:user as user_has_found_token %} + {% if user_has_found_token %} + + {% else %} + + {% endif %} + + + + + + {% if user_has_found_token %} + + + + {% else %} + + + + {% endif %} + + {% endwith %} + {% endfor %} + + +
    +
    +
    + +{% endblock %} diff --git a/src/tokens/templates/token_submit.html b/src/tokens/templates/token_submit.html new file mode 100644 index 000000000..6b1282388 --- /dev/null +++ b/src/tokens/templates/token_submit.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% load static %} +{% load commonmark %}$ +{% load token_tags %} +{% load django_bootstrap5 %} + +{% block title %} + Submit token | {{ block.super }} +{% endblock %} + +{% block content %} + +
    +
    +
    +

    Submit token

    +
    + {% csrf_token %} + {% bootstrap_form form %} + +
    +
    +
    +
    + +{% endblock %} diff --git a/src/tokens/templates/tokenfind_list.html b/src/tokens/templates/tokenfind_list.html deleted file mode 100644 index 4fa3820b4..000000000 --- a/src/tokens/templates/tokenfind_list.html +++ /dev/null @@ -1,73 +0,0 @@ -{% extends 'profile_base.html' %} -{% load static %} -{% load commonmark %}$ -{% load token_tags %} - -{% block title %} - Your Secret Tokens | {{ block.super }} -{% endblock %} - -{% block profile_content %} -
    -

    Your Secret Tokens

    -
    - -

    The Secret Token game lasts the whole event and is about finding little text strings matching the regular expression:

    - [0-9a-zA-Z\.@]{12,32}

    - Tokens are hidden or in plain sight physically or virtually on the BornHack venue, online and offline.

    -

    If you think you found a secret token you can register it by visiting https://bornhack.dk/token/TOKEN where TOKEN is replaced by the token you found.

    -

    This page shows an overview of the tokens in this years game, a hint for each token, and how many of them you have found. Here is your first token to start the hunt: "HelloTokenHunters2024"

    - - - - {% for token in object_list %} - {% ifchanged token.camp %} - - - - - - - - - - - {% endifchanged %} - - - - - {% with token|found_by_user:user as user_has_found_token %} - {% if user_has_found_token %} - - - - {% else %} - - - - {% endif %} - {% endwith %} - - - {% endfor %} - -

    {{ token.camp.title }}

    CategoryTime LimitsTokenDescriptionFound
    {{ token.category }} - {% if token.valid_now %} - - {% else %} - - {% endif %} - - {% if token.valid_when.lower %} - Not valid before {{ token.valid_when.lower }}
    - {% endif %} - {% if token.valid_when.upper %} - Not valid after {{ token.valid_when.upper }}
    - {% endif %} - {% if not token.valid_when %} - No time limit - {% endif %} -
    {{ token.token }}{{ token.description }}{{ user_has_found_token }}---
    -
    -{% endblock %} diff --git a/src/tokens/tests/test_models.py b/src/tokens/tests/test_models.py new file mode 100644 index 000000000..3f73bd43a --- /dev/null +++ b/src/tokens/tests/test_models.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from django.core.exceptions import ValidationError + +from tokens.models import Token +from tokens.models import TokenCategory +from utils.tests import BornhackTestBase + + +class TestTokenModel(BornhackTestBase): + """Test Token model.""" + + @classmethod + def setUpTestData(cls) -> None: + """Add test data.""" + # first add users and other basics + super().setUpTestData() + + cls.category = TokenCategory.objects.create( + name="Test", + description="Test category", + ) + + def create_token(self, token_str: str) -> Token: + """Helper method for creating tokens""" + return Token.objects.create( + token=token_str, + camp=self.camp, + category=self.category, + hint="Test token hint", + description="Test token description", + active=True, + ) + + def test_token_regex_validator_min_length(self) -> None: + """Test minimum length of token, with regex validator raising exception.""" + with self.assertRaises(ValidationError): + self.create_token("minlength") + + def test_token_regex_validator_max_length(self) -> None: + """Test maximum length of token, with regex validator raising exception.""" + with self.assertRaises(ValidationError): + self.create_token("maxLengthOf32CharactersTokenTests") + + def test_token_regex_validator_invalid_char(self) -> None: + """Test invalid characters in token, with regex validator raising exception.""" + with self.assertRaises(ValidationError): + self.create_token("useOfInvalidCharacter+") + + with self.assertRaises(ValidationError): + self.create_token("-useOfInvalidCharacter") + + def test_creating_valid_tokens(self) -> None: + """Test valid tokens, with regex validator.""" + self.assertIsInstance(self.create_token("validTokenWith@"), Token) + self.assertIsInstance(self.create_token("validTokenWith."), Token) + self.assertIsInstance(self.create_token("validToken12"), Token) + self.assertIsInstance( + self.create_token("maxLengthOf32CharactersTokenTest"), + Token, + ) diff --git a/src/tokens/tests/test_views.py b/src/tokens/tests/test_views.py index 508b31091..0dc48f900 100644 --- a/src/tokens/tests/test_views.py +++ b/src/tokens/tests/test_views.py @@ -9,6 +9,7 @@ from django.urls import reverse from tokens.models import Token +from tokens.models import TokenCategory from utils.tests import BornhackTestBase @@ -32,12 +33,27 @@ def setUpTestData(cls) -> None: now = datetime.now(tz) year = now.year + kwargs = {"camp_slug": cls.camp.slug} + cls.url_dashboard = reverse( + "tokens:dashboard", + kwargs=kwargs, + ) + cls.url_submit = reverse( + "tokens:submit", + kwargs=kwargs, + ) + + # create test category + cls.category = TokenCategory(name="Test", description="Test category") + cls.category.save() + # then create some tokens cls.token = Token( camp=cls.camp, token="DEADBEAF1234", description="Test token", - category="Test", + hint="Token1", + category=cls.category, active=True, ) cls.token.save() @@ -46,7 +62,8 @@ def setUpTestData(cls) -> None: camp=cls.camp, token="1234DEADBEAF", description="Test Inactive token", - category="Test", + hint="Token2", + category=cls.category, active=False, ) cls.token_inactive.save() @@ -55,7 +72,8 @@ def setUpTestData(cls) -> None: camp=cls.camp, token="12341337F01F", description="Test timed token", - category="Test", + hint="Token3", + category=cls.category, active=True, valid_when=["2000-01-01 00:00:00", "2000-10-10 10:10:10"], ) @@ -65,7 +83,8 @@ def setUpTestData(cls) -> None: camp=cls.camp, token="12341337F02F", description="Test timed token", - category="Test", + hint="Token4", + category=cls.category, active=True, valid_when=[now, datetime(year + 1, 1, 1, 1, 1, tzinfo=tz)], ) @@ -75,89 +94,97 @@ def setUpTestData(cls) -> None: camp=cls.camp, token="12341337F03F", description="Test timed token", - category="Test", + hint="Token5", + category=cls.category, 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.""" + def test_player_stats_in_context(self) -> None: + """Test `player stats` values in context (only active tokens gets counted)""" self.client.force_login(self.users[0]) - 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)") + response = self.client.get(self.url_dashboard) + result = response.context.get("player_stats") - def test_token_find_view(self) -> None: - """Test the basics of the token find view.""" + assert result.get("tokens_found") == 0 + assert result.get("tokens_missing") == 4 + assert result.get("tokens_count") == 4 + + def test_dashboard_table_shows_active_tokens(self) -> None: + """Test if all of the active tokens shows up in the dashboard table""" self.client.force_login(self.users[0]) - url = reverse("tokens:details", kwargs={"token": self.token.token}) + response = self.client.get(self.url_dashboard) + decoded_content = response.content.decode() + soup = BeautifulSoup(decoded_content, "html.parser") + rows = soup.select("div#main table.submitted-tokens tbody tr") + self.assertEqual(len(rows), 4, "The dashboard table does not show 4 rows") + + def test_token_submit_form_view(self) -> None: + """Test the basics of the token submit form view.""" + self.client.force_login(self.users[0]) # Test finding the test token - response = self.client.get(path=url, follow=True) + response = self.client.post(self.url_submit, {"token": self.token.token}, follow=True) assert response.status_code == 200 # Test if the page returned a success - content = response.content.decode() - soup = BeautifulSoup(content, "html.parser") + decoded_content = response.content.decode() + soup = BeautifulSoup(decoded_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)] + matches = [s for s in rows if "You found a 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") + response = self.client.post(self.url_submit, {"token": "F00000001234"}, follow=True) + decoded_content = response.content.decode() + soup = BeautifulSoup(decoded_content, "html.parser") + rows = soup.select("div.alert.alert-danger") + matches = [s for s in rows if "We did not recognize the token" in str(s)] + self.assertEqual(len(matches), 1, "token find does not return the found msg.") # Test finding a inactive token - url = reverse("tokens:details", kwargs={"token": self.token_inactive.token}) - response = self.client.get(path=url, follow=True) + response = self.client.post(self.url_submit, {"token": self.token_inactive.token}, follow=True) assert response.status_code == 200 # Test if the page returned a warning - content = response.content.decode() - soup = BeautifulSoup(content, "html.parser") + decoded_content = response.content.decode() + soup = BeautifulSoup(decoded_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) + response = self.client.post(self.url_submit, {"token": self.token_timed_old.token}, follow=True) assert response.status_code == 200 # Test if the page returned a warning - content = response.content.decode() - soup = BeautifulSoup(content, "html.parser") + decoded_content = response.content.decode() + soup = BeautifulSoup(decoded_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) + response = self.client.post(self.url_submit, {"token": self.token_timed_current.token}, follow=True) assert response.status_code == 200 # Test if the page returned a success - content = response.content.decode() - soup = BeautifulSoup(content, "html.parser") + decoded_content = response.content.decode() + soup = BeautifulSoup(decoded_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)] + matches = [s for s in rows if "Congratulations! You found a 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) + response = self.client.post(self.url_submit, {"token": self.token_timed_new.token}, follow=True) assert response.status_code == 200 # Test if the page returned a warning - content = response.content.decode() - soup = BeautifulSoup(content, "html.parser") + decoded_content = response.content.decode() + soup = BeautifulSoup(decoded_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 495bdfb50..ba5db6508 100644 --- a/src/tokens/urls.py +++ b/src/tokens/urls.py @@ -3,18 +3,14 @@ from __future__ import annotations from django.urls import path -from django.urls import re_path -from .views import TokenFindListView -from .views import TokenFindView +from .views import TokenDashboardListView +from .views import TokenSubmitFormView app_name = "tokens" urlpatterns = [ - path("", TokenFindListView.as_view(), name="tokenfind_list"), - re_path( - r"(?P[0-9a-zA-Z\.@]{12,32})/$", - TokenFindView.as_view(), - name="details", - ), + path("", TokenDashboardListView.as_view(), name="dashboard"), + path("", TokenSubmitFormView.as_view()), # Allow token in URL + path("submit", TokenSubmitFormView.as_view(), name="submit"), ] diff --git a/src/tokens/views.py b/src/tokens/views.py index 4fa02b858..bc86abfdf 100644 --- a/src/tokens/views.py +++ b/src/tokens/views.py @@ -2,28 +2,40 @@ from __future__ import annotations +import logging +from datetime import timedelta from typing import TYPE_CHECKING from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.models import User +from django.db.models import Count +from django.db.models import Exists +from django.db.models import F +from django.db.models import Min +from django.db.models import OuterRef +from django.db.models import Q +from django.db.models.functions import TruncHour from django.http import Http404 from django.shortcuts import redirect from django.urls import reverse from django.utils import timezone -from django.views.generic import DetailView +from django.views.generic import FormView 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 tokens.forms import TokenFindSubmitForm from utils.models import CampReadOnlyModeError from .models import Token from .models import TokenFind +logger = logging.getLogger(f"bornhack.{__name__}") + + TOKEN_FINDS = Gauge( "bornhack_tokengame_token_finds_total", "The total number of token finds by camp, user and token", @@ -36,64 +48,256 @@ ) -class TokenFindView(LoginRequiredMixin, DetailView): - """View for submitting the token found.""" +class TokenDashboardListView(LoginRequiredMixin, ListView): + """A View with a list of active tokens one can find.""" model = Token - slug_field = "token" - slug_url_kwarg = "token" + template_name = "token_dashboard.html" + + def get_queryset(self): + """Get active tokens filtered by camp slug""" + return self.model.objects.filter(active=True).filter(camp=self.request.camp).prefetch_related("category") + + def get_context_data(self, **kwargs): + """Return context containing form, player-statistics, and widgets metrics""" + context = super().get_context_data(**kwargs) + context["form"] = TokenFindSubmitForm() + + camp_finds = TokenFind.objects.filter(token__camp=self.request.camp.pk) + player_finds = camp_finds.filter(user=self.request.user) + + context["player_stats"] = self.get_player_stats_metrics(player_finds) + context["widgets"] = { + "options": {"camp_colour": self.request.camp.colour}, + "total_players": self.get_total_players_metrics(camp_finds), + "total_finds": self.get_total_finds_metrics(camp_finds), + "token_activity": self.get_token_activity_metrics(camp_finds), + "token_categories": self.get_token_categories_metrics(), + } + + return context + + def get_player_stats_metrics(self, player_finds: QuerySet) -> dict: + """Return all of the metrics for player statistics""" + player_finds_count = player_finds.count() + tokens_count = self.object_list.count() + return { + "tokens_found": player_finds_count, + "tokens_missing": tokens_count - player_finds_count, + "tokens_count": tokens_count, + } + + def get_total_players_metrics(self, camp_finds: QuerySet) -> dict: + """ "Return metrics for the 'total players' widget""" + last_joined_player = ( + User.objects.filter( + token_finds__isnull=False, + token_finds__token__camp=self.request.camp.pk, + ) + .annotate(latest_find=Min("token_finds__created")) + .order_by("latest_find") + .last() + ) + unique_player_count = camp_finds.distinct("user").count() + non_player_count = self.request.camp.participant_count + + if non_player_count: # Avoid ZeroDivisionError + players_pct = unique_player_count / (unique_player_count + non_player_count) * 100 + non_players_pct = non_player_count / (unique_player_count + non_player_count) * 100 + else: + players_pct = 0 + non_players_pct = 0 + + return { + "count": unique_player_count, + "no_js": { + "Players": { + "value": unique_player_count, + "pct": players_pct, + }, + "Non-Players": { + "value": non_player_count, + "pct": non_players_pct, + }, + }, + "chart": { + "series": [unique_player_count, non_player_count], + "labels": ["Players", "Non-players"], + }, + "last_join_time": (last_joined_player.latest_find if last_joined_player else None), + } + + def get_total_finds_metrics(self, camp_finds: QuerySet) -> dict: + """Return metrics for the 'total finds' widget""" + total_finds_count = camp_finds.distinct("token").count() + token_count = self.object_list.count() + latest_find = camp_finds.order_by("created").last() + + return { + "count": camp_finds.count(), + "latest_find": latest_find.created if latest_find else None, + "no_js": { + "Unique finds": { + "value": total_finds_count, + "pct": (total_finds_count / token_count) * 100, + }, + "Not found": { + "value": (token_count - total_finds_count), + "pct": (token_count - total_finds_count) / token_count * 100, + }, + }, + "chart": { + "series": [ + total_finds_count, + (token_count - total_finds_count), + ], + "labels": ["Unique finds", "Not found"], + }, + } + + def get_token_categories_metrics(self) -> dict: + """Return metrics for the 'token categories' widget + + Calculate the percentage of tokens found in each category by all players. + """ + token_finds_qs = TokenFind.objects.filter(token=OuterRef("id")) + found_tokens_qs = Token.objects.filter(camp=self.request.camp).annotate( + was_found=Exists(token_finds_qs), + ) + category_data = found_tokens_qs.values(category_name=F("category__name")).annotate( + total_tokens=Count("id", distinct=True), + found_tokens=Count("id", filter=Q(was_found=True), distinct=True), + ) + + labels, series = [], [] + for category in category_data: + labels.append(category["category_name"]) + series.append( + (category["found_tokens"] / category["total_tokens"]) * 100, + ) + + return { + "count": len(labels), + "no_js": dict(zip(labels, series, strict=False)), + "chart": { + "labels": labels, + "series": series, + }, + } - def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponseRedirect: - """Method for submitting the token found.""" - if not self.get_object().active: + def get_token_activity_metrics(self, camp_finds: QuerySet) -> dict: + """Return metrics for the 'token activity' widget""" + now = timezone.localtime() + start = now - timezone.timedelta(hours=23) + last_24h_qs = ( + camp_finds.filter(created__gte=start, created__lte=now) + .annotate(hour=TruncHour("created")) + .values("hour") + .annotate(count=Count("id")) + .order_by("hour") + ) + count_by_hours = {entry["hour"]: entry["count"] for entry in last_24h_qs} + + labels, series = [], [] + HOURS = 24 + for i in range(HOURS): + delta = start + timedelta(hours=i) + labels.append(delta.strftime("%H")) + + trunc_hour = delta.replace(minute=0, second=0, microsecond=0) + series.append(count_by_hours.get(trunc_hour, 0)) + + last_60m_qs = camp_finds.filter(created__gte=(now - timedelta(minutes=60))) + no_js_series = series.copy() + no_js_series.reverse() + no_js_labels = labels.copy() + no_js_labels.reverse() + + return { + "last_60m_count": last_60m_qs.count(), + "no_js": dict(zip(no_js_labels, no_js_series, strict=False)), + "chart": { + "series": series, + "labels": labels, + }, + } + + +class TokenSubmitFormView(LoginRequiredMixin, FormView): + """View for submitting a token form""" + + form_class = TokenFindSubmitForm + template_name = "token_submit.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + form_data = {"token": self.kwargs.get("token")} + context["form"] = TokenFindSubmitForm(initial=form_data) + return context + + def form_invalid(self, form): + return super().form_invalid(form) + + def form_valid(self, form): + """Return success url when form is valid""" + cleaned_token = form.cleaned_data.get("token") + try: + token = Token.objects.get(token=cleaned_token) + except Token.DoesNotExist: + messages.error( + self.request, + "We did not recognize the token you submitted. Please try something else.", + ) + return redirect(reverse("tokens:dashboard", kwargs={"camp_slug": self.request.camp.slug})) + + if not token.active: messages.warning( self.request, "Patience! You found a valid token, but it is not active. Try again later!", ) - return redirect(reverse("tokens:tokenfind_list")) + return redirect(reverse("tokens:dashboard", kwargs={"camp_slug": self.request.camp.slug})) - if self.get_object().valid_when: - if self.get_object().valid_when.lower and self.get_object().valid_when.lower > timezone.now(): + if token.valid_when: + if token.valid_when.lower and token.valid_when.lower > timezone.now(): messages.warning( self.request, - f"This token is not valid yet! Try again after {self.get_object().valid_when.lower}", + f"This token is not valid yet! Try again after {token.valid_when.lower}", ) - return redirect(reverse("tokens:tokenfind_list")) + return redirect(reverse("tokens:dashboard", kwargs={"camp_slug": self.request.camp.slug})) - if self.get_object().valid_when.upper and self.get_object().valid_when.upper < timezone.now(): + if token.valid_when.upper and token.valid_when.upper < timezone.now(): messages.warning( self.request, - f"This token is not valid after {self.get_object().valid_when.upper}! Maybe find a flux capacitor?", + f"This token is not valid after {token.valid_when.upper}! Maybe find a flux capacitor?", ) - return redirect(reverse("tokens:tokenfind_list")) + return redirect(reverse("tokens:dashboard", kwargs={"camp_slug": self.request.camp.slug})) - # register this token find if it isn't already try: - token, created = TokenFind.objects.get_or_create( - token=self.get_object(), - user=request.user, + token_find, created = TokenFind.objects.get_or_create( + token=token, + user=self.request.user, ) except CampReadOnlyModeError as e: raise Http404 from e if created: # user found a new token - username = request.user.profile.get_public_credit_name + username = self.request.user.profile.get_public_credit_name if username == "Unnamed": username = "anonymous_player_{request.user.id}" # register metrics - if TokenFind.objects.filter(token=self.get_object()).count() == 1: + if TokenFind.objects.filter(token=token).count() == 1: # this is the first time this token has been found, count it as such FIRST_TOKEN_FINDS.labels( token.camp.title, - request.user.id, + self.request.user.id, username, token.id, ).inc() TOKEN_FINDS.labels( token.camp.title, - request.user.id, + self.request.user.id, username, token.id, ).inc() @@ -101,19 +305,18 @@ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponseRedirect: # 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 token: '{token.description}' " + "- 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.""" + else: + messages.info( + self.request, + f"You already got this token. You submitted it at: {token_find.created}", + ) - model = Token - template_name = "tokenfind_list.html" + return redirect(self.get_success_url()) - def get_queryset(self) -> QuerySet: - """Get QuerySet of active tokens.""" - qs = super().get_queryset() - return qs.filter(active=True) + def get_success_url(self): + """Redirect back to dashboard on success""" + return reverse("tokens:dashboard", kwargs={"camp_slug": self.request.camp.slug}) diff --git a/src/utils/bootstrap/base.py b/src/utils/bootstrap/base.py index 2cb76c6e4..47da1f1c8 100644 --- a/src/utils/bootstrap/base.py +++ b/src/utils/bootstrap/base.py @@ -92,6 +92,7 @@ from tickets.models import SponsorTicket from tickets.models import TicketType from tokens.models import Token +from tokens.models import TokenCategory from tokens.models import TokenFind from utils.slugs import unique_slugify from villages.models import Village @@ -1452,6 +1453,11 @@ def create_camp_teams(self, camp: Camp) -> dict: description="The NOC team is in charge of establishing and running a network onsite.", camp=camp, ) + teams["game"] = Team.objects.create( + name="Game", + description="The Game team is in charge of token game.", + camp=camp, + ) teams["gis"] = Team.objects.create( name="GIS", description="The GIS team is in charge of managing the gis data.", @@ -1988,7 +1994,34 @@ def create_camp_sponsor_tickets( ticket_type=ticket_types["adult_full_week"], ) - def create_camp_tokens(self, camp: Camp) -> dict[Token]: + def create_token_categories(self, camp: Camp) -> dict[str, TokenCategory]: + """Create the camp tokens.""" + year = camp.camp.lower.year + self.output(f"Creating token categories for {year}...") + categories = {} + categories["physical"], _ = TokenCategory.objects.get_or_create( + name="Physical", + description="Tokens exist in the physical space", + ) + categories["phone"], _ = TokenCategory.objects.get_or_create( + name="Phone", + description="Tokens exist in a phoney space", + ) + categories["electrical"], _ = TokenCategory.objects.get_or_create( + name="Electrical", + description="Tokens with power", + ) + categories["internet"], _ = TokenCategory.objects.get_or_create( + name="Internet", + description="Tokens exist in the virtual space", + ) + categories["website"], _ = TokenCategory.objects.get_or_create( + name="Website", + description="Tokens exist on the bornhack website", + ) + return categories + + def create_camp_tokens(self, camp: Camp, categories: dict) -> dict[Token]: """Create the camp tokens.""" tokens = {} year = camp.camp.lower.year @@ -1996,42 +2029,48 @@ def create_camp_tokens(self, camp: Camp) -> dict[Token]: tokens[0] = Token.objects.create( camp=camp, token=get_random_string(length=32), - category="Physical", + category=categories["physical"], + hint="Token in a tent", description="Token in the back of the speakers tent (in binary)", active=True, ) tokens[1] = Token.objects.create( camp=camp, token=get_random_string(length=32), - category="Internet", - description="Twitter", + category=categories["internet"], + hint="Social media", + description="Mastodon", active=True, ) tokens[2] = Token.objects.create( camp=camp, token=get_random_string(length=32), - category="Website", + category=categories["website"], + hint="Web server", description="Token hidden in the X-Secret-Token HTTP header on the BornHack website", active=True, ) tokens[3] = Token.objects.create( camp=camp, token=get_random_string(length=32), - category="Physical", + category=categories["physical"], + hint="QR Code", description="Token in infodesk (QR code)", active=True, ) tokens[4] = Token.objects.create( camp=camp, token=get_random_string(length=32), - category="Physical", + category=categories["physical"], + hint="Gadget", description=f"Token on the back of the BornHack {year} badge", active=True, ) tokens[5] = Token.objects.create( camp=camp, token=get_random_string(length=32), - category="Website", + category=categories["website"], + hint="EXIF", description="Token hidden in EXIF data in the logo posted on the website sunday", active=True, ) @@ -2461,7 +2500,9 @@ def bootstrap_camp(self, options: dict) -> None: camp_sponsors = self.create_camp_sponsors(camp, sponsor_tiers) - tokens = self.create_camp_tokens(camp) + categories = self.create_token_categories(camp) + + tokens = self.create_camp_tokens(camp, categories) self.create_camp_token_finds(camp, tokens, self.users) diff --git a/src/utils/management/commands/bootstrap_devsite.py b/src/utils/management/commands/bootstrap_devsite.py index 2bb8a79b0..cc6722c50 100644 --- a/src/utils/management/commands/bootstrap_devsite.py +++ b/src/utils/management/commands/bootstrap_devsite.py @@ -10,6 +10,7 @@ logger = logging.getLogger(f"bornhack.{__name__}") + class Command(BaseCommand): args = "none" help = "Create mock data for development instances" diff --git a/src/utils/middleware.py b/src/utils/middleware.py index 8f6cb36d7..15f9b2807 100644 --- a/src/utils/middleware.py +++ b/src/utils/middleware.py @@ -45,5 +45,5 @@ def __call__(self, request): response = self.get_response(request) if "Vary" in response: if request.path.startswith("/maps/kfproxy/"): - del(response["Vary"]) + del response["Vary"] return response diff --git a/src/utils/mixins.py b/src/utils/mixins.py index 7eabe716d..9608bd039 100644 --- a/src/utils/mixins.py +++ b/src/utils/mixins.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING from django.conf import settings - from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import UserPassesTestMixin @@ -39,8 +38,10 @@ class RaisePermissionRequiredMixin(PermissionRequiredMixin): raise_exception = True + class IsTeamPermContextMixin: """Mixing for adding is_team_{perm} to context""" + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) perms = self.request.user.get_all_permissions() @@ -57,6 +58,7 @@ def get_context_data(self, **kwargs): context[f"is_team_{perm}"] = False return context + class BaseTeamPermRequiredMixin: """Base class for TeamRequiredMixins."""