From 076d75effec569f5f30f527161dc16d700ced3a1 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sun, 8 Mar 2026 15:14:41 -0400 Subject: [PATCH] new apps/dashboard/wallet.html for stripe payment integration and user's consumables; nav added to _footer.html & also dynamic copyright year with django now Y template; new apps.dash.tests ITs & UTs reflect new wallet functionality in .urls & .views --- .../dashboard/tests/integrated/test_views.py | 25 +++++++++ .../tests/integrated/test_wallet_views.py | 49 +++++++++++++++++ .../dashboard/tests/unit/test_templates.py | 9 ++++ src/apps/dashboard/urls.py | 1 + src/apps/dashboard/views.py | 13 ++++- src/apps/lyric/admin.py | 4 +- src/apps/lyric/authentication.py | 10 ++-- .../migrations/0005_rename_logintoken.py | 14 +++++ .../lyric/migrations/0006_token_wallet.py | 33 ++++++++++++ src/apps/lyric/models.py | 51 +++++++++++++++++- .../tests/integrated/test_authentication.py | 24 ++++----- .../lyric/tests/integrated/test_models.py | 53 ++++++++++++++++--- src/apps/lyric/tests/integrated/test_views.py | 22 ++++---- src/apps/lyric/tests/unit/test_tokens.py | 43 +++++++++++++++ src/apps/lyric/views.py | 6 +-- src/templates/apps/dashboard/wallet.html | 41 ++++++++++++++ src/templates/core/_partials/_footer.html | 6 ++- 17 files changed, 362 insertions(+), 42 deletions(-) create mode 100644 src/apps/dashboard/tests/integrated/test_wallet_views.py create mode 100644 src/apps/dashboard/tests/unit/test_templates.py create mode 100644 src/apps/lyric/migrations/0005_rename_logintoken.py create mode 100644 src/apps/lyric/migrations/0006_token_wallet.py create mode 100644 src/apps/lyric/tests/unit/test_tokens.py create mode 100644 src/templates/apps/dashboard/wallet.html diff --git a/src/apps/dashboard/tests/integrated/test_views.py b/src/apps/dashboard/tests/integrated/test_views.py index 4417e96..828d33e 100644 --- a/src/apps/dashboard/tests/integrated/test_views.py +++ b/src/apps/dashboard/tests/integrated/test_views.py @@ -414,3 +414,28 @@ class AppletVisibilityContextTest(TestCase): applet_map = {entry["applet"].slug: entry["visible"] for entry in response.context["applets"]} self.assertFalse(applet_map["palette"]) self.assertTrue(applet_map["username"]) + +class FooterNavTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="disco@test.io") + self.client.force_login(self.user) + + def test_footer_nav_present_on_dashboard(self): + response = self.client.get("/") + parsed = lxml.html.fromstring(response.content) + [nav] = parsed.cssselect("#id_footer_nav") + self.assertIsNotNone(nav) + + def test_footer_nav_has_dashboard_link(self): + response = self.client.get("/") + parsed = lxml.html.fromstring(response.content) + [nav] = parsed.cssselect("#id_footer_nav") + links = [a.get("href") for a in nav.cssselect("a")] + self.assertIn("/", links) + + def test_footer_nav_has_gameboard_link(self): + response = self.client.get("/") + parsed = lxml.html.fromstring(response.content) + [nav] = parsed.cssselect("#id_footer_nav") + links = [a.get("href") for a in nav.cssselect("a")] + self.assertIn("/gameboard/", links) diff --git a/src/apps/dashboard/tests/integrated/test_wallet_views.py b/src/apps/dashboard/tests/integrated/test_wallet_views.py new file mode 100644 index 0000000..adec63b --- /dev/null +++ b/src/apps/dashboard/tests/integrated/test_wallet_views.py @@ -0,0 +1,49 @@ +import lxml.html + +from django.test import TestCase + +from apps.lyric.models import Token, User, Wallet + + +class WalletViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="capman@test.io") + self.client.force_login(self.user) + response = self.client.get("/dashboard/wallet/") + self.parsed = lxml.html.fromstring(response.content) + + def test_wallet_page_requires_login(self): + self.client.logout() + response = self.client.get("/dashboard/wallet/") + self.assertRedirects( + response, "/?next=/dashboard/wallet/", fetch_redirect_response=False + ) + + def test_wallet_page_renders(self): + [el] = self.parsed.cssselect("#id_writs_balance") + self.assertEqual(el.text_content().strip(), "144") + + def test_wallet_page_shows_esteem_balance(self): + [el] = self.parsed.cssselect("#id_esteem_balance") + self.assertEqual(el.text_content().strip(), "0") + + def test_wallet_page_shows_coin_on_a_string(self): + [_] = self.parsed.cssselect("#id_coin_on_a_string") + + def test_wallet_page_shows_free_token(self): + [_] = self.parsed.cssselect("#id_free_token_0") + + def test_wallet_page_shows_payment_methods_section(self): + [_] = self.parsed.cssselect("#id_add_payment_method") + + def test_wallet_page_shows_stripe_payment_element(self): + [_] = self.parsed.cssselect("#id_stripe_payment_element") + + def test_wallet_page_shows_tithe_token_shop(self): + [_] = self.parsed.cssselect("#id_tithe_token_shop") + + def test_tithe_token_shop_shows_bundle(self): + bundles = self.parsed.cssselect("#id_tithe_token_shop .token-bundle") + self.assertGreater(len(bundles), 0) + + diff --git a/src/apps/dashboard/tests/unit/test_templates.py b/src/apps/dashboard/tests/unit/test_templates.py new file mode 100644 index 0000000..8dee7a1 --- /dev/null +++ b/src/apps/dashboard/tests/unit/test_templates.py @@ -0,0 +1,9 @@ +from datetime import date +from django.test import SimpleTestCase +from django.template.loader import render_to_string + + +class FooterTemplateTest(SimpleTestCase): + def test_footer_shows_current_year(self): + rendered = render_to_string("core/_partials/_footer.html") + self.assertIn(str(date.today().year), rendered) diff --git a/src/apps/dashboard/urls.py b/src/apps/dashboard/urls.py index 9ac721b..39a8e96 100644 --- a/src/apps/dashboard/urls.py +++ b/src/apps/dashboard/urls.py @@ -9,4 +9,5 @@ urlpatterns = [ path('set_profile', views.set_profile, name='set_profile'), path('users//', views.my_lists, name='my_lists'), path('toggle_applets', views.toggle_applets, name="toggle_applets"), + path('wallet/', views.wallet, name='wallet'), ] diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 3bf27ab..ab6224c 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -6,7 +6,7 @@ from django.shortcuts import redirect, render from apps.dashboard.forms import ExistingListItemForm, ItemForm from apps.dashboard.models import Applet, Item, List, UserApplet -from apps.lyric.models import User +from apps.lyric.models import Token, User, Wallet APPLET_ORDER = ["new-list", "my-lists", "username", "palette"] @@ -144,3 +144,14 @@ def toggle_applets(request): "recent_lists": _recent_lists(request.user), }) return redirect("home") + +@login_required(login_url="/") +def wallet(request): + wallet = request.user.wallet + coin = request.user.tokens.filter(token_type=Token.COIN).first() + free_tokens = list(request.user.tokens.filter(token_type=Token.FREE)) + return render(request, "apps/dashboard/wallet.html", { + "wallet": wallet, + "coin": coin, + "free_tokens": free_tokens, + }) diff --git a/src/apps/lyric/admin.py b/src/apps/lyric/admin.py index 898834d..ceeabd9 100644 --- a/src/apps/lyric/admin.py +++ b/src/apps/lyric/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Token, User +from .models import LoginToken, User class UserAdmin(admin.ModelAdmin): @@ -8,4 +8,4 @@ class UserAdmin(admin.ModelAdmin): search_fields = ["email"] admin.site.register(User, UserAdmin) -admin.site.register(Token) +admin.site.register(LoginToken) diff --git a/src/apps/lyric/authentication.py b/src/apps/lyric/authentication.py index 355fbc7..dde774c 100644 --- a/src/apps/lyric/authentication.py +++ b/src/apps/lyric/authentication.py @@ -1,5 +1,5 @@ from django.core.exceptions import ValidationError -from .models import Token, User +from .models import LoginToken, User class PasswordlessAuthenticationBackend: @@ -7,13 +7,13 @@ class PasswordlessAuthenticationBackend: if uid is None: return None try: - token = Token.objects.get(uid=uid) - except (Token.DoesNotExist, ValidationError): + login_token = LoginToken.objects.get(uid=uid) + except (LoginToken.DoesNotExist, ValidationError): return None try: - return User.objects.get(email=token.email) + return User.objects.get(email=login_token.email) except User.DoesNotExist: - return User.objects.create_user(email=token.email) + return User.objects.create_user(email=login_token.email) def get_user(self, user_id): try: diff --git a/src/apps/lyric/migrations/0005_rename_logintoken.py b/src/apps/lyric/migrations/0005_rename_logintoken.py new file mode 100644 index 0000000..8f09a20 --- /dev/null +++ b/src/apps/lyric/migrations/0005_rename_logintoken.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('lyric', '0004_remove_user_theme_user_palette'), + ] + + operations = [ + migrations.RenameModel( + old_name="Token", + new_name="LoginToken", + ), + ] diff --git a/src/apps/lyric/migrations/0006_token_wallet.py b/src/apps/lyric/migrations/0006_token_wallet.py new file mode 100644 index 0000000..f51bdc6 --- /dev/null +++ b/src/apps/lyric/migrations/0006_token_wallet.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0 on 2026-03-08 18:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyric', '0005_rename_logintoken'), + ] + + operations = [ + migrations.CreateModel( + name='Token', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_type', models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token')], max_length=8)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Wallet', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('writs', models.IntegerField(default=0)), + ('esteem', models.IntegerField(default=0)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='wallet', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index c9765ed..2bb12a4 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -1,7 +1,11 @@ import uuid +from datetime import timedelta from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils import timezone class UserManager(BaseUserManager): @@ -17,7 +21,7 @@ class UserManager(BaseUserManager): user.save(using=self._db) return user -class Token(models.Model): +class LoginToken(models.Model): email = models.EmailField() uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -40,3 +44,48 @@ class User(AbstractBaseUser): def has_module_perms(self, app_label): return self.is_superuser + +class Wallet(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="wallet") + writs = models.IntegerField(default=0) + esteem = models.IntegerField(default=0) + +class Token(models.Model): + COIN = "coin" + FREE = "Free" + TITHE = "tithe" + TOKEN_TYPE_CHOICES = [ + (COIN, "Coin-on-a-String"), + (FREE, "Free Token"), + (TITHE, "Tithe Token"), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens") + token_type = models.CharField(max_length=8, choices=TOKEN_TYPE_CHOICES) + expires_at = models.DateTimeField(null=True, blank=True) + + def tooltip_text(self): + if self.token_type == self.COIN: + return ( + "Coin-on-a-String: Admit 1 Entry" + " (and another after that, and another after that\u2026)" + " \u2014 no expiry" + ) + if self.token_type == self.FREE: + return ( + f"Free Token: Admit 1 Entry" + f" \u2014 Expires {self.expires_at.strftime('%Y-%m-%d')}" + ) + return self.get_token_type_display() + +@receiver(post_save, sender=User) +def create_wallet_and_tokens(sender, instance, created, **kwargs): + if not created: + return + Wallet.objects.create(user=instance, writs=144) + Token.objects.create(user=instance, token_type=Token.COIN) + Token.objects.create( + user=instance, + token_type=Token.FREE, + expires_at=timezone.now() + timedelta(days=7), + ) diff --git a/src/apps/lyric/tests/integrated/test_authentication.py b/src/apps/lyric/tests/integrated/test_authentication.py index dc54fbd..39981d2 100644 --- a/src/apps/lyric/tests/integrated/test_authentication.py +++ b/src/apps/lyric/tests/integrated/test_authentication.py @@ -3,39 +3,39 @@ from django.http import HttpRequest from django.test import TestCase from apps.lyric.authentication import PasswordlessAuthenticationBackend -from apps.lyric.models import Token, User +from apps.lyric.models import LoginToken, User class AuthenticateTest(TestCase): - def test_returns_None_if_token_uuid_not_found(self): + def test_returns_None_if_login_token_uuid_not_found(self): uid = uuid.uuid4() result = PasswordlessAuthenticationBackend().authenticate( HttpRequest(), uid ) self.assertIsNone(result) - def test_returns_new_user_with_correct_email_if_token_exists(self): + def test_returns_new_user_with_correct_email_if_login_token_exists(self): email = "discoman@example.com" - token = Token.objects.create(email=email) + login_token = LoginToken.objects.create(email=email) user = PasswordlessAuthenticationBackend().authenticate( - HttpRequest(), token.uid + HttpRequest(), login_token.uid ) new_user = User.objects.get(email=email) self.assertEqual(user, new_user) - def test_returns_existing_user_with_correct_email_if_token_exists(self): + def test_returns_existing_user_with_correct_email_if_login_token_exists(self): email = "discoman@example.com" existing_user = User.objects.create(email=email) - token = Token.objects.create(email=email) + login_token = LoginToken.objects.create(email=email) user = PasswordlessAuthenticationBackend().authenticate( - HttpRequest(), token.uid + HttpRequest(), login_token.uid ) self.assertEqual(user, existing_user) - def test_can_retrieve_token_by_uuid(self): - token = Token.objects.create(email="a@b.cde") - fetched = Token.objects.get(pk=token.uid) - self.assertEqual(fetched, token) + def test_can_retrieve_login_token_by_uuid(self): + login_token = LoginToken.objects.create(email="a@b.cde") + fetched = LoginToken.objects.get(pk=login_token.uid) + self.assertEqual(fetched, login_token) class GetUserTest(TestCase): def test_gets_user_by_uuid(self): diff --git a/src/apps/lyric/tests/integrated/test_models.py b/src/apps/lyric/tests/integrated/test_models.py index a3ae0dd..2e0cbdf 100644 --- a/src/apps/lyric/tests/integrated/test_models.py +++ b/src/apps/lyric/tests/integrated/test_models.py @@ -1,8 +1,9 @@ import uuid from django.contrib import auth from django.test import TestCase +from django.utils import timezone -from apps.lyric.models import Token, User +from apps.lyric.models import LoginToken, Token, User, Wallet class UserModelTest(TestCase): @@ -28,12 +29,12 @@ class UserModelTest(TestCase): user = User.objects.create(email="a@b.cde") self.assertFalse(user.searchable) -class TokenModelTest(TestCase): +class LoginTokenModelTest(TestCase): def test_links_user_with_autogen_uid(self): - token1 = Token.objects.create(email="a@b.cde") - token2 = Token.objects.create(email="v@w.xyz") - self.assertNotEqual(token1.pk, token2.pk) - self.assertIsInstance(token1.pk, uuid.UUID) + login_token1 = LoginToken.objects.create(email="a@b.cde") + login_token2 = LoginToken.objects.create(email="v@w.xyz") + self.assertNotEqual(login_token1.pk, login_token2.pk) + self.assertIsInstance(login_token1.pk, uuid.UUID) class UserManagerTest(TestCase): def test_create_superuser_sets_is_staff_and_is_superuser(self): @@ -55,3 +56,43 @@ class UserPaletteTest(TestCase): def test_palette_field_defaults_to_palette_default(self): user = User.objects.create(email="a@b.cde") self.assertEqual(user.palette, "palette-default") + +class WalletCreationTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="capman@test.io") + + def test_wallet_is_created_for_new_user(self): + self.assertTrue(Wallet.objects.filter(user=self.user).exists()) + + def test_new_wallet_has_144_writs(self): + wallet = Wallet.objects.get(user = self.user) + self.assertEqual(wallet.writs, 144) + + def test_new_wallet_has_0_esteem(self): + wallet = Wallet.objects.get(user=self.user) + self.assertEqual(wallet.esteem, 0) + +class TokenCreationTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="capman@test.io") + + def test_coin_on_a_string_created_for_new_user(self): + self.assertTrue( + Token.objects.filter(user=self.user, token_type=Token.COIN).exists() + ) + + def test_free_token_created_for_new_user(self): + self.assertTrue( + Token.objects.filter(user=self.user, token_type=Token.FREE).exists() + ) + + def test_coin_on_a_string_has_no_expiry(self): + coin = Token.objects.get(user=self.user, token_type=Token.COIN) + self.assertIsNone(coin.expires_at) + + def test_free_token_has_expiry_within_7_days(self): + free = Token.objects.get(user=self.user, token_type=Token.FREE) + self.assertIsNotNone(free.expires_at) + delta = free.expires_at - timezone.now() + self.assertLessEqual(delta.days, 7) + self.assertGreater(delta.total_seconds(), 0) diff --git a/src/apps/lyric/tests/integrated/test_views.py b/src/apps/lyric/tests/integrated/test_views.py index da14cab..9746002 100644 --- a/src/apps/lyric/tests/integrated/test_views.py +++ b/src/apps/lyric/tests/integrated/test_views.py @@ -2,7 +2,7 @@ from django.contrib import auth from django.test import TestCase from unittest import mock -from apps.lyric.models import Token +from apps.lyric.models import LoginToken @mock.patch("apps.lyric.views.send_login_email_task.delay") @@ -35,20 +35,20 @@ class SendLoginEmailViewTest(TestCase): ) self.assertEqual(message.tags, "success") - def test_creates_token_associated_with_email(self, mock_delay): + def test_creates_login_token_associated_with_email(self, mock_delay): self.client.post( "/lyric/send_login_email", data={"email": "discoman@example.com"} ) - token = Token.objects.get() - self.assertEqual(token.email, "discoman@example.com") + login_token = LoginToken.objects.get() + self.assertEqual(login_token.email, "discoman@example.com") - def test_sends_link_to_login_using_token_uid(self, mock_delay): + def test_sends_link_to_login_using_login_token_uid(self, mock_delay): self.client.post( "/lyric/send_login_email", data={"email": "discoman@example.com"} ) - token = Token.objects.get() - expected_url = f"http://testserver/lyric/login?token={token.uid}" + login_token = LoginToken.objects.get() + expected_url = f"http://testserver/lyric/login?token={login_token.uid}" self.assertEqual(mock_delay.call_args.args[1], expected_url) class LoginViewTest(TestCase): @@ -56,18 +56,18 @@ class LoginViewTest(TestCase): response = self.client.get("/lyric/login?token=abc123") self.assertRedirects(response, "/") - def test_logs_in_if_given_valid_token(self): + def test_logs_in_if_given_valid_login_token(self): anon_user = auth.get_user(self.client) self.assertEqual(anon_user.is_authenticated, False) - token = Token.objects.create(email="discoman@example.com") - self.client.get(f"/lyric/login?token={token.uid}", follow=True) + login_token = LoginToken.objects.create(email="discoman@example.com") + self.client.get(f"/lyric/login?token={login_token.uid}", follow=True) user = auth.get_user(self.client) self.assertEqual(user.is_authenticated, True) self.assertEqual(user.email, "discoman@example.com") - def test_shows_login_error_if_token_invalid(self): + def test_shows_login_error_if_login_token_invalid(self): response = self.client.get("/lyric/login?token=invalid-token", follow=True) user = auth.get_user(self.client) self.assertEqual(user.is_authenticated, False) diff --git a/src/apps/lyric/tests/unit/test_tokens.py b/src/apps/lyric/tests/unit/test_tokens.py new file mode 100644 index 0000000..9844d90 --- /dev/null +++ b/src/apps/lyric/tests/unit/test_tokens.py @@ -0,0 +1,43 @@ +from django.test import SimpleTestCase +from unittest.mock import MagicMock + +from apps.lyric.models import Token + + +class CoinTooltipTest(SimpleTestCase): + def setUp(self): + self.coin = Token () + self.coin.token_type = Token.COIN + self.coin.expires_at = None + + def test_tooltip_contains_name(self): + self.assertIn("Coin-on-a-String", self.coin.tooltip_text()) + + def test_tooltip_contains_entry(self): + self.assertIn("Admit 1 Entry", self.coin.tooltip_text()) + + def test_tooltip_contains_reuse_description(self): + self.assertIn("and another after that", self.coin.tooltip_text()) + + def test_tooltip_contains_no_expiry(self): + self.assertIn("no expiry", self.coin.tooltip_text()) + +class FreeTokenTooltipTest(SimpleTestCase): + def setUp(self): + self.token = Token() + self.token.token_type = Token.FREE + self.token.expires_at = MagicMock() + self.token.expires_at.strftime = lambda fmt: "2026-03-15" + + def test_tooltip_contains_name(self): + self.assertIn("Free Token", self.token.tooltip_text()) + + def test_tooltip_contains_entry(self): + self.assertIn("Admit 1 Entry", self.token.tooltip_text()) + + def test_tooltip_contains_expires(self): + self.assertIn("Expires", self.token.tooltip_text()) + + def test_tooltip_contains_expiry_date(self): + self.assertIn("2026-03-15", self.token.tooltip_text()) + diff --git a/src/apps/lyric/views.py b/src/apps/lyric/views.py index 467faf3..586b64a 100644 --- a/src/apps/lyric/views.py +++ b/src/apps/lyric/views.py @@ -2,15 +2,15 @@ from django.contrib import auth, messages from django.shortcuts import redirect from django.urls import reverse -from .models import Token +from .models import LoginToken from .tasks import send_login_email_task def send_login_email(request): email = request.POST["email"] - token = Token.objects.create(email=email) + login_token = LoginToken.objects.create(email=email) url = request.build_absolute_uri( - reverse("login") + "?token=" + str(token.uid), + reverse("login") + "?token=" + str(login_token.uid), ) send_login_email_task.delay(email, url) diff --git a/src/templates/apps/dashboard/wallet.html b/src/templates/apps/dashboard/wallet.html new file mode 100644 index 0000000..1ff0e25 --- /dev/null +++ b/src/templates/apps/dashboard/wallet.html @@ -0,0 +1,41 @@ +{% extends "core/base.html" %} + +{% block content %} +
+

Wallet

+ +
+
Writs: {{ wallet.writs }}
+
Esteem: {{ wallet.esteem }}
+
+ +
+ {% if coin %} +
+ {{ coin.tooltip_text }} +
+ {% endif %} + {% for token in free_tokens %} +
+ {{ token.tooltip_text }} +
+ {% endfor %} +
+ +
+

Payment Methods

+ + +
+
+ +
+

Tithe Tokens

+ +
+ Tithe Token ×1 + + Writ bonus +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/src/templates/core/_partials/_footer.html b/src/templates/core/_partials/_footer.html index 2fe61e8..2b4153f 100644 --- a/src/templates/core/_partials/_footer.html +++ b/src/templates/core/_partials/_footer.html @@ -1,5 +1,9 @@