diff --git a/manage.py b/manage.py index 138d284..d84abce 100755 --- a/manage.py +++ b/manage.py @@ -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: diff --git a/src/pkmntrade_club/__init__.py b/src/pkmntrade_club/__init__.py index 8d1f1f7..bcdfc83 100644 --- a/src/pkmntrade_club/__init__.py +++ b/src/pkmntrade_club/__init__.py @@ -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"] diff --git a/src/pkmntrade_club/_version.py b/src/pkmntrade_club/_version.py index 6f90c71..77921b5 100644 --- a/src/pkmntrade_club/_version.py +++ b/src/pkmntrade_club/_version.py @@ -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 - - # 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 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__, + ) + + 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, + } + 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 -} \ No newline at end of file diff --git a/src/pkmntrade_club/accounts/adapter.py b/src/pkmntrade_club/accounts/adapter.py index b41e761..b39fed3 100644 --- a/src/pkmntrade_club/accounts/adapter.py +++ b/src/pkmntrade_club/accounts/adapter.py @@ -1,4 +1,3 @@ -from django.conf import settings from allauth.account.adapter import DefaultAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter @@ -10,4 +9,4 @@ class NoSignupAccountAdapter(DefaultAccountAdapter): class NoSignupSocialAccountAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request): - return False \ No newline at end of file + return False diff --git a/src/pkmntrade_club/accounts/admin.py b/src/pkmntrade_club/accounts/admin.py index f3bd5c3..bd475e3 100644 --- a/src/pkmntrade_club/accounts/admin.py +++ b/src/pkmntrade_club/accounts/admin.py @@ -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 diff --git a/src/pkmntrade_club/accounts/apps.py b/src/pkmntrade_club/accounts/apps.py index df72a2b..408657e 100644 --- a/src/pkmntrade_club/accounts/apps.py +++ b/src/pkmntrade_club/accounts/apps.py @@ -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" diff --git a/src/pkmntrade_club/accounts/forms.py b/src/pkmntrade_club/accounts/forms.py index 6788d85..0aac8fe 100644 --- a/src/pkmntrade_club/accounts/forms.py +++ b/src/pkmntrade_club/accounts/forms.py @@ -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'] \ No newline at end of file + fields = ["show_friend_code_on_link_previews", "enable_email_notifications"] diff --git a/src/pkmntrade_club/accounts/management/commands/clear_cache.py b/src/pkmntrade_club/accounts/management/commands/clear_cache.py index 8c6863f..96a2b7b 100644 --- a/src/pkmntrade_club/accounts/management/commands/clear_cache.py +++ b/src/pkmntrade_club/accounts/management/commands/clear_cache.py @@ -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') \ No newline at end of file + self.stdout.write("Cleared cache\n") diff --git a/src/pkmntrade_club/accounts/models.py b/src/pkmntrade_club/accounts/models.py index 65610e7..a78a15b 100644 --- a/src/pkmntrade_club/accounts/models.py +++ b/src/pkmntrade_club/accounts/models.py @@ -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) @@ -67,4 +74,4 @@ class FriendCode(models.Model): self.user.save(update_fields=["default_friend_code"]) def __str__(self): - return self.friend_code \ No newline at end of file + return self.friend_code diff --git a/src/pkmntrade_club/accounts/templatetags/gravatar.py b/src/pkmntrade_club/accounts/templatetags/gravatar.py index c2005a0..a719cd6 100644 --- a/src/pkmntrade_club/accounts/templatetags/gravatar.py +++ b/src/pkmntrade_club/accounts/templatetags/gravatar.py @@ -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'Gravatar' 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'Gravatar' return mark_safe(html) + @register.filter def gravatar_profile_data(email): """ diff --git a/src/pkmntrade_club/accounts/tests.py b/src/pkmntrade_club/accounts/tests.py index b6c8695..6d00785 100644 --- a/src/pkmntrade_club/accounts/tests.py +++ b/src/pkmntrade_club/accounts/tests.py @@ -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,25 +425,25 @@ 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.") - + def test_delete_friend_code_view_only_code(self): """ If the user has only one friend code, deletion should be disabled. This test uses a new user with a single friend code. """ user_only = get_user_model().objects.create_user( - username="onlyuser", - email="onlyuser@example.com", - password="password123" + 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) diff --git a/src/pkmntrade_club/accounts/urls.py b/src/pkmntrade_club/accounts/urls.py index 8e9b106..202d789 100644 --- a/src/pkmntrade_club/accounts/urls.py +++ b/src/pkmntrade_club/accounts/urls.py @@ -9,8 +9,20 @@ from .views import ( urlpatterns = [ path("friend-codes/add/", AddFriendCodeView.as_view(), name="add_friend_code"), - path("friend-codes/edit//", EditFriendCodeView.as_view(), name="edit_friend_code"), - path("friend-codes/delete//", DeleteFriendCodeView.as_view(), name="delete_friend_code"), - path("friend-codes/default//", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"), + path( + "friend-codes/edit//", + EditFriendCodeView.as_view(), + name="edit_friend_code", + ), + path( + "friend-codes/delete//", + DeleteFriendCodeView.as_view(), + name="delete_friend_code", + ), + path( + "friend-codes/default//", + ChangeDefaultFriendCodeView.as_view(), + name="change_default_friend_code", + ), path("dashboard/", DashboardView.as_view(), name="dashboard"), -] \ No newline at end of file +] diff --git a/src/pkmntrade_club/accounts/views.py b/src/pkmntrade_club/accounts/views.py index fbecc1e..34e81b7 100644 --- a/src/pkmntrade_club/accounts/views.py +++ b/src/pkmntrade_club/accounts/views.py @@ -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,48 +92,59 @@ 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()) - + self.object.delete() messages.success(request, "Friend code removed successfully.") return redirect(self.get_success_url()) + class ChangeDefaultFriendCodeView(LoginRequiredMixin, View): """ Change the default friend code for the current user. """ + def post(self, request, *args, **kwargs): friend_code_id = kwargs.get("pk") - friend_code = get_object_or_404(FriendCode, pk=friend_code_id, user=request.user) + 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} @@ -258,19 +328,19 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat request = self.request selected_friend_code = self.get_selected_friend_code() context["selected_friend_code"] = selected_friend_code - + # Get the default friend code's primary key if it exists default_pk = getattr(request.user.default_friend_code, "pk", None) - + # Annotate friend codes with is_default flag context["friend_codes"] = request.user.friend_codes.all().annotate( is_default=Case( When(pk=default_pk, then=Value(True)), default=Value(False), - output_field=BooleanField() + output_field=BooleanField(), ) ) - + ajax_section = request.GET.get("ajax_section") if ajax_section == "dashboard_offers": offers_page = request.GET.get("page", 1) @@ -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 super().get(request, *args, **kwargs) \ No newline at end of file + 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) diff --git a/src/pkmntrade_club/cards/mixins.py b/src/pkmntrade_club/cards/mixins.py index 575d62c..18d887c 100644 --- a/src/pkmntrade_club/cards/mixins.py +++ b/src/pkmntrade_club/cards/mixins.py @@ -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): @@ -39,4 +41,4 @@ class ReusablePaginationMixin: "next_page": page_number + 1 if page_number < num_pages else num_pages, "paginator": {"num_pages": num_pages}, } - return items, pagination_context \ No newline at end of file + return items, pagination_context diff --git a/src/pkmntrade_club/cards/models.py b/src/pkmntrade_club/cards/models.py index b3376ef..d443a6a 100644 --- a/src/pkmntrade_club/cards/models.py +++ b/src/pkmntrade_club/cards/models.py @@ -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): diff --git a/src/pkmntrade_club/cards/signals.py b/src/pkmntrade_club/cards/signals.py index dd87f2a..af9a8ee 100644 --- a/src/pkmntrade_club/cards/signals.py +++ b/src/pkmntrade_club/cards/signals.py @@ -2,15 +2,16 @@ from django.db.models.signals import m2m_changed from django.dispatch import receiver from .models import Card + def color_is_dark(bg_color): """ Determine if a given hexadecimal color is dark. This function accepts a 6-digit hex color string (with or without a leading '#'). It calculates the brightness using the formula: - + brightness = (0.299 * red) + (0.587 * green) + (0.114 * blue) - + A brightness value less than or equal to 186 indicates that the color is dark. Args: @@ -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) @@ -29,9 +30,10 @@ def color_is_dark(bg_color): # Compute brightness based on weighted RGB values. brightness = (r * 0.299) + (g * 0.587) + (b * 0.114) - + return brightness <= 200 + @receiver(m2m_changed, sender=Card.decks.through) def update_card_style(sender, instance, action, **kwargs): if action == "post_add": @@ -41,11 +43,15 @@ 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: instance.style += "text-shadow: 0 0 0 #fff;" - instance.save(update_fields=["style"]) \ No newline at end of file + instance.save(update_fields=["style"]) diff --git a/src/pkmntrade_club/cards/templatetags/card_badge.py b/src/pkmntrade_club/cards/templatetags/card_badge.py index ace3b17..adc2930 100644 --- a/src/pkmntrade_club/cards/templatetags/card_badge.py +++ b/src/pkmntrade_club/cards/templatetags/card_badge.py @@ -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) \ No newline at end of file + return mark_safe(html) diff --git a/src/pkmntrade_club/cards/templatetags/card_multiselect.py b/src/pkmntrade_club/cards/templatetags/card_multiselect.py index dc03500..65b43a3 100644 --- a/src/pkmntrade_club/cards/templatetags/card_multiselect.py +++ b/src/pkmntrade_club/cards/templatetags/card_multiselect.py @@ -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 \ No newline at end of file + return context # Return the MODIFIED original context diff --git a/src/pkmntrade_club/cards/tests.py b/src/pkmntrade_club/cards/tests.py index 75e73b8..85219a2 100644 --- a/src/pkmntrade_club/cards/tests.py +++ b/src/pkmntrade_club/cards/tests.py @@ -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) @@ -44,7 +54,8 @@ class CardsModelsTests(TestCase): card_translation = CardNameTranslation.objects.create( name="Card Translated", card=self.card, language="en" ) - self.assertEqual(str(card_translation), "Card Translated") + self.assertEqual(str(card_translation), "Card Translated") + class CardTemplatetagsTests(TestCase): def setUp(self): @@ -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) @@ -142,7 +153,8 @@ class CardTemplatetagsTests(TestCase): selected_values=[], ) # Verify that the context's cards match those in the database. - self.assertEqual(list(context["cards"]), default_cards) + self.assertEqual(list(context["cards"]), default_cards) + class CardsViewsTests(TestCase): def setUp(self): @@ -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() @@ -285,4 +293,4 @@ class CardsViewsTests(TestCase): trade_offers_oldest = response_oldest.context.get("trade_offers") self.assertEqual(len(trade_offers_oldest), 2) self.assertEqual(trade_offers_oldest[0].pk, offer1.pk) - self.assertEqual(trade_offers_oldest[1].pk, offer2.pk) \ No newline at end of file + self.assertEqual(trade_offers_oldest[1].pk, offer2.pk) diff --git a/src/pkmntrade_club/cards/urls.py b/src/pkmntrade_club/cards/urls.py index 599427f..3338d08 100644 --- a/src/pkmntrade_club/cards/urls.py +++ b/src/pkmntrade_club/cards/urls.py @@ -9,8 +9,16 @@ from .views import ( app_name = "cards" urlpatterns = [ - path('', CardListView.as_view(), name='card_list'), - path('/', CardDetailView.as_view(), name='card_detail'), - path('/trade-offers-have/', TradeOfferHaveCardListView.as_view(), name='card_trade_offer_have_list'), - path('/trade-offers-want/', TradeOfferWantCardListView.as_view(), name='card_trade_offer_want_list'), + path("", CardListView.as_view(), name="card_list"), + path("/", CardDetailView.as_view(), name="card_detail"), + path( + "/trade-offers-have/", + TradeOfferHaveCardListView.as_view(), + name="card_trade_offer_have_list", + ), + path( + "/trade-offers-want/", + TradeOfferWantCardListView.as_view(), + name="card_trade_offer_want_list", + ), ] diff --git a/src/pkmntrade_club/cards/views.py b/src/pkmntrade_club/cards/views.py index 08a7bb0..f4c1cf2 100644 --- a/src/pkmntrade_club/cards/views.py +++ b/src/pkmntrade_club/cards/views.py @@ -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,8 +159,10 @@ 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() - return context \ No newline at end of file + return context diff --git a/src/pkmntrade_club/common/apps.py b/src/pkmntrade_club/common/apps.py index 6e2cde5..7427944 100644 --- a/src/pkmntrade_club/common/apps.py +++ b/src/pkmntrade_club/common/apps.py @@ -5,4 +5,4 @@ class CommonConfig(AppConfig): name = "pkmntrade_club.common" def ready(self): - pass \ No newline at end of file + pass diff --git a/src/pkmntrade_club/common/context_processors.py b/src/pkmntrade_club/common/context_processors.py index 7950ded..b286201 100644 --- a/src/pkmntrade_club/common/context_processors.py +++ b/src/pkmntrade_club/common/context_processors.py @@ -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, - } \ No newline at end of file + "VERSION": settings.VERSION, + "VERSION_INFO": settings.VERSION_INFO, + } diff --git a/src/pkmntrade_club/common/mixins.py b/src/pkmntrade_club/common/mixins.py index 6290fdc..0df467a 100644 --- a/src/pkmntrade_club/common/mixins.py +++ b/src/pkmntrade_club/common/mixins.py @@ -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 \ No newline at end of file + return page.object_list, pagination_context diff --git a/src/pkmntrade_club/common/templatetags/pagination_tags.py b/src/pkmntrade_club/common/templatetags/pagination_tags.py index a9a2890..0840881 100644 --- a/src/pkmntrade_club/common/templatetags/pagination_tags.py +++ b/src/pkmntrade_club/common/templatetags/pagination_tags.py @@ -2,9 +2,10 @@ from django import template register = template.Library() + @register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True) def render_pagination(context, page_obj, hide_if_one_page=True): """ Renders the pagination controls given a page_obj. Optionally hides the controls if there is only one page. """ - return {"page_obj": page_obj, "hide_if_one_page": hide_if_one_page} \ No newline at end of file + return {"page_obj": page_obj, "hide_if_one_page": hide_if_one_page} diff --git a/src/pkmntrade_club/django_project/__init__.py b/src/pkmntrade_club/django_project/__init__.py index 1e3599b..5568b6d 100644 --- a/src/pkmntrade_club/django_project/__init__.py +++ b/src/pkmntrade_club/django_project/__init__.py @@ -2,4 +2,4 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app -__all__ = ('celery_app',) \ No newline at end of file +__all__ = ("celery_app",) diff --git a/src/pkmntrade_club/django_project/asgi.py b/src/pkmntrade_club/django_project/asgi.py index 11400ef..715ac8b 100644 --- a/src/pkmntrade_club/django_project/asgi.py +++ b/src/pkmntrade_club/django_project/asgi.py @@ -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() diff --git a/src/pkmntrade_club/django_project/celery.py b/src/pkmntrade_club/django_project/celery.py index 44c9d6c..7896883 100644 --- a/src/pkmntrade_club/django_project/celery.py +++ b/src/pkmntrade_club/django_project/celery.py @@ -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}') \ No newline at end of file + print(f"Request: {self.request!r}") diff --git a/src/pkmntrade_club/django_project/settings.py b/src/pkmntrade_club/django_project/settings.py index d048108..eb152d8 100644 --- a/src/pkmntrade_club/django_project/settings.py +++ b/src/pkmntrade_club/django_project/settings.py @@ -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} diff --git a/src/pkmntrade_club/django_project/urls.py b/src/pkmntrade_club/django_project/urls.py index 61cac00..84a15be 100644 --- a/src/pkmntrade_club/django_project/urls.py +++ b/src/pkmntrade_club/django_project/urls.py @@ -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() diff --git a/src/pkmntrade_club/django_project/wsgi.py b/src/pkmntrade_club/django_project/wsgi.py index 4c5ab87..711707d 100644 --- a/src/pkmntrade_club/django_project/wsgi.py +++ b/src/pkmntrade_club/django_project/wsgi.py @@ -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() diff --git a/src/pkmntrade_club/home/tests.py b/src/pkmntrade_club/home/tests.py index 9c51303..a174be0 100644 --- a/src/pkmntrade_club/home/tests.py +++ b/src/pkmntrade_club/home/tests.py @@ -2,241 +2,221 @@ 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.""" - + @classmethod def setUpTestData(cls): """Set up data for all test methods.""" # Create a user cls.user = User.objects.create_user( - username='testuser', - email='testuser@example.com', - password='testpass123' + 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): """Test that the home page returns a 200 status code.""" response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - + def test_home_page_template(self): """Test that the home page uses the correct template.""" response = self.client.get(self.url) - self.assertTemplateUsed(response, 'home/home.html') - + 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) - + 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.""" # Close one of the trade offers self.common_trade.is_closed = True self.common_trade.save() - + response = self.client.get(self.url) - recent_offers = response.context['recent_offers'] + recent_offers = response.context["recent_offers"] # Should only show the rare trade now self.assertEqual(len(recent_offers), 1) self.assertEqual(recent_offers[0], self.rare_trade) - + def test_home_page_with_no_data(self): """Test home page rendering when there's no trade data.""" # Delete all trade offers TradeOffer.objects.all().delete() - + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) # Should have empty lists for offers - self.assertEqual(len(response.context['recent_offers']), 0) - + 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) - + def test_rarity_sorting_in_featured_offers(self): """Test that offers are sorted by rarity level in descending order.""" # Create a new ultra rare trade with consistent rarity ultra_trade = TradeOffer.objects.create( - initiated_by=self.friend_code, - rarity_icon='★★★★', - rarity_level=4 + 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) self.assertEqual(keys[0], "All") self.assertEqual(keys[1], "★★★★") @@ -246,216 +226,202 @@ class HomePageViewTests(TestCase): class HomePageViewMockTests(TestCase): """Test suite using mocks for HomePageView.""" - + def setUp(self): self.factory = RequestFactory() self.view = HomePageView() - - @patch('trades.models.TradeOffer.objects') - @patch('cards.models.Card.objects') + + @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 mock_offer_filter = MagicMock() mock_offer_objects.filter.return_value = mock_offer_filter mock_offer_filter.order_by.return_value = [] - + mock_card_filter = MagicMock() mock_card_objects.filter.return_value = mock_card_filter mock_card_objects.annotate.return_value = mock_card_filter mock_card_objects.all.return_value.order_by.return_value = [] mock_card_filter.annotate.return_value = mock_card_filter mock_card_filter.order_by.return_value = [] - + mock_offer_filter.values_list.return_value.distinct.return_value = [] - + # Call the method context = self.view.get_context_data() - + # Verify the expected context keys exist - self.assertIn('cards', context) - self.assertIn('recent_offers', context) - self.assertIn('most_offered_cards', context) - self.assertIn('most_wanted_cards', context) - self.assertIn('least_offered_cards', context) - self.assertIn('featured_offers', context) - - @patch('trades.models.TradeOffer.objects') + self.assertIn("cards", context) + self.assertIn("recent_offers", context) + self.assertIn("most_offered_cards", context) + self.assertIn("most_wanted_cards", context) + self.assertIn("least_offered_cards", context) + self.assertIn("featured_offers", context) + + @patch("trades.models.TradeOffer.objects") def test_empty_featured_offers(self, mock_offer_objects): """Test handling of empty featured offers.""" # Set up request - request = self.factory.get(reverse('home')) + request = self.factory.get(reverse("home")) self.view.request = request - + # Configure mock to return empty queryset mock_offer_filter = MagicMock() mock_offer_objects.filter.return_value = mock_offer_filter mock_offer_filter.order_by.return_value = [] mock_offer_filter.values_list.return_value.distinct.return_value = [] - + # Call the method context = self.view.get_context_data() - + # Verify the featured_offers is an OrderedDict but with just the "All" key - self.assertIsInstance(context['featured_offers'], OrderedDict) - self.assertIn("All", context['featured_offers']) - self.assertEqual(len(context['featured_offers']), 1) - - @patch('trades.models.TradeOffer.objects.filter') + self.assertIsInstance(context["featured_offers"], OrderedDict) + self.assertIn("All", context["featured_offers"]) + self.assertEqual(len(context["featured_offers"]), 1) + + @patch("trades.models.TradeOffer.objects.filter") def test_exception_handling(self, mock_filter): """Test that exceptions are handled gracefully.""" # Set up request - request = self.factory.get(reverse('home')) + 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 - ) - - TradeOfferWantCard.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) + 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.""" # The view should ignore invalid parameters response = self.client.get(f"{self.url}?invalid=param&another=invalid") self.assertEqual(response.status_code, 200) - + def test_performance_with_large_dataset(self): """Test performance with a larger dataset (basic check).""" # Create a card card = Card.objects.create( - name='Performance Test Card', - cardset='PERF01', + 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() - + self.assertEqual(response.status_code, 200) - + # Should be reasonably fast (adjust threshold as needed) execution_time = end - start self.assertLess(execution_time, 2.0) # Should complete in under 2 seconds @@ -463,129 +429,116 @@ class HomePageEdgeCaseTests(TestCase): class TemplateRenderingTests(TestCase): """Tests focused on template rendering.""" - + @classmethod def setUpTestData(cls): # Create a user cls.user = User.objects.create_user( - username='testuser', - email='testuser@example.com', - password='testpass123' + 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): self.client = Client() self.factory = RequestFactory() - + def test_template_used(self): """Test that the correct template is used.""" - response = self.client.get(reverse('home')) - self.assertTemplateUsed(response, 'home/home.html') - + 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: self.assertIn(key, response.context) - + def test_view_with_pagination_params(self): """Test that view handles pagination parameters correctly, if applicable.""" # Create additional trade offers if pagination is implemented for i in range(10): trade = TradeOffer.objects.create( - initiated_by=self.friend_code, - rarity_icon='★', - rarity_level=1 + 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 response = self.client.get(f"{reverse('home')}?page=1") self.assertEqual(response.status_code, 200) - + # Test with invalid page parameter response = self.client.get(f"{reverse('home')}?page=999") - self.assertEqual(response.status_code, 200) # Should still render with default page - + 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) diff --git a/src/pkmntrade_club/home/views.py b/src/pkmntrade_club/home/views.py index cc05847..22c66af 100644 --- a/src/pkmntrade_club/home/views.py +++ b/src/pkmntrade_club/home/views.py @@ -1,54 +1,58 @@ -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) - + # Recent Offers try: recent_offers_qs = base_offer_qs.order_by("-created_at")[:6] context["recent_offers"] = recent_offers_qs - context["cache_key_recent_offers"] = f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}" + context["cache_key_recent_offers"] = ( + f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}" + ) except Exception as e: logger.error(f"Error fetching recent offers: {str(e)}") context["recent_offers"] = [] context["cache_key_recent_offers"] = "recent_offers_error" - + # Most Offered Cards try: most_offered_cards_qs = ( - Card.objects.filter(tradeofferhavecard__isnull=False).filter(rarity_level__lte=5) + 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,26 +60,32 @@ 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"] = [] - + # 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"] = [] @@ -88,22 +98,22 @@ class HomePageView(TemplateView): except Exception as e: logger.error(f"Error fetching 'All' featured offers: {str(e)}") featured["All"] = [] - - # *** we only show All Featured Offers for now, + + # *** we only show All Featured Offers for now, # *** we will add rarity-tabbed featured offers later # try: # # Pull out distinct (rarity_level, rarity_icon) tuples # distinct_rarities = base_offer_qs.values_list("rarity_level", "rarity_icon").distinct() - + # # Prepare a list that holds tuples of (rarity_level, rarity_icon, offers) # rarity_offers = [] # for rarity_level, rarity_icon in distinct_rarities: # offers = base_offer_qs.filter(rarity_level=rarity_level).order_by("created_at")[:6] # rarity_offers.append((rarity_level, rarity_icon, offers)) - + # # Sort by rarity_level (from greatest to least) # rarity_offers.sort(key=lambda x: x[0], reverse=True) - + # # Add the sorted offers to the OrderedDict # for rarity_level, rarity_icon, offers in rarity_offers: # featured[rarity_icon] = offers @@ -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 @@ -133,9 +147,9 @@ class HomePageView(TemplateView): context["most_wanted_cards"] = [] context["least_offered_cards"] = [] context["featured_offers"] = OrderedDict([("All", [])]) - + return context - + def get(self, request, *args, **kwargs): """Override get method to add caching""" return super().get(request, *args, **kwargs) diff --git a/src/pkmntrade_club/tests/utils/rarity.py b/src/pkmntrade_club/tests/utils/rarity.py index 24bf7dd..900eb00 100644 --- a/src/pkmntrade_club/tests/utils/rarity.py +++ b/src/pkmntrade_club/tests/utils/rarity.py @@ -6,5 +6,5 @@ RARITY_MAPPING = { 5: "⭐️", 6: "⭐️⭐️", 7: "⭐️⭐️⭐️", - 8: "👑" -} \ No newline at end of file + 8: "👑", +} diff --git a/src/pkmntrade_club/theme/apps.py b/src/pkmntrade_club/theme/apps.py index 71e85a3..8f60b15 100644 --- a/src/pkmntrade_club/theme/apps.py +++ b/src/pkmntrade_club/theme/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class ThemeConfig(AppConfig): - name = 'pkmntrade_club.theme' + name = "pkmntrade_club.theme" diff --git a/src/pkmntrade_club/trades/forms.py b/src/pkmntrade_club/trades/forms.py index 69f037c..af2a3e2 100644 --- a/src/pkmntrade_club/trades/forms.py +++ b/src/pkmntrade_club/trades/forms.py @@ -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()) @@ -87,13 +106,18 @@ class TradeAcceptanceTransitionForm(forms.Form): raise ValueError("A TradeAcceptance instance must be provided") self.instance = instance self.user = user - + 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): @@ -167,15 +195,19 @@ class TradeOfferCreateForm(ModelForm): # Clear any existing through model entries in case of update TradeOfferHaveCard.objects.filter(trade_offer=instance).delete() TradeOfferWantCard.objects.filter(trade_offer=instance).delete() - + # 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 diff --git a/src/pkmntrade_club/trades/mixins.py b/src/pkmntrade_club/trades/mixins.py index 36d4272..3e5caf9 100644 --- a/src/pkmntrade_club/trades/mixins.py +++ b/src/pkmntrade_club/trades/mixins.py @@ -1,37 +1,49 @@ 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. context = kwargs.copy() # Include available cards requirements for multiselect fields. context.setdefault("cards", Card.objects.all().order_by("name", "rarity_level")) - + # Provide friend_codes and selected_friend_code as in TradeOfferCreateView friend_codes = self.request.user.friend_codes.all() context["friend_codes"] = friend_codes - + 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 + + 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) \ No newline at end of file + return super().dispatch(request, *args, **kwargs) diff --git a/src/pkmntrade_club/trades/models.py b/src/pkmntrade_club/trades/models.py index 1e50eff..a78d451 100644 --- a/src/pkmntrade_club/trades/models.py +++ b/src/pkmntrade_club/trades/models.py @@ -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) @@ -105,7 +102,7 @@ class TradeOffer(models.Model): def update_rarity_fields(self): """ - Recalculates and updates the rarity_level and rarity_icon fields based on + Recalculates and updates the rarity_level and rarity_icon fields based on the associated have_cards and want_cards. Enforces that all cards in the trade offer share the same rarity. @@ -118,11 +115,16 @@ class TradeOffer(models.Model): # Enforce same rarity across all cards. 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,19 +217,20 @@ 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 = [ AcceptanceState.ACCEPTED, @@ -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,11 +311,14 @@ 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, }, diff --git a/src/pkmntrade_club/trades/signals.py b/src/pkmntrade_club/trades/signals.py index 37caddb..e11c6e3 100644 --- a/src/pkmntrade_club/trades/signals.py +++ b/src/pkmntrade_club/trades/signals.py @@ -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,17 +192,18 @@ 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): """ - Update the denormalized reputation score on the user model based on + Update the denormalized reputation score on the user model based on state transitions for TradeAcceptance. - + - THANKED_BY_BOTH: both the initiator and the acceptor receive +1 when transitioning into this state, and -1 when leaving it. - REJECTED_BY_INITIATOR: only the acceptor gets -1 when transitioning into it (and +1 when leaving it). - REJECTED_BY_ACCEPTOR: only the initiator gets -1 when transitioning into it (and +1 when leaving it). - + Creation events are ignored because trade acceptances are never created with a terminal state. """ if created: @@ -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,12 +275,13 @@ 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): """ When a TradeAcceptance is deleted, adjust the reputation score for the affected user(s) by reversing any reputation changes previously applied. - + - If the deleted instance was in THANKED_BY_BOTH: subtract 1 from both parties. - If it was in REJECTED_BY_INITIATOR: add 1 to the acceptor. - If it was in REJECTED_BY_ACCEPTOR: add 1 to the initiator. @@ -263,6 +302,7 @@ def trade_acceptance_reputation_delete(sender, instance, **kwargs): reputation_score=F("reputation_score") + 1 ) + @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']) \ No newline at end of file + trade_offer.save(update_fields=["updated_at"]) diff --git a/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py b/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py index 2978445..56d92e1 100644 --- a/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py +++ b/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py @@ -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,24 +100,29 @@ 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 else: 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_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 + request = context.get("request") if request.get_host().startswith("localhost"): base_url = "http://{0}".format(request.get_host()) @@ -121,23 +130,23 @@ def render_trade_offer_png(context, offer, show_friend_code=False): base_url = "https://{0}".format(request.get_host()) 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) - return context \ No newline at end of file + return context diff --git a/src/pkmntrade_club/trades/tests.py b/src/pkmntrade_club/trades/tests.py index c188c16..a38de08 100644 --- a/src/pkmntrade_club/trades/tests.py +++ b/src/pkmntrade_club/trades/tests.py @@ -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 @@ -445,21 +488,27 @@ class TradeViewsTest(TestCase): state=TradeAcceptance.AcceptanceState.ACCEPTED, ) update_url = reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}) - + # First, try an invalid state update. response = self.client.post(update_url, {"state": "INVALID_STATE"}) self.assertEqual(response.status_code, 200) - + 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,14 +593,14 @@ 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) def test_cross_user_friend_code_manipulation(self): """Test that users cannot use other users' friend codes.""" self.client.login(username="user2", password="password2") - + # Try to create a trade offer using user1's friend code response = self.client.get( reverse("trade_offer_create"), @@ -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,18 +619,20 @@ 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 self.client.logout() - + for url in urls_to_test: response = self.client.get(url) self.assertRedirects( response, f"/accounts/login/?next={url}", - msg_prefix=f"URL {url} should require authentication" + msg_prefix=f"URL {url} should require authentication", ) @@ -588,19 +645,31 @@ class TradeOfferEdgeCasesTest(TestCase): self.friend_code = FriendCode.objects.create( friend_code="3333-4444-5555-6666", in_game_name="EdgeUser", user=self.user ) - + # 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) @@ -753,12 +831,12 @@ class TradeSearchTests(TestCase): """Test that closed trades don't appear in search results.""" self.trade_offer1.is_closed = True self.trade_offer1.save() - + response = self.client.post( 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) @@ -773,30 +851,50 @@ class TradeAcceptanceComplexTests(TestCase): self.acceptor = User.objects.create_user( username="acceptor", email="accept@example.com", password="password" ) - + 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 @@ -819,58 +917,67 @@ class TradeAcceptanceComplexTests(TestCase): def test_multiple_acceptances_quantity_limit(self): """Test that multiple acceptances cannot exceed the offer's quantity limit.""" self.client.login(username="acceptor", password="password") - + # 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): """Test complex state transition scenarios.""" self.client.login(username="acceptor", password="password") - + # Create an acceptance acceptance = TradeAcceptance.objects.create( trade_offer=self.trade_offer, @@ -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", ) - - diff --git a/src/pkmntrade_club/trades/urls.py b/src/pkmntrade_club/trades/urls.py index ce4cf7a..4b63a25 100644 --- a/src/pkmntrade_club/trades/urls.py +++ b/src/pkmntrade_club/trades/urls.py @@ -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("/", TradeOfferDetailView.as_view(), name="trade_offer_detail"), path(".png", TradeOfferPNGView.as_view(), name="trade_offer_png"), path("delete//", TradeOfferDeleteView.as_view(), name="trade_offer_delete"), - path("accept/", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"), - path("update//", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"), + path( + "accept/", + TradeAcceptanceCreateView.as_view(), + name="trade_acceptance_create", + ), + path( + "update//", + TradeAcceptanceUpdateView.as_view(), + name="trade_acceptance_update", + ), ] diff --git a/src/pkmntrade_club/trades/views.py b/src/pkmntrade_club/trades/views.py index 8a25d45..5eb5c93 100644 --- a/src/pkmntrade_club/trades/views.py +++ b/src/pkmntrade_club/trades/views.py @@ -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): @@ -143,8 +174,8 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states) 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." + request, + "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": @@ -215,7 +248,7 @@ class TradeOfferSearchView(ListView): qs = TradeOffer.objects.filter( is_closed=False, ) - + if self.request.user.is_authenticated: qs = qs.exclude(initiated_by__in=self.request.user.friend_codes.all()) @@ -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,35 +292,40 @@ 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. num_has = trade_offer.trade_offer_have_cards.count() num_wants = trade_offer.trade_offer_want_cards.count() @@ -315,14 +356,14 @@ class TradeOfferDetailView(DetailView): # compute the height from the width. image_width = base_width 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, @@ -333,7 +374,7 @@ class TradeOfferDetailView(DetailView): use_facebook=True, use_schemaorg=True, ) - + # Define terminal (closed) acceptance states based on our new system: terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, @@ -342,32 +383,41 @@ class TradeOfferDetailView(DetailView): TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, ] - + # For example, if you want to separate active from terminal acceptances: 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() # 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]) @@ -514,18 +586,22 @@ class TradeOfferPNGView(View): if trade_offer.image and not request.GET.get("debug"): trade_offer.image.open() 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,16 +713,18 @@ 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"), } return render(request, "trades/trade_offer_create.html", context) - + def _redirect_to_edit(self, request): query_params = request.POST.copy() query_params.pop("csrfmiddlewaretoken", None) @@ -641,10 +732,11 @@ 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) - + def _preview_offer(self, request): form = TradeOfferCreateForm(request.POST) form.fields["initiated_by"].queryset = request.user.friend_codes.all() @@ -656,15 +748,16 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View): "initiated_by": request.POST.get("initiated_by"), } 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")