style: standardize string formatting and improve readability across multiple files

- Refactored string formatting in various files to use consistent double quotes.
- Improved readability by adding newlines in function definitions and method calls.
- Cleaned up unnecessary imports and ensured proper spacing for better code clarity.
- Updated management commands and context processors for consistent formatting.
- Enhanced the overall maintainability of the codebase by adhering to PEP 8 guidelines.
- Applied Ruff linting and formatting
This commit is contained in:
badblocks 2025-06-12 20:53:38 -07:00
parent 4b9e4f651e
commit 39a002e394
No known key found for this signature in database
43 changed files with 1661 additions and 1159 deletions

View file

@ -1,11 +1,15 @@
#!/usr/bin/env -S uv run #!/usr/bin/env -S uv run
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings") os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings"
)
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try: try:

View file

@ -2,4 +2,4 @@
from pkmntrade_club._version import __version__, get_version, get_version_info from pkmntrade_club._version import __version__, get_version, get_version_info
__all__ = ['__version__', 'get_version', 'get_version_info'] __all__ = ["__version__", "get_version", "get_version_info"]

View file

@ -1,5 +1,6 @@
from importlib.metadata import version, PackageNotFoundError from importlib.metadata import version, PackageNotFoundError
from setuptools_scm import get_version from setuptools_scm import get_version
""" """
Version module for pkmntrade.club Version module for pkmntrade.club
@ -7,55 +8,57 @@ This module provides version information from git tags via setuptools-scm.
""" """
try: try:
__version__ = version("pkmntrade-club") __version__ = version("pkmntrade-club")
except PackageNotFoundError: except PackageNotFoundError:
# Package is not installed, try to get version from setuptools_scm # Package is not installed, try to get version from setuptools_scm
try: try:
__version__ = get_version(root='../../..', relative_to=__file__) __version__ = get_version(root="../../..", relative_to=__file__)
except (ImportError, LookupError): except (ImportError, LookupError):
__version__ = "0.0.0+unknown" __version__ = "0.0.0+unknown"
def get_version(): def get_version():
"""Return the current version.""" """Return the current version."""
return __version__ return __version__
def get_version_info(): def get_version_info():
"""Return detailed version information.""" """Return detailed version information."""
import re import re
# Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease") # Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease")
match = re.match( match = re.match(
r'^(\d+)\.(\d+)\.(\d+)' r"^(\d+)\.(\d+)\.(\d+)"
r'(?:\.dev(\d+))?' r"(?:\.dev(\d+))?"
r'(?:\+g([a-f0-9]+))?' r"(?:\+g([a-f0-9]+))?"
r'(?:-(.+))?$', r"(?:-(.+))?$",
__version__ __version__,
) )
if match: if match:
major, minor, patch, dev, git_sha, prerelease = match.groups() major, minor, patch, dev, git_sha, prerelease = match.groups()
return {
"version": __version__,
"major": int(major),
"minor": int(minor),
"patch": int(patch),
"dev": int(dev) if dev else None,
"git_sha": git_sha,
"prerelease": prerelease,
"is_release": dev is None and not prerelease,
"is_prerelease": bool(prerelease),
"is_dev": dev is not None,
}
return { return {
'version': __version__, "version": __version__,
'major': int(major), "major": 0,
'minor': int(minor), "minor": 0,
'patch': int(patch), "patch": 0,
'dev': int(dev) if dev else None, "dev": None,
'git_sha': git_sha, "git_sha": None,
'prerelease': prerelease, "prerelease": None,
'is_release': dev is None and not prerelease, "is_release": False,
'is_prerelease': bool(prerelease), "is_prerelease": False,
'is_dev': dev is not None "is_dev": True,
} }
return {
'version': __version__,
'major': 0,
'minor': 0,
'patch': 0,
'dev': None,
'git_sha': None,
'prerelease': None,
'is_release': False,
'is_prerelease': False,
'is_dev': True
}

View file

@ -1,4 +1,3 @@
from django.conf import settings
from allauth.account.adapter import DefaultAccountAdapter from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
@ -10,4 +9,4 @@ class NoSignupAccountAdapter(DefaultAccountAdapter):
class NoSignupSocialAccountAdapter(DefaultSocialAccountAdapter): class NoSignupSocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request): def is_open_for_signup(self, request):
return False return False

View file

@ -1,5 +1,4 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from .forms import CustomUserCreationForm, CustomUserChangeForm from .forms import CustomUserCreationForm, CustomUserChangeForm

View file

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

View file

@ -2,15 +2,13 @@ from django import forms
from django.contrib.auth.forms import UserCreationForm, UserChangeForm from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import CustomUser, FriendCode from .models import CustomUser, FriendCode
from allauth.account.forms import SignupForm 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 CustomUserChangeForm(UserChangeForm):
class Meta: class Meta:
model = CustomUser model = CustomUser
fields = ['email'] fields = ["email"]
class FriendCodeForm(forms.ModelForm): class FriendCodeForm(forms.ModelForm):
class Meta: class Meta:
@ -27,23 +25,27 @@ class FriendCodeForm(forms.ModelForm):
friend_code_formatted = f"{friend_code_clean[:4]}-{friend_code_clean[4:8]}-{friend_code_clean[8:12]}-{friend_code_clean[12:16]}" 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 return friend_code_formatted
class CustomUserCreationForm(SignupForm):
class CustomUserCreationForm(SignupForm):
class Meta(UserCreationForm.Meta): class Meta(UserCreationForm.Meta):
model = CustomUser model = CustomUser
fields = ['email', 'username', 'friend_code'] fields = ["email", "username", "friend_code"]
email = forms.EmailField( email = forms.EmailField(
required=True, required=True,
label="Email", label="Email",
widget=forms.TextInput(attrs={'placeholder': 'Email', 'class':'dark:bg-base-100'}) widget=forms.TextInput(
attrs={"placeholder": "Email", "class": "dark:bg-base-100"}
),
) )
username = forms.CharField( username = forms.CharField(
max_length=24, max_length=24,
required=True, required=True,
label="Username", label="Username",
widget=forms.TextInput(attrs={'placeholder': 'Username', 'class':'dark:bg-base-100'}) widget=forms.TextInput(
attrs={"placeholder": "Username", "class": "dark:bg-base-100"}
),
) )
friend_code = forms.CharField( friend_code = forms.CharField(
@ -51,14 +53,18 @@ class CustomUserCreationForm(SignupForm):
required=True, required=True,
label="Friend Code", label="Friend Code",
help_text="Enter your friend code in the format XXXX-XXXX-XXXX-XXXX.", 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'}) widget=forms.TextInput(
attrs={"placeholder": "XXXX-XXXX-XXXX-XXXX", "class": "dark:bg-base-100"}
),
) )
in_game_name = forms.CharField( in_game_name = forms.CharField(
max_length=16, max_length=16,
required=True, required=True,
label="In-Game Name", label="In-Game Name",
help_text="Enter your in-game name.", help_text="Enter your in-game name.",
widget=forms.TextInput(attrs={'placeholder': 'In-Game Name', 'class':'dark:bg-base-100'}) widget=forms.TextInput(
attrs={"placeholder": "In-Game Name", "class": "dark:bg-base-100"}
),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -78,13 +84,14 @@ class CustomUserCreationForm(SignupForm):
friend_code_instance = FriendCode.objects.create( friend_code_instance = FriendCode.objects.create(
friend_code=self.cleaned_data["friend_code"], friend_code=self.cleaned_data["friend_code"],
in_game_name=self.cleaned_data["in_game_name"], in_game_name=self.cleaned_data["in_game_name"],
user=user user=user,
) )
user.default_friend_code = friend_code_instance user.default_friend_code = friend_code_instance
user.save() user.save()
return user return user
class UserSettingsForm(forms.ModelForm): class UserSettingsForm(forms.ModelForm):
class Meta: class Meta:
model = CustomUser model = CustomUser
fields = ['show_friend_code_on_link_previews', 'enable_email_notifications'] fields = ["show_friend_code_on_link_previews", "enable_email_notifications"]

View file

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

View file

@ -3,24 +3,28 @@ from django.db import models
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
import re import re
def validate_friend_code(value): def validate_friend_code(value):
"""Validate that friend code follows the format XXXX-XXXX-XXXX-XXXX where X is a digit.""" """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): if not re.match(r"^\d{4}-\d{4}-\d{4}-\d{4}$", value):
raise ValidationError( raise ValidationError(
'Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit.' "Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit."
) )
class CustomUser(AbstractUser): class CustomUser(AbstractUser):
default_friend_code = models.ForeignKey("FriendCode", on_delete=models.SET_NULL, null=True, blank=True) default_friend_code = models.ForeignKey(
"FriendCode", on_delete=models.SET_NULL, null=True, blank=True
)
show_friend_code_on_link_previews = models.BooleanField( show_friend_code_on_link_previews = models.BooleanField(
default=False, default=False,
verbose_name="Show Friend Code on Link Previews", verbose_name="Show Friend Code on Link Previews",
help_text="This will primarily affect share link previews on X, Discord, etc." help_text="This will primarily affect share link previews on X, Discord, etc.",
) )
enable_email_notifications = models.BooleanField( enable_email_notifications = models.BooleanField(
default=True, default=True,
verbose_name="Enable Email Notifications", verbose_name="Enable Email Notifications",
help_text="Receive trade notifications via email." help_text="Receive trade notifications via email.",
) )
reputation_score = models.IntegerField(default=0) reputation_score = models.IntegerField(default=0)
@ -47,10 +51,13 @@ class CustomUser(AbstractUser):
self.default_friend_code = other_codes.first() self.default_friend_code = other_codes.first()
self.save(update_fields=["default_friend_code"]) self.save(update_fields=["default_friend_code"])
class FriendCode(models.Model): class FriendCode(models.Model):
friend_code = models.CharField(max_length=19, validators=[validate_friend_code]) friend_code = models.CharField(max_length=19, validators=[validate_friend_code])
in_game_name = models.CharField(max_length=14, null=False, blank=False) in_game_name = models.CharField(max_length=14, null=False, blank=False)
user = models.ForeignKey(CustomUser, on_delete=models.PROTECT, related_name='friend_codes') user = models.ForeignKey(
CustomUser, on_delete=models.PROTECT, related_name="friend_codes"
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -67,4 +74,4 @@ class FriendCode(models.Model):
self.user.save(update_fields=["default_friend_code"]) self.user.save(update_fields=["default_friend_code"])
def __str__(self): def __str__(self):
return self.friend_code return self.friend_code

View file

@ -6,15 +6,17 @@ from django.utils.safestring import mark_safe
register = template.Library() register = template.Library()
@register.filter @register.filter
def gravatar_hash(email): def gravatar_hash(email):
""" """
Returns the hash of the email. Returns the hash of the email.
""" """
email_encoded = email.strip().lower().encode('utf-8') email_encoded = email.strip().lower().encode("utf-8")
email_hash = hashlib.sha256(email_encoded).hexdigest() email_hash = hashlib.sha256(email_encoded).hexdigest()
return email_hash return email_hash
@register.filter @register.filter
def gravatar_url(email, size=20): def gravatar_url(email, size=20):
""" """
@ -23,20 +25,22 @@ def gravatar_url(email, size=20):
""" """
default = "retro" default = "retro"
email_hash = gravatar_hash(email) email_hash = gravatar_hash(email)
params = urlencode({'d': default, 's': str(size)}) params = urlencode({"d": default, "s": str(size)})
params = params.replace("&", "&") params = params.replace("&", "&")
return f"https://www.gravatar.com/avatar/{email_hash}?{params}" return f"https://www.gravatar.com/avatar/{email_hash}?{params}"
@register.filter @register.filter
def gravatar_profile_url(email=None): def gravatar_profile_url(email=None):
""" """
Returns the Gravatar Profile URL for a given email. Returns the Gravatar Profile URL for a given email.
""" """
if email is None: if email is None:
return f"https://www.gravatar.com/profile" return "https://www.gravatar.com/profile"
email_hash = gravatar_hash(email) email_hash = gravatar_hash(email)
return f"https://secure.gravatar.com/{email_hash}" return f"https://secure.gravatar.com/{email_hash}"
@register.filter @register.filter
def gravatar(email, size=20): def gravatar(email, size=20):
""" """
@ -48,6 +52,7 @@ def gravatar(email, size=20):
html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar"></img>' html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar"></img>'
return mark_safe(html) return mark_safe(html)
@register.filter @register.filter
def gravatar_no_hover(email, size=20): def gravatar_no_hover(email, size=20):
""" """
@ -59,6 +64,7 @@ def gravatar_no_hover(email, size=20):
html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar" class="ignore"></img>' html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar" class="ignore"></img>'
return mark_safe(html) return mark_safe(html)
@register.filter @register.filter
def gravatar_profile_data(email): def gravatar_profile_data(email):
""" """

View file

@ -9,34 +9,34 @@ from django.core.exceptions import ValidationError
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
from pkmntrade_club.accounts.models import FriendCode from pkmntrade_club.accounts.models import FriendCode
from pkmntrade_club.accounts.forms import FriendCodeForm, CustomUserCreationForm, UserSettingsForm from pkmntrade_club.accounts.forms import (
FriendCodeForm,
CustomUserCreationForm,
UserSettingsForm,
)
from pkmntrade_club.accounts.templatetags import gravatar from pkmntrade_club.accounts.templatetags import gravatar
from pkmntrade_club.trades.models import TradeOffer from pkmntrade_club.trades.models import TradeOffer
from tests.utils.rarity import RARITY_MAPPING from tests.utils.rarity import RARITY_MAPPING
# Create your tests here. # Create your tests here.
# ----------------------------- # -----------------------------
# Model Tests # Model Tests
# ----------------------------- # -----------------------------
class CustomUserModelTests(TestCase): class CustomUserModelTests(TestCase):
def setUp(self): def setUp(self):
self.user = get_user_model().objects.create_user( self.user = get_user_model().objects.create_user(
username="testuser", username="testuser", email="test@example.com", password="password123"
email="test@example.com",
password="password123"
) )
def test_set_default_friend_code(self): def test_set_default_friend_code(self):
"""User can manually set a friend code as their default.""" """User can manually set a friend code as their default."""
fc1 = FriendCode.objects.create( fc1 = FriendCode.objects.create(
friend_code="1234-5678-9012-3456", friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne"
user=self.user,
in_game_name="GameOne"
) )
fc2 = FriendCode.objects.create( fc2 = FriendCode.objects.create(
friend_code="2345-6789-0123-4567", friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo"
user=self.user,
in_game_name="GameTwo"
) )
# Manually set fc2 as default. # Manually set fc2 as default.
self.user.set_default_friend_code(fc2) self.user.set_default_friend_code(fc2)
@ -48,14 +48,10 @@ class CustomUserModelTests(TestCase):
Attempting to set a friend code that does not belong to the user should raise an exception. 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( other_user = get_user_model().objects.create_user(
username="otheruser", username="otheruser", email="other@example.com", password="password456"
email="other@example.com",
password="password456"
) )
fc_other = FriendCode.objects.create( fc_other = FriendCode.objects.create(
friend_code="3456-7890-1234-5678", friend_code="3456-7890-1234-5678", user=other_user, in_game_name="OtherGame"
user=other_user,
in_game_name="OtherGame"
) )
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
self.user.set_default_friend_code(fc_other) self.user.set_default_friend_code(fc_other)
@ -66,14 +62,10 @@ class CustomUserModelTests(TestCase):
the default should be reassigned to another friend code. the default should be reassigned to another friend code.
""" """
fc1 = FriendCode.objects.create( fc1 = FriendCode.objects.create(
friend_code="1234-5678-9012-3456", friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne"
user=self.user,
in_game_name="GameOne"
) )
fc2 = FriendCode.objects.create( fc2 = FriendCode.objects.create(
friend_code="2345-6789-0123-4567", friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo"
user=self.user,
in_game_name="GameTwo"
) )
# Set fc2 as default. # Set fc2 as default.
self.user.set_default_friend_code(fc2) self.user.set_default_friend_code(fc2)
@ -89,9 +81,7 @@ class CustomUserModelTests(TestCase):
should be prohibited. should be prohibited.
""" """
fc = FriendCode.objects.create( fc = FriendCode.objects.create(
friend_code="1234-5678-9012-3456", friend_code="1234-5678-9012-3456", user=self.user, in_game_name="OnlyGame"
user=self.user,
in_game_name="OnlyGame"
) )
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc) self.assertEqual(self.user.default_friend_code, fc)
@ -104,21 +94,19 @@ class CustomUserModelTests(TestCase):
the current default should remain unchanged. the current default should remain unchanged.
""" """
fc1 = FriendCode.objects.create( fc1 = FriendCode.objects.create(
friend_code="1234-5678-9012-3456", friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne"
user=self.user,
in_game_name="GameOne"
) )
fc2 = FriendCode.objects.create( fc2 = FriendCode.objects.create(
friend_code="2345-6789-0123-4567", friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo"
user=self.user,
in_game_name="GameTwo"
) )
# By default, fc1 is the default friend code. # By default, fc1 is the default friend code.
self.assertEqual(self.user.default_friend_code, fc1) self.assertEqual(self.user.default_friend_code, fc1)
try: try:
self.user.remove_default_friend_code(fc2) self.user.remove_default_friend_code(fc2)
except Exception as e: except Exception:
self.fail("remove_default_friend_code raised an exception when removing a non-default code.") self.fail(
"remove_default_friend_code raised an exception when removing a non-default code."
)
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc1) self.assertEqual(self.user.default_friend_code, fc1)
@ -129,9 +117,7 @@ class CustomUserModelTests(TestCase):
class FriendCodeModelTests(TestCase): class FriendCodeModelTests(TestCase):
def setUp(self): def setUp(self):
self.user = get_user_model().objects.create_user( self.user = get_user_model().objects.create_user(
username="testuser2", username="testuser2", email="test2@example.com", password="password123"
email="test2@example.com",
password="password123"
) )
def test_default_set_on_creation(self): def test_default_set_on_creation(self):
@ -142,7 +128,7 @@ class FriendCodeModelTests(TestCase):
fc = FriendCode.objects.create( fc = FriendCode.objects.create(
friend_code="1234-5678-9012-3456", friend_code="1234-5678-9012-3456",
user=self.user, user=self.user,
in_game_name="GameDefault" in_game_name="GameDefault",
) )
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc) self.assertEqual(self.user.default_friend_code, fc)
@ -155,14 +141,14 @@ class FriendCodeModelTests(TestCase):
fc1 = FriendCode.objects.create( fc1 = FriendCode.objects.create(
friend_code="1111-1111-1111-1111", friend_code="1111-1111-1111-1111",
user=self.user, user=self.user,
in_game_name="PrimaryGame" in_game_name="PrimaryGame",
) )
# fc1 becomes the default automatically. # fc1 becomes the default automatically.
self.assertEqual(self.user.default_friend_code, fc1) self.assertEqual(self.user.default_friend_code, fc1)
fc2 = FriendCode.objects.create( fc2 = FriendCode.objects.create(
friend_code="2222-2222-2222-2222", friend_code="2222-2222-2222-2222",
user=self.user, user=self.user,
in_game_name="SecondaryGame" in_game_name="SecondaryGame",
) )
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc1) self.assertEqual(self.user.default_friend_code, fc1)
@ -174,39 +160,34 @@ class FriendCodeModelTests(TestCase):
class FriendCodeFormTests(TestCase): class FriendCodeFormTests(TestCase):
def test_valid_friend_code(self): def test_valid_friend_code(self):
"""Ensure valid friend code is cleaned and formatted properly.""" """Ensure valid friend code is cleaned and formatted properly."""
form_data = { form_data = {"friend_code": "1234567890123456", "in_game_name": "GameTest"}
"friend_code": "1234567890123456",
"in_game_name": "GameTest"
}
form = FriendCodeForm(data=form_data) form = FriendCodeForm(data=form_data)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["friend_code"], "1234-5678-9012-3456") self.assertEqual(form.cleaned_data["friend_code"], "1234-5678-9012-3456")
def test_invalid_friend_code_length(self): def test_invalid_friend_code_length(self):
"""Friend codes with incorrect length should cause validation errors.""" """Friend codes with incorrect length should cause validation errors."""
form_data = { form_data = {"friend_code": "12345", "in_game_name": "GameTest"}
"friend_code": "12345",
"in_game_name": "GameTest"
}
form = FriendCodeForm(data=form_data) form = FriendCodeForm(data=form_data)
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) self.assertIn(
"Friend code must be exactly 16 digits long.", form.errors["friend_code"]
)
def test_invalid_friend_code_characters(self): def test_invalid_friend_code_characters(self):
"""Friend codes containing non-digit characters should cause validation errors.""" """Friend codes containing non-digit characters should cause validation errors."""
form_data = { form_data = {"friend_code": "12345678901234ab", "in_game_name": "GameTest"}
"friend_code": "12345678901234ab",
"in_game_name": "GameTest"
}
form = FriendCodeForm(data=form_data) form = FriendCodeForm(data=form_data)
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) self.assertIn(
"Friend code must be exactly 16 digits long.", form.errors["friend_code"]
)
def test_friend_code_with_whitespace(self): def test_friend_code_with_whitespace(self):
"""Ensure that leading/trailing whitespace is stripped.""" """Ensure that leading/trailing whitespace is stripped."""
form_data = { form_data = {
"friend_code": " 1234567890123456 ", "friend_code": " 1234567890123456 ",
"in_game_name": "WhitespaceGame" "in_game_name": "WhitespaceGame",
} }
form = FriendCodeForm(data=form_data) form = FriendCodeForm(data=form_data)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
@ -216,7 +197,7 @@ class FriendCodeFormTests(TestCase):
"""Proper dashes in the input should be accepted.""" """Proper dashes in the input should be accepted."""
form_data = { form_data = {
"friend_code": "1234-5678-9012-3456", "friend_code": "1234-5678-9012-3456",
"in_game_name": "ExtraDashGame" "in_game_name": "ExtraDashGame",
} }
form = FriendCodeForm(data=form_data) form = FriendCodeForm(data=form_data)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
@ -292,7 +273,9 @@ class CustomUserCreationFormTests(TestCase):
} }
form = CustomUserCreationForm(data=form_data) form = CustomUserCreationForm(data=form_data)
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) self.assertIn(
"Friend code must be exactly 16 digits long.", form.errors["friend_code"]
)
def test_invalid_custom_user_creation_password_mismatch(self): def test_invalid_custom_user_creation_password_mismatch(self):
""" """
@ -318,7 +301,7 @@ class UserSettingsFormTests(TestCase):
self.user = get_user_model().objects.create_user( self.user = get_user_model().objects.create_user(
username="settingsuser", username="settingsuser",
email="settings@example.com", email="settings@example.com",
password="password123" password="password123",
) )
def test_toggle_show_friend_code_on_link_previews(self): def test_toggle_show_friend_code_on_link_previews(self):
@ -337,9 +320,7 @@ class UserSettingsFormTests(TestCase):
class FriendCodeViewsTests(TestCase): class FriendCodeViewsTests(TestCase):
def setUp(self): def setUp(self):
self.user = get_user_model().objects.create_user( self.user = get_user_model().objects.create_user(
username="viewuser", username="viewuser", email="viewuser@example.com", password="password123"
email="viewuser@example.com",
password="password123"
) )
# Log in this user. # Log in this user.
self.client.login(username="viewuser", password="password123") self.client.login(username="viewuser", password="password123")
@ -347,12 +328,12 @@ class FriendCodeViewsTests(TestCase):
self.friend_code1 = FriendCode.objects.create( self.friend_code1 = FriendCode.objects.create(
friend_code="7777-7777-7777-7777", friend_code="7777-7777-7777-7777",
user=self.user, user=self.user,
in_game_name="ViewGameOne" in_game_name="ViewGameOne",
) )
self.friend_code2 = FriendCode.objects.create( self.friend_code2 = FriendCode.objects.create(
friend_code="8888-8888-8888-8888", friend_code="8888-8888-8888-8888",
user=self.user, user=self.user,
in_game_name="ViewGameTwo" in_game_name="ViewGameTwo",
) )
# By default, friend_code1 is the default. # By default, friend_code1 is the default.
@ -390,8 +371,7 @@ class FriendCodeViewsTests(TestCase):
self.assertRedirects(response, reverse("list_friend_codes")) self.assertRedirects(response, reverse("list_friend_codes"))
self.assertTrue( self.assertTrue(
FriendCode.objects.filter( FriendCode.objects.filter(
user=self.user, user=self.user, friend_code="9999-9999-9999-9999"
friend_code="9999-9999-9999-9999"
).exists() ).exists()
) )
# Ensure that adding a new friend code does not change the default. # Ensure that adding a new friend code does not change the default.
@ -404,10 +384,16 @@ class FriendCodeViewsTests(TestCase):
data = {"friend_code": "invalidfriendcode", "in_game_name": "InvalidGame"} data = {"friend_code": "invalidfriendcode", "in_game_name": "InvalidGame"}
response = self.client.post(url, data) response = self.client.post(url, data)
# Extract the form from the response's context. If response.context is a list, use its first element. # 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 context = (
response.context[0]
if isinstance(response.context, list)
else response.context
)
form = context.get("form") form = context.get("form")
self.assertIsNotNone(form, "Form not found in response context") self.assertIsNotNone(form, "Form not found in response context")
self.assertFormError(form, "friend_code", "Friend code must be exactly 16 digits long.") self.assertFormError(
form, "friend_code", "Friend code must be exactly 16 digits long."
)
def test_edit_friend_code_view(self): def test_edit_friend_code_view(self):
"""Test editing the in-game name of an existing friend code.""" """Test editing the in-game name of an existing friend code."""
@ -425,14 +411,10 @@ class FriendCodeViewsTests(TestCase):
def test_edit_friend_code_view_wrong_user(self): 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.""" """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( other_user = get_user_model().objects.create_user(
username="otheruser", username="otheruser", email="other@example.com", password="password1234"
email="other@example.com",
password="password1234"
) )
friend_code_other = FriendCode.objects.create( friend_code_other = FriendCode.objects.create(
friend_code="0000-0000-0000-0000", friend_code="0000-0000-0000-0000", user=other_user, in_game_name="OtherGame"
user=other_user,
in_game_name="OtherGame"
) )
url = reverse("edit_friend_code", kwargs={"pk": friend_code_other.pk}) url = reverse("edit_friend_code", kwargs={"pk": friend_code_other.pk})
response = self.client.get(url) response = self.client.get(url)
@ -443,25 +425,25 @@ class FriendCodeViewsTests(TestCase):
url = reverse("edit_friend_code", kwargs={"pk": self.friend_code2.pk}) url = reverse("edit_friend_code", kwargs={"pk": self.friend_code2.pk})
new_data = {"in_game_name": ""} # in_game_name is required. new_data = {"in_game_name": ""} # in_game_name is required.
response = self.client.post(url, new_data) response = self.client.post(url, new_data)
context = response.context[0] if isinstance(response.context, list) else response.context context = (
response.context[0]
if isinstance(response.context, list)
else response.context
)
form = context.get("form") form = context.get("form")
self.assertIsNotNone(form, "Form not found in response context") self.assertIsNotNone(form, "Form not found in response context")
self.assertFormError(form, "in_game_name", "This field is required.") self.assertFormError(form, "in_game_name", "This field is required.")
def test_delete_friend_code_view_only_code(self): def test_delete_friend_code_view_only_code(self):
""" """
If the user has only one friend code, deletion should be disabled. If the user has only one friend code, deletion should be disabled.
This test uses a new user with a single friend code. This test uses a new user with a single friend code.
""" """
user_only = get_user_model().objects.create_user( user_only = get_user_model().objects.create_user(
username="onlyuser", username="onlyuser", email="onlyuser@example.com", password="password123"
email="onlyuser@example.com",
password="password123"
) )
friend_code_only = FriendCode.objects.create( friend_code_only = FriendCode.objects.create(
friend_code="4444-4444-4444-4444", friend_code="4444-4444-4444-4444", user=user_only, in_game_name="SoloGame"
user=user_only,
in_game_name="SoloGame"
) )
self.client.logout() self.client.logout()
self.client.login(username="onlyuser", password="password123") self.client.login(username="onlyuser", password="password123")
@ -492,7 +474,7 @@ class FriendCodeViewsTests(TestCase):
initiated_by=self.friend_code2, initiated_by=self.friend_code2,
is_closed=False, is_closed=False,
rarity_icon=RARITY_MAPPING[5], rarity_icon=RARITY_MAPPING[5],
rarity_level=5 rarity_level=5,
) )
url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk}) url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk})
response = self.client.post(url, {}) response = self.client.post(url, {})
@ -517,14 +499,10 @@ class FriendCodeViewsTests(TestCase):
def test_change_default_friend_code_view_not_owned(self): 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.""" """A friend code that does not belong to the current user should result in a 404."""
other_user = get_user_model().objects.create_user( other_user = get_user_model().objects.create_user(
username="otheruser2", username="otheruser2", email="other2@example.com", password="password789"
email="other2@example.com",
password="password789"
) )
friend_code_other = FriendCode.objects.create( friend_code_other = FriendCode.objects.create(
friend_code="1111-1111-1111-1111", friend_code="1111-1111-1111-1111", user=other_user, in_game_name="NotMine"
user=other_user,
in_game_name="NotMine"
) )
url = reverse("change_default_friend_code", kwargs={"pk": friend_code_other.pk}) url = reverse("change_default_friend_code", kwargs={"pk": friend_code_other.pk})
response = self.client.post(url, {}) response = self.client.post(url, {})
@ -561,12 +539,12 @@ class FriendCodeViewsTests(TestCase):
other_user = get_user_model().objects.create_user( other_user = get_user_model().objects.create_user(
username="otherdeluser", username="otherdeluser",
email="otherdel@example.com", email="otherdel@example.com",
password="password321" password="password321",
) )
friend_code_other = FriendCode.objects.create( friend_code_other = FriendCode.objects.create(
friend_code="2222-2222-2222-2222", friend_code="2222-2222-2222-2222",
user=other_user, user=other_user,
in_game_name="OtherDelete" in_game_name="OtherDelete",
) )
url = reverse("delete_friend_code", kwargs={"pk": friend_code_other.pk}) url = reverse("delete_friend_code", kwargs={"pk": friend_code_other.pk})
response = self.client.get(url) response = self.client.get(url)

View file

@ -9,8 +9,20 @@ from .views import (
urlpatterns = [ urlpatterns = [
path("friend-codes/add/", AddFriendCodeView.as_view(), name="add_friend_code"), 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(
path("friend-codes/delete/<int:pk>/", DeleteFriendCodeView.as_view(), name="delete_friend_code"), "friend-codes/edit/<int:pk>/",
path("friend-codes/default/<int:pk>/", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"), 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"), path("dashboard/", DashboardView.as_view(), name="dashboard"),
] ]

View file

@ -1,9 +1,14 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin 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.shortcuts import redirect, get_object_or_404, render
from django.views.generic import ListView, CreateView, DeleteView, View, TemplateView, UpdateView from django.views.generic import (
from pkmntrade_club.accounts.models import FriendCode, CustomUser CreateView,
DeleteView,
View,
TemplateView,
UpdateView,
)
from pkmntrade_club.accounts.models import FriendCode
from pkmntrade_club.accounts.forms import FriendCodeForm, UserSettingsForm from pkmntrade_club.accounts.forms import FriendCodeForm, UserSettingsForm
from django.db.models import Case, When, Value, BooleanField from django.db.models import Case, When, Value, BooleanField
from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance
@ -13,14 +18,17 @@ from pkmntrade_club.common.mixins import ReusablePaginationMixin
from django.urls import reverse from django.urls import reverse
from django.utils.http import urlencode from django.utils.http import urlencode
class AddFriendCodeView(LoginRequiredMixin, CreateView): class AddFriendCodeView(LoginRequiredMixin, CreateView):
""" """
Add a new friend code for the current user. If the user does not yet have a default, 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. the newly added code will automatically become the default.
""" """
model = FriendCode model = FriendCode
form_class = FriendCodeForm form_class = FriendCodeForm
template_name = "friend_codes/add_friend_code.html" template_name = "friend_codes/add_friend_code.html"
def get_success_url(self): def get_success_url(self):
base_url = reverse("dashboard") base_url = reverse("dashboard")
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}" return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
@ -30,6 +38,7 @@ class AddFriendCodeView(LoginRequiredMixin, CreateView):
messages.success(self.request, "Friend code added successfully.") messages.success(self.request, "Friend code added successfully.")
return super().form_valid(form) return super().form_valid(form)
class DeleteFriendCodeView(LoginRequiredMixin, DeleteView): class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
""" """
Remove an existing friend code. Remove an existing friend code.
@ -37,9 +46,11 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
Also, prevent deletion if the friend code is either the only one or Also, prevent deletion if the friend code is either the only one or
is set as the default friend code. is set as the default friend code.
""" """
model = FriendCode model = FriendCode
template_name = "friend_codes/confirm_delete_friend_code.html" template_name = "friend_codes/confirm_delete_friend_code.html"
context_object_name = "friend_code" context_object_name = "friend_code"
def get_success_url(self): def get_success_url(self):
base_url = reverse("dashboard") base_url = reverse("dashboard")
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}" return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
@ -81,48 +92,59 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
if user.default_friend_code == self.object: if user.default_friend_code == self.object:
messages.error( messages.error(
request, request,
"Cannot delete your default friend code. Please set a different default first." "Cannot delete your default friend code. Please set a different default first.",
) )
return redirect(self.get_success_url()) return redirect(self.get_success_url())
trade_offer_exists = TradeOffer.objects.filter(initiated_by_id=self.object.pk).exists() trade_offer_exists = TradeOffer.objects.filter(
trade_acceptance_exists = TradeAcceptance.objects.filter(accepted_by_id=self.object.pk).exists() 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: if trade_offer_exists or trade_acceptance_exists:
messages.error( messages.error(
request, request,
"Cannot remove this friend code because there are existing trade offers associated with it." "Cannot remove this friend code because there are existing trade offers associated with it.",
) )
return redirect(self.get_success_url()) return redirect(self.get_success_url())
self.object.delete() self.object.delete()
messages.success(request, "Friend code removed successfully.") messages.success(request, "Friend code removed successfully.")
return redirect(self.get_success_url()) return redirect(self.get_success_url())
class ChangeDefaultFriendCodeView(LoginRequiredMixin, View): class ChangeDefaultFriendCodeView(LoginRequiredMixin, View):
""" """
Change the default friend code for the current user. Change the default friend code for the current user.
""" """
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
friend_code_id = kwargs.get("pk") friend_code_id = kwargs.get("pk")
friend_code = get_object_or_404(FriendCode, pk=friend_code_id, user=request.user) friend_code = get_object_or_404(
FriendCode, pk=friend_code_id, user=request.user
)
request.user.set_default_friend_code(friend_code) request.user.set_default_friend_code(friend_code)
messages.success(request, "Default friend code updated successfully.") messages.success(request, "Default friend code updated successfully.")
base_url = reverse("dashboard") base_url = reverse("dashboard")
query_string = urlencode({"tab": "friend_codes"}) query_string = urlencode({"tab": "friend_codes"})
return redirect(f"{base_url}?{query_string}") return redirect(f"{base_url}?{query_string}")
class EditFriendCodeView(LoginRequiredMixin, UpdateView): class EditFriendCodeView(LoginRequiredMixin, UpdateView):
""" """
Edit the in-game name for a friend code. Edit the in-game name for a friend code.
The friend code itself is displayed as plain text. The friend code itself is displayed as plain text.
Also includes "Set Default" and "Delete" buttons in the template. Also includes "Set Default" and "Delete" buttons in the template.
""" """
model = FriendCode model = FriendCode
# Only the in_game_name field is editable # Only the in_game_name field is editable
fields = ['in_game_name'] fields = ["in_game_name"]
template_name = "friend_codes/edit_friend_code.html" template_name = "friend_codes/edit_friend_code.html"
context_object_name = "friend_code" context_object_name = "friend_code"
def get_success_url(self): def get_success_url(self):
base_url = reverse("dashboard") base_url = reverse("dashboard")
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}" return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
@ -135,12 +157,16 @@ class EditFriendCodeView(LoginRequiredMixin, UpdateView):
messages.success(self.request, "Friend code updated successfully.") messages.success(self.request, "Friend code updated successfully.")
return super().form_valid(form) return super().form_valid(form)
class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginationMixin, TemplateView):
class DashboardView(
LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginationMixin, TemplateView
):
template_name = "account/dashboard.html" template_name = "account/dashboard.html"
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if 'update_settings' in request.POST: if "update_settings" in request.POST:
from pkmntrade_club.accounts.forms import UserSettingsForm from pkmntrade_club.accounts.forms import UserSettingsForm
form = UserSettingsForm(request.POST, instance=request.user) form = UserSettingsForm(request.POST, instance=request.user)
if form.is_valid(): if form.is_valid():
form.save() form.save()
@ -156,21 +182,30 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
try: try:
selected_friend_code = friend_codes.get(pk=friend_code_param) selected_friend_code = friend_codes.get(pk=friend_code_param)
except friend_codes.model.DoesNotExist: except friend_codes.model.DoesNotExist:
selected_friend_code = self.request.user.default_friend_code or friend_codes.first() selected_friend_code = (
self.request.user.default_friend_code or friend_codes.first()
)
else: else:
selected_friend_code = self.request.user.default_friend_code or friend_codes.first() selected_friend_code = (
self.request.user.default_friend_code or friend_codes.first()
)
if not selected_friend_code: if not selected_friend_code:
raise PermissionDenied("You do not have an active friend code associated with your account.") raise PermissionDenied(
"You do not have an active friend code associated with your account."
)
return selected_friend_code return selected_friend_code
def get_dashboard_offers_paginated(self, page_param): def get_dashboard_offers_paginated(self, page_param):
selected_friend_code = self.get_selected_friend_code() selected_friend_code = self.get_selected_friend_code()
queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=False) queryset = TradeOffer.objects.filter(
initiated_by=selected_friend_code, is_closed=False
)
object_list, pagination_context = self.paginate_data(queryset, int(page_param)) object_list, pagination_context = self.paginate_data(queryset, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context} return {"object_list": object_list, "page_obj": pagination_context}
def get_involved_acceptances(self, selected_friend_code): def get_involved_acceptances(self, selected_friend_code):
from django.db.models import Q from django.db.models import Q
terminal_states = [ terminal_states = [
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
@ -179,7 +214,8 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
] ]
involved = TradeAcceptance.objects.filter( involved = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code) Q(trade_offer__initiated_by=selected_friend_code)
| Q(accepted_by=selected_friend_code)
).order_by("-updated_at") ).order_by("-updated_at")
return involved.exclude(state__in=terminal_states) return involved.exclude(state__in=terminal_states)
@ -187,12 +223,19 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
selected_friend_code = self.get_selected_friend_code() selected_friend_code = self.get_selected_friend_code()
involved = self.get_involved_acceptances(selected_friend_code) involved = self.get_involved_acceptances(selected_friend_code)
from django.db.models import Q from django.db.models import Q
waiting = involved.filter( waiting = involved.filter(
Q(trade_offer__initiated_by=selected_friend_code, state__in=[ Q(
TradeAcceptance.AcceptanceState.ACCEPTED, trade_offer__initiated_by=selected_friend_code,
TradeAcceptance.AcceptanceState.RECEIVED, state__in=[
]) | TradeAcceptance.AcceptanceState.ACCEPTED,
Q(accepted_by=selected_friend_code, state__in=[TradeAcceptance.AcceptanceState.SENT]) 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)) object_list, pagination_context = self.paginate_data(waiting, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context} return {"object_list": object_list, "page_obj": pagination_context}
@ -201,12 +244,19 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
selected_friend_code = self.get_selected_friend_code() selected_friend_code = self.get_selected_friend_code()
involved = self.get_involved_acceptances(selected_friend_code) involved = self.get_involved_acceptances(selected_friend_code)
from django.db.models import Q from django.db.models import Q
waiting = involved.filter( waiting = involved.filter(
Q(trade_offer__initiated_by=selected_friend_code, state__in=[ Q(
TradeAcceptance.AcceptanceState.ACCEPTED, trade_offer__initiated_by=selected_friend_code,
TradeAcceptance.AcceptanceState.RECEIVED, state__in=[
]) | TradeAcceptance.AcceptanceState.ACCEPTED,
Q(accepted_by=selected_friend_code, state__in=[TradeAcceptance.AcceptanceState.SENT]) TradeAcceptance.AcceptanceState.RECEIVED,
],
)
| Q(
accepted_by=selected_friend_code,
state__in=[TradeAcceptance.AcceptanceState.SENT],
)
) )
others = involved.exclude(pk__in=waiting.values("pk")) others = involved.exclude(pk__in=waiting.values("pk"))
object_list, pagination_context = self.paginate_data(others, int(page_param)) object_list, pagination_context = self.paginate_data(others, int(page_param))
@ -214,12 +264,15 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
def get_closed_offers_paginated(self, page_param): def get_closed_offers_paginated(self, page_param):
selected_friend_code = self.get_selected_friend_code() selected_friend_code = self.get_selected_friend_code()
queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=True) queryset = TradeOffer.objects.filter(
initiated_by=selected_friend_code, is_closed=True
)
object_list, pagination_context = self.paginate_data(queryset, int(page_param)) object_list, pagination_context = self.paginate_data(queryset, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context} return {"object_list": object_list, "page_obj": pagination_context}
def get_closed_acceptances_paginated(self, page_param): def get_closed_acceptances_paginated(self, page_param):
from django.db.models import Q from django.db.models import Q
selected_friend_code = self.get_selected_friend_code() selected_friend_code = self.get_selected_friend_code()
terminal_success_states = [ terminal_success_states = [
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
@ -227,28 +280,45 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
] ]
acceptance_qs = TradeAcceptance.objects.filter( acceptance_qs = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code), Q(trade_offer__initiated_by=selected_friend_code)
state__in=terminal_success_states | Q(accepted_by=selected_friend_code),
state__in=terminal_success_states,
).order_by("-updated_at") ).order_by("-updated_at")
object_list, pagination_context = self.paginate_data(acceptance_qs, int(page_param)) object_list, pagination_context = self.paginate_data(
acceptance_qs, int(page_param)
)
return {"object_list": object_list, "page_obj": pagination_context} return {"object_list": object_list, "page_obj": pagination_context}
def get_rejected_by_me_paginated(self, page_param): def get_rejected_by_me_paginated(self, page_param):
from django.db.models import Q from django.db.models import Q
selected_friend_code = self.get_selected_friend_code() selected_friend_code = self.get_selected_friend_code()
rejection = TradeAcceptance.objects.filter( rejection = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR) | Q(
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR) 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") ).order_by("-updated_at")
object_list, pagination_context = self.paginate_data(rejection, int(page_param)) object_list, pagination_context = self.paginate_data(rejection, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context} return {"object_list": object_list, "page_obj": pagination_context}
def get_rejected_by_them_paginated(self, page_param): def get_rejected_by_them_paginated(self, page_param):
from django.db.models import Q from django.db.models import Q
selected_friend_code = self.get_selected_friend_code() selected_friend_code = self.get_selected_friend_code()
rejection = TradeAcceptance.objects.filter( rejection = TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR) | Q(
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR) 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") ).order_by("-updated_at")
object_list, pagination_context = self.paginate_data(rejection, int(page_param)) object_list, pagination_context = self.paginate_data(rejection, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context} return {"object_list": object_list, "page_obj": pagination_context}
@ -258,19 +328,19 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
request = self.request request = self.request
selected_friend_code = self.get_selected_friend_code() selected_friend_code = self.get_selected_friend_code()
context["selected_friend_code"] = selected_friend_code context["selected_friend_code"] = selected_friend_code
# Get the default friend code's primary key if it exists # Get the default friend code's primary key if it exists
default_pk = getattr(request.user.default_friend_code, "pk", None) default_pk = getattr(request.user.default_friend_code, "pk", None)
# Annotate friend codes with is_default flag # Annotate friend codes with is_default flag
context["friend_codes"] = request.user.friend_codes.all().annotate( context["friend_codes"] = request.user.friend_codes.all().annotate(
is_default=Case( is_default=Case(
When(pk=default_pk, then=Value(True)), When(pk=default_pk, then=Value(True)),
default=Value(False), default=Value(False),
output_field=BooleanField() output_field=BooleanField(),
) )
) )
ajax_section = request.GET.get("ajax_section") ajax_section = request.GET.get("ajax_section")
if ajax_section == "dashboard_offers": if ajax_section == "dashboard_offers":
offers_page = request.GET.get("page", 1) offers_page = request.GET.get("page", 1)
@ -307,14 +377,28 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
else: else:
rejected_by_them_page = request.GET.get("rejected_by_them_page", 1) rejected_by_them_page = request.GET.get("rejected_by_them_page", 1)
context["dashboard_offers_paginated"] = self.get_dashboard_offers_paginated(offers_page) context["dashboard_offers_paginated"] = self.get_dashboard_offers_paginated(
context["trade_acceptances_waiting_paginated"] = self.get_trade_acceptances_waiting_paginated(waiting_page) offers_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["trade_acceptances_waiting_paginated"] = (
context["closed_acceptances_paginated"] = self.get_closed_acceptances_paginated(closed_acceptances_page) self.get_trade_acceptances_waiting_paginated(waiting_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) context["other_party_trade_acceptances_paginated"] = (
from pkmntrade_club.accounts.forms import UserSettingsForm 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
)
context["settings_form"] = UserSettingsForm(instance=request.user) context["settings_form"] = UserSettingsForm(instance=request.user)
context["active_tab"] = request.GET.get("tab", "dash") context["active_tab"] = request.GET.get("tab", "dash")
return context return context
@ -327,9 +411,13 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
if ajax_section == "dashboard_offers": if ajax_section == "dashboard_offers":
fragment_context = context.get("dashboard_offers_paginated", {}) fragment_context = context.get("dashboard_offers_paginated", {})
elif ajax_section == "waiting_acceptances": elif ajax_section == "waiting_acceptances":
fragment_context = context.get("trade_acceptances_waiting_paginated", {}) fragment_context = context.get(
"trade_acceptances_waiting_paginated", {}
)
elif ajax_section == "other_party_acceptances": elif ajax_section == "other_party_acceptances":
fragment_context = context.get("other_party_trade_acceptances_paginated", {}) fragment_context = context.get(
"other_party_trade_acceptances_paginated", {}
)
elif ajax_section == "closed_offers": elif ajax_section == "closed_offers":
fragment_context = context.get("closed_offers_paginated", {}) fragment_context = context.get("closed_offers_paginated", {})
elif ajax_section == "closed_acceptances": elif ajax_section == "closed_acceptances":
@ -342,8 +430,12 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
fragment_context = {} fragment_context = {}
if fragment_context: if fragment_context:
return render(request, "trades/_trade_offer_list.html", { return render(
"offers": fragment_context.get("object_list", []), request,
"page_obj": fragment_context.get("page_obj") "trades/_trade_offer_list.html",
}) {
return super().get(request, *args, **kwargs) "offers": fragment_context.get("object_list", []),
"page_obj": fragment_context.get("page_obj"),
},
)
return super().get(request, *args, **kwargs)

View file

@ -1,10 +1,12 @@
from math import ceil from math import ceil
class ReusablePaginationMixin: class ReusablePaginationMixin:
""" """
A mixin that encapsulates reusable pagination logic. A mixin that encapsulates reusable pagination logic.
Use in Django ListViews to generate custom pagination context. Use in Django ListViews to generate custom pagination context.
""" """
per_page = 10 # Default; can be overridden in your view. per_page = 10 # Default; can be overridden in your view.
def paginate_data(self, data_list, page_number): def paginate_data(self, data_list, page_number):
@ -39,4 +41,4 @@ class ReusablePaginationMixin:
"next_page": page_number + 1 if page_number < num_pages else num_pages, "next_page": page_number + 1 if page_number < num_pages else num_pages,
"paginator": {"num_pages": num_pages}, "paginator": {"num_pages": num_pages},
} }
return items, pagination_context return items, pagination_context

View file

@ -106,7 +106,7 @@ class AttackCost(models.Model):
unique_together = ("attack", "energy") unique_together = ("attack", "energy")
def __str__(self): def __str__(self):
return f"{self.attack.name} {_("requires")} {self.quantity} {self.energy.name}" return f"{self.attack.name} {_('requires')} {self.quantity} {self.energy.name}"
class Attack(TranslatableModel): class Attack(TranslatableModel):

View file

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

View file

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

View file

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

View file

@ -6,11 +6,21 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from pkmntrade_club.accounts.models import CustomUser, FriendCode from pkmntrade_club.accounts.models import CustomUser, FriendCode
from pkmntrade_club.cards.models import Card, Deck, DeckNameTranslation, CardNameTranslation from pkmntrade_club.cards.models import (
from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard Card,
from pkmntrade_club.cards.templatetags import card_badge, card_multiselect Deck,
DeckNameTranslation,
CardNameTranslation,
)
from pkmntrade_club.trades.models import (
TradeOffer,
TradeOfferHaveCard,
TradeOfferWantCard,
)
from pkmntrade_club.cards.templatetags import card_multiselect
from tests.utils.rarity import RARITY_MAPPING from tests.utils.rarity import RARITY_MAPPING
class CardsModelsTests(TestCase): class CardsModelsTests(TestCase):
def setUp(self): def setUp(self):
self.deck = Deck.objects.create( self.deck = Deck.objects.create(
@ -22,7 +32,7 @@ class CardsModelsTests(TestCase):
cardnum=1, cardnum=1,
style="default", style="default",
rarity_icon=RARITY_MAPPING[1], rarity_icon=RARITY_MAPPING[1],
rarity_level=1 rarity_level=1,
) )
# Establish many-to-many relationship. # Establish many-to-many relationship.
self.card.decks.add(self.deck) self.card.decks.add(self.deck)
@ -44,7 +54,8 @@ class CardsModelsTests(TestCase):
card_translation = CardNameTranslation.objects.create( card_translation = CardNameTranslation.objects.create(
name="Card Translated", card=self.card, language="en" name="Card Translated", card=self.card, language="en"
) )
self.assertEqual(str(card_translation), "Card Translated") self.assertEqual(str(card_translation), "Card Translated")
class CardTemplatetagsTests(TestCase): class CardTemplatetagsTests(TestCase):
def setUp(self): def setUp(self):
@ -55,12 +66,12 @@ class CardTemplatetagsTests(TestCase):
cardnum=2, cardnum=2,
style="background: green;", style="background: green;",
rarity_icon="", rarity_icon="",
rarity_level=2 rarity_level=2,
) )
def test_card_badge_inclusion_tag(self): def test_card_badge_inclusion_tag(self):
"""Test the card_badge inclusion tag renders correctly.""" """Test the card_badge inclusion tag renders correctly."""
template_str = '{% load card_badge %}{% card_badge card quantity=3 %}' template_str = "{% load card_badge %}{% card_badge card quantity=3 %}"
t = Template(template_str) t = Template(template_str)
c = Context({"card": self.card}) c = Context({"card": self.card})
rendered = t.render(c) rendered = t.render(c)
@ -71,7 +82,7 @@ class CardTemplatetagsTests(TestCase):
def test_card_badge_inline_filter(self): def test_card_badge_inline_filter(self):
"""Test the card_badge_inline filter returns safe HTML with correct data.""" """Test the card_badge_inline filter returns safe HTML with correct data."""
template_str = '{% load card_badge %}{{ card|card_badge_inline:5 }}' template_str = "{% load card_badge %}{{ card|card_badge_inline:5 }}"
t = Template(template_str) t = Template(template_str)
c = Context({"card": self.card}) c = Context({"card": self.card})
rendered = t.render(c) rendered = t.render(c)
@ -142,7 +153,8 @@ class CardTemplatetagsTests(TestCase):
selected_values=[], selected_values=[],
) )
# Verify that the context's cards match those in the database. # Verify that the context's cards match those in the database.
self.assertEqual(list(context["cards"]), default_cards) self.assertEqual(list(context["cards"]), default_cards)
class CardsViewsTests(TestCase): class CardsViewsTests(TestCase):
def setUp(self): def setUp(self):
@ -161,7 +173,7 @@ class CardsViewsTests(TestCase):
cardnum=1, cardnum=1,
style="default", style="default",
rarity_icon=RARITY_MAPPING[1], rarity_icon=RARITY_MAPPING[1],
rarity_level=1 rarity_level=1,
) )
def test_card_detail_view_context(self): def test_card_detail_view_context(self):
@ -198,9 +210,7 @@ class CardsViewsTests(TestCase):
Helper method to create a trade offer for the 'have' side with a custom updated_at. Helper method to create a trade offer for the 'have' side with a custom updated_at.
""" """
offer = TradeOffer.objects.create(initiated_by=self.friendcode) offer = TradeOffer.objects.create(initiated_by=self.friendcode)
TradeOfferHaveCard.objects.create( TradeOfferHaveCard.objects.create(trade_offer=offer, card=self.card, quantity=1)
trade_offer=offer, card=self.card, quantity=1
)
# Adjust updated_at so that ordering can be tested. # Adjust updated_at so that ordering can be tested.
new_time = timezone.now() + timedelta(minutes=updated_delta_minutes) new_time = timezone.now() + timedelta(minutes=updated_delta_minutes)
TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time) TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time)
@ -212,9 +222,7 @@ class CardsViewsTests(TestCase):
Helper method to create a trade offer for the 'want' side with a custom updated_at. Helper method to create a trade offer for the 'want' side with a custom updated_at.
""" """
offer = TradeOffer.objects.create(initiated_by=self.friendcode) offer = TradeOffer.objects.create(initiated_by=self.friendcode)
TradeOfferWantCard.objects.create( TradeOfferWantCard.objects.create(trade_offer=offer, card=self.card, quantity=1)
trade_offer=offer, card=self.card, quantity=1
)
new_time = timezone.now() + timedelta(minutes=updated_delta_minutes) new_time = timezone.now() + timedelta(minutes=updated_delta_minutes)
TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time) TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time)
offer.refresh_from_db() offer.refresh_from_db()
@ -285,4 +293,4 @@ class CardsViewsTests(TestCase):
trade_offers_oldest = response_oldest.context.get("trade_offers") trade_offers_oldest = response_oldest.context.get("trade_offers")
self.assertEqual(len(trade_offers_oldest), 2) self.assertEqual(len(trade_offers_oldest), 2)
self.assertEqual(trade_offers_oldest[0].pk, offer1.pk) self.assertEqual(trade_offers_oldest[0].pk, offer1.pk)
self.assertEqual(trade_offers_oldest[1].pk, offer2.pk) self.assertEqual(trade_offers_oldest[1].pk, offer2.pk)

View file

@ -9,8 +9,16 @@ from .views import (
app_name = "cards" app_name = "cards"
urlpatterns = [ urlpatterns = [
path('', CardListView.as_view(), name='card_list'), path("", CardListView.as_view(), name="card_list"),
path('<int:pk>/', CardDetailView.as_view(), name='card_detail'), 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(
path('<int:pk>/trade-offers-want/', TradeOfferWantCardListView.as_view(), name='card_trade_offer_want_list'), "<int:pk>/trade-offers-have/",
TradeOfferHaveCardListView.as_view(),
name="card_trade_offer_have_list",
),
path(
"<int:pk>/trade-offers-want/",
TradeOfferWantCardListView.as_view(),
name="card_trade_offer_want_list",
),
] ]

View file

@ -1,12 +1,14 @@
from django.views.generic import TemplateView from django.views.generic import (
from django.urls import reverse_lazy ListView,
from django.views.generic import UpdateView, DeleteView, CreateView, ListView, DetailView DetailView,
)
from pkmntrade_club.cards.models import Card from pkmntrade_club.cards.models import Card
from pkmntrade_club.trades.models import TradeOffer from pkmntrade_club.trades.models import TradeOffer
from pkmntrade_club.common.mixins import ReusablePaginationMixin from pkmntrade_club.common.mixins import ReusablePaginationMixin
from django.views import View from django.views import View
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
class CardDetailView(DetailView): class CardDetailView(DetailView):
model = Card model = Card
template_name = "cards/card_detail.html" template_name = "cards/card_detail.html"
@ -16,15 +18,20 @@ class CardDetailView(DetailView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
card = self.get_object() card = self.get_object()
# Count of trade offers where the card appears as a "have" in a trade. # Count of trade offers where the card appears as a "have" in a trade.
context['trade_offer_have_count'] = TradeOffer.objects.filter( context["trade_offer_have_count"] = (
trade_offer_have_cards__card=card TradeOffer.objects.filter(trade_offer_have_cards__card=card)
).distinct().count() .distinct()
.count()
)
# Count of trade offers where the card appears as a "want" in a trade. # Count of trade offers where the card appears as a "want" in a trade.
context['trade_offer_want_count'] = TradeOffer.objects.filter( context["trade_offer_want_count"] = (
trade_offer_want_cards__card=card TradeOffer.objects.filter(trade_offer_want_cards__card=card)
).distinct().count() .distinct()
.count()
)
return context return context
class TradeOfferHaveCardListView(ReusablePaginationMixin, View): class TradeOfferHaveCardListView(ReusablePaginationMixin, View):
def get(self, request, pk): def get(self, request, pk):
card = get_object_or_404(Card, pk=pk) card = get_object_or_404(Card, pk=pk)
@ -48,6 +55,7 @@ class TradeOfferHaveCardListView(ReusablePaginationMixin, View):
# Render the partial template to be injected via AJAX # Render the partial template to be injected via AJAX
return render(request, "trades/_trade_offer_list.html", context) return render(request, "trades/_trade_offer_list.html", context)
class TradeOfferWantCardListView(ReusablePaginationMixin, View): class TradeOfferWantCardListView(ReusablePaginationMixin, View):
def get(self, request, pk): def get(self, request, pk):
card = get_object_or_404(Card, pk=pk) card = get_object_or_404(Card, pk=pk)
@ -72,6 +80,8 @@ class TradeOfferWantCardListView(ReusablePaginationMixin, View):
} }
# Render the partial template containing the new pagination controls # Render the partial template containing the new pagination controls
return render(request, "trades/_trade_offer_list.html", context) return render(request, "trades/_trade_offer_list.html", context)
class CardListView(ReusablePaginationMixin, ListView): class CardListView(ReusablePaginationMixin, ListView):
model = Card model = Card
# Removed built-in pagination; using custom mixin instead # Removed built-in pagination; using custom mixin instead
@ -119,12 +129,20 @@ class CardListView(ReusablePaginationMixin, ListView):
flat_cards.sort(key=lambda x: x["group"].lower()) flat_cards.sort(key=lambda x: x["group"].lower())
elif group_by == "rarity": elif group_by == "rarity":
for card in all_cards: for card in all_cards:
flat_cards.append({"group": card.rarity_icon, "sort_group": card.rarity_level, "card": card}) 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) flat_cards.sort(key=lambda x: x["sort_group"], reverse=True)
page_number = self.get_page_number() page_number = self.get_page_number()
self.per_page = 36 self.per_page = 36
page_flat_cards, pagination_context = self.paginate_data(flat_cards, page_number) page_flat_cards, pagination_context = self.paginate_data(
flat_cards, page_number
)
page_groups = [] page_groups = []
for item in page_flat_cards: for item in page_flat_cards:
@ -141,8 +159,10 @@ class CardListView(ReusablePaginationMixin, ListView):
else: else:
page_number = self.get_page_number() page_number = self.get_page_number()
self.per_page = 36 self.per_page = 36
paginated_cards, pagination_context = self.paginate_data(self.get_queryset(), page_number) paginated_cards, pagination_context = self.paginate_data(
self.get_queryset(), page_number
)
context["cards"] = paginated_cards context["cards"] = paginated_cards
context["page_obj"] = pagination_context context["page_obj"] = pagination_context
context["object_list"] = self.get_queryset() context["object_list"] = self.get_queryset()
return context return context

View file

@ -5,4 +5,4 @@ class CommonConfig(AppConfig):
name = "pkmntrade_club.common" name = "pkmntrade_club.common"
def ready(self): def ready(self):
pass pass

View file

@ -1,12 +1,14 @@
from django.conf import settings from django.conf import settings
def cache_settings(request): def cache_settings(request):
return { return {
'CACHE_TIMEOUT': settings.CACHE_TIMEOUT, "CACHE_TIMEOUT": settings.CACHE_TIMEOUT,
} }
def version_info(request): def version_info(request):
return { return {
'VERSION': settings.VERSION, "VERSION": settings.VERSION,
'VERSION_INFO': settings.VERSION_INFO, "VERSION_INFO": settings.VERSION_INFO,
} }

View file

@ -26,9 +26,13 @@ class ReusablePaginationMixin:
"number": page.number, "number": page.number,
"has_previous": page.has_previous(), "has_previous": page.has_previous(),
"has_next": page.has_next(), "has_next": page.has_next(),
"previous_page_number": page.previous_page_number() if page.has_previous() else 1, "previous_page_number": (
"next_page_number": page.next_page_number() if page.has_next() else paginator.num_pages, 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}, "paginator": {"num_pages": paginator.num_pages},
"count": paginator.count "count": paginator.count,
} }
return page.object_list, pagination_context return page.object_list, pagination_context

View file

@ -2,9 +2,10 @@ from django import template
register = template.Library() register = template.Library()
@register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True) @register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True)
def render_pagination(context, page_obj, hide_if_one_page=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. 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} return {"page_obj": page_obj, "hide_if_one_page": hide_if_one_page}

View file

@ -2,4 +2,4 @@
# Django starts so that shared_task will use this app. # Django starts so that shared_task will use this app.
from .celery import app as celery_app from .celery import app as celery_app
__all__ = ('celery_app',) __all__ = ("celery_app",)

View file

@ -2,6 +2,8 @@ import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pkmntrade_club.django_project.settings') os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings"
)
application = get_asgi_application() application = get_asgi_application()

View file

@ -3,15 +3,17 @@ import os
from celery import Celery from celery import Celery
# Set the default Django settings module for the 'celery' program. # Set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pkmntrade_club.django_project.settings') os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings"
)
app = Celery('django_project') app = Celery("django_project")
# Using a string here means the worker doesn't have to serialize # Using a string here means the worker doesn't have to serialize
# the configuration object to child processes. # the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys # - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix. # should have a `CELERY_` prefix.
app.config_from_object('django.conf:settings', namespace='CELERY') app.config_from_object("django.conf:settings", namespace="CELERY")
# Load task modules from all registered Django apps. # Load task modules from all registered Django apps.
app.autodiscover_tasks() app.autodiscover_tasks()
@ -19,4 +21,4 @@ app.autodiscover_tasks()
@app.task(bind=True, ignore_result=True) @app.task(bind=True, ignore_result=True)
def debug_task(self): def debug_task(self):
print(f'Request: {self.request!r}') print(f"Request: {self.request!r}")

View file

@ -9,67 +9,76 @@ from pkmntrade_club._version import __version__, get_version_info
# set default values to local dev values # set default values to local dev values
env = environ.Env( env = environ.Env(
DEBUG=(bool, False), # MUST STAY FALSE FOR DEFAULT FOR SECURITY REASONS (e.g. if app can't access .env, prevent showing debug output) DEBUG=(
bool,
False,
), # MUST STAY FALSE FOR DEFAULT FOR SECURITY REASONS (e.g. if app can't access .env, prevent showing debug output)
DISABLE_SIGNUPS=(bool, True), DISABLE_SIGNUPS=(bool, True),
DISABLE_CACHE=(bool, True), DISABLE_CACHE=(bool, True),
DJANGO_DATABASE_URL=(str, 'postgresql://postgres@localhost:5432/postgres?sslmode=disable'), DJANGO_DATABASE_URL=(
DJANGO_EMAIL_HOST=(str, ''), str,
"postgresql://postgres@localhost:5432/postgres?sslmode=disable",
),
DJANGO_EMAIL_HOST=(str, ""),
DJANGO_EMAIL_PORT=(int, 587), DJANGO_EMAIL_PORT=(int, 587),
DJANGO_EMAIL_USER=(str, ''), DJANGO_EMAIL_USER=(str, ""),
DJANGO_EMAIL_PASSWORD=(str, ''), DJANGO_EMAIL_PASSWORD=(str, ""),
DJANGO_EMAIL_USE_TLS=(bool, True), DJANGO_EMAIL_USE_TLS=(bool, True),
DJANGO_DEFAULT_FROM_EMAIL=(str, ''), DJANGO_DEFAULT_FROM_EMAIL=(str, ""),
DJANGO_EMAIL_SUBJECT_PREFIX=(str, ''), DJANGO_EMAIL_SUBJECT_PREFIX=(str, ""),
SECRET_KEY=(str, '0000000000000000000000000000000000000000000000000000000000000000'), SECRET_KEY=(
ALLOWED_HOSTS=(str, 'localhost,127.0.0.1'), str,
PUBLIC_HOST=(str, 'localhost'), "0000000000000000000000000000000000000000000000000000000000000000",
ACCOUNT_EMAIL_VERIFICATION=(str, 'none'), ),
SCHEME=(str, 'http'), ALLOWED_HOSTS=(str, "localhost,127.0.0.1"),
REDIS_URL=(str, 'redis://localhost:6379'), PUBLIC_HOST=(str, "localhost"),
ACCOUNT_EMAIL_VERIFICATION=(str, "none"),
SCHEME=(str, "http"),
REDIS_URL=(str, "redis://localhost:6379"),
CACHE_TIMEOUT=(int, 604800), CACHE_TIMEOUT=(int, 604800),
TIME_ZONE=(str, 'America/Los_Angeles'), TIME_ZONE=(str, "America/Los_Angeles"),
) )
LOGGING = { LOGGING = {
'version': 1, "version": 1,
'disable_existing_loggers': False, "disable_existing_loggers": False,
'formatters': { "formatters": {
'verbose': { "verbose": {
'format': '%(asctime)s %(name)-12s %(levelname)-8s %(message)s', "format": "%(asctime)s %(name)-12s %(levelname)-8s %(message)s",
}, },
}, },
'handlers': { "handlers": {
'console': { "console": {
'level': 'INFO', "level": "INFO",
'class': 'logging.StreamHandler', "class": "logging.StreamHandler",
'stream': sys.stdout, "stream": sys.stdout,
'formatter': 'verbose', "formatter": "verbose",
'filters': [], "filters": [],
}, },
}, },
'loggers': { "loggers": {
'django': { "django": {
'handlers': ['console'], "handlers": ["console"],
'level': 'INFO', "level": "INFO",
}, },
'django.server': { "django.server": {
'handlers': ['console'], "handlers": ["console"],
'level': 'INFO', "level": "INFO",
'propagate': False, "propagate": False,
}, },
'granian.access': { "granian.access": {
'handlers': ['console'], "handlers": ["console"],
'level': 'INFO', "level": "INFO",
'propagate': False, "propagate": False,
}, },
'_granian': { "_granian": {
'handlers': ['console'], "handlers": ["console"],
'level': 'INFO', "level": "INFO",
'propagate': False, "propagate": False,
}, },
'': { "": {
'handlers': ['console'], "handlers": ["console"],
'level': 'INFO', "level": "INFO",
}, },
}, },
} }
@ -78,14 +87,14 @@ LOGGING = {
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
# Take environment variables from .env file # Take environment variables from .env file
environ.Env.read_env(os.path.join(BASE_DIR, '.env')) environ.Env.read_env(os.path.join(BASE_DIR, ".env"))
SCHEME = env('SCHEME') SCHEME = env("SCHEME")
PUBLIC_HOST = env('PUBLIC_HOST') PUBLIC_HOST = env("PUBLIC_HOST")
REDIS_URL = env('REDIS_URL') REDIS_URL = env("REDIS_URL")
CACHE_TIMEOUT = env('CACHE_TIMEOUT') CACHE_TIMEOUT = env("CACHE_TIMEOUT")
DISABLE_SIGNUPS = env('DISABLE_SIGNUPS') DISABLE_SIGNUPS = env("DISABLE_SIGNUPS")
DISABLE_CACHE = env('DISABLE_CACHE') DISABLE_CACHE = env("DISABLE_CACHE")
VERSION = __version__ VERSION = __version__
VERSION_INFO = get_version_info() VERSION_INFO = get_version_info()
@ -95,34 +104,38 @@ VERSION_INFO = get_version_info()
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env('SECRET_KEY') SECRET_KEY = env("SECRET_KEY")
# https://docs.djangoproject.com/en/dev/ref/settings/#debug # https://docs.djangoproject.com/en/dev/ref/settings/#debug
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env('DEBUG') DEBUG = env("DEBUG")
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = env('ALLOWED_HOSTS').split(',') ALLOWED_HOSTS = env("ALLOWED_HOSTS").split(",")
try: try:
current_web_worker_hostname = socket.gethostname() current_web_worker_hostname = socket.gethostname()
ALLOWED_HOSTS.append(current_web_worker_hostname) ALLOWED_HOSTS.append(current_web_worker_hostname)
logging.getLogger(__name__).info(f"Added {current_web_worker_hostname} to allowed hosts.") logging.getLogger(__name__).info(
f"Added {current_web_worker_hostname} to allowed hosts."
)
except Exception: except Exception:
logging.getLogger(__name__).info(f"Error determining server hostname for allowed hosts.") logging.getLogger(__name__).info(
"Error determining server hostname for allowed hosts."
)
CSRF_TRUSTED_ORIGINS = [f"{SCHEME}://{PUBLIC_HOST}"] CSRF_TRUSTED_ORIGINS = [f"{SCHEME}://{PUBLIC_HOST}"]
SHORTHAND_DATETIME_FORMAT = 'Y-m-d P' SHORTHAND_DATETIME_FORMAT = "Y-m-d P"
SHORTHAND_DATE_FORMAT = 'Y-m-d' SHORTHAND_DATE_FORMAT = "Y-m-d"
FIRST_PARTY_APPS = [ FIRST_PARTY_APPS = [
'pkmntrade_club.accounts', "pkmntrade_club.accounts",
'pkmntrade_club.cards', "pkmntrade_club.cards",
'pkmntrade_club.common', "pkmntrade_club.common",
'pkmntrade_club.home', "pkmntrade_club.home",
'pkmntrade_club.theme', "pkmntrade_club.theme",
'pkmntrade_club.trades', "pkmntrade_club.trades",
] ]
# Application definition # Application definition
@ -140,20 +153,20 @@ INSTALLED_APPS = [
"django_celery_beat", "django_celery_beat",
"allauth", "allauth",
"allauth.account", "allauth.account",
'allauth.socialaccount.providers.google', "allauth.socialaccount.providers.google",
"crispy_forms", "crispy_forms",
"crispy_tailwind", "crispy_tailwind",
"tailwind", "tailwind",
"django_linear_migrations", "django_linear_migrations",
'health_check', "health_check",
'health_check.db', "health_check.db",
'health_check.cache', "health_check.cache",
'health_check.storage', "health_check.storage",
'health_check.contrib.migrations', "health_check.contrib.migrations",
'health_check.contrib.celery', "health_check.contrib.celery",
'health_check.contrib.celery_ping', "health_check.contrib.celery_ping",
'health_check.contrib.psutil', "health_check.contrib.psutil",
'health_check.contrib.redis', "health_check.contrib.redis",
"meta", "meta",
"parler", "parler",
] + FIRST_PARTY_APPS ] + FIRST_PARTY_APPS
@ -165,12 +178,12 @@ if DEBUG:
"debug_toolbar", "debug_toolbar",
] ]
TAILWIND_APP_NAME = 'theme' TAILWIND_APP_NAME = "theme"
META_SITE_NAME = 'PKMN Trade Club' META_SITE_NAME = "PKMN Trade Club"
META_SITE_PROTOCOL = SCHEME META_SITE_PROTOCOL = SCHEME
META_USE_SITES = True META_USE_SITES = True
META_IMAGE_URL = f'{SCHEME}://{PUBLIC_HOST}/' META_IMAGE_URL = f"{SCHEME}://{PUBLIC_HOST}/"
# https://docs.djangoproject.com/en/dev/ref/settings/#middleware # https://docs.djangoproject.com/en/dev/ref/settings/#middleware
MIDDLEWARE = [ MIDDLEWARE = [
@ -193,22 +206,22 @@ if DEBUG:
] ]
HEALTH_CHECK = { HEALTH_CHECK = {
'DISK_USAGE_MAX': 90, # percent "DISK_USAGE_MAX": 90, # percent
'MEMORY_MIN': 100, # in MB "MEMORY_MIN": 100, # in MB
} }
DAISY_SETTINGS = { DAISY_SETTINGS = {
'SITE_TITLE': 'PKMN Trade Club Admin', "SITE_TITLE": "PKMN Trade Club Admin",
'DONT_SUPPORT_ME': True, "DONT_SUPPORT_ME": True,
} }
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf # https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
ROOT_URLCONF = 'pkmntrade_club.django_project.urls' ROOT_URLCONF = "pkmntrade_club.django_project.urls"
# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application # https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
WSGI_APPLICATION = 'pkmntrade_club.django_project.wsgi.app' WSGI_APPLICATION = "pkmntrade_club.django_project.wsgi.app"
ASGI_APPLICATION = 'pkmntrade_club.django_project.asgi.application' ASGI_APPLICATION = "pkmntrade_club.django_project.asgi.application"
# https://docs.djangoproject.com/en/dev/ref/settings/#templates # https://docs.djangoproject.com/en/dev/ref/settings/#templates
TEMPLATES = [ TEMPLATES = [
@ -231,7 +244,7 @@ TEMPLATES = [
# https://docs.djangoproject.com/en/dev/ref/settings/#databases # https://docs.djangoproject.com/en/dev/ref/settings/#databases
DATABASES = { DATABASES = {
'default': env.db(var="DJANGO_DATABASE_URL"), "default": env.db(var="DJANGO_DATABASE_URL"),
} }
# Password validation # Password validation
@ -256,12 +269,10 @@ AUTH_PASSWORD_VALIDATORS = [
# https://docs.djangoproject.com/en/dev/ref/settings/#language-code # https://docs.djangoproject.com/en/dev/ref/settings/#language-code
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
LANGUAGES = ( LANGUAGES = (("en", _("English")),)
('en', _("English")),
)
# https://docs.djangoproject.com/en/dev/ref/settings/#time-zone # https://docs.djangoproject.com/en/dev/ref/settings/#time-zone
TIME_ZONE = env('TIME_ZONE') TIME_ZONE = env("TIME_ZONE")
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-USE_I18N # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-USE_I18N
USE_I18N = True USE_I18N = True
@ -270,7 +281,7 @@ USE_I18N = True
USE_TZ = True USE_TZ = True
# https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths # https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths
LOCALE_PATHS = [BASE_DIR / 'locale'] LOCALE_PATHS = [BASE_DIR / "locale"]
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/ # https://docs.djangoproject.com/en/5.0/howto/static-files/
@ -305,24 +316,24 @@ STORAGES = {
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/stable/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/stable/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# django-crispy-forms # django-crispy-forms
# https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs # https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs
CRISPY_ALLOWED_TEMPLATE_PACKS = 'tailwind' CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind"
CRISPY_TEMPLATE_PACK = "tailwind" CRISPY_TEMPLATE_PACK = "tailwind"
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = env('DJANGO_EMAIL_HOST') EMAIL_HOST = env("DJANGO_EMAIL_HOST")
EMAIL_PORT = env('DJANGO_EMAIL_PORT') EMAIL_PORT = env("DJANGO_EMAIL_PORT")
EMAIL_HOST_USER = env('DJANGO_EMAIL_USER') EMAIL_HOST_USER = env("DJANGO_EMAIL_USER")
EMAIL_HOST_PASSWORD = env('DJANGO_EMAIL_PASSWORD') EMAIL_HOST_PASSWORD = env("DJANGO_EMAIL_PASSWORD")
EMAIL_USE_TLS = env('DJANGO_EMAIL_USE_TLS') EMAIL_USE_TLS = env("DJANGO_EMAIL_USE_TLS")
EMAIL_SUBJECT_PREFIX = env('DJANGO_EMAIL_SUBJECT_PREFIX') EMAIL_SUBJECT_PREFIX = env("DJANGO_EMAIL_SUBJECT_PREFIX")
# https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email # https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email
DEFAULT_FROM_EMAIL = env('DJANGO_DEFAULT_FROM_EMAIL') DEFAULT_FROM_EMAIL = env("DJANGO_DEFAULT_FROM_EMAIL")
# django-debug-toolbar # django-debug-toolbar
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
@ -335,7 +346,7 @@ INTERNAL_IPS = [
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
for ip in ips: for ip in ips:
INTERNAL_IPS.append(ip) INTERNAL_IPS.append(ip)
INTERNAL_IPS.append(".".join(ip.rsplit(".")[:-1])+ ".1") INTERNAL_IPS.append(".".join(ip.rsplit(".")[:-1]) + ".1")
# https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model # https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model
AUTH_USER_MODEL = "accounts.CustomUser" AUTH_USER_MODEL = "accounts.CustomUser"
@ -345,12 +356,10 @@ AUTH_USER_MODEL = "accounts.CustomUser"
SITE_ID = 1 SITE_ID = 1
PARLER_LANGUAGES = { PARLER_LANGUAGES = {
SITE_ID: ( SITE_ID: ({"code": "en"},),
{'code': 'en'}, "default": {
), "fallbacks": ["en"],
'default': { "hide_untranslated": False,
'fallbacks': ['en'],
'hide_untranslated': False,
}, },
} }
@ -368,13 +377,13 @@ AUTHENTICATION_BACKENDS = (
) )
# https://django-allauth.readthedocs.io/en/latest/configuration.html # https://django-allauth.readthedocs.io/en/latest/configuration.html
if DISABLE_SIGNUPS: if DISABLE_SIGNUPS:
ACCOUNT_ADAPTER = 'pkmntrade_club.accounts.adapter.NoSignupAccountAdapter' ACCOUNT_ADAPTER = "pkmntrade_club.accounts.adapter.NoSignupAccountAdapter"
SOCIALACCOUNT_ADAPTER = 'pkmntrade_club.accounts.adapter.NoSignupSocialAccountAdapter' # always disable social account signups SOCIALACCOUNT_ADAPTER = "pkmntrade_club.accounts.adapter.NoSignupSocialAccountAdapter" # always disable social account signups
ACCOUNT_SESSION_REMEMBER = True ACCOUNT_SESSION_REMEMBER = True
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = True ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = True
ACCOUNT_AUTHENTICATION_METHOD = "username_email" ACCOUNT_AUTHENTICATION_METHOD = "username_email"
ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_EMAIL_VERIFICATION = env('ACCOUNT_EMAIL_VERIFICATION') ACCOUNT_EMAIL_VERIFICATION = env("ACCOUNT_EMAIL_VERIFICATION")
ACCOUNT_EMAIL_NOTIFICATIONS = True ACCOUNT_EMAIL_NOTIFICATIONS = True
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
ACCOUNT_DEFAULT_HTTP_PROTOCOL = SCHEME ACCOUNT_DEFAULT_HTTP_PROTOCOL = SCHEME
@ -395,7 +404,7 @@ SOCIALACCOUNT_ONLY = False
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = PUBLIC_HOST != 'localhost' or PUBLIC_HOST != '127.0.0.1' SESSION_COOKIE_SECURE = PUBLIC_HOST != "localhost" or PUBLIC_HOST != "127.0.0.1"
# auto-detection doesn't work properly sometimes, so we'll just use the DEBUG setting # auto-detection doesn't work properly sometimes, so we'll just use the DEBUG setting
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG} DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG}

View file

@ -4,11 +4,11 @@ from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path('account/', include('pkmntrade_club.accounts.urls')), path("account/", include("pkmntrade_club.accounts.urls")),
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
path("", include("pkmntrade_club.home.urls")), path("", include("pkmntrade_club.home.urls")),
path("cards/", include("pkmntrade_club.cards.urls")), path("cards/", include("pkmntrade_club.cards.urls")),
path("health/", include('health_check.urls')), path("health/", include("health_check.urls")),
path("trades/", include("pkmntrade_club.trades.urls")), path("trades/", include("pkmntrade_club.trades.urls")),
path("__reload__/", include("django_browser_reload.urls")), path("__reload__/", include("django_browser_reload.urls")),
] + debug_toolbar_urls() ] + debug_toolbar_urls()

View file

@ -2,6 +2,8 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings") os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings"
)
app = get_wsgi_application() app = get_wsgi_application()

View file

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

View file

@ -1,54 +1,58 @@
from collections import defaultdict, OrderedDict from collections import OrderedDict
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.urls import reverse_lazy from django.db.models import (
from django.db.models import Count, Q, Prefetch, Sum, F, IntegerField, Value, BooleanField, Case, When Sum,
)
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from pkmntrade_club.trades.models import (
from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance, TradeOfferHaveCard, TradeOfferWantCard TradeOffer,
)
from pkmntrade_club.cards.models import Card 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 import logging
from django.views import View
from django.http import HttpResponse
import contextlib
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class HomePageView(TemplateView): class HomePageView(TemplateView):
template_name = "home/home.html" template_name = "home/home.html"
#@silk_profile(name='Home Page') # @silk_profile(name='Home Page')
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
try: try:
# Get all cards ordered by name, exclude cards with rarity level > 5 # 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") context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by(
"name", "rarity_level"
)
# Reuse base trade offer queryset for market stats # Reuse base trade offer queryset for market stats
base_offer_qs = TradeOffer.objects.filter(is_closed=False) base_offer_qs = TradeOffer.objects.filter(is_closed=False)
# Recent Offers # Recent Offers
try: try:
recent_offers_qs = base_offer_qs.order_by("-created_at")[:6] recent_offers_qs = base_offer_qs.order_by("-created_at")[:6]
context["recent_offers"] = recent_offers_qs context["recent_offers"] = recent_offers_qs
context["cache_key_recent_offers"] = f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}" context["cache_key_recent_offers"] = (
f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}"
)
except Exception as e: except Exception as e:
logger.error(f"Error fetching recent offers: {str(e)}") logger.error(f"Error fetching recent offers: {str(e)}")
context["recent_offers"] = [] context["recent_offers"] = []
context["cache_key_recent_offers"] = "recent_offers_error" context["cache_key_recent_offers"] = "recent_offers_error"
# Most Offered Cards # Most Offered Cards
try: try:
most_offered_cards_qs = ( most_offered_cards_qs = (
Card.objects.filter(tradeofferhavecard__isnull=False).filter(rarity_level__lte=5) Card.objects.filter(tradeofferhavecard__isnull=False)
.filter(rarity_level__lte=5)
.annotate(offer_count=Sum("tradeofferhavecard__quantity")) .annotate(offer_count=Sum("tradeofferhavecard__quantity"))
.order_by("-offer_count")[:6] .order_by("-offer_count")[:6]
) )
context["most_offered_cards"] = most_offered_cards_qs 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')}" context["cache_key_most_offered_cards"] = (
f"most_offered_cards_{most_offered_cards_qs.values_list('pk', 'updated_at')}"
)
except Exception as e: except Exception as e:
logger.error(f"Error fetching most offered cards: {str(e)}") logger.error(f"Error fetching most offered cards: {str(e)}")
context["most_offered_cards"] = [] context["most_offered_cards"] = []
@ -56,26 +60,32 @@ class HomePageView(TemplateView):
# Most Wanted Cards # Most Wanted Cards
try: try:
most_wanted_cards_qs = ( most_wanted_cards_qs = (
Card.objects.filter(tradeofferwantcard__isnull=False).filter(rarity_level__lte=5) Card.objects.filter(tradeofferwantcard__isnull=False)
.filter(rarity_level__lte=5)
.annotate(offer_count=Sum("tradeofferwantcard__quantity")) .annotate(offer_count=Sum("tradeofferwantcard__quantity"))
.order_by("-offer_count")[:6] .order_by("-offer_count")[:6]
) )
context["most_wanted_cards"] = most_wanted_cards_qs 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')}" context["cache_key_most_wanted_cards"] = (
f"most_wanted_cards_{most_wanted_cards_qs.values_list('pk', 'updated_at')}"
)
except Exception as e: except Exception as e:
logger.error(f"Error fetching most wanted cards: {str(e)}") logger.error(f"Error fetching most wanted cards: {str(e)}")
context["most_wanted_cards"] = [] context["most_wanted_cards"] = []
# Least Offered Cards # Least Offered Cards
try: try:
least_offered_cards_qs = ( least_offered_cards_qs = (
Card.objects.filter(rarity_level__lte=5).annotate( Card.objects.filter(rarity_level__lte=5)
.annotate(
offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0) offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0)
) )
.order_by("offer_count")[:6] .order_by("offer_count")[:6]
) )
context["least_offered_cards"] = least_offered_cards_qs 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')}" context["cache_key_least_offered_cards"] = (
f"least_offered_cards_{least_offered_cards_qs.values_list('pk', 'updated_at')}"
)
except Exception as e: except Exception as e:
logger.error(f"Error fetching least offered cards: {str(e)}") logger.error(f"Error fetching least offered cards: {str(e)}")
context["least_offered_cards"] = [] context["least_offered_cards"] = []
@ -88,22 +98,22 @@ class HomePageView(TemplateView):
except Exception as e: except Exception as e:
logger.error(f"Error fetching 'All' featured offers: {str(e)}") logger.error(f"Error fetching 'All' featured offers: {str(e)}")
featured["All"] = [] featured["All"] = []
# *** we only show All Featured Offers for now, # *** we only show All Featured Offers for now,
# *** we will add rarity-tabbed featured offers later # *** we will add rarity-tabbed featured offers later
# try: # try:
# # Pull out distinct (rarity_level, rarity_icon) tuples # # Pull out distinct (rarity_level, rarity_icon) tuples
# distinct_rarities = base_offer_qs.values_list("rarity_level", "rarity_icon").distinct() # distinct_rarities = base_offer_qs.values_list("rarity_level", "rarity_icon").distinct()
# # Prepare a list that holds tuples of (rarity_level, rarity_icon, offers) # # Prepare a list that holds tuples of (rarity_level, rarity_icon, offers)
# rarity_offers = [] # rarity_offers = []
# for rarity_level, rarity_icon in distinct_rarities: # for rarity_level, rarity_icon in distinct_rarities:
# offers = base_offer_qs.filter(rarity_level=rarity_level).order_by("created_at")[:6] # offers = base_offer_qs.filter(rarity_level=rarity_level).order_by("created_at")[:6]
# rarity_offers.append((rarity_level, rarity_icon, offers)) # rarity_offers.append((rarity_level, rarity_icon, offers))
# # Sort by rarity_level (from greatest to least) # # Sort by rarity_level (from greatest to least)
# rarity_offers.sort(key=lambda x: x[0], reverse=True) # rarity_offers.sort(key=lambda x: x[0], reverse=True)
# # Add the sorted offers to the OrderedDict # # Add the sorted offers to the OrderedDict
# for rarity_level, rarity_icon, offers in rarity_offers: # for rarity_level, rarity_icon, offers in rarity_offers:
# featured[rarity_icon] = offers # featured[rarity_icon] = offers
@ -114,16 +124,20 @@ class HomePageView(TemplateView):
# Generate a cache key based on the pks and updated_at timestamps of all featured offers # Generate a cache key based on the pks and updated_at timestamps of all featured offers
# *** we will separate cache keys for each featured section later # *** we will separate cache keys for each featured section later
all_offer_identifiers = [] all_offer_identifiers = []
for section_name,section_offers in featured.items(): for section_name, section_offers in featured.items():
# featured_section is a QuerySet. Fetch (pk, updated_at) tuples. # featured_section is a QuerySet. Fetch (pk, updated_at) tuples.
identifiers = section_offers.values_list('pk', 'updated_at') identifiers = section_offers.values_list("pk", "updated_at")
# Format each tuple as "pk_timestamp" and add to the list # Format each tuple as "pk_timestamp" and add to the list
section_strings = [f"{section_name}_{pk}_{ts.timestamp()}" for pk, ts in identifiers] section_strings = [
f"{section_name}_{pk}_{ts.timestamp()}" for pk, ts in identifiers
]
all_offer_identifiers.extend(section_strings) all_offer_identifiers.extend(section_strings)
# Join all identifiers into a single string, sorted for consistency regardless of order # Join all identifiers into a single string, sorted for consistency regardless of order
combined_identifiers = "|".join(sorted(all_offer_identifiers)) combined_identifiers = "|".join(sorted(all_offer_identifiers))
context["cache_key_featured_offers"] = f"featured_offers_{combined_identifiers}" context["cache_key_featured_offers"] = (
f"featured_offers_{combined_identifiers}"
)
except Exception as e: except Exception as e:
logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}") logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}")
# Provide fallback empty data # Provide fallback empty data
@ -133,9 +147,9 @@ class HomePageView(TemplateView):
context["most_wanted_cards"] = [] context["most_wanted_cards"] = []
context["least_offered_cards"] = [] context["least_offered_cards"] = []
context["featured_offers"] = OrderedDict([("All", [])]) context["featured_offers"] = OrderedDict([("All", [])])
return context return context
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Override get method to add caching""" """Override get method to add caching"""
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)

View file

@ -6,5 +6,5 @@ RARITY_MAPPING = {
5: "⭐️", 5: "⭐️",
6: "⭐️⭐️", 6: "⭐️⭐️",
7: "⭐️⭐️⭐️", 7: "⭐️⭐️⭐️",
8: "👑" 8: "👑",
} }

View file

@ -2,4 +2,4 @@ from django.apps import AppConfig
class ThemeConfig(AppConfig): class ThemeConfig(AppConfig):
name = 'pkmntrade_club.theme' name = "pkmntrade_club.theme"

View file

@ -1,20 +1,21 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError
from .models import TradeOffer, TradeAcceptance from .models import TradeOffer, TradeAcceptance
from pkmntrade_club.accounts.models import FriendCode from pkmntrade_club.accounts.models import FriendCode
from pkmntrade_club.cards.models import Card from pkmntrade_club.cards.models import Card
from django.forms import ModelForm from django.forms import ModelForm
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard
class NoValidationMultipleChoiceField(forms.MultipleChoiceField): class NoValidationMultipleChoiceField(forms.MultipleChoiceField):
def validate(self, value): def validate(self, value):
# Override the validation to skip checking against defined choices # Override the validation to skip checking against defined choices
pass pass
class TradeOfferAcceptForm(forms.Form): class TradeOfferAcceptForm(forms.Form):
friend_code = forms.ModelChoiceField( friend_code = forms.ModelChoiceField(
queryset=FriendCode.objects.none(), queryset=FriendCode.objects.none(),
label="Select a Friend Code to Accept This Trade Offer" label="Select a Friend Code to Accept This Trade Offer",
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -23,6 +24,7 @@ class TradeOfferAcceptForm(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["friend_code"].queryset = friend_codes self.fields["friend_code"].queryset = friend_codes
class TradeAcceptanceCreateForm(forms.ModelForm): class TradeAcceptanceCreateForm(forms.ModelForm):
""" """
Form for creating a TradeAcceptance. Form for creating a TradeAcceptance.
@ -32,11 +34,19 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
- default_friend_code (optional): the user's default FriendCode. - default_friend_code (optional): the user's default FriendCode.
It filters available requested and offered cards based on what's still available. It filters available requested and offered cards based on what's still available.
""" """
class Meta: class Meta:
model = TradeAcceptance model = TradeAcceptance
fields = ["accepted_by", "requested_card", "offered_card"] fields = ["accepted_by", "requested_card", "offered_card"]
def __init__(self, *args, trade_offer=None, friend_codes=None, default_friend_code=None, **kwargs): def __init__(
self,
*args,
trade_offer=None,
friend_codes=None,
default_friend_code=None,
**kwargs,
):
if trade_offer is None: if trade_offer is None:
raise ValueError("trade_offer must be provided to filter choices.") raise ValueError("trade_offer must be provided to filter choices.")
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -52,16 +62,23 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
self.initial["accepted_by"] = friend_codes.first().pk self.initial["accepted_by"] = friend_codes.first().pk
self.fields["accepted_by"].widget = forms.HiddenInput() self.fields["accepted_by"].widget = forms.HiddenInput()
# Otherwise, if a default friend code is provided and it is in the queryset, preselect it. # Otherwise, if a default friend code is provided and it is in the queryset, preselect it.
elif default_friend_code and friend_codes.filter(pk=default_friend_code.pk).exists(): elif (
default_friend_code
and friend_codes.filter(pk=default_friend_code.pk).exists()
):
self.initial["accepted_by"] = default_friend_code.pk self.initial["accepted_by"] = default_friend_code.pk
available_have_items = trade_offer.have_cards_available available_have_items = trade_offer.have_cards_available
requested_card_pks = [item.card.pk for item in available_have_items] requested_card_pks = [item.card.pk for item in available_have_items]
self.fields["requested_card"].queryset = Card.objects.filter(pk__in=requested_card_pks).order_by('name') self.fields["requested_card"].queryset = Card.objects.filter(
pk__in=requested_card_pks
).order_by("name")
available_want_items = trade_offer.want_cards_available available_want_items = trade_offer.want_cards_available
offered_card_pks = [item.card.pk for item in available_want_items] offered_card_pks = [item.card.pk for item in available_want_items]
self.fields["offered_card"].queryset = Card.objects.filter(pk__in=offered_card_pks).order_by('name') self.fields["offered_card"].queryset = Card.objects.filter(
pk__in=offered_card_pks
).order_by("name")
def clean(self): def clean(self):
""" """
@ -71,9 +88,11 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
self.instance.trade_offer = self.trade_offer self.instance.trade_offer = self.trade_offer
return super().clean() return super().clean()
class ButtonRadioSelect(forms.RadioSelect): class ButtonRadioSelect(forms.RadioSelect):
template_name = "widgets/button_radio_select.html" template_name = "widgets/button_radio_select.html"
class TradeAcceptanceTransitionForm(forms.Form): class TradeAcceptanceTransitionForm(forms.Form):
state = forms.ChoiceField(widget=forms.HiddenInput()) state = forms.ChoiceField(widget=forms.HiddenInput())
@ -87,13 +106,18 @@ class TradeAcceptanceTransitionForm(forms.Form):
raise ValueError("A TradeAcceptance instance must be provided") raise ValueError("A TradeAcceptance instance must be provided")
self.instance = instance self.instance = instance
self.user = user self.user = user
self.fields["state"].choices = instance.get_allowed_state_transitions(user) self.fields["state"].choices = instance.get_allowed_state_transitions(user)
class TradeOfferCreateForm(ModelForm): class TradeOfferCreateForm(ModelForm):
# Override the default fields to capture quantity info in the format 'card_id:quantity' # Override the default fields to capture quantity info in the format 'card_id:quantity'
have_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True) have_cards = NoValidationMultipleChoiceField(
want_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True) widget=forms.SelectMultiple, required=True
)
want_cards = NoValidationMultipleChoiceField(
widget=forms.SelectMultiple, required=True
)
class Meta: class Meta:
model = TradeOffer model = TradeOffer
@ -111,10 +135,10 @@ class TradeOfferCreateForm(ModelForm):
data = self.data.getlist("have_cards") data = self.data.getlist("have_cards")
parsed = {} parsed = {}
for item in data: for item in data:
if ':' not in item: if ":" not in item:
# Ignore any input without a colon. # Ignore any input without a colon.
continue continue
parts = item.split(':') parts = item.split(":")
card_id = parts[0] card_id = parts[0]
try: try:
# Only parse quantity when a colon is present. # Only parse quantity when a colon is present.
@ -131,16 +155,18 @@ class TradeOfferCreateForm(ModelForm):
) )
# Ensure no more than 20 unique have cards are selected. # Ensure no more than 20 unique have cards are selected.
if len(parsed) > 20: if len(parsed) > 20:
raise forms.ValidationError("You can only select a maximum of 20 unique have cards.") raise forms.ValidationError(
"You can only select a maximum of 20 unique have cards."
)
return parsed return parsed
def clean_want_cards(self): def clean_want_cards(self):
data = self.data.getlist("want_cards") data = self.data.getlist("want_cards")
parsed = {} parsed = {}
for item in data: for item in data:
if ':' not in item: if ":" not in item:
continue continue
parts = item.split(':') parts = item.split(":")
card_id = parts[0] card_id = parts[0]
try: try:
quantity = int(parts[1]) quantity = int(parts[1])
@ -157,7 +183,9 @@ class TradeOfferCreateForm(ModelForm):
) )
# Ensure no more than 20 unique want cards are selected. # Ensure no more than 20 unique want cards are selected.
if len(parsed) > 20: if len(parsed) > 20:
raise forms.ValidationError("You can only select a maximum of 20 unique want cards.") raise forms.ValidationError(
"You can only select a maximum of 20 unique want cards."
)
return parsed return parsed
def save(self, commit=True): def save(self, commit=True):
@ -167,15 +195,19 @@ class TradeOfferCreateForm(ModelForm):
# Clear any existing through model entries in case of update # Clear any existing through model entries in case of update
TradeOfferHaveCard.objects.filter(trade_offer=instance).delete() TradeOfferHaveCard.objects.filter(trade_offer=instance).delete()
TradeOfferWantCard.objects.filter(trade_offer=instance).delete() TradeOfferWantCard.objects.filter(trade_offer=instance).delete()
# Create through entries for have_cards # Create through entries for have_cards
for card_id, quantity in self.cleaned_data["have_cards"].items(): for card_id, quantity in self.cleaned_data["have_cards"].items():
card = Card.objects.get(pk=card_id) card = Card.objects.get(pk=card_id)
TradeOfferHaveCard.objects.create(trade_offer=instance, card=card, quantity=quantity) TradeOfferHaveCard.objects.create(
trade_offer=instance, card=card, quantity=quantity
)
# Create through entries for want_cards # Create through entries for want_cards
for card_id, quantity in self.cleaned_data["want_cards"].items(): for card_id, quantity in self.cleaned_data["want_cards"].items():
card = Card.objects.get(pk=card_id) card = Card.objects.get(pk=card_id)
TradeOfferWantCard.objects.create(trade_offer=instance, card=card, quantity=quantity) TradeOfferWantCard.objects.create(
trade_offer=instance, card=card, quantity=quantity
)
return instance return instance

View file

@ -1,37 +1,49 @@
from pkmntrade_club.cards.models import Card from pkmntrade_club.cards.models import Card
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
class TradeOfferContextMixin: class TradeOfferContextMixin:
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
# Start with any context passed in. # Start with any context passed in.
context = kwargs.copy() context = kwargs.copy()
# Include available cards requirements for multiselect fields. # Include available cards requirements for multiselect fields.
context.setdefault("cards", Card.objects.all().order_by("name", "rarity_level")) context.setdefault("cards", Card.objects.all().order_by("name", "rarity_level"))
# Provide friend_codes and selected_friend_code as in TradeOfferCreateView # Provide friend_codes and selected_friend_code as in TradeOfferCreateView
friend_codes = self.request.user.friend_codes.all() friend_codes = self.request.user.friend_codes.all()
context["friend_codes"] = friend_codes context["friend_codes"] = friend_codes
if "initiated_by" in self.request.GET: if "initiated_by" in self.request.GET:
try: try:
selected_friend_code = friend_codes.get(pk=self.request.GET.get("initiated_by")) selected_friend_code = friend_codes.get(
pk=self.request.GET.get("initiated_by")
)
except friend_codes.model.DoesNotExist: except friend_codes.model.DoesNotExist:
selected_friend_code = self.request.user.default_friend_code or friend_codes.first() selected_friend_code = (
self.request.user.default_friend_code or friend_codes.first()
)
else: else:
selected_friend_code = self.request.user.default_friend_code or friend_codes.first() selected_friend_code = (
self.request.user.default_friend_code or friend_codes.first()
)
context["selected_friend_code"] = selected_friend_code context["selected_friend_code"] = selected_friend_code
return context return context
class FriendCodeRequiredMixin: class FriendCodeRequiredMixin:
""" """
Mixin to ensure the authenticated user has at least one friend code. Mixin to ensure the authenticated user has at least one friend code.
This mixin must be placed after LoginRequiredMixin in the view's inheritance order. This mixin must be placed after LoginRequiredMixin in the view's inheritance order.
""" """
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
# Since LoginRequiredMixin guarantees that request.user is authenticated, # Since LoginRequiredMixin guarantees that request.user is authenticated,
# we assume request.user has the attribute `friend_codes`. If no friend code exists, # we assume request.user has the attribute `friend_codes`. If no friend code exists,
# raise a PermissionDenied error. # raise a PermissionDenied error.
if not getattr(request.user, 'friend_codes', None) or not request.user.friend_codes.exists(): if (
not getattr(request.user, "friend_codes", None)
or not request.user.friend_codes.exists()
):
raise PermissionDenied("No friend codes available for your account.") raise PermissionDenied("No friend codes available for your account.")
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)

View file

@ -1,13 +1,10 @@
from django.db import models from django.db import models
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q, Count, Prefetch, F, Sum, Max from django.db.models import Prefetch
import hashlib import hashlib
from pkmntrade_club.cards.models import Card
from pkmntrade_club.accounts.models import FriendCode
from datetime import timedelta
from django.utils import timezone
import uuid import uuid
def generate_tradeoffer_hash(): def generate_tradeoffer_hash():
""" """
Generates a unique 9-character hash for a TradeOffer. Generates a unique 9-character hash for a TradeOffer.
@ -15,6 +12,7 @@ def generate_tradeoffer_hash():
""" """
return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "z" return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "z"
def generate_tradeacceptance_hash(): def generate_tradeacceptance_hash():
""" """
Generates a unique 9-character hash for a TradeAcceptance. Generates a unique 9-character hash for a TradeAcceptance.
@ -22,34 +20,36 @@ def generate_tradeacceptance_hash():
""" """
return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "y" return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "y"
class TradeOfferManager(models.Manager):
class TradeOfferManager(models.Manager):
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
# Prefetch for have_cards (through model: TradeOfferHaveCard) # Prefetch for have_cards (through model: TradeOfferHaveCard)
# Ensures 'card' is select_related and 'Meta.ordering' is respected/applied. # Ensures 'card' is select_related and 'Meta.ordering' is respected/applied.
prefetch_have_cards = Prefetch( prefetch_have_cards = Prefetch(
'trade_offer_have_cards', "trade_offer_have_cards",
queryset=TradeOfferHaveCard.objects.select_related('card').order_by('card__name') queryset=TradeOfferHaveCard.objects.select_related("card").order_by(
"card__name"
),
) )
# Prefetch for want_cards (through model: TradeOfferWantCard) # Prefetch for want_cards (through model: TradeOfferWantCard)
# Ensures 'card' is select_related and 'Meta.ordering' is respected/applied. # Ensures 'card' is select_related and 'Meta.ordering' is respected/applied.
prefetch_want_cards = Prefetch( prefetch_want_cards = Prefetch(
'trade_offer_want_cards', "trade_offer_want_cards",
queryset=TradeOfferWantCard.objects.select_related('card').order_by('card__name') queryset=TradeOfferWantCard.objects.select_related("card").order_by(
"card__name"
),
) )
# Prefetch for acceptances # Prefetch for acceptances
# Ensures related 'accepted_by__user', 'requested_card', 'offered_card' are fetched. # Ensures related 'accepted_by__user', 'requested_card', 'offered_card' are fetched.
prefetch_acceptances = Prefetch( prefetch_acceptances = Prefetch(
'acceptances', "acceptances",
queryset=TradeAcceptance.objects.select_related( queryset=TradeAcceptance.objects.select_related(
'accepted_by__user', "accepted_by__user", "requested_card", "offered_card"
'requested_card', ).order_by("-created_at"), # Sensible default ordering for acceptances
'offered_card'
).order_by('-created_at') # Sensible default ordering for acceptances
) )
qs = qs.select_related( qs = qs.select_related(
@ -60,11 +60,12 @@ class TradeOfferManager(models.Manager):
prefetch_acceptances, prefetch_acceptances,
# If direct access like offer.have_cards.all() (the M2M to Card, not through model) # If direct access like offer.have_cards.all() (the M2M to Card, not through model)
# is heavily used AND causes N+1s (e.g. via __str__), uncomment these: # is heavily used AND causes N+1s (e.g. via __str__), uncomment these:
Prefetch('have_cards'), Prefetch("have_cards"),
Prefetch('want_cards'), Prefetch("want_cards"),
) )
return qs.order_by("-updated_at") # Default ordering for TradeOffer querysets return qs.order_by("-updated_at") # Default ordering for TradeOffer querysets
class TradeOffer(models.Model): class TradeOffer(models.Model):
objects = TradeOfferManager() objects = TradeOfferManager()
@ -75,20 +76,16 @@ class TradeOffer(models.Model):
initiated_by = models.ForeignKey( initiated_by = models.ForeignKey(
"accounts.FriendCode", "accounts.FriendCode",
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='initiated_trade_offers' related_name="initiated_trade_offers",
) )
rarity_icon = models.CharField(max_length=8, null=True) rarity_icon = models.CharField(max_length=8, null=True)
rarity_level = models.IntegerField(null=True) rarity_level = models.IntegerField(null=True)
image = models.ImageField(upload_to='trade_offers/', null=True, blank=True) image = models.ImageField(upload_to="trade_offers/", null=True, blank=True)
want_cards = models.ManyToManyField( want_cards = models.ManyToManyField(
"cards.Card", "cards.Card", related_name="trade_offers_want", through="TradeOfferWantCard"
related_name='trade_offers_want',
through="TradeOfferWantCard"
) )
have_cards = models.ManyToManyField( have_cards = models.ManyToManyField(
"cards.Card", "cards.Card", related_name="trade_offers_have", through="TradeOfferHaveCard"
related_name='trade_offers_have',
through="TradeOfferHaveCard"
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -105,7 +102,7 @@ class TradeOffer(models.Model):
def update_rarity_fields(self): def update_rarity_fields(self):
""" """
Recalculates and updates the rarity_level and rarity_icon fields based on Recalculates and updates the rarity_level and rarity_icon fields based on
the associated have_cards and want_cards. the associated have_cards and want_cards.
Enforces that all cards in the trade offer share the same rarity. Enforces that all cards in the trade offer share the same rarity.
@ -118,11 +115,16 @@ class TradeOffer(models.Model):
# Enforce same rarity across all cards. # Enforce same rarity across all cards.
rarity_levels = {card.rarity_level for card in cards} rarity_levels = {card.rarity_level for card in cards}
if len(rarity_levels) > 1: if len(rarity_levels) > 1:
raise ValidationError("All cards in a trade offer must have the same rarity.") raise ValidationError(
"All cards in a trade offer must have the same rarity."
)
first_card = cards[0] first_card = cards[0]
if first_card.rarity_level > 5: if first_card.rarity_level > 5:
raise ValidationError("Cannot trade cards above one-star rarity.") raise ValidationError("Cannot trade cards above one-star rarity.")
if self.rarity_level != first_card.rarity_level or self.rarity_icon != first_card.rarity_icon: if (
self.rarity_level != first_card.rarity_level
or self.rarity_icon != first_card.rarity_icon
):
self.rarity_level = first_card.rarity_level self.rarity_level = first_card.rarity_level
self.rarity_icon = first_card.rarity_icon self.rarity_icon = first_card.rarity_icon
# Use super().save() here to avoid recursion. # Use super().save() here to avoid recursion.
@ -131,23 +133,33 @@ class TradeOffer(models.Model):
@property @property
def have_cards_available(self): def have_cards_available(self):
# Returns the list of have_cards (through objects) that still have available quantity. # Returns the list of have_cards (through objects) that still have available quantity.
return [item for item in self.trade_offer_have_cards.all() if item.quantity > item.qty_accepted] return [
item
for item in self.trade_offer_have_cards.all()
if item.quantity > item.qty_accepted
]
@property @property
def want_cards_available(self): def want_cards_available(self):
# Returns the list of want_cards (through objects) that still have available quantity. # Returns the list of want_cards (through objects) that still have available quantity.
return [item for item in self.trade_offer_want_cards.all() if item.quantity > item.qty_accepted] return [
item
for item in self.trade_offer_want_cards.all()
if item.quantity > item.qty_accepted
]
class TradeOfferHaveCard(models.Model): class TradeOfferHaveCard(models.Model):
""" """
Through model for TradeOffer.have_cards. Through model for TradeOffer.have_cards.
Represents the card the initiator is offering along with the quantity available. Represents the card the initiator is offering along with the quantity available.
""" """
trade_offer = models.ForeignKey( trade_offer = models.ForeignKey(
TradeOffer, TradeOffer,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='trade_offer_have_cards', related_name="trade_offer_have_cards",
db_index=True db_index=True,
) )
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True) card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True)
quantity = models.PositiveIntegerField(default=1) quantity = models.PositiveIntegerField(default=1)
@ -171,17 +183,17 @@ class TradeOfferHaveCard(models.Model):
class Meta: class Meta:
unique_together = ("trade_offer", "card") unique_together = ("trade_offer", "card")
ordering = ['card__name'] ordering = ["card__name"]
class TradeOfferWantCard(models.Model): class TradeOfferWantCard(models.Model):
""" """
Through model for TradeOffer.want_cards. Through model for TradeOffer.want_cards.
Represents the card the initiator is requesting along with the quantity requested. Represents the card the initiator is requesting along with the quantity requested.
""" """
trade_offer = models.ForeignKey( trade_offer = models.ForeignKey(
TradeOffer, TradeOffer, on_delete=models.CASCADE, related_name="trade_offer_want_cards"
on_delete=models.CASCADE,
related_name='trade_offer_want_cards'
) )
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT) card = models.ForeignKey("cards.Card", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(default=1) quantity = models.PositiveIntegerField(default=1)
@ -205,19 +217,20 @@ class TradeOfferWantCard(models.Model):
class Meta: class Meta:
unique_together = ("trade_offer", "card") unique_together = ("trade_offer", "card")
ordering = ['card__name'] ordering = ["card__name"]
class TradeAcceptance(models.Model): class TradeAcceptance(models.Model):
class AcceptanceState(models.TextChoices): class AcceptanceState(models.TextChoices):
ACCEPTED = 'ACCEPTED', 'Accepted' ACCEPTED = "ACCEPTED", "Accepted"
SENT = 'SENT', 'Sent' SENT = "SENT", "Sent"
RECEIVED = 'RECEIVED', 'Received' RECEIVED = "RECEIVED", "Received"
THANKED_BY_INITIATOR = 'THANKED_BY_INITIATOR', 'Thanked by Initiator' THANKED_BY_INITIATOR = "THANKED_BY_INITIATOR", "Thanked by Initiator"
THANKED_BY_ACCEPTOR = 'THANKED_BY_ACCEPTOR', 'Thanked by Acceptor' THANKED_BY_ACCEPTOR = "THANKED_BY_ACCEPTOR", "Thanked by Acceptor"
THANKED_BY_BOTH = 'THANKED_BY_BOTH', 'Thanked by Both' THANKED_BY_BOTH = "THANKED_BY_BOTH", "Thanked by Both"
REJECTED_BY_INITIATOR = 'REJECTED_BY_INITIATOR', 'Rejected by Initiator' REJECTED_BY_INITIATOR = "REJECTED_BY_INITIATOR", "Rejected by Initiator"
REJECTED_BY_ACCEPTOR = 'REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor' REJECTED_BY_ACCEPTOR = "REJECTED_BY_ACCEPTOR", "Rejected by Acceptor"
# DRY improvement: define active states once as a class-level constant. # DRY improvement: define active states once as a class-level constant.
POSITIVE_STATES = [ POSITIVE_STATES = [
AcceptanceState.ACCEPTED, AcceptanceState.ACCEPTED,
@ -229,30 +242,21 @@ class TradeAcceptance(models.Model):
] ]
trade_offer = models.ForeignKey( trade_offer = models.ForeignKey(
TradeOffer, TradeOffer, on_delete=models.CASCADE, related_name="acceptances", db_index=True
on_delete=models.CASCADE,
related_name='acceptances',
db_index=True
) )
accepted_by = models.ForeignKey( accepted_by = models.ForeignKey(
"accounts.FriendCode", "accounts.FriendCode",
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='trade_acceptances' related_name="trade_acceptances",
) )
requested_card = models.ForeignKey( requested_card = models.ForeignKey(
"cards.Card", "cards.Card", on_delete=models.PROTECT, related_name="accepted_requested"
on_delete=models.PROTECT,
related_name='accepted_requested'
) )
offered_card = models.ForeignKey( offered_card = models.ForeignKey(
"cards.Card", "cards.Card", on_delete=models.PROTECT, related_name="accepted_offered"
on_delete=models.PROTECT,
related_name='accepted_offered'
) )
state = models.CharField( state = models.CharField(
max_length=25, max_length=25, choices=AcceptanceState.choices, default=AcceptanceState.ACCEPTED
choices=AcceptanceState.choices,
default=AcceptanceState.ACCEPTED
) )
hash = models.CharField(max_length=9, editable=False, blank=True) hash = models.CharField(max_length=9, editable=False, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@ -307,11 +311,14 @@ class TradeAcceptance(models.Model):
return self.get_action_label_for_state(self.AcceptanceState.SENT) return self.get_action_label_for_state(self.AcceptanceState.SENT)
elif self.state == self.AcceptanceState.SENT: elif self.state == self.AcceptanceState.SENT:
return self.get_action_label_for_state(self.AcceptanceState.RECEIVED) return self.get_action_label_for_state(self.AcceptanceState.RECEIVED)
elif self.state == self.AcceptanceState.RECEIVED or self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR or self.state == self.AcceptanceState.THANKED_BY_INITIATOR: elif (
self.state == self.AcceptanceState.RECEIVED
or self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR
or self.state == self.AcceptanceState.THANKED_BY_INITIATOR
):
return self.get_action_label_for_state(self.AcceptanceState.THANKED_BY_BOTH) return self.get_action_label_for_state(self.AcceptanceState.THANKED_BY_BOTH)
else: else:
return None return None
@classmethod @classmethod
def get_action_label_for_state_2(cls, state_value): def get_action_label_for_state_2(cls, state_value):
@ -331,12 +338,20 @@ class TradeAcceptance(models.Model):
@property @property
def is_initiator_state(self): def is_initiator_state(self):
return self.state in [self.AcceptanceState.SENT.value, self.AcceptanceState.THANKED_BY_INITIATOR.value, self.AcceptanceState.THANKED_BY_BOTH.value] return self.state in [
self.AcceptanceState.SENT.value,
self.AcceptanceState.THANKED_BY_INITIATOR.value,
self.AcceptanceState.THANKED_BY_BOTH.value,
]
@property @property
def is_acceptor_state(self): def is_acceptor_state(self):
return self.state in [self.AcceptanceState.ACCEPTED.value, self.AcceptanceState.RECEIVED.value, self.AcceptanceState.THANKED_BY_ACCEPTOR.value, self.AcceptanceState.THANKED_BY_BOTH.value] return self.state in [
self.AcceptanceState.ACCEPTED.value,
self.AcceptanceState.RECEIVED.value,
self.AcceptanceState.THANKED_BY_ACCEPTOR.value,
self.AcceptanceState.THANKED_BY_BOTH.value,
]
@property @property
def is_completed(self): def is_completed(self):
@ -368,19 +383,30 @@ class TradeAcceptance(models.Model):
def clean(self): def clean(self):
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
try: try:
have_card = self.trade_offer.trade_offer_have_cards.get(card_id=self.requested_card_id) have_card = self.trade_offer.trade_offer_have_cards.get(
card_id=self.requested_card_id
)
except TradeOfferHaveCard.DoesNotExist: except TradeOfferHaveCard.DoesNotExist:
raise ValidationError("The requested card must be one of the trade offer's available cards (have_cards).") raise ValidationError(
"The requested card must be one of the trade offer's available cards (have_cards)."
)
try: try:
want_card = self.trade_offer.trade_offer_want_cards.get(card_id=self.offered_card_id) want_card = self.trade_offer.trade_offer_want_cards.get(
card_id=self.offered_card_id
)
except TradeOfferWantCard.DoesNotExist: except TradeOfferWantCard.DoesNotExist:
raise ValidationError("The offered card must be one of the trade offer's requested cards (want_cards).") raise ValidationError(
"The offered card must be one of the trade offer's requested cards (want_cards)."
)
# Only perform these validations on creation (when self.pk is None). # Only perform these validations on creation (when self.pk is None).
if self.pk is None: if self.pk is None:
if self.trade_offer.is_closed: if self.trade_offer.is_closed:
raise ValidationError("This trade offer is closed. No more acceptances are allowed.") raise ValidationError(
"This trade offer is closed. No more acceptances are allowed."
)
# Use direct comparison with qty_accepted and quantity. # Use direct comparison with qty_accepted and quantity.
if have_card.qty_accepted >= have_card.quantity: if have_card.qty_accepted >= have_card.quantity:
raise ValidationError("The requested card has no available quantity.") raise ValidationError("The requested card has no available quantity.")
@ -403,26 +429,42 @@ class TradeAcceptance(models.Model):
]: ]:
return 0 return 0
else: else:
return next(index for index, choice in enumerate(self.AcceptanceState.choices) if choice[0] == self.state) + 1 return (
next(
index
for index, choice in enumerate(self.AcceptanceState.choices)
if choice[0] == self.state
)
+ 1
)
def update_state(self, new_state, user): def update_state(self, new_state, user):
if new_state not in [choice[0] for choice in self.AcceptanceState.choices]: if new_state not in [choice[0] for choice in self.AcceptanceState.choices]:
raise ValueError(f"'{new_state}' is not a valid state.") raise ValueError(f"'{new_state}' is not a valid state.")
if (new_state == self.AcceptanceState.THANKED_BY_ACCEPTOR and self.state == self.AcceptanceState.THANKED_BY_INITIATOR) or \ if (
(new_state == self.AcceptanceState.THANKED_BY_INITIATOR and self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR): new_state == self.AcceptanceState.THANKED_BY_ACCEPTOR
and self.state == self.AcceptanceState.THANKED_BY_INITIATOR
) or (
new_state == self.AcceptanceState.THANKED_BY_INITIATOR
and self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR
):
new_state = self.AcceptanceState.THANKED_BY_BOTH new_state = self.AcceptanceState.THANKED_BY_BOTH
if self.state in [ if self.state in [
self.AcceptanceState.THANKED_BY_BOTH, self.AcceptanceState.THANKED_BY_BOTH,
self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_INITIATOR,
self.AcceptanceState.REJECTED_BY_ACCEPTOR self.AcceptanceState.REJECTED_BY_ACCEPTOR,
]: ]:
raise ValueError(f"No transitions allowed from the terminal state '{self.state}'.") raise ValueError(
f"No transitions allowed from the terminal state '{self.state}'."
)
allowed = [x for x, y in self.get_allowed_state_transitions(user)] allowed = [x for x, y in self.get_allowed_state_transitions(user)]
if new_state not in allowed: if new_state not in allowed:
raise ValueError(f"Transition from {self.state} to {new_state} is not allowed.") raise ValueError(
f"Transition from {self.state} to {new_state} is not allowed."
)
self._actioning_user = user self._actioning_user = user
self.state = new_state self.state = new_state
@ -434,10 +476,12 @@ class TradeAcceptance(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def __str__(self): def __str__(self):
return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, " return (
f"accepted_by={self.accepted_by}, " f"TradeAcceptance(offer_hash={self.trade_offer.hash}, "
f"requested_card={self.requested_card}, " f"accepted_by={self.accepted_by}, "
f"offered_card={self.offered_card}, state={self.state})") f"requested_card={self.requested_card}, "
f"offered_card={self.offered_card}, state={self.state})"
)
def get_allowed_state_transitions(self, user): def get_allowed_state_transitions(self, user):
if self.trade_offer.initiated_by in user.friend_codes.all(): if self.trade_offer.initiated_by in user.friend_codes.all():
@ -453,7 +497,7 @@ class TradeAcceptance(models.Model):
self.AcceptanceState.THANKED_BY_INITIATOR, self.AcceptanceState.THANKED_BY_INITIATOR,
self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_INITIATOR,
}, },
self.AcceptanceState.THANKED_BY_INITIATOR: { }, self.AcceptanceState.THANKED_BY_INITIATOR: {},
self.AcceptanceState.THANKED_BY_ACCEPTOR: { self.AcceptanceState.THANKED_BY_ACCEPTOR: {
self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_INITIATOR,
self.AcceptanceState.THANKED_BY_BOTH, self.AcceptanceState.THANKED_BY_BOTH,
@ -469,10 +513,10 @@ class TradeAcceptance(models.Model):
self.AcceptanceState.REJECTED_BY_ACCEPTOR, self.AcceptanceState.REJECTED_BY_ACCEPTOR,
}, },
self.AcceptanceState.RECEIVED: { self.AcceptanceState.RECEIVED: {
self.AcceptanceState.THANKED_BY_ACCEPTOR, #allow early thanks (uses THANKED_BY_ACCEPTOR state) self.AcceptanceState.THANKED_BY_ACCEPTOR, # allow early thanks (uses THANKED_BY_ACCEPTOR state)
self.AcceptanceState.REJECTED_BY_ACCEPTOR self.AcceptanceState.REJECTED_BY_ACCEPTOR,
}, },
self.AcceptanceState.THANKED_BY_ACCEPTOR: { }, self.AcceptanceState.THANKED_BY_ACCEPTOR: {},
self.AcceptanceState.THANKED_BY_INITIATOR: { self.AcceptanceState.THANKED_BY_INITIATOR: {
self.AcceptanceState.THANKED_BY_BOTH, self.AcceptanceState.THANKED_BY_BOTH,
}, },

View file

@ -1,19 +1,15 @@
from django.db.models.signals import post_save, post_delete, pre_save from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.db.models import F from django.db.models import F
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance, TradeOffer from pkmntrade_club.trades.models import (
from django.db import transaction TradeOfferHaveCard,
TradeOfferWantCard,
TradeAcceptance,
)
from pkmntrade_club.accounts.models import CustomUser from pkmntrade_club.accounts.models import CustomUser
from datetime import timedelta
from django.utils import timezone
import uuid
import hashlib
from django.core.mail import send_mail from django.core.mail import send_mail
from django.conf import settings
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.cache import cache
import logging
POSITIVE_STATES = [ POSITIVE_STATES = [
TradeAcceptance.AcceptanceState.ACCEPTED, TradeAcceptance.AcceptanceState.ACCEPTED,
@ -24,20 +20,20 @@ POSITIVE_STATES = [
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
] ]
def adjust_qty_for_trade_offer(trade_offer, card, side, delta): def adjust_qty_for_trade_offer(trade_offer, card, side, delta):
""" """
Increment (or decrement) qty_accepted by delta for the given card on the specified side. Increment (or decrement) qty_accepted by delta for the given card on the specified side.
""" """
if side == 'have': if side == "have":
TradeOfferHaveCard.objects.filter( TradeOfferHaveCard.objects.filter(trade_offer=trade_offer, card=card).update(
trade_offer=trade_offer, qty_accepted=F("qty_accepted") + delta
card=card )
).update(qty_accepted=F('qty_accepted') + delta) elif side == "want":
elif side == 'want': TradeOfferWantCard.objects.filter(trade_offer=trade_offer, card=card).update(
TradeOfferWantCard.objects.filter( qty_accepted=F("qty_accepted") + delta
trade_offer=trade_offer, )
card=card
).update(qty_accepted=F('qty_accepted') + delta)
def update_trade_offer_closed_status(trade_offer): def update_trade_offer_closed_status(trade_offer):
""" """
@ -46,18 +42,17 @@ def update_trade_offer_closed_status(trade_offer):
greater than or equal to quantity; otherwise, mark it as open. greater than or equal to quantity; otherwise, mark it as open.
""" """
have_complete = not TradeOfferHaveCard.objects.filter( have_complete = not TradeOfferHaveCard.objects.filter(
trade_offer=trade_offer, trade_offer=trade_offer, qty_accepted__lt=F("quantity")
qty_accepted__lt=F('quantity')
).exists() ).exists()
want_complete = not TradeOfferWantCard.objects.filter( want_complete = not TradeOfferWantCard.objects.filter(
trade_offer=trade_offer, trade_offer=trade_offer, qty_accepted__lt=F("quantity")
qty_accepted__lt=F('quantity')
).exists() ).exists()
closed = have_complete or want_complete closed = have_complete or want_complete
if trade_offer.is_closed != closed: if trade_offer.is_closed != closed:
trade_offer.is_closed = closed trade_offer.is_closed = closed
trade_offer.save(update_fields=["is_closed"]) trade_offer.save(update_fields=["is_closed"])
@receiver(pre_save, sender=TradeAcceptance) @receiver(pre_save, sender=TradeAcceptance)
def trade_acceptance_pre_save(sender, instance, **kwargs): def trade_acceptance_pre_save(sender, instance, **kwargs):
# Skip signal processing during raw fixture load or when saving a new instance # Skip signal processing during raw fixture load or when saving a new instance
@ -68,6 +63,7 @@ def trade_acceptance_pre_save(sender, instance, **kwargs):
old_instance = TradeAcceptance.objects.get(pk=instance.pk) old_instance = TradeAcceptance.objects.get(pk=instance.pk)
instance._old_state = old_instance.state instance._old_state = old_instance.state
@receiver(post_save, sender=TradeAcceptance) @receiver(post_save, sender=TradeAcceptance)
def trade_acceptance_post_save(sender, instance, created, **kwargs): def trade_acceptance_post_save(sender, instance, created, **kwargs):
delta = 0 delta = 0
@ -75,7 +71,7 @@ def trade_acceptance_post_save(sender, instance, created, **kwargs):
if instance.state in POSITIVE_STATES: if instance.state in POSITIVE_STATES:
delta = 1 delta = 1
else: else:
old_state = getattr(instance, '_old_state', None) old_state = getattr(instance, "_old_state", None)
if old_state is not None: if old_state is not None:
if old_state in POSITIVE_STATES and instance.state not in POSITIVE_STATES: if old_state in POSITIVE_STATES and instance.state not in POSITIVE_STATES:
delta = -1 delta = -1
@ -84,19 +80,29 @@ def trade_acceptance_post_save(sender, instance, created, **kwargs):
if delta != 0: if delta != 0:
trade_offer = instance.trade_offer trade_offer = instance.trade_offer
adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta) adjust_qty_for_trade_offer(
adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta) trade_offer, instance.requested_card, side="have", delta=delta
)
adjust_qty_for_trade_offer(
trade_offer, instance.offered_card, side="want", delta=delta
)
update_trade_offer_closed_status(trade_offer) update_trade_offer_closed_status(trade_offer)
@receiver(post_delete, sender=TradeAcceptance) @receiver(post_delete, sender=TradeAcceptance)
def trade_acceptance_post_delete(sender, instance, **kwargs): def trade_acceptance_post_delete(sender, instance, **kwargs):
if instance.state in POSITIVE_STATES: if instance.state in POSITIVE_STATES:
delta = -1 delta = -1
trade_offer = instance.trade_offer trade_offer = instance.trade_offer
adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta) adjust_qty_for_trade_offer(
adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta) trade_offer, instance.requested_card, side="have", delta=delta
)
adjust_qty_for_trade_offer(
trade_offer, instance.offered_card, side="want", delta=delta
)
update_trade_offer_closed_status(trade_offer) update_trade_offer_closed_status(trade_offer)
@receiver(post_save, sender=TradeAcceptance) @receiver(post_save, sender=TradeAcceptance)
def trade_acceptance_email_notification(sender, instance, created, **kwargs): def trade_acceptance_email_notification(sender, instance, created, **kwargs):
# Only proceed if the update was triggered by an acting user. # Only proceed if the update was triggered by an acting user.
@ -132,7 +138,6 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
else: else:
return return
# Determine the non-acting party: # Determine the non-acting party:
if instance.trade_offer.initiated_by.user.pk == acting_user.pk: if instance.trade_offer.initiated_by.user.pk == acting_user.pk:
# The initiator made the change; notify the acceptor. # The initiator made the change; notify the acceptor.
@ -153,17 +158,31 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
"want_card": instance.offered_card, "want_card": instance.offered_card,
"hash": instance.hash, "hash": instance.hash,
"acting_user": acting_user.username, "acting_user": acting_user.username,
"acting_user_ign": instance.trade_offer.initiated_by.in_game_name if is_initiator else instance.accepted_by.in_game_name, "acting_user_ign": (
instance.trade_offer.initiated_by.in_game_name
if is_initiator
else instance.accepted_by.in_game_name
),
"recipient_user": recipient_user.username, "recipient_user": recipient_user.username,
"recipient_user_ign": instance.accepted_by.in_game_name if is_initiator else instance.trade_offer.initiated_by.in_game_name, "recipient_user_ign": (
"acting_user_friend_code": instance.trade_offer.initiated_by.friend_code if is_initiator else instance.accepted_by.friend_code, instance.accepted_by.in_game_name
if is_initiator
else instance.trade_offer.initiated_by.in_game_name
),
"acting_user_friend_code": (
instance.trade_offer.initiated_by.friend_code
if is_initiator
else instance.accepted_by.friend_code
),
"is_initiator": is_initiator, "is_initiator": is_initiator,
"domain": "https://" + Site.objects.get_current().domain, "domain": "https://" + Site.objects.get_current().domain,
"pk": instance.pk, "pk": instance.pk,
} }
email_template = "email/trades/trade_update_" + state + ".txt" email_template = "email/trades/trade_update_" + state + ".txt"
email_subject = render_to_string("email/common/subject.txt", email_context) email_subject = render_to_string("email/common/subject.txt", email_context)
email_subject += render_to_string("email/trades/trade_update_" + state + "_subject.txt", email_context) email_subject += render_to_string(
"email/trades/trade_update_" + state + "_subject.txt", email_context
)
email_body = render_to_string(email_template, email_context) email_body = render_to_string(email_template, email_context)
send_mail( send_mail(
@ -173,17 +192,18 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
[recipient_user.email], [recipient_user.email],
) )
@receiver(post_save, sender=TradeAcceptance) @receiver(post_save, sender=TradeAcceptance)
def trade_acceptance_reputation_update(sender, instance, created, **kwargs): def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
""" """
Update the denormalized reputation score on the user model based on Update the denormalized reputation score on the user model based on
state transitions for TradeAcceptance. state transitions for TradeAcceptance.
- THANKED_BY_BOTH: both the initiator and the acceptor receive +1 when transitioning - THANKED_BY_BOTH: both the initiator and the acceptor receive +1 when transitioning
into this state, and -1 when leaving it. into this state, and -1 when leaving it.
- REJECTED_BY_INITIATOR: only the acceptor gets -1 when transitioning into it (and +1 when leaving it). - REJECTED_BY_INITIATOR: only the acceptor gets -1 when transitioning into it (and +1 when leaving it).
- REJECTED_BY_ACCEPTOR: only the initiator gets -1 when transitioning into it (and +1 when leaving it). - REJECTED_BY_ACCEPTOR: only the initiator gets -1 when transitioning into it (and +1 when leaving it).
Creation events are ignored because trade acceptances are never created with a terminal state. Creation events are ignored because trade acceptances are never created with a terminal state.
""" """
if created: if created:
@ -191,28 +211,46 @@ def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
thanks_delta = 0 thanks_delta = 0
rejection_delta_initiator = 0 # Delta for the initiator's reputation rejection_delta_initiator = 0 # Delta for the initiator's reputation
rejection_delta_acceptor = 0 # Delta for the acceptor's reputation rejection_delta_acceptor = 0 # Delta for the acceptor's reputation
old_state = getattr(instance, '_old_state', None) old_state = getattr(instance, "_old_state", None)
if old_state is None: if old_state is None:
return return
# Handle THANKED_BY_BOTH transitions # Handle THANKED_BY_BOTH transitions
if old_state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH and instance.state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH: if (
old_state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH
and instance.state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH
):
thanks_delta = 1 thanks_delta = 1
elif old_state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH and instance.state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH: elif (
old_state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH
and instance.state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH
):
thanks_delta = -1 thanks_delta = -1
# Handle REJECTED_BY_INITIATOR transitions (affects the acceptor) # Handle REJECTED_BY_INITIATOR transitions (affects the acceptor)
if old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR: if (
old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR
and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR
):
rejection_delta_acceptor = -1 rejection_delta_acceptor = -1
elif old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR: elif (
old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR
and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR
):
rejection_delta_acceptor = 1 rejection_delta_acceptor = 1
# Handle REJECTED_BY_ACCEPTOR transitions (affects the initiator) # Handle REJECTED_BY_ACCEPTOR transitions (affects the initiator)
if old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR: if (
old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR
and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR
):
rejection_delta_initiator = -1 rejection_delta_initiator = -1
elif old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR: elif (
old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR
and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR
):
rejection_delta_initiator = 1 rejection_delta_initiator = 1
# Apply reputation updates: # Apply reputation updates:
@ -237,12 +275,13 @@ def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
reputation_score=F("reputation_score") + rejection_delta_initiator reputation_score=F("reputation_score") + rejection_delta_initiator
) )
@receiver(post_delete, sender=TradeAcceptance) @receiver(post_delete, sender=TradeAcceptance)
def trade_acceptance_reputation_delete(sender, instance, **kwargs): def trade_acceptance_reputation_delete(sender, instance, **kwargs):
""" """
When a TradeAcceptance is deleted, adjust the reputation score for the When a TradeAcceptance is deleted, adjust the reputation score for the
affected user(s) by reversing any reputation changes previously applied. affected user(s) by reversing any reputation changes previously applied.
- If the deleted instance was in THANKED_BY_BOTH: subtract 1 from both parties. - If the deleted instance was in THANKED_BY_BOTH: subtract 1 from both parties.
- If it was in REJECTED_BY_INITIATOR: add 1 to the acceptor. - If it was in REJECTED_BY_INITIATOR: add 1 to the acceptor.
- If it was in REJECTED_BY_ACCEPTOR: add 1 to the initiator. - If it was in REJECTED_BY_ACCEPTOR: add 1 to the initiator.
@ -263,6 +302,7 @@ def trade_acceptance_reputation_delete(sender, instance, **kwargs):
reputation_score=F("reputation_score") + 1 reputation_score=F("reputation_score") + 1
) )
@receiver(post_save, sender=TradeOfferHaveCard) @receiver(post_save, sender=TradeOfferHaveCard)
@receiver(post_delete, sender=TradeOfferHaveCard) @receiver(post_delete, sender=TradeOfferHaveCard)
@receiver(post_save, sender=TradeOfferWantCard) @receiver(post_save, sender=TradeOfferWantCard)
@ -274,9 +314,11 @@ def bubble_up_trade_offer_updates(sender, instance, **kwargs):
Bubble up updated_at to the TradeOffer model when related instances change. Bubble up updated_at to the TradeOffer model when related instances change.
Also invalidates any cached image by deleting the file. Also invalidates any cached image by deleting the file.
""" """
trade_offer = getattr(instance, 'trade_offer', None) trade_offer = getattr(instance, "trade_offer", None)
if trade_offer and trade_offer.image: if trade_offer and trade_offer.image:
trade_offer.image.delete(save=True) # deleting the image will trigger a save, which updates the updated_at field trade_offer.image.delete(
save=True
) # deleting the image will trigger a save, which updates the updated_at field
elif trade_offer: elif trade_offer:
trade_offer.save(update_fields=['updated_at']) trade_offer.save(update_fields=["updated_at"])

View file

@ -1,9 +1,10 @@
from django import template from django import template
from math import ceil from math import ceil
from pkmntrade_club.trades.models import TradeAcceptance
register = template.Library() register = template.Library()
@register.inclusion_tag('templatetags/trade_offer.html', takes_context=True)
@register.inclusion_tag("templatetags/trade_offer.html", takes_context=True)
def render_trade_offer(context, offer): def render_trade_offer(context, offer):
""" """
Renders a trade offer including detailed trade acceptance information. Renders a trade offer including detailed trade acceptance information.
@ -15,14 +16,11 @@ def render_trade_offer(context, offer):
trade_offer_want_cards = list(offer.trade_offer_want_cards.all()) trade_offer_want_cards = list(offer.trade_offer_want_cards.all())
acceptances = list(offer.acceptances.all()) acceptances = list(offer.acceptances.all())
have_cards_available = [ have_cards_available = [
card for card in trade_offer_have_cards card for card in trade_offer_have_cards if card.quantity > card.qty_accepted
if card.quantity > card.qty_accepted
] ]
want_cards_available = [ want_cards_available = [
card for card in trade_offer_want_cards card for card in trade_offer_want_cards if card.quantity > card.qty_accepted
if card.quantity > card.qty_accepted
] ]
if not have_cards_available or not want_cards_available: if not have_cards_available or not want_cards_available:
@ -31,37 +29,41 @@ def render_trade_offer(context, offer):
flipped = False flipped = False
tag_context = { tag_context = {
'offer_pk': offer.pk, "offer_pk": offer.pk,
'flipped': flipped, "flipped": flipped,
'offer_hash': offer.hash, "offer_hash": offer.hash,
'rarity_icon': offer.rarity_icon, "rarity_icon": offer.rarity_icon,
'initiated_by_email': offer.initiated_by.user.email, "initiated_by_email": offer.initiated_by.user.email,
'initiated_by_username': offer.initiated_by.user.username, "initiated_by_username": offer.initiated_by.user.username,
'initiated_reputation': offer.initiated_by.user.reputation_score, "initiated_reputation": offer.initiated_by.user.reputation_score,
'acceptances': acceptances, "acceptances": acceptances,
'have_cards_available': have_cards_available, "have_cards_available": have_cards_available,
'want_cards_available': want_cards_available, "want_cards_available": want_cards_available,
'num_cards_available': len(have_cards_available) + len(want_cards_available), "num_cards_available": len(have_cards_available) + len(want_cards_available),
'on_detail_page': context.get("request").path.endswith("trades/"+str(offer.pk)+"/"), "on_detail_page": context.get("request").path.endswith(
'cache_key': f'trade_offer_{offer.pk}_{offer.updated_at.timestamp()}_{flipped}', "trades/" + str(offer.pk) + "/"
),
"cache_key": f"trade_offer_{offer.pk}_{offer.updated_at.timestamp()}_{flipped}",
} }
context.update(tag_context) context.update(tag_context)
return context return context
@register.inclusion_tag('templatetags/trade_acceptance.html', takes_context=True)
@register.inclusion_tag("templatetags/trade_acceptance.html", takes_context=True)
def render_trade_acceptance(context, acceptance): def render_trade_acceptance(context, acceptance):
""" """
Renders a simple trade acceptance view with a single row and simplified header/footer. Renders a simple trade acceptance view with a single row and simplified header/footer.
""" """
tag_context = { tag_context = {
"acceptance": acceptance, "acceptance": acceptance,
'cache_key': f'trade_acceptance_{acceptance.pk}_{acceptance.updated_at.timestamp()}', "cache_key": f"trade_acceptance_{acceptance.pk}_{acceptance.updated_at.timestamp()}",
} }
context.update(tag_context) context.update(tag_context)
return context return context
@register.filter @register.filter
def get_action_label(acceptance, state_value): def get_action_label(acceptance, state_value):
""" """
@ -69,25 +71,27 @@ def get_action_label(acceptance, state_value):
""" """
return acceptance.get_action_label_for_state(state_value) return acceptance.get_action_label_for_state(state_value)
@register.filter @register.filter
def action_button_class(state_value): def action_button_class(state_value):
""" """
Returns daisyUI button classes based on the provided state value. Returns daisyUI button classes based on the provided state value.
""" """
mapping = { mapping = {
'ACCEPTED': 'btn btn-primary', "ACCEPTED": "btn btn-primary",
'SENT': 'btn btn-info', "SENT": "btn btn-info",
'RECEIVED': 'btn btn-info', "RECEIVED": "btn btn-info",
'THANKED_BY_INITIATOR': 'btn btn-success', "THANKED_BY_INITIATOR": "btn btn-success",
'THANKED_BY_ACCEPTOR': 'btn btn-success', "THANKED_BY_ACCEPTOR": "btn btn-success",
'THANKED_BY_BOTH': 'btn btn-success', "THANKED_BY_BOTH": "btn btn-success",
'REJECTED_BY_INITIATOR': 'btn btn-error', "REJECTED_BY_INITIATOR": "btn btn-error",
'REJECTED_BY_ACCEPTOR': 'btn btn-error', "REJECTED_BY_ACCEPTOR": "btn btn-error",
} }
# Return a default style if the state isn't in the mapping. # Return a default style if the state isn't in the mapping.
return mapping.get(state_value, 'btn btn-outline') return mapping.get(state_value, "btn btn-outline")
@register.inclusion_tag('templatetags/trade_offer_png.html', takes_context=True)
@register.inclusion_tag("templatetags/trade_offer_png.html", takes_context=True)
def render_trade_offer_png(context, offer, show_friend_code=False): def render_trade_offer_png(context, offer, show_friend_code=False):
CARD_HEIGHT = 32 CARD_HEIGHT = 32
CARD_WIDTH = 160 CARD_WIDTH = 160
@ -96,24 +100,29 @@ def render_trade_offer_png(context, offer, show_friend_code=False):
CARD_WIDTH_PADDING = 64 CARD_WIDTH_PADDING = 64
EXPANDED_CARD_WIDTH_PADDING = 80 EXPANDED_CARD_WIDTH_PADDING = 80
CARD_COL_GAP = 4 CARD_COL_GAP = 4
OUTPUT_PADDING = 24 # height padding is handled by the HTML OUTPUT_PADDING = 24 # height padding is handled by the HTML
have_cards_available = offer.have_cards_available have_cards_available = offer.have_cards_available
want_cards_available = offer.want_cards_available want_cards_available = offer.want_cards_available
num_cards = max(len(have_cards_available), len(want_cards_available)) num_cards = max(len(have_cards_available), len(want_cards_available))
expanded = (len(have_cards_available) + len(want_cards_available)) > 4 expanded = (len(have_cards_available) + len(want_cards_available)) > 4
if expanded: if expanded:
num_cards = ceil(num_cards / 2) # 2 cards per row if expanded num_cards = ceil(num_cards / 2) # 2 cards per row if expanded
image_height = (num_cards * CARD_HEIGHT) + ((num_cards - 1) * CARD_COL_GAP) + HEADER_HEIGHT + FOOTER_HEIGHT image_height = (
(num_cards * CARD_HEIGHT)
+ ((num_cards - 1) * CARD_COL_GAP)
+ HEADER_HEIGHT
+ FOOTER_HEIGHT
)
if expanded: if expanded:
image_width = (4 * CARD_WIDTH) + EXPANDED_CARD_WIDTH_PADDING image_width = (4 * CARD_WIDTH) + EXPANDED_CARD_WIDTH_PADDING
else: else:
image_width = (2 * CARD_WIDTH) + CARD_WIDTH_PADDING image_width = (2 * CARD_WIDTH) + CARD_WIDTH_PADDING
image_width += OUTPUT_PADDING image_width += OUTPUT_PADDING
image_height += OUTPUT_PADDING # height padding is handled by the HTML, but we need to also calculate it here for og meta tag use image_height += OUTPUT_PADDING # height padding is handled by the HTML, but we need to also calculate it here for og meta tag use
request = context.get("request") request = context.get("request")
if request.get_host().startswith("localhost"): if request.get_host().startswith("localhost"):
base_url = "http://{0}".format(request.get_host()) base_url = "http://{0}".format(request.get_host())
@ -121,23 +130,23 @@ def render_trade_offer_png(context, offer, show_friend_code=False):
base_url = "https://{0}".format(request.get_host()) base_url = "https://{0}".format(request.get_host())
tag_context = { tag_context = {
'offer_pk': offer.pk, "offer_pk": offer.pk,
'offer_hash': offer.hash, "offer_hash": offer.hash,
'rarity_icon': offer.rarity_icon, "rarity_icon": offer.rarity_icon,
'initiated_by_email': offer.initiated_by.user.email, "initiated_by_email": offer.initiated_by.user.email,
'initiated_by_username': offer.initiated_by.user.username, "initiated_by_username": offer.initiated_by.user.username,
'have_cards_available': have_cards_available, "have_cards_available": have_cards_available,
'want_cards_available': want_cards_available, "want_cards_available": want_cards_available,
'in_game_name': offer.initiated_by.in_game_name, "in_game_name": offer.initiated_by.in_game_name,
'friend_code': offer.initiated_by.friend_code, "friend_code": offer.initiated_by.friend_code,
'show_friend_code': show_friend_code, "show_friend_code": show_friend_code,
'num_cards_available': len(have_cards_available) + len(want_cards_available), "num_cards_available": len(have_cards_available) + len(want_cards_available),
'expanded': expanded, "expanded": expanded,
'image_width': image_width, "image_width": image_width,
'image_height': image_height, "image_height": image_height,
'base_url': base_url, "base_url": base_url,
'cache_key': f'trade_offer_png_{offer.pk}_{offer.updated_at.timestamp()}_{expanded}', "cache_key": f"trade_offer_png_{offer.pk}_{offer.updated_at.timestamp()}_{expanded}",
} }
context.update(tag_context) context.update(tag_context)
return context return context

View file

@ -20,6 +20,7 @@ from pkmntrade_club.trades.forms import (
) )
from tests.utils.rarity import RARITY_MAPPING from tests.utils.rarity import RARITY_MAPPING
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
# Model Tests # Model Tests
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
@ -35,17 +36,29 @@ class TradeOfferModelTest(TestCase):
# Create cards with the same rarity (valid scenario) # Create cards with the same rarity (valid scenario)
self.card1 = Card.objects.create( self.card1 = Card.objects.create(
name="Card1", cardset="set1", cardnum=1, style="default", name="Card1",
rarity_icon=RARITY_MAPPING[1], rarity_level=1 cardset="set1",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[1],
rarity_level=1,
) )
self.card2 = Card.objects.create( self.card2 = Card.objects.create(
name="Card2", cardset="set1", cardnum=2, style="default", name="Card2",
rarity_icon=RARITY_MAPPING[1], rarity_level=1 cardset="set1",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[1],
rarity_level=1,
) )
# Create a card with a different rarity (to test invalid trade offers) # Create a card with a different rarity (to test invalid trade offers)
self.card3 = Card.objects.create( self.card3 = Card.objects.create(
name="Card3", cardset="set1", cardnum=3, style="default", name="Card3",
rarity_icon=RARITY_MAPPING[8], rarity_level=8 cardset="set1",
cardnum=3,
style="default",
rarity_icon=RARITY_MAPPING[8],
rarity_level=8,
) )
# Create a valid trade offer with consistent rarity details # Create a valid trade offer with consistent rarity details
@ -92,17 +105,27 @@ class TradeAcceptanceModelTest(TestCase):
username="initiator", email="init@example.com", password="password" username="initiator", email="init@example.com", password="password"
) )
self.initiator_friend_code = FriendCode.objects.create( self.initiator_friend_code = FriendCode.objects.create(
friend_code="5555-6666-7777-8888", in_game_name="InitInGame", user=self.other_user friend_code="5555-6666-7777-8888",
in_game_name="InitInGame",
user=self.other_user,
) )
# Create two cards (with the same rarity) # Create two cards (with the same rarity)
self.card1 = Card.objects.create( self.card1 = Card.objects.create(
name="CardA", cardset="setA", cardnum=1, style="default", name="CardA",
rarity_icon=RARITY_MAPPING[2], rarity_level=2 cardset="setA",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[2],
rarity_level=2,
) )
self.card2 = Card.objects.create( self.card2 = Card.objects.create(
name="CardB", cardset="setA", cardnum=2, style="default", name="CardB",
rarity_icon=RARITY_MAPPING[2], rarity_level=2 cardset="setA",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[2],
rarity_level=2,
) )
# Create a trade offer by the initiator. # Create a trade offer by the initiator.
@ -150,9 +173,7 @@ class TradeAcceptanceModelTest(TestCase):
self.acceptance.update_state( self.acceptance.update_state(
TradeAcceptance.AcceptanceState.SENT, user=self.other_user TradeAcceptance.AcceptanceState.SENT, user=self.other_user
) )
self.assertEqual( self.assertEqual(self.acceptance.state, TradeAcceptance.AcceptanceState.SENT)
self.acceptance.state, TradeAcceptance.AcceptanceState.SENT
)
def test_signal_adjusts_qty_accepted(self): def test_signal_adjusts_qty_accepted(self):
""" """
@ -206,12 +227,20 @@ class TradeOfferFormTest(TestCase):
) )
# Create two cards with the same rarity details. # Create two cards with the same rarity details.
self.card1 = Card.objects.create( self.card1 = Card.objects.create(
name="FormCard1", cardset="formset", cardnum=1, style="default", name="FormCard1",
rarity_icon=RARITY_MAPPING[3], rarity_level=3 cardset="formset",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[3],
rarity_level=3,
) )
self.card2 = Card.objects.create( self.card2 = Card.objects.create(
name="FormCard2", cardset="formset", cardnum=2, style="default", name="FormCard2",
rarity_icon=RARITY_MAPPING[3], rarity_level=3 cardset="formset",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[3],
rarity_level=3,
) )
def test_trade_offer_create_form_valid(self): def test_trade_offer_create_form_valid(self):
@ -219,7 +248,7 @@ class TradeOfferFormTest(TestCase):
A valid POST using colon-separated quantity strings should succeed. A valid POST using colon-separated quantity strings should succeed.
""" """
# Build a QueryDict with multiple values for each list field. # Build a QueryDict with multiple values for each list field.
qd = QueryDict('', mutable=True) qd = QueryDict("", mutable=True)
qd.setlist("have_cards", [f"{self.card1.pk}:2"]) qd.setlist("have_cards", [f"{self.card1.pk}:2"])
qd.setlist("want_cards", [f"{self.card2.pk}:3"]) qd.setlist("want_cards", [f"{self.card2.pk}:3"])
# 'initiated_by' is a normal field so we can update it directly. # 'initiated_by' is a normal field so we can update it directly.
@ -231,7 +260,7 @@ class TradeOfferFormTest(TestCase):
""" """
If quantity cannot be parsed as an integer a ValidationError should be raised. If quantity cannot be parsed as an integer a ValidationError should be raised.
""" """
qd = QueryDict('', mutable=True) qd = QueryDict("", mutable=True)
# Provide an invalid quantity ("two" instead of an integer). # Provide an invalid quantity ("two" instead of an integer).
qd.setlist("have_cards", [f"{self.card1.pk}:two"]) qd.setlist("have_cards", [f"{self.card1.pk}:two"])
qd.setlist("want_cards", [f"{self.card2.pk}:3"]) qd.setlist("want_cards", [f"{self.card2.pk}:3"])
@ -244,7 +273,7 @@ class TradeOfferFormTest(TestCase):
""" """
An entry missing a colon should be ignored. An entry missing a colon should be ignored.
""" """
qd = QueryDict('', mutable=True) qd = QueryDict("", mutable=True)
# No colon present in the selections. # No colon present in the selections.
qd.setlist("have_cards", [f"{self.card1.pk}"]) qd.setlist("have_cards", [f"{self.card1.pk}"])
qd.setlist("want_cards", [f"{self.card2.pk}"]) qd.setlist("want_cards", [f"{self.card2.pk}"])
@ -283,9 +312,7 @@ class TradeOfferFormTest(TestCase):
"""Test that TradeOfferAcceptForm correctly sets the friend_code queryset.""" """Test that TradeOfferAcceptForm correctly sets the friend_code queryset."""
friend_codes = FriendCode.objects.filter(pk=self.friend_code.pk) friend_codes = FriendCode.objects.filter(pk=self.friend_code.pk)
form = TradeOfferAcceptForm(friend_codes=friend_codes) form = TradeOfferAcceptForm(friend_codes=friend_codes)
self.assertEqual( self.assertEqual(list(form.fields["friend_code"].queryset), list(friend_codes))
list(form.fields["friend_code"].queryset), list(friend_codes)
)
def test_trade_acceptance_transition_form(self): def test_trade_acceptance_transition_form(self):
"""Test that the transition form provides only allowed transitions.""" """Test that the transition form provides only allowed transitions."""
@ -312,7 +339,10 @@ class TradeOfferFormTest(TestCase):
) )
form = TradeAcceptanceTransitionForm(instance=acceptance, user=other_user) form = TradeAcceptanceTransitionForm(instance=acceptance, user=other_user)
# Compare the form's state choices with the allowed transitions. # Compare the form's state choices with the allowed transitions.
allowed = [choice[0] for choice in acceptance.get_allowed_state_transitions(user=other_user)] allowed = [
choice[0]
for choice in acceptance.get_allowed_state_transitions(user=other_user)
]
form_choices = [choice[0] for choice in form.fields["state"].choices] form_choices = [choice[0] for choice in form.fields["state"].choices]
for choice in allowed: for choice in allowed:
self.assertIn(choice, form_choices) self.assertIn(choice, form_choices)
@ -337,12 +367,20 @@ class TradeViewsTest(TestCase):
# Create sample cards. # Create sample cards.
self.card1 = Card.objects.create( self.card1 = Card.objects.create(
name="ViewCard1", cardset="setV", cardnum=1, style="default", name="ViewCard1",
rarity_icon=RARITY_MAPPING[7], rarity_level=7 cardset="setV",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[7],
rarity_level=7,
) )
self.card2 = Card.objects.create( self.card2 = Card.objects.create(
name="ViewCard2", cardset="setV", cardnum=2, style="default", name="ViewCard2",
rarity_icon=RARITY_MAPPING[7], rarity_level=7 cardset="setV",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[7],
rarity_level=7,
) )
# Create a trade offer initiated by the logged-in user's friend code. # Create a trade offer initiated by the logged-in user's friend code.
self.trade_offer = TradeOffer.objects.create(initiated_by=self.friend_code) self.trade_offer = TradeOffer.objects.create(initiated_by=self.friend_code)
@ -387,7 +425,9 @@ class TradeViewsTest(TestCase):
Instead, if no active acceptances remain it should mark the offer as closed. Instead, if no active acceptances remain it should mark the offer as closed.
""" """
# Create a trade offer with an active acceptance. # Create a trade offer with an active acceptance.
trade_offer_with_acceptance = TradeOffer.objects.create(initiated_by=self.friend_code) trade_offer_with_acceptance = TradeOffer.objects.create(
initiated_by=self.friend_code
)
# Use quantity=2 so the trade offer isn't automatically closed when one acceptance is created # Use quantity=2 so the trade offer isn't automatically closed when one acceptance is created
TradeOfferHaveCard.objects.create( TradeOfferHaveCard.objects.create(
trade_offer=trade_offer_with_acceptance, card=self.card1, quantity=2 trade_offer=trade_offer_with_acceptance, card=self.card1, quantity=2
@ -403,10 +443,13 @@ class TradeViewsTest(TestCase):
offered_card=self.card2, offered_card=self.card2,
state=TradeAcceptance.AcceptanceState.ACCEPTED, state=TradeAcceptance.AcceptanceState.ACCEPTED,
) )
delete_url = reverse("trade_offer_delete", kwargs={"pk": trade_offer_with_acceptance.pk}) delete_url = reverse(
"trade_offer_delete", kwargs={"pk": trade_offer_with_acceptance.pk}
)
# --- Patch the view's get_object() method to return our trade offer --- # --- Patch the view's get_object() method to return our trade offer ---
from pkmntrade_club.trades.views import TradeOfferDeleteView from pkmntrade_club.trades.views import TradeOfferDeleteView
orig_get_object = TradeOfferDeleteView.get_object orig_get_object = TradeOfferDeleteView.get_object
TradeOfferDeleteView.get_object = lambda self: trade_offer_with_acceptance TradeOfferDeleteView.get_object = lambda self: trade_offer_with_acceptance
@ -445,21 +488,27 @@ class TradeViewsTest(TestCase):
state=TradeAcceptance.AcceptanceState.ACCEPTED, state=TradeAcceptance.AcceptanceState.ACCEPTED,
) )
update_url = reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}) update_url = reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk})
# First, try an invalid state update. # First, try an invalid state update.
response = self.client.post(update_url, {"state": "INVALID_STATE"}) response = self.client.post(update_url, {"state": "INVALID_STATE"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
form = response.context.get("form") form = response.context.get("form")
self.assertIsNotNone(form, "Form should be present in the response context.") self.assertIsNotNone(form, "Form should be present in the response context.")
self.assertIn( self.assertIn(
"state", form.errors, "state",
"Expected an error on the 'state' field when an invalid state is submitted." form.errors,
"Expected an error on the 'state' field when an invalid state is submitted.",
)
self.assertTrue(
form.errors["state"], "The 'state' field should have error messages."
) )
self.assertTrue(form.errors["state"], "The 'state' field should have error messages.")
# Next, if there is an allowed valid transition, try it. # Next, if there is an allowed valid transition, try it.
allowed_states = [choice[0] for choice in acceptance.get_allowed_state_transitions(user=self.user)] allowed_states = [
choice[0]
for choice in acceptance.get_allowed_state_transitions(user=self.user)
]
if allowed_states: if allowed_states:
valid_state = allowed_states[0] valid_state = allowed_states[0]
response = self.client.post(update_url, {"state": valid_state}) response = self.client.post(update_url, {"state": valid_state})
@ -493,12 +542,20 @@ class TradeOfferSecurityTests(TestCase):
# Create test cards with proper rarity levels # Create test cards with proper rarity levels
self.card1 = Card.objects.create( self.card1 = Card.objects.create(
name="SecCard1", cardset="secset", cardnum=1, style="default", name="SecCard1",
rarity_icon=RARITY_MAPPING[3], rarity_level=3 cardset="secset",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[3],
rarity_level=3,
) )
self.card2 = Card.objects.create( self.card2 = Card.objects.create(
name="SecCard2", cardset="secset", cardnum=2, style="default", name="SecCard2",
rarity_icon=RARITY_MAPPING[3], rarity_level=3 cardset="secset",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[3],
rarity_level=3,
) )
# Create a trade offer by user1 # Create a trade offer by user1
@ -536,14 +593,14 @@ class TradeOfferSecurityTests(TestCase):
self.client.login(username="user3", password="password3") self.client.login(username="user3", password="password3")
response = self.client.post( response = self.client.post(
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
{"state": TradeAcceptance.AcceptanceState.SENT} {"state": TradeAcceptance.AcceptanceState.SENT},
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_cross_user_friend_code_manipulation(self): def test_cross_user_friend_code_manipulation(self):
"""Test that users cannot use other users' friend codes.""" """Test that users cannot use other users' friend codes."""
self.client.login(username="user2", password="password2") self.client.login(username="user2", password="password2")
# Try to create a trade offer using user1's friend code # Try to create a trade offer using user1's friend code
response = self.client.get( response = self.client.get(
reverse("trade_offer_create"), reverse("trade_offer_create"),
@ -551,12 +608,10 @@ class TradeOfferSecurityTests(TestCase):
"initiated_by": self.fc1.pk, # User1's friend code "initiated_by": self.fc1.pk, # User1's friend code
"have_cards": [f"{self.card1.pk}:1"], "have_cards": [f"{self.card1.pk}:1"],
"want_cards": [f"{self.card2.pk}:1"], "want_cards": [f"{self.card2.pk}:1"],
} },
) )
self.assertEqual(response.status_code, 200) # Form should fail validation self.assertEqual(response.status_code, 200) # Form should fail validation
self.assertFalse( self.assertFalse(TradeOffer.objects.filter(initiated_by=self.fc1).count() > 1)
TradeOffer.objects.filter(initiated_by=self.fc1).count() > 1
)
def test_authenticated_only_views(self): def test_authenticated_only_views(self):
"""Test that authenticated-only views are properly protected.""" """Test that authenticated-only views are properly protected."""
@ -564,18 +619,20 @@ class TradeOfferSecurityTests(TestCase):
urls_to_test = [ urls_to_test = [
reverse("trade_offer_create"), reverse("trade_offer_create"),
reverse("trade_offer_dashboard"), reverse("trade_offer_dashboard"),
reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), reverse(
"trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}
),
] ]
# First ensure we're logged out # First ensure we're logged out
self.client.logout() self.client.logout()
for url in urls_to_test: for url in urls_to_test:
response = self.client.get(url) response = self.client.get(url)
self.assertRedirects( self.assertRedirects(
response, response,
f"/accounts/login/?next={url}", f"/accounts/login/?next={url}",
msg_prefix=f"URL {url} should require authentication" msg_prefix=f"URL {url} should require authentication",
) )
@ -588,19 +645,31 @@ class TradeOfferEdgeCasesTest(TestCase):
self.friend_code = FriendCode.objects.create( self.friend_code = FriendCode.objects.create(
friend_code="3333-4444-5555-6666", in_game_name="EdgeUser", user=self.user friend_code="3333-4444-5555-6666", in_game_name="EdgeUser", user=self.user
) )
# Create test cards with different rarities using proper levels and icons # Create test cards with different rarities using proper levels and icons
self.common_card = Card.objects.create( self.common_card = Card.objects.create(
name="CommonCard", cardset="edgeset", cardnum=1, style="default", name="CommonCard",
rarity_icon=RARITY_MAPPING[1], rarity_level=1 cardset="edgeset",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[1],
rarity_level=1,
) )
self.rare_card = Card.objects.create( self.rare_card = Card.objects.create(
name="RareCard", cardset="edgeset", cardnum=2, style="default", name="RareCard",
rarity_icon=RARITY_MAPPING[5], rarity_level=5 cardset="edgeset",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[5],
rarity_level=5,
) )
self.crown_card = Card.objects.create( self.crown_card = Card.objects.create(
name="CrownCard", cardset="edgeset", cardnum=3, style="default", name="CrownCard",
rarity_icon=RARITY_MAPPING[8], rarity_level=8 cardset="edgeset",
cardnum=3,
style="default",
rarity_icon=RARITY_MAPPING[8],
rarity_level=8,
) )
self.client = Client() self.client = Client()
@ -614,7 +683,7 @@ class TradeOfferEdgeCasesTest(TestCase):
"initiated_by": self.friend_code.pk, "initiated_by": self.friend_code.pk,
"have_cards": [f"{self.common_card.pk}:0"], "have_cards": [f"{self.common_card.pk}:0"],
"want_cards": [f"{self.common_card.pk}:1"], "want_cards": [f"{self.common_card.pk}:1"],
} },
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse( self.assertFalse(
@ -629,7 +698,7 @@ class TradeOfferEdgeCasesTest(TestCase):
"initiated_by": self.friend_code.pk, "initiated_by": self.friend_code.pk,
"have_cards": [f"{self.common_card.pk}:-1"], "have_cards": [f"{self.common_card.pk}:-1"],
"want_cards": [f"{self.common_card.pk}:1"], "want_cards": [f"{self.common_card.pk}:1"],
} },
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse( self.assertFalse(
@ -644,7 +713,7 @@ class TradeOfferEdgeCasesTest(TestCase):
"initiated_by": self.friend_code.pk, "initiated_by": self.friend_code.pk,
"have_cards": [f"{self.common_card.pk}:1"], "have_cards": [f"{self.common_card.pk}:1"],
"want_cards": [f"{self.crown_card.pk}:1"], "want_cards": [f"{self.crown_card.pk}:1"],
} },
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse( self.assertFalse(
@ -657,12 +726,9 @@ class TradeOfferEdgeCasesTest(TestCase):
reverse("trade_offer_create"), reverse("trade_offer_create"),
{ {
"initiated_by": self.friend_code.pk, "initiated_by": self.friend_code.pk,
"have_cards": [ "have_cards": [f"{self.common_card.pk}:1", f"{self.common_card.pk}:1"],
f"{self.common_card.pk}:1",
f"{self.common_card.pk}:1"
],
"want_cards": [f"{self.common_card.pk}:1"], "want_cards": [f"{self.common_card.pk}:1"],
} },
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse( self.assertFalse(
@ -682,16 +748,28 @@ class TradeSearchTests(TestCase):
# Create test cards with proper rarity levels # Create test cards with proper rarity levels
self.card1 = Card.objects.create( self.card1 = Card.objects.create(
name="SearchCard1", cardset="sc1", cardnum=1, style="default", name="SearchCard1",
rarity_icon=RARITY_MAPPING[4], rarity_level=4 cardset="sc1",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[4],
rarity_level=4,
) )
self.card2 = Card.objects.create( self.card2 = Card.objects.create(
name="SearchCard2", cardset="sc1", cardnum=2, style="default", name="SearchCard2",
rarity_icon=RARITY_MAPPING[4], rarity_level=4 cardset="sc1",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[4],
rarity_level=4,
) )
self.card3 = Card.objects.create( self.card3 = Card.objects.create(
name="SearchCard3", cardset="sc1", cardnum=3, style="default", name="SearchCard3",
rarity_icon=RARITY_MAPPING[4], rarity_level=4 cardset="sc1",
cardnum=3,
style="default",
rarity_icon=RARITY_MAPPING[4],
rarity_level=4,
) )
# Create some trade offers # Create some trade offers
@ -719,7 +797,7 @@ class TradeSearchTests(TestCase):
reverse("trade_offer_search"), reverse("trade_offer_search"),
{ {
"have_cards": [f"{self.card2.pk}:1"], "have_cards": [f"{self.card2.pk}:1"],
} },
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
@ -731,7 +809,7 @@ class TradeSearchTests(TestCase):
reverse("trade_offer_search"), reverse("trade_offer_search"),
{ {
"want_cards": [f"{self.card1.pk}:1"], "want_cards": [f"{self.card1.pk}:1"],
} },
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
@ -743,7 +821,7 @@ class TradeSearchTests(TestCase):
reverse("trade_offer_search"), reverse("trade_offer_search"),
{ {
"have_cards": ["999999:1"], # Non-existent card ID "have_cards": ["999999:1"], # Non-existent card ID
} },
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
@ -753,12 +831,12 @@ class TradeSearchTests(TestCase):
"""Test that closed trades don't appear in search results.""" """Test that closed trades don't appear in search results."""
self.trade_offer1.is_closed = True self.trade_offer1.is_closed = True
self.trade_offer1.save() self.trade_offer1.save()
response = self.client.post( response = self.client.post(
reverse("trade_offer_search"), reverse("trade_offer_search"),
{ {
"have_cards": [f"{self.card2.pk}:1"], "have_cards": [f"{self.card2.pk}:1"],
} },
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
@ -773,30 +851,50 @@ class TradeAcceptanceComplexTests(TestCase):
self.acceptor = User.objects.create_user( self.acceptor = User.objects.create_user(
username="acceptor", email="accept@example.com", password="password" username="acceptor", email="accept@example.com", password="password"
) )
self.initiator_fc = FriendCode.objects.create( self.initiator_fc = FriendCode.objects.create(
friend_code="1234-5678-9012-3456", in_game_name="InitUser", user=self.initiator friend_code="1234-5678-9012-3456",
in_game_name="InitUser",
user=self.initiator,
) )
self.acceptor_fc = FriendCode.objects.create( self.acceptor_fc = FriendCode.objects.create(
friend_code="6543-2109-8765-4321", in_game_name="AcceptUser", user=self.acceptor friend_code="6543-2109-8765-4321",
in_game_name="AcceptUser",
user=self.acceptor,
) )
# Create test cards with proper rarity levels # Create test cards with proper rarity levels
self.card1 = Card.objects.create( self.card1 = Card.objects.create(
name="ComplexCard1", cardset="cx1", cardnum=1, style="default", name="ComplexCard1",
rarity_icon=RARITY_MAPPING[6], rarity_level=6 cardset="cx1",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[6],
rarity_level=6,
) )
self.card2 = Card.objects.create( self.card2 = Card.objects.create(
name="ComplexCard2", cardset="cx1", cardnum=2, style="default", name="ComplexCard2",
rarity_icon=RARITY_MAPPING[6], rarity_level=6 cardset="cx1",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[6],
rarity_level=6,
) )
self.card3 = Card.objects.create( self.card3 = Card.objects.create(
name="ComplexCard3", cardset="cx1", cardnum=3, style="default", name="ComplexCard3",
rarity_icon=RARITY_MAPPING[6], rarity_level=6 cardset="cx1",
cardnum=3,
style="default",
rarity_icon=RARITY_MAPPING[6],
rarity_level=6,
) )
self.card4 = Card.objects.create( self.card4 = Card.objects.create(
name="ComplexCard4", cardset="cx1", cardnum=4, style="default", name="ComplexCard4",
rarity_icon=RARITY_MAPPING[6], rarity_level=6 cardset="cx1",
cardnum=4,
style="default",
rarity_icon=RARITY_MAPPING[6],
rarity_level=6,
) )
# Create a trade offer with multiple quantities # Create a trade offer with multiple quantities
@ -819,58 +917,67 @@ class TradeAcceptanceComplexTests(TestCase):
def test_multiple_acceptances_quantity_limit(self): def test_multiple_acceptances_quantity_limit(self):
"""Test that multiple acceptances cannot exceed the offer's quantity limit.""" """Test that multiple acceptances cannot exceed the offer's quantity limit."""
self.client.login(username="acceptor", password="password") self.client.login(username="acceptor", password="password")
# Create first acceptance # Create first acceptance
response1 = self.client.post( response1 = self.client.post(
reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), reverse(
"trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}
),
{ {
"accepted_by": self.acceptor_fc.pk, "accepted_by": self.acceptor_fc.pk,
"requested_card": self.card1.pk, "requested_card": self.card1.pk,
"offered_card": self.card2.pk, "offered_card": self.card2.pk,
} },
) )
self.assertEqual(response1.status_code, 302) # Successful creation self.assertEqual(response1.status_code, 302) # Successful creation
# Create second acceptance # Create second acceptance
response2 = self.client.post( response2 = self.client.post(
reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), reverse(
"trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}
),
{ {
"accepted_by": self.acceptor_fc.pk, "accepted_by": self.acceptor_fc.pk,
"requested_card": self.card1.pk, "requested_card": self.card1.pk,
"offered_card": self.card2.pk, "offered_card": self.card2.pk,
} },
) )
self.assertEqual(response2.status_code, 302) # Successful creation self.assertEqual(response2.status_code, 302) # Successful creation
# Try to create a fourth acceptance (should fail as only 3 are allowed) # Try to create a fourth acceptance (should fail as only 3 are allowed)
response3 = self.client.post( response3 = self.client.post(
reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), reverse(
"trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}
),
{ {
"accepted_by": self.acceptor_fc.pk, "accepted_by": self.acceptor_fc.pk,
"requested_card": self.card1.pk, "requested_card": self.card1.pk,
"offered_card": self.card2.pk, "offered_card": self.card2.pk,
} },
) )
self.assertEqual(response3.status_code, 302) # Successful creation self.assertEqual(response3.status_code, 302) # Successful creation
response4 = self.client.post( response4 = self.client.post(
reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), reverse(
"trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}
),
{ {
"accepted_by": self.acceptor_fc.pk, "accepted_by": self.acceptor_fc.pk,
"requested_card": self.card1.pk, "requested_card": self.card1.pk,
"offered_card": self.card2.pk, "offered_card": self.card2.pk,
} },
) )
self.assertEqual(response4.status_code, 200) # Should fail self.assertEqual(response4.status_code, 200) # Should fail
self.assertEqual( self.assertEqual(
self.trade_offer.acceptances.count(), 3, self.trade_offer.acceptances.count(),
"Should not allow more acceptances than the quantity limit" 3,
"Should not allow more acceptances than the quantity limit",
) )
def test_complex_state_transitions(self): def test_complex_state_transitions(self):
"""Test complex state transition scenarios.""" """Test complex state transition scenarios."""
self.client.login(username="acceptor", password="password") self.client.login(username="acceptor", password="password")
# Create an acceptance # Create an acceptance
acceptance = TradeAcceptance.objects.create( acceptance = TradeAcceptance.objects.create(
trade_offer=self.trade_offer, trade_offer=self.trade_offer,
@ -890,14 +997,14 @@ class TradeAcceptanceComplexTests(TestCase):
for invalid_state in invalid_transitions: for invalid_state in invalid_transitions:
response = self.client.post( response = self.client.post(
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
{"state": invalid_state} {"state": invalid_state},
) )
self.assertEqual(response.status_code, 200) # Should stay on form self.assertEqual(response.status_code, 200) # Should stay on form
acceptance.refresh_from_db() acceptance.refresh_from_db()
self.assertEqual( self.assertEqual(
acceptance.state, acceptance.state,
TradeAcceptance.AcceptanceState.ACCEPTED, TradeAcceptance.AcceptanceState.ACCEPTED,
f"Invalid transition to {invalid_state} should not be allowed" f"Invalid transition to {invalid_state} should not be allowed",
) )
# Test valid state transition sequence # Test valid state transition sequence
@ -912,14 +1019,12 @@ class TradeAcceptanceComplexTests(TestCase):
self.client.login(username=user.username, password="password") self.client.login(username=user.username, password="password")
response = self.client.post( response = self.client.post(
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
{"state": state} {"state": state},
) )
self.assertEqual(response.status_code, 302) # Should redirect on success self.assertEqual(response.status_code, 302) # Should redirect on success
acceptance.refresh_from_db() acceptance.refresh_from_db()
self.assertEqual( self.assertEqual(
acceptance.state, acceptance.state,
state, state,
f"Valid transition to {state} should be allowed" f"Valid transition to {state} should be allowed",
) )

View file

@ -13,12 +13,24 @@ from .views import (
urlpatterns = [ urlpatterns = [
path("create/", TradeOfferCreateView.as_view(), name="trade_offer_create"), path("create/", TradeOfferCreateView.as_view(), name="trade_offer_create"),
path("create/confirm/", TradeOfferCreateConfirmView.as_view(), name="trade_offer_confirm_create"), path(
"create/confirm/",
TradeOfferCreateConfirmView.as_view(),
name="trade_offer_confirm_create",
),
path("", TradeOfferAllListView.as_view(), name="trade_offer_list"), path("", TradeOfferAllListView.as_view(), name="trade_offer_list"),
path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"), path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"),
path("<int:pk>/", TradeOfferDetailView.as_view(), name="trade_offer_detail"), path("<int:pk>/", TradeOfferDetailView.as_view(), name="trade_offer_detail"),
path("<int:pk>.png", TradeOfferPNGView.as_view(), name="trade_offer_png"), path("<int:pk>.png", TradeOfferPNGView.as_view(), name="trade_offer_png"),
path("delete/<int:pk>/", TradeOfferDeleteView.as_view(), name="trade_offer_delete"), path("delete/<int:pk>/", TradeOfferDeleteView.as_view(), name="trade_offer_delete"),
path("accept/<int:offer_pk>", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"), path(
path("update/<int:pk>/", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"), "accept/<int:offer_pk>",
TradeAcceptanceCreateView.as_view(),
name="trade_acceptance_create",
),
path(
"update/<int:pk>/",
TradeAcceptanceUpdateView.as_view(),
name="trade_acceptance_update",
),
] ]

View file

@ -1,25 +1,35 @@
from django.template import RequestContext
from django.views.generic import DeleteView, CreateView, ListView, DetailView, UpdateView
from django.views import View
from django.urls import reverse_lazy
from django.http import HttpResponseRedirect
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import render
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.paginator import Paginator
from django.contrib import messages from django.contrib import messages
from meta.views import Meta from django.contrib.auth.mixins import LoginRequiredMixin
from .models import TradeOffer, TradeAcceptance from django.core.exceptions import PermissionDenied, ValidationError
from .forms import (TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm) from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.template.loader import render_to_string from django.template.loader import render_to_string
from pkmntrade_club.trades.templatetags.trade_offer_tags import render_trade_offer_png from django.urls import reverse_lazy
from django.views import View
from django.views.generic import (
CreateView,
DeleteView,
DetailView,
ListView,
UpdateView,
)
from meta.views import Meta
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
from django.conf import settings
from .mixins import FriendCodeRequiredMixin
from pkmntrade_club.common.mixins import ReusablePaginationMixin from pkmntrade_club.common.mixins import ReusablePaginationMixin
from pkmntrade_club.trades.templatetags.trade_offer_tags import render_trade_offer_png
from .forms import (
TradeAcceptanceCreateForm,
TradeAcceptanceTransitionForm,
TradeOfferCreateForm,
)
from .mixins import FriendCodeRequiredMixin
from .models import TradeAcceptance, TradeOffer
class TradeOfferCreateView(LoginRequiredMixin, CreateView): class TradeOfferCreateView(LoginRequiredMixin, CreateView):
http_method_names = ['get'] # restricts this view to GET only http_method_names = ["get"] # restricts this view to GET only
model = TradeOffer model = TradeOffer
form_class = TradeOfferCreateForm form_class = TradeOfferCreateForm
template_name = "trades/trade_offer_create.html" template_name = "trades/trade_offer_create.html"
@ -42,20 +52,30 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
from pkmntrade_club.cards.models import Card from pkmntrade_club.cards.models import Card
# Ensure available_cards is a proper QuerySet # Ensure available_cards is a proper QuerySet
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level") context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by(
"name", "rarity_level"
)
friend_codes = self.request.user.friend_codes.all() friend_codes = self.request.user.friend_codes.all()
if "initiated_by" in self.request.GET: if "initiated_by" in self.request.GET:
try: try:
selected_friend_code = friend_codes.get(pk=self.request.GET.get("initiated_by")) selected_friend_code = friend_codes.get(
pk=self.request.GET.get("initiated_by")
)
except friend_codes.model.DoesNotExist: except friend_codes.model.DoesNotExist:
selected_friend_code = self.request.user.default_friend_code or friend_codes.first() selected_friend_code = (
self.request.user.default_friend_code or friend_codes.first()
)
else: else:
selected_friend_code = self.request.user.default_friend_code or friend_codes.first() selected_friend_code = (
self.request.user.default_friend_code or friend_codes.first()
)
context["friend_codes"] = friend_codes context["friend_codes"] = friend_codes
context["selected_friend_code"] = selected_friend_code context["selected_friend_code"] = selected_friend_code
return context return context
class TradeOfferAllListView(ReusablePaginationMixin, ListView): class TradeOfferAllListView(ReusablePaginationMixin, ListView):
model = TradeOffer model = TradeOffer
template_name = "trades/trade_offer_all_list.html" template_name = "trades/trade_offer_all_list.html"
@ -93,14 +113,21 @@ class TradeOfferAllListView(ReusablePaginationMixin, ListView):
page_number = self.get_page_number() page_number = self.get_page_number()
self.per_page = 10 self.per_page = 10
paginated_offers, pagination_context = self.paginate_data(queryset, page_number) paginated_offers, pagination_context = self.paginate_data(
queryset, page_number
)
return render( return render(
self.request, self.request,
"trades/_trade_offer_list.html", "trades/_trade_offer_list.html",
{"offers": paginated_offers, "page_obj": pagination_context, "expanded": expanded} {
"offers": paginated_offers,
"page_obj": pagination_context,
"expanded": expanded,
},
) )
return super().render_to_response(context, **response_kwargs) return super().render_to_response(context, **response_kwargs)
class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteView): class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteView):
model = TradeOffer model = TradeOffer
success_url = reverse_lazy("trade_offer_list") success_url = reverse_lazy("trade_offer_list")
@ -108,8 +135,12 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.object = super().get_object() self.object = super().get_object()
if self.object.initiated_by_id not in request.user.friend_codes.values_list("id", flat=True): if self.object.initiated_by_id not in request.user.friend_codes.values_list(
raise PermissionDenied("You are not authorized to delete or close this trade offer.") "id", flat=True
):
raise PermissionDenied(
"You are not authorized to delete or close this trade offer."
)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -143,8 +174,8 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi
active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states) active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states)
if active_acceptances.exists(): if active_acceptances.exists():
messages.error( messages.error(
request, request,
"Cannot close this trade offer while there are active acceptances. Please reject all acceptances before closing, or finish the trades." "Cannot close this trade offer while there are active acceptances. Please reject all acceptances before closing, or finish the trades.",
) )
context = self.get_context_data() context = self.get_context_data()
return self.render_to_response(context) return self.render_to_response(context)
@ -158,6 +189,7 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi
messages.success(request, "Trade offer has been deleted.") messages.success(request, "Trade offer has been deleted.")
return super().delete(request, *args, **kwargs) return super().delete(request, *args, **kwargs)
class TradeOfferSearchView(ListView): class TradeOfferSearchView(ListView):
""" """
Reworked trade offer search view using POST. Reworked trade offer search view using POST.
@ -171,6 +203,7 @@ class TradeOfferSearchView(ListView):
(_search_results.html) is rendered. On GET (initial page load), the search results queryset (_search_results.html) is rendered. On GET (initial page load), the search results queryset
is empty. is empty.
""" """
model = TradeOffer model = TradeOffer
context_object_name = "search_results" context_object_name = "search_results"
template_name = "trades/trade_offer_search.html" template_name = "trades/trade_offer_search.html"
@ -198,7 +231,7 @@ class TradeOfferSearchView(ListView):
results.append((card_id, qty)) results.append((card_id, qty))
return results return results
#@silk_profile(name="Trade Offer Search- Get Queryset") # @silk_profile(name="Trade Offer Search- Get Queryset")
def get_queryset(self): def get_queryset(self):
# For a GET request (initial load), return an empty queryset. # For a GET request (initial load), return an empty queryset.
if self.request.method == "GET": if self.request.method == "GET":
@ -215,7 +248,7 @@ class TradeOfferSearchView(ListView):
qs = TradeOffer.objects.filter( qs = TradeOffer.objects.filter(
is_closed=False, is_closed=False,
) )
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
qs = qs.exclude(initiated_by__in=self.request.user.friend_codes.all()) qs = qs.exclude(initiated_by__in=self.request.user.friend_codes.all())
@ -237,17 +270,20 @@ class TradeOfferSearchView(ListView):
return qs.distinct() return qs.distinct()
#@silk_profile(name="Trade Offer Search- Post") # @silk_profile(name="Trade Offer Search- Post")
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
# For POST, simply process the search through get(). # For POST, simply process the search through get().
return self.get(request, *args, **kwargs) return self.get(request, *args, **kwargs)
#@silk_profile(name="Trade Offer Search- Get Context Data") # @silk_profile(name="Trade Offer Search- Get Context Data")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
from pkmntrade_club.cards.models import Card from pkmntrade_club.cards.models import Card
# Populate available_cards to re-populate the multiselects. Exclude cards with rarity level > 5. # Populate available_cards to re-populate the multiselects. Exclude cards with rarity level > 5.
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level") context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by(
"name", "rarity_level"
)
if self.request.method == "POST": if self.request.method == "POST":
context["have_cards"] = self.request.POST.getlist("have_cards") context["have_cards"] = self.request.POST.getlist("have_cards")
context["want_cards"] = self.request.POST.getlist("want_cards") context["want_cards"] = self.request.POST.getlist("want_cards")
@ -256,35 +292,40 @@ class TradeOfferSearchView(ListView):
context["want_cards"] = [] context["want_cards"] = []
return context return context
#@silk_profile(name="Trade Offer Search- Render to Response") # @silk_profile(name="Trade Offer Search- Render to Response")
def render_to_response(self, context, **response_kwargs): def render_to_response(self, context, **response_kwargs):
""" """
Render the AJAX fragment if the request is AJAX; otherwise, render the complete page. Render the AJAX fragment if the request is AJAX; otherwise, render the complete page.
""" """
if self.request.headers.get("X-Requested-With") == "XMLHttpRequest": if self.request.headers.get("X-Requested-With") == "XMLHttpRequest":
from django.shortcuts import render from django.shortcuts import render
return render(self.request, "trades/_search_results.html", context) return render(self.request, "trades/_search_results.html", context)
else: else:
return super().render_to_response(context, **response_kwargs) return super().render_to_response(context, **response_kwargs)
class TradeOfferDetailView(DetailView): class TradeOfferDetailView(DetailView):
""" """
Displays the details of a TradeOffer along with its active acceptances. Displays the details of a TradeOffer along with its active acceptances.
If the offer is still open and the current user is not its initiator, If the offer is still open and the current user is not its initiator,
an acceptance form is provided to create a new acceptance. an acceptance form is provided to create a new acceptance.
""" """
model = TradeOffer model = TradeOffer
template_name = "trades/trade_offer_detail.html" template_name = "trades/trade_offer_detail.html"
#@silk_profile(name="Trade Offer Detail- Get Context Data") # @silk_profile(name="Trade Offer Detail- Get Context Data")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
trade_offer = self.get_object() trade_offer = self.get_object()
screenshot_mode = self.request.GET.get("screenshot_mode") screenshot_mode = self.request.GET.get("screenshot_mode")
if screenshot_mode: if screenshot_mode:
context["show_friend_code"] = trade_offer.initiated_by.user.show_friend_code_on_link_previews context["show_friend_code"] = (
trade_offer.initiated_by.user.show_friend_code_on_link_previews
)
context["screenshot_mode"] = screenshot_mode context["screenshot_mode"] = screenshot_mode
# Calculate the number of cards in each category. # Calculate the number of cards in each category.
num_has = trade_offer.trade_offer_have_cards.count() num_has = trade_offer.trade_offer_have_cards.count()
num_wants = trade_offer.trade_offer_want_cards.count() num_wants = trade_offer.trade_offer_want_cards.count()
@ -315,14 +356,14 @@ class TradeOfferDetailView(DetailView):
# compute the height from the width. # compute the height from the width.
image_width = base_width image_width = base_width
image_height = int(round(image_width / aspect_ratio)) image_height = int(round(image_width / aspect_ratio))
# Build the meta tags with the computed dimensions. # Build the meta tags with the computed dimensions.
title = f'Trade Offer from {trade_offer.initiated_by.in_game_name} ({trade_offer.initiated_by.friend_code})' title = f"Trade Offer from {trade_offer.initiated_by.in_game_name} ({trade_offer.initiated_by.friend_code})"
context["meta"] = Meta( context["meta"] = Meta(
title=title, title=title,
description=f'Has: {", ".join([card.card.name for card in trade_offer.trade_offer_have_cards.all()])}\nWants: {", ".join([card.card.name for card in trade_offer.trade_offer_want_cards.all()])}', description=f"Has: {', '.join([card.card.name for card in trade_offer.trade_offer_have_cards.all()])}\nWants: {', '.join([card.card.name for card in trade_offer.trade_offer_want_cards.all()])}",
image_object={ image_object={
"url": f'http://localhost:8000{reverse_lazy("trade_offer_png", kwargs={"pk": trade_offer.pk})}', "url": f"http://localhost:8000{reverse_lazy('trade_offer_png', kwargs={'pk': trade_offer.pk})}",
"type": "image/png", "type": "image/png",
"width": image_width, "width": image_width,
"height": image_height, "height": image_height,
@ -333,7 +374,7 @@ class TradeOfferDetailView(DetailView):
use_facebook=True, use_facebook=True,
use_schemaorg=True, use_schemaorg=True,
) )
# Define terminal (closed) acceptance states based on our new system: # Define terminal (closed) acceptance states based on our new system:
terminal_states = [ terminal_states = [
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
@ -342,32 +383,41 @@ class TradeOfferDetailView(DetailView):
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
] ]
# For example, if you want to separate active from terminal acceptances: # For example, if you want to separate active from terminal acceptances:
context["acceptances"] = trade_offer.acceptances.all() context["acceptances"] = trade_offer.acceptances.all()
# Option 1: Filter active acceptances using the queryset lookup. # Option 1: Filter active acceptances using the queryset lookup.
context["active_acceptances"] = trade_offer.acceptances.exclude(state__in=terminal_states) context["active_acceptances"] = trade_offer.acceptances.exclude(
state__in=terminal_states
)
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
user_friend_codes = self.request.user.friend_codes.all() user_friend_codes = self.request.user.friend_codes.all()
# Add context flag and deletion URL if the current user is the initiator # Add context flag and deletion URL if the current user is the initiator
if trade_offer.initiated_by in user_friend_codes: if trade_offer.initiated_by in user_friend_codes:
context["is_initiator"] = True context["is_initiator"] = True
context["delete_close_url"] = reverse_lazy("trade_offer_delete", kwargs={"pk": trade_offer.pk}) context["delete_close_url"] = reverse_lazy(
"trade_offer_delete", kwargs={"pk": trade_offer.pk}
)
else: else:
context["is_initiator"] = False context["is_initiator"] = False
# Determine the user's default friend code (or fallback as needed). # Determine the user's default friend code (or fallback as needed).
default_friend_code = self.request.user.default_friend_code or user_friend_codes.first() default_friend_code = (
self.request.user.default_friend_code or user_friend_codes.first()
)
# If the current user is not the initiator and the offer is open, allow a new acceptance. # If the current user is not the initiator and the offer is open, allow a new acceptance.
if trade_offer.initiated_by not in user_friend_codes and not trade_offer.is_closed: if (
trade_offer.initiated_by not in user_friend_codes
and not trade_offer.is_closed
):
context["acceptance_form"] = TradeAcceptanceCreateForm( context["acceptance_form"] = TradeAcceptanceCreateForm(
trade_offer=trade_offer, trade_offer=trade_offer,
friend_codes=user_friend_codes, friend_codes=user_friend_codes,
default_friend_code=default_friend_code default_friend_code=default_friend_code,
) )
else: else:
context["is_initiator"] = False context["is_initiator"] = False
@ -376,11 +426,15 @@ class TradeOfferDetailView(DetailView):
return context return context
class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, CreateView):
class TradeAcceptanceCreateView(
LoginRequiredMixin, FriendCodeRequiredMixin, CreateView
):
""" """
View to create a new TradeAcceptance. View to create a new TradeAcceptance.
The URL should provide 'offer_pk' so that the proper TradeOffer can be identified. The URL should provide 'offer_pk' so that the proper TradeOffer can be identified.
""" """
model = TradeAcceptance model = TradeAcceptance
form_class = TradeAcceptanceCreateForm form_class = TradeAcceptanceCreateForm
template_name = "trades/trade_acceptance_create.html" template_name = "trades/trade_acceptance_create.html"
@ -390,16 +444,18 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_trade_offer(self): def get_trade_offer(self):
return TradeOffer.objects.get(pk=self.kwargs['offer_pk']) return TradeOffer.objects.get(pk=self.kwargs["offer_pk"])
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
if (self.trade_offer.initiated_by_id in if (
self.request.user.friend_codes.values_list("id", flat=True) or self.trade_offer.initiated_by_id
self.trade_offer.is_closed): in self.request.user.friend_codes.values_list("id", flat=True)
or self.trade_offer.is_closed
):
raise PermissionDenied("You cannot accept this trade offer.") raise PermissionDenied("You cannot accept this trade offer.")
kwargs['trade_offer'] = self.trade_offer kwargs["trade_offer"] = self.trade_offer
kwargs['friend_codes'] = self.request.user.friend_codes.all() kwargs["friend_codes"] = self.request.user.friend_codes.all()
return kwargs return kwargs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -430,7 +486,13 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre
"acceptance_form": form, "acceptance_form": form,
"friend_codes": friend_codes, "friend_codes": friend_codes,
"is_initiator": is_initiator, "is_initiator": is_initiator,
"delete_close_url": reverse_lazy("trade_offer_delete", kwargs={"pk": self.trade_offer.pk}) if is_initiator else None, "delete_close_url": (
reverse_lazy(
"trade_offer_delete", kwargs={"pk": self.trade_offer.pk}
)
if is_initiator
else None
),
} }
# Render the detail page with the form errors # Render the detail page with the form errors
return render(self.request, "trades/trade_offer_detail.html", context) return render(self.request, "trades/trade_offer_detail.html", context)
@ -439,11 +501,15 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre
def get_success_url(self): def get_success_url(self):
return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk}) return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk})
class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, UpdateView):
class TradeAcceptanceUpdateView(
LoginRequiredMixin, FriendCodeRequiredMixin, UpdateView
):
""" """
View to update the state of an existing TradeAcceptance. View to update the state of an existing TradeAcceptance.
The allowed state transitions are provided via the form. The allowed state transitions are provided via the form.
""" """
model = TradeAcceptance model = TradeAcceptance
form_class = TradeAcceptanceTransitionForm form_class = TradeAcceptanceTransitionForm
template_name = "trades/trade_acceptance_update.html" template_name = "trades/trade_acceptance_update.html"
@ -451,8 +517,10 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, Upd
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
friend_codes = request.user.friend_codes.values_list("id", flat=True) friend_codes = request.user.friend_codes.values_list("id", flat=True)
if (self.object.accepted_by_id not in friend_codes and if (
self.object.trade_offer.initiated_by_id not in friend_codes): self.object.accepted_by_id not in friend_codes
and self.object.trade_offer.initiated_by_id not in friend_codes
):
raise PermissionDenied("You are not authorized to update this acceptance.") raise PermissionDenied("You are not authorized to update this acceptance.")
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@ -481,6 +549,7 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, Upd
def get_success_url(self): def get_success_url(self):
return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk}) return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk})
class TradeOfferPNGView(View): class TradeOfferPNGView(View):
""" """
Generate a PNG screenshot of the rendered trade offer detail page using Playwright. Generate a PNG screenshot of the rendered trade offer detail page using Playwright.
@ -488,15 +557,17 @@ class TradeOfferPNGView(View):
runs at a time for a given TradeOffer. The generated PNG is then cached in the runs at a time for a given TradeOffer. The generated PNG is then cached in the
TradeOffer model's `image` field (assumed to be an ImageField). TradeOffer model's `image` field (assumed to be an ImageField).
""" """
def get_lock_key(self, trade_offer_id): def get_lock_key(self, trade_offer_id):
# Use the trade_offer_id as the lock key; adjust if needed. # Use the trade_offer_id as the lock key; adjust if needed.
return trade_offer_id return trade_offer_id
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
from django.shortcuts import get_object_or_404
from django.http import HttpResponse
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
trade_offer = get_object_or_404(TradeOffer, pk=kwargs['pk']) from django.http import HttpResponse
from django.shortcuts import get_object_or_404
trade_offer = get_object_or_404(TradeOffer, pk=kwargs["pk"])
# If the image is already generated and stored, serve it directly. # If the image is already generated and stored, serve it directly.
if trade_offer.image and not request.GET.get("debug"): if trade_offer.image and not request.GET.get("debug"):
@ -505,6 +576,7 @@ class TradeOfferPNGView(View):
# Acquire PostgreSQL advisory lock to prevent concurrent generation. # Acquire PostgreSQL advisory lock to prevent concurrent generation.
from django.db import connection from django.db import connection
lock_key = self.get_lock_key(trade_offer.pk) lock_key = self.get_lock_key(trade_offer.pk)
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key]) cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key])
@ -514,18 +586,22 @@ class TradeOfferPNGView(View):
if trade_offer.image and not request.GET.get("debug"): if trade_offer.image and not request.GET.get("debug"):
trade_offer.image.open() trade_offer.image.open()
return HttpResponse(trade_offer.image.read(), content_type="image/png") return HttpResponse(trade_offer.image.read(), content_type="image/png")
tag_context = render_trade_offer_png( tag_context = render_trade_offer_png(
{'request': request}, trade_offer, show_friend_code=trade_offer.initiated_by.user.show_friend_code_on_link_previews {"request": request},
trade_offer,
show_friend_code=trade_offer.initiated_by.user.show_friend_code_on_link_previews,
) )
image_width = tag_context.get('image_width') image_width = tag_context.get("image_width")
image_height = tag_context.get('image_height') image_height = tag_context.get("image_height")
if not image_width or not image_height: if not image_width or not image_height:
raise ValueError("Could not determine image dimensions from tag_context") raise ValueError(
"Could not determine image dimensions from tag_context"
)
html = render_to_string( html = render_to_string(
"templatetags/trade_offer_png.html", "templatetags/trade_offer_png.html",
context=tag_context, context=tag_context,
request=request request=request,
) )
# if query string has "debug", render the HTML instead of the PNG # if query string has "debug", render the HTML instead of the PNG
@ -545,13 +621,20 @@ class TradeOfferPNGView(View):
"--disable-audio-output", "--disable-audio-output",
"--disable-webgl", "--disable-webgl",
"--no-first-run", "--no-first-run",
] ],
)
context_browser = browser.new_context(
viewport={"width": image_width, "height": image_height}
) )
context_browser = browser.new_context(viewport={"width": image_width, "height": image_height})
page = context_browser.new_page() page = context_browser.new_page()
page.on("console", lambda msg: print(f"Console {msg.type}: {msg.text}")) page.on("console", lambda msg: print(f"Console {msg.type}: {msg.text}"))
page.on("pageerror", lambda err: print(f"Page error: {err}")) page.on("pageerror", lambda err: print(f"Page error: {err}"))
page.on("requestfailed", lambda req: print(f"Failed to load: {req.url} - {req.failure.error_text}")) page.on(
"requestfailed",
lambda req: print(
f"Failed to load: {req.url} - {req.failure.error_text}"
),
)
page.set_content(html, wait_until="networkidle") page.set_content(html, wait_until="networkidle")
element = page.wait_for_selector(".trade-offer-card-screenshot") element = page.wait_for_selector(".trade-offer-card-screenshot")
screenshot_bytes = element.screenshot(type="png", omit_background=True) screenshot_bytes = element.screenshot(type="png", omit_background=True)
@ -567,11 +650,13 @@ class TradeOfferPNGView(View):
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key]) cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
class TradeOfferCreateConfirmView(LoginRequiredMixin, View): class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
""" """
Processes a two-step create for TradeOffer; on confirmation, Processes a two-step create for TradeOffer; on confirmation,
commits the offer and shows form errors if any occur. commits the offer and shows form errors if any occur.
""" """
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if "confirm" in request.POST: if "confirm" in request.POST:
return self._commit_offer(request) return self._commit_offer(request)
@ -605,17 +690,21 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
} }
# Supply additional context required by trade_offer_create.html. # Supply additional context required by trade_offer_create.html.
from pkmntrade_club.cards.models import Card from pkmntrade_club.cards.models import Card
context = { context = {
"form": form, "form": form,
"friend_codes": request.user.friend_codes.all(), "friend_codes": request.user.friend_codes.all(),
"selected_friend_code": ( "selected_friend_code": (
request.user.default_friend_code or request.user.friend_codes.first() request.user.default_friend_code
or request.user.friend_codes.first()
), ),
"cards": Card.objects.all().order_by("name", "rarity_level"), "cards": Card.objects.all().order_by("name", "rarity_level"),
} }
return render(request, "trades/trade_offer_create.html", context) return render(request, "trades/trade_offer_create.html", context)
messages.success(request, "Trade offer created successfully!") messages.success(request, "Trade offer created successfully!")
return HttpResponseRedirect(reverse_lazy("trade_offer_detail", kwargs={"pk": trade_offer.pk})) return HttpResponseRedirect(
reverse_lazy("trade_offer_detail", kwargs={"pk": trade_offer.pk})
)
else: else:
# When the form is not valid, update its initial data as well: # When the form is not valid, update its initial data as well:
form.initial = { form.initial = {
@ -624,16 +713,18 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
"initiated_by": request.POST.get("initiated_by"), "initiated_by": request.POST.get("initiated_by"),
} }
from pkmntrade_club.cards.models import Card from pkmntrade_club.cards.models import Card
context = { context = {
"form": form, "form": form,
"friend_codes": request.user.friend_codes.all(), "friend_codes": request.user.friend_codes.all(),
"selected_friend_code": ( "selected_friend_code": (
request.user.default_friend_code or request.user.friend_codes.first() request.user.default_friend_code
or request.user.friend_codes.first()
), ),
"cards": Card.objects.all().order_by("name", "rarity_level"), "cards": Card.objects.all().order_by("name", "rarity_level"),
} }
return render(request, "trades/trade_offer_create.html", context) return render(request, "trades/trade_offer_create.html", context)
def _redirect_to_edit(self, request): def _redirect_to_edit(self, request):
query_params = request.POST.copy() query_params = request.POST.copy()
query_params.pop("csrfmiddlewaretoken", None) query_params.pop("csrfmiddlewaretoken", None)
@ -641,10 +732,11 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
query_params.pop("confirm", None) query_params.pop("confirm", None)
query_params.pop("preview", None) query_params.pop("preview", None)
from django.urls import reverse from django.urls import reverse
base_url = reverse("trade_offer_create") base_url = reverse("trade_offer_create")
url_with_params = f"{base_url}?{query_params.urlencode()}" url_with_params = f"{base_url}?{query_params.urlencode()}"
return HttpResponseRedirect(url_with_params) return HttpResponseRedirect(url_with_params)
def _preview_offer(self, request): def _preview_offer(self, request):
form = TradeOfferCreateForm(request.POST) form = TradeOfferCreateForm(request.POST)
form.fields["initiated_by"].queryset = request.user.friend_codes.all() form.fields["initiated_by"].queryset = request.user.friend_codes.all()
@ -656,15 +748,16 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
"initiated_by": request.POST.get("initiated_by"), "initiated_by": request.POST.get("initiated_by"),
} }
from pkmntrade_club.cards.models import Card from pkmntrade_club.cards.models import Card
context = { context = {
"form": form, "form": form,
"friend_codes": request.user.friend_codes.all(), "friend_codes": request.user.friend_codes.all(),
"selected_friend_code": request.user.default_friend_code or request.user.friend_codes.first(), "selected_friend_code": request.user.default_friend_code
or request.user.friend_codes.first(),
"cards": Card.objects.all().order_by("name", "rarity_level"), "cards": Card.objects.all().order_by("name", "rarity_level"),
} }
return render(request, "trades/trade_offer_create.html", context) return render(request, "trades/trade_offer_create.html", context)
# Parse the card selections for "have" and "want" cards. # Parse the card selections for "have" and "want" cards.
have_selections = self._parse_card_selections("have_cards") have_selections = self._parse_card_selections("have_cards")
want_selections = self._parse_card_selections("want_cards") want_selections = self._parse_card_selections("want_cards")