Finish packaging and change to src-based packaging layout, replace caddy with haproxy for performance, and update docker-compose and Dockerfiles for new packaging.

This commit is contained in:
badblocks 2025-05-09 18:39:04 -07:00
parent 959b06c425
commit 762361a21b
210 changed files with 235 additions and 168 deletions

View file

View file

View file

@ -0,0 +1,30 @@
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin
from .forms import CustomUserCreationForm, CustomUserChangeForm
from .models import CustomUser
class CustomUserAdmin(UserAdmin):
add_form = CustomUserCreationForm
form = CustomUserChangeForm
model = CustomUser
list_display = [
"email",
"username",
]
# Explicitly define add_fieldsets to prevent unexpected fields
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("username", "email", "password1", "password2"),
},
),
)
admin.site.register(CustomUser, CustomUserAdmin)

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'pkmntrade_club.accounts'

View file

@ -0,0 +1,90 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import CustomUser, FriendCode
from allauth.account.forms import SignupForm
from crispy_tailwind.tailwind import CSSContainer
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Submit
class CustomUserChangeForm(UserChangeForm):
class Meta:
model = CustomUser
fields = ['email']
class FriendCodeForm(forms.ModelForm):
class Meta:
model = FriendCode
fields = ["friend_code", "in_game_name"]
def clean_friend_code(self):
friend_code = self.cleaned_data.get("friend_code", "").strip()
# Remove any dashes from the input for validation.
friend_code_clean = friend_code.replace("-", "")
if len(friend_code_clean) != 16 or not friend_code_clean.isdigit():
raise forms.ValidationError("Friend code must be exactly 16 digits long.")
# Format the friend code as: XXXX-XXXX-XXXX-XXXX.
friend_code_formatted = f"{friend_code_clean[:4]}-{friend_code_clean[4:8]}-{friend_code_clean[8:12]}-{friend_code_clean[12:16]}"
return friend_code_formatted
class CustomUserCreationForm(SignupForm):
class Meta(UserCreationForm.Meta):
model = CustomUser
fields = ['email', 'username', 'friend_code']
email = forms.EmailField(
required=True,
label="Email",
widget=forms.TextInput(attrs={'placeholder': 'Email', 'class':'dark:bg-base-100'})
)
username = forms.CharField(
max_length=24,
required=True,
label="Username",
widget=forms.TextInput(attrs={'placeholder': 'Username', 'class':'dark:bg-base-100'})
)
friend_code = forms.CharField(
max_length=19,
required=True,
label="Friend Code",
help_text="Enter your friend code in the format XXXX-XXXX-XXXX-XXXX.",
widget=forms.TextInput(attrs={'placeholder': 'XXXX-XXXX-XXXX-XXXX', 'class':'dark:bg-base-100'})
)
in_game_name = forms.CharField(
max_length=16,
required=True,
label="In-Game Name",
help_text="Enter your in-game name.",
widget=forms.TextInput(attrs={'placeholder': 'In-Game Name', 'class':'dark:bg-base-100'})
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def clean_friend_code(self):
friend_code = self.cleaned_data.get("friend_code", "").strip().replace("-", "")
if len(friend_code) != 16 or not friend_code.isdigit():
raise forms.ValidationError("Friend code must be exactly 16 digits long.")
formatted = f"{friend_code[:4]}-{friend_code[4:8]}-{friend_code[8:12]}-{friend_code[12:16]}"
return formatted
def save(self, request):
# First, complete the normal signup process.
user = super(CustomUserCreationForm, self).save(request)
# Create the associated FriendCode record, now including in_game_name.
friend_code_instance = FriendCode.objects.create(
friend_code=self.cleaned_data["friend_code"],
in_game_name=self.cleaned_data["in_game_name"],
user=user
)
user.default_friend_code = friend_code_instance
user.save()
return user
class UserSettingsForm(forms.ModelForm):
class Meta:
model = CustomUser
fields = ['show_friend_code_on_link_previews', 'enable_email_notifications']

View file

@ -0,0 +1,7 @@
from django.core.management.base import BaseCommand
from django.core.cache import cache
class Command(BaseCommand):
def handle(self, *args, **kwargs):
cache.clear()
self.stdout.write('Cleared cache\n')

View file

@ -0,0 +1,66 @@
# Generated by Django 5.1 on 2025-05-10 01:22
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import pkmntrade_club.accounts.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('show_friend_code_on_link_previews', models.BooleanField(default=False, help_text='This will primarily affect share link previews on X, Discord, etc.', verbose_name='Show Friend Code on Link Previews')),
('enable_email_notifications', models.BooleanField(default=True, help_text='Receive trade notifications via email.', verbose_name='Enable Email Notifications')),
('reputation_score', models.IntegerField(default=0)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='FriendCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('friend_code', models.CharField(max_length=19, validators=[pkmntrade_club.accounts.models.validate_friend_code])),
('in_game_name', models.CharField(max_length=14)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='friend_codes', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='customuser',
name='default_friend_code',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.friendcode'),
),
]

View file

@ -0,0 +1 @@
0001_initial

View file

@ -0,0 +1,70 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.core.exceptions import ValidationError
import re
def validate_friend_code(value):
"""Validate that friend code follows the format XXXX-XXXX-XXXX-XXXX where X is a digit."""
if not re.match(r'^\d{4}-\d{4}-\d{4}-\d{4}$', value):
raise ValidationError(
'Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit.'
)
class CustomUser(AbstractUser):
default_friend_code = models.ForeignKey("FriendCode", on_delete=models.SET_NULL, null=True, blank=True)
show_friend_code_on_link_previews = models.BooleanField(
default=False,
verbose_name="Show Friend Code on Link Previews",
help_text="This will primarily affect share link previews on X, Discord, etc."
)
enable_email_notifications = models.BooleanField(
default=True,
verbose_name="Enable Email Notifications",
help_text="Receive trade notifications via email."
)
reputation_score = models.IntegerField(default=0)
def __str__(self):
return self.email
def set_default_friend_code(self, friend_code):
"""Set a friend code as default if it belongs to the user."""
if friend_code.user != self:
raise ValidationError("Friend code does not belong to this user.")
self.default_friend_code = friend_code
self.save(update_fields=["default_friend_code"])
def remove_default_friend_code(self, friend_code):
"""
If the given friend code is the current default,
assign another of the user's friend codes as default.
Raises ValidationError if it's the only friend code.
"""
if self.default_friend_code == friend_code:
other_codes = self.friend_codes.exclude(pk=friend_code.pk)
if not other_codes.exists():
raise ValidationError("A user must always have a default friend code.")
self.default_friend_code = other_codes.first()
self.save(update_fields=["default_friend_code"])
class FriendCode(models.Model):
friend_code = models.CharField(max_length=19, validators=[validate_friend_code])
in_game_name = models.CharField(max_length=14, null=False, blank=False)
user = models.ForeignKey(CustomUser, on_delete=models.PROTECT, related_name='friend_codes')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
"""
When a new friend code is saved,
if the user has no default friend code yet,
automatically set this as the default.
"""
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new and not self.user.default_friend_code:
self.user.default_friend_code = self
self.user.save(update_fields=["default_friend_code"])
def __str__(self):
return self.friend_code

View file

@ -0,0 +1,83 @@
import hashlib
from urllib.parse import urlencode
import requests # Added to perform HTTP requests
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter
def gravatar_hash(email):
"""
Returns the hash of the email.
"""
email_encoded = email.strip().lower().encode('utf-8')
email_hash = hashlib.sha256(email_encoded).hexdigest()
return email_hash
@register.filter
def gravatar_url(email, size=20):
"""
Returns the Gravatar URL for a given email. The URL includes parameters
for the default image and the size.
"""
default = "retro"
email_hash = gravatar_hash(email)
params = urlencode({'d': default, 's': str(size)})
params = params.replace("&", "&")
return f"https://www.gravatar.com/avatar/{email_hash}?{params}"
@register.filter
def gravatar_profile_url(email=None):
"""
Returns the Gravatar Profile URL for a given email.
"""
if email is None:
return f"https://www.gravatar.com/profile"
email_hash = gravatar_hash(email)
return f"https://secure.gravatar.com/{email_hash}"
@register.filter
def gravatar(email, size=20):
"""
Returns an HTML image tag for the Gravatar of a given email,
with the specified width and height.
"""
url = gravatar_url(email, size)
# Return a safe HTML snippet with the image element
html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar"></img>'
return mark_safe(html)
@register.filter
def gravatar_no_hover(email, size=20):
"""
Returns an HTML image tag for the Gravatar of a given email,
with the specified width and height.
"""
url = gravatar_url(email, size)
# Return a safe HTML snippet with the image element
html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar" class="ignore"></img>'
return mark_safe(html)
@register.filter
def gravatar_profile_data(email):
"""
Retrieves the Gravatar profile JSON for a given email.
It fetches data from https://gravatar.com/<HASH>.json, extracts the first entry,
and returns it as a standardized dictionary for use in templates.
If the email is None or if any error occurs, returns an empty dictionary.
"""
if not email:
return {}
email_hash = gravatar_hash(email)
url = f"https://gravatar.com/{email_hash}.json"
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
data = response.json()
# Gravatar's JSON response typically contains an "entry" list; we take the first entry.
if "entry" in data and data["entry"]:
return data["entry"][0]
return {}
except (requests.RequestException, ValueError):
return {}

View file

@ -0,0 +1,638 @@
import hashlib
from unittest.mock import patch, MagicMock
import requests
from django.contrib.auth import get_user_model
from django.test import TestCase, RequestFactory
from django.urls import reverse
from django.core.exceptions import ValidationError
from django.contrib.sessions.middleware import SessionMiddleware
from pkmntrade_club.accounts.models import FriendCode
from pkmntrade_club.accounts.forms import FriendCodeForm, CustomUserCreationForm, UserSettingsForm
from pkmntrade_club.accounts.templatetags import gravatar
from pkmntrade_club.trades.models import TradeOffer
from tests.utils.rarity import RARITY_MAPPING
# Create your tests here.
# -----------------------------
# Model Tests
# -----------------------------
class CustomUserModelTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="testuser",
email="test@example.com",
password="password123"
)
def test_set_default_friend_code(self):
"""User can manually set a friend code as their default."""
fc1 = FriendCode.objects.create(
friend_code="1234-5678-9012-3456",
user=self.user,
in_game_name="GameOne"
)
fc2 = FriendCode.objects.create(
friend_code="2345-6789-0123-4567",
user=self.user,
in_game_name="GameTwo"
)
# Manually set fc2 as default.
self.user.set_default_friend_code(fc2)
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc2)
def test_set_default_friend_code_invalid(self):
"""
Attempting to set a friend code that does not belong to the user should raise an exception.
"""
other_user = get_user_model().objects.create_user(
username="otheruser",
email="other@example.com",
password="password456"
)
fc_other = FriendCode.objects.create(
friend_code="3456-7890-1234-5678",
user=other_user,
in_game_name="OtherGame"
)
with self.assertRaises(ValidationError):
self.user.set_default_friend_code(fc_other)
def test_remove_default_friend_code_with_multiple_codes(self):
"""
When removing the default friend code and other friend codes exist,
the default should be reassigned to another friend code.
"""
fc1 = FriendCode.objects.create(
friend_code="1234-5678-9012-3456",
user=self.user,
in_game_name="GameOne"
)
fc2 = FriendCode.objects.create(
friend_code="2345-6789-0123-4567",
user=self.user,
in_game_name="GameTwo"
)
# Set fc2 as default.
self.user.set_default_friend_code(fc2)
# Removing fc2 should reassign the default to fc1.
self.user.remove_default_friend_code(fc2)
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc1)
def test_removing_only_friend_code_raises(self):
"""
A user must always have a default friend code.
Attempting to remove the only friend code (and thus the default)
should be prohibited.
"""
fc = FriendCode.objects.create(
friend_code="1234-5678-9012-3456",
user=self.user,
in_game_name="OnlyGame"
)
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc)
with self.assertRaises(ValidationError):
self.user.remove_default_friend_code(fc)
def test_remove_non_default_friend_code_does_nothing(self):
"""
When attempting to remove a friend code that isn't the default,
the current default should remain unchanged.
"""
fc1 = FriendCode.objects.create(
friend_code="1234-5678-9012-3456",
user=self.user,
in_game_name="GameOne"
)
fc2 = FriendCode.objects.create(
friend_code="2345-6789-0123-4567",
user=self.user,
in_game_name="GameTwo"
)
# By default, fc1 is the default friend code.
self.assertEqual(self.user.default_friend_code, fc1)
try:
self.user.remove_default_friend_code(fc2)
except Exception as e:
self.fail("remove_default_friend_code raised an exception when removing a non-default code.")
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc1)
# -----------------------------
# FriendCode Model Tests
# -----------------------------
class FriendCodeModelTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="testuser2",
email="test2@example.com",
password="password123"
)
def test_default_set_on_creation(self):
"""
When creating a FriendCode for a user with no default,
the new friend code is automatically set as the default.
"""
fc = FriendCode.objects.create(
friend_code="1234-5678-9012-3456",
user=self.user,
in_game_name="GameDefault"
)
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc)
def test_adding_additional_friend_code_preserves_default(self):
"""
When additional friend codes are added to a user who already has a default,
the initial friend code remains the default.
"""
fc1 = FriendCode.objects.create(
friend_code="1111-1111-1111-1111",
user=self.user,
in_game_name="PrimaryGame"
)
# fc1 becomes the default automatically.
self.assertEqual(self.user.default_friend_code, fc1)
fc2 = FriendCode.objects.create(
friend_code="2222-2222-2222-2222",
user=self.user,
in_game_name="SecondaryGame"
)
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc1)
# -----------------------------
# Form Tests
# -----------------------------
class FriendCodeFormTests(TestCase):
def test_valid_friend_code(self):
"""Ensure valid friend code is cleaned and formatted properly."""
form_data = {
"friend_code": "1234567890123456",
"in_game_name": "GameTest"
}
form = FriendCodeForm(data=form_data)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["friend_code"], "1234-5678-9012-3456")
def test_invalid_friend_code_length(self):
"""Friend codes with incorrect length should cause validation errors."""
form_data = {
"friend_code": "12345",
"in_game_name": "GameTest"
}
form = FriendCodeForm(data=form_data)
self.assertFalse(form.is_valid())
self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"])
def test_invalid_friend_code_characters(self):
"""Friend codes containing non-digit characters should cause validation errors."""
form_data = {
"friend_code": "12345678901234ab",
"in_game_name": "GameTest"
}
form = FriendCodeForm(data=form_data)
self.assertFalse(form.is_valid())
self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"])
def test_friend_code_with_whitespace(self):
"""Ensure that leading/trailing whitespace is stripped."""
form_data = {
"friend_code": " 1234567890123456 ",
"in_game_name": "WhitespaceGame"
}
form = FriendCodeForm(data=form_data)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["friend_code"], "1234-5678-9012-3456")
def test_friend_code_with_dashes(self):
"""Proper dashes in the input should be accepted."""
form_data = {
"friend_code": "1234-5678-9012-3456",
"in_game_name": "ExtraDashGame"
}
form = FriendCodeForm(data=form_data)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["friend_code"], "1234-5678-9012-3456")
class CustomUserCreationFormTests(TestCase):
def _get_request_with_session(self):
"""
Helper to create a Request object that has a session,
so that the signup view and form can use request.session.
"""
request = RequestFactory().get("/")
middleware = SessionMiddleware(lambda r: None)
middleware.process_request(request)
request.session.save()
return request
def test_valid_custom_user_creation(self):
"""
Test that the custom signup form creates a user and associated friend code properly.
"""
form_data = {
"email": "new@example.com",
"username": "newuser",
"password1": "complexpass123",
"password2": "complexpass123",
"friend_code": "5555-5555-5555-5555",
"in_game_name": "NewGame",
}
form = CustomUserCreationForm(data=form_data)
self.assertTrue(form.is_valid())
request = self._get_request_with_session()
user = form.save(request)
self.assertIsNotNone(user)
# Check that the associated friend code exists and marked as the default.
friend_code = user.default_friend_code
self.assertIsNotNone(friend_code)
self.assertEqual(friend_code.friend_code, "5555-5555-5555-5555")
self.assertEqual(friend_code.in_game_name, "NewGame")
def test_user_always_has_default_after_signup(self):
"""
Ensure that after sign-up (which creates the initial friend code),
the user always has a default friend code.
"""
form_data = {
"email": "another@example.com",
"username": "anotheruser",
"password1": "complexpass456",
"password2": "complexpass456",
"friend_code": "6666-6666-6666-6666",
"in_game_name": "AnotherGame",
}
form = CustomUserCreationForm(data=form_data)
self.assertTrue(form.is_valid())
request = self._get_request_with_session()
user = form.save(request)
# Immediately after signup, the user should have a default friend code.
self.assertIsNotNone(user.default_friend_code)
def test_invalid_custom_user_creation_invalid_friend_code(self):
"""
Supplying an invalid friend code (wrong length/format) should cause the form to fail.
"""
form_data = {
"email": "bad@example.com",
"username": "baduser",
"password1": "pass12345",
"password2": "pass12345",
"friend_code": "abcde", # Invalid friend code
"in_game_name": "BadGame",
}
form = CustomUserCreationForm(data=form_data)
self.assertFalse(form.is_valid())
self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"])
def test_invalid_custom_user_creation_password_mismatch(self):
"""
The form should catch mismatched passwords.
"""
form_data = {
"email": "passmismatch@example.com",
"username": "passmismatch",
"password1": "pass12345",
"password2": "differentpass",
"friend_code": "5555-5555-5555-5555",
"in_game_name": "MismatchGame",
}
form = CustomUserCreationForm(data=form_data)
self.assertFalse(form.is_valid())
# The error key may be '__all__' or 'password2' depending on the implementation.
errors = form.errors.get("__all__") or form.errors.get("password2")
self.assertTrue(errors, "Expected a password mismatch error.")
class UserSettingsFormTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="settingsuser",
email="settings@example.com",
password="password123"
)
def test_toggle_show_friend_code_on_link_previews(self):
"""Test updating the user setting for showing friend code on link previews."""
form_data = {"show_friend_code_on_link_previews": True}
form = UserSettingsForm(form_data, instance=self.user)
self.assertTrue(form.is_valid())
form.save()
self.user.refresh_from_db()
self.assertTrue(self.user.show_friend_code_on_link_previews)
# -----------------------------
# View Tests
# -----------------------------
class FriendCodeViewsTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="viewuser",
email="viewuser@example.com",
password="password123"
)
# Log in this user.
self.client.login(username="viewuser", password="password123")
# Create two friend codes.
self.friend_code1 = FriendCode.objects.create(
friend_code="7777-7777-7777-7777",
user=self.user,
in_game_name="ViewGameOne"
)
self.friend_code2 = FriendCode.objects.create(
friend_code="8888-8888-8888-8888",
user=self.user,
in_game_name="ViewGameTwo"
)
# By default, friend_code1 is the default.
def test_list_friend_codes_view(self):
"""The list view should display all friend codes with a correct default flag."""
url = reverse("list_friend_codes")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
friend_codes = response.context["friend_codes"]
self.assertEqual(friend_codes.count(), 2)
for fc in friend_codes:
if fc.pk == self.friend_code1.pk:
self.assertTrue(fc.is_default)
else:
self.assertFalse(fc.is_default)
def test_list_friend_codes_view_unauthenticated(self):
"""An unauthenticated user should be redirected from the friend codes list view."""
self.client.logout()
url = reverse("list_friend_codes")
response = self.client.get(url)
self.assertNotEqual(response.status_code, 200)
# Adjust the login URL as per your configuration.
self.assertIn("/accounts/login/", response.url)
def test_add_friend_code_view(self):
"""Test both GET and POST for adding a new friend code."""
url = reverse("add_friend_code")
# GET request.
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# POST request.
data = {"friend_code": "9999999999999999", "in_game_name": "ViewGameThree"}
response = self.client.post(url, data)
self.assertRedirects(response, reverse("list_friend_codes"))
self.assertTrue(
FriendCode.objects.filter(
user=self.user,
friend_code="9999-9999-9999-9999"
).exists()
)
# Ensure that adding a new friend code does not change the default.
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, self.friend_code1)
def test_add_friend_code_view_invalid_data(self):
"""Submitting invalid friend code data should not create a new record."""
url = reverse("add_friend_code")
data = {"friend_code": "invalidfriendcode", "in_game_name": "InvalidGame"}
response = self.client.post(url, data)
# Extract the form from the response's context. If response.context is a list, use its first element.
context = response.context[0] if isinstance(response.context, list) else response.context
form = context.get("form")
self.assertIsNotNone(form, "Form not found in response context")
self.assertFormError(form, "friend_code", "Friend code must be exactly 16 digits long.")
def test_edit_friend_code_view(self):
"""Test editing the in-game name of an existing friend code."""
url = reverse("edit_friend_code", kwargs={"pk": self.friend_code2.pk})
# GET request.
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# POST request.
new_data = {"in_game_name": "UpdatedGame"}
response = self.client.post(url, new_data)
self.assertEqual(response.status_code, 302)
self.friend_code2.refresh_from_db()
self.assertEqual(self.friend_code2.in_game_name, "UpdatedGame")
def test_edit_friend_code_view_wrong_user(self):
"""A user should not be able to edit a friend code that does not belong to them."""
other_user = get_user_model().objects.create_user(
username="otheruser",
email="other@example.com",
password="password1234"
)
friend_code_other = FriendCode.objects.create(
friend_code="0000-0000-0000-0000",
user=other_user,
in_game_name="OtherGame"
)
url = reverse("edit_friend_code", kwargs={"pk": friend_code_other.pk})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_edit_friend_code_view_invalid_data(self):
"""Invalid POST data for editing friend code should result in form errors."""
url = reverse("edit_friend_code", kwargs={"pk": self.friend_code2.pk})
new_data = {"in_game_name": ""} # in_game_name is required.
response = self.client.post(url, new_data)
context = response.context[0] if isinstance(response.context, list) else response.context
form = context.get("form")
self.assertIsNotNone(form, "Form not found in response context")
self.assertFormError(form, "in_game_name", "This field is required.")
def test_delete_friend_code_view_only_code(self):
"""
If the user has only one friend code, deletion should be disabled.
This test uses a new user with a single friend code.
"""
user_only = get_user_model().objects.create_user(
username="onlyuser",
email="onlyuser@example.com",
password="password123"
)
friend_code_only = FriendCode.objects.create(
friend_code="4444-4444-4444-4444",
user=user_only,
in_game_name="SoloGame"
)
self.client.logout()
self.client.login(username="onlyuser", password="password123")
url = reverse("delete_friend_code", kwargs={"pk": friend_code_only.pk})
# GET request: deletion should be disabled.
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertIn("disable_delete", response.context)
self.assertTrue(response.context["disable_delete"])
# POST request should not delete the friend code.
response = self.client.post(url, {})
self.assertRedirects(response, reverse("list_friend_codes"))
self.assertTrue(FriendCode.objects.filter(pk=friend_code_only.pk).exists())
def test_delete_friend_code_view_default_code(self):
"""Deleting the default friend code should be prevented."""
url = reverse("delete_friend_code", kwargs={"pk": self.friend_code1.pk})
response = self.client.post(url, {})
self.assertRedirects(response, reverse("list_friend_codes"))
self.assertTrue(FriendCode.objects.filter(pk=self.friend_code1.pk).exists())
def test_delete_friend_code_view_with_trade_offers(self):
"""
If a friend code is associated with trade offers, deletion should be blocked.
Instead of direct assignment, we patch the `exists` methods on the related managers.
"""
self.trade_offer = TradeOffer.objects.create(
initiated_by=self.friend_code2,
is_closed=False,
rarity_icon=RARITY_MAPPING[5],
rarity_level=5
)
url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk})
response = self.client.post(url, {})
self.assertRedirects(response, reverse("list_friend_codes"))
self.assertTrue(FriendCode.objects.filter(pk=self.friend_code2.pk).exists())
self.trade_offer.delete()
def test_change_default_friend_code_view(self):
"""Test that a POST to change the default friend code updates the user setting."""
url = reverse("change_default_friend_code", kwargs={"pk": self.friend_code2.pk})
response = self.client.post(url, {})
self.assertRedirects(response, reverse("list_friend_codes"))
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code.pk, self.friend_code2.pk)
def test_change_default_friend_code_view_invalid_friend_code(self):
"""Posting a non-existent friend code id should return a 404 error."""
url = reverse("change_default_friend_code", kwargs={"pk": 99999})
response = self.client.post(url, {})
self.assertEqual(response.status_code, 404)
def test_change_default_friend_code_view_not_owned(self):
"""A friend code that does not belong to the current user should result in a 404."""
other_user = get_user_model().objects.create_user(
username="otheruser2",
email="other2@example.com",
password="password789"
)
friend_code_other = FriendCode.objects.create(
friend_code="1111-1111-1111-1111",
user=other_user,
in_game_name="NotMine"
)
url = reverse("change_default_friend_code", kwargs={"pk": friend_code_other.pk})
response = self.client.post(url, {})
self.assertEqual(response.status_code, 404)
def test_settings_view(self):
"""Settings view should allow updating of user settings."""
url = reverse("settings")
# GET request.
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# POST request.
data = {"show_friend_code_on_link_previews": True}
response = self.client.post(url, data)
self.assertRedirects(response, reverse("settings"))
self.user.refresh_from_db()
self.assertTrue(self.user.show_friend_code_on_link_previews)
def test_profile_view(self):
"""Profile page should be accessible for authenticated users."""
url = reverse("profile")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_profile_view_unauthenticated(self):
"""Unauthenticated users should be redirected from the profile page."""
self.client.logout()
url = reverse("profile")
response = self.client.get(url)
self.assertNotEqual(response.status_code, 200)
def test_delete_friend_code_view_wrong_user(self):
"""A user should not be able to delete a friend code that does not belong to them."""
other_user = get_user_model().objects.create_user(
username="otherdeluser",
email="otherdel@example.com",
password="password321"
)
friend_code_other = FriendCode.objects.create(
friend_code="2222-2222-2222-2222",
user=other_user,
in_game_name="OtherDelete"
)
url = reverse("delete_friend_code", kwargs={"pk": friend_code_other.pk})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
# -----------------------------
# Template Tags Tests
# -----------------------------
class TemplateTagTests(TestCase):
def test_gravatar_hash(self):
"""Test that gravatar_hash returns the correct SHA256 hash."""
email = "Test@Example.com"
expected = hashlib.sha256(email.strip().lower().encode("utf-8")).hexdigest()
result = gravatar.gravatar_hash(email)
self.assertEqual(result, expected)
def test_gravatar_url(self):
"""Ensure gravatar_url returns a URL with the proper parameters."""
email = "user@example.com"
size = 100
url = gravatar.gravatar_url(email, size)
self.assertIn("s=100", url)
self.assertIn("https://www.gravatar.com/avatar/", url)
def test_gravatar_profile_url_with_none(self):
"""Test gravatar_profile_url returns the generic profile URL if no email is provided."""
url = gravatar.gravatar_profile_url()
self.assertEqual(url, "https://www.gravatar.com/profile")
def test_gravatar_filter(self):
"""Test that the gravatar filter returns an HTML image tag with expected attributes."""
email = "user@example.com"
size = 50
result = gravatar.gravatar(email, size)
self.assertIn('img src="', result)
self.assertIn(f'width="{size}"', result)
@patch("pkmntrade_club.accounts.templatetags.gravatar.requests.get")
def test_gravatar_profile_data_success(self, mock_get):
"""Test that gravatar_profile_data returns the first entry when JSON response is valid."""
dummy_entry = {"name": "Test User"}
mock_response = MagicMock()
mock_response.json.return_value = {"entry": [dummy_entry]}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
data = gravatar.gravatar_profile_data("user@example.com")
self.assertEqual(data, dummy_entry)
@patch("pkmntrade_club.accounts.templatetags.gravatar.requests.get")
def test_gravatar_profile_data_failure(self, mock_get):
"""
If requests.get fails or the JSON is not valid,
gravatar_profile_data should return an empty dictionary.
"""
mock_get.side_effect = requests.RequestException("Request failed")
data = gravatar.gravatar_profile_data("user@example.com")
self.assertEqual(data, {})
def test_gravatar_no_hover(self):
"""Test that gravatar_no_hover returns an image tag with the additional 'ignore' class."""
email = "hover@example.com"
result = gravatar.gravatar_no_hover(email, 30)
self.assertIn('class="ignore"', result)
def test_gravatar_filter_with_empty_string(self):
"""Even if an empty email is passed, the gravatar filter should return an image tag."""
result = gravatar.gravatar("", 40)
self.assertIn('img src="', result)

View file

@ -0,0 +1,16 @@
from django.urls import path
from .views import (
AddFriendCodeView,
DeleteFriendCodeView,
ChangeDefaultFriendCodeView,
EditFriendCodeView,
DashboardView,
)
urlpatterns = [
path("friend-codes/add/", AddFriendCodeView.as_view(), name="add_friend_code"),
path("friend-codes/edit/<int:pk>/", EditFriendCodeView.as_view(), name="edit_friend_code"),
path("friend-codes/delete/<int:pk>/", DeleteFriendCodeView.as_view(), name="delete_friend_code"),
path("friend-codes/default/<int:pk>/", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"),
path("dashboard/", DashboardView.as_view(), name="dashboard"),
]

View file

@ -0,0 +1,349 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.shortcuts import redirect, get_object_or_404, render
from django.views.generic import ListView, CreateView, DeleteView, View, TemplateView, UpdateView
from pkmntrade_club.accounts.models import FriendCode, CustomUser
from pkmntrade_club.accounts.forms import FriendCodeForm, UserSettingsForm
from django.db.models import Case, When, Value, BooleanField
from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance
from django.core.exceptions import PermissionDenied
from pkmntrade_club.trades.mixins import FriendCodeRequiredMixin
from pkmntrade_club.common.mixins import ReusablePaginationMixin
from django.urls import reverse
from django.utils.http import urlencode
class AddFriendCodeView(LoginRequiredMixin, CreateView):
"""
Add a new friend code for the current user. If the user does not yet have a default,
the newly added code will automatically become the default.
"""
model = FriendCode
form_class = FriendCodeForm
template_name = "friend_codes/add_friend_code.html"
def get_success_url(self):
base_url = reverse("dashboard")
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
def form_valid(self, form):
form.instance.user = self.request.user
messages.success(self.request, "Friend code added successfully.")
return super().form_valid(form)
class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
"""
Remove an existing friend code.
Prevent deletion if the friend code is bound to any trade offers.
Also, prevent deletion if the friend code is either the only one or
is set as the default friend code.
"""
model = FriendCode
template_name = "friend_codes/confirm_delete_friend_code.html"
context_object_name = "friend_code"
def get_success_url(self):
base_url = reverse("dashboard")
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
def get_queryset(self):
# Only allow deletion of friend codes owned by the current user.
return FriendCode.objects.filter(user=self.request.user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
friend_code = self.get_object()
user = self.request.user
# Determine if the deletion should be disabled.
disable_delete = False
error_message = None
if user.friend_codes.count() == 1:
disable_delete = True
error_message = "Cannot delete your only friend code."
elif user.default_friend_code == friend_code:
disable_delete = True
error_message = (
"Cannot delete your default friend code. "
"Please set a different default first."
)
context["disable_delete"] = disable_delete
context["error_message"] = error_message
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
user = self.object.user
if user.friend_codes.count() == 1:
messages.error(request, "Cannot remove your only friend code.")
return redirect(self.get_success_url())
if user.default_friend_code == self.object:
messages.error(
request,
"Cannot delete your default friend code. Please set a different default first."
)
return redirect(self.get_success_url())
trade_offer_exists = TradeOffer.objects.filter(initiated_by_id=self.object.pk).exists()
trade_acceptance_exists = TradeAcceptance.objects.filter(accepted_by_id=self.object.pk).exists()
if trade_offer_exists or trade_acceptance_exists:
messages.error(
request,
"Cannot remove this friend code because there are existing trade offers associated with it."
)
return redirect(self.get_success_url())
self.object.delete()
messages.success(request, "Friend code removed successfully.")
return redirect(self.get_success_url())
class ChangeDefaultFriendCodeView(LoginRequiredMixin, View):
"""
Change the default friend code for the current user.
"""
def post(self, request, *args, **kwargs):
friend_code_id = kwargs.get("pk")
friend_code = get_object_or_404(FriendCode, pk=friend_code_id, user=request.user)
request.user.set_default_friend_code(friend_code)
messages.success(request, "Default friend code updated successfully.")
base_url = reverse("dashboard")
query_string = urlencode({"tab": "friend_codes"})
return redirect(f"{base_url}?{query_string}")
class EditFriendCodeView(LoginRequiredMixin, UpdateView):
"""
Edit the in-game name for a friend code.
The friend code itself is displayed as plain text.
Also includes "Set Default" and "Delete" buttons in the template.
"""
model = FriendCode
# Only the in_game_name field is editable
fields = ['in_game_name']
template_name = "friend_codes/edit_friend_code.html"
context_object_name = "friend_code"
def get_success_url(self):
base_url = reverse("dashboard")
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
def get_queryset(self):
# Ensure the user can only edit their own friend codes
return FriendCode.objects.filter(user=self.request.user)
def form_valid(self, form):
messages.success(self.request, "Friend code updated successfully.")
return super().form_valid(form)
class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginationMixin, TemplateView):
template_name = "account/dashboard.html"
def post(self, request, *args, **kwargs):
if 'update_settings' in request.POST:
from pkmntrade_club.accounts.forms import UserSettingsForm
form = UserSettingsForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
messages.success(request, "Settings updated successfully.")
else:
messages.error(request, "Please correct the errors below.")
return self.get(request, *args, **kwargs)
def get_selected_friend_code(self):
friend_codes = self.request.user.friend_codes.all()
friend_code_param = self.request.GET.get("friend_code")
if friend_code_param:
try:
selected_friend_code = friend_codes.get(pk=friend_code_param)
except friend_codes.model.DoesNotExist:
selected_friend_code = self.request.user.default_friend_code or friend_codes.first()
else:
selected_friend_code = self.request.user.default_friend_code or friend_codes.first()
if not selected_friend_code:
raise PermissionDenied("You do not have an active friend code associated with your account.")
return selected_friend_code
def get_dashboard_offers_paginated(self, page_param):
selected_friend_code = self.get_selected_friend_code()
queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=False)
object_list, pagination_context = self.paginate_data(queryset, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context}
def get_involved_acceptances(self, selected_friend_code):
from django.db.models import Q
terminal_states = [
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
]
involved = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code)
).order_by("-updated_at")
return involved.exclude(state__in=terminal_states)
def get_trade_acceptances_waiting_paginated(self, page_param):
selected_friend_code = self.get_selected_friend_code()
involved = self.get_involved_acceptances(selected_friend_code)
from django.db.models import Q
waiting = involved.filter(
Q(trade_offer__initiated_by=selected_friend_code, state__in=[
TradeAcceptance.AcceptanceState.ACCEPTED,
TradeAcceptance.AcceptanceState.RECEIVED,
]) |
Q(accepted_by=selected_friend_code, state__in=[TradeAcceptance.AcceptanceState.SENT])
)
object_list, pagination_context = self.paginate_data(waiting, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context}
def get_other_party_trade_acceptances_paginated(self, page_param):
selected_friend_code = self.get_selected_friend_code()
involved = self.get_involved_acceptances(selected_friend_code)
from django.db.models import Q
waiting = involved.filter(
Q(trade_offer__initiated_by=selected_friend_code, state__in=[
TradeAcceptance.AcceptanceState.ACCEPTED,
TradeAcceptance.AcceptanceState.RECEIVED,
]) |
Q(accepted_by=selected_friend_code, state__in=[TradeAcceptance.AcceptanceState.SENT])
)
others = involved.exclude(pk__in=waiting.values("pk"))
object_list, pagination_context = self.paginate_data(others, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context}
def get_closed_offers_paginated(self, page_param):
selected_friend_code = self.get_selected_friend_code()
queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=True)
object_list, pagination_context = self.paginate_data(queryset, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context}
def get_closed_acceptances_paginated(self, page_param):
from django.db.models import Q
selected_friend_code = self.get_selected_friend_code()
terminal_success_states = [
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
]
acceptance_qs = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code),
state__in=terminal_success_states
).order_by("-updated_at")
object_list, pagination_context = self.paginate_data(acceptance_qs, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context}
def get_rejected_by_me_paginated(self, page_param):
from django.db.models import Q
selected_friend_code = self.get_selected_friend_code()
rejection = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR) |
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR)
).order_by("-updated_at")
object_list, pagination_context = self.paginate_data(rejection, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context}
def get_rejected_by_them_paginated(self, page_param):
from django.db.models import Q
selected_friend_code = self.get_selected_friend_code()
rejection = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR) |
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR)
).order_by("-updated_at")
object_list, pagination_context = self.paginate_data(rejection, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
request = self.request
selected_friend_code = self.get_selected_friend_code()
context["selected_friend_code"] = selected_friend_code
# Get the default friend code's primary key if it exists
default_pk = getattr(request.user.default_friend_code, "pk", None)
# Annotate friend codes with is_default flag
context["friend_codes"] = request.user.friend_codes.all().annotate(
is_default=Case(
When(pk=default_pk, then=Value(True)),
default=Value(False),
output_field=BooleanField()
)
)
ajax_section = request.GET.get("ajax_section")
if ajax_section == "dashboard_offers":
offers_page = request.GET.get("page", 1)
else:
offers_page = request.GET.get("offers_page", 1)
if ajax_section == "waiting_acceptances":
waiting_page = request.GET.get("page", 1)
else:
waiting_page = request.GET.get("waiting_page", 1)
if ajax_section == "other_party_acceptances":
other_page = request.GET.get("page", 1)
else:
other_page = request.GET.get("other_page", 1)
if ajax_section == "closed_offers":
closed_offers_page = request.GET.get("page", 1)
else:
closed_offers_page = request.GET.get("closed_offers_page", 1)
if ajax_section == "closed_acceptances":
closed_acceptances_page = request.GET.get("page", 1)
else:
closed_acceptances_page = request.GET.get("closed_acceptances_page", 1)
if ajax_section == "rejected_by_me":
rejected_by_me_page = request.GET.get("page", 1)
else:
rejected_by_me_page = request.GET.get("rejected_by_me_page", 1)
if ajax_section == "rejected_by_them":
rejected_by_them_page = request.GET.get("page", 1)
else:
rejected_by_them_page = request.GET.get("rejected_by_them_page", 1)
context["dashboard_offers_paginated"] = self.get_dashboard_offers_paginated(offers_page)
context["trade_acceptances_waiting_paginated"] = self.get_trade_acceptances_waiting_paginated(waiting_page)
context["other_party_trade_acceptances_paginated"] = self.get_other_party_trade_acceptances_paginated(other_page)
context["closed_offers_paginated"] = self.get_closed_offers_paginated(closed_offers_page)
context["closed_acceptances_paginated"] = self.get_closed_acceptances_paginated(closed_acceptances_page)
context["rejected_by_me_paginated"] = self.get_rejected_by_me_paginated(rejected_by_me_page)
context["rejected_by_them_paginated"] = self.get_rejected_by_them_paginated(rejected_by_them_page)
from pkmntrade_club.accounts.forms import UserSettingsForm
context["settings_form"] = UserSettingsForm(instance=request.user)
context["active_tab"] = request.GET.get("tab", "dash")
return context
# Handle AJAX requests to return only the trade offer list fragment
def get(self, request, *args, **kwargs):
context = self.get_context_data(**kwargs)
ajax_section = request.GET.get("ajax_section")
if request.headers.get("X-Requested-With") == "XMLHttpRequest" and ajax_section:
if ajax_section == "dashboard_offers":
fragment_context = context.get("dashboard_offers_paginated", {})
elif ajax_section == "waiting_acceptances":
fragment_context = context.get("trade_acceptances_waiting_paginated", {})
elif ajax_section == "other_party_acceptances":
fragment_context = context.get("other_party_trade_acceptances_paginated", {})
elif ajax_section == "closed_offers":
fragment_context = context.get("closed_offers_paginated", {})
elif ajax_section == "closed_acceptances":
fragment_context = context.get("closed_acceptances_paginated", {})
elif ajax_section == "rejected_by_me":
fragment_context = context.get("rejected_by_me_paginated", {})
elif ajax_section == "rejected_by_them":
fragment_context = context.get("rejected_by_them_paginated", {})
else:
fragment_context = {}
if fragment_context:
return render(request, "trades/_trade_offer_list.html", {
"offers": fragment_context.get("object_list", []),
"page_obj": fragment_context.get("page_obj")
})
return super().get(request, *args, **kwargs)

View file

View file

@ -0,0 +1,7 @@
from django.contrib import admin
from .models import Deck, Card, DeckNameTranslation, CardNameTranslation
admin.site.register(Deck)
admin.site.register(Card)
admin.site.register(DeckNameTranslation)
admin.site.register(CardNameTranslation)

View file

@ -0,0 +1,8 @@
from django.apps import AppConfig
class CardsConfig(AppConfig):
name = "pkmntrade_club.cards"
def ready(self):
import pkmntrade_club.cards.signals

View file

@ -0,0 +1,71 @@
# Generated by Django 5.1 on 2025-05-10 01:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Card',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('cardset', models.CharField(max_length=32)),
('cardnum', models.IntegerField()),
('style', models.CharField(max_length=128)),
('rarity_icon', models.CharField(max_length=12)),
('rarity_level', models.IntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='Deck',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('hex_color', models.CharField(max_length=9)),
('cardset', models.CharField(max_length=8)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='CardNameTranslation',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('language', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.card')),
],
),
migrations.AddField(
model_name='card',
name='decks',
field=models.ManyToManyField(to='cards.deck'),
),
migrations.CreateModel(
name='DeckNameTranslation',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('language', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deck', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.deck')),
],
),
migrations.AlterUniqueTogether(
name='card',
unique_together={('cardset', 'cardnum')},
),
]

View file

@ -0,0 +1 @@
0001_initial

View file

@ -0,0 +1,42 @@
from math import ceil
class ReusablePaginationMixin:
"""
A mixin that encapsulates reusable pagination logic.
Use in Django ListViews to generate custom pagination context.
"""
per_page = 10 # Default; can be overridden in your view.
def paginate_data(self, data_list, page_number):
"""
Paginate a list of items.
Arguments:
data_list (list): The list of items to paginate.
page_number (int): Current page number.
Returns:
tuple: (paginated_items, pagination_context)
"""
total_items = len(data_list)
num_pages = ceil(total_items / self.per_page) if self.per_page > 0 else 1
# Ensure page_number is within valid bounds.
if page_number < 1:
page_number = 1
elif page_number > num_pages:
page_number = num_pages
start = (page_number - 1) * self.per_page
end = page_number * self.per_page
items = data_list[start:end]
pagination_context = {
"number": page_number,
"has_previous": page_number > 1,
"has_next": page_number < num_pages,
"previous_page": page_number - 1 if page_number > 1 else 1,
"next_page": page_number + 1 if page_number < num_pages else num_pages,
"paginator": {"num_pages": num_pages},
}
return items, pagination_context

View file

@ -0,0 +1,53 @@
from django.db import models
from django.db.models import Prefetch
from django.apps import apps
class DeckNameTranslation(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
deck = models.ForeignKey("Deck", on_delete=models.PROTECT, related_name='name_translations')
language = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class CardNameTranslation(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
card = models.ForeignKey("Card", on_delete=models.PROTECT, related_name='name_translations')
language = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class Deck(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
hex_color = models.CharField(max_length=9)
cardset = models.CharField(max_length=8)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class Card(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
decks = models.ManyToManyField("Deck")
cardset = models.CharField(max_length=32)
cardnum = models.IntegerField()
style = models.CharField(max_length=128)
rarity_icon = models.CharField(max_length=12)
rarity_level = models.IntegerField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('cardset', 'cardnum')
def __str__(self):
return f"{self.name} ({self.cardset} #{self.cardnum})"

View file

@ -0,0 +1,51 @@
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import Card
def color_is_dark(bg_color):
"""
Determine if a given hexadecimal color is dark.
This function accepts a 6-digit hex color string (with or without a leading '#').
It calculates the brightness using the formula:
brightness = (0.299 * red) + (0.587 * green) + (0.114 * blue)
A brightness value less than or equal to 186 indicates that the color is dark.
Args:
bg_color (str): A 6-digit hex color string (e.g. "#FFFFFF" or "FFFFFF").
Returns:
bool: True if the color is dark (brightness <= 186), False otherwise.
"""
# Remove the leading '#' if it exists.
color = bg_color[1:7] if bg_color[0] == '#' else bg_color
# Convert the hex color components to integers.
r = int(color[0:2], 16)
g = int(color[2:4], 16)
b = int(color[4:6], 16)
# Compute brightness based on weighted RGB values.
brightness = (r * 0.299) + (g * 0.587) + (b * 0.114)
return brightness <= 200
@receiver(m2m_changed, sender=Card.decks.through)
def update_card_style(sender, instance, action, **kwargs):
if action == "post_add":
decks = instance.decks.all()
num_decks = decks.count()
if num_decks == 1:
instance.style = "background-color: " + decks.first().hex_color + ";"
elif num_decks >= 2:
hex_colors = [deck.hex_color for deck in decks]
instance.style = f"background: linear-gradient(to right, {', '.join(hex_colors)});"
else:
instance.style = "background: linear-gradient(to right, #AAAAAA, #AAAAAA, #AAAAAA);"
if not color_is_dark(decks.first().hex_color):
instance.style += "color: var(--color-gray-700); text-shadow: 0 0 0 var(--color-gray-700);"
else:
instance.style += "text-shadow: 0 0 0 #fff;"
instance.save(update_fields=["style"])

View file

@ -0,0 +1,46 @@
from django import template
from django.conf import settings
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from django.urls import reverse_lazy
register = template.Library()
@register.inclusion_tag("templatetags/card_badge.html", takes_context=True)
def card_badge(context, card, quantity=None, expanded=False):
"""
Renders a card badge.
"""
url = reverse_lazy('cards:card_detail', args=[card.pk])
tag_context = {
'quantity': quantity,
'style': card.style,
'name': card.name,
'rarity': card.rarity_icon,
'cardset': card.cardset,
'expanded': expanded,
'cache_key': f'card_badge_{card.pk}_{quantity}_{expanded}',
'url': url,
}
context.update(tag_context)
return context
@register.filter
def card_badge_inline(card, quantity=None):
"""
Renders an inline card badge by directly rendering the template.
"""
url = reverse_lazy('cards:card_detail', args=[card.pk])
tag_context = {
'quantity': quantity,
'style': card.style,
'name': card.name,
'rarity': card.rarity_icon,
'cardset': card.cardset,
'expanded': True,
'cache_key': f'card_badge_{card.pk}_{quantity}_{True}',
'CACHE_TIMEOUT': settings.CACHE_TIMEOUT,
'url': url,
}
html = render_to_string("templatetags/card_badge.html", tag_context)
return mark_safe(html)

View file

@ -0,0 +1,72 @@
import uuid
from django import template
from pkmntrade_club.cards.models import Card
from django.db.models.query import QuerySet
import json
import hashlib
import logging
register = template.Library()
@register.filter
def get_item(dictionary, key):
"""Allows accessing dictionary items using a variable key in templates."""
return dictionary.get(key)
@register.simple_tag
def fetch_all_cards():
"""Simple tag to fetch all Card objects."""
return Card.objects.order_by('pk').all()
@register.inclusion_tag('templatetags/card_multiselect.html', takes_context=True)
def card_multiselect(context, field_name, label, placeholder, cards=None, selected_values=None):
"""
Prepares context for rendering a card multiselect input.
Database querying and rendering are handled within the template's cache block.
"""
if selected_values is None:
selected_values = []
selected_cards = {}
for val in selected_values:
parts = str(val).split(':')
if len(parts) >= 1 and parts[0]:
card_id = parts[0]
quantity = parts[1] if len(parts) > 1 else 1
selected_cards[str(card_id)] = quantity
effective_field_name = field_name if field_name is not None else 'card_multiselect'
effective_label = label if label is not None else 'Card'
effective_placeholder = placeholder if placeholder is not None else 'Select Cards'
selected_cards_key_part = json.dumps(selected_cards, sort_keys=True)
has_passed_cards = isinstance(cards, QuerySet)
if has_passed_cards:
try:
query_string = str(cards.query)
passed_cards_identifier = hashlib.sha256(query_string.encode('utf-8')).hexdigest()
except Exception as e:
logging.warning(f"Could not generate query hash for card_multiselect. Error: {e}")
passed_cards_identifier = 'specific_qs_fallback_' + str(uuid.uuid4())
else:
passed_cards_identifier = 'all_cards'
# Define the variables specific to this tag
tag_specific_context = {
'field_name': effective_field_name,
'field_id': effective_field_name,
'label': effective_label,
'placeholder': effective_placeholder,
'passed_cards': cards if has_passed_cards else None,
'has_passed_cards': has_passed_cards,
'selected_cards': selected_cards,
'selected_cards_key_part': selected_cards_key_part,
'passed_cards_identifier': passed_cards_identifier,
}
# Update the original context with the tag-specific variables
# This preserves CACHE_TIMEOUT and other parent context variables
context.update(tag_specific_context)
return context # Return the MODIFIED original context

View file

@ -0,0 +1,288 @@
from django.test import TestCase, Client
from django.template import Template, Context
from datetime import timedelta
from django.urls import reverse
from django.utils import timezone
from pkmntrade_club.accounts.models import CustomUser, FriendCode
from pkmntrade_club.cards.models import Card, Deck, DeckNameTranslation, CardNameTranslation
from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard
from pkmntrade_club.cards.templatetags import card_badge, card_multiselect
from tests.utils.rarity import RARITY_MAPPING
class CardsModelsTests(TestCase):
def setUp(self):
self.deck = Deck.objects.create(
name="Test Deck", hex_color="#FFFFFF", cardset="A"
)
self.card = Card.objects.create(
name="Test Card",
cardset="A",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[1],
rarity_level=1
)
# Establish many-to-many relationship.
self.card.decks.add(self.deck)
def test_card_str(self):
expected = f"{self.card.name} ({self.card.cardset} #{self.card.cardnum})"
self.assertEqual(str(self.card), expected)
def test_deck_str(self):
self.assertEqual(str(self.deck), self.deck.name)
def test_deck_name_translation_str(self):
deck_translation = DeckNameTranslation.objects.create(
name="Deck Translated", deck=self.deck, language="en"
)
self.assertEqual(str(deck_translation), "Deck Translated")
def test_card_name_translation_str(self):
card_translation = CardNameTranslation.objects.create(
name="Card Translated", card=self.card, language="en"
)
self.assertEqual(str(card_translation), "Card Translated")
class CardTemplatetagsTests(TestCase):
def setUp(self):
# Create a dummy card to use in template tag tests.
self.card = Card.objects.create(
name="Template Test Card",
cardset="B",
cardnum=2,
style="background: green;",
rarity_icon="",
rarity_level=2
)
def test_card_badge_inclusion_tag(self):
"""Test the card_badge inclusion tag renders correctly."""
template_str = '{% load card_badge %}{% card_badge card quantity=3 %}'
t = Template(template_str)
c = Context({"card": self.card})
rendered = t.render(c)
# Check that the rendered HTML contains the card name, quantity, and rarity.
self.assertIn(self.card.name, rendered)
self.assertIn("3", rendered)
self.assertIn(self.card.rarity_icon, rendered)
def test_card_badge_inline_filter(self):
"""Test the card_badge_inline filter returns safe HTML with correct data."""
template_str = '{% load card_badge %}{{ card|card_badge_inline:5 }}'
t = Template(template_str)
c = Context({"card": self.card})
rendered = t.render(c)
self.assertIn(self.card.name, rendered)
self.assertIn("5", rendered)
self.assertIn(self.card.rarity_icon, rendered)
def test_card_multiselect_tag_no_selected_values(self):
"""Test card_multiselect tag with no selected values."""
context = card_multiselect.card_multiselect(
field_name="cards",
label="Select Cards",
placeholder="Choose a card",
cards=[self.card],
selected_values=None,
)
self.assertEqual(context["field_name"], "cards")
self.assertEqual(context["label"], "Select Cards")
self.assertEqual(context["placeholder"], "Choose a card")
# When no cards are preselected, each card should have default attributes.
for card in context["cards"]:
self.assertFalse(getattr(card, "selected", False))
self.assertEqual(getattr(card, "selected_quantity", 1), 1)
self.assertEqual(context["selected_values"], [])
def test_card_multiselect_tag_with_selected_values(self):
"""Test card_multiselect tag with preselected values (testing both with and without explicit quantity)."""
# Create a second card.
card2 = Card.objects.create(
name="Another Card",
cardset="B",
cardnum=3,
style="background: blue;",
rarity_icon="",
rarity_level=2,
)
selected_values = [f"{self.card.pk}:4", f"{card2.pk}"]
context = card_multiselect.card_multiselect(
field_name="cards",
label="Select Cards",
placeholder="Choose a card",
cards=[self.card, card2],
selected_values=selected_values,
)
# Verify that self.card is marked as selected with quantity "4" and card2 with default quantity 1.
for card in context["cards"]:
if card.pk == self.card.pk:
self.assertTrue(getattr(card, "selected", False))
self.assertEqual(getattr(card, "selected_quantity", 1), "4")
elif card.pk == card2.pk:
self.assertTrue(getattr(card, "selected", False))
self.assertEqual(getattr(card, "selected_quantity", 1), 1)
else:
self.fail("Unexpected card in the multiselect context.")
self.assertCountEqual(
context["selected_values"], [str(self.card.pk), str(card2.pk)]
)
def test_card_multiselect_default_cards_when_none_provided(self):
"""Test that card_multiselect defaults to Card.objects.all() when no cards are provided."""
# Capture all cards from the database.
default_cards = list(Card.objects.all())
context = card_multiselect.card_multiselect(
field_name="cards",
label="Select Cards",
placeholder="Choose a card",
cards=None,
selected_values=[],
)
# Verify that the context's cards match those in the database.
self.assertEqual(list(context["cards"]), default_cards)
class CardsViewsTests(TestCase):
def setUp(self):
self.client = Client()
# Create a test user and friend code for trade offers.
self.user = CustomUser.objects.create_user(
username="testuser", password="secret", email="test@example.com"
)
self.friendcode = FriendCode.objects.create(
user=self.user, friend_code="1234-5678-9012", in_game_name="TestPlayer"
)
# Create a test card.
self.card = Card.objects.create(
name="Test Card",
cardset="A",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[1],
rarity_level=1
)
def test_card_detail_view_context(self):
"""Test that the card detail view includes correct trade offer counts in context."""
# Create a trade offer where the card appears as a "have" card.
trade_offer_have = TradeOffer.objects.create(initiated_by=self.friendcode)
TradeOfferHaveCard.objects.create(
trade_offer=trade_offer_have, card=self.card, quantity=2
)
# Create a trade offer where the card appears as a "want" card.
trade_offer_want = TradeOffer.objects.create(initiated_by=self.friendcode)
TradeOfferWantCard.objects.create(
trade_offer=trade_offer_want, card=self.card, quantity=3
)
url = reverse("cards:card_detail", kwargs={"pk": self.card.pk})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# Verify that the card instance is in context.
self.assertEqual(response.context["card"], self.card)
# Verify that the counts are correctly computed.
self.assertEqual(response.context.get("trade_offer_have_count"), 1)
self.assertEqual(response.context.get("trade_offer_want_count"), 1)
def test_card_detail_view_404(self):
"""Test that the card detail view returns a 404 for a non-existent card."""
url = reverse("cards:card_detail", kwargs={"pk": 99999})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def create_trade_offer_for_have(self, updated_delta_minutes=0):
"""
Helper method to create a trade offer for the 'have' side with a custom updated_at.
"""
offer = TradeOffer.objects.create(initiated_by=self.friendcode)
TradeOfferHaveCard.objects.create(
trade_offer=offer, card=self.card, quantity=1
)
# Adjust updated_at so that ordering can be tested.
new_time = timezone.now() + timedelta(minutes=updated_delta_minutes)
TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time)
offer.refresh_from_db()
return offer
def create_trade_offer_for_want(self, updated_delta_minutes=0):
"""
Helper method to create a trade offer for the 'want' side with a custom updated_at.
"""
offer = TradeOffer.objects.create(initiated_by=self.friendcode)
TradeOfferWantCard.objects.create(
trade_offer=offer, card=self.card, quantity=1
)
new_time = timezone.now() + timedelta(minutes=updated_delta_minutes)
TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time)
offer.refresh_from_db()
return offer
def test_trade_offer_have_list_view_pagination_and_ordering(self):
"""Test the have list view for correct pagination and ordering."""
# Create three trade offers with distinct updated_at times.
offer1 = self.create_trade_offer_for_have(updated_delta_minutes=1)
offer2 = self.create_trade_offer_for_have(updated_delta_minutes=2)
offer3 = self.create_trade_offer_for_have(updated_delta_minutes=3)
url = reverse("cards:card_trade_offer_have_list", kwargs={"pk": self.card.pk})
# Test default ordering ("newest" which orders descending by updated_at).
response = self.client.get(url, {"order": "newest"})
self.assertEqual(response.status_code, 200)
trade_offers = response.context.get("trade_offers")
self.assertEqual(response.context.get("side"), "have")
# With paginate_by=2, the first page should have 2 offers.
self.assertEqual(len(trade_offers), 2)
# The first offer should be the newest (offer3).
self.assertEqual(trade_offers[0].pk, offer3.pk)
self.assertEqual(trade_offers[1].pk, offer2.pk)
# Test pagination: second page should contain the remaining offer.
response_page2 = self.client.get(url, {"order": "newest", "page": 2})
self.assertEqual(response_page2.status_code, 200)
trade_offers_page2 = response_page2.context.get("trade_offers")
self.assertEqual(len(trade_offers_page2), 1)
self.assertEqual(trade_offers_page2[0].pk, offer1.pk)
# Test "oldest" ordering (ascending by updated_at).
response_oldest = self.client.get(url, {"order": "oldest"})
self.assertEqual(response_oldest.status_code, 200)
trade_offers_oldest = response_oldest.context.get("trade_offers")
self.assertEqual(len(trade_offers_oldest), 2)
self.assertEqual(trade_offers_oldest[0].pk, offer1.pk)
self.assertEqual(trade_offers_oldest[1].pk, offer2.pk)
def test_trade_offer_want_list_view_pagination_and_ordering(self):
"""Test the want list view for correct pagination and ordering."""
offer1 = self.create_trade_offer_for_want(updated_delta_minutes=1)
offer2 = self.create_trade_offer_for_want(updated_delta_minutes=2)
offer3 = self.create_trade_offer_for_want(updated_delta_minutes=3)
url = reverse("cards:card_trade_offer_want_list", kwargs={"pk": self.card.pk})
# Test order with "newest" first.
response = self.client.get(url, {"order": "newest"})
self.assertEqual(response.status_code, 200)
trade_offers = response.context.get("trade_offers")
self.assertEqual(response.context.get("side"), "want")
self.assertEqual(len(trade_offers), 2)
self.assertEqual(trade_offers[0].pk, offer3.pk)
self.assertEqual(trade_offers[1].pk, offer2.pk)
# Test pagination boundary on page 2.
response_page2 = self.client.get(url, {"order": "newest", "page": 2})
self.assertEqual(response_page2.status_code, 200)
trade_offers_page2 = response_page2.context.get("trade_offers")
self.assertEqual(len(trade_offers_page2), 1)
self.assertEqual(trade_offers_page2[0].pk, offer1.pk)
# Test ordering parameter for "oldest" ordering.
response_oldest = self.client.get(url, {"order": "oldest"})
self.assertEqual(response_oldest.status_code, 200)
trade_offers_oldest = response_oldest.context.get("trade_offers")
self.assertEqual(len(trade_offers_oldest), 2)
self.assertEqual(trade_offers_oldest[0].pk, offer1.pk)
self.assertEqual(trade_offers_oldest[1].pk, offer2.pk)

View file

@ -0,0 +1,16 @@
from django.urls import path
from .views import (
CardDetailView,
TradeOfferHaveCardListView,
TradeOfferWantCardListView,
CardListView,
)
app_name = "cards"
urlpatterns = [
path('', CardListView.as_view(), name='card_list'),
path('<int:pk>/', CardDetailView.as_view(), name='card_detail'),
path('<int:pk>/trade-offers-have/', TradeOfferHaveCardListView.as_view(), name='card_trade_offer_have_list'),
path('<int:pk>/trade-offers-want/', TradeOfferWantCardListView.as_view(), name='card_trade_offer_want_list'),
]

View file

@ -0,0 +1,148 @@
from django.views.generic import TemplateView
from django.urls import reverse_lazy
from django.views.generic import UpdateView, DeleteView, CreateView, ListView, DetailView
from pkmntrade_club.cards.models import Card
from pkmntrade_club.trades.models import TradeOffer
from pkmntrade_club.common.mixins import ReusablePaginationMixin
from django.views import View
from django.shortcuts import get_object_or_404, render
class CardDetailView(DetailView):
model = Card
template_name = "cards/card_detail.html"
context_object_name = "card"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
card = self.get_object()
# Count of trade offers where the card appears as a "have" in a trade.
context['trade_offer_have_count'] = TradeOffer.objects.filter(
trade_offer_have_cards__card=card
).distinct().count()
# Count of trade offers where the card appears as a "want" in a trade.
context['trade_offer_want_count'] = TradeOffer.objects.filter(
trade_offer_want_cards__card=card
).distinct().count()
return context
class TradeOfferHaveCardListView(ReusablePaginationMixin, View):
def get(self, request, pk):
card = get_object_or_404(Card, pk=pk)
order = request.GET.get("order", "newest")
page_number = self.get_page_number()
offers = TradeOffer.objects.filter(trade_offer_have_cards__card=card).distinct()
if order == "oldest":
offers = offers.order_by("created_at")
else:
offers = offers.order_by("-created_at")
self.per_page = 12
offers_page, page_obj = self.paginate_data(offers, page_number)
context = {
"offers": offers_page,
"page_obj": page_obj,
}
# Render the partial template to be injected via AJAX
return render(request, "trades/_trade_offer_list.html", context)
class TradeOfferWantCardListView(ReusablePaginationMixin, View):
def get(self, request, pk):
card = get_object_or_404(Card, pk=pk)
order = request.GET.get("order", "newest")
page_number = self.get_page_number()
offers = TradeOffer.objects.filter(trade_offer_want_cards__card=card).distinct()
if order == "oldest":
offers = offers.order_by("created_at")
else:
offers = offers.order_by("-created_at")
self.per_page = 12
offers_page, page_obj = self.paginate_data(offers, page_number)
context = {
"offers": offers_page,
"page_obj": page_obj,
}
# Render the partial template containing the new pagination controls
return render(request, "trades/_trade_offer_list.html", context)
class CardListView(ReusablePaginationMixin, ListView):
model = Card
# Removed built-in pagination; using custom mixin instead
context_object_name = "cards"
def get_template_names(self):
if self.request.headers.get("x-requested-with") == "XMLHttpRequest":
return ["cards/_card_list.html"]
return ["cards/card_list.html"]
def get_ordering(self):
order = self.request.GET.get("order", "absolute")
if order == "alphabetical":
return "name"
elif order == "rarity":
return "-rarity_level"
else: # absolute ordering
return "id"
def get_queryset(self):
qs = super().get_queryset()
ordering = self.get_ordering()
qs = qs.order_by(ordering)
return qs.prefetch_related("decks").distinct()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
order = self.request.GET.get("order", "absolute")
group_by = self.request.GET.get("group_by")
context["order"] = order
context["group_by"] = group_by
if group_by in ("deck", "cardset", "rarity"):
full_qs = self.get_queryset()
all_cards = list(full_qs)
flat_cards = []
if group_by == "deck":
for card in all_cards:
for deck in card.decks.all():
flat_cards.append({"group": deck.name, "card": card})
flat_cards.sort(key=lambda x: x["group"].lower())
elif group_by == "cardset":
for card in all_cards:
flat_cards.append({"group": card.cardset, "card": card})
flat_cards.sort(key=lambda x: x["group"].lower())
elif group_by == "rarity":
for card in all_cards:
flat_cards.append({"group": card.rarity_icon, "sort_group": card.rarity_level, "card": card})
flat_cards.sort(key=lambda x: x["sort_group"], reverse=True)
page_number = self.get_page_number()
self.per_page = 36
page_flat_cards, pagination_context = self.paginate_data(flat_cards, page_number)
page_groups = []
for item in page_flat_cards:
group_value = item["group"]
card_obj = item["card"]
if page_groups and page_groups[-1]["group"] == group_value:
page_groups[-1]["cards"].append(card_obj)
else:
page_groups.append({"group": group_value, "cards": [card_obj]})
context["groups"] = page_groups
context["page_obj"] = pagination_context
context["total_cards"] = len(flat_cards)
context["object_list"] = full_qs
else:
page_number = self.get_page_number()
self.per_page = 36
paginated_cards, pagination_context = self.paginate_data(self.get_queryset(), page_number)
context["cards"] = paginated_cards
context["page_obj"] = pagination_context
context["object_list"] = self.get_queryset()
return context

View file

View file

@ -0,0 +1,8 @@
from django.apps import AppConfig
class CommonConfig(AppConfig):
name = "pkmntrade_club.common"
def ready(self):
pass

View file

@ -0,0 +1,6 @@
from django.conf import settings
def cache_settings(request):
return {
'CACHE_TIMEOUT': settings.CACHE_TIMEOUT,
}

View file

@ -0,0 +1,34 @@
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
class ReusablePaginationMixin:
per_page = 10
def get_page_number(self):
try:
return int(self.request.GET.get("page", 1))
except (ValueError, TypeError):
return 1
def paginate_data(self, data, page_number):
"""
Paginates data (a QuerySet or list) and returns a tuple: (page_data, pagination_context).
"""
paginator = Paginator(data, self.per_page)
try:
page = paginator.page(page_number)
except PageNotAnInteger:
page = paginator.page(1)
except EmptyPage:
page = paginator.page(paginator.num_pages)
pagination_context = {
"number": page.number,
"has_previous": page.has_previous(),
"has_next": page.has_next(),
"previous_page_number": page.previous_page_number() if page.has_previous() else 1,
"next_page_number": page.next_page_number() if page.has_next() else paginator.num_pages,
"paginator": {"num_pages": paginator.num_pages},
"count": paginator.count
}
return page.object_list, pagination_context

View file

@ -0,0 +1,10 @@
from django import template
register = template.Library()
@register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True)
def render_pagination(context, page_obj, hide_if_one_page=True):
"""
Renders the pagination controls given a page_obj. Optionally hides the controls if there is only one page.
"""
return {"page_obj": page_obj, "hide_if_one_page": hide_if_one_page}

View file

@ -0,0 +1,7 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pkmntrade_club.django_project.settings')
application = get_asgi_application()

View file

@ -0,0 +1,20 @@
from django.conf import settings
from django.contrib.auth import login
import time
import logging
class LogRequestsMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path == "/health/":
return self.get_response(request)
start = time.perf_counter()
response = self.get_response(request)
end = time.perf_counter()
self.log(request, response, start, end)
return response
def log(self, request, response, start, end):
logging.info(f"{request.method} {request.path_info} -> RESP {response.status_code}, took {end - start}s")

View file

@ -0,0 +1,320 @@
import socket
from pathlib import Path
import environ
import os
import logging
import sys
env = environ.Env(
DEBUG=(bool, False)
)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '%(asctime)s %(name)-12s %(levelname)-8s %(message)s',
},
},
'handlers': {
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'stream': sys.stdout,
'formatter': 'verbose',
'filters': [],
},
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'INFO',
},
'django.server': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
'': {
'handlers': ['console'],
'level': 'INFO',
'propagate': True,
},
},
}
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Take environment variables from .env file
environ.Env.read_env(os.path.join(BASE_DIR, '.env'))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env('SECRET_KEY')
# Resend API Key
RESEND_API_KEY = env('RESEND_API_KEY')
# https://docs.djangoproject.com/en/dev/ref/settings/#debug
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env('DEBUG')
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = env('ALLOWED_HOSTS').split(',')
CSRF_TRUSTED_ORIGINS = env('CSRF_TRUSTED_ORIGINS').split(',')
FIRST_PARTY_APPS = [
'pkmntrade_club.accounts',
'pkmntrade_club.cards',
'pkmntrade_club.common',
'pkmntrade_club.home',
'pkmntrade_club.theme',
'pkmntrade_club.trades',
]
# Application definition
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = [
"django_daisy",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
"django.contrib.sites",
"allauth",
"allauth.account",
'allauth.socialaccount.providers.google',
"crispy_forms",
"crispy_tailwind",
"tailwind",
"django_linear_migrations",
"meta",
] + FIRST_PARTY_APPS
if DEBUG:
INSTALLED_APPS.append("django_browser_reload")
INSTALLED_APPS.append("debug_toolbar")
TAILWIND_APP_NAME = 'theme'
META_SITE_NAME = 'PKMN Trade Club'
META_SITE_PROTOCOL = 'https'
META_USE_SITES = True
META_IMAGE_URL = 'https://pkmntrade.club/'
# https://docs.djangoproject.com/en/dev/ref/settings/#middleware
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", # WhiteNoise
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware", # django-allauth
"pkmntrade_club.django_project.middleware.LogRequestsMiddleware",
]
if DEBUG:
MIDDLEWARE.append(
"django_browser_reload.middleware.BrowserReloadMiddleware")
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
DAISY_SETTINGS = {
'SITE_TITLE': 'PKMN Trade Club Admin',
'DONT_SUPPORT_ME': True,
}
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
ROOT_URLCONF = 'pkmntrade_club.django_project.urls'
# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
WSGI_APPLICATION = 'pkmntrade_club.django_project.wsgi.app'
ASGI_APPLICATION = 'pkmntrade_club.django_project.asgi.application'
# https://docs.djangoproject.com/en/dev/ref/settings/#templates
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "theme/templates", BASE_DIR / "theme"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"pkmntrade_club.common.context_processors.cache_settings",
],
},
},
]
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
DATABASES = {
'default': env.db(),
}
# Password validation
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/dev/topics/i18n/
# https://docs.djangoproject.com/en/dev/ref/settings/#language-code
LANGUAGE_CODE = "en-us"
# https://docs.djangoproject.com/en/dev/ref/settings/#time-zone
TIME_ZONE = "UTC"
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-USE_I18N
USE_I18N = True
# https://docs.djangoproject.com/en/dev/ref/settings/#use-tz
USE_TZ = True
# https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths
LOCALE_PATHS = [BASE_DIR / 'locale']
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
# https://docs.djangoproject.com/en/dev/ref/settings/#static-root
STATIC_ROOT = BASE_DIR / "staticfiles"
# https://docs.djangoproject.com/en/dev/ref/settings/#static-url
STATIC_URL = "/static/"
# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
STATICFILES_DIRS = [BASE_DIR / "static"]
# https://docs.djangoproject.com/en/dev/ref/settings/#media-root
MEDIA_ROOT = BASE_DIR / "media"
# https://docs.djangoproject.com/en/dev/ref/settings/#media-url
MEDIA_URL = "/media/"
# https://whitenoise.readthedocs.io/en/latest/django.html
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
},
}
# Default primary key field type
# https://docs.djangoproject.com/en/stable/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# django-crispy-forms
# https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs
CRISPY_ALLOWED_TEMPLATE_PACKS = 'tailwind'
CRISPY_TEMPLATE_PACK = "tailwind"
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.resend.com"
EMAIL_PORT = 587
EMAIL_HOST_USER = "resend"
EMAIL_HOST_PASSWORD = RESEND_API_KEY
EMAIL_USE_TLS = True
# https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email
DEFAULT_FROM_EMAIL = "noreply@pkmntrade.club"
# django-debug-toolbar
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
# https://docs.djangoproject.com/en/dev/ref/settings/#internal-ips
INTERNAL_IPS = [
"127.0.0.1",
]
# for docker development
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
for ip in ips:
INTERNAL_IPS.append(ip)
ALLOWED_HOSTS.append(ip)
# https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model
AUTH_USER_MODEL = "accounts.CustomUser"
# django-allauth config
# https://docs.djangoproject.com/en/dev/ref/settings/#site-id
SITE_ID = 1
# https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url
LOGIN_REDIRECT_URL = "home"
# https://django-allauth.readthedocs.io/en/latest/views.html#logout-account-logout
ACCOUNT_LOGOUT_REDIRECT_URL = "home"
# https://django-allauth.readthedocs.io/en/latest/installation.html?highlight=backends
AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
)
# https://django-allauth.readthedocs.io/en/latest/configuration.html
ACCOUNT_SESSION_REMEMBER = True
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = True
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_EMAIL_VERIFICATION = env('ACCOUNT_EMAIL_VERIFICATION')
ACCOUNT_EMAIL_NOTIFICATIONS = True
ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True
ACCOUNT_USERNAME_MIN_LENGTH = 3
ACCOUNT_CHANGE_EMAIL = True
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_LOGIN_BY_CODE_ENABLED = True
ACCOUNT_SIGNUP_FORM_HONEYPOT_FIELD = "website"
ACCOUNT_USERNAME_REQUIRED = True
ACCOUNT_FORMS = {
"signup": "accounts.forms.CustomUserCreationForm",
}
SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = False
SOCIALACCOUNT_ONLY = False
CACHE_TIMEOUT = 604800 # 1 week
if DEBUG:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
}
}
else:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
"LOCATION": "django_cache",
"TIMEOUT": 604800, # 1 week
}
}

View file

@ -0,0 +1,20 @@
from django.conf import settings
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
path("", include("pkmntrade_club.home.urls")),
path("cards/", include("pkmntrade_club.cards.urls")),
path('account/', include('pkmntrade_club.accounts.urls')),
path("trades/", include("pkmntrade_club.trades.urls")),
path("__reload__/", include("django_browser_reload.urls")),
]
if settings.DEBUG:
import debug_toolbar
urlpatterns = [
path("__debug__/", include(debug_toolbar.urls)),
] + urlpatterns

View file

@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings")
app = get_wsgi_application()

View file

View file

@ -0,0 +1 @@
from django.contrib import admin

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class HomeConfig(AppConfig):
name = "pkmntrade_club.home"

View file

@ -0,0 +1 @@
from django.db import models

View file

@ -0,0 +1,591 @@
from django.test import TestCase, Client, RequestFactory
from django.urls import reverse
from django.contrib.auth import get_user_model
from pkmntrade_club.cards.models import Card, Deck
from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard
from pkmntrade_club.accounts.models import FriendCode
from pkmntrade_club.home.views import HomePageView
import json
from collections import OrderedDict
from unittest.mock import patch, MagicMock
from django.core.exceptions import ObjectDoesNotExist
import importlib
from tests.utils.rarity import RARITY_MAPPING
User = get_user_model()
class HomePageViewTests(TestCase):
"""Test suite for the HomePageView."""
@classmethod
def setUpTestData(cls):
"""Set up data for all test methods."""
# Create a user
cls.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpass123'
)
# Create a friend code for the user
cls.friend_code = FriendCode.objects.create(
user=cls.user,
friend_code='SW-1234-5678-9012',
in_game_name='TestTrainer'
)
# Create decks
cls.deck1 = Deck.objects.create(
name='Test Deck 1',
hex_color='#FF0000',
cardset='TEST01'
)
# Create cards with different rarities
cls.common_card = Card.objects.create(
name='Common Test Card',
cardset='TEST01',
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
)
cls.common_card.decks.add(cls.deck1)
cls.rare_card = Card.objects.create(
name='Rare Test Card',
cardset='TEST01',
cardnum=2,
style='normal',
rarity_icon='★★★',
rarity_level=3
)
cls.rare_card.decks.add(cls.deck1)
cls.ultra_rare_card = Card.objects.create(
name='Ultra Rare Test Card',
cardset='TEST01',
cardnum=3,
style='normal',
rarity_icon='★★★★',
rarity_level=4
)
cls.ultra_rare_card.decks.add(cls.deck1)
# Create trade offers with consistent rarities
cls.common_trade = TradeOffer.objects.create(
initiated_by=cls.friend_code,
rarity_icon=RARITY_MAPPING[1],
rarity_level=1
)
cls.rare_trade = TradeOffer.objects.create(
initiated_by=cls.friend_code,
rarity_icon=RARITY_MAPPING[3],
rarity_level=3
)
# Add have and want cards with the SAME rarity for each trade
TradeOfferHaveCard.objects.create(
trade_offer=cls.common_trade,
card=cls.common_card,
quantity=2
)
TradeOfferHaveCard.objects.create(
trade_offer=cls.rare_trade,
card=cls.rare_card,
quantity=1
)
# Add want cards with the SAME rarity as the have cards for each trade
TradeOfferWantCard.objects.create(
trade_offer=cls.common_trade,
card=cls.common_card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=cls.rare_trade,
card=cls.rare_card, # Changed from ultra_rare_card to match the rarity
quantity=1
)
def setUp(self):
"""Set up before each test method."""
self.client = Client()
self.url = reverse('home')
self.factory = RequestFactory()
def test_home_page_status_code(self):
"""Test that the home page returns a 200 status code."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
def test_home_page_template(self):
"""Test that the home page uses the correct template."""
response = self.client.get(self.url)
self.assertTemplateUsed(response, 'home/home.html')
def test_home_page_context_cards(self):
"""Test that the home page contains all cards in the context."""
response = self.client.get(self.url)
self.assertIn('cards', response.context)
self.assertEqual(response.context['cards'].count(), 3)
def test_home_page_context_recent_offers(self):
"""Test that the home page contains recent offers in the context."""
response = self.client.get(self.url)
self.assertIn('recent_offers', response.context)
self.assertEqual(len(response.context['recent_offers']), 2)
# Recent offers should be ordered by most recent first
self.assertEqual(response.context['recent_offers'][0], self.rare_trade)
def test_home_page_context_most_offered_cards(self):
"""Test that the home page contains most offered cards in the context."""
response = self.client.get(self.url)
self.assertIn('most_offered_cards', response.context)
most_offered = list(response.context['most_offered_cards'])
self.assertEqual(len(most_offered), 2)
# Common card should be most offered (quantity of 2)
self.assertEqual(most_offered[0], self.common_card)
def test_home_page_context_most_wanted_cards(self):
"""Test that the home page contains most wanted cards in the context."""
response = self.client.get(self.url)
self.assertIn('most_wanted_cards', response.context)
most_wanted = list(response.context['most_wanted_cards'])
self.assertEqual(len(most_wanted), 2)
def test_home_page_context_least_offered_cards(self):
"""Test that the home page contains least offered cards in the context."""
response = self.client.get(self.url)
self.assertIn('least_offered_cards', response.context)
def test_home_page_context_featured_offers(self):
"""Test that the home page contains featured offers in the context."""
response = self.client.get(self.url)
self.assertIn('featured_offers', response.context)
featured = response.context['featured_offers']
# Should be an OrderedDict
self.assertIsInstance(featured, OrderedDict)
# Should contain "All" category
self.assertIn("All", featured)
# Should contain both rarity icons
self.assertIn('★★★', featured)
self.assertIn('', featured)
# Higher rarity should come before lower rarity
keys = list(featured.keys())
# First key should be "All"
self.assertEqual(keys[0], "All")
# Higher rarity (★★★) should come before lower rarity (★)
self.assertIn('★★★', keys)
self.assertIn('', keys)
self.assertTrue(keys.index('★★★') < keys.index(''))
def test_closed_offers_not_shown(self):
"""Test that closed offers are not shown on the home page."""
# Close one of the trade offers
self.common_trade.is_closed = True
self.common_trade.save()
response = self.client.get(self.url)
recent_offers = response.context['recent_offers']
# Should only show the rare trade now
self.assertEqual(len(recent_offers), 1)
self.assertEqual(recent_offers[0], self.rare_trade)
def test_home_page_with_no_data(self):
"""Test home page rendering when there's no trade data."""
# Delete all trade offers
TradeOffer.objects.all().delete()
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
# Should have empty lists for offers
self.assertEqual(len(response.context['recent_offers']), 0)
def test_home_page_with_authenticated_user(self):
"""Test that the home page works for authenticated users."""
self.client.login(username='testuser', password='testpass123')
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
def test_rarity_sorting_in_featured_offers(self):
"""Test that offers are sorted by rarity level in descending order."""
# Create a new ultra rare trade with consistent rarity
ultra_trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon='★★★★',
rarity_level=4
)
# Add have and want cards with the same rarity
TradeOfferHaveCard.objects.create(
trade_offer=ultra_trade,
card=self.ultra_rare_card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=ultra_trade,
card=self.ultra_rare_card,
quantity=1
)
response = self.client.get(self.url)
featured = response.context['featured_offers']
keys = list(featured.keys())
# Order should be: "All", "★★★★" (level 4), "★★★" (level 3), "★" (level 1)
self.assertEqual(keys[0], "All")
self.assertEqual(keys[1], "★★★★")
self.assertEqual(keys[2], "★★★")
self.assertEqual(keys[3], "")
class HomePageViewMockTests(TestCase):
"""Test suite using mocks for HomePageView."""
def setUp(self):
self.factory = RequestFactory()
self.view = HomePageView()
@patch('trades.models.TradeOffer.objects')
@patch('cards.models.Card.objects')
def test_get_context_data_with_mocks(self, mock_card_objects, mock_offer_objects):
"""Test get_context_data using mocks."""
# Set up request
request = self.factory.get(reverse('home'))
self.view.request = request
# Mock the queryset responses
mock_offer_filter = MagicMock()
mock_offer_objects.filter.return_value = mock_offer_filter
mock_offer_filter.order_by.return_value = []
mock_card_filter = MagicMock()
mock_card_objects.filter.return_value = mock_card_filter
mock_card_objects.annotate.return_value = mock_card_filter
mock_card_objects.all.return_value.order_by.return_value = []
mock_card_filter.annotate.return_value = mock_card_filter
mock_card_filter.order_by.return_value = []
mock_offer_filter.values_list.return_value.distinct.return_value = []
# Call the method
context = self.view.get_context_data()
# Verify the expected context keys exist
self.assertIn('cards', context)
self.assertIn('recent_offers', context)
self.assertIn('most_offered_cards', context)
self.assertIn('most_wanted_cards', context)
self.assertIn('least_offered_cards', context)
self.assertIn('featured_offers', context)
@patch('trades.models.TradeOffer.objects')
def test_empty_featured_offers(self, mock_offer_objects):
"""Test handling of empty featured offers."""
# Set up request
request = self.factory.get(reverse('home'))
self.view.request = request
# Configure mock to return empty queryset
mock_offer_filter = MagicMock()
mock_offer_objects.filter.return_value = mock_offer_filter
mock_offer_filter.order_by.return_value = []
mock_offer_filter.values_list.return_value.distinct.return_value = []
# Call the method
context = self.view.get_context_data()
# Verify the featured_offers is an OrderedDict but with just the "All" key
self.assertIsInstance(context['featured_offers'], OrderedDict)
self.assertIn("All", context['featured_offers'])
self.assertEqual(len(context['featured_offers']), 1)
@patch('trades.models.TradeOffer.objects.filter')
def test_exception_handling(self, mock_filter):
"""Test that exceptions are handled gracefully."""
# Set up request
request = self.factory.get(reverse('home'))
self.view.request = request
# Configure mock to raise an exception
mock_filter.side_effect = Exception("Database error")
# Call the method - should not raise an exception
with self.assertLogs(level='ERROR') as cm:
context = self.view.get_context_data()
# Check if error was logged
self.assertIn("Unhandled error in HomePageView.get_context_data", cm.output[0])
# Verify fallback values were set
self.assertEqual(len(context['cards']), 0)
self.assertEqual(len(context['recent_offers']), 0)
self.assertEqual(len(context['most_offered_cards']), 0)
self.assertEqual(len(context['most_wanted_cards']), 0)
self.assertEqual(len(context['least_offered_cards']), 0)
self.assertIsInstance(context['featured_offers'], OrderedDict)
self.assertEqual(len(context['featured_offers']), 1)
self.assertIn("All", context['featured_offers'])
class HomePageEdgeCaseTests(TestCase):
"""Test edge cases for the home page."""
def setUp(self):
self.client = Client()
self.url = reverse('home')
# Create a user
self.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpass123'
)
# Create a friend code for the user
self.friend_code = FriendCode.objects.create(
user=self.user,
friend_code='SW-1234-5678-9012',
in_game_name='TestTrainer'
)
def test_home_page_with_no_cards(self):
"""Test home page with no cards in the database."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context['cards']), 0)
def test_home_page_with_many_offers(self):
"""Test home page with many offers to verify pagination or limiting works."""
# Create a card
card = Card.objects.create(
name='Test Card',
cardset='TEST01',
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
)
# Create 20 trade offers
for i in range(20):
trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon='',
rarity_level=1
)
# Add have and want cards
TradeOfferHaveCard.objects.create(
trade_offer=trade,
card=card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=trade,
card=card,
quantity=1
)
response = self.client.get(self.url)
# Check that recent_offers is limited to 6 as per the view
self.assertEqual(len(response.context['recent_offers']), 6)
def test_home_page_with_invalid_parameters(self):
"""Test home page with invalid GET parameters."""
# The view should ignore invalid parameters
response = self.client.get(f"{self.url}?invalid=param&another=invalid")
self.assertEqual(response.status_code, 200)
def test_performance_with_large_dataset(self):
"""Test performance with a larger dataset (basic check)."""
# Create a card
card = Card.objects.create(
name='Performance Test Card',
cardset='PERF01',
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
)
# Create 50 trade offers with different rarities
for i in range(50):
rarity_level = (i % 5) + 1 # 1-5
rarity_icon = '' * rarity_level
trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon=rarity_icon,
rarity_level=rarity_level
)
# Add have and want cards with the same rarity
rarity_card = Card.objects.create(
name=f'Performance Test Card {i}',
cardset='PERF01',
cardnum=i+10,
style='normal',
rarity_icon=rarity_icon,
rarity_level=rarity_level
)
TradeOfferHaveCard.objects.create(
trade_offer=trade,
card=rarity_card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=trade,
card=rarity_card,
quantity=1
)
# Basic performance test - just checking it completes without timeout
import time
start = time.time()
response = self.client.get(self.url)
end = time.time()
self.assertEqual(response.status_code, 200)
# Should be reasonably fast (adjust threshold as needed)
execution_time = end - start
self.assertLess(execution_time, 2.0) # Should complete in under 2 seconds
class TemplateRenderingTests(TestCase):
"""Tests focused on template rendering."""
@classmethod
def setUpTestData(cls):
# Create a user
cls.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpass123'
)
# Create a friend code for the user
cls.friend_code = FriendCode.objects.create(
user=cls.user,
friend_code='SW-1234-5678-9012',
in_game_name='TestTrainer'
)
# Create a card
cls.card = Card.objects.create(
name='Test Card',
cardset='TEST01',
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
)
# Create a trade offer
cls.trade = TradeOffer.objects.create(
initiated_by=cls.friend_code,
rarity_icon='',
rarity_level=1
)
# Add have and want cards
TradeOfferHaveCard.objects.create(
trade_offer=cls.trade,
card=cls.card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=cls.trade,
card=cls.card,
quantity=1
)
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
def test_template_used(self):
"""Test that the correct template is used."""
response = self.client.get(reverse('home'))
self.assertTemplateUsed(response, 'home/home.html')
def test_context_variables_exist(self):
"""Test that all expected context variables exist."""
response = self.client.get(reverse('home'))
# Check all required context variables
expected_keys = [
'cards',
'recent_offers',
'most_offered_cards',
'most_wanted_cards',
'least_offered_cards',
'featured_offers',
]
for key in expected_keys:
self.assertIn(key, response.context)
def test_view_with_pagination_params(self):
"""Test that view handles pagination parameters correctly, if applicable."""
# Create additional trade offers if pagination is implemented
for i in range(10):
trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon='',
rarity_level=1
)
# Add have and want cards
TradeOfferHaveCard.objects.create(
trade_offer=trade,
card=self.card,
quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=trade,
card=self.card,
quantity=1
)
# Test with page parameter
response = self.client.get(f"{reverse('home')}?page=1")
self.assertEqual(response.status_code, 200)
# Test with invalid page parameter
response = self.client.get(f"{reverse('home')}?page=999")
self.assertEqual(response.status_code, 200) # Should still render with default page
# Test with non-numeric page parameter
response = self.client.get(f"{reverse('home')}?page=abc")
self.assertEqual(response.status_code, 200) # Should handle gracefully
@patch('home.views.HomePageView.get_context_data')
def test_view_renders_with_missing_context(self, mock_get_context):
"""Test that view renders even with incomplete context data."""
# Return incomplete context
mock_get_context.return_value = {'cards': []}
# Should still render without error even with missing context variables
response = self.client.get(reverse('home'))
self.assertEqual(response.status_code, 200)
def test_compatibility_with_multiple_django_versions(self):
"""Ensure compatibility with different Django versions."""
import django
# Simply log the Django version - the test itself verifies the page renders
# with the current version
django_version = django.get_version()
response = self.client.get(reverse('home'))
self.assertEqual(response.status_code, 200)

View file

@ -0,0 +1,9 @@
from django.urls import path
from .views import HomePageView, HealthCheckView
urlpatterns = [
path("", HomePageView.as_view(), name="home"),
path("health", HealthCheckView.as_view(), name="health"),
path("health/", HealthCheckView.as_view(), name="health"),
]

View file

@ -0,0 +1,164 @@
from collections import defaultdict, OrderedDict
from django.views.generic import TemplateView
from django.urls import reverse_lazy
from django.db.models import Count, Q, Prefetch, Sum, F, IntegerField, Value, BooleanField, Case, When
from django.db.models.functions import Coalesce
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance, TradeOfferHaveCard, TradeOfferWantCard
from pkmntrade_club.cards.models import Card
from django.utils.decorators import method_decorator
from django.template.response import TemplateResponse
from django.http import HttpResponseRedirect
import logging
from django.views import View
from django.http import HttpResponse
import contextlib
logger = logging.getLogger(__name__)
class HomePageView(TemplateView):
template_name = "home/home.html"
#@silk_profile(name='Home Page')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
try:
# Get all cards ordered by name, exclude cards with rarity level > 5
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level")
# Reuse base trade offer queryset for market stats
base_offer_qs = TradeOffer.objects.filter(is_closed=False)
# Recent Offers
try:
recent_offers_qs = base_offer_qs.order_by("-created_at")[:6]
context["recent_offers"] = recent_offers_qs
context["cache_key_recent_offers"] = f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}"
except Exception as e:
logger.error(f"Error fetching recent offers: {str(e)}")
context["recent_offers"] = []
context["cache_key_recent_offers"] = "recent_offers_error"
# Most Offered Cards
try:
most_offered_cards_qs = (
Card.objects.filter(tradeofferhavecard__isnull=False).filter(rarity_level__lte=5)
.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
.order_by("-offer_count")[:6]
)
context["most_offered_cards"] = most_offered_cards_qs
context["cache_key_most_offered_cards"] = f"most_offered_cards_{most_offered_cards_qs.values_list('pk', 'updated_at')}"
except Exception as e:
logger.error(f"Error fetching most offered cards: {str(e)}")
context["most_offered_cards"] = []
context["cache_key_most_offered_cards"] = "most_offered_cards_error"
# Most Wanted Cards
try:
most_wanted_cards_qs = (
Card.objects.filter(tradeofferwantcard__isnull=False).filter(rarity_level__lte=5)
.annotate(offer_count=Sum("tradeofferwantcard__quantity"))
.order_by("-offer_count")[:6]
)
context["most_wanted_cards"] = most_wanted_cards_qs
context["cache_key_most_wanted_cards"] = f"most_wanted_cards_{most_wanted_cards_qs.values_list('pk', 'updated_at')}"
except Exception as e:
logger.error(f"Error fetching most wanted cards: {str(e)}")
context["most_wanted_cards"] = []
# Least Offered Cards
try:
least_offered_cards_qs = (
Card.objects.filter(rarity_level__lte=5).annotate(
offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0)
)
.order_by("offer_count")[:6]
)
context["least_offered_cards"] = least_offered_cards_qs
context["cache_key_least_offered_cards"] = f"least_offered_cards_{least_offered_cards_qs.values_list('pk', 'updated_at')}"
except Exception as e:
logger.error(f"Error fetching least offered cards: {str(e)}")
context["least_offered_cards"] = []
context["cache_key_least_offered_cards"] = "least_offered_cards_error"
# Build featured offers with custom ordering
featured = OrderedDict()
# Featured "All" offers remains fixed at the top
try:
featured["All"] = base_offer_qs.order_by("created_at")[:6]
except Exception as e:
logger.error(f"Error fetching 'All' featured offers: {str(e)}")
featured["All"] = []
try:
# Pull out distinct (rarity_level, rarity_icon) tuples
distinct_rarities = base_offer_qs.values_list("rarity_level", "rarity_icon").distinct()
# Prepare a list that holds tuples of (rarity_level, rarity_icon, offers)
rarity_offers = []
for rarity_level, rarity_icon in distinct_rarities:
offers = base_offer_qs.filter(rarity_level=rarity_level).order_by("created_at")[:6]
rarity_offers.append((rarity_level, rarity_icon, offers))
# Sort by rarity_level (from greatest to least)
rarity_offers.sort(key=lambda x: x[0], reverse=True)
# Add the sorted offers to the OrderedDict
for rarity_level, rarity_icon, offers in rarity_offers:
featured[rarity_icon] = offers
except Exception as e:
logger.error(f"Error processing rarity-based featured offers: {str(e)}")
context["featured_offers"] = featured
# Generate a cache key based on the pks and updated_at timestamps of all featured offers
all_offer_identifiers = []
for section_name,section_offers in featured.items():
# featured_section is a QuerySet. Fetch (pk, updated_at) tuples.
identifiers = section_offers.values_list('pk', 'updated_at')
# Format each tuple as "pk_timestamp" and add to the list
section_strings = [f"{section_name}_{pk}_{ts.timestamp()}" for pk, ts in identifiers]
all_offer_identifiers.extend(section_strings)
# Join all identifiers into a single string, sorted for consistency regardless of order
combined_identifiers = "|".join(sorted(all_offer_identifiers))
context["cache_key_featured_offers"] = f"featured_offers_{combined_identifiers}"
except Exception as e:
logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}")
# Provide fallback empty data
context["cards"] = None
context["recent_offers"] = []
context["most_offered_cards"] = []
context["most_wanted_cards"] = []
context["least_offered_cards"] = []
context["featured_offers"] = OrderedDict([("All", [])])
return context
def get(self, request, *args, **kwargs):
"""Override get method to add caching"""
return super().get(request, *args, **kwargs)
class HealthCheckView(View):
def get(self, request, *args, **kwargs):
try:
from django.db import connection
connection.cursor().execute("SELECT 1")
except Exception as e:
return HttpResponse("Database connection failed", status=500)
try:
from pkmntrade_club.trades.models import TradeOffer
with contextlib.redirect_stdout(None):
print(TradeOffer.objects.count())
except Exception as e:
return HttpResponse("DB models not reachable, but db is reachable", status=500)
try:
from django.core.cache import cache
cache.set("test", "test")
with contextlib.redirect_stdout(None):
print(cache.get("test"))
except Exception as e:
return HttpResponse("Cache not reachable", status=500)
return HttpResponse("OK/HEALTHY")

View file

View file

@ -0,0 +1,48 @@
[x-cloak] { display: none !important; }
/* Beta Badge */
#navbar-logo::after {
content: 'BETA';
font-size: 12px;
font-weight: bold;
color: var(--color-base-content);
background-color: var(--color-base-300);
padding: 4px 8px;
border-radius: 4px;
}
.gravatar-hovercard .gravatar-hovercard__inner {
background-color: var(--color-base-100) !important;
border-color: var(--color-base-300) !important;
color: var(--color-base-content) !important;
}
.gravatar-hovercard .gravatar-hovercard__inner,
.gravatar-hovercard .gravatar-hovercard__header-image,
.gravatar-hovercard .gravatar-hovercard__header,
.gravatar-hovercard .gravatar-hovercard__avatar-link,
.gravatar-hovercard .gravatar-hovercard__avatar,
.gravatar-hovercard .gravatar-hovercard__personal-info-plink,
.gravatar-hovercard .gravatar-hovercard__name,
.gravatar-hovercard .gravatar-hovercard__job,
.gravatar-hovercard .gravatar-hovercard__location,
.gravatar-hovercard .gravatar-hovercard__body,
.gravatar-hovercard .gravatar-hovercard__description,
.gravatar-hovercard .gravatar-hovercard__social-links,
.gravatar-hovercard .gravatar-hovercard__buttons,
.gravatar-hovercard .gravatar-hovercard__button,
.gravatar-hovercard .gravatar-hovercard__button:hover,
.gravatar-hovercard .gravatar-hovercard__footer,
.gravatar-hovercard .gravatar-hovercard__profile-url,
.gravatar-hovercard .gravatar-hovercard__profile-link,
.gravatar-hovercard .gravatar-hovercard__profile-color {
color: var(--color-base-content) !important;
}
.gravatar-hovercard .gravatar-hovercard__location {
color: var(--color-base-content) !important;
}
.dark .gravatar-hovercard .gravatar-hovercard__social-icon {
filter: invert(1) !important;
}

View file

@ -0,0 +1,88 @@
select.card-multiselect {
height: calc(var(--spacing) * 35);
/*background-image: linear-gradient(45deg, #0000 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, #0000 50%); */
background-image: none;
}
.choices.is-disabled .choices__inner,
.choices.is-disabled .choices__input {
background-color: var(--color-neutral);
}
.choices[data-type*=select-one] .choices__input {
border-bottom: 1px solid var(--btn-shadow);
background-color: var(--color-base-100);
}
.choices[data-type*=select-one] .choices__button:focus {
box-shadow: 0 0 0 2px #005F75;
}
.choices__inner {
background-color: var(--color-base-100);
border: 1px solid var(--btn-shadow);
}
.is-focused .choices__inner, .is-open .choices__inner {
border-color: var(--btn-shadow);
}
.choices__list--multiple .choices__item {
background-color: var(--color-base-100);
border: 1px solid var(--btn-shadow);
color: var(--color-primary);
}
.choices__list--multiple .choices__item.is-highlighted {
background-color: var(--color-base-100);
border: 1px solid var(--btn-shadow);
}
.is-disabled .choices__list--multiple .choices__item {
background-color: var(--color-neutral);
border: 1px solid var(--btn-shadow);
}
.choices__list--dropdown, .choices__list[aria-expanded] {
background-color: var(--color-base-100);
border: 1px solid var(--btn-shadow);
}
.is-open .choices__list--dropdown, .is-open .choices__list[aria-expanded] {
border-color: var(--btn-shadow);
}
.choices__list--dropdown .choices__item--selectable.is-highlighted, .choices__list[aria-expanded] .choices__item--selectable.is-highlighted {
background-color: var(--color-base-100);
border: 1px solid var(--btn-shadow);
}
.choices__heading {
border-bottom: 1px solid var(--btn-shadow);
color: var(--color-neutral);
}
.choices__input {
background-color: var(--color-base-100);
}
.choices.select {
height: inherit;
padding-inline-start: 0;
}
.choices__inner {
border: 1px solid var(--color-gray-500) !important;
}
.choices__list {
border: none !important;
}
.choices__list--dropdown {
border-left: 1px solid var(--color-gray-500) !important;
border-right: 1px solid var(--color-gray-500) !important;
border-bottom: 1px solid var(--color-gray-500) !important;
border-top: none !important;
}
.choices.select[data-type*="select-one"]::after {
display: none;
}
.choices__inner.bg-secondary {
background-color: var(--color-secondary);
border: none;
z-index: 10;
}
.choices__item.mx-auto.w-max:hover {
background-color: #e2e8f0;
}
.choices__input,
.choices__input--cloned {
width: 100% !important;
}
.choices__list--dropdown span.card-quantity-badge {
display: none;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

View file

@ -0,0 +1 @@
(()=>{function g(n){n.directive("collapse",e),e.inline=(t,{modifiers:i})=>{i.includes("min")&&(t._x_doShow=()=>{},t._x_doHide=()=>{})};function e(t,{modifiers:i}){let r=l(i,"duration",250)/1e3,h=l(i,"min",0),u=!i.includes("min");t._x_isShown||(t.style.height=`${h}px`),!t._x_isShown&&u&&(t.hidden=!0),t._x_isShown||(t.style.overflow="hidden");let c=(d,s)=>{let o=n.setStyles(d,s);return s.height?()=>{}:o},f={transitionProperty:"height",transitionDuration:`${r}s`,transitionTimingFunction:"cubic-bezier(0.4, 0.0, 0.2, 1)"};t._x_transition={in(d=()=>{},s=()=>{}){u&&(t.hidden=!1),u&&(t.style.display=null);let o=t.getBoundingClientRect().height;t.style.height="auto";let a=t.getBoundingClientRect().height;o===a&&(o=h),n.transition(t,n.setStyles,{during:f,start:{height:o+"px"},end:{height:a+"px"}},()=>t._x_isShown=!0,()=>{Math.abs(t.getBoundingClientRect().height-a)<1&&(t.style.overflow=null)})},out(d=()=>{},s=()=>{}){let o=t.getBoundingClientRect().height;n.transition(t,c,{during:f,start:{height:o+"px"},end:{height:h+"px"}},()=>t.style.overflow="hidden",()=>{t._x_isShown=!1,t.style.height==`${h}px`&&u&&(t.style.display="none",t.hidden=!0)})}}}}function l(n,e,t){if(n.indexOf(e)===-1)return t;let i=n[n.indexOf(e)+1];if(!i)return t;if(e==="duration"){let r=i.match(/([0-9]+)ms/);if(r)return r[1]}if(e==="min"){let r=i.match(/([0-9]+)px/);if(r)return r[1]}return i}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(g)});})();

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,146 @@
/* global window, document, localStorage */
const $ = selector => Array.from(document.querySelectorAll(selector));
const $$ = selector => Array.from(document.querySelector(selector));
(() => {
"use strict";
/**
* Initialize the theme toggle button functionality.
* Toggles between 'dark' and 'light' themes and persists the state in localStorage.
*/
function initThemeToggle() {
const themeToggleButton = document.getElementById("theme-toggle-btn");
if (!themeToggleButton) return;
themeToggleButton.classList.toggle("btn-ghost", !("theme" in localStorage));
themeToggleButton.addEventListener("click", () => {
const documentRoot = document.documentElement;
const isSystemTheme = themeToggleButton.classList.contains("btn-ghost");
const isDarkTheme = documentRoot.classList.contains("dark");
const newTheme = isSystemTheme ? "dark" : (isDarkTheme ? "light" : "system");
if (newTheme === "system") {
documentRoot.classList.toggle("dark", window.matchMedia("(prefers-color-scheme: dark)").matches);
documentRoot.setAttribute("data-theme", window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
localStorage.removeItem("theme");
} else {
if (newTheme === "light") {
documentRoot.classList.remove("dark");
} else if (newTheme === "dark") {
documentRoot.classList.add("dark");
}
documentRoot.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
}
themeToggleButton.classList.toggle("btn-ghost", newTheme === "system");
});
}
/**
* Initialize event listeners for forms containing multiselect fields.
* When the form is submitted, process each 'card-multiselect' to create hidden inputs.
*/
function initCardMultiselectHandling() {
const forms = document.querySelectorAll("form");
forms.forEach(form => {
if (form.querySelector("select.card-multiselect")) {
form.addEventListener("submit", () => {
processMultiselectForm(form);
});
}
});
}
/**
* Process multiselect fields within a form before submission by:
* - Creating hidden inputs for each selected option with value in 'card_id:quantity' format.
* - Removing the original name attribute to avoid duplicate submissions.
*
* @param {HTMLFormElement} form - The form element to process.
*/
function processMultiselectForm(form) {
const multiselectFields = form.querySelectorAll("select.card-multiselect");
multiselectFields.forEach(selectField => {
const originalFieldName =
selectField.getAttribute("data-original-name") || selectField.getAttribute("name");
if (!originalFieldName) return;
selectField.setAttribute("data-original-name", originalFieldName);
// Remove any previously generated hidden inputs for this multiselect.
form
.querySelectorAll(`input[data-generated-for-card-multiselect="${originalFieldName}"]`)
.forEach(input => input.remove());
// For each selected option, create a hidden input.
selectField.querySelectorAll("option:checked").forEach(option => {
const cardId = option.value;
const quantity = option.getAttribute("data-quantity") || "1";
const hiddenInput = document.createElement("input");
hiddenInput.type = "hidden";
hiddenInput.name = originalFieldName;
hiddenInput.value = `${cardId}:${quantity}`;
hiddenInput.setAttribute("data-generated-for-card-multiselect", originalFieldName);
form.appendChild(hiddenInput);
});
// Prevent the browser from submitting the select field directly.
selectField.removeAttribute("name");
});
}
/**
* Reset stale selections in all card multiselect fields.
* This is triggered on the window's 'pageshow' event to clear any lingering selections.
*/
function resetCardMultiselectState() {
const multiselectFields = document.querySelectorAll("select.card-multiselect");
multiselectFields.forEach(selectField => {
// Deselect all options.
selectField.querySelectorAll("option").forEach(option => {
option.selected = false;
});
// If the select field has an associated Choices.js instance, clear its selection.
if (selectField.choicesInstance) {
const activeSelections = selectField.choicesInstance.getValue(true);
if (activeSelections.length > 0) {
selectField.choicesInstance.removeActiveItemsByValue(activeSelections);
}
selectField.choicesInstance.setValue([]);
}
});
}
/**
* Process all elements with the 'marquee' class.
* For each element, if its content is overflowing (using isElementOverflowing),
* wrap its innerHTML within a <marquee> tag and remove the 'marquee' class.
*/
function processMarqueeElements() {
document.querySelectorAll('.marquee-calc').forEach(element => {
if (element.offsetWidth >= 148 || element.offsetWidth < element.scrollWidth) {
element.innerHTML = '<marquee behavior="scroll" direction="left" scrolldelay="80">' + element.innerHTML + '</marquee>';
}
element.classList.remove('marquee-calc');
});
}
// Expose processMarqueeElements to be available for AJAX-loaded partial updates.
window.processMarqueeElements = processMarqueeElements;
// On DOMContentLoaded, initialize theme toggling, form processing, and marquee wrapping.
document.addEventListener("DOMContentLoaded", () => {
initThemeToggle();
initCardMultiselectHandling();
processMarqueeElements();
});
// On pageshow, only reset multiselect state if the page was loaded from bfcache.
window.addEventListener("pageshow", function(event) {
if (event.persisted) {
resetCardMultiselectState();
}
});
})();

View file

@ -0,0 +1,219 @@
document.addEventListener('DOMContentLoaded', function() {
if (!window.updateGlobalCardFilters) {
window.updateGlobalCardFilters = function() {
const selects = document.querySelectorAll('.card-multiselect');
// Rebuild global selections and rarity filtering.
const globalSelectedIds = [];
let globalRarity = null;
selects.forEach(select => {
const selectedValues = select.choicesInstance ? select.choicesInstance.getValue(true) : [];
selectedValues.forEach(cardId => {
if (cardId && globalSelectedIds.indexOf(cardId) === -1) {
globalSelectedIds.push(cardId);
}
});
if (selectedValues.length > 0 && globalRarity === null) {
const option = select.querySelector('option[value="${selectedValues[0]}"]');
if (option) {
globalRarity = option.getAttribute('data-rarity');
}
}
});
selects.forEach(select => {
if (select.choicesInstance && select.choicesInstance.dropdown.element) {
// Reset all options to enabled.
select.querySelectorAll('option').forEach(function(option) {
option.disabled = false;
});
// Reset all items to visible.
select.choicesInstance.dropdown.element.querySelectorAll('[data-card-id]').forEach(function(item) {
item.style.display = '';
});
// Filter out options/items that do not match the global rarity.
if (globalRarity) {
select.querySelectorAll('option[data-rarity]:not([data-rarity="'+globalRarity+'"])').forEach(function(option) {
option.disabled = true;
});
select.choicesInstance.dropdown.element.querySelectorAll('[data-rarity]:not([data-rarity="'+globalRarity+'"])').forEach(function(item) {
item.style.display = 'none';
});
}
// Filter out options/items that match the global selected card IDs.
for (const cardId of globalSelectedIds) {
select.choicesInstance.dropdown.element.querySelectorAll('[data-card-id="' + cardId + '"]').forEach(function(item) {
item.style.display = 'none';
});
select.querySelectorAll('option[data-card-id="'+cardId+'"]:not(option[selected])').forEach(function(option) {
option.disabled = true;
});
}
}
});
};
}
if (!window.updateOptionQuantity) {
window.updateOptionQuantity = function(item, quantity) {
const cardId = item.getAttribute('data-card-id');
const option = item.closest('.choices__inner').querySelector('option[value="' + cardId + '"]');
if (option) {
option.setAttribute('data-quantity', quantity);
}
}
}
if (!window.getOptionQuantity) {
window.getOptionQuantity = function(item) {
const cardId = item.getAttribute('data-card-id');
const option = item.closest('.choices__inner').querySelector('option[value="' + cardId + '"]');
return option ? parseInt(option.getAttribute('data-quantity')) : "";
}
}
const selectFields = document.querySelectorAll('.card-multiselect');
selectFields.forEach(selectField => {
const placeholder = selectField.getAttribute('data-placeholder') || '';
const choicesInstance = new Choices(selectField, {
removeItemButton: false,
placeholderValue: placeholder,
searchEnabled: true,
shouldSort: false,
allowHTML: true,
closeDropdownOnSelect: true,
removeItemButton: true,
searchFields: ['label'],
resetScrollPosition: false,
callbackOnCreateTemplates: function(template) {
const getCardContent = (data) => {
let htmlContent = (data.element && data.element.getAttribute('data-html-content')) || data.label;
let quantity = data.element.getAttribute('data-quantity');
quantity = quantity ? parseInt(quantity) : 1;
htmlContent = htmlContent.replace('__QUANTITY__', quantity);
return htmlContent;
};
const renderCard = (classNames, data, type) => {
const rarity = data.element ? data.element.getAttribute('data-rarity') : '';
const cardId = data.element ? data.element.getAttribute('data-card-id') : 0;
const cardname = data.element ? data.element.getAttribute('data-name') : '';
const content = getCardContent(data);
if (type === 'item') {
return template(`
<div class="${classNames.item} mx-auto w-max ${data.highlighted ? classNames.highlightedState : ''} relative"
data-id="${data.id}"
data-card-id="${cardId}"
data-item
data-rarity="${rarity}"
data-name="${cardname}"
aria-selected="true"
style="cursor: pointer; padding: 1rem;">
<button type="button" class="decrement absolute left-[-1.5rem] top-1/2 transform -translate-y-1/2 bg-base-300 text-base-content px-2">-</button>
<button type="button" class="increment absolute right-[-1.5rem] top-1/2 transform -translate-y-1/2 bg-base-300 text-base-content px-2">+</button>
<div class="card-content">${content}</div>
</div>
`);
} else {
const extraAttributes = `data-select-text="${this.config.itemSelectText}" data-choice ${
data.disabled ? 'data-choice-disabled aria-disabled="true"' : 'data-choice-selectable'
}`;
const extraClasses = classNames.itemChoice;
return template(`
<div class="${classNames.item} ${extraClasses} ${data.highlighted ? classNames.highlightedState : ''} mx-auto w-max"
${extraAttributes}
data-id="${data.id}"
data-card-id="${cardId}"
data-name="${cardname}"
data-choice
data-rarity="${rarity}"
style="cursor: pointer;">
${content}
</div>
`);
}
};
return {
choice: function(classNames, data) {
return renderCard(classNames, data, 'choice');
},
item: function(classNames, data) {
return renderCard(classNames, data, 'item');
}
};
}
});
// Associate the Choices instance with the select field.
selectField.choicesInstance = choicesInstance;
if (!window.cardMultiselectInstances) {
window.cardMultiselectInstances = [];
}
window.cardMultiselectInstances.push(selectField);
selectField.addEventListener('change', function() {
if (window.updateGlobalCardFilters) {
window.updateGlobalCardFilters();
}
});
if (choicesInstance.getValue(true).length > 0 && window.updateGlobalCardFilters) {
window.updateGlobalCardFilters();
}
// Listen for increment/decrement clicks (scoped to the choices container).
const choicesContainer = selectField.closest('.choices') || document;
choicesContainer.addEventListener('click', function(e) {
if (e.target.classList.contains('increment')) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const container = e.target.closest('[data-item]');
if (container) {
let quantityBadge = container.querySelector('.card-quantity-badge');
let quantity = window.getOptionQuantity(container);
quantity = quantity + 1;
quantityBadge.innerText = quantity;
window.updateOptionQuantity(container, quantity);
}
}
if (e.target.classList.contains('decrement')) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const container = e.target.closest('[data-item]');
if (container) {
let quantityBadge = container.querySelector('.card-quantity-badge');
let quantity = window.getOptionQuantity(container);
const cardId = container.getAttribute('data-card-id');
if (quantity === 1) {
const option = selectField.querySelector('option[value="' + cardId + '"]');
if (option) {
choicesInstance.removeActiveItemsByValue(option.value);
option.selected = false;
}
if (window.updateGlobalCardFilters) {
window.updateGlobalCardFilters();
}
} else {
quantity = quantity - 1;
quantityBadge.innerText = quantity;
window.updateOptionQuantity(container, quantity);
}
}
}
if (e.target.closest('[data-item]') &&
!e.target.classList.contains('increment') &&
!e.target.classList.contains('decrement')) {
e.stopPropagation();
e.stopImmediatePropagation();
}
});
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,123 @@
/**
* tooltip.js
*
* This script uses FloatingUI to create modern, styled tooltips for elements with the
* custom attribute "data-tooltip-html". The tooltips are styled using Tailwind CSS classes
* to support both light and dark themes and include a dynamically positioned arrow.
*
* Make sure the FloatingUIDOM global is available.
* For example, include in your base template:
* <script src="https://unpkg.com/@floating-ui/dom"></script>
*/
const { computePosition, offset, flip, shift, arrow } = FloatingUIDOM;
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-tooltip-html]').forEach((el) => {
let tooltipContainer = null;
let arrowElement = null;
let fadeOutTimeout;
const showTooltip = () => {
if (tooltipContainer) return; // Tooltip already visible
// Retrieve the custom HTML content from the data attribute
const tooltipContent = el.getAttribute('data-tooltip-html');
// Create a container for the tooltip (with modern styling)
tooltipContainer = document.createElement('div');
tooltipContainer.classList.add(
'bg-black', 'text-white',
'shadow-lg', 'rounded-lg', 'p-2',
// Transition classes for simple fade in/out
'transition-opacity', 'duration-200', 'opacity-0'
);
tooltipContainer.style.position = 'absolute';
tooltipContainer.style.zIndex = '9999';
// Set the HTML content for the tooltip
tooltipContainer.innerHTML = '<div class="p-2">' + tooltipContent + '</div>';
// Create the arrow element. The arrow is styled as a small rotated square.
arrowElement = document.createElement('div');
arrowElement.classList.add(
'w-3', 'h-3',
'bg-black',
'transform', 'rotate-45'
);
arrowElement.style.position = 'absolute';
// Append the arrow into the tooltip container
tooltipContainer.appendChild(arrowElement);
// Append the tooltip container to the document body
document.body.appendChild(tooltipContainer);
// Use Floating UI to position the tooltip, including the arrow middleware
computePosition(el, tooltipContainer, {
middleware: [
offset(8),
flip(),
shift({ padding: 5 }),
arrow({ element: arrowElement })
]
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(tooltipContainer.style, {
left: `${x}px`,
top: `${y}px`
});
// Position the arrow using the arrow middleware data
const { x: arrowX, y: arrowY } = middlewareData.arrow || {};
// Reset any previous inline values
arrowElement.style.left = '';
arrowElement.style.top = '';
arrowElement.style.right = '';
arrowElement.style.bottom = '';
// Adjust the arrow's position according to the placement
if (placement.startsWith('top')) {
arrowElement.style.bottom = '-4px';
arrowElement.style.left = arrowX !== undefined ? `${arrowX}px` : '50%';
} else if (placement.startsWith('bottom')) {
arrowElement.style.top = '-4px';
arrowElement.style.left = arrowX !== undefined ? `${arrowX}px` : '50%';
} else if (placement.startsWith('left')) {
arrowElement.style.right = '-4px';
arrowElement.style.top = arrowY !== undefined ? `${arrowY}px` : '50%';
} else if (placement.startsWith('right')) {
arrowElement.style.left = '-4px';
arrowElement.style.top = arrowY !== undefined ? `${arrowY}px` : '50%';
}
});
// Trigger a fade-in by moving from opacity-0 to opacity-100
requestAnimationFrame(() => {
tooltipContainer.classList.remove('opacity-0');
tooltipContainer.classList.add('opacity-100');
});
};
const hideTooltip = () => {
if (tooltipContainer) {
tooltipContainer.classList.remove('opacity-100');
tooltipContainer.classList.add('opacity-0');
// Remove the tooltip from the DOM after the transition duration
fadeOutTimeout = setTimeout(() => {
if (tooltipContainer && tooltipContainer.parentNode) {
tooltipContainer.parentNode.removeChild(tooltipContainer);
}
tooltipContainer = null;
arrowElement = null;
}, 200); // Matches the duration-200 class (200ms)
}
};
// Attach event listeners to show/hide the tooltip
el.addEventListener('mouseenter', showTooltip);
el.addEventListener('mouseleave', hideTooltip);
el.addEventListener('focus', showTooltip);
el.addEventListener('blur', hideTooltip);
});
});

View file

@ -0,0 +1,10 @@
RARITY_MAPPING = {
1: "🔷",
2: "🔷🔷",
3: "🔷🔷🔷",
4: "🔷🔷🔷🔷",
5: "⭐️",
6: "⭐️⭐️",
7: "⭐️⭐️⭐️",
8: "👑"
}

View file

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ThemeConfig(AppConfig):
name = 'pkmntrade_club.theme'

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
{
"name": "theme",
"version": "3.8.0",
"description": "",
"scripts": {
"start": "npm run dev",
"build": "npm run build:clean && npm run build:tailwind",
"build:clean": "rimraf ../static/css/dist",
"build:tailwind": "cross-env NODE_ENV=production tailwindcss --postcss -i ./src/styles.css -o ../static/css/dist/styles.css --minify",
"dev": "cross-env NODE_ENV=development tailwindcss --postcss -i ./src/styles.css -o ../static/css/dist/styles.css -w",
"tailwindcss": "node ./node_modules/tailwindcss/lib/cli.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/cli": "^4.0.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"cross-env": "^7.0.3",
"daisyui": "^5.0.0-beta.9",
"postcss": "^8.5.1",
"postcss-import": "^16.1.0",
"postcss-nested": "^7.0.2",
"postcss-simple-vars": "^7.0.1",
"rimraf": "^6.0.1",
"tailwindcss": "^4.0.0"
}
}

View file

@ -0,0 +1,8 @@
module.exports = {
plugins: {
//"postcss-import": {},
//"postcss-simple-vars": {},
//"postcss-nested": {},
"@tailwindcss/postcss": {}
},
}

View file

@ -0,0 +1,116 @@
/*
* This is where you can configure the folders that Tailwind will scan.
*
* For detailed documents, check the Tailwind docs at:
*
* https://tailwindcss.com/docs/detecting-classes-in-source-files#explicitly-registering-sources
*
* This default configuration will scan all folder in your root project directory.
*
* Here is an example configuration that will only scan your templates/ folder:
*
* @import "tailwindcss" source(none);
*
* @source "../../../templates";
*/
@import "tailwindcss" source("../../");
/*
* If you would like to customise you theme, you can do that here too.
*
* https://tailwindcss.com/docs/theme
*
*/
@theme {
--breakpoint-xs: 24rem;
}
/*
* You can install tailwind plugins like below.
*
* https://tailwindcss.com/docs/functions-and-directives#plugin-directive
*
*/
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/aspect-ratio";
@tailwind base;
@tailwind components;
@tailwind utilities;
@custom-variant dark (&:where(.dark, .dark *));
@plugin "daisyui";
/* @plugin "daisyui/theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: light;
--color-base-100: oklch(100% 0 0);
--color-base-200: oklch(98% 0 0);
--color-base-300: oklch(95% 0 0);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: #CF36E0;
--color-primary-content: oklch(100% 0 0);
--color-secondary: #8040E0;
--color-secondary-content: oklch(100% 0 0);
--color-accent: #302FD9;
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(37% 0 0);
--color-neutral-content: oklch(100% 0 0);
--color-info: #1070EB;
--color-info-content: oklch(100% 0 0);
--color-success: #20AA80;
--color-success-content: oklch(100% 0 0);
--color-warning: #EA8200;
--color-warning-content: oklch(100% 0 0);
--color-error: #E00202;
--color-error-content: oklch(100% 0 0);
--radius-selector: 0.5rem;
--radius-field: 0rem;
--radius-box: 0rem;
--size-selector: 0.3125rem;
--size-field: 0.3125rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
@plugin "daisyui/theme" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: dark;
--color-base-100: oklch(25.33% 0.016 252.42);
--color-base-200: oklch(23.26% 0.014 253.1);
--color-base-300: oklch(21.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: #CF36E0;
--color-primary-content: oklch(100% 0 0);
--color-secondary: #8040E0;
--color-secondary-content: oklch(100% 0 0);
--color-accent: #302FD9;
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(37% 0 0);
--color-neutral-content: oklch(100% 0 0);
--color-info: #1070EB;
--color-info-content: oklch(100% 0 0);
--color-success: #20AA80;
--color-success-content: oklch(100% 0 0);
--color-warning: #EA8200;
--color-warning-content: oklch(100% 0 0);
--color-error: #E00202;
--color-error-content: oklch(100% 0 0);
--radius-selector: 0.5rem;
--radius-field: 0rem;
--radius-box: 0rem;
--size-selector: 0.3125rem;
--size-field: 0.3125rem;
--border: 1px;
--depth: 1;
--noise: 0;
} */

View file

@ -0,0 +1,73 @@
/**
* This is a minimal config.
*
* If you need the full config, get it from here:
* https://unpkg.com/browse/tailwindcss@latest/stubs/defaultConfig.stub.js
*/
module.exports = {
content: [
/**
* HTML. Paths to Django template files that will contain Tailwind CSS classes.
*/
/* Templates within theme app (<tailwind_app_name>/templates), e.g. base.html. */
"../templates/**/*.html",
/*
* Main templates directory of the project (BASE_DIR/templates).
* Adjust the following line to match your project structure.
*/
"../../templates/**/*.html",
/*
* Templates in other django apps (BASE_DIR/<any_app_name>/templates).
* Adjust the following line to match your project structure.
*/
"../../**/templates/**/*.html",
/**
* JS: If you use Tailwind CSS in JavaScript, uncomment the following lines and make sure
* patterns match your project structure.
*/
/* JS 1: Ignore any JavaScript in node_modules folder. */
// '!../../**/node_modules',
/* JS 2: Process all JavaScript files in the project. */
// '../../**/*.js',
/**
* Python: If you use Tailwind CSS classes in Python, uncomment the following line
* and make sure the pattern below matches your project structure.
*/
// '../../**/*.py'
],
safelist: [
"alert-info",
"alert-success",
"alert-warning",
"alert-error",
"btn-info",
"btn-success",
"btn-warning",
"btn-error",
"bg-info",
"bg-success",
"bg-warning",
"bg-error",
"text-gray-700",
],
theme: {
extend: {},
},
plugins: [
/**
* '@tailwindcss/forms' is the forms plugin that provides a minimal styling
* for forms. If you don't like it or have own styling for forms,
* comment the line below to disable '@tailwindcss/forms'.
*/
require("@tailwindcss/forms"),
require("@tailwindcss/typography"),
require("@tailwindcss/aspect-ratio"),
],
darkMode: "class",
};

View file

@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block title %}Forbidden (403){% endblock title %}
{% block content %}
<div class="container mx-auto max-w-md mt-12 text-center">
<h1 class="text-5xl font-bold mb-4">Forbidden (403)</h1>
<p class="text-xl mb-4">CSRF verification failed. Request aborted.</p>
<a href="{% url 'home' %}" class="btn btn-primary">Return Home</a>
</div>
{% endblock content %}

View file

@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block title %}404 Page not found{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-md mt-12 text-center">
<h1 class="text-5xl font-bold mb-4">404</h1>
<p class="text-xl mb-4">Page not found</p>
<a href="{% url 'home' %}" class="btn btn-primary">Return Home</a>
</div>
{% endblock content %}

View file

@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block title %}500 Server Error{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-md mt-12 text-center">
<h1 class="text-5xl font-bold mb-4">500</h1>
<p class="text-xl mb-4">Looks like something went wrong!</p>
<a href="{% url 'home' %}" class="btn btn-primary">Return Home</a>
</div>
{% endblock content %}

View file

@ -0,0 +1,10 @@
{% if messages %}
<div class="flex flex-col gap-2">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }} text-(--color-{{ message.tags }}-content){% else %}alert-info text-(--color-info-content){% endif %} font-semibold mb-4 flex justify-between items-center">
<span>{{ message }}</span>
<button class="btn btn-xs btn-circle border-none bg-black/20" onclick="this.parentElement.remove();" aria-label="Dismiss"></button>
</div>
{% endfor %}
</div>
{% endif %}

View file

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% load i18n %}
{% load allauth %}
{% block head_title %}{% trans "Account Inactive" %}{% endblock head_title %}
{% block content %}
<div class="container mx-auto max-w-md mt-6 text-center">
<h1 class="text-3xl font-bold mb-6">{% trans "Account Inactive" %}</h1>
<p>
{% trans "This account is inactive." %}
</p>
</div>
{% endblock content %}

View file

@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% load i18n %}
{% load allauth account %}
{% block head_title %}{% trans "Email Verification" %}{% endblock head_title %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h1 class="text-3xl font-bold text-center mb-6">{% trans "Enter Email Verification Code" %}</h1>
<p class="text-center mb-4">
<a class="text-primary underline" href="mailto:{{ email }}">{{ email }}</a>
</p>
{% url 'account_email_verification_sent' as action_url %}
<form method="post" action="{{ action_url }}" class="space-y-4">
{% csrf_token %}
{{ form.non_field_errors }}
<div>
<label for="{{ form.code.id_for_label }}" class="block font-medium>{% trans "Verification Code" %}</label>
{{ form.code }}
{{ form.code.errors }}
</div>
<button type="submit" class="btn btn-primary w-full">{% trans "Confirm" %}</button>
</form>
</div>
{% endblock content %}

View file

@ -0,0 +1,29 @@
{% extends 'base.html' %}
{% load i18n %}
{% load allauth account %}
{% block head_title %}{% trans "Sign In" %}{% endblock head_title %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h1 class="text-3xl font-bold text-center mb-6">{% trans "Enter Sign-In Code" %}</h1>
<p class="text-center mb-4">
{% if email %}
<a class="text-primary underline" href="mailto:{{ email }}">{{ email }}</a>
{% else %}
<a class="text-primary underline" href="tel:{{ phone }}">{{ phone }}</a>
{% endif %}
</p>
{% url 'account_confirm_login_code' as action_url %}
<form method="post" action="{{ action_url }}" class="space-y-4">
{% csrf_token %}
{{ form.non_field_errors }}
<div>
<label for="{{ form.code.id_for_label }}" class="block font-medium>{% trans "Sign-In Code" %}</label>
{{ form.code }}
{{ form.code.errors }}
</div>
<button type="submit" class="btn btn-primary w-full">{% trans "Confirm" %}</button>
</form>
</div>
{% endblock content %}

View file

@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% load i18n %}
{% load allauth account %}
{% block head_title %}{% trans "Password Reset" %}{% endblock head_title %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h1 class="text-3xl font-bold text-center mb-6">{% trans "Enter Password Reset Code" %}</h1>
<p class="text-center mb-4">
<a class="text-primary underline" href="mailto:{{ email }}">{{ email }}</a>
</p>
{% url 'account_confirm_password_reset_code' as action_url %}
<form method="post" action="{{ action_url }}" class="space-y-4">
{% csrf_token %}
{{ form.non_field_errors }}
<div>
<label for="{{ form.code.id_for_label }}" class="block font-medium>{% trans "Reset Code" %}</label>
{{ form.code }}
{{ form.code.errors }}
</div>
<button type="submit" class="btn btn-primary w-full">{% trans "Confirm" %}</button>
</form>
</div>
{% endblock content %}

View file

@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% load i18n %}
{% load allauth account %}
{% block head_title %}{% trans "Phone Verification" %}{% endblock head_title %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h1 class="text-3xl font-bold text-center mb-6">{% trans "Enter Phone Verification Code" %}</h1>
<p class="text-center mb-4">
<a class="text-primary underline" href="tel:{{ phone }}">{{ phone }}</a>
</p>
{% url 'account_verify_phone' as action_url %}
<form method="post" action="{{ action_url }}" class="space-y-4">
{% csrf_token %}
{{ form.non_field_errors }}
<div>
<label for="{{ form.code.id_for_label }}" class="block font-medium>{% trans "Verification Code" %}</label>
{{ form.code }}
{{ form.code.errors }}
</div>
<button type="submit" class="btn btn-primary w-full">{% trans "Confirm" %}</button>
</form>
</div>
{% endblock content %}

View file

@ -0,0 +1,228 @@
{% extends "base.html" %}
{% load i18n static crispy_forms_tags gravatar %}
{% block head_title %}{{ _('Dashboard') }}{% endblock %}
{% block content %}
<div class="container mx-auto" x-data="{ activeTab: '{{ active_tab|default:'dash' }}' }">
<!-- Tab Navigation -->
<div class="tabs tabs-border mb-8">
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'dash'}" @click="activeTab = 'dash'">{{ _('Dash') }}</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'dashboard_offers'}" @click="activeTab = 'dashboard_offers'">{{ _('Your Trade Offers') }} ({{ dashboard_offers_paginated.page_obj.count }})</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'waiting_on_you'}" @click="activeTab = 'waiting_on_you'">{{ _('Waiting on You') }} ({{ trade_acceptances_waiting_paginated.page_obj.count }})</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'waiting_on_them'}" @click="activeTab = 'waiting_on_them'">{{ _('Waiting on Them') }} ({{ other_party_trade_acceptances_paginated.page_obj.count }})</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'trade_history'}" @click="activeTab = 'trade_history'">{{ _('Trade History') }}</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'profile'}" @click="activeTab = 'profile'">{{ _('Profile') }}</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'friend_codes'}" @click="activeTab = 'friend_codes'">{{ _('Friend Codes') }}</button>
<button type="button" :class="{'tab': true, 'tab-active': activeTab === 'settings'}" @click="activeTab = 'settings'">{{ _('Settings') }}</button>
</div>
<!-- Tab Panels -->
<!-- Dash Tab - Dashboard Summary -->
<div x-show="activeTab === 'dash'">
<div class="card bg-base-100 shadow-xl mb-4">
<div class="card-body">
<h2 class="card-title mb-2">{{ _('Trade Summary') }}</h2>
<div class="flex flex-col md:flex-row justify-center gap-4">
<div class="stats shadow-lg bg-base-300">
<div class="stat">
<div class="stat-title">{{ _('Your Reputation') }}</div>
<div class="stat-value">{{ request.user.reputation_score }}</div>
<div class="stat-desc">{{ _('Current Score') }}</div>
</div>
<div class="stat">
<div class="stat-title">{{ _('Your Trade Offers') }}</div>
<div class="stat-value">{{ dashboard_offers_paginated.page_obj.count }}</div>
<div class="stat-desc">{{ _('Active Offers') }}</div>
</div>
</div>
<div class="stats shadow-lg bg-base-300">
<div class="stat">
<div class="stat-title">{{ _('Waiting on You') }}</div>
<div class="stat-value">{{ trade_acceptances_waiting_paginated.page_obj.count }}</div>
<div class="stat-desc">{{ _('Pending Requests') }}</div>
</div>
<div class="stat">
<div class="stat-title">{{ _('Waiting on Them') }}</div>
<div class="stat-value">{{ other_party_trade_acceptances_paginated.page_obj.count }}</div>
<div class="stat-desc">{{ _('Pending Responses') }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-2">{{ _('Quick Actions') }}</h2>
<div class="flex flex-wrap gap-4">
<a href="{% url 'trade_offer_create' %}" class="btn btn-primary grow">{{ _('Create New Offer') }}</a>
<a href="{% url 'trade_offer_list' %}" class="btn btn-secondary grow">{{ _('View All Offers') }}</a>
<a href="{% url 'account_logout' %}" class="btn btn-warning grow">{{ _('Sign Out') }}</a>
</div>
</div>
</div>
</div>
<!-- Your Trade Offers Tab -->
<div x-show="activeTab === 'dashboard_offers'" x-data="tradeOffersPagination('{% url 'dashboard' %}?ajax_section=dashboard_offers')">
{% include 'trades/_trade_offer_list.html' with offers=dashboard_offers_paginated.object_list page_obj=dashboard_offers_paginated.page_obj %}
</div>
<!-- Waiting on You Tab -->
<div x-show="activeTab === 'waiting_on_you'" x-data="tradeOffersPagination('{% url 'dashboard' %}?ajax_section=waiting_acceptances')">
{% include 'trades/_trade_offer_list.html' with offers=trade_acceptances_waiting_paginated.object_list page_obj=trade_acceptances_waiting_paginated.page_obj %}
</div>
<!-- Waiting on Them Tab -->
<div x-show="activeTab === 'waiting_on_them'" x-data="tradeOffersPagination('{% url 'dashboard' %}?ajax_section=other_party_acceptances')">
{% include 'trades/_trade_offer_list.html' with offers=other_party_trade_acceptances_paginated.object_list page_obj=other_party_trade_acceptances_paginated.page_obj %}
</div>
<!-- Trade History Tab -->
<div x-show="activeTab === 'trade_history'">
<div class="divider">{{ _('Closed Offers') }} ({{ closed_offers_paginated.page_obj.count }})</div>
<div class="mb-8">
{% include 'trades/_trade_offer_list.html' with offers=closed_offers_paginated.object_list page_obj=closed_offers_paginated.page_obj %}
</div>
<div class="divider">{{ _('Closed Acceptances') }} ({{ closed_acceptances_paginated.page_obj.count }})</div>
<div class="mb-8">
{% include 'trades/_trade_offer_list.html' with offers=closed_acceptances_paginated.object_list page_obj=closed_acceptances_paginated.page_obj %}
</div>
<div class="divider">{{ _('Rejected by Them') }} ({{ rejected_by_them_paginated.page_obj.count }})</div>
<div class="mb-8">
{% include 'trades/_trade_offer_list.html' with offers=rejected_by_them_paginated.object_list page_obj=rejected_by_them_paginated.page_obj %}
</div>
<div class="divider">{{ _('Rejected by Me') }} ({{ rejected_by_me_paginated.page_obj.count }})</div>
<div class="mb-8">
{% include 'trades/_trade_offer_list.html' with offers=rejected_by_me_paginated.object_list page_obj=rejected_by_me_paginated.page_obj %}
</div>
</div>
<!-- Profile Tab -->
<div x-show="activeTab === 'profile'">
<div class="card card-border bg-base-100 shadow-lg mx-auto p-6 mb-4">
{% with gravatar_profile=request.user.email|gravatar_profile_data %}
<div class="hovercard-profile mb-4 text-center">
<div class="avatar block mx-auto max-w-32">
<div class="rounded-full">
{{ request.user.email|gravatar:128 }}
</div>
</div>
<a href="https://gravatar.com/profile/avatars" target="_blank" rel="noopener noreferrer" class="btn btn-primary mt-4">
Edit Avatar on Gravatar
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
</div>
{% endwith %}
<div class="divider"></div>
<h2 class="text-base font-semibold pt-0">What is Gravatar?</h2>
<p class="mb-4 text-sm">Gravatar (Globally Recognized Avatar) is a free service that links your email address to a profile picture. Many websites, including this one, use Gravatar to display your preferred avatar automatically.</p>
<h2 class="text-base font-semibold">How does it work?</h2>
<p class="mb-4 text-sm">If you've set up a Gravatar, your profile picture will appear here whenever you use your email on supported sites. If you don't have a Gravatar yet, you'll see a default randomly-generated avatar instead.</p>
<h2 class="text-base font-semibold">Is it safe? What about privacy?</h2>
<p class="mb-4 text-sm">Gravatar is completely optional, opt-in, and prioritizes your security and privacy. Your email is never visible to anyone and only a hashed version is shown on the page and sent to Gravatar, protecting your identity while ensuring that your email address is not exposed to bots or scrapers.</p>
<h2 class="text-base font-semibold">Want to update or add a Gravatar?</h2>
<p class="mb-4 text-sm">Go to Gravatar.com to set up or change your avatar. Your updates will appear here once saved!</p>
</div>
</div>
<!-- Friend Codes Tab -->
<div x-show="activeTab === 'friend_codes'">
<div class="card card-border bg-base-100 shadow-lg mx-auto p-6 mb-4">
{% if friend_codes %}
<ul class="space-y-2">
{% for code in friend_codes %}
<li class="w-full grid grid-cols-2 grid-rows-2 md:grid-cols-8 md:grid-rows-1 items-center {% if code.is_default %}bg-green-200 dark:bg-green-300 dark:text-base-100{% else %}bg-base-100 dark:bg-base-900 dark:text-white{% endif %} p-4 rounded shadow">
<div class="row-start-1 md:col-span-3">
<span class="align-baseline"><a href="{% url 'edit_friend_code' code.id %}" class="link link-hover">{{ code.in_game_name }}</a></span>
{% if code.is_default %}
<span class="badge badge-success ml-2 align-baseline">Default</span>
{% endif %}
</div>
<div class="row-start-2 col-start-1 md:row-start-1 md:col-span-3 {% if not code.is_default %}mr-4{% endif %}">
<span class="font-mono text-sm sm:text-base align-baseline">{{ code.friend_code }}</span>
</div>
<div class="row-start-2 col-start-2 md:row-start-1 md:col-span-2 flex justify-end space-x-2">
{% if not code.is_default %}
<form method="post" action="{% url 'change_default_friend_code' code.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-secondary btn-sm align-baseline">Set Default</button>
</form>
{% endif %}
<a href="{% url 'delete_friend_code' code.id %}" class="btn btn-error btn-sm align-baseline">Delete</a>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>You do not have any friend codes added yet.</p>
{% endif %}
<div class="mt-4 flex justify-end">
<a href="{% url 'add_friend_code' %}" class="btn btn-primary">Add a New Friend Code</a>
</div>
</div>
</div>
<!-- Settings Tab -->
<div x-show="activeTab === 'settings'">
<div class="card card-border bg-base-100 shadow-lg mx-auto p-6 mb-4">
<form method="post" action="{% url 'dashboard' %}">
{% csrf_token %}
{{ settings_form|crispy }}
<div class="flex justify-end">
<button type="submit" name="update_settings" class="btn btn-success mt-4">{{ _('Save Settings') }}</button>
</div>
</form>
</div>
</div>
</div>
<script>
function tradeOffersPagination(baseUrl) {
return {
baseUrl: baseUrl,
_hasChangePageListener: false,
loadPage(page) {
let url = new URL(this.baseUrl, window.location.origin);
url.searchParams.set("page", page);
fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(response => response.text())
.then(html => {
this.$el.innerHTML = html;
this.init();
window.processMarqueeElements && window.processMarqueeElements();
});
},
init() {
if (!this._hasChangePageListener) {
this.$el.addEventListener('change-page', event => {
let page = event.detail.page;
this.loadPage(page);
});
this._hasChangePageListener = true;
}
this.$el.querySelectorAll("a.ajax-page-link").forEach(link => {
link.addEventListener("click", (event) => {
event.preventDefault();
let page = link.getAttribute("data-page");
this.loadPage(page);
});
});
}
}
}
</script>
{% endblock %}

View file

@ -0,0 +1,67 @@
{% extends 'base.html' %}
{% load static allauth i18n %}
{% block head_title %}{% trans "Email Addresses" %}{% endblock head_title %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h1 class="text-3xl font-bold text-center mb-6">{% trans "Email Addresses" %}</h1>
{% if emailaddresses %}
<p class="mb-4">
{% trans "The following email addresses are associated with your account:" %}
</p>
{% url 'account_email' as email_url %}
<form method="post" action="{{ email_url }}" class="space-y-4">
{% csrf_token %}
{% for radio in emailaddress_radios %}
<div class="flex items-center">
<input type="radio" id="{{ radio.id }}" name="email" value="{{ radio.emailaddress.email }}" class="mr-2" {% if radio.checked %}checked{% endif %}>
<label for="{{ radio.id }}">
{{ radio.emailaddress.email }}
{% if radio.emailaddress.verified %}
<span class="ml-1 bg-green-200 text-green-800 px-2 py-1 rounded text-xs">{% trans "Verified" %}</span>
{% else %}
<span class="ml-1 bg-yellow-200 text-yellow-800 px-2 py-1 rounded text-xs">{% trans "Unverified" %}</span>
{% endif %}
{% if radio.emailaddress.primary %}
<span class="ml-1 bg-blue-200 text-blue-800 px-2 py-1 rounded text-xs">{% trans "Primary" %}</span>
{% endif %}
</label>
</div>
{% endfor %}
<div class="flex flex-col md:flex-row md:space-x-2">
<button type="submit" name="action_primary" class="btn btn-primary w-full">{% trans "Make Primary" %}</button>
<button type="submit" name="action_send" class="btn btn-secondary w-full">{% trans "Re-send Verification" %}</button>
<button type="submit" name="action_remove" class="btn btn-danger w-full">{% trans "Remove" %}</button>
</div>
</form>
{% else %}
{% include "account/snippets/warn_no_email.html" %}
{% endif %}
{% if can_add_email %}
<h2 class="text-2xl font-bold mt-8 mb-4">{% trans "Add Email Address" %}</h2>
{% url 'account_email' as action_url %}
<form method="post" action="{{ action_url }}" class="space-y-4">
{% csrf_token %}
<div>
<label for="{{ form.email.id_for_label }}" class="block font-medium>{{ form.email.label }}</label>
{{ form.email }}
{{ form.email.errors }}
</div>
<button type="submit" name="action_add" class="btn btn-primary w-full">{% trans "Add Email" %}</button>
</form>
{% endif %}
</div>
{% endblock content %}
{% block extra_body %}
<script src="{% static 'account/js/account.js' %}"></script>
<script src="{% static 'account/js/onload.js' %}"></script>
<script data-allauth-onload="allauth.account.forms.manageEmailForm" type="application/json">
{
"i18n": {"confirmDelete": "{% trans 'Do you really want to remove the selected email address?' %}"}
}
</script>
{% endblock extra_body %}

View file

@ -0,0 +1,13 @@
{% extends "account/email/base_message.txt" %}
{% load i18n %}
{% block content %}{% autoescape off %}{% blocktrans %}You are receiving this email because you or someone else tried to signup for an
account using email address:
{{ email }}
However, an account using that email address already exists. In case you have
forgotten about this, please use the password forgotten procedure to recover
your account:
{{ password_reset_url }}{% endblocktrans %}{% endautoescape %}{% endblock content %}

View file

@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Account Already Exists{% endblocktrans %}
{% endautoescape %}

View file

@ -0,0 +1,7 @@
{% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name %}Hello from {{ site_name }}!{% endblocktrans %}
{% block content %}{% endblock content %}
{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you for using {{ site_name }}!
{{ site_domain }}{% endblocktrans %}
{% endautoescape %}

View file

@ -0,0 +1,14 @@
{% extends "account/email/base_message.txt" %}
{% load account %}
{% load i18n %}
{% block content %}{% autoescape off %}{% blocktrans %}You are receiving this mail because the following change was made to your account:{% endblocktrans %}
{% block notification_message %}
{% endblock notification_message%}
{% blocktrans %}If you do not recognize this change then please take proper security precautions immediately. The change to your account originates from:
- IP address: {{ip}}
- Browser: {{user_agent}}
- Date: {{timestamp}}{% endblocktrans %}{% endautoescape %}{% endblock %}

View file

@ -0,0 +1,4 @@
{% extends "account/email/base_notification.txt" %}
{% load i18n %}
{% block notification_message %}{% blocktrans %}Your email has been changed from {{ from_email }} to {{ to_email }}.{% endblocktrans %}{% endblock notification_message %}

View file

@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Email Changed{% endblocktrans %}
{% endautoescape %}

View file

@ -0,0 +1,4 @@
{% extends "account/email/base_notification.txt" %}
{% load i18n %}
{% block notification_message %}{% blocktrans %}Your email has been confirmed.{% endblocktrans %}{% endblock notification_message %}

View file

@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Email Confirmation{% endblocktrans %}
{% endautoescape %}

View file

@ -0,0 +1,9 @@
{% extends "account/email/base_message.txt" %}
{% load account %}
{% load i18n %}
{% block content %}{% autoescape off %}{% user_display user as user_display %}{% blocktranslate with site_name=current_site.name site_domain=current_site.domain %}You're receiving this email because user {{ user_display }} has given your email address to register an account on {{ site_domain }}.{% endblocktranslate %}
{% if code %}{% blocktranslate %}Your email verification code is listed below. Please enter it in your open browser window.{% endblocktranslate %}
{{ code }}{% else %}{% blocktranslate %}To confirm this is correct, go to {{ activate_url }}{% endblocktranslate %}{% endif %}{% endautoescape %}{% endblock content %}

View file

@ -0,0 +1 @@
{% include "account/email/email_confirmation_message.txt" %}

View file

@ -0,0 +1 @@
{% include "account/email/email_confirmation_subject.txt" %}

View file

@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Please Confirm Your Email Address{% endblocktrans %}
{% endautoescape %}

View file

@ -0,0 +1,4 @@
{% extends "account/email/base_notification.txt" %}
{% load i18n %}
{% block notification_message %}{% blocktrans %}Email address {{ deleted_email }} has been removed from your account.{% endblocktrans %}{% endblock notification_message %}

View file

@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Email Removed{% endblocktrans %}
{% endautoescape %}

View file

@ -0,0 +1,9 @@
{% extends "account/email/base_message.txt" %}
{% load account %}
{% load i18n %}
{% block content %}{% autoescape off %}{% blocktranslate %}Your sign-in code is listed below. Please enter it in your open browser window.{% endblocktranslate %}{% endautoescape %}
{{ code }}
{% blocktranslate %}This mail can be safely ignored if you did not initiate this action.{% endblocktranslate %}{% endblock content %}

Some files were not shown because too many files have changed in this diff Show more