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
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""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__))))
try:

View file

@ -2,4 +2,4 @@
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 setuptools_scm import get_version
"""
Version module for pkmntrade.club
@ -7,55 +8,57 @@ This module provides version information from git tags via setuptools-scm.
"""
try:
__version__ = version("pkmntrade-club")
__version__ = version("pkmntrade-club")
except PackageNotFoundError:
# Package is not installed, try to get version from setuptools_scm
try:
__version__ = get_version(root='../../..', relative_to=__file__)
except (ImportError, LookupError):
__version__ = "0.0.0+unknown"
# Package is not installed, try to get version from setuptools_scm
try:
__version__ = get_version(root="../../..", relative_to=__file__)
except (ImportError, LookupError):
__version__ = "0.0.0+unknown"
def get_version():
"""Return the current version."""
return __version__
"""Return the current version."""
return __version__
def get_version_info():
"""Return detailed version information."""
import re
"""Return detailed version information."""
import re
# Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease")
match = re.match(
r'^(\d+)\.(\d+)\.(\d+)'
r'(?:\.dev(\d+))?'
r'(?:\+g([a-f0-9]+))?'
r'(?:-(.+))?$',
__version__
)
# Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease")
match = re.match(
r"^(\d+)\.(\d+)\.(\d+)"
r"(?:\.dev(\d+))?"
r"(?:\+g([a-f0-9]+))?"
r"(?:-(.+))?$",
__version__,
)
if match:
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,
}
if match:
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
"version": __version__,
"major": 0,
"minor": 0,
"patch": 0,
"dev": None,
"git_sha": None,
"prerelease": None,
"is_release": False,
"is_prerelease": False,
"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.socialaccount.adapter import DefaultSocialAccountAdapter

View file

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

View file

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

View file

@ -2,15 +2,13 @@ from django import forms
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import CustomUser, FriendCode
from allauth.account.forms import SignupForm
from crispy_tailwind.tailwind import CSSContainer
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Submit
class CustomUserChangeForm(UserChangeForm):
class Meta:
model = CustomUser
fields = ['email']
fields = ["email"]
class FriendCodeForm(forms.ModelForm):
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]}"
return friend_code_formatted
class CustomUserCreationForm(SignupForm):
class CustomUserCreationForm(SignupForm):
class Meta(UserCreationForm.Meta):
model = CustomUser
fields = ['email', 'username', 'friend_code']
fields = ["email", "username", "friend_code"]
email = forms.EmailField(
required=True,
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(
max_length=24,
required=True,
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(
@ -51,14 +53,18 @@ class CustomUserCreationForm(SignupForm):
required=True,
label="Friend Code",
help_text="Enter your friend code in the format XXXX-XXXX-XXXX-XXXX.",
widget=forms.TextInput(attrs={'placeholder': 'XXXX-XXXX-XXXX-XXXX', 'class':'dark:bg-base-100'})
widget=forms.TextInput(
attrs={"placeholder": "XXXX-XXXX-XXXX-XXXX", "class": "dark:bg-base-100"}
),
)
in_game_name = forms.CharField(
max_length=16,
required=True,
label="In-Game Name",
help_text="Enter your in-game name.",
widget=forms.TextInput(attrs={'placeholder': 'In-Game Name', 'class':'dark:bg-base-100'})
widget=forms.TextInput(
attrs={"placeholder": "In-Game Name", "class": "dark:bg-base-100"}
),
)
def __init__(self, *args, **kwargs):
@ -78,13 +84,14 @@ class CustomUserCreationForm(SignupForm):
friend_code_instance = FriendCode.objects.create(
friend_code=self.cleaned_data["friend_code"],
in_game_name=self.cleaned_data["in_game_name"],
user=user
user=user,
)
user.default_friend_code = friend_code_instance
user.save()
return user
class UserSettingsForm(forms.ModelForm):
class Meta:
model = CustomUser
fields = ['show_friend_code_on_link_previews', 'enable_email_notifications']
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.cache import cache
class Command(BaseCommand):
def handle(self, *args, **kwargs):
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
import re
def validate_friend_code(value):
"""Validate that friend code follows the format XXXX-XXXX-XXXX-XXXX where X is a digit."""
if not re.match(r'^\d{4}-\d{4}-\d{4}-\d{4}$', value):
if not re.match(r"^\d{4}-\d{4}-\d{4}-\d{4}$", value):
raise ValidationError(
'Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit.'
"Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit."
)
class CustomUser(AbstractUser):
default_friend_code = models.ForeignKey("FriendCode", on_delete=models.SET_NULL, null=True, blank=True)
default_friend_code = models.ForeignKey(
"FriendCode", on_delete=models.SET_NULL, null=True, blank=True
)
show_friend_code_on_link_previews = models.BooleanField(
default=False,
verbose_name="Show Friend Code on Link Previews",
help_text="This will primarily affect share link previews on X, Discord, etc."
help_text="This will primarily affect share link previews on X, Discord, etc.",
)
enable_email_notifications = models.BooleanField(
default=True,
verbose_name="Enable Email Notifications",
help_text="Receive trade notifications via email."
help_text="Receive trade notifications via email.",
)
reputation_score = models.IntegerField(default=0)
@ -47,10 +51,13 @@ class CustomUser(AbstractUser):
self.default_friend_code = other_codes.first()
self.save(update_fields=["default_friend_code"])
class FriendCode(models.Model):
friend_code = models.CharField(max_length=19, validators=[validate_friend_code])
in_game_name = models.CharField(max_length=14, null=False, blank=False)
user = models.ForeignKey(CustomUser, on_delete=models.PROTECT, related_name='friend_codes')
user = models.ForeignKey(
CustomUser, on_delete=models.PROTECT, related_name="friend_codes"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

View file

@ -6,15 +6,17 @@ from django.utils.safestring import mark_safe
register = template.Library()
@register.filter
def gravatar_hash(email):
"""
Returns the hash of the email.
"""
email_encoded = email.strip().lower().encode('utf-8')
email_encoded = email.strip().lower().encode("utf-8")
email_hash = hashlib.sha256(email_encoded).hexdigest()
return email_hash
@register.filter
def gravatar_url(email, size=20):
"""
@ -23,20 +25,22 @@ def gravatar_url(email, size=20):
"""
default = "retro"
email_hash = gravatar_hash(email)
params = urlencode({'d': default, 's': str(size)})
params = urlencode({"d": default, "s": str(size)})
params = params.replace("&", "&")
return f"https://www.gravatar.com/avatar/{email_hash}?{params}"
@register.filter
def gravatar_profile_url(email=None):
"""
Returns the Gravatar Profile URL for a given email.
"""
if email is None:
return f"https://www.gravatar.com/profile"
return "https://www.gravatar.com/profile"
email_hash = gravatar_hash(email)
return f"https://secure.gravatar.com/{email_hash}"
@register.filter
def gravatar(email, size=20):
"""
@ -48,6 +52,7 @@ def gravatar(email, size=20):
html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar"></img>'
return mark_safe(html)
@register.filter
def gravatar_no_hover(email, size=20):
"""
@ -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>'
return mark_safe(html)
@register.filter
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 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.trades.models import TradeOffer
from tests.utils.rarity import RARITY_MAPPING
# Create your tests here.
# -----------------------------
# Model Tests
# -----------------------------
class CustomUserModelTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="testuser",
email="test@example.com",
password="password123"
username="testuser", email="test@example.com", password="password123"
)
def test_set_default_friend_code(self):
"""User can manually set a friend code as their default."""
fc1 = FriendCode.objects.create(
friend_code="1234-5678-9012-3456",
user=self.user,
in_game_name="GameOne"
friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne"
)
fc2 = FriendCode.objects.create(
friend_code="2345-6789-0123-4567",
user=self.user,
in_game_name="GameTwo"
friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo"
)
# Manually set fc2 as default.
self.user.set_default_friend_code(fc2)
@ -48,14 +48,10 @@ class CustomUserModelTests(TestCase):
Attempting to set a friend code that does not belong to the user should raise an exception.
"""
other_user = get_user_model().objects.create_user(
username="otheruser",
email="other@example.com",
password="password456"
username="otheruser", email="other@example.com", password="password456"
)
fc_other = FriendCode.objects.create(
friend_code="3456-7890-1234-5678",
user=other_user,
in_game_name="OtherGame"
friend_code="3456-7890-1234-5678", user=other_user, in_game_name="OtherGame"
)
with self.assertRaises(ValidationError):
self.user.set_default_friend_code(fc_other)
@ -66,14 +62,10 @@ class CustomUserModelTests(TestCase):
the default should be reassigned to another friend code.
"""
fc1 = FriendCode.objects.create(
friend_code="1234-5678-9012-3456",
user=self.user,
in_game_name="GameOne"
friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne"
)
fc2 = FriendCode.objects.create(
friend_code="2345-6789-0123-4567",
user=self.user,
in_game_name="GameTwo"
friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo"
)
# Set fc2 as default.
self.user.set_default_friend_code(fc2)
@ -89,9 +81,7 @@ class CustomUserModelTests(TestCase):
should be prohibited.
"""
fc = FriendCode.objects.create(
friend_code="1234-5678-9012-3456",
user=self.user,
in_game_name="OnlyGame"
friend_code="1234-5678-9012-3456", user=self.user, in_game_name="OnlyGame"
)
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc)
@ -104,21 +94,19 @@ class CustomUserModelTests(TestCase):
the current default should remain unchanged.
"""
fc1 = FriendCode.objects.create(
friend_code="1234-5678-9012-3456",
user=self.user,
in_game_name="GameOne"
friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne"
)
fc2 = FriendCode.objects.create(
friend_code="2345-6789-0123-4567",
user=self.user,
in_game_name="GameTwo"
friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo"
)
# By default, fc1 is the default friend code.
self.assertEqual(self.user.default_friend_code, fc1)
try:
self.user.remove_default_friend_code(fc2)
except Exception as e:
self.fail("remove_default_friend_code raised an exception when removing a non-default code.")
except Exception:
self.fail(
"remove_default_friend_code raised an exception when removing a non-default code."
)
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc1)
@ -129,9 +117,7 @@ class CustomUserModelTests(TestCase):
class FriendCodeModelTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="testuser2",
email="test2@example.com",
password="password123"
username="testuser2", email="test2@example.com", password="password123"
)
def test_default_set_on_creation(self):
@ -142,7 +128,7 @@ class FriendCodeModelTests(TestCase):
fc = FriendCode.objects.create(
friend_code="1234-5678-9012-3456",
user=self.user,
in_game_name="GameDefault"
in_game_name="GameDefault",
)
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc)
@ -155,14 +141,14 @@ class FriendCodeModelTests(TestCase):
fc1 = FriendCode.objects.create(
friend_code="1111-1111-1111-1111",
user=self.user,
in_game_name="PrimaryGame"
in_game_name="PrimaryGame",
)
# fc1 becomes the default automatically.
self.assertEqual(self.user.default_friend_code, fc1)
fc2 = FriendCode.objects.create(
friend_code="2222-2222-2222-2222",
user=self.user,
in_game_name="SecondaryGame"
in_game_name="SecondaryGame",
)
self.user.refresh_from_db()
self.assertEqual(self.user.default_friend_code, fc1)
@ -174,39 +160,34 @@ class FriendCodeModelTests(TestCase):
class FriendCodeFormTests(TestCase):
def test_valid_friend_code(self):
"""Ensure valid friend code is cleaned and formatted properly."""
form_data = {
"friend_code": "1234567890123456",
"in_game_name": "GameTest"
}
form_data = {"friend_code": "1234567890123456", "in_game_name": "GameTest"}
form = FriendCodeForm(data=form_data)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["friend_code"], "1234-5678-9012-3456")
def test_invalid_friend_code_length(self):
"""Friend codes with incorrect length should cause validation errors."""
form_data = {
"friend_code": "12345",
"in_game_name": "GameTest"
}
form_data = {"friend_code": "12345", "in_game_name": "GameTest"}
form = FriendCodeForm(data=form_data)
self.assertFalse(form.is_valid())
self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"])
self.assertIn(
"Friend code must be exactly 16 digits long.", form.errors["friend_code"]
)
def test_invalid_friend_code_characters(self):
"""Friend codes containing non-digit characters should cause validation errors."""
form_data = {
"friend_code": "12345678901234ab",
"in_game_name": "GameTest"
}
form_data = {"friend_code": "12345678901234ab", "in_game_name": "GameTest"}
form = FriendCodeForm(data=form_data)
self.assertFalse(form.is_valid())
self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"])
self.assertIn(
"Friend code must be exactly 16 digits long.", form.errors["friend_code"]
)
def test_friend_code_with_whitespace(self):
"""Ensure that leading/trailing whitespace is stripped."""
form_data = {
"friend_code": " 1234567890123456 ",
"in_game_name": "WhitespaceGame"
"in_game_name": "WhitespaceGame",
}
form = FriendCodeForm(data=form_data)
self.assertTrue(form.is_valid())
@ -216,7 +197,7 @@ class FriendCodeFormTests(TestCase):
"""Proper dashes in the input should be accepted."""
form_data = {
"friend_code": "1234-5678-9012-3456",
"in_game_name": "ExtraDashGame"
"in_game_name": "ExtraDashGame",
}
form = FriendCodeForm(data=form_data)
self.assertTrue(form.is_valid())
@ -292,7 +273,9 @@ class CustomUserCreationFormTests(TestCase):
}
form = CustomUserCreationForm(data=form_data)
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):
"""
@ -318,7 +301,7 @@ class UserSettingsFormTests(TestCase):
self.user = get_user_model().objects.create_user(
username="settingsuser",
email="settings@example.com",
password="password123"
password="password123",
)
def test_toggle_show_friend_code_on_link_previews(self):
@ -337,9 +320,7 @@ class UserSettingsFormTests(TestCase):
class FriendCodeViewsTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="viewuser",
email="viewuser@example.com",
password="password123"
username="viewuser", email="viewuser@example.com", password="password123"
)
# Log in this user.
self.client.login(username="viewuser", password="password123")
@ -347,12 +328,12 @@ class FriendCodeViewsTests(TestCase):
self.friend_code1 = FriendCode.objects.create(
friend_code="7777-7777-7777-7777",
user=self.user,
in_game_name="ViewGameOne"
in_game_name="ViewGameOne",
)
self.friend_code2 = FriendCode.objects.create(
friend_code="8888-8888-8888-8888",
user=self.user,
in_game_name="ViewGameTwo"
in_game_name="ViewGameTwo",
)
# By default, friend_code1 is the default.
@ -390,8 +371,7 @@ class FriendCodeViewsTests(TestCase):
self.assertRedirects(response, reverse("list_friend_codes"))
self.assertTrue(
FriendCode.objects.filter(
user=self.user,
friend_code="9999-9999-9999-9999"
user=self.user, friend_code="9999-9999-9999-9999"
).exists()
)
# 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"}
response = self.client.post(url, data)
# Extract the form from the response's context. If response.context is a list, use its first element.
context = response.context[0] if isinstance(response.context, list) else response.context
context = (
response.context[0]
if isinstance(response.context, list)
else response.context
)
form = context.get("form")
self.assertIsNotNone(form, "Form not found in response context")
self.assertFormError(form, "friend_code", "Friend code must be exactly 16 digits long.")
self.assertFormError(
form, "friend_code", "Friend code must be exactly 16 digits long."
)
def test_edit_friend_code_view(self):
"""Test editing the in-game name of an existing friend code."""
@ -425,14 +411,10 @@ class FriendCodeViewsTests(TestCase):
def test_edit_friend_code_view_wrong_user(self):
"""A user should not be able to edit a friend code that does not belong to them."""
other_user = get_user_model().objects.create_user(
username="otheruser",
email="other@example.com",
password="password1234"
username="otheruser", email="other@example.com", password="password1234"
)
friend_code_other = FriendCode.objects.create(
friend_code="0000-0000-0000-0000",
user=other_user,
in_game_name="OtherGame"
friend_code="0000-0000-0000-0000", user=other_user, in_game_name="OtherGame"
)
url = reverse("edit_friend_code", kwargs={"pk": friend_code_other.pk})
response = self.client.get(url)
@ -443,7 +425,11 @@ class FriendCodeViewsTests(TestCase):
url = reverse("edit_friend_code", kwargs={"pk": self.friend_code2.pk})
new_data = {"in_game_name": ""} # in_game_name is required.
response = self.client.post(url, new_data)
context = response.context[0] if isinstance(response.context, list) else response.context
context = (
response.context[0]
if isinstance(response.context, list)
else response.context
)
form = context.get("form")
self.assertIsNotNone(form, "Form not found in response context")
self.assertFormError(form, "in_game_name", "This field is required.")
@ -454,14 +440,10 @@ class FriendCodeViewsTests(TestCase):
This test uses a new user with a single friend code.
"""
user_only = get_user_model().objects.create_user(
username="onlyuser",
email="onlyuser@example.com",
password="password123"
username="onlyuser", email="onlyuser@example.com", password="password123"
)
friend_code_only = FriendCode.objects.create(
friend_code="4444-4444-4444-4444",
user=user_only,
in_game_name="SoloGame"
friend_code="4444-4444-4444-4444", user=user_only, in_game_name="SoloGame"
)
self.client.logout()
self.client.login(username="onlyuser", password="password123")
@ -492,7 +474,7 @@ class FriendCodeViewsTests(TestCase):
initiated_by=self.friend_code2,
is_closed=False,
rarity_icon=RARITY_MAPPING[5],
rarity_level=5
rarity_level=5,
)
url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk})
response = self.client.post(url, {})
@ -517,14 +499,10 @@ class FriendCodeViewsTests(TestCase):
def test_change_default_friend_code_view_not_owned(self):
"""A friend code that does not belong to the current user should result in a 404."""
other_user = get_user_model().objects.create_user(
username="otheruser2",
email="other2@example.com",
password="password789"
username="otheruser2", email="other2@example.com", password="password789"
)
friend_code_other = FriendCode.objects.create(
friend_code="1111-1111-1111-1111",
user=other_user,
in_game_name="NotMine"
friend_code="1111-1111-1111-1111", user=other_user, in_game_name="NotMine"
)
url = reverse("change_default_friend_code", kwargs={"pk": friend_code_other.pk})
response = self.client.post(url, {})
@ -561,12 +539,12 @@ class FriendCodeViewsTests(TestCase):
other_user = get_user_model().objects.create_user(
username="otherdeluser",
email="otherdel@example.com",
password="password321"
password="password321",
)
friend_code_other = FriendCode.objects.create(
friend_code="2222-2222-2222-2222",
user=other_user,
in_game_name="OtherDelete"
in_game_name="OtherDelete",
)
url = reverse("delete_friend_code", kwargs={"pk": friend_code_other.pk})
response = self.client.get(url)

View file

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

View file

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

View file

@ -1,10 +1,12 @@
from math import ceil
class ReusablePaginationMixin:
"""
A mixin that encapsulates reusable pagination logic.
Use in Django ListViews to generate custom pagination context.
"""
per_page = 10 # Default; can be overridden in your view.
def paginate_data(self, data_list, page_number):

View file

@ -106,7 +106,7 @@ class AttackCost(models.Model):
unique_together = ("attack", "energy")
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):

View file

@ -2,6 +2,7 @@ from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import Card
def color_is_dark(bg_color):
"""
Determine if a given hexadecimal color is dark.
@ -20,7 +21,7 @@ def color_is_dark(bg_color):
bool: True if the color is dark (brightness <= 186), False otherwise.
"""
# Remove the leading '#' if it exists.
color = bg_color[1:7] if bg_color[0] == '#' else bg_color
color = bg_color[1:7] if bg_color[0] == "#" else bg_color
# Convert the hex color components to integers.
r = int(color[0:2], 16)
@ -32,6 +33,7 @@ def color_is_dark(bg_color):
return brightness <= 200
@receiver(m2m_changed, sender=Card.decks.through)
def update_card_style(sender, instance, action, **kwargs):
if action == "post_add":
@ -41,9 +43,13 @@ def update_card_style(sender, instance, action, **kwargs):
instance.style = "background-color: " + decks.first().hex_color + ";"
elif num_decks >= 2:
hex_colors = [deck.hex_color for deck in decks]
instance.style = f"background: linear-gradient(to right, {', '.join(hex_colors)});"
instance.style = (
f"background: linear-gradient(to right, {', '.join(hex_colors)});"
)
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):
instance.style += "color: var(--color-gray-700); text-shadow: 0 0 0 var(--color-gray-700);"
else:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ from django import template
register = template.Library()
@register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True)
def render_pagination(context, page_obj, hide_if_one_page=True):
"""

View file

@ -2,4 +2,4 @@
# Django starts so that shared_task will use this 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
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()

View file

@ -3,15 +3,17 @@ import os
from celery import Celery
# 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
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# 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.
app.autodiscover_tasks()
@ -19,4 +21,4 @@ app.autodiscover_tasks()
@app.task(bind=True, ignore_result=True)
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
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_CACHE=(bool, True),
DJANGO_DATABASE_URL=(str, 'postgresql://postgres@localhost:5432/postgres?sslmode=disable'),
DJANGO_EMAIL_HOST=(str, ''),
DJANGO_DATABASE_URL=(
str,
"postgresql://postgres@localhost:5432/postgres?sslmode=disable",
),
DJANGO_EMAIL_HOST=(str, ""),
DJANGO_EMAIL_PORT=(int, 587),
DJANGO_EMAIL_USER=(str, ''),
DJANGO_EMAIL_PASSWORD=(str, ''),
DJANGO_EMAIL_USER=(str, ""),
DJANGO_EMAIL_PASSWORD=(str, ""),
DJANGO_EMAIL_USE_TLS=(bool, True),
DJANGO_DEFAULT_FROM_EMAIL=(str, ''),
DJANGO_EMAIL_SUBJECT_PREFIX=(str, ''),
SECRET_KEY=(str, '0000000000000000000000000000000000000000000000000000000000000000'),
ALLOWED_HOSTS=(str, 'localhost,127.0.0.1'),
PUBLIC_HOST=(str, 'localhost'),
ACCOUNT_EMAIL_VERIFICATION=(str, 'none'),
SCHEME=(str, 'http'),
REDIS_URL=(str, 'redis://localhost:6379'),
DJANGO_DEFAULT_FROM_EMAIL=(str, ""),
DJANGO_EMAIL_SUBJECT_PREFIX=(str, ""),
SECRET_KEY=(
str,
"0000000000000000000000000000000000000000000000000000000000000000",
),
ALLOWED_HOSTS=(str, "localhost,127.0.0.1"),
PUBLIC_HOST=(str, "localhost"),
ACCOUNT_EMAIL_VERIFICATION=(str, "none"),
SCHEME=(str, "http"),
REDIS_URL=(str, "redis://localhost:6379"),
CACHE_TIMEOUT=(int, 604800),
TIME_ZONE=(str, 'America/Los_Angeles'),
TIME_ZONE=(str, "America/Los_Angeles"),
)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '%(asctime)s %(name)-12s %(levelname)-8s %(message)s',
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "%(asctime)s %(name)-12s %(levelname)-8s %(message)s",
},
},
'handlers': {
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'stream': sys.stdout,
'formatter': 'verbose',
'filters': [],
"handlers": {
"console": {
"level": "INFO",
"class": "logging.StreamHandler",
"stream": sys.stdout,
"formatter": "verbose",
"filters": [],
},
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'INFO',
"loggers": {
"django": {
"handlers": ["console"],
"level": "INFO",
},
'django.server': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
"django.server": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
'granian.access': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
"granian.access": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
'_granian': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
"_granian": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
'': {
'handlers': ['console'],
'level': 'INFO',
"": {
"handlers": ["console"],
"level": "INFO",
},
},
}
@ -78,14 +87,14 @@ LOGGING = {
BASE_DIR = Path(__file__).resolve().parent.parent
# 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')
PUBLIC_HOST = env('PUBLIC_HOST')
REDIS_URL = env('REDIS_URL')
CACHE_TIMEOUT = env('CACHE_TIMEOUT')
DISABLE_SIGNUPS = env('DISABLE_SIGNUPS')
DISABLE_CACHE = env('DISABLE_CACHE')
SCHEME = env("SCHEME")
PUBLIC_HOST = env("PUBLIC_HOST")
REDIS_URL = env("REDIS_URL")
CACHE_TIMEOUT = env("CACHE_TIMEOUT")
DISABLE_SIGNUPS = env("DISABLE_SIGNUPS")
DISABLE_CACHE = env("DISABLE_CACHE")
VERSION = __version__
VERSION_INFO = get_version_info()
@ -95,34 +104,38 @@ VERSION_INFO = get_version_info()
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env('SECRET_KEY')
SECRET_KEY = env("SECRET_KEY")
# https://docs.djangoproject.com/en/dev/ref/settings/#debug
# 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
ALLOWED_HOSTS = env('ALLOWED_HOSTS').split(',')
ALLOWED_HOSTS = env("ALLOWED_HOSTS").split(",")
try:
current_web_worker_hostname = socket.gethostname()
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:
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}"]
SHORTHAND_DATETIME_FORMAT = 'Y-m-d P'
SHORTHAND_DATE_FORMAT = 'Y-m-d'
SHORTHAND_DATETIME_FORMAT = "Y-m-d P"
SHORTHAND_DATE_FORMAT = "Y-m-d"
FIRST_PARTY_APPS = [
'pkmntrade_club.accounts',
'pkmntrade_club.cards',
'pkmntrade_club.common',
'pkmntrade_club.home',
'pkmntrade_club.theme',
'pkmntrade_club.trades',
"pkmntrade_club.accounts",
"pkmntrade_club.cards",
"pkmntrade_club.common",
"pkmntrade_club.home",
"pkmntrade_club.theme",
"pkmntrade_club.trades",
]
# Application definition
@ -140,20 +153,20 @@ INSTALLED_APPS = [
"django_celery_beat",
"allauth",
"allauth.account",
'allauth.socialaccount.providers.google',
"allauth.socialaccount.providers.google",
"crispy_forms",
"crispy_tailwind",
"tailwind",
"django_linear_migrations",
'health_check',
'health_check.db',
'health_check.cache',
'health_check.storage',
'health_check.contrib.migrations',
'health_check.contrib.celery',
'health_check.contrib.celery_ping',
'health_check.contrib.psutil',
'health_check.contrib.redis',
"health_check",
"health_check.db",
"health_check.cache",
"health_check.storage",
"health_check.contrib.migrations",
"health_check.contrib.celery",
"health_check.contrib.celery_ping",
"health_check.contrib.psutil",
"health_check.contrib.redis",
"meta",
"parler",
] + FIRST_PARTY_APPS
@ -165,12 +178,12 @@ if DEBUG:
"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_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
MIDDLEWARE = [
@ -193,22 +206,22 @@ if DEBUG:
]
HEALTH_CHECK = {
'DISK_USAGE_MAX': 90, # percent
'MEMORY_MIN': 100, # in MB
"DISK_USAGE_MAX": 90, # percent
"MEMORY_MIN": 100, # in MB
}
DAISY_SETTINGS = {
'SITE_TITLE': 'PKMN Trade Club Admin',
'DONT_SUPPORT_ME': True,
"SITE_TITLE": "PKMN Trade Club Admin",
"DONT_SUPPORT_ME": True,
}
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
ROOT_URLCONF = 'pkmntrade_club.django_project.urls'
ROOT_URLCONF = "pkmntrade_club.django_project.urls"
# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
WSGI_APPLICATION = 'pkmntrade_club.django_project.wsgi.app'
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
TEMPLATES = [
@ -231,7 +244,7 @@ TEMPLATES = [
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
DATABASES = {
'default': env.db(var="DJANGO_DATABASE_URL"),
"default": env.db(var="DJANGO_DATABASE_URL"),
}
# Password validation
@ -256,12 +269,10 @@ AUTH_PASSWORD_VALIDATORS = [
# https://docs.djangoproject.com/en/dev/ref/settings/#language-code
LANGUAGE_CODE = "en-us"
LANGUAGES = (
('en', _("English")),
)
LANGUAGES = (("en", _("English")),)
# 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
USE_I18N = True
@ -270,7 +281,7 @@ USE_I18N = True
USE_TZ = True
# 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)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
@ -305,24 +316,24 @@ STORAGES = {
# Default primary key field type
# 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
# 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"
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = env('DJANGO_EMAIL_HOST')
EMAIL_PORT = env('DJANGO_EMAIL_PORT')
EMAIL_HOST_USER = env('DJANGO_EMAIL_USER')
EMAIL_HOST_PASSWORD = env('DJANGO_EMAIL_PASSWORD')
EMAIL_USE_TLS = env('DJANGO_EMAIL_USE_TLS')
EMAIL_SUBJECT_PREFIX = env('DJANGO_EMAIL_SUBJECT_PREFIX')
EMAIL_HOST = env("DJANGO_EMAIL_HOST")
EMAIL_PORT = env("DJANGO_EMAIL_PORT")
EMAIL_HOST_USER = env("DJANGO_EMAIL_USER")
EMAIL_HOST_PASSWORD = env("DJANGO_EMAIL_PASSWORD")
EMAIL_USE_TLS = env("DJANGO_EMAIL_USE_TLS")
EMAIL_SUBJECT_PREFIX = env("DJANGO_EMAIL_SUBJECT_PREFIX")
# 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
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
@ -335,7 +346,7 @@ INTERNAL_IPS = [
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
for ip in ips:
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
AUTH_USER_MODEL = "accounts.CustomUser"
@ -345,12 +356,10 @@ AUTH_USER_MODEL = "accounts.CustomUser"
SITE_ID = 1
PARLER_LANGUAGES = {
SITE_ID: (
{'code': 'en'},
),
'default': {
'fallbacks': ['en'],
'hide_untranslated': False,
SITE_ID: ({"code": "en"},),
"default": {
"fallbacks": ["en"],
"hide_untranslated": False,
},
}
@ -368,13 +377,13 @@ AUTHENTICATION_BACKENDS = (
)
# https://django-allauth.readthedocs.io/en/latest/configuration.html
if DISABLE_SIGNUPS:
ACCOUNT_ADAPTER = 'pkmntrade_club.accounts.adapter.NoSignupAccountAdapter'
SOCIALACCOUNT_ADAPTER = 'pkmntrade_club.accounts.adapter.NoSignupSocialAccountAdapter' # always disable social account signups
ACCOUNT_ADAPTER = "pkmntrade_club.accounts.adapter.NoSignupAccountAdapter"
SOCIALACCOUNT_ADAPTER = "pkmntrade_club.accounts.adapter.NoSignupSocialAccountAdapter" # always disable social account signups
ACCOUNT_SESSION_REMEMBER = True
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = True
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_EMAIL_VERIFICATION = env('ACCOUNT_EMAIL_VERIFICATION')
ACCOUNT_EMAIL_VERIFICATION = env("ACCOUNT_EMAIL_VERIFICATION")
ACCOUNT_EMAIL_NOTIFICATIONS = True
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
ACCOUNT_DEFAULT_HTTP_PROTOCOL = SCHEME
@ -395,7 +404,7 @@ SOCIALACCOUNT_ONLY = False
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
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
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG}

View file

@ -4,11 +4,11 @@ from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns = [
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("", include("pkmntrade_club.home.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("__reload__/", include("django_browser_reload.urls")),
] + debug_toolbar_urls()

View file

@ -2,6 +2,8 @@ import os
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()

View file

@ -2,18 +2,20 @@ from django.test import TestCase, Client, RequestFactory
from django.urls import reverse
from django.contrib.auth import get_user_model
from pkmntrade_club.cards.models import Card, Deck
from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard
from pkmntrade_club.trades.models import (
TradeOffer,
TradeOfferHaveCard,
TradeOfferWantCard,
)
from pkmntrade_club.accounts.models import FriendCode
from pkmntrade_club.home.views import HomePageView
import json
from collections import OrderedDict
from unittest.mock import patch, MagicMock
from django.core.exceptions import ObjectDoesNotExist
import importlib
from tests.utils.rarity import RARITY_MAPPING
User = get_user_model()
class HomePageViewTests(TestCase):
"""Test suite for the HomePageView."""
@ -22,99 +24,83 @@ class HomePageViewTests(TestCase):
"""Set up data for all test methods."""
# Create a user
cls.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpass123'
username="testuser", email="testuser@example.com", password="testpass123"
)
# Create a friend code for the user
cls.friend_code = FriendCode.objects.create(
user=cls.user,
friend_code='SW-1234-5678-9012',
in_game_name='TestTrainer'
user=cls.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer"
)
# Create decks
cls.deck1 = Deck.objects.create(
name='Test Deck 1',
hex_color='#FF0000',
cardset='TEST01'
name="Test Deck 1", hex_color="#FF0000", cardset="TEST01"
)
# Create cards with different rarities
cls.common_card = Card.objects.create(
name='Common Test Card',
cardset='TEST01',
name="Common Test Card",
cardset="TEST01",
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
style="normal",
rarity_icon="",
rarity_level=1,
)
cls.common_card.decks.add(cls.deck1)
cls.rare_card = Card.objects.create(
name='Rare Test Card',
cardset='TEST01',
name="Rare Test Card",
cardset="TEST01",
cardnum=2,
style='normal',
rarity_icon='★★★',
rarity_level=3
style="normal",
rarity_icon="★★★",
rarity_level=3,
)
cls.rare_card.decks.add(cls.deck1)
cls.ultra_rare_card = Card.objects.create(
name='Ultra Rare Test Card',
cardset='TEST01',
name="Ultra Rare Test Card",
cardset="TEST01",
cardnum=3,
style='normal',
rarity_icon='★★★★',
rarity_level=4
style="normal",
rarity_icon="★★★★",
rarity_level=4,
)
cls.ultra_rare_card.decks.add(cls.deck1)
# Create trade offers with consistent rarities
cls.common_trade = TradeOffer.objects.create(
initiated_by=cls.friend_code,
rarity_icon=RARITY_MAPPING[1],
rarity_level=1
initiated_by=cls.friend_code, rarity_icon=RARITY_MAPPING[1], rarity_level=1
)
cls.rare_trade = TradeOffer.objects.create(
initiated_by=cls.friend_code,
rarity_icon=RARITY_MAPPING[3],
rarity_level=3
initiated_by=cls.friend_code, rarity_icon=RARITY_MAPPING[3], rarity_level=3
)
# Add have and want cards with the SAME rarity for each trade
TradeOfferHaveCard.objects.create(
trade_offer=cls.common_trade,
card=cls.common_card,
quantity=2
trade_offer=cls.common_trade, card=cls.common_card, quantity=2
)
TradeOfferHaveCard.objects.create(
trade_offer=cls.rare_trade,
card=cls.rare_card,
quantity=1
trade_offer=cls.rare_trade, card=cls.rare_card, quantity=1
)
# Add want cards with the SAME rarity as the have cards for each trade
TradeOfferWantCard.objects.create(
trade_offer=cls.common_trade,
card=cls.common_card,
quantity=1
trade_offer=cls.common_trade, card=cls.common_card, quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=cls.rare_trade,
card=cls.rare_card, # Changed from ultra_rare_card to match the rarity
quantity=1
quantity=1,
)
def setUp(self):
"""Set up before each test method."""
self.client = Client()
self.url = reverse('home')
self.url = reverse("home")
self.factory = RequestFactory()
def test_home_page_status_code(self):
@ -125,27 +111,27 @@ class HomePageViewTests(TestCase):
def test_home_page_template(self):
"""Test that the home page uses the correct template."""
response = self.client.get(self.url)
self.assertTemplateUsed(response, 'home/home.html')
self.assertTemplateUsed(response, "home/home.html")
def test_home_page_context_cards(self):
"""Test that the home page contains all cards in the context."""
response = self.client.get(self.url)
self.assertIn('cards', response.context)
self.assertEqual(response.context['cards'].count(), 3)
self.assertIn("cards", response.context)
self.assertEqual(response.context["cards"].count(), 3)
def test_home_page_context_recent_offers(self):
"""Test that the home page contains recent offers in the context."""
response = self.client.get(self.url)
self.assertIn('recent_offers', response.context)
self.assertEqual(len(response.context['recent_offers']), 2)
self.assertIn("recent_offers", response.context)
self.assertEqual(len(response.context["recent_offers"]), 2)
# Recent offers should be ordered by most recent first
self.assertEqual(response.context['recent_offers'][0], self.rare_trade)
self.assertEqual(response.context["recent_offers"][0], self.rare_trade)
def test_home_page_context_most_offered_cards(self):
"""Test that the home page contains most offered cards in the context."""
response = self.client.get(self.url)
self.assertIn('most_offered_cards', response.context)
most_offered = list(response.context['most_offered_cards'])
self.assertIn("most_offered_cards", response.context)
most_offered = list(response.context["most_offered_cards"])
self.assertEqual(len(most_offered), 2)
# Common card should be most offered (quantity of 2)
self.assertEqual(most_offered[0], self.common_card)
@ -153,35 +139,35 @@ class HomePageViewTests(TestCase):
def test_home_page_context_most_wanted_cards(self):
"""Test that the home page contains most wanted cards in the context."""
response = self.client.get(self.url)
self.assertIn('most_wanted_cards', response.context)
most_wanted = list(response.context['most_wanted_cards'])
self.assertIn("most_wanted_cards", response.context)
most_wanted = list(response.context["most_wanted_cards"])
self.assertEqual(len(most_wanted), 2)
def test_home_page_context_least_offered_cards(self):
"""Test that the home page contains least offered cards in the context."""
response = self.client.get(self.url)
self.assertIn('least_offered_cards', response.context)
self.assertIn("least_offered_cards", response.context)
def test_home_page_context_featured_offers(self):
"""Test that the home page contains featured offers in the context."""
response = self.client.get(self.url)
self.assertIn('featured_offers', response.context)
featured = response.context['featured_offers']
self.assertIn("featured_offers", response.context)
featured = response.context["featured_offers"]
# Should be an OrderedDict
self.assertIsInstance(featured, OrderedDict)
# Should contain "All" category
self.assertIn("All", featured)
# Should contain both rarity icons
self.assertIn('★★★', featured)
self.assertIn('', featured)
self.assertIn("★★★", featured)
self.assertIn("", featured)
# Higher rarity should come before lower rarity
keys = list(featured.keys())
# First key should be "All"
self.assertEqual(keys[0], "All")
# Higher rarity (★★★) should come before lower rarity (★)
self.assertIn('★★★', keys)
self.assertIn('', keys)
self.assertTrue(keys.index('★★★') < keys.index(''))
self.assertIn("★★★", keys)
self.assertIn("", keys)
self.assertTrue(keys.index("★★★") < keys.index(""))
def test_closed_offers_not_shown(self):
"""Test that closed offers are not shown on the home page."""
@ -190,7 +176,7 @@ class HomePageViewTests(TestCase):
self.common_trade.save()
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
self.assertEqual(len(recent_offers), 1)
self.assertEqual(recent_offers[0], self.rare_trade)
@ -203,11 +189,11 @@ class HomePageViewTests(TestCase):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
# Should have empty lists for offers
self.assertEqual(len(response.context['recent_offers']), 0)
self.assertEqual(len(response.context["recent_offers"]), 0)
def test_home_page_with_authenticated_user(self):
"""Test that the home page works for authenticated users."""
self.client.login(username='testuser', password='testpass123')
self.client.login(username="testuser", password="testpass123")
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
@ -215,26 +201,20 @@ class HomePageViewTests(TestCase):
"""Test that offers are sorted by rarity level in descending order."""
# Create a new ultra rare trade with consistent rarity
ultra_trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon='★★★★',
rarity_level=4
initiated_by=self.friend_code, rarity_icon="★★★★", rarity_level=4
)
# Add have and want cards with the same rarity
TradeOfferHaveCard.objects.create(
trade_offer=ultra_trade,
card=self.ultra_rare_card,
quantity=1
trade_offer=ultra_trade, card=self.ultra_rare_card, quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=ultra_trade,
card=self.ultra_rare_card,
quantity=1
trade_offer=ultra_trade, card=self.ultra_rare_card, quantity=1
)
response = self.client.get(self.url)
featured = response.context['featured_offers']
featured = response.context["featured_offers"]
keys = list(featured.keys())
# Order should be: "All", "★★★★" (level 4), "★★★" (level 3), "★" (level 1)
@ -251,12 +231,12 @@ class HomePageViewMockTests(TestCase):
self.factory = RequestFactory()
self.view = HomePageView()
@patch('trades.models.TradeOffer.objects')
@patch('cards.models.Card.objects')
@patch("trades.models.TradeOffer.objects")
@patch("cards.models.Card.objects")
def test_get_context_data_with_mocks(self, mock_card_objects, mock_offer_objects):
"""Test get_context_data using mocks."""
# Set up request
request = self.factory.get(reverse('home'))
request = self.factory.get(reverse("home"))
self.view.request = request
# Mock the queryset responses
@ -277,18 +257,18 @@ class HomePageViewMockTests(TestCase):
context = self.view.get_context_data()
# Verify the expected context keys exist
self.assertIn('cards', context)
self.assertIn('recent_offers', context)
self.assertIn('most_offered_cards', context)
self.assertIn('most_wanted_cards', context)
self.assertIn('least_offered_cards', context)
self.assertIn('featured_offers', context)
self.assertIn("cards", context)
self.assertIn("recent_offers", context)
self.assertIn("most_offered_cards", context)
self.assertIn("most_wanted_cards", context)
self.assertIn("least_offered_cards", context)
self.assertIn("featured_offers", context)
@patch('trades.models.TradeOffer.objects')
@patch("trades.models.TradeOffer.objects")
def test_empty_featured_offers(self, mock_offer_objects):
"""Test handling of empty featured offers."""
# Set up request
request = self.factory.get(reverse('home'))
request = self.factory.get(reverse("home"))
self.view.request = request
# Configure mock to return empty queryset
@ -301,101 +281,90 @@ class HomePageViewMockTests(TestCase):
context = self.view.get_context_data()
# Verify the featured_offers is an OrderedDict but with just the "All" key
self.assertIsInstance(context['featured_offers'], OrderedDict)
self.assertIn("All", context['featured_offers'])
self.assertEqual(len(context['featured_offers']), 1)
self.assertIsInstance(context["featured_offers"], OrderedDict)
self.assertIn("All", context["featured_offers"])
self.assertEqual(len(context["featured_offers"]), 1)
@patch('trades.models.TradeOffer.objects.filter')
@patch("trades.models.TradeOffer.objects.filter")
def test_exception_handling(self, mock_filter):
"""Test that exceptions are handled gracefully."""
# Set up request
request = self.factory.get(reverse('home'))
request = self.factory.get(reverse("home"))
self.view.request = request
# Configure mock to raise an exception
mock_filter.side_effect = Exception("Database error")
# Call the method - should not raise an exception
with self.assertLogs(level='ERROR') as cm:
with self.assertLogs(level="ERROR") as cm:
context = self.view.get_context_data()
# Check if error was logged
self.assertIn("Unhandled error in HomePageView.get_context_data", cm.output[0])
self.assertIn(
"Unhandled error in HomePageView.get_context_data", cm.output[0]
)
# Verify fallback values were set
self.assertEqual(len(context['cards']), 0)
self.assertEqual(len(context['recent_offers']), 0)
self.assertEqual(len(context['most_offered_cards']), 0)
self.assertEqual(len(context['most_wanted_cards']), 0)
self.assertEqual(len(context['least_offered_cards']), 0)
self.assertIsInstance(context['featured_offers'], OrderedDict)
self.assertEqual(len(context['featured_offers']), 1)
self.assertIn("All", context['featured_offers'])
self.assertEqual(len(context["cards"]), 0)
self.assertEqual(len(context["recent_offers"]), 0)
self.assertEqual(len(context["most_offered_cards"]), 0)
self.assertEqual(len(context["most_wanted_cards"]), 0)
self.assertEqual(len(context["least_offered_cards"]), 0)
self.assertIsInstance(context["featured_offers"], OrderedDict)
self.assertEqual(len(context["featured_offers"]), 1)
self.assertIn("All", context["featured_offers"])
class HomePageEdgeCaseTests(TestCase):
"""Test edge cases for the home page."""
def setUp(self):
self.client = Client()
self.url = reverse('home')
self.url = reverse("home")
# Create a user
self.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpass123'
username="testuser", email="testuser@example.com", password="testpass123"
)
# Create a friend code for the user
self.friend_code = FriendCode.objects.create(
user=self.user,
friend_code='SW-1234-5678-9012',
in_game_name='TestTrainer'
user=self.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer"
)
def test_home_page_with_no_cards(self):
"""Test home page with no cards in the database."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context['cards']), 0)
self.assertEqual(len(response.context["cards"]), 0)
def test_home_page_with_many_offers(self):
"""Test home page with many offers to verify pagination or limiting works."""
# Create a card
card = Card.objects.create(
name='Test Card',
cardset='TEST01',
name="Test Card",
cardset="TEST01",
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
style="normal",
rarity_icon="",
rarity_level=1,
)
# Create 20 trade offers
for i in range(20):
trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon='',
rarity_level=1
initiated_by=self.friend_code, rarity_icon="", rarity_level=1
)
# Add have and want cards
TradeOfferHaveCard.objects.create(
trade_offer=trade,
card=card,
quantity=1
)
TradeOfferHaveCard.objects.create(trade_offer=trade, card=card, quantity=1)
TradeOfferWantCard.objects.create(
trade_offer=trade,
card=card,
quantity=1
)
TradeOfferWantCard.objects.create(trade_offer=trade, card=card, quantity=1)
response = self.client.get(self.url)
# Check that recent_offers is limited to 6 as per the view
self.assertEqual(len(response.context['recent_offers']), 6)
self.assertEqual(len(response.context["recent_offers"]), 6)
def test_home_page_with_invalid_parameters(self):
"""Test home page with invalid GET parameters."""
@ -407,49 +376,46 @@ class HomePageEdgeCaseTests(TestCase):
"""Test performance with a larger dataset (basic check)."""
# Create a card
card = Card.objects.create(
name='Performance Test Card',
cardset='PERF01',
name="Performance Test Card",
cardset="PERF01",
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
style="normal",
rarity_icon="",
rarity_level=1,
)
# Create 50 trade offers with different rarities
for i in range(50):
rarity_level = (i % 5) + 1 # 1-5
rarity_icon = '' * rarity_level
rarity_icon = "" * rarity_level
trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon=rarity_icon,
rarity_level=rarity_level
rarity_level=rarity_level,
)
# Add have and want cards with the same rarity
rarity_card = Card.objects.create(
name=f'Performance Test Card {i}',
cardset='PERF01',
cardnum=i+10,
style='normal',
name=f"Performance Test Card {i}",
cardset="PERF01",
cardnum=i + 10,
style="normal",
rarity_icon=rarity_icon,
rarity_level=rarity_level
rarity_level=rarity_level,
)
TradeOfferHaveCard.objects.create(
trade_offer=trade,
card=rarity_card,
quantity=1
trade_offer=trade, card=rarity_card, quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=trade,
card=rarity_card,
quantity=1
trade_offer=trade, card=rarity_card, quantity=1
)
# Basic performance test - just checking it completes without timeout
import time
start = time.time()
response = self.client.get(self.url)
end = time.time()
@ -468,46 +434,36 @@ class TemplateRenderingTests(TestCase):
def setUpTestData(cls):
# Create a user
cls.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpass123'
username="testuser", email="testuser@example.com", password="testpass123"
)
# Create a friend code for the user
cls.friend_code = FriendCode.objects.create(
user=cls.user,
friend_code='SW-1234-5678-9012',
in_game_name='TestTrainer'
user=cls.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer"
)
# Create a card
cls.card = Card.objects.create(
name='Test Card',
cardset='TEST01',
name="Test Card",
cardset="TEST01",
cardnum=1,
style='normal',
rarity_icon='',
rarity_level=1
style="normal",
rarity_icon="",
rarity_level=1,
)
# Create a trade offer
cls.trade = TradeOffer.objects.create(
initiated_by=cls.friend_code,
rarity_icon='',
rarity_level=1
initiated_by=cls.friend_code, rarity_icon="", rarity_level=1
)
# Add have and want cards
TradeOfferHaveCard.objects.create(
trade_offer=cls.trade,
card=cls.card,
quantity=1
trade_offer=cls.trade, card=cls.card, quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=cls.trade,
card=cls.card,
quantity=1
trade_offer=cls.trade, card=cls.card, quantity=1
)
def setUp(self):
@ -516,21 +472,21 @@ class TemplateRenderingTests(TestCase):
def test_template_used(self):
"""Test that the correct template is used."""
response = self.client.get(reverse('home'))
self.assertTemplateUsed(response, 'home/home.html')
response = self.client.get(reverse("home"))
self.assertTemplateUsed(response, "home/home.html")
def test_context_variables_exist(self):
"""Test that all expected context variables exist."""
response = self.client.get(reverse('home'))
response = self.client.get(reverse("home"))
# Check all required context variables
expected_keys = [
'cards',
'recent_offers',
'most_offered_cards',
'most_wanted_cards',
'least_offered_cards',
'featured_offers',
"cards",
"recent_offers",
"most_offered_cards",
"most_wanted_cards",
"least_offered_cards",
"featured_offers",
]
for key in expected_keys:
@ -541,22 +497,16 @@ class TemplateRenderingTests(TestCase):
# Create additional trade offers if pagination is implemented
for i in range(10):
trade = TradeOffer.objects.create(
initiated_by=self.friend_code,
rarity_icon='',
rarity_level=1
initiated_by=self.friend_code, rarity_icon="", rarity_level=1
)
# Add have and want cards
TradeOfferHaveCard.objects.create(
trade_offer=trade,
card=self.card,
quantity=1
trade_offer=trade, card=self.card, quantity=1
)
TradeOfferWantCard.objects.create(
trade_offer=trade,
card=self.card,
quantity=1
trade_offer=trade, card=self.card, quantity=1
)
# Test with page parameter
@ -565,27 +515,30 @@ class TemplateRenderingTests(TestCase):
# Test with invalid page parameter
response = self.client.get(f"{reverse('home')}?page=999")
self.assertEqual(response.status_code, 200) # Should still render with default page
self.assertEqual(
response.status_code, 200
) # Should still render with default page
# Test with non-numeric page parameter
response = self.client.get(f"{reverse('home')}?page=abc")
self.assertEqual(response.status_code, 200) # Should handle gracefully
@patch('home.views.HomePageView.get_context_data')
@patch("home.views.HomePageView.get_context_data")
def test_view_renders_with_missing_context(self, mock_get_context):
"""Test that view renders even with incomplete context data."""
# Return incomplete context
mock_get_context.return_value = {'cards': []}
mock_get_context.return_value = {"cards": []}
# 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)
def test_compatibility_with_multiple_django_versions(self):
"""Ensure compatibility with different Django versions."""
import django
# Simply log the Django version - the test itself verifies the page renders
# with the current version
django_version = django.get_version()
response = self.client.get(reverse('home'))
response = self.client.get(reverse("home"))
self.assertEqual(response.status_code, 200)

View file

@ -1,31 +1,30 @@
from collections import defaultdict, OrderedDict
from collections import OrderedDict
from django.views.generic import TemplateView
from django.urls import reverse_lazy
from django.db.models import Count, Q, Prefetch, Sum, F, IntegerField, Value, BooleanField, Case, When
from django.db.models import (
Sum,
)
from django.db.models.functions import Coalesce
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance, TradeOfferHaveCard, TradeOfferWantCard
from pkmntrade_club.trades.models import (
TradeOffer,
)
from pkmntrade_club.cards.models import Card
from django.utils.decorators import method_decorator
from django.template.response import TemplateResponse
from django.http import HttpResponseRedirect
import logging
from django.views import View
from django.http import HttpResponse
import contextlib
logger = logging.getLogger(__name__)
class HomePageView(TemplateView):
template_name = "home/home.html"
#@silk_profile(name='Home Page')
# @silk_profile(name='Home Page')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
try:
# Get all cards ordered by name, exclude cards with rarity level > 5
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level")
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by(
"name", "rarity_level"
)
# Reuse base trade offer queryset for market stats
base_offer_qs = TradeOffer.objects.filter(is_closed=False)
@ -34,7 +33,9 @@ class HomePageView(TemplateView):
try:
recent_offers_qs = base_offer_qs.order_by("-created_at")[:6]
context["recent_offers"] = recent_offers_qs
context["cache_key_recent_offers"] = f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}"
context["cache_key_recent_offers"] = (
f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}"
)
except Exception as e:
logger.error(f"Error fetching recent offers: {str(e)}")
context["recent_offers"] = []
@ -43,12 +44,15 @@ class HomePageView(TemplateView):
# Most Offered Cards
try:
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"))
.order_by("-offer_count")[:6]
)
context["most_offered_cards"] = most_offered_cards_qs
context["cache_key_most_offered_cards"] = f"most_offered_cards_{most_offered_cards_qs.values_list('pk', 'updated_at')}"
context["cache_key_most_offered_cards"] = (
f"most_offered_cards_{most_offered_cards_qs.values_list('pk', 'updated_at')}"
)
except Exception as e:
logger.error(f"Error fetching most offered cards: {str(e)}")
context["most_offered_cards"] = []
@ -56,12 +60,15 @@ class HomePageView(TemplateView):
# Most Wanted Cards
try:
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"))
.order_by("-offer_count")[:6]
)
context["most_wanted_cards"] = most_wanted_cards_qs
context["cache_key_most_wanted_cards"] = f"most_wanted_cards_{most_wanted_cards_qs.values_list('pk', 'updated_at')}"
context["cache_key_most_wanted_cards"] = (
f"most_wanted_cards_{most_wanted_cards_qs.values_list('pk', 'updated_at')}"
)
except Exception as e:
logger.error(f"Error fetching most wanted cards: {str(e)}")
context["most_wanted_cards"] = []
@ -69,13 +76,16 @@ class HomePageView(TemplateView):
# Least Offered Cards
try:
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)
)
.order_by("offer_count")[:6]
)
context["least_offered_cards"] = least_offered_cards_qs
context["cache_key_least_offered_cards"] = f"least_offered_cards_{least_offered_cards_qs.values_list('pk', 'updated_at')}"
context["cache_key_least_offered_cards"] = (
f"least_offered_cards_{least_offered_cards_qs.values_list('pk', 'updated_at')}"
)
except Exception as e:
logger.error(f"Error fetching least offered cards: {str(e)}")
context["least_offered_cards"] = []
@ -114,16 +124,20 @@ class HomePageView(TemplateView):
# 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
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.
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
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)
# Join all identifiers into a single string, sorted for consistency regardless of order
combined_identifiers = "|".join(sorted(all_offer_identifiers))
context["cache_key_featured_offers"] = f"featured_offers_{combined_identifiers}"
context["cache_key_featured_offers"] = (
f"featured_offers_{combined_identifiers}"
)
except Exception as e:
logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}")
# Provide fallback empty data

View file

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

View file

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

View file

@ -1,20 +1,21 @@
from django import forms
from django.core.exceptions import ValidationError
from .models import TradeOffer, TradeAcceptance
from pkmntrade_club.accounts.models import FriendCode
from pkmntrade_club.cards.models import Card
from django.forms import ModelForm
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard
class NoValidationMultipleChoiceField(forms.MultipleChoiceField):
def validate(self, value):
# Override the validation to skip checking against defined choices
pass
class TradeOfferAcceptForm(forms.Form):
friend_code = forms.ModelChoiceField(
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):
@ -23,6 +24,7 @@ class TradeOfferAcceptForm(forms.Form):
super().__init__(*args, **kwargs)
self.fields["friend_code"].queryset = friend_codes
class TradeAcceptanceCreateForm(forms.ModelForm):
"""
Form for creating a TradeAcceptance.
@ -32,11 +34,19 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
- default_friend_code (optional): the user's default FriendCode.
It filters available requested and offered cards based on what's still available.
"""
class Meta:
model = TradeAcceptance
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:
raise ValueError("trade_offer must be provided to filter choices.")
super().__init__(*args, **kwargs)
@ -52,16 +62,23 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
self.initial["accepted_by"] = friend_codes.first().pk
self.fields["accepted_by"].widget = forms.HiddenInput()
# 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
available_have_items = trade_offer.have_cards_available
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
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):
"""
@ -71,9 +88,11 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
self.instance.trade_offer = self.trade_offer
return super().clean()
class ButtonRadioSelect(forms.RadioSelect):
template_name = "widgets/button_radio_select.html"
class TradeAcceptanceTransitionForm(forms.Form):
state = forms.ChoiceField(widget=forms.HiddenInput())
@ -90,10 +109,15 @@ class TradeAcceptanceTransitionForm(forms.Form):
self.fields["state"].choices = instance.get_allowed_state_transitions(user)
class TradeOfferCreateForm(ModelForm):
# Override the default fields to capture quantity info in the format 'card_id:quantity'
have_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True)
want_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True)
have_cards = NoValidationMultipleChoiceField(
widget=forms.SelectMultiple, required=True
)
want_cards = NoValidationMultipleChoiceField(
widget=forms.SelectMultiple, required=True
)
class Meta:
model = TradeOffer
@ -111,10 +135,10 @@ class TradeOfferCreateForm(ModelForm):
data = self.data.getlist("have_cards")
parsed = {}
for item in data:
if ':' not in item:
if ":" not in item:
# Ignore any input without a colon.
continue
parts = item.split(':')
parts = item.split(":")
card_id = parts[0]
try:
# 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.
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
def clean_want_cards(self):
data = self.data.getlist("want_cards")
parsed = {}
for item in data:
if ':' not in item:
if ":" not in item:
continue
parts = item.split(':')
parts = item.split(":")
card_id = parts[0]
try:
quantity = int(parts[1])
@ -157,7 +183,9 @@ class TradeOfferCreateForm(ModelForm):
)
# Ensure no more than 20 unique want cards are selected.
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
def save(self, commit=True):
@ -171,11 +199,15 @@ class TradeOfferCreateForm(ModelForm):
# Create through entries for have_cards
for card_id, quantity in self.cleaned_data["have_cards"].items():
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
for card_id, quantity in self.cleaned_data["want_cards"].items():
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

View file

@ -1,6 +1,7 @@
from pkmntrade_club.cards.models import Card
from django.core.exceptions import PermissionDenied
class TradeOfferContextMixin:
def get_context_data(self, **kwargs):
# Start with any context passed in.
@ -14,24 +15,35 @@ class TradeOfferContextMixin:
if "initiated_by" in self.request.GET:
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:
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:
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
return context
class FriendCodeRequiredMixin:
"""
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.
"""
def dispatch(self, request, *args, **kwargs):
# Since LoginRequiredMixin guarantees that request.user is authenticated,
# we assume request.user has the attribute `friend_codes`. If no friend code exists,
# 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.")
return super().dispatch(request, *args, **kwargs)

View file

@ -1,13 +1,10 @@
from django.db import models
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
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
def generate_tradeoffer_hash():
"""
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"
def generate_tradeacceptance_hash():
"""
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"
class TradeOfferManager(models.Manager):
class TradeOfferManager(models.Manager):
def get_queryset(self):
qs = super().get_queryset()
# Prefetch for have_cards (through model: TradeOfferHaveCard)
# Ensures 'card' is select_related and 'Meta.ordering' is respected/applied.
prefetch_have_cards = Prefetch(
'trade_offer_have_cards',
queryset=TradeOfferHaveCard.objects.select_related('card').order_by('card__name')
"trade_offer_have_cards",
queryset=TradeOfferHaveCard.objects.select_related("card").order_by(
"card__name"
),
)
# Prefetch for want_cards (through model: TradeOfferWantCard)
# Ensures 'card' is select_related and 'Meta.ordering' is respected/applied.
prefetch_want_cards = Prefetch(
'trade_offer_want_cards',
queryset=TradeOfferWantCard.objects.select_related('card').order_by('card__name')
"trade_offer_want_cards",
queryset=TradeOfferWantCard.objects.select_related("card").order_by(
"card__name"
),
)
# Prefetch for acceptances
# Ensures related 'accepted_by__user', 'requested_card', 'offered_card' are fetched.
prefetch_acceptances = Prefetch(
'acceptances',
"acceptances",
queryset=TradeAcceptance.objects.select_related(
'accepted_by__user',
'requested_card',
'offered_card'
).order_by('-created_at') # Sensible default ordering for acceptances
"accepted_by__user", "requested_card", "offered_card"
).order_by("-created_at"), # Sensible default ordering for acceptances
)
qs = qs.select_related(
@ -60,11 +60,12 @@ class TradeOfferManager(models.Manager):
prefetch_acceptances,
# 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:
Prefetch('have_cards'),
Prefetch('want_cards'),
Prefetch("have_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):
objects = TradeOfferManager()
@ -75,20 +76,16 @@ class TradeOffer(models.Model):
initiated_by = models.ForeignKey(
"accounts.FriendCode",
on_delete=models.PROTECT,
related_name='initiated_trade_offers'
related_name="initiated_trade_offers",
)
rarity_icon = models.CharField(max_length=8, 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(
"cards.Card",
related_name='trade_offers_want',
through="TradeOfferWantCard"
"cards.Card", related_name="trade_offers_want", through="TradeOfferWantCard"
)
have_cards = models.ManyToManyField(
"cards.Card",
related_name='trade_offers_have',
through="TradeOfferHaveCard"
"cards.Card", related_name="trade_offers_have", through="TradeOfferHaveCard"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@ -118,11 +115,16 @@ class TradeOffer(models.Model):
# Enforce same rarity across all cards.
rarity_levels = {card.rarity_level for card in cards}
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]
if first_card.rarity_level > 5:
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_icon = first_card.rarity_icon
# Use super().save() here to avoid recursion.
@ -131,23 +133,33 @@ class TradeOffer(models.Model):
@property
def have_cards_available(self):
# 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
def want_cards_available(self):
# 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):
"""
Through model for TradeOffer.have_cards.
Represents the card the initiator is offering along with the quantity available.
"""
trade_offer = models.ForeignKey(
TradeOffer,
on_delete=models.CASCADE,
related_name='trade_offer_have_cards',
db_index=True
related_name="trade_offer_have_cards",
db_index=True,
)
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True)
quantity = models.PositiveIntegerField(default=1)
@ -171,17 +183,17 @@ class TradeOfferHaveCard(models.Model):
class Meta:
unique_together = ("trade_offer", "card")
ordering = ['card__name']
ordering = ["card__name"]
class TradeOfferWantCard(models.Model):
"""
Through model for TradeOffer.want_cards.
Represents the card the initiator is requesting along with the quantity requested.
"""
trade_offer = models.ForeignKey(
TradeOffer,
on_delete=models.CASCADE,
related_name='trade_offer_want_cards'
TradeOffer, on_delete=models.CASCADE, related_name="trade_offer_want_cards"
)
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(default=1)
@ -205,18 +217,19 @@ class TradeOfferWantCard(models.Model):
class Meta:
unique_together = ("trade_offer", "card")
ordering = ['card__name']
ordering = ["card__name"]
class TradeAcceptance(models.Model):
class AcceptanceState(models.TextChoices):
ACCEPTED = 'ACCEPTED', 'Accepted'
SENT = 'SENT', 'Sent'
RECEIVED = 'RECEIVED', 'Received'
THANKED_BY_INITIATOR = 'THANKED_BY_INITIATOR', 'Thanked by Initiator'
THANKED_BY_ACCEPTOR = 'THANKED_BY_ACCEPTOR', 'Thanked by Acceptor'
THANKED_BY_BOTH = 'THANKED_BY_BOTH', 'Thanked by Both'
REJECTED_BY_INITIATOR = 'REJECTED_BY_INITIATOR', 'Rejected by Initiator'
REJECTED_BY_ACCEPTOR = 'REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor'
ACCEPTED = "ACCEPTED", "Accepted"
SENT = "SENT", "Sent"
RECEIVED = "RECEIVED", "Received"
THANKED_BY_INITIATOR = "THANKED_BY_INITIATOR", "Thanked by Initiator"
THANKED_BY_ACCEPTOR = "THANKED_BY_ACCEPTOR", "Thanked by Acceptor"
THANKED_BY_BOTH = "THANKED_BY_BOTH", "Thanked by Both"
REJECTED_BY_INITIATOR = "REJECTED_BY_INITIATOR", "Rejected by Initiator"
REJECTED_BY_ACCEPTOR = "REJECTED_BY_ACCEPTOR", "Rejected by Acceptor"
# DRY improvement: define active states once as a class-level constant.
POSITIVE_STATES = [
@ -229,30 +242,21 @@ class TradeAcceptance(models.Model):
]
trade_offer = models.ForeignKey(
TradeOffer,
on_delete=models.CASCADE,
related_name='acceptances',
db_index=True
TradeOffer, on_delete=models.CASCADE, related_name="acceptances", db_index=True
)
accepted_by = models.ForeignKey(
"accounts.FriendCode",
on_delete=models.PROTECT,
related_name='trade_acceptances'
related_name="trade_acceptances",
)
requested_card = models.ForeignKey(
"cards.Card",
on_delete=models.PROTECT,
related_name='accepted_requested'
"cards.Card", on_delete=models.PROTECT, related_name="accepted_requested"
)
offered_card = models.ForeignKey(
"cards.Card",
on_delete=models.PROTECT,
related_name='accepted_offered'
"cards.Card", on_delete=models.PROTECT, related_name="accepted_offered"
)
state = models.CharField(
max_length=25,
choices=AcceptanceState.choices,
default=AcceptanceState.ACCEPTED
max_length=25, choices=AcceptanceState.choices, default=AcceptanceState.ACCEPTED
)
hash = models.CharField(max_length=9, editable=False, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
@ -307,12 +311,15 @@ class TradeAcceptance(models.Model):
return self.get_action_label_for_state(self.AcceptanceState.SENT)
elif self.state == self.AcceptanceState.SENT:
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)
else:
return None
@classmethod
def get_action_label_for_state_2(cls, state_value):
"""
@ -331,12 +338,20 @@ class TradeAcceptance(models.Model):
@property
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
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
def is_completed(self):
@ -368,19 +383,30 @@ class TradeAcceptance(models.Model):
def clean(self):
from django.core.exceptions import ValidationError
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:
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:
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:
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).
if self.pk is None:
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.
if have_card.qty_accepted >= have_card.quantity:
raise ValidationError("The requested card has no available quantity.")
@ -403,26 +429,42 @@ class TradeAcceptance(models.Model):
]:
return 0
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):
if new_state not in [choice[0] for choice in self.AcceptanceState.choices]:
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 \
(new_state == self.AcceptanceState.THANKED_BY_INITIATOR and self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR):
if (
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
if self.state in [
self.AcceptanceState.THANKED_BY_BOTH,
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)]
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.state = new_state
@ -434,10 +476,12 @@ class TradeAcceptance(models.Model):
super().save(*args, **kwargs)
def __str__(self):
return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, "
f"accepted_by={self.accepted_by}, "
f"requested_card={self.requested_card}, "
f"offered_card={self.offered_card}, state={self.state})")
return (
f"TradeAcceptance(offer_hash={self.trade_offer.hash}, "
f"accepted_by={self.accepted_by}, "
f"requested_card={self.requested_card}, "
f"offered_card={self.offered_card}, state={self.state})"
)
def get_allowed_state_transitions(self, user):
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.REJECTED_BY_INITIATOR,
},
self.AcceptanceState.THANKED_BY_INITIATOR: { },
self.AcceptanceState.THANKED_BY_INITIATOR: {},
self.AcceptanceState.THANKED_BY_ACCEPTOR: {
self.AcceptanceState.REJECTED_BY_INITIATOR,
self.AcceptanceState.THANKED_BY_BOTH,
@ -469,10 +513,10 @@ class TradeAcceptance(models.Model):
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
},
self.AcceptanceState.RECEIVED: {
self.AcceptanceState.THANKED_BY_ACCEPTOR, #allow early thanks (uses THANKED_BY_ACCEPTOR state)
self.AcceptanceState.REJECTED_BY_ACCEPTOR
self.AcceptanceState.THANKED_BY_ACCEPTOR, # allow early thanks (uses THANKED_BY_ACCEPTOR state)
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
},
self.AcceptanceState.THANKED_BY_ACCEPTOR: { },
self.AcceptanceState.THANKED_BY_ACCEPTOR: {},
self.AcceptanceState.THANKED_BY_INITIATOR: {
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.dispatch import receiver
from django.db.models import F
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance, TradeOffer
from django.db import transaction
from pkmntrade_club.trades.models import (
TradeOfferHaveCard,
TradeOfferWantCard,
TradeAcceptance,
)
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.conf import settings
from django.template.loader import render_to_string
from django.contrib.sites.models import Site
from django.core.cache import cache
import logging
POSITIVE_STATES = [
TradeAcceptance.AcceptanceState.ACCEPTED,
@ -24,20 +20,20 @@ POSITIVE_STATES = [
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
]
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.
"""
if side == 'have':
TradeOfferHaveCard.objects.filter(
trade_offer=trade_offer,
card=card
).update(qty_accepted=F('qty_accepted') + delta)
elif side == 'want':
TradeOfferWantCard.objects.filter(
trade_offer=trade_offer,
card=card
).update(qty_accepted=F('qty_accepted') + delta)
if side == "have":
TradeOfferHaveCard.objects.filter(trade_offer=trade_offer, card=card).update(
qty_accepted=F("qty_accepted") + delta
)
elif side == "want":
TradeOfferWantCard.objects.filter(trade_offer=trade_offer, card=card).update(
qty_accepted=F("qty_accepted") + delta
)
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.
"""
have_complete = not TradeOfferHaveCard.objects.filter(
trade_offer=trade_offer,
qty_accepted__lt=F('quantity')
trade_offer=trade_offer, qty_accepted__lt=F("quantity")
).exists()
want_complete = not TradeOfferWantCard.objects.filter(
trade_offer=trade_offer,
qty_accepted__lt=F('quantity')
trade_offer=trade_offer, qty_accepted__lt=F("quantity")
).exists()
closed = have_complete or want_complete
if trade_offer.is_closed != closed:
trade_offer.is_closed = closed
trade_offer.save(update_fields=["is_closed"])
@receiver(pre_save, sender=TradeAcceptance)
def trade_acceptance_pre_save(sender, instance, **kwargs):
# 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)
instance._old_state = old_instance.state
@receiver(post_save, sender=TradeAcceptance)
def trade_acceptance_post_save(sender, instance, created, **kwargs):
delta = 0
@ -75,7 +71,7 @@ def trade_acceptance_post_save(sender, instance, created, **kwargs):
if instance.state in POSITIVE_STATES:
delta = 1
else:
old_state = getattr(instance, '_old_state', None)
old_state = getattr(instance, "_old_state", None)
if old_state is not None:
if old_state in POSITIVE_STATES and instance.state not in POSITIVE_STATES:
delta = -1
@ -84,19 +80,29 @@ def trade_acceptance_post_save(sender, instance, created, **kwargs):
if delta != 0:
trade_offer = instance.trade_offer
adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta)
adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta)
adjust_qty_for_trade_offer(
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)
@receiver(post_delete, sender=TradeAcceptance)
def trade_acceptance_post_delete(sender, instance, **kwargs):
if instance.state in POSITIVE_STATES:
delta = -1
trade_offer = instance.trade_offer
adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta)
adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta)
adjust_qty_for_trade_offer(
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)
@receiver(post_save, sender=TradeAcceptance)
def trade_acceptance_email_notification(sender, instance, created, **kwargs):
# 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:
return
# Determine the non-acting party:
if instance.trade_offer.initiated_by.user.pk == acting_user.pk:
# 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,
"hash": instance.hash,
"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_ign": 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,
"recipient_user_ign": (
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,
"domain": "https://" + Site.objects.get_current().domain,
"pk": instance.pk,
}
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/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)
send_mail(
@ -173,6 +192,7 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
[recipient_user.email],
)
@receiver(post_save, sender=TradeAcceptance)
def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
"""
@ -191,28 +211,46 @@ def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
thanks_delta = 0
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:
return
# 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
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
# 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
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
# 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
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
# Apply reputation updates:
@ -237,6 +275,7 @@ def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
reputation_score=F("reputation_score") + rejection_delta_initiator
)
@receiver(post_delete, sender=TradeAcceptance)
def trade_acceptance_reputation_delete(sender, instance, **kwargs):
"""
@ -263,6 +302,7 @@ def trade_acceptance_reputation_delete(sender, instance, **kwargs):
reputation_score=F("reputation_score") + 1
)
@receiver(post_save, sender=TradeOfferHaveCard)
@receiver(post_delete, sender=TradeOfferHaveCard)
@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.
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:
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:
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 math import ceil
from pkmntrade_club.trades.models import TradeAcceptance
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):
"""
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())
acceptances = list(offer.acceptances.all())
have_cards_available = [
card for card in trade_offer_have_cards
if card.quantity > card.qty_accepted
card for card in trade_offer_have_cards if card.quantity > card.qty_accepted
]
want_cards_available = [
card for card in trade_offer_want_cards
if card.quantity > card.qty_accepted
card for card in trade_offer_want_cards if card.quantity > card.qty_accepted
]
if not have_cards_available or not want_cards_available:
@ -31,37 +29,41 @@ def render_trade_offer(context, offer):
flipped = False
tag_context = {
'offer_pk': offer.pk,
'flipped': flipped,
'offer_hash': offer.hash,
'rarity_icon': offer.rarity_icon,
'initiated_by_email': offer.initiated_by.user.email,
'initiated_by_username': offer.initiated_by.user.username,
'initiated_reputation': offer.initiated_by.user.reputation_score,
'acceptances': acceptances,
'have_cards_available': have_cards_available,
'want_cards_available': 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)+"/"),
'cache_key': f'trade_offer_{offer.pk}_{offer.updated_at.timestamp()}_{flipped}',
"offer_pk": offer.pk,
"flipped": flipped,
"offer_hash": offer.hash,
"rarity_icon": offer.rarity_icon,
"initiated_by_email": offer.initiated_by.user.email,
"initiated_by_username": offer.initiated_by.user.username,
"initiated_reputation": offer.initiated_by.user.reputation_score,
"acceptances": acceptances,
"have_cards_available": have_cards_available,
"want_cards_available": 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) + "/"
),
"cache_key": f"trade_offer_{offer.pk}_{offer.updated_at.timestamp()}_{flipped}",
}
context.update(tag_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):
"""
Renders a simple trade acceptance view with a single row and simplified header/footer.
"""
tag_context = {
"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)
return context
@register.filter
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)
@register.filter
def action_button_class(state_value):
"""
Returns daisyUI button classes based on the provided state value.
"""
mapping = {
'ACCEPTED': 'btn btn-primary',
'SENT': 'btn btn-info',
'RECEIVED': 'btn btn-info',
'THANKED_BY_INITIATOR': 'btn btn-success',
'THANKED_BY_ACCEPTOR': 'btn btn-success',
'THANKED_BY_BOTH': 'btn btn-success',
'REJECTED_BY_INITIATOR': 'btn btn-error',
'REJECTED_BY_ACCEPTOR': 'btn btn-error',
"ACCEPTED": "btn btn-primary",
"SENT": "btn btn-info",
"RECEIVED": "btn btn-info",
"THANKED_BY_INITIATOR": "btn btn-success",
"THANKED_BY_ACCEPTOR": "btn btn-success",
"THANKED_BY_BOTH": "btn btn-success",
"REJECTED_BY_INITIATOR": "btn btn-error",
"REJECTED_BY_ACCEPTOR": "btn btn-error",
}
# 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):
CARD_HEIGHT = 32
CARD_WIDTH = 160
@ -96,15 +100,20 @@ def render_trade_offer_png(context, offer, show_friend_code=False):
CARD_WIDTH_PADDING = 64
EXPANDED_CARD_WIDTH_PADDING = 80
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
want_cards_available = offer.want_cards_available
num_cards = max(len(have_cards_available), len(want_cards_available))
expanded = (len(have_cards_available) + len(want_cards_available)) > 4
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
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
)
if expanded:
image_width = (4 * CARD_WIDTH) + EXPANDED_CARD_WIDTH_PADDING
@ -112,7 +121,7 @@ def render_trade_offer_png(context, offer, show_friend_code=False):
image_width = (2 * CARD_WIDTH) + CARD_WIDTH_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")
if request.get_host().startswith("localhost"):
@ -121,22 +130,22 @@ def render_trade_offer_png(context, offer, show_friend_code=False):
base_url = "https://{0}".format(request.get_host())
tag_context = {
'offer_pk': offer.pk,
'offer_hash': offer.hash,
'rarity_icon': offer.rarity_icon,
'initiated_by_email': offer.initiated_by.user.email,
'initiated_by_username': offer.initiated_by.user.username,
'have_cards_available': have_cards_available,
'want_cards_available': want_cards_available,
'in_game_name': offer.initiated_by.in_game_name,
'friend_code': offer.initiated_by.friend_code,
'show_friend_code': show_friend_code,
'num_cards_available': len(have_cards_available) + len(want_cards_available),
'expanded': expanded,
'image_width': image_width,
'image_height': image_height,
'base_url': base_url,
'cache_key': f'trade_offer_png_{offer.pk}_{offer.updated_at.timestamp()}_{expanded}',
"offer_pk": offer.pk,
"offer_hash": offer.hash,
"rarity_icon": offer.rarity_icon,
"initiated_by_email": offer.initiated_by.user.email,
"initiated_by_username": offer.initiated_by.user.username,
"have_cards_available": have_cards_available,
"want_cards_available": want_cards_available,
"in_game_name": offer.initiated_by.in_game_name,
"friend_code": offer.initiated_by.friend_code,
"show_friend_code": show_friend_code,
"num_cards_available": len(have_cards_available) + len(want_cards_available),
"expanded": expanded,
"image_width": image_width,
"image_height": image_height,
"base_url": base_url,
"cache_key": f"trade_offer_png_{offer.pk}_{offer.updated_at.timestamp()}_{expanded}",
}
context.update(tag_context)

View file

@ -20,6 +20,7 @@ from pkmntrade_club.trades.forms import (
)
from tests.utils.rarity import RARITY_MAPPING
# ------------------------------------------------------------------------
# Model Tests
# ------------------------------------------------------------------------
@ -35,17 +36,29 @@ class TradeOfferModelTest(TestCase):
# Create cards with the same rarity (valid scenario)
self.card1 = Card.objects.create(
name="Card1", cardset="set1", cardnum=1, style="default",
rarity_icon=RARITY_MAPPING[1], rarity_level=1
name="Card1",
cardset="set1",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[1],
rarity_level=1,
)
self.card2 = Card.objects.create(
name="Card2", cardset="set1", cardnum=2, style="default",
rarity_icon=RARITY_MAPPING[1], rarity_level=1
name="Card2",
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)
self.card3 = Card.objects.create(
name="Card3", cardset="set1", cardnum=3, style="default",
rarity_icon=RARITY_MAPPING[8], rarity_level=8
name="Card3",
cardset="set1",
cardnum=3,
style="default",
rarity_icon=RARITY_MAPPING[8],
rarity_level=8,
)
# Create a valid trade offer with consistent rarity details
@ -92,17 +105,27 @@ class TradeAcceptanceModelTest(TestCase):
username="initiator", email="init@example.com", password="password"
)
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)
self.card1 = Card.objects.create(
name="CardA", cardset="setA", cardnum=1, style="default",
rarity_icon=RARITY_MAPPING[2], rarity_level=2
name="CardA",
cardset="setA",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[2],
rarity_level=2,
)
self.card2 = Card.objects.create(
name="CardB", cardset="setA", cardnum=2, style="default",
rarity_icon=RARITY_MAPPING[2], rarity_level=2
name="CardB",
cardset="setA",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[2],
rarity_level=2,
)
# Create a trade offer by the initiator.
@ -150,9 +173,7 @@ class TradeAcceptanceModelTest(TestCase):
self.acceptance.update_state(
TradeAcceptance.AcceptanceState.SENT, user=self.other_user
)
self.assertEqual(
self.acceptance.state, TradeAcceptance.AcceptanceState.SENT
)
self.assertEqual(self.acceptance.state, TradeAcceptance.AcceptanceState.SENT)
def test_signal_adjusts_qty_accepted(self):
"""
@ -206,12 +227,20 @@ class TradeOfferFormTest(TestCase):
)
# Create two cards with the same rarity details.
self.card1 = Card.objects.create(
name="FormCard1", cardset="formset", cardnum=1, style="default",
rarity_icon=RARITY_MAPPING[3], rarity_level=3
name="FormCard1",
cardset="formset",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[3],
rarity_level=3,
)
self.card2 = Card.objects.create(
name="FormCard2", cardset="formset", cardnum=2, style="default",
rarity_icon=RARITY_MAPPING[3], rarity_level=3
name="FormCard2",
cardset="formset",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[3],
rarity_level=3,
)
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.
"""
# 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("want_cards", [f"{self.card2.pk}:3"])
# '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.
"""
qd = QueryDict('', mutable=True)
qd = QueryDict("", mutable=True)
# Provide an invalid quantity ("two" instead of an integer).
qd.setlist("have_cards", [f"{self.card1.pk}:two"])
qd.setlist("want_cards", [f"{self.card2.pk}:3"])
@ -244,7 +273,7 @@ class TradeOfferFormTest(TestCase):
"""
An entry missing a colon should be ignored.
"""
qd = QueryDict('', mutable=True)
qd = QueryDict("", mutable=True)
# No colon present in the selections.
qd.setlist("have_cards", [f"{self.card1.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."""
friend_codes = FriendCode.objects.filter(pk=self.friend_code.pk)
form = TradeOfferAcceptForm(friend_codes=friend_codes)
self.assertEqual(
list(form.fields["friend_code"].queryset), list(friend_codes)
)
self.assertEqual(list(form.fields["friend_code"].queryset), list(friend_codes))
def test_trade_acceptance_transition_form(self):
"""Test that the transition form provides only allowed transitions."""
@ -312,7 +339,10 @@ class TradeOfferFormTest(TestCase):
)
form = TradeAcceptanceTransitionForm(instance=acceptance, user=other_user)
# 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]
for choice in allowed:
self.assertIn(choice, form_choices)
@ -337,12 +367,20 @@ class TradeViewsTest(TestCase):
# Create sample cards.
self.card1 = Card.objects.create(
name="ViewCard1", cardset="setV", cardnum=1, style="default",
rarity_icon=RARITY_MAPPING[7], rarity_level=7
name="ViewCard1",
cardset="setV",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[7],
rarity_level=7,
)
self.card2 = Card.objects.create(
name="ViewCard2", cardset="setV", cardnum=2, style="default",
rarity_icon=RARITY_MAPPING[7], rarity_level=7
name="ViewCard2",
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.
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.
"""
# 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
TradeOfferHaveCard.objects.create(
trade_offer=trade_offer_with_acceptance, card=self.card1, quantity=2
@ -403,10 +443,13 @@ class TradeViewsTest(TestCase):
offered_card=self.card2,
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 ---
from pkmntrade_club.trades.views import TradeOfferDeleteView
orig_get_object = TradeOfferDeleteView.get_object
TradeOfferDeleteView.get_object = lambda self: trade_offer_with_acceptance
@ -453,13 +496,19 @@ class TradeViewsTest(TestCase):
form = response.context.get("form")
self.assertIsNotNone(form, "Form should be present in the response context.")
self.assertIn(
"state", form.errors,
"Expected an error on the 'state' field when an invalid state is submitted."
"state",
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.
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:
valid_state = allowed_states[0]
response = self.client.post(update_url, {"state": valid_state})
@ -493,12 +542,20 @@ class TradeOfferSecurityTests(TestCase):
# Create test cards with proper rarity levels
self.card1 = Card.objects.create(
name="SecCard1", cardset="secset", cardnum=1, style="default",
rarity_icon=RARITY_MAPPING[3], rarity_level=3
name="SecCard1",
cardset="secset",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[3],
rarity_level=3,
)
self.card2 = Card.objects.create(
name="SecCard2", cardset="secset", cardnum=2, style="default",
rarity_icon=RARITY_MAPPING[3], rarity_level=3
name="SecCard2",
cardset="secset",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[3],
rarity_level=3,
)
# Create a trade offer by user1
@ -536,7 +593,7 @@ class TradeOfferSecurityTests(TestCase):
self.client.login(username="user3", password="password3")
response = self.client.post(
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
{"state": TradeAcceptance.AcceptanceState.SENT}
{"state": TradeAcceptance.AcceptanceState.SENT},
)
self.assertEqual(response.status_code, 403)
@ -551,12 +608,10 @@ class TradeOfferSecurityTests(TestCase):
"initiated_by": self.fc1.pk, # User1's friend code
"have_cards": [f"{self.card1.pk}:1"],
"want_cards": [f"{self.card2.pk}:1"],
}
},
)
self.assertEqual(response.status_code, 200) # Form should fail validation
self.assertFalse(
TradeOffer.objects.filter(initiated_by=self.fc1).count() > 1
)
self.assertFalse(TradeOffer.objects.filter(initiated_by=self.fc1).count() > 1)
def test_authenticated_only_views(self):
"""Test that authenticated-only views are properly protected."""
@ -564,7 +619,9 @@ class TradeOfferSecurityTests(TestCase):
urls_to_test = [
reverse("trade_offer_create"),
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
@ -575,7 +632,7 @@ class TradeOfferSecurityTests(TestCase):
self.assertRedirects(
response,
f"/accounts/login/?next={url}",
msg_prefix=f"URL {url} should require authentication"
msg_prefix=f"URL {url} should require authentication",
)
@ -591,16 +648,28 @@ class TradeOfferEdgeCasesTest(TestCase):
# Create test cards with different rarities using proper levels and icons
self.common_card = Card.objects.create(
name="CommonCard", cardset="edgeset", cardnum=1, style="default",
rarity_icon=RARITY_MAPPING[1], rarity_level=1
name="CommonCard",
cardset="edgeset",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[1],
rarity_level=1,
)
self.rare_card = Card.objects.create(
name="RareCard", cardset="edgeset", cardnum=2, style="default",
rarity_icon=RARITY_MAPPING[5], rarity_level=5
name="RareCard",
cardset="edgeset",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[5],
rarity_level=5,
)
self.crown_card = Card.objects.create(
name="CrownCard", cardset="edgeset", cardnum=3, style="default",
rarity_icon=RARITY_MAPPING[8], rarity_level=8
name="CrownCard",
cardset="edgeset",
cardnum=3,
style="default",
rarity_icon=RARITY_MAPPING[8],
rarity_level=8,
)
self.client = Client()
@ -614,7 +683,7 @@ class TradeOfferEdgeCasesTest(TestCase):
"initiated_by": self.friend_code.pk,
"have_cards": [f"{self.common_card.pk}:0"],
"want_cards": [f"{self.common_card.pk}:1"],
}
},
)
self.assertEqual(response.status_code, 200)
self.assertFalse(
@ -629,7 +698,7 @@ class TradeOfferEdgeCasesTest(TestCase):
"initiated_by": self.friend_code.pk,
"have_cards": [f"{self.common_card.pk}:-1"],
"want_cards": [f"{self.common_card.pk}:1"],
}
},
)
self.assertEqual(response.status_code, 200)
self.assertFalse(
@ -644,7 +713,7 @@ class TradeOfferEdgeCasesTest(TestCase):
"initiated_by": self.friend_code.pk,
"have_cards": [f"{self.common_card.pk}:1"],
"want_cards": [f"{self.crown_card.pk}:1"],
}
},
)
self.assertEqual(response.status_code, 200)
self.assertFalse(
@ -657,12 +726,9 @@ class TradeOfferEdgeCasesTest(TestCase):
reverse("trade_offer_create"),
{
"initiated_by": self.friend_code.pk,
"have_cards": [
f"{self.common_card.pk}:1",
f"{self.common_card.pk}:1"
],
"have_cards": [f"{self.common_card.pk}:1", f"{self.common_card.pk}:1"],
"want_cards": [f"{self.common_card.pk}:1"],
}
},
)
self.assertEqual(response.status_code, 200)
self.assertFalse(
@ -682,16 +748,28 @@ class TradeSearchTests(TestCase):
# Create test cards with proper rarity levels
self.card1 = Card.objects.create(
name="SearchCard1", cardset="sc1", cardnum=1, style="default",
rarity_icon=RARITY_MAPPING[4], rarity_level=4
name="SearchCard1",
cardset="sc1",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[4],
rarity_level=4,
)
self.card2 = Card.objects.create(
name="SearchCard2", cardset="sc1", cardnum=2, style="default",
rarity_icon=RARITY_MAPPING[4], rarity_level=4
name="SearchCard2",
cardset="sc1",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[4],
rarity_level=4,
)
self.card3 = Card.objects.create(
name="SearchCard3", cardset="sc1", cardnum=3, style="default",
rarity_icon=RARITY_MAPPING[4], rarity_level=4
name="SearchCard3",
cardset="sc1",
cardnum=3,
style="default",
rarity_icon=RARITY_MAPPING[4],
rarity_level=4,
)
# Create some trade offers
@ -719,7 +797,7 @@ class TradeSearchTests(TestCase):
reverse("trade_offer_search"),
{
"have_cards": [f"{self.card2.pk}:1"],
}
},
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
@ -731,7 +809,7 @@ class TradeSearchTests(TestCase):
reverse("trade_offer_search"),
{
"want_cards": [f"{self.card1.pk}:1"],
}
},
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
@ -743,7 +821,7 @@ class TradeSearchTests(TestCase):
reverse("trade_offer_search"),
{
"have_cards": ["999999:1"], # Non-existent card ID
}
},
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
@ -758,7 +836,7 @@ class TradeSearchTests(TestCase):
reverse("trade_offer_search"),
{
"have_cards": [f"{self.card2.pk}:1"],
}
},
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
@ -775,28 +853,48 @@ class TradeAcceptanceComplexTests(TestCase):
)
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(
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
self.card1 = Card.objects.create(
name="ComplexCard1", cardset="cx1", cardnum=1, style="default",
rarity_icon=RARITY_MAPPING[6], rarity_level=6
name="ComplexCard1",
cardset="cx1",
cardnum=1,
style="default",
rarity_icon=RARITY_MAPPING[6],
rarity_level=6,
)
self.card2 = Card.objects.create(
name="ComplexCard2", cardset="cx1", cardnum=2, style="default",
rarity_icon=RARITY_MAPPING[6], rarity_level=6
name="ComplexCard2",
cardset="cx1",
cardnum=2,
style="default",
rarity_icon=RARITY_MAPPING[6],
rarity_level=6,
)
self.card3 = Card.objects.create(
name="ComplexCard3", cardset="cx1", cardnum=3, style="default",
rarity_icon=RARITY_MAPPING[6], rarity_level=6
name="ComplexCard3",
cardset="cx1",
cardnum=3,
style="default",
rarity_icon=RARITY_MAPPING[6],
rarity_level=6,
)
self.card4 = Card.objects.create(
name="ComplexCard4", cardset="cx1", cardnum=4, style="default",
rarity_icon=RARITY_MAPPING[6], rarity_level=6
name="ComplexCard4",
cardset="cx1",
cardnum=4,
style="default",
rarity_icon=RARITY_MAPPING[6],
rarity_level=6,
)
# Create a trade offer with multiple quantities
@ -822,49 +920,58 @@ class TradeAcceptanceComplexTests(TestCase):
# Create first acceptance
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,
"requested_card": self.card1.pk,
"offered_card": self.card2.pk,
}
},
)
self.assertEqual(response1.status_code, 302) # Successful creation
# Create second acceptance
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,
"requested_card": self.card1.pk,
"offered_card": self.card2.pk,
}
},
)
self.assertEqual(response2.status_code, 302) # Successful creation
# Try to create a fourth acceptance (should fail as only 3 are allowed)
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,
"requested_card": self.card1.pk,
"offered_card": self.card2.pk,
}
},
)
self.assertEqual(response3.status_code, 302) # Successful creation
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,
"requested_card": self.card1.pk,
"offered_card": self.card2.pk,
}
},
)
self.assertEqual(response4.status_code, 200) # Should fail
self.assertEqual(
self.trade_offer.acceptances.count(), 3,
"Should not allow more acceptances than the quantity limit"
self.trade_offer.acceptances.count(),
3,
"Should not allow more acceptances than the quantity limit",
)
def test_complex_state_transitions(self):
@ -890,14 +997,14 @@ class TradeAcceptanceComplexTests(TestCase):
for invalid_state in invalid_transitions:
response = self.client.post(
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
{"state": invalid_state}
{"state": invalid_state},
)
self.assertEqual(response.status_code, 200) # Should stay on form
acceptance.refresh_from_db()
self.assertEqual(
acceptance.state,
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
@ -912,14 +1019,12 @@ class TradeAcceptanceComplexTests(TestCase):
self.client.login(username=user.username, password="password")
response = self.client.post(
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
{"state": state}
{"state": state},
)
self.assertEqual(response.status_code, 302) # Should redirect on success
acceptance.refresh_from_db()
self.assertEqual(
acceptance.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 = [
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("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"),
path("<int:pk>/", TradeOfferDetailView.as_view(), name="trade_offer_detail"),
path("<int:pk>.png", TradeOfferPNGView.as_view(), name="trade_offer_png"),
path("delete/<int:pk>/", TradeOfferDeleteView.as_view(), name="trade_offer_delete"),
path("accept/<int:offer_pk>", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"),
path("update/<int:pk>/", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"),
path(
"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 meta.views import Meta
from .models import TradeOffer, TradeAcceptance
from .forms import (TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm)
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError
from django.http import HttpResponseRedirect
from django.shortcuts import render
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 django.conf import settings
from .mixins import FriendCodeRequiredMixin
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):
http_method_names = ['get'] # restricts this view to GET only
http_method_names = ["get"] # restricts this view to GET only
model = TradeOffer
form_class = TradeOfferCreateForm
template_name = "trades/trade_offer_create.html"
@ -42,20 +52,30 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
from pkmntrade_club.cards.models import Card
# 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()
if "initiated_by" in self.request.GET:
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:
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:
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["selected_friend_code"] = selected_friend_code
return context
class TradeOfferAllListView(ReusablePaginationMixin, ListView):
model = TradeOffer
template_name = "trades/trade_offer_all_list.html"
@ -93,14 +113,21 @@ class TradeOfferAllListView(ReusablePaginationMixin, ListView):
page_number = self.get_page_number()
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(
self.request,
"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)
class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteView):
model = TradeOffer
success_url = reverse_lazy("trade_offer_list")
@ -108,8 +135,12 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi
def dispatch(self, request, *args, **kwargs):
self.object = super().get_object()
if self.object.initiated_by_id not in request.user.friend_codes.values_list("id", flat=True):
raise PermissionDenied("You are not authorized to delete or close this trade offer.")
if self.object.initiated_by_id not in request.user.friend_codes.values_list(
"id", flat=True
):
raise PermissionDenied(
"You are not authorized to delete or close this trade offer."
)
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
@ -144,7 +175,7 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi
if active_acceptances.exists():
messages.error(
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()
return self.render_to_response(context)
@ -158,6 +189,7 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi
messages.success(request, "Trade offer has been deleted.")
return super().delete(request, *args, **kwargs)
class TradeOfferSearchView(ListView):
"""
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
is empty.
"""
model = TradeOffer
context_object_name = "search_results"
template_name = "trades/trade_offer_search.html"
@ -198,7 +231,7 @@ class TradeOfferSearchView(ListView):
results.append((card_id, qty))
return results
#@silk_profile(name="Trade Offer Search- Get Queryset")
# @silk_profile(name="Trade Offer Search- Get Queryset")
def get_queryset(self):
# For a GET request (initial load), return an empty queryset.
if self.request.method == "GET":
@ -237,17 +270,20 @@ class TradeOfferSearchView(ListView):
return qs.distinct()
#@silk_profile(name="Trade Offer Search- Post")
# @silk_profile(name="Trade Offer Search- Post")
def post(self, request, *args, **kwargs):
# For POST, simply process the search through get().
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):
context = super().get_context_data(**kwargs)
from pkmntrade_club.cards.models import Card
# 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":
context["have_cards"] = self.request.POST.getlist("have_cards")
context["want_cards"] = self.request.POST.getlist("want_cards")
@ -256,33 +292,38 @@ class TradeOfferSearchView(ListView):
context["want_cards"] = []
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):
"""
Render the AJAX fragment if the request is AJAX; otherwise, render the complete page.
"""
if self.request.headers.get("X-Requested-With") == "XMLHttpRequest":
from django.shortcuts import render
return render(self.request, "trades/_search_results.html", context)
else:
return super().render_to_response(context, **response_kwargs)
class TradeOfferDetailView(DetailView):
"""
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,
an acceptance form is provided to create a new acceptance.
"""
model = TradeOffer
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):
context = super().get_context_data(**kwargs)
trade_offer = self.get_object()
screenshot_mode = self.request.GET.get("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
# Calculate the number of cards in each category.
@ -317,12 +358,12 @@ class TradeOfferDetailView(DetailView):
image_height = int(round(image_width / aspect_ratio))
# 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(
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={
"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",
"width": image_width,
"height": image_height,
@ -347,7 +388,9 @@ class TradeOfferDetailView(DetailView):
context["acceptances"] = trade_offer.acceptances.all()
# 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:
user_friend_codes = self.request.user.friend_codes.all()
@ -355,19 +398,26 @@ class TradeOfferDetailView(DetailView):
# Add context flag and deletion URL if the current user is the initiator
if trade_offer.initiated_by in user_friend_codes:
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:
context["is_initiator"] = False
# 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 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(
trade_offer=trade_offer,
friend_codes=user_friend_codes,
default_friend_code=default_friend_code
default_friend_code=default_friend_code,
)
else:
context["is_initiator"] = False
@ -376,11 +426,15 @@ class TradeOfferDetailView(DetailView):
return context
class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, CreateView):
class TradeAcceptanceCreateView(
LoginRequiredMixin, FriendCodeRequiredMixin, CreateView
):
"""
View to create a new TradeAcceptance.
The URL should provide 'offer_pk' so that the proper TradeOffer can be identified.
"""
model = TradeAcceptance
form_class = TradeAcceptanceCreateForm
template_name = "trades/trade_acceptance_create.html"
@ -390,16 +444,18 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre
return super().dispatch(request, *args, **kwargs)
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):
kwargs = super().get_form_kwargs()
if (self.trade_offer.initiated_by_id in
self.request.user.friend_codes.values_list("id", flat=True) or
self.trade_offer.is_closed):
if (
self.trade_offer.initiated_by_id
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.")
kwargs['trade_offer'] = self.trade_offer
kwargs['friend_codes'] = self.request.user.friend_codes.all()
kwargs["trade_offer"] = self.trade_offer
kwargs["friend_codes"] = self.request.user.friend_codes.all()
return kwargs
def get_context_data(self, **kwargs):
@ -430,7 +486,13 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre
"acceptance_form": form,
"friend_codes": friend_codes,
"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
return render(self.request, "trades/trade_offer_detail.html", context)
@ -439,11 +501,15 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre
def get_success_url(self):
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.
The allowed state transitions are provided via the form.
"""
model = TradeAcceptance
form_class = TradeAcceptanceTransitionForm
template_name = "trades/trade_acceptance_update.html"
@ -451,8 +517,10 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, Upd
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
friend_codes = request.user.friend_codes.values_list("id", flat=True)
if (self.object.accepted_by_id not in friend_codes and
self.object.trade_offer.initiated_by_id not in friend_codes):
if (
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.")
return super().dispatch(request, *args, **kwargs)
@ -481,6 +549,7 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, Upd
def get_success_url(self):
return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk})
class TradeOfferPNGView(View):
"""
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
TradeOffer model's `image` field (assumed to be an ImageField).
"""
def get_lock_key(self, trade_offer_id):
# Use the trade_offer_id as the lock key; adjust if needed.
return trade_offer_id
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
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 trade_offer.image and not request.GET.get("debug"):
@ -505,6 +576,7 @@ class TradeOfferPNGView(View):
# Acquire PostgreSQL advisory lock to prevent concurrent generation.
from django.db import connection
lock_key = self.get_lock_key(trade_offer.pk)
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key])
@ -516,16 +588,20 @@ class TradeOfferPNGView(View):
return HttpResponse(trade_offer.image.read(), content_type="image/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_height = tag_context.get('image_height')
image_width = tag_context.get("image_width")
image_height = tag_context.get("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(
"templatetags/trade_offer_png.html",
context=tag_context,
request=request
request=request,
)
# if query string has "debug", render the HTML instead of the PNG
@ -545,13 +621,20 @@ class TradeOfferPNGView(View):
"--disable-audio-output",
"--disable-webgl",
"--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.on("console", lambda msg: print(f"Console {msg.type}: {msg.text}"))
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")
element = page.wait_for_selector(".trade-offer-card-screenshot")
screenshot_bytes = element.screenshot(type="png", omit_background=True)
@ -567,11 +650,13 @@ class TradeOfferPNGView(View):
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
"""
Processes a two-step create for TradeOffer; on confirmation,
commits the offer and shows form errors if any occur.
"""
def post(self, request, *args, **kwargs):
if "confirm" in request.POST:
return self._commit_offer(request)
@ -605,17 +690,21 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
}
# Supply additional context required by trade_offer_create.html.
from pkmntrade_club.cards.models import Card
context = {
"form": form,
"friend_codes": request.user.friend_codes.all(),
"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"),
}
return render(request, "trades/trade_offer_create.html", context)
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:
# When the form is not valid, update its initial data as well:
form.initial = {
@ -624,11 +713,13 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
"initiated_by": request.POST.get("initiated_by"),
}
from pkmntrade_club.cards.models import Card
context = {
"form": form,
"friend_codes": request.user.friend_codes.all(),
"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"),
}
@ -641,6 +732,7 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
query_params.pop("confirm", None)
query_params.pop("preview", None)
from django.urls import reverse
base_url = reverse("trade_offer_create")
url_with_params = f"{base_url}?{query_params.urlencode()}"
return HttpResponseRedirect(url_with_params)
@ -656,15 +748,16 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
"initiated_by": request.POST.get("initiated_by"),
}
from pkmntrade_club.cards.models import Card
context = {
"form": form,
"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"),
}
return render(request, "trades/trade_offer_create.html", context)
# Parse the card selections for "have" and "want" cards.
have_selections = self._parse_card_selections("have_cards")
want_selections = self._parse_card_selections("want_cards")