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:
parent
959b06c425
commit
762361a21b
210 changed files with 235 additions and 168 deletions
0
src/pkmntrade_club/__init__.py
Normal file
0
src/pkmntrade_club/__init__.py
Normal file
0
src/pkmntrade_club/accounts/__init__.py
Normal file
0
src/pkmntrade_club/accounts/__init__.py
Normal file
30
src/pkmntrade_club/accounts/admin.py
Normal file
30
src/pkmntrade_club/accounts/admin.py
Normal 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)
|
||||
6
src/pkmntrade_club/accounts/apps.py
Normal file
6
src/pkmntrade_club/accounts/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'pkmntrade_club.accounts'
|
||||
90
src/pkmntrade_club/accounts/forms.py
Normal file
90
src/pkmntrade_club/accounts/forms.py
Normal 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']
|
||||
0
src/pkmntrade_club/accounts/management/__init__.py
Normal file
0
src/pkmntrade_club/accounts/management/__init__.py
Normal 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')
|
||||
66
src/pkmntrade_club/accounts/migrations/0001_initial.py
Normal file
66
src/pkmntrade_club/accounts/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
src/pkmntrade_club/accounts/migrations/__init__.py
Normal file
0
src/pkmntrade_club/accounts/migrations/__init__.py
Normal file
1
src/pkmntrade_club/accounts/migrations/max_migration.txt
Normal file
1
src/pkmntrade_club/accounts/migrations/max_migration.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
0001_initial
|
||||
70
src/pkmntrade_club/accounts/models.py
Normal file
70
src/pkmntrade_club/accounts/models.py
Normal 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
|
||||
83
src/pkmntrade_club/accounts/templatetags/gravatar.py
Normal file
83
src/pkmntrade_club/accounts/templatetags/gravatar.py
Normal 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 {}
|
||||
638
src/pkmntrade_club/accounts/tests.py
Normal file
638
src/pkmntrade_club/accounts/tests.py
Normal 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 sign‑up 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)
|
||||
16
src/pkmntrade_club/accounts/urls.py
Normal file
16
src/pkmntrade_club/accounts/urls.py
Normal 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"),
|
||||
]
|
||||
349
src/pkmntrade_club/accounts/views.py
Normal file
349
src/pkmntrade_club/accounts/views.py
Normal 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)
|
||||
0
src/pkmntrade_club/cards/__init__.py
Normal file
0
src/pkmntrade_club/cards/__init__.py
Normal file
7
src/pkmntrade_club/cards/admin.py
Normal file
7
src/pkmntrade_club/cards/admin.py
Normal 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)
|
||||
8
src/pkmntrade_club/cards/apps.py
Normal file
8
src/pkmntrade_club/cards/apps.py
Normal 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
|
||||
71
src/pkmntrade_club/cards/migrations/0001_initial.py
Normal file
71
src/pkmntrade_club/cards/migrations/0001_initial.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
0
src/pkmntrade_club/cards/migrations/__init__.py
Normal file
0
src/pkmntrade_club/cards/migrations/__init__.py
Normal file
1
src/pkmntrade_club/cards/migrations/max_migration.txt
Normal file
1
src/pkmntrade_club/cards/migrations/max_migration.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
0001_initial
|
||||
42
src/pkmntrade_club/cards/mixins.py
Normal file
42
src/pkmntrade_club/cards/mixins.py
Normal 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
|
||||
53
src/pkmntrade_club/cards/models.py
Normal file
53
src/pkmntrade_club/cards/models.py
Normal 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})"
|
||||
51
src/pkmntrade_club/cards/signals.py
Normal file
51
src/pkmntrade_club/cards/signals.py
Normal 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"])
|
||||
0
src/pkmntrade_club/cards/templatetags/__init__.py
Normal file
0
src/pkmntrade_club/cards/templatetags/__init__.py
Normal file
46
src/pkmntrade_club/cards/templatetags/card_badge.py
Normal file
46
src/pkmntrade_club/cards/templatetags/card_badge.py
Normal 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)
|
||||
72
src/pkmntrade_club/cards/templatetags/card_multiselect.py
Normal file
72
src/pkmntrade_club/cards/templatetags/card_multiselect.py
Normal 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
|
||||
288
src/pkmntrade_club/cards/tests.py
Normal file
288
src/pkmntrade_club/cards/tests.py
Normal 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)
|
||||
16
src/pkmntrade_club/cards/urls.py
Normal file
16
src/pkmntrade_club/cards/urls.py
Normal 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'),
|
||||
]
|
||||
148
src/pkmntrade_club/cards/views.py
Normal file
148
src/pkmntrade_club/cards/views.py
Normal 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
|
||||
0
src/pkmntrade_club/common/__init__.py
Normal file
0
src/pkmntrade_club/common/__init__.py
Normal file
8
src/pkmntrade_club/common/apps.py
Normal file
8
src/pkmntrade_club/common/apps.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommonConfig(AppConfig):
|
||||
name = "pkmntrade_club.common"
|
||||
|
||||
def ready(self):
|
||||
pass
|
||||
6
src/pkmntrade_club/common/context_processors.py
Normal file
6
src/pkmntrade_club/common/context_processors.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.conf import settings
|
||||
|
||||
def cache_settings(request):
|
||||
return {
|
||||
'CACHE_TIMEOUT': settings.CACHE_TIMEOUT,
|
||||
}
|
||||
34
src/pkmntrade_club/common/mixins.py
Normal file
34
src/pkmntrade_club/common/mixins.py
Normal 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
|
||||
0
src/pkmntrade_club/common/templatetags/__init__.py
Normal file
0
src/pkmntrade_club/common/templatetags/__init__.py
Normal file
10
src/pkmntrade_club/common/templatetags/pagination_tags.py
Normal file
10
src/pkmntrade_club/common/templatetags/pagination_tags.py
Normal 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}
|
||||
0
src/pkmntrade_club/django_project/__init__.py
Normal file
0
src/pkmntrade_club/django_project/__init__.py
Normal file
7
src/pkmntrade_club/django_project/asgi.py
Normal file
7
src/pkmntrade_club/django_project/asgi.py
Normal 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()
|
||||
20
src/pkmntrade_club/django_project/middleware.py
Normal file
20
src/pkmntrade_club/django_project/middleware.py
Normal 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")
|
||||
|
||||
320
src/pkmntrade_club/django_project/settings.py
Normal file
320
src/pkmntrade_club/django_project/settings.py
Normal 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
|
||||
}
|
||||
}
|
||||
20
src/pkmntrade_club/django_project/urls.py
Normal file
20
src/pkmntrade_club/django_project/urls.py
Normal 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
|
||||
7
src/pkmntrade_club/django_project/wsgi.py
Normal file
7
src/pkmntrade_club/django_project/wsgi.py
Normal 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()
|
||||
0
src/pkmntrade_club/home/__init__.py
Normal file
0
src/pkmntrade_club/home/__init__.py
Normal file
1
src/pkmntrade_club/home/admin.py
Normal file
1
src/pkmntrade_club/home/admin.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from django.contrib import admin
|
||||
5
src/pkmntrade_club/home/apps.py
Normal file
5
src/pkmntrade_club/home/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HomeConfig(AppConfig):
|
||||
name = "pkmntrade_club.home"
|
||||
0
src/pkmntrade_club/home/migrations/__init__.py
Normal file
0
src/pkmntrade_club/home/migrations/__init__.py
Normal file
1
src/pkmntrade_club/home/models.py
Normal file
1
src/pkmntrade_club/home/models.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from django.db import models
|
||||
591
src/pkmntrade_club/home/tests.py
Normal file
591
src/pkmntrade_club/home/tests.py
Normal 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)
|
||||
9
src/pkmntrade_club/home/urls.py
Normal file
9
src/pkmntrade_club/home/urls.py
Normal 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"),
|
||||
]
|
||||
164
src/pkmntrade_club/home/views.py
Normal file
164
src/pkmntrade_club/home/views.py
Normal 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")
|
||||
0
src/pkmntrade_club/static/__init__.py
Normal file
0
src/pkmntrade_club/static/__init__.py
Normal file
48
src/pkmntrade_club/static/css/base.css
Normal file
48
src/pkmntrade_club/static/css/base.css
Normal 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;
|
||||
}
|
||||
88
src/pkmntrade_club/static/css/card-multiselect.css
Normal file
88
src/pkmntrade_club/static/css/card-multiselect.css
Normal 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;
|
||||
}
|
||||
1
src/pkmntrade_club/static/css/choices.min.css
vendored
Normal file
1
src/pkmntrade_club/static/css/choices.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
src/pkmntrade_club/static/css/hovercards.min.css
vendored
Normal file
3
src/pkmntrade_club/static/css/hovercards.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
src/pkmntrade_club/static/images/favicon.ico
Normal file
BIN
src/pkmntrade_club/static/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 549 B |
1
src/pkmntrade_club/static/js/alpinejs.collapse@3.14.8.min.js
vendored
Normal file
1
src/pkmntrade_club/static/js/alpinejs.collapse@3.14.8.min.js
vendored
Normal 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)});})();
|
||||
5
src/pkmntrade_club/static/js/alpinejs@3.14.8.min.js
vendored
Normal file
5
src/pkmntrade_club/static/js/alpinejs@3.14.8.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
146
src/pkmntrade_club/static/js/base.js
Normal file
146
src/pkmntrade_club/static/js/base.js
Normal 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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
219
src/pkmntrade_club/static/js/card-multiselect.js
Normal file
219
src/pkmntrade_club/static/js/card-multiselect.js
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
2
src/pkmntrade_club/static/js/choices.min.js
vendored
Normal file
2
src/pkmntrade_club/static/js/choices.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/pkmntrade_club/static/js/floating-ui_core@1.6.9.9.min.js
vendored
Normal file
1
src/pkmntrade_club/static/js/floating-ui_core@1.6.9.9.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/pkmntrade_club/static/js/floating-ui_dom@1.6.13.13.min.js
vendored
Normal file
1
src/pkmntrade_club/static/js/floating-ui_dom@1.6.13.13.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
src/pkmntrade_club/static/js/hovercards.min.js
vendored
Normal file
2
src/pkmntrade_club/static/js/hovercards.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
123
src/pkmntrade_club/static/js/tooltip.js
Normal file
123
src/pkmntrade_club/static/js/tooltip.js
Normal 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);
|
||||
});
|
||||
});
|
||||
10
src/pkmntrade_club/tests/utils/rarity.py
Normal file
10
src/pkmntrade_club/tests/utils/rarity.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
RARITY_MAPPING = {
|
||||
1: "🔷",
|
||||
2: "🔷🔷",
|
||||
3: "🔷🔷🔷",
|
||||
4: "🔷🔷🔷🔷",
|
||||
5: "⭐️",
|
||||
6: "⭐️⭐️",
|
||||
7: "⭐️⭐️⭐️",
|
||||
8: "👑"
|
||||
}
|
||||
0
src/pkmntrade_club/theme/__init__.py
Normal file
0
src/pkmntrade_club/theme/__init__.py
Normal file
5
src/pkmntrade_club/theme/apps.py
Normal file
5
src/pkmntrade_club/theme/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ThemeConfig(AppConfig):
|
||||
name = 'pkmntrade_club.theme'
|
||||
1777
src/pkmntrade_club/theme/static_src/package-lock.json
generated
Normal file
1777
src/pkmntrade_club/theme/static_src/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
src/pkmntrade_club/theme/static_src/package.json
Normal file
30
src/pkmntrade_club/theme/static_src/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
src/pkmntrade_club/theme/static_src/postcss.config.js
Normal file
8
src/pkmntrade_club/theme/static_src/postcss.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
//"postcss-import": {},
|
||||
//"postcss-simple-vars": {},
|
||||
//"postcss-nested": {},
|
||||
"@tailwindcss/postcss": {}
|
||||
},
|
||||
}
|
||||
116
src/pkmntrade_club/theme/static_src/src/styles.css
Normal file
116
src/pkmntrade_club/theme/static_src/src/styles.css
Normal 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;
|
||||
} */
|
||||
73
src/pkmntrade_club/theme/static_src/tailwind.config.js
Normal file
73
src/pkmntrade_club/theme/static_src/tailwind.config.js
Normal 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",
|
||||
};
|
||||
10
src/pkmntrade_club/theme/templates/403_csrf.html
Normal file
10
src/pkmntrade_club/theme/templates/403_csrf.html
Normal 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 %}
|
||||
10
src/pkmntrade_club/theme/templates/404.html
Normal file
10
src/pkmntrade_club/theme/templates/404.html
Normal 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 %}
|
||||
10
src/pkmntrade_club/theme/templates/500.html
Normal file
10
src/pkmntrade_club/theme/templates/500.html
Normal 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 %}
|
||||
10
src/pkmntrade_club/theme/templates/_messages.html
Normal file
10
src/pkmntrade_club/theme/templates/_messages.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
228
src/pkmntrade_club/theme/templates/account/dashboard.html
Normal file
228
src/pkmntrade_club/theme/templates/account/dashboard.html
Normal 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 %}
|
||||
67
src/pkmntrade_club/theme/templates/account/email.html
Normal file
67
src/pkmntrade_club/theme/templates/account/email.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{% load i18n %}
|
||||
{% autoescape off %}
|
||||
{% blocktrans %}Account Already Exists{% endblocktrans %}
|
||||
{% endautoescape %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{% load i18n %}
|
||||
{% autoescape off %}
|
||||
{% blocktrans %}Email Changed{% endblocktrans %}
|
||||
{% endautoescape %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{% load i18n %}
|
||||
{% autoescape off %}
|
||||
{% blocktrans %}Email Confirmation{% endblocktrans %}
|
||||
{% endautoescape %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{% include "account/email/email_confirmation_message.txt" %}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{% include "account/email/email_confirmation_subject.txt" %}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{% load i18n %}
|
||||
{% autoescape off %}
|
||||
{% blocktrans %}Please Confirm Your Email Address{% endblocktrans %}
|
||||
{% endautoescape %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{% load i18n %}
|
||||
{% autoescape off %}
|
||||
{% blocktrans %}Email Removed{% endblocktrans %}
|
||||
{% endautoescape %}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue