refactor(db): update cursor rules and enhance deployment rollback script

- Standardized string formatting in cursor rules for consistency.
- Added a new rollback deployment script to facilitate blue-green deployment strategy.
- Removed outdated seed data files and introduced new rarity mappings for better data management.
- Improved model relationships and query optimizations in various views and admin configurations.
- Enhanced caching strategies across templates to improve performance and reduce load times, including jitter in cache settings for better performance.
- Refactored card-related views and templates to utilize new model fields and relationships.
This commit is contained in:
badblocks 2025-06-19 15:42:36 -07:00
parent 39a002e394
commit af2f48a491
No known key found for this signature in database
37 changed files with 2444 additions and 13565 deletions

View file

@ -10,9 +10,9 @@ Key Principles
Django/Python
- Use Djangos class-based views (CBVs) for more complex views; prefer function-based views (FBVs) for simpler logic.
- Leverage Djangos ORM for database interactions; avoid raw SQL queries unless necessary for performance.
- Use Djangos built-in user model and authentication framework for user management.
- Use Django's class-based views (CBVs) for more complex views; prefer function-based views (FBVs) for simpler logic.
- Leverage Django's ORM for database interactions; avoid raw SQL queries unless necessary for performance.
- Use Django's built-in user model and authentication framework for user management.
- Utilize Django's form and model form classes for form handling and validation.
- Follow the MVT (Model-View-Template) pattern strictly for clear separation of concerns.
- Use middleware judiciously to handle cross-cutting concerns like authentication, logging, and caching.
@ -25,20 +25,29 @@ Error Handling and Validation
- Customize error pages (e.g., 404, 500) to improve user experience and provide helpful information.
- Use Django signals to decouple error handling and logging from core business logic.
Development, Testing, and Operations
- Use Gatus for service health monitoring and status pages.
- Employ Locust for load testing to ensure application scalability and performance under stress.
- Utilize Playwright for end-to-end testing to simulate user interactions and validate application behavior from the user's perspective.
Dependencies
- Django
- Django REST Framework (for API development)
- Celery (for background tasks)
- Redis (for caching and task queues)
- PostgreSQL or MySQL (preferred databases for production)
- Granian / Gunicorn (for serving the application)
- Whitenoise (for serving static files)
- Tailwind CSS for the frontend
- Django Crispy Forms for the frontend
- Django Allauth for authentication
- Django DaisyUI for the frontend
- Django El Pagination for the frontend
- Django Crispy Forms for the frontend
- Crispy Tailwind for Tailwind-compatible Crispy Forms
- Django DaisyUI for the admin frontend
- Django Widget Tweaks for the frontend
- Django Crispy Tailwind for the frontend
- django-debug-toolbar for debugging
- django-health-check for application health monitoring
- django-parler for multilingual support
Django-Specific Guidelines
@ -46,17 +55,17 @@ Django-Specific Guidelines
- Keep business logic in models and forms; keep views light and focused on request handling.
- Use Django's URL dispatcher (urls.py) to define clear and RESTful URL patterns.
- Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention).
- Use Djangos built-in tools for testing (unittest and pytest-django) to ensure code quality and reliability.
- Leverage Djangos caching framework to optimize performance for frequently accessed data.
- Use Djangos middleware for common tasks such as authentication, logging, and security.
- Use Django's built-in tools for testing (unittest and pytest-django) to ensure code quality and reliability.
- Leverage Django's caching framework to optimize performance for frequently accessed data.
- Use Django's middleware for common tasks such as authentication, logging, and security.
Performance Optimization
- Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching.
- Use Djangos cache framework with backend support (e.g., Redis or Memcached) to reduce database load.
- Use Django's cache framework with backend support (e.g., Redis or Memcached) to reduce database load.
- Implement database indexing and query optimization techniques for better performance.
- Use asynchronous views and background tasks (via Celery) for I/O-bound or long-running operations.
- Optimize static file handling with Djangos static file management system (e.g., WhiteNoise or CDN integration).
- Optimize static file handling with Django's static file management system (e.g., WhiteNoise or CDN integration).
Key Conventions

98
scripts/rollback-deployment.sh Executable file
View file

@ -0,0 +1,98 @@
#!/bin/bash
set -euo pipefail
# Rollback deployment by swapping colors
# Usage: ./rollback-deployment.sh
# Source common functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common-lib.sh"
validate_deployment_env
# Get current state
STATE=$(get_deployment_state)
echo "🔍 Current deployment state: $STATE"
if [ "$STATE" = "none" ]; then
echo "❌ No active deployment found to rollback"
exit 1
fi
if [ "$STATE" != "both" ]; then
echo "❌ Rollback requires both colors to be running"
echo " Current state: only $STATE is running"
echo ""
echo " To perform a manual rollback:"
echo " 1. Find the previous release in ${RELEASES_PATH}/"
echo " 2. Update the symlink: ln -sfn <previous-release> ${CURRENT_LINK_PATH}"
echo " 3. Redeploy using: ./deploy-blue-green.sh"
exit 1
fi
# Both colors running - determine which is newer
CURRENT_COLOR=$(get_current_color)
ROLLBACK_COLOR=$(switch_color "$CURRENT_COLOR")
# Get image tags for both deployments
CURRENT_IMAGE=$(get_deployment_image_tag "$CURRENT_COLOR")
ROLLBACK_IMAGE=$(get_deployment_image_tag "$ROLLBACK_COLOR")
echo "🔄 Rolling back from $CURRENT_COLOR (newer) to $ROLLBACK_COLOR (older)"
echo " Current image: ${CURRENT_IMAGE}"
echo " Rollback image: ${ROLLBACK_IMAGE}"
# Verify the rollback image exists
if ! run_on_target "docker images -q 'badbl0cks/pkmntrade-club:${ROLLBACK_IMAGE}' | grep -q ."; then
echo "❌ Rollback image not found: badbl0cks/pkmntrade-club:${ROLLBACK_IMAGE}"
echo " The image may have been pruned. Cannot perform rollback."
exit 1
fi
# Verify rollback color is healthy
ROLLBACK_PROJECT=$(get_project_name "$ROLLBACK_COLOR")
HEALTHY_COUNT=$(count_containers "label=com.docker.compose.project=${ROLLBACK_PROJECT} --filter status=running")
if [ "$HEALTHY_COUNT" -eq 0 ]; then
echo "❌ No healthy $ROLLBACK_COLOR containers found, cannot perform rollback"
exit 1
fi
echo "✅ Found $HEALTHY_COUNT healthy $ROLLBACK_COLOR containers"
# Store the release path of the current color before we change the symlink
CURRENT_RELEASE_PATH=$(readlink -f "${CURRENT_LINK_PATH}")
echo "🔄 Performing rollback..."
echo "🔎 Finding release for rollback color ($ROLLBACK_COLOR)..."
# Find the second newest release directory. This is assumed to be the rollback target.
ROLLBACK_RELEASE_PATH=$(ls -dt "${RELEASES_PATH}"/*/ | sed -n '2p' | tr -d '\n')
if [ -z "$ROLLBACK_RELEASE_PATH" ]; then
echo "❌ Could not find a previous release to rollback to in ${RELEASES_PATH}"
exit 1
fi
echo " Found rollback release: ${ROLLBACK_RELEASE_PATH}"
echo "🔗 Switching 'current' symlink to point to rollback release..."
ln -sfn "${ROLLBACK_RELEASE_PATH}" "${CURRENT_LINK_PATH}"
# Refresh gatekeepers to switch traffic to the rollback color
refresh_gatekeepers
wait_with_countdown 10 "⏳ Waiting for traffic to stabilize on $ROLLBACK_COLOR..."
# Stop and clean up current color containers, using the correct release path
export CLEANUP_RELEASE_PATH="${CURRENT_RELEASE_PATH}"
cleanup_color_containers "$CURRENT_COLOR"
unset CLEANUP_RELEASE_PATH
# Refresh gatekeepers again to remove routes to the old color
refresh_gatekeepers
echo "✅ Rollback completed!"
echo " Active deployment: $ROLLBACK_COLOR"
echo ""
echo "📌 Note: The next deployment will now deploy as $CURRENT_COLOR"

View file

@ -1,101 +0,0 @@
[
{
"model": "cards.deck",
"pk": 1,
"fields": {
"name": "Promo-A",
"cardset": "Promo-A",
"hex_color": "#1070EB",
"created_at": "2025-02-16T07:55:34.988Z",
"updated_at": "2025-02-16T07:55:34.988Z"
}
},
{
"model": "cards.deck",
"pk": 2,
"fields": {
"name": "Genetic Apex: Mewtwo",
"cardset": "A1",
"hex_color": "#8040E0",
"created_at": "2025-02-16T07:54:57.445Z",
"updated_at": "2025-02-16T07:54:57.445Z"
}
},
{
"model": "cards.deck",
"pk": 3,
"fields": {
"name": "Genetic Apex: Charizard",
"cardset": "A1",
"hex_color": "#E00202",
"created_at": "2025-02-16T07:54:52.381Z",
"updated_at": "2025-02-16T07:54:52.381Z"
}
},
{
"model": "cards.deck",
"pk": 4,
"fields": {
"name": "Genetic Apex: Pikachu",
"cardset": "A1",
"hex_color": "#FCF326",
"created_at": "2025-02-16T07:55:05.097Z",
"updated_at": "2025-02-16T07:55:05.097Z"
}
},
{
"model": "cards.deck",
"pk": 5,
"fields": {
"name": "Mythical Island",
"cardset": "A1a",
"hex_color": "#20AA80",
"created_at": "2025-02-16T07:55:11.916Z",
"updated_at": "2025-02-16T07:55:11.916Z"
}
},
{
"model": "cards.deck",
"pk": 6,
"fields": {
"name": "Space-Time Smackdown: Dialga",
"cardset": "A2",
"hex_color": "#302FD9",
"created_at": "2025-02-16T07:55:17.582Z",
"updated_at": "2025-02-16T07:55:17.582Z"
}
},
{
"model": "cards.deck",
"pk": 7,
"fields": {
"name": "Space-Time Smackdown: Palkia",
"cardset": "A2",
"hex_color": "#CF36E0",
"created_at": "2025-02-16T07:55:27.503Z",
"updated_at": "2025-02-16T07:55:27.503Z"
}
},
{
"model": "cards.deck",
"pk": 8,
"fields": {
"name": "Triumphant Light",
"cardset": "A2a",
"hex_color": "#DF8D2C",
"created_at": "2025-03-26T12:25:17.706Z",
"updated_at": "2025-03-26T12:25:17.706Z"
}
},
{
"model": "cards.deck",
"pk": 9,
"fields": {
"name": "Shining Revelry",
"cardset": "A2b",
"hex_color": "#D7FDFC",
"created_at": "2025-03-26T12:25:17.706Z",
"updated_at": "2025-03-26T12:25:17.706Z"
}
}
]

View file

@ -0,0 +1 @@
[{"model": "cards.raritymapping", "pk": 1, "fields": {"original_name": "Common", "mapped_name": "Common", "icon": "🔷", "level": 1, "created_at": "2025-06-15T03:51:40.147Z", "updated_at": "2025-06-15T03:51:40.147Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 2, "fields": {"original_name": "Uncommon", "mapped_name": "Uncommon", "icon": "🔷🔷", "level": 2, "created_at": "2025-06-15T03:53:12.209Z", "updated_at": "2025-06-15T03:53:12.209Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 3, "fields": {"original_name": "Rare", "mapped_name": "Rare", "icon": "🔷🔷🔷", "level": 3, "created_at": "2025-06-15T03:53:31.267Z", "updated_at": "2025-06-15T03:53:31.267Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 4, "fields": {"original_name": "Rare EX", "mapped_name": "Double Rare", "icon": "🔷🔷🔷🔷", "level": 4, "created_at": "2025-06-15T03:53:54.712Z", "updated_at": "2025-06-15T03:53:54.712Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 5, "fields": {"original_name": "Full Art", "mapped_name": "Art Rare", "icon": "⭐️", "level": 5, "created_at": "2025-06-15T03:54:26.671Z", "updated_at": "2025-06-15T03:54:26.671Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 6, "fields": {"original_name": "Full Art EX/Support", "mapped_name": "Super Rare", "icon": "⭐️⭐️", "level": 6, "created_at": "2025-06-15T03:54:58.835Z", "updated_at": "2025-06-15T03:54:58.835Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 7, "fields": {"original_name": "Immersive", "mapped_name": "Immersive Rare", "icon": "⭐️⭐️⭐️", "level": 7, "created_at": "2025-06-15T03:55:25.941Z", "updated_at": "2025-06-15T03:59:14.725Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 8, "fields": {"original_name": "Gold Crown", "mapped_name": "Ultra Rare", "icon": "👑", "level": 10, "created_at": "2025-06-15T03:56:05.786Z", "updated_at": "2025-06-15T03:56:32.728Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 9, "fields": {"original_name": "One shiny star", "mapped_name": "Shiny Rare", "icon": "✨", "level": 8, "created_at": "2025-06-15T03:57:03.342Z", "updated_at": "2025-06-15T03:59:04.136Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 10, "fields": {"original_name": "Two shiny stars", "mapped_name": "Shiny Super Rare", "icon": "✨✨", "level": 9, "created_at": "2025-06-15T03:57:33.360Z", "updated_at": "2025-06-15T03:57:51.004Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 11, "fields": {"original_name": "Two shiny star", "mapped_name": "Shiny Super Rare", "icon": "✨✨", "level": 9, "created_at": "2025-06-15T03:58:10.204Z", "updated_at": "2025-06-15T03:58:10.204Z", "deleted_at": null}}]

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .forms import CustomUserCreationForm, CustomUserChangeForm
from .models import CustomUser
from .forms import CustomUserChangeForm, CustomUserCreationForm
from .models import CustomUser, FriendCode
class CustomUserAdmin(UserAdmin):
@ -27,3 +27,11 @@ class CustomUserAdmin(UserAdmin):
admin.site.register(CustomUser, CustomUserAdmin)
@admin.register(FriendCode)
class FriendCodeAdmin(admin.ModelAdmin):
list_display = ("friend_code", "in_game_name", "user")
search_fields = ("friend_code", "in_game_name", "user__username", "user__email")
list_select_related = ("user",)
autocomplete_fields = ("user",)

View file

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

View file

@ -1,22 +1,23 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect, get_object_or_404, render
from django.core.exceptions import PermissionDenied
from django.db.models import BooleanField, Case, Q, Value, When
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.http import urlencode
from django.views.generic import (
CreateView,
DeleteView,
View,
TemplateView,
UpdateView,
View,
)
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
from django.core.exceptions import PermissionDenied
from pkmntrade_club.trades.mixins import FriendCodeRequiredMixin
from pkmntrade_club.accounts.models import FriendCode
from pkmntrade_club.common.mixins import ReusablePaginationMixin
from django.urls import reverse
from django.utils.http import urlencode
from pkmntrade_club.trades.mixins import FriendCodeRequiredMixin
from pkmntrade_club.trades.models import TradeAcceptance, TradeOffer
class AddFriendCodeView(LoginRequiredMixin, CreateView):
@ -204,8 +205,6 @@ class DashboardView(
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,
@ -213,10 +212,25 @@ class DashboardView(
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
]
involved = TradeAcceptance.objects.filter(
involved = (
TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code)
| Q(accepted_by=selected_friend_code)
).order_by("-updated_at")
)
.select_related(
"trade_offer__initiated_by__user",
"accepted_by__user",
"requested_card__rarity",
"requested_card__cardset",
"offered_card__rarity",
"offered_card__cardset",
)
.prefetch_related(
"requested_card__translations",
"offered_card__translations",
)
.order_by("-updated_at")
)
return involved.exclude(state__in=terminal_states)
def get_trade_acceptances_waiting_paginated(self, page_param):
@ -271,29 +285,41 @@ class DashboardView(
return {"object_list": object_list, "page_obj": pagination_context}
def get_closed_acceptances_paginated(self, page_param):
from django.db.models import Q
selected_friend_code = self.get_selected_friend_code()
terminal_success_states = [
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
]
acceptance_qs = TradeAcceptance.objects.filter(
acceptance_qs = (
TradeAcceptance.objects.filter(
Q(trade_offer__initiated_by=selected_friend_code)
| Q(accepted_by=selected_friend_code),
state__in=terminal_success_states,
).order_by("-updated_at")
)
.select_related(
"trade_offer__initiated_by__user",
"accepted_by__user",
"requested_card__rarity",
"requested_card__cardset",
"offered_card__rarity",
"offered_card__cardset",
)
.prefetch_related(
"requested_card__translations",
"offered_card__translations",
)
.order_by("-updated_at")
)
object_list, pagination_context = self.paginate_data(
acceptance_qs, int(page_param)
)
return {"object_list": object_list, "page_obj": pagination_context}
def get_rejected_by_me_paginated(self, page_param):
from django.db.models import Q
selected_friend_code = self.get_selected_friend_code()
rejection = TradeAcceptance.objects.filter(
rejection = (
TradeAcceptance.objects.filter(
Q(
trade_offer__initiated_by=selected_friend_code,
state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
@ -302,15 +328,28 @@ class DashboardView(
accepted_by=selected_friend_code,
state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
)
).order_by("-updated_at")
)
.select_related(
"trade_offer__initiated_by__user",
"accepted_by__user",
"requested_card__rarity",
"requested_card__cardset",
"offered_card__rarity",
"offered_card__cardset",
)
.prefetch_related(
"requested_card__translations",
"offered_card__translations",
)
.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(
rejection = (
TradeAcceptance.objects.filter(
Q(
trade_offer__initiated_by=selected_friend_code,
state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
@ -319,7 +358,21 @@ class DashboardView(
accepted_by=selected_friend_code,
state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
)
).order_by("-updated_at")
)
.select_related(
"trade_offer__initiated_by__user",
"accepted_by__user",
"requested_card__rarity",
"requested_card__cardset",
"offered_card__rarity",
"offered_card__cardset",
)
.prefetch_related(
"requested_card__translations",
"offered_card__translations",
)
.order_by("-updated_at")
)
object_list, pagination_context = self.paginate_data(rejection, int(page_param))
return {"object_list": object_list, "page_obj": pagination_context}

View file

@ -1,34 +1,68 @@
from django.contrib import admin, messages
from django.urls import path
from django.shortcuts import render
from django.http import HttpResponseRedirect
from parler.admin import TranslatableAdmin
from .models import (
CardSet_New, Pack_New, Energy_New, Attack_New, Ability_New,
Rarity_New, CardType_New, Card_New, AttackCost_New, RarityMapping
)
import hashlib
import io
import json
import os
import re # For parsing set name and ID
import zipfile
from dataclasses import dataclass
import requests
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from django.contrib import admin, messages
from django.contrib.admin.filters import RelatedFieldListFilter
from django.db import transaction
import hashlib
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.urls import path, reverse
from parler.admin import TranslatableAdmin
from .models import (
Ability,
Attack,
AttackCost,
Card,
CardSet,
CardType,
Energy,
Pack,
Rarity,
RarityMapping,
)
@dataclass
class ImportResult:
"""A data class to hold the results of the card import process."""
newly_imported_count: int = 0
updated_count: int = 0
skipped_count: int = 0
files_processed_count: int = 0
has_error: bool = False
message: str = ""
class PrefetchedSortedRelatedFieldListFilter(RelatedFieldListFilter):
def field_choices(self, field, request, model_admin):
related_manager = field.related_model._default_manager
queryset = related_manager.all().prefetch_related("translations")
return [(obj.pk, str(obj)) for obj in queryset]
def parse_set_details(set_string):
match = re.match(r'^(.*?)\s*\(([A-Za-z0-9]+)\)$', set_string)
match = re.match(r"^(.*?)\s*\(([A-Za-z0-9]+)\)$", set_string)
if match:
name = match.group(1).strip()
set_id = match.group(2)
return name, set_id
match = re.match(r'^Promo-(.*?)$', set_string)
match = re.match(r"^Promo-(.*?)$", set_string)
if match:
name = set_string
set_id = 'P-' + match.group(1)
set_id = "P-" + match.group(1)
return name, set_id
return set_string, None
def calculate_card_checksum(card_data):
"""
Calculates a SHA256 checksum for a card's data.
@ -38,55 +72,67 @@ def calculate_card_checksum(card_data):
# Select and normalize fields that define the card's state
# Order of keys in `data_to_hash` and sorting of lists are important for consistency
data_to_hash = {
'id': card_data.get('id'),
'name': card_data.get('name'),
'type': card_data.get('type'),
'subtype': card_data.get('subtype'),
'rarity': card_data.get('rarity'), # Rarity name from JSON
'health': card_data.get('health'),
'evolvesFrom': card_data.get('evolvesFrom'),
'retreatCost': card_data.get('retreatCost'),
'element': card_data.get('element'), # Element name from JSON
'weakness': card_data.get('weakness'), # Weakness name from JSON
'pack': card_data.get('pack'), # Pack name from JSON
"id": card_data.get("id"),
"name": card_data.get("name"),
"type": card_data.get("type"),
"subtype": card_data.get("subtype"),
"rarity": card_data.get("rarity"), # Rarity name from JSON
"health": card_data.get("health"),
"evolvesFrom": card_data.get("evolvesFrom"),
"retreatCost": card_data.get("retreatCost"),
"element": card_data.get("element"), # Element name from JSON
"weakness": card_data.get("weakness"), # Weakness name from JSON
"pack": card_data.get("pack"), # Pack name from JSON
# For abilities and attacks, ensure stable order and content
'abilities': sorted([
{'name': a.get('name'), 'effect': a.get('effect')}
for a in card_data.get('abilities', []) if a and a.get('name') # ensure ability itself and name exist
], key=lambda x: x['name'] if x and x.get('name') else ''),
'attacks': sorted([
"abilities": sorted(
[
{"name": a.get("name"), "effect": a.get("effect")}
for a in card_data.get("abilities", [])
if a and a.get("name") # ensure ability itself and name exist
],
key=lambda x: x["name"] if x and x.get("name") else "",
),
"attacks": sorted(
[
{
'name': atk.get('name'),
'effect': atk.get('effect', ''),
'damage': atk.get('damage', ''),
'cost': sorted(atk.get('cost', []) if atk.get('cost') else []) # Sort energy costs
"name": atk.get("name"),
"effect": atk.get("effect", ""),
"damage": atk.get("damage", ""),
"cost": sorted(
atk.get("cost", []) if atk.get("cost") else []
), # Sort energy costs
}
for atk in card_data.get('attacks', []) if atk and atk.get('name') # ensure attack itself and name exist
], key=lambda x: x['name'] if x and x.get('name') else ''),
for atk in card_data.get("attacks", [])
if atk and atk.get("name") # ensure attack itself and name exist
],
key=lambda x: x["name"] if x and x.get("name") else "",
),
}
# Serialize to a canonical JSON string (sort keys, no indent, compact)
canonical_json = json.dumps(data_to_hash, sort_keys=True, separators=(',', ':'))
canonical_json = json.dumps(data_to_hash, sort_keys=True, separators=(",", ":"))
sha256_hash = hashlib.sha256(canonical_json.encode('utf-8')).hexdigest()
sha256_hash = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()
return sha256_hash
def _get_or_create_card_type(card_data):
card_type_obj, created = CardType_New.objects.language('en').get_or_create(
translations__name=card_data['type'],
translations__subtype=card_data.get('subtype', ''),
defaults={'name': card_data['type'], 'subtype': card_data.get('subtype', '')}
card_type_obj, created = CardType.objects.language("en").get_or_create(
translations__name=card_data["type"],
translations__subtype=card_data.get("subtype", ""),
defaults={"name": card_data["type"], "subtype": card_data.get("subtype", "")},
)
if not created:
current_subtype = card_data.get('subtype')
current_subtype = card_data.get("subtype")
if current_subtype is not None and card_type_obj.subtype != current_subtype:
card_type_obj.set_current_language('en')
card_type_obj.set_current_language("en")
card_type_obj.subtype = current_subtype
card_type_obj.save()
return card_type_obj
def _get_or_create_rarity(card_data, rarity_mappings_dict):
original_rarity_name_from_json = card_data.get('rarity')
original_rarity_name_from_json = card_data.get("rarity")
# Attempt to find a mapping for the original rarity name
mapping = rarity_mappings_dict.get(original_rarity_name_from_json)
@ -99,24 +145,28 @@ def _get_or_create_rarity(card_data, rarity_mappings_dict):
elif original_rarity_name_from_json:
# No mapping found, use the original name from JSON, default icon/level
target_rarity_name = original_rarity_name_from_json
target_icon = 'x' # Default icon if no mapping
target_icon = "x" # Default icon if no mapping
target_level = 0 # Default level if no mapping
else:
# Rarity is None or empty in JSON, treat as 'Promo'
target_rarity_name = 'Promo'
target_rarity_name = "Promo"
# Check if 'Promo' itself has a mapping
promo_mapping = rarity_mappings_dict.get('Promo')
promo_mapping = rarity_mappings_dict.get("Promo")
if promo_mapping:
target_icon = promo_mapping.icon
target_level = promo_mapping.level
else:
target_icon = 'x' # Default icon for 'Promo' if no mapping for 'Promo'
target_icon = "x" # Default icon for 'Promo' if no mapping for 'Promo'
target_level = 0 # Default level for 'Promo' if no mapping for 'Promo'
# Get or create the Rarity_New object using the (potentially mapped) values
rarity_obj, created = Rarity_New.objects.language('en').get_or_create(
# Get or create the Rarity object using the (potentially mapped) values
rarity_obj, created = Rarity.objects.language("en").get_or_create(
translations__name=target_rarity_name,
defaults={'name': target_rarity_name, 'icon': target_icon, 'level': target_level}
defaults={
"name": target_rarity_name,
"icon": target_icon,
"level": target_level,
},
)
# If the rarity already existed, check if its icon or level needs updating based on the mapping
@ -134,71 +184,74 @@ def _get_or_create_rarity(card_data, rarity_mappings_dict):
return rarity_obj
def _get_or_create_energy(energy_name):
if not energy_name:
return None
energy_obj, _ = Energy_New.objects.language('en').get_or_create(
translations__name=energy_name,
defaults={'name': energy_name}
energy_obj, _ = Energy.objects.language("en").get_or_create(
translations__name=energy_name, defaults={"name": energy_name}
)
return energy_obj
def _update_card_packs(card_obj, card_data, card_set):
card_obj.packs.clear()
pack_name_from_json = card_data.get('pack')
pack_name_from_json = card_data.get("pack")
if pack_name_from_json:
card_set.set_current_language('en')
card_set.set_current_language("en")
pack_full_name = f"{card_set.name}: {pack_name_from_json}"
pack_obj, _ = Pack_New.objects.language('en').get_or_create(
pack_obj, _ = Pack.objects.language("en").get_or_create(
translations__name=pack_name_from_json,
cardset=card_set,
defaults={
'name': pack_name_from_json,
'full_name': pack_full_name,
'hex_color': '#FFFFFF'
}
"name": pack_name_from_json,
"full_name": pack_full_name,
"hex_color": "#FFFFFF",
},
)
card_obj.packs.add(pack_obj)
else:
all_packs_in_set = Pack_New.objects.filter(cardset=card_set)
all_packs_in_set = Pack.objects.filter(cardset=card_set)
if all_packs_in_set.exists():
card_obj.packs.add(*all_packs_in_set)
def _update_card_abilities(card_obj, card_data):
card_obj.abilities.clear()
for ability_data in card_data.get('abilities', []):
ability_obj, created = Ability_New.objects.language('en').get_or_create(
translations__name=ability_data['name'],
defaults={'name': ability_data['name'], 'effect': ability_data['effect']}
for ability_data in card_data.get("abilities", []):
ability_obj, created = Ability.objects.language("en").get_or_create(
translations__name=ability_data["name"],
defaults={"name": ability_data["name"], "effect": ability_data["effect"]},
)
if not created and ability_obj.effect != ability_data['effect']:
ability_obj.set_current_language('en')
ability_obj.effect = ability_data['effect']
if not created and ability_obj.effect != ability_data["effect"]:
ability_obj.set_current_language("en")
ability_obj.effect = ability_data["effect"]
ability_obj.save()
card_obj.abilities.add(ability_obj)
def _update_card_attacks_and_costs(card_obj, card_data):
card_obj.attacks.clear()
for attack_data in card_data.get('attacks', []):
attack_obj, created = Attack_New.objects.language('en').get_or_create(
translations__name=attack_data['name'],
for attack_data in card_data.get("attacks", []):
attack_obj, created = Attack.objects.language("en").get_or_create(
translations__name=attack_data["name"],
defaults={
'name': attack_data['name'],
'effect': attack_data.get('effect', ''),
'damage': attack_data.get('damage', '')
}
"name": attack_data["name"],
"effect": attack_data.get("effect", ""),
"damage": attack_data.get("damage", ""),
},
)
needs_save = False
if not created:
json_effect = attack_data.get('effect', '')
json_effect = attack_data.get("effect", "")
if attack_obj.effect != json_effect:
attack_obj.set_current_language('en')
attack_obj.set_current_language("en")
attack_obj.effect = json_effect
needs_save = True
json_damage = attack_data.get('damage', '')
json_damage = attack_data.get("damage", "")
if attack_obj.damage != json_damage:
attack_obj.damage = json_damage
needs_save = True
@ -210,63 +263,65 @@ def _update_card_attacks_and_costs(card_obj, card_data):
attack_obj.energy_cost.clear()
energy_counts = {}
for cost_energy_name in attack_data.get('cost', []):
for cost_energy_name in attack_data.get("cost", []):
energy_counts[cost_energy_name] = energy_counts.get(cost_energy_name, 0) + 1
for energy_name, quantity in energy_counts.items():
energy_obj = _get_or_create_energy(energy_name)
if energy_obj:
AttackCost_New.objects.update_or_create(
AttackCost.objects.update_or_create(
attack=attack_obj,
energy=energy_obj,
defaults={'quantity': quantity}
defaults={"quantity": quantity},
)
def _process_single_card_data(card_data, card_set, stats_accumulator, error_tracking, rarity_mappings_dict):
def _process_single_card_data(
card_data, card_set, stats_accumulator, error_tracking, rarity_mappings_dict
):
"""
Processes a single card's data from the JSON.
Updates stats_accumulator with newly_imported_count, updated_count, or skipped_count.
error_tracking is a dict {'file_name': ..., 'card_id': ...} for precise error reporting.
"""
card_id = card_data['id']
card_id = card_data["id"]
incoming_checksum = calculate_card_checksum(card_data)
error_tracking['card_id'] = card_id
error_tracking["card_id"] = card_id
try:
existing_card = Card_New.objects.language('en').get(id=card_id)
existing_card = Card.objects.language("en").get(id=card_id)
if existing_card.checksum == incoming_checksum:
stats_accumulator['skipped_count'] += 1
stats_accumulator["skipped_count"] += 1
return
except Card_New.DoesNotExist:
except Card.DoesNotExist:
existing_card = None
card_type_obj = _get_or_create_card_type(card_data)
rarity_obj = _get_or_create_rarity(card_data, rarity_mappings_dict)
pkmn_type_obj = _get_or_create_energy(card_data.get('element'))
weakness_type_obj = _get_or_create_energy(card_data.get('weakness'))
pkmn_type_obj = _get_or_create_energy(card_data.get("element"))
weakness_type_obj = _get_or_create_energy(card_data.get("weakness"))
card_defaults = {
'name': card_data['name'],
'cardset': card_set,
'card_type': card_type_obj,
'rarity': rarity_obj,
'health': card_data.get('health'),
'evolves_from_name': card_data.get('evolvesFrom'),
'retreat_cost': card_data.get('retreatCost'),
'pkmn_type': pkmn_type_obj,
'weakness_type': weakness_type_obj,
'checksum': incoming_checksum
"name": card_data["name"],
"cardset": card_set,
"card_type": card_type_obj,
"rarity": rarity_obj,
"health": card_data.get("health"),
"evolves_from_name": card_data.get("evolvesFrom"),
"retreat_cost": card_data.get("retreatCost"),
"pkmn_type": pkmn_type_obj,
"weakness_type": weakness_type_obj,
"checksum": incoming_checksum,
}
card_obj, card_created = Card_New.objects.language('en').update_or_create(
id=card_id,
defaults=card_defaults
card_obj, card_created = Card.objects.language("en").update_or_create(
id=card_id, defaults=card_defaults
)
if card_created:
stats_accumulator['newly_imported_count'] += 1
stats_accumulator["newly_imported_count"] += 1
elif existing_card:
stats_accumulator['updated_count'] +=1
stats_accumulator["updated_count"] += 1
# If not created and checksum differs, it's an update, which is handled by updated_count.
# update_or_create takes care of setting the new checksum via defaults.
@ -282,200 +337,400 @@ def _process_single_card_data(card_data, card_set, stats_accumulator, error_trac
# However, for skipping based on *incoming JSON data*, this approach is correct.
# The `update_or_create` will ensure the `checksum` field (which is part of `card_defaults`) is saved.
def perform_card_import_logic():
def _fetch_card_data_from_github_zip():
"""
Downloads and extracts card data from the GitHub repository zip archive.
Yields:
tuple: A tuple containing the file name (str) and its parsed JSON data (list).
Raises:
requests.exceptions.RequestException: If the download fails.
"""
owner = "hugoburguete"
repo = "pokemon-tcg-pocket-card-database"
branch = "main"
zip_url = f"https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip"
print(f"Downloading card data from {zip_url}...")
response = requests.get(zip_url)
response.raise_for_status() # Will raise an exception for 4xx/5xx status
print("Download complete.")
zip_file = zipfile.ZipFile(io.BytesIO(response.content))
# The root folder in the zip is usually `repo-name-branch-name`
path_prefix = f"{repo}-{branch}/cards/en/"
json_file_paths = sorted(
[
name
for name in zip_file.namelist()
if name.startswith(path_prefix) and name.endswith(".json")
]
)
print(f"Found {len(json_file_paths)} JSON files in the archive.")
for file_path in json_file_paths:
file_name = os.path.basename(file_path)
with zip_file.open(file_path) as f:
data = json.load(f)
yield file_name, data
def _fetch_card_data_from_local_files():
"""
Reads card data from local JSON files for debugging purposes.
Yields:
tuple: A tuple containing the file name (str) and its parsed JSON data (list).
"""
base_path = os.path.join(
settings.BASE_DIR,
"REMOTE_GIT_REPOS",
"pokemon-tcg-pocket-card-database",
"cards",
"en",
)
print(f"DEBUG MODE: Reading card data from local path: {base_path}")
if not os.path.isdir(base_path):
print(f"Source directory not found: {base_path}. Import halted.")
return # An empty generator
json_files = sorted([f for f in os.listdir(base_path) if f.endswith(".json")])
print(f"Found {len(json_files)} local JSON files to process.")
for file_name in json_files:
file_path = os.path.join(base_path, file_name)
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
yield file_name, data
def perform_card_import_logic() -> ImportResult:
"""
Main importer logic. Iterates through JSON files and processes them.
In DEBUG mode, it reads from local files. Otherwise, fetches from a remote GitHub repo.
Halts and rolls back on any error.
"""
print("Card import process started.")
base_path = os.path.join(settings.BASE_DIR, 'REMOTE_GIT_REPOS', 'pokemon-tcg-pocket-card-database', 'cards', 'en')
stats = {'newly_imported_count': 0, 'updated_count': 0, 'skipped_count': 0, 'files_processed_count': 0}
error_tracking = {'file_name': "N/A", 'card_id': "N/A"}
result = ImportResult()
error_tracking = {"file_name": "N/A", "card_id": "N/A"}
# Fetch all rarity mappings once
rarity_mappings = RarityMapping.objects.all()
rarity_mappings_dict = {mapping.original_name: mapping for mapping in rarity_mappings}
rarity_mappings_dict = {
mapping.original_name: mapping for mapping in rarity_mappings
}
print(f"Loaded {len(rarity_mappings_dict)} rarity mappings.")
if not os.path.isdir(base_path):
message = f"Source directory not found: {base_path}. Import halted."
print(message)
return 0, 0, True, message, 0, 0
json_files = [f for f in os.listdir(base_path) if f.endswith('.json')]
json_files.sort()
if not json_files:
message = "No JSON files found in the source directory to import."
print(message)
return 0, 0, False, message, 0, 0
print(f"Found {len(json_files)} JSON files to process.")
try:
if settings.DEBUG:
card_data_iterator = _fetch_card_data_from_local_files()
source_message = "local files"
else:
# Fetch card data from the GitHub zip archive
card_data_iterator = _fetch_card_data_from_github_zip()
source_message = "the GitHub archive"
all_files_data = list(card_data_iterator)
total_files = len(all_files_data)
if not all_files_data:
result.message = f"No JSON files found in {source_message} to import."
print(result.message)
return result
print(f"Found {total_files} JSON files to process from {source_message}.")
with transaction.atomic():
for idx, file_name in enumerate(json_files):
error_tracking['file_name'] = file_name
error_tracking['card_id'] = "N/A"
file_path = os.path.join(base_path, file_name)
stats_accumulator = {
"newly_imported_count": 0,
"updated_count": 0,
"skipped_count": 0,
}
print(f"Processing file: {file_name} ({idx + 1}/{len(json_files)})")
for idx, (file_name, data) in enumerate(all_files_data):
error_tracking["file_name"] = file_name
error_tracking["card_id"] = "N/A"
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
print(f"Processing file: {file_name} ({idx + 1}/{total_files})")
if not data:
raise ValueError(f"JSON file {file_name} is empty or contains no data.")
raise ValueError(
f"JSON file {file_name} is empty or contains no data."
)
stats['files_processed_count'] += 1
result.files_processed_count += 1
first_card_data = data[0]
set_info_str = first_card_data.get('set')
set_info_str = first_card_data.get("set")
if not set_info_str:
raise ValueError(f"Could not determine set information from first card in {file_name}.")
raise ValueError(
f"Could not determine set information from first card in {file_name}."
)
parsed_set_name, parsed_set_id = parse_set_details(set_info_str)
if not parsed_set_id:
raise ValueError(f"Could not parse set ID from '{set_info_str}' in {file_name}.")
raise ValueError(
f"Could not parse set ID from '{set_info_str}' in {file_name}."
)
card_set_defaults = {
'name': parsed_set_name,
'file_name': file_name
}
card_set, _ = CardSet_New.objects.language('en').update_or_create(
id=parsed_set_id,
defaults=card_set_defaults
card_set_defaults = {"name": parsed_set_name, "file_name": file_name}
card_set, _ = CardSet.objects.language("en").update_or_create(
id=parsed_set_id, defaults=card_set_defaults
)
for card_data_item in data:
print("Processing card: ", card_data_item['id'])
_process_single_card_data(card_data_item, card_set, stats, error_tracking, rarity_mappings_dict)
print("Processing card: ", card_data_item["id"])
_process_single_card_data(
card_data_item,
card_set,
stats_accumulator,
error_tracking,
rarity_mappings_dict,
)
print(f"Finished processing file: {file_name}")
success_message = (
f"Import completed successfully. Processed {stats['files_processed_count']} files. "
f"Imported {stats['newly_imported_count']} new cards. "
f"Updated {stats['updated_count']} existing cards. "
f"Skipped {stats['skipped_count']} unchanged cards."
result.newly_imported_count = stats_accumulator["newly_imported_count"]
result.updated_count = stats_accumulator["updated_count"]
result.skipped_count = stats_accumulator["skipped_count"]
result.message = (
f"Import completed successfully. Processed {result.files_processed_count} files. "
f"Imported {result.newly_imported_count} new cards. "
f"Updated {result.updated_count} existing cards. "
f"Skipped {result.skipped_count} unchanged cards."
)
print("Committing transaction.")
transaction.on_commit(lambda: print(success_message))
return stats['newly_imported_count'], stats['updated_count'], False, success_message, stats['files_processed_count'], stats['skipped_count']
transaction.on_commit(lambda: print(result.message))
return result
except requests.exceptions.RequestException as e:
# Handle network-related errors for the download
result.has_error = True
result.message = f"Failed to download card data from GitHub: {e}"
print(result.message)
return result
except Exception as e:
# Any exception during the process will cause the transaction to roll back.
# Any other exception during the process will cause the transaction to roll back.
error_detail = f"Error during import (file: {error_tracking['file_name']}, card: {error_tracking['card_id']}): {str(e)}"
halt_message = f"Import HALTED. All changes rolled back. Reason: {error_detail}"
print(halt_message)
# Return 0 for counts as the transaction is rolled back
return 0, 0, True, halt_message, stats['files_processed_count'], stats.get('skipped_count', 0)
result.has_error = True
result.message = (
f"Import HALTED. All changes rolled back. Reason: {error_detail}"
)
print(result.message)
return result
if admin.site.is_registered(CardSet_New): admin.site.unregister(CardSet_New)
if admin.site.is_registered(Pack_New): admin.site.unregister(Pack_New)
if admin.site.is_registered(Energy_New): admin.site.unregister(Energy_New)
if admin.site.is_registered(Attack_New): admin.site.unregister(Attack_New)
if admin.site.is_registered(Ability_New): admin.site.unregister(Ability_New)
if admin.site.is_registered(Rarity_New): admin.site.unregister(Rarity_New)
if admin.site.is_registered(CardType_New): admin.site.unregister(CardType_New)
if admin.site.is_registered(Card_New): admin.site.unregister(Card_New)
if admin.site.is_registered(AttackCost_New): admin.site.unregister(AttackCost_New)
if admin.site.is_registered(RarityMapping): admin.site.unregister(RarityMapping)
@admin.register(CardSet_New)
@admin.register(CardSet)
class CardSetAdmin(TranslatableAdmin):
list_display = ('id', 'name', 'file_name')
readonly_fields = ('id', 'file_name', 'created_at', 'updated_at')
search_fields = ('translations__name',)
readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at')
list_display = ("id", "name", "file_name")
search_fields = ("translations__name",)
readonly_fields = ("id", "file_name", "created_at", "updated_at", "deleted_at")
@admin.register(Pack_New)
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("translations")
@admin.register(Pack)
class PackAdmin(TranslatableAdmin):
list_display = ('id', 'full_name', 'name', 'cardset', 'hex_color')
list_filter = ('cardset',)
search_fields = ('translations__name', 'translations__full_name')
readonly_fields = ('id', 'created_at', 'updated_at')
list_display = ("id", "full_name", "name", "cardset", "hex_color")
list_filter = ("cardset",)
search_fields = ("translations__name", "translations__full_name")
readonly_fields = ("id", "created_at", "updated_at")
@admin.register(Energy_New)
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.select_related("cardset")
.prefetch_related("translations", "cardset__translations")
)
@admin.register(Energy)
class EnergyAdmin(TranslatableAdmin):
list_display = ('id', 'name')
search_fields = ('translations__name',)
readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at')
list_display = ("id", "name")
search_fields = ("translations__name",)
readonly_fields = ("id", "created_at", "updated_at", "deleted_at")
@admin.register(Attack_New)
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("translations")
@admin.register(Attack)
class AttackAdmin(TranslatableAdmin):
list_display = ('id', 'name', 'damage', 'effect')
search_fields = ('translations__name',)
readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at')
list_display = ("id", "name", "damage", "effect")
search_fields = ("translations__name",)
readonly_fields = ("id", "created_at", "updated_at", "deleted_at")
@admin.register(Ability_New)
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("translations")
@admin.register(Ability)
class AbilityAdmin(TranslatableAdmin):
list_display = ('id', 'name', 'effect')
search_fields = ('translations__name',)
readonly_fields = ('id', 'created_at', 'updated_at')
list_display = ("id", "name", "effect")
search_fields = ("translations__name",)
readonly_fields = ("id", "created_at", "updated_at")
@admin.register(Rarity_New)
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("translations")
@admin.register(Rarity)
class RarityAdmin(TranslatableAdmin):
list_display = ('id', 'name', 'icon', 'level')
search_fields = ('translations__name',)
readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at')
list_display = ("id", "name", "icon", "level")
search_fields = ("translations__name",)
readonly_fields = ("id", "created_at", "updated_at", "deleted_at")
@admin.register(CardType_New)
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("translations")
@admin.register(CardType)
class CardTypeAdmin(TranslatableAdmin):
list_display = ('id', 'name', 'subtype')
search_fields = ('translations__name', 'translations__subtype')
readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at')
list_display = ("id", "name", "subtype")
search_fields = ("translations__name", "translations__subtype")
readonly_fields = ("id", "created_at", "updated_at", "deleted_at")
@admin.register(Card_New)
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("translations")
@admin.register(Card)
class CardAdmin(TranslatableAdmin):
list_display = ('id', 'cardnum', 'name', 'cardset', 'card_type', 'rarity', 'health', 'pkmn_type')
list_filter = ('cardset', 'card_type', 'rarity', 'pkmn_type', 'packs')
search_fields = ('id', 'translations__name', 'cardset__translations__name', 'packs__translations__name')
filter_horizontal = ('packs', 'abilities', 'attacks')
readonly_fields = ('id', 'cardnum', 'created_at', 'updated_at', 'deleted_at')
list_display = (
"id",
"cardnum",
"name",
"cardset",
"card_type",
"rarity",
"health",
"pkmn_type",
)
list_filter = (
("cardset", PrefetchedSortedRelatedFieldListFilter),
("card_type", PrefetchedSortedRelatedFieldListFilter),
("rarity", PrefetchedSortedRelatedFieldListFilter),
("pkmn_type", PrefetchedSortedRelatedFieldListFilter),
("packs", PrefetchedSortedRelatedFieldListFilter),
)
search_fields = (
"id",
"translations__name",
"cardset__translations__name",
"packs__translations__name",
)
filter_horizontal = ("packs", "abilities", "attacks")
readonly_fields = ("id", "cardnum", "created_at", "updated_at", "deleted_at")
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related(
"cardset", "card_type", "rarity", "pkmn_type"
).prefetch_related(
"translations",
"cardset__translations",
"card_type__translations",
"rarity__translations",
"pkmn_type__translations",
)
admin.site.register(AttackCost)
admin.site.register(AttackCost_New)
@admin.register(RarityMapping)
class RarityMappingAdmin(admin.ModelAdmin):
list_display = ('original_name', 'mapped_name', 'icon', 'level', 'created_at', 'updated_at', 'deleted_at')
search_fields = ('original_name', 'mapped_name')
list_filter = ('level',)
readonly_fields = ('created_at', 'updated_at', 'deleted_at')
list_display = (
"original_name",
"mapped_name",
"icon",
"level",
"created_at",
"updated_at",
"deleted_at",
)
search_fields = ("original_name", "mapped_name")
list_filter = ("level",)
readonly_fields = ("created_at", "updated_at", "deleted_at")
def get_admin_urls(urls):
def importer_view(request):
context = {
'title': 'Card Importer',
'site_header': admin.site.site_header,
'site_title': admin.site.site_title,
'index_title': admin.site.index_title,
'has_permission': admin.site.has_permission(request),
'app_label': 'cards',
"title": "Card Importer",
"site_header": admin.site.site_header,
"site_title": admin.site.site_title,
"index_title": admin.site.index_title,
"has_permission": admin.site.has_permission(request),
"app_label": "cards",
}
if request.method == 'POST':
new, updated, has_error, message_text, files_processed, skipped = perform_card_import_logic()
if request.method == "POST":
result = perform_card_import_logic()
if has_error:
messages.error(request, message_text + f" Files attempted before halt: {files_processed}.")
if result.has_error:
message = result.message
if result.files_processed_count > 0:
message += (
f" Files attempted before halt: {result.files_processed_count}."
)
messages.error(request, message)
else:
messages.success(request, message_text)
messages.success(request, result.message)
return HttpResponseRedirect(request.path_info)
return render(request, 'admin/cards/importer_status.html', context)
return render(request, "admin/cards/importer_status.html", context)
custom_urls = [
path('cards/import/', admin.site.admin_view(importer_view), name='cards_full_importer'),
path(
"cards/import/",
admin.site.admin_view(importer_view),
name="cards_full_importer",
),
]
return custom_urls + urls
original_get_urls = admin.site.get_urls
def new_get_urls():
urls = original_get_urls()
return get_admin_urls(urls)
admin.site.get_urls = new_get_urls
# Restore admin sidebar link for the importer
original_get_app_list = admin.site.get_app_list
def new_get_app_list(request):
app_list = original_get_app_list(request)
for app in app_list:
if app.get("app_label") == "cards":
app["models"].insert(
0,
{
"name": "Full Card Importer",
"object_name": "fullcardimporter",
"admin_url": reverse("admin:cards_full_importer"),
"view_only": True,
},
)
break
return app_list
admin.site.get_app_list = new_get_app_list

View file

@ -1,6 +1,8 @@
# Generated by Django 5.1 on 2025-05-10 01:22
# Generated by Django 5.1 on 2025-06-15 03:44
import django.db.models.deletion
import parler.fields
import parler.models
from django.db import migrations, models
@ -8,64 +10,733 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Card',
name="Ability",
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('cardset', models.CharField(max_length=32)),
('cardnum', models.IntegerField()),
('style', models.CharField(max_length=128)),
('rarity_icon', models.CharField(max_length=12)),
('rarity_level', models.IntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
("id", models.AutoField(primary_key=True, serialize=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
],
options={
"verbose_name": "Ability",
"verbose_name_plural": "Abilities",
},
bases=(parler.models.TranslatableModelMixin, models.Model),
),
migrations.CreateModel(
name='Deck',
name="Attack",
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('hex_color', models.CharField(max_length=9)),
('cardset', models.CharField(max_length=8)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
("id", models.AutoField(primary_key=True, serialize=False)),
(
"damage",
models.CharField(
blank=True,
help_text="Damage string, e.g., '40', '20x', '80+'.",
max_length=10,
null=True,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
],
options={
"verbose_name": "Attack",
"verbose_name_plural": "Attacks",
},
bases=(parler.models.TranslatableModelMixin, models.Model),
),
migrations.CreateModel(
name='CardNameTranslation',
name="CardSet",
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('language', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.card')),
(
"id",
models.CharField(
help_text="The ID for the set, e.g., 'A1', 'A1a'.",
max_length=3,
primary_key=True,
serialize=False,
),
),
(
"file_name",
models.CharField(
help_text="Original name of the JSON file, e.g., 'a1-genetic-apex.json'.",
max_length=32,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
],
options={
"verbose_name": "Card Set",
"verbose_name_plural": "Card Sets",
},
bases=(parler.models.TranslatableModelMixin, models.Model),
),
migrations.CreateModel(
name="CardType",
fields=[
("id", models.AutoField(primary_key=True, serialize=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
],
options={
"verbose_name": "Card Type",
"verbose_name_plural": "Card Types",
},
bases=(parler.models.TranslatableModelMixin, models.Model),
),
migrations.CreateModel(
name="Energy",
fields=[
("id", models.AutoField(primary_key=True, serialize=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
],
options={
"verbose_name": "Energy",
"verbose_name_plural": "Energies",
},
bases=(parler.models.TranslatableModelMixin, models.Model),
),
migrations.CreateModel(
name="Rarity",
fields=[
("id", models.AutoField(primary_key=True, serialize=False)),
("icon", models.CharField(max_length=12)),
("level", models.PositiveIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
],
options={
"verbose_name": "Rarity",
"verbose_name_plural": "Rarities",
},
bases=(parler.models.TranslatableModelMixin, models.Model),
),
migrations.CreateModel(
name="RarityMapping",
fields=[
("id", models.AutoField(primary_key=True, serialize=False)),
(
"original_name",
models.CharField(
help_text="The rarity name as it appears in the import source (e.g., JSON file).",
max_length=255,
unique=True,
),
),
(
"mapped_name",
models.CharField(
help_text="The standardized rarity name to use in the system.",
max_length=32,
),
),
(
"icon",
models.CharField(
help_text="The icon associated with this rarity.", max_length=12
),
),
(
"level",
models.PositiveIntegerField(
help_text="The level or order of this rarity."
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
],
options={
"verbose_name": "Rarity Mapping",
"verbose_name_plural": "Rarity Mappings",
"ordering": ["original_name"],
},
),
migrations.CreateModel(
name="AttackCost",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"quantity",
models.PositiveIntegerField(
default=1,
help_text="Quantity of this energy type required for the attack.",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
(
"attack",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="cards.attack"
),
),
(
"energy",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="cards.energy"
),
),
],
options={
"verbose_name": "Attack Cost",
"verbose_name_plural": "Attack Costs",
"unique_together": {("attack", "energy")},
},
),
migrations.AddField(
model_name='card',
name='decks',
field=models.ManyToManyField(to='cards.deck'),
model_name="attack",
name="energy_cost",
field=models.ManyToManyField(
related_name="attacks", through="cards.AttackCost", to="cards.energy"
),
),
migrations.CreateModel(
name='DeckNameTranslation',
name="Pack",
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('language', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deck', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.deck')),
],
("id", models.AutoField(primary_key=True, serialize=False)),
("hex_color", models.CharField(max_length=9)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
(
"cardset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="packs",
to="cards.cardset",
),
migrations.AlterUniqueTogether(
name='card',
unique_together={('cardset', 'cardnum')},
),
],
options={
"verbose_name": "Pack",
"verbose_name_plural": "Packs",
},
bases=(parler.models.TranslatableModelMixin, models.Model),
),
migrations.CreateModel(
name="Card",
fields=[
("cardnum", models.AutoField(primary_key=True, serialize=False)),
(
"id",
models.CharField(
db_index=True,
help_text="The unique ID from the JSON source, cardset-cardnum (e.g., 'a1-001').",
max_length=10,
),
),
(
"checksum",
models.CharField(
blank=True,
db_index=True,
help_text="SHA256 checksum of the card data.",
max_length=64,
null=True,
),
),
(
"health",
models.PositiveIntegerField(
blank=True, help_text="HP of the Pokémon.", null=True
),
),
(
"retreat_cost",
models.PositiveIntegerField(
blank=True,
help_text="The number of retreat cost for the card.",
null=True,
),
),
(
"style",
models.CharField(
blank=True,
help_text="Inline CSS style for the card, used for dynamic styling.",
max_length=255,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
(
"abilities",
models.ManyToManyField(
blank=True, related_name="cards", to="cards.ability"
),
),
(
"attacks",
models.ManyToManyField(related_name="cards", to="cards.attack"),
),
(
"cardset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="cards",
to="cards.cardset",
),
),
(
"card_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="cards",
to="cards.cardtype",
),
),
(
"pkmn_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="cards_pkmn_type",
to="cards.energy",
),
),
(
"weakness_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="cards_weakness_type",
to="cards.energy",
),
),
(
"packs",
models.ManyToManyField(related_name="cards", to="cards.pack"),
),
(
"rarity",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="cards",
to="cards.rarity",
),
),
],
options={
"verbose_name": "Card",
"verbose_name_plural": "Cards",
},
bases=(parler.models.TranslatableModelMixin, models.Model),
),
migrations.CreateModel(
name="AbilityTranslation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"language_code",
models.CharField(
db_index=True, max_length=15, verbose_name="Language"
),
),
(
"name",
models.CharField(
help_text="The name of the ability.", max_length=32
),
),
(
"effect",
models.TextField(help_text="Description of the ability's effect."),
),
(
"master",
parler.fields.TranslationsForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="translations",
to="cards.ability",
),
),
],
options={
"verbose_name": "Ability Translation",
"db_table": "cards_ability_translation",
"db_tablespace": "",
"managed": True,
"default_permissions": (),
"unique_together": {("language_code", "master")},
},
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
),
migrations.CreateModel(
name="AttackTranslation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"language_code",
models.CharField(
db_index=True, max_length=15, verbose_name="Language"
),
),
(
"name",
models.CharField(
help_text="The name of the attack.", max_length=32
),
),
(
"effect",
models.TextField(help_text="Description of the attack's effect."),
),
(
"master",
parler.fields.TranslationsForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="translations",
to="cards.attack",
),
),
],
options={
"verbose_name": "Attack Translation",
"db_table": "cards_attack_translation",
"db_tablespace": "",
"managed": True,
"default_permissions": (),
"unique_together": {("language_code", "master")},
},
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
),
migrations.CreateModel(
name="CardSetTranslation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"language_code",
models.CharField(
db_index=True, max_length=15, verbose_name="Language"
),
),
(
"name",
models.CharField(
help_text="The full name of the set, e.g., 'Genetic Apex'.",
max_length=32,
),
),
(
"master",
parler.fields.TranslationsForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="translations",
to="cards.cardset",
),
),
],
options={
"verbose_name": "Card Set Translation",
"db_table": "cards_cardset_translation",
"db_tablespace": "",
"managed": True,
"default_permissions": (),
"unique_together": {("language_code", "master")},
},
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
),
migrations.CreateModel(
name="CardTranslation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"language_code",
models.CharField(
db_index=True, max_length=15, verbose_name="Language"
),
),
(
"name",
models.CharField(help_text="The name of the card.", max_length=32),
),
(
"evolves_from_name",
models.CharField(
blank=True,
help_text="Name of the Pokémon this card evolves from.",
max_length=32,
null=True,
),
),
(
"master",
parler.fields.TranslationsForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="translations",
to="cards.card",
),
),
],
options={
"verbose_name": "Card Translation",
"db_table": "cards_card_translation",
"db_tablespace": "",
"managed": True,
"default_permissions": (),
"unique_together": {("language_code", "master")},
},
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
),
migrations.CreateModel(
name="CardTypeTranslation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"language_code",
models.CharField(
db_index=True, max_length=15, verbose_name="Language"
),
),
(
"name",
models.CharField(
help_text="The name of the card type.", max_length=32
),
),
(
"subtype",
models.CharField(
blank=True,
help_text="The subtype of the card type.",
max_length=32,
null=True,
),
),
(
"master",
parler.fields.TranslationsForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="translations",
to="cards.cardtype",
),
),
],
options={
"verbose_name": "Card Type Translation",
"db_table": "cards_cardtype_translation",
"db_tablespace": "",
"managed": True,
"default_permissions": (),
"unique_together": {("language_code", "master")},
},
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
),
migrations.CreateModel(
name="EnergyTranslation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"language_code",
models.CharField(
db_index=True, max_length=15, verbose_name="Language"
),
),
(
"name",
models.CharField(
help_text="The name of the energy.", max_length=32
),
),
(
"master",
parler.fields.TranslationsForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="translations",
to="cards.energy",
),
),
],
options={
"verbose_name": "Energy Translation",
"db_table": "cards_energy_translation",
"db_tablespace": "",
"managed": True,
"default_permissions": (),
"unique_together": {("language_code", "master")},
},
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
),
migrations.CreateModel(
name="PackTranslation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"language_code",
models.CharField(
db_index=True, max_length=15, verbose_name="Language"
),
),
(
"full_name",
models.CharField(
help_text="The full name of the pack, e.g., 'Genetic Apex: Mewtwo'.",
max_length=32,
),
),
(
"name",
models.CharField(
help_text="The pack name itself, e.g., 'Mewtwo'.", max_length=32
),
),
(
"master",
parler.fields.TranslationsForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="translations",
to="cards.pack",
),
),
],
options={
"verbose_name": "Pack Translation",
"db_table": "cards_pack_translation",
"db_tablespace": "",
"managed": True,
"default_permissions": (),
"unique_together": {("language_code", "master")},
},
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
),
migrations.CreateModel(
name="RarityTranslation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"language_code",
models.CharField(
db_index=True, max_length=15, verbose_name="Language"
),
),
(
"name",
models.CharField(
help_text="The name of the rarity.", max_length=32
),
),
(
"master",
parler.fields.TranslationsForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="translations",
to="cards.rarity",
),
),
],
options={
"verbose_name": "Rarity Translation",
"db_table": "cards_rarity_translation",
"db_tablespace": "",
"managed": True,
"default_permissions": (),
"unique_together": {("language_code", "master")},
},
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
),
]

View file

@ -1,6 +1,35 @@
from django.db import models
from parler.models import TranslatableModel, TranslatedFields
from django.utils.translation import gettext_lazy as _
from parler.managers import TranslatableManager
from parler.models import TranslatableModel, TranslatedFields
class CardManager(TranslatableManager):
def with_details(self):
"""
Returns a Card queryset with all related fields pre-selected to avoid N+1 queries.
"""
return (
self.get_queryset()
.select_related(
"rarity",
"cardset",
"card_type",
"pkmn_type",
"weakness_type",
)
.prefetch_related(
"translations",
"rarity__translations",
"cardset__translations",
"card_type__translations",
"pkmn_type__translations",
"weakness_type__translations",
"attacks__translations",
"abilities__translations",
"packs__translations",
)
)
class CardSet(TranslatableModel):
@ -29,8 +58,8 @@ class CardSet(TranslatableModel):
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("Card Set (New)")
verbose_name_plural = _("Card Sets (New)")
verbose_name = _("Card Set")
verbose_name_plural = _("Card Sets")
def __str__(self):
return f"{self.id} - {self.name}"
@ -58,8 +87,8 @@ class Pack(TranslatableModel):
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("Pack (New)")
verbose_name_plural = _("Packs (New)")
verbose_name = _("Pack")
verbose_name_plural = _("Packs")
def __str__(self):
return f"{self.full_name}"
@ -79,8 +108,8 @@ class Energy(TranslatableModel):
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("Energy (New)")
verbose_name_plural = _("Energies (New)")
verbose_name = _("Energy")
verbose_name_plural = _("Energies")
def __str__(self):
return f"{self.name}"
@ -101,8 +130,8 @@ class AttackCost(models.Model):
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("Attack Cost (New)")
verbose_name_plural = _("Attack Costs (New)")
verbose_name = _("Attack Cost")
verbose_name_plural = _("Attack Costs")
unique_together = ("attack", "energy")
def __str__(self):
@ -133,8 +162,8 @@ class Attack(TranslatableModel):
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("Attack (New)")
verbose_name_plural = _("Attacks (New)")
verbose_name = _("Attack")
verbose_name_plural = _("Attacks")
def __str__(self):
return f"{self.name}"
@ -155,8 +184,8 @@ class Ability(TranslatableModel):
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("Ability (New)")
verbose_name_plural = _("Abilities (New)")
verbose_name = _("Ability")
verbose_name_plural = _("Abilities")
def __str__(self):
return f"{self.name}"
@ -178,8 +207,8 @@ class Rarity(TranslatableModel):
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("Rarity (New)")
verbose_name_plural = _("Rarities (New)")
verbose_name = _("Rarity")
verbose_name_plural = _("Rarities")
def __str__(self):
return f"{self.name}"
@ -205,8 +234,8 @@ class CardType(TranslatableModel):
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("Card Type (New)")
verbose_name_plural = _("Card Types (New)")
verbose_name = _("Card Type")
verbose_name_plural = _("Card Types")
def __str__(self):
return f"{self.name}"
@ -217,6 +246,8 @@ class Card(TranslatableModel):
Represents a single, unique digital printing of a Pokémon card.
"""
objects = CardManager()
translations = TranslatedFields(
name=models.CharField(max_length=32, help_text=_("The name of the card.")),
evolves_from_name=models.CharField(
@ -271,13 +302,19 @@ class Card(TranslatableModel):
attacks = models.ManyToManyField(Attack, related_name="cards")
rarity = models.ForeignKey(Rarity, on_delete=models.CASCADE, related_name="cards")
style = models.CharField(
max_length=255,
blank=True,
help_text=_("Inline CSS style for the card, used for dynamic styling."),
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("Card (New)")
verbose_name_plural = _("Cards (New)")
verbose_name = _("Card")
verbose_name_plural = _("Cards")
def __str__(self):
return f"{self.id} {self.name}"

View file

@ -1,5 +1,9 @@
from django.db.models.signals import m2m_changed
from django.core.cache import cache
from django.db.models.signals import m2m_changed, post_delete, post_save
from django.dispatch import receiver
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard
from .models import Card
@ -34,24 +38,68 @@ def color_is_dark(bg_color):
return brightness <= 200
@receiver(m2m_changed, sender=Card.decks.through)
@receiver(m2m_changed, sender=Card.packs.through)
def update_card_style(sender, instance, action, **kwargs):
if action == "post_add":
decks = instance.decks.all()
num_decks = decks.count()
if num_decks == 1:
instance.style = "background-color: " + decks.first().hex_color + ";"
elif num_decks >= 2:
hex_colors = [deck.hex_color for deck in decks]
instance.style = (
f"background: linear-gradient(to right, {', '.join(hex_colors)});"
)
else:
instance.style = (
packs = instance.packs.all()
num_packs = packs.count()
style_parts = []
if num_packs == 0:
style_parts.append(
"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);"
style_parts.append("text-shadow: 0 0 0 #fff;")
else:
instance.style += "text-shadow: 0 0 0 #fff;"
if num_packs == 1:
style_parts.append(f"background-color: {packs.first().hex_color};")
else: # num_packs >= 2
hex_colors = [pack.hex_color for pack in packs]
gradient = f"linear-gradient(to right, {', '.join(hex_colors)})"
style_parts.append(f"background: {gradient};")
if not color_is_dark(packs.first().hex_color):
style_parts.append("color: var(--color-gray-700);")
style_parts.append("text-shadow: 0 0 0 var(--color-gray-700);")
else:
style_parts.append("text-shadow: 0 0 0 #fff;")
instance.style = "".join(style_parts)
instance.save(update_fields=["style"])
def invalidate_card_cache(instance):
"""Invalidate cache for a card."""
# Invalidate the card_badge cache
# The key is constructed as "card_badge_<card_id>"
cache.delete(f"card_badge_{instance.pk}")
# Invalidate card_multiselect cache by clearing all of them using a pattern.
# This is necessary as we can't easily reconstruct all possible keys in the signal.
cache.delete_pattern("card_multiselect_*")
# Invalidate trade offers that contain this card in either have or want lists.
have_offers_pks = TradeOfferHaveCard.objects.filter(card=instance).values_list(
"trade_offer_id", flat=True
)
want_offers_pks = TradeOfferWantCard.objects.filter(card=instance).values_list(
"trade_offer_id", flat=True
)
all_offer_pks = set(have_offers_pks) | set(want_offers_pks)
for offer_pk in all_offer_pks:
cache.delete(f"trade_offer_{offer_pk}")
@receiver(post_save, sender=Card)
def on_card_save(sender, instance, **kwargs):
"""Invalidate cache for a card when it's updated."""
invalidate_card_cache(instance)
@receiver(post_delete, sender=Card)
def on_card_delete(sender, instance, **kwargs):
"""Invalidate cache for a card when it's deleted."""
invalidate_card_cache(instance)

View file

@ -1,8 +1,8 @@
from django import template
from django.conf import settings
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from django.urls import reverse_lazy
from django.utils.safestring import mark_safe
register = template.Library()
@ -17,11 +17,12 @@ def card_badge(context, card, quantity=None, expanded=False):
"quantity": quantity,
"style": card.style,
"name": card.name,
"rarity": card.rarity_icon,
"cardset": card.cardset,
"rarity": card.rarity.icon,
"cardset": card.cardset.id,
"expanded": expanded,
"cache_key": f"card_badge_{card.pk}_{quantity}_{expanded}",
"url": url,
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
}
context.update(tag_context)
return context
@ -37,11 +38,11 @@ def card_badge_inline(card, quantity=None):
"quantity": quantity,
"style": card.style,
"name": card.name,
"rarity": card.rarity_icon,
"rarity": card.rarity,
"cardset": card.cardset,
"expanded": True,
"cache_key": f"card_badge_{card.pk}_{quantity}_{True}",
"CACHE_TIMEOUT": settings.CACHE_TIMEOUT,
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
"url": url,
}
html = render_to_string("templatetags/card_badge.html", tag_context)

View file

@ -1,10 +1,12 @@
import uuid
from django import template
from pkmntrade_club.cards.models import Card
from django.db.models.query import QuerySet
import json
import hashlib
import json
import logging
import uuid
from django import template
from django.db.models.query import QuerySet
from pkmntrade_club.cards.models import Card
register = template.Library()
@ -18,7 +20,7 @@ def get_item(dictionary, 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.with_details().order_by("id").all()
@register.inclusion_tag("templatetags/card_multiselect.html", takes_context=True)

View file

@ -1,12 +1,13 @@
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
from django.views import View
from django.views.generic import (
DetailView,
ListView,
)
from pkmntrade_club.cards.models import Card
from pkmntrade_club.common.mixins import ReusablePaginationMixin
from pkmntrade_club.trades.models import TradeOffer
class CardDetailView(DetailView):
@ -14,6 +15,24 @@ class CardDetailView(DetailView):
template_name = "cards/card_detail.html"
context_object_name = "card"
def get_queryset(self):
qs = super().get_queryset()
# Prefetch all related data and translations to avoid N+1 queries in the template.
return qs.select_related(
"cardset", "rarity", "card_type", "pkmn_type", "weakness_type"
).prefetch_related(
"translations",
"cardset__translations",
"rarity__translations",
"card_type__translations",
"pkmn_type__translations",
"weakness_type__translations",
"packs__translations",
"abilities__translations",
"attacks__translations",
"attacks__energy_cost__energy__translations",
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
card = self.get_object()
@ -94,18 +113,43 @@ class CardListView(ReusablePaginationMixin, ListView):
def get_ordering(self):
order = self.request.GET.get("order", "absolute")
group_by = self.request.GET.get("group_by")
# When grouping, the ordering must match the grouping attribute.
if group_by == "cardset":
return ("cardset__name",)
elif group_by == "rarity":
# Order by level (desc) then by icon/name to keep groups together.
return "-rarity__level", "rarity__icon"
# Note: Grouping by 'pack' is complex due to M2M and would require a custom implementation.
if order == "alphabetical":
return "name"
elif order == "rarity":
return "-rarity_level"
return "-rarity__level"
else: # absolute ordering
return "id"
def get_queryset(self):
qs = super().get_queryset()
ordering = self.get_ordering()
# Handle both single string and tuple orderings
if isinstance(ordering, tuple):
qs = qs.order_by(*ordering)
else:
qs = qs.order_by(ordering)
return qs.prefetch_related("decks").distinct()
return (
qs.select_related("cardset", "rarity", "card_type", "pkmn_type")
.prefetch_related(
"translations",
"cardset__translations",
"rarity__translations",
"card_type__translations",
"pkmn_type__translations",
"packs__translations",
)
.distinct()
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -114,55 +158,14 @@ class CardListView(ReusablePaginationMixin, ListView):
context["order"] = order
context["group_by"] = group_by
if group_by in ("deck", "cardset", "rarity"):
full_qs = self.get_queryset()
all_cards = list(full_qs)
flat_cards = []
if group_by == "deck":
for card in all_cards:
for deck in card.decks.all():
flat_cards.append({"group": deck.name, "card": card})
flat_cards.sort(key=lambda x: x["group"].lower())
elif group_by == "cardset":
for card in all_cards:
flat_cards.append({"group": card.cardset, "card": card})
flat_cards.sort(key=lambda x: x["group"].lower())
elif group_by == "rarity":
for card in all_cards:
flat_cards.append(
{
"group": card.rarity_icon,
"sort_group": card.rarity_level,
"card": card,
}
)
flat_cards.sort(key=lambda x: x["sort_group"], reverse=True)
# Unified pagination logic for all cases.
# The complex manual grouping logic has been removed for performance.
# The template should now use the `regroup` template tag for display.
page_number = self.get_page_number()
self.per_page = 36
page_flat_cards, pagination_context = self.paginate_data(
flat_cards, page_number
)
page_groups = []
for item in page_flat_cards:
group_value = item["group"]
card_obj = item["card"]
if page_groups and page_groups[-1]["group"] == group_value:
page_groups[-1]["cards"].append(card_obj)
else:
page_groups.append({"group": group_value, "cards": [card_obj]})
context["groups"] = page_groups
context["page_obj"] = pagination_context
context["total_cards"] = len(flat_cards)
context["object_list"] = full_qs
else:
page_number = self.get_page_number()
self.per_page = 36
paginated_cards, pagination_context = self.paginate_data(
self.get_queryset(), page_number
)
queryset = self.get_queryset()
paginated_cards, pagination_context = self.paginate_data(queryset, page_number)
context["cards"] = paginated_cards
context["page_obj"] = pagination_context
context["object_list"] = self.get_queryset()
context["object_list"] = queryset
return context

View file

@ -1,9 +1,23 @@
import random
from django.conf import settings
def cache_settings(request):
"""
Pass cache settings to the template context.
Applies jitter to the timeouts.
"""
jitter = settings.CACHE_JITTER
return {
"CACHE_TIMEOUT": settings.CACHE_TIMEOUT,
"CACHE_SHORT_TIMEOUT": settings.CACHE_SHORT_TIMEOUT
+ random.randint(-jitter, jitter),
"CACHE_MEDIUM_TIMEOUT": settings.CACHE_MEDIUM_TIMEOUT
+ random.randint(-jitter, jitter),
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT
+ random.randint(-jitter, jitter),
"CACHE_DEFAULT_TIMEOUT": settings.CACHE_DEFAULT_TIMEOUT
+ random.randint(-jitter, jitter),
}

View file

@ -1,10 +1,12 @@
import socket
from pathlib import Path
import environ
import os
import logging
import os
import socket
import sys
from pathlib import Path
import environ
from django.utils.translation import gettext_lazy as _
from pkmntrade_club._version import __version__, get_version_info
# set default values to local dev values
@ -35,7 +37,11 @@ env = environ.Env(
ACCOUNT_EMAIL_VERIFICATION=(str, "none"),
SCHEME=(str, "http"),
REDIS_URL=(str, "redis://localhost:6379"),
CACHE_TIMEOUT=(int, 604800),
CACHE_DEFAULT_TIMEOUT=(int, 60 * 5), # 5 minutes
CACHE_SHORT_TIMEOUT=(int, 60 * 5), # 5 minutes
CACHE_MEDIUM_TIMEOUT=(int, 60 * 60 * 1), # 1 hour
CACHE_LONG_TIMEOUT=(int, 60 * 60 * 24), # 24 hours
CACHE_JITTER=(int, 30), # 30 seconds
TIME_ZONE=(str, "America/Los_Angeles"),
)
@ -92,7 +98,11 @@ 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")
CACHE_DEFAULT_TIMEOUT = env("CACHE_DEFAULT_TIMEOUT")
CACHE_SHORT_TIMEOUT = env("CACHE_SHORT_TIMEOUT")
CACHE_MEDIUM_TIMEOUT = env("CACHE_MEDIUM_TIMEOUT")
CACHE_LONG_TIMEOUT = env("CACHE_LONG_TIMEOUT")
CACHE_JITTER = env("CACHE_JITTER")
DISABLE_SIGNUPS = env("DISABLE_SIGNUPS")
DISABLE_CACHE = env("DISABLE_CACHE")

View file

@ -1,14 +1,17 @@
import logging
from collections import OrderedDict
from django.views.generic import TemplateView
from django.db.models import (
Max,
Sum,
)
from django.db.models.functions import Coalesce
from django.views.generic import TemplateView
from pkmntrade_club.cards.models import Card
from pkmntrade_club.trades.models import (
TradeOffer,
)
from pkmntrade_club.cards.models import Card
import logging
logger = logging.getLogger(__name__)
@ -22,8 +25,10 @@ class HomePageView(TemplateView):
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.with_details()
.filter(rarity__level__lte=5)
.order_by("translations__name", "rarity__level")
)
# Reuse base trade offer queryset for market stats
@ -33,9 +38,15 @@ class HomePageView(TemplateView):
try:
recent_offers_qs = base_offer_qs.order_by("-created_at")[:6]
context["recent_offers"] = recent_offers_qs
latest_update = recent_offers_qs.aggregate(latest=Max("updated_at"))[
"latest"
]
if latest_update:
context["cache_key_recent_offers"] = (
f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}"
f"recent_offers_{latest_update.timestamp()}"
)
else:
context["cache_key_recent_offers"] = "recent_offers_empty"
except Exception as e:
logger.error(f"Error fetching recent offers: {str(e)}")
context["recent_offers"] = []
@ -44,15 +55,22 @@ class HomePageView(TemplateView):
# Most Offered Cards
try:
most_offered_cards_qs = (
Card.objects.filter(tradeofferhavecard__isnull=False)
.filter(rarity_level__lte=5)
Card.objects.with_details()
.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
latest_update = most_offered_cards_qs.aggregate(
latest=Max("updated_at")
)["latest"]
if latest_update:
context["cache_key_most_offered_cards"] = (
f"most_offered_cards_{most_offered_cards_qs.values_list('pk', 'updated_at')}"
f"most_offered_{latest_update.timestamp()}"
)
else:
context["cache_key_most_offered_cards"] = "most_offered_empty"
except Exception as e:
logger.error(f"Error fetching most offered cards: {str(e)}")
context["most_offered_cards"] = []
@ -60,15 +78,22 @@ class HomePageView(TemplateView):
# Most Wanted Cards
try:
most_wanted_cards_qs = (
Card.objects.filter(tradeofferwantcard__isnull=False)
.filter(rarity_level__lte=5)
Card.objects.with_details()
.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
latest_update = most_wanted_cards_qs.aggregate(
latest=Max("updated_at")
)["latest"]
if latest_update:
context["cache_key_most_wanted_cards"] = (
f"most_wanted_cards_{most_wanted_cards_qs.values_list('pk', 'updated_at')}"
f"most_wanted_{latest_update.timestamp()}"
)
else:
context["cache_key_most_wanted_cards"] = "most_wanted_empty"
except Exception as e:
logger.error(f"Error fetching most wanted cards: {str(e)}")
context["most_wanted_cards"] = []
@ -76,16 +101,23 @@ class HomePageView(TemplateView):
# Least Offered Cards
try:
least_offered_cards_qs = (
Card.objects.filter(rarity_level__lte=5)
Card.objects.with_details()
.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
latest_update = least_offered_cards_qs.aggregate(
latest=Max("updated_at")
)["latest"]
if latest_update:
context["cache_key_least_offered_cards"] = (
f"least_offered_cards_{least_offered_cards_qs.values_list('pk', 'updated_at')}"
f"least_offered_{latest_update.timestamp()}"
)
else:
context["cache_key_least_offered_cards"] = "least_offered_empty"
except Exception as e:
logger.error(f"Error fetching least offered cards: {str(e)}")
context["least_offered_cards"] = []
@ -94,10 +126,21 @@ class HomePageView(TemplateView):
featured = OrderedDict()
# Featured "All" offers remains fixed at the top
try:
featured["All"] = base_offer_qs.order_by("created_at")[:6]
all_featured = base_offer_qs.order_by("created_at")[:6]
featured["All"] = all_featured
latest_update = all_featured.aggregate(latest=Max("updated_at"))[
"latest"
]
if latest_update:
context["cache_key_featured_offers"] = (
f"featured_all_{latest_update.timestamp()}"
)
else:
context["cache_key_featured_offers"] = "featured_all_empty"
except Exception as e:
logger.error(f"Error fetching 'All' featured offers: {str(e)}")
featured["All"] = []
context["cache_key_featured_offers"] = "featured_all_error"
# *** we only show All Featured Offers for now,
# *** we will add rarity-tabbed featured offers later
@ -121,23 +164,6 @@ class HomePageView(TemplateView):
# logger.error(f"Error processing rarity-based featured offers: {str(e)}")
context["featured_offers"] = featured
# Generate a cache key based on the pks and updated_at timestamps of all featured offers
# *** we will separate cache keys for each featured section later
all_offer_identifiers = []
for section_name, section_offers in featured.items():
# featured_section is a QuerySet. Fetch (pk, updated_at) tuples.
identifiers = section_offers.values_list("pk", "updated_at")
# Format each tuple as "pk_timestamp" and add to the list
section_strings = [
f"{section_name}_{pk}_{ts.timestamp()}" for pk, ts in identifiers
]
all_offer_identifiers.extend(section_strings)
# Join all identifiers into a single string, sorted for consistency regardless of order
combined_identifiers = "|".join(sorted(all_offer_identifiers))
context["cache_key_featured_offers"] = (
f"featured_offers_{combined_identifiers}"
)
except Exception as e:
logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}")
# Provide fallback empty data

View file

@ -138,6 +138,8 @@
<!-- Goatcounter: 100% privacy-first, no tracking analytics -->
<script data-goatcounter="https://stats.pkmntrade.club/count" async src="{% static 'js/count-v4.js' %}"></script>
<!-- bugdrop.app -->
<script src="https://cdn.bugdrop.app/widget.js" data-site-id="9652eaccff27e8db8b8f86595514f63e"></script>
{% block javascript %}{% endblock %}
</body>
</html>

View file

@ -5,7 +5,7 @@
<div class="flex items-center mb-6">
<div class="ml-4">
<h1 class="text-3xl font-bold">{{card.name}}</h1>
<h2 class="text-lg text-gray-500">{{ card.cardset }} #{{ card.cardnum }} &bull; {{ card.rarity_icon }}</h2>
<h2 class="text-lg text-gray-500">{{ card.cardset }} #{{ card.cardnum }} &bull; {{ card.rarity.icon }}</h2>
</div>
</div>

View file

@ -1,63 +1,48 @@
{% extends "base.html" %}
{% load static card_badge %}
{% block content %}
<div class="container mx-auto"
x-data="{
order: '{{ order }}',
groupBy: '{{ group_by|default:'none' }}',
page: 1,
loadCards() {
// Construct URL using current pathname and query parameters.
let groupParam = this.groupBy === 'none' ? '' : this.groupBy;
let url = window.location.pathname + '?order=' + this.order + '&group_by=' + groupParam + '&page=' + this.page;
fetch(url, { headers: { 'x-requested-with': 'XMLHttpRequest' } })
.then(response => response.text())
.then(html => {
this.$refs.cardList.innerHTML = html;
window.processMarqueeElements && window.processMarqueeElements();
});
}
}"
x-on:change-page.window="page = $event.detail.page; loadCards()">
<div class="flex flex-wrap items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Cards</h1>
<div class="container mx-auto p-4">
<h1 class="text-3xl font-bold mb-4">Card List</h1>
<div class="mb-4">
<!-- Sorting and Grouping controls can go here -->
</div>
<div>
<!-- Sort Dropdown -->
<div class="dropdown dropdown-end m-1">
<div tabindex="0" class="btn">
Sort by: <span x-text="order === 'absolute' ? 'Cardset' : (order === 'alphabetical' ? 'Alphabetical' : 'Rarity')"></span>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-w-52">
<li><a href="#" @click.prevent="order = 'absolute'; page = 1; loadCards()">Cardset</a></li>
<li><a href="#" @click.prevent="order = 'alphabetical'; page = 1; loadCards()">Alphabetical</a></li>
<li><a href="#" @click.prevent="order = 'rarity'; page = 1; loadCards()">Rarity</a></li>
</ul>
</div>
<!-- Grouping Dropdown -->
<div class="dropdown dropdown-end m-1">
<div tabindex="0" class="btn">
Group by: <span x-text="groupBy === 'none' ? 'None' : (groupBy.charAt(0).toUpperCase() + groupBy.slice(1))"></span>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-w-52">
<li><a href="#" @click.prevent="groupBy = 'none'; page = 1; loadCards()">None</a></li>
<li><a href="#" @click.prevent="groupBy = 'deck'; page = 1; loadCards()">Deck</a></li>
<li><a href="#" @click.prevent="groupBy = 'cardset'; page = 1; loadCards()">Cardset</a></li>
<li><a href="#" @click.prevent="groupBy = 'rarity'; page = 1; loadCards()">Rarity</a></li>
</ul>
{% if group_by == 'rarity' %}
{% regroup cards by rarity as grouped_list %}
{% for group in grouped_list %}
<div class="mb-6">
<h2 class="text-2xl font-semibold mb-2">{{ group.grouper.icon }} {{ group.grouper.name }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% for card in group.list %}
{% card_badge card expanded=True %}
{% endfor %}
</div>
</div>
{% endfor %}
{% elif group_by == 'cardset' %}
{% regroup cards by cardset as grouped_list %}
{% for group in grouped_list %}
<div class="mb-6">
<h2 class="text-2xl font-semibold mb-2">{{ group.grouper.name }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% for card in group.list %}
{% card_badge card expanded=True %}
{% endfor %}
</div>
<!-- Container for the partial card list -->
<div x-ref="cardList">
{% include "cards/_card_list.html" with cards=cards page_obj=page_obj %}
</div>
{% endfor %}
{% else %}
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% for card in cards %}
{% card_badge card expanded=True %}
{% endfor %}
</div>
{% endif %}
<div class="mt-8">
{% include "templatetags/pagination_controls.html" with page_obj=page_obj %}
</div>
</div>
{% endblock %}

View file

@ -3,6 +3,7 @@
<div class="mx-4 grid gap-3 grid-cols-[repeat(auto-fit,minmax(150px,1fr))] justify-items-center">
{% for card in cards %}
{% card_badge card quantity=card.offer_count expanded=True %}
<div class="text-sm font-semibold">{{ card.rarity.icon }}</div>
{% endfor %}
</div>
{% else %}

View file

@ -45,7 +45,7 @@ Welcome
<h2 id="stats-heading" class="text-2xl font-semibold mb-4">Card Stats</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<!-- Most Offered Cards -->
{% cache CACHE_TIMEOUT most_offered_cards cache_key_most_offered_cards %}
{% cache CACHE_MEDIUM_TIMEOUT most_offered_cards cache_key_most_offered_cards %}
<div>
<div class="card card-border bg-base-100 shadow-lg">
<div class="card-header text-base-content p-4">
@ -58,7 +58,7 @@ Welcome
</div>
{% endcache %}
<!-- Most Wanted Cards -->
{% cache CACHE_TIMEOUT most_wanted_cards cache_key_most_wanted_cards %}
{% cache CACHE_MEDIUM_TIMEOUT most_wanted_cards cache_key_most_wanted_cards %}
<div>
<div class="card card-border bg-base-100 shadow-lg">
<div class="card-header text-base-content p-4">
@ -71,7 +71,7 @@ Welcome
</div>
{% endcache %}
<!-- Least Offered Cards (Last Group) -->
{% cache CACHE_TIMEOUT least_offered_cards cache_key_least_offered_cards %}
{% cache CACHE_MEDIUM_TIMEOUT least_offered_cards cache_key_least_offered_cards %}
<div class="col-span-2 md:col-span-1">
<div class="card card-border bg-base-100 shadow-lg">
<div class="card-header text-base-content p-4">
@ -90,7 +90,7 @@ Welcome
<section class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Featured Offers -->
{% cache CACHE_TIMEOUT featured_offers cache_key_featured_offers %}
{% cache CACHE_MEDIUM_TIMEOUT featured_offers cache_key_featured_offers %}
<div>
<div class="p-4 text-center ">
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Featured Offers</h5>
@ -109,7 +109,7 @@ Welcome
</div>
{% endcache %}
<!-- Recent Offers -->
{% cache CACHE_TIMEOUT recent_offers cache_key_recent_offers %}
{% cache CACHE_MEDIUM_TIMEOUT recent_offers cache_key_recent_offers %}
<div>
<div class="text-center p-4">
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Recent Offers</h5>

View file

@ -7,23 +7,25 @@
value="{{ card.pk }}:{{ selected_cards|get_item:card_id_str }}"
data-card-id="{{ card.pk }}"
data-quantity="{{ selected_cards|get_item:card_id_str }}"
data-rarity="{{ card.rarity.level }}"
data-cardset="{{ card.cardset.id }}"
data-packs="{% for pack in card.packs.all %}{{ pack.id }},{% endfor %}"
selected
data-html-content='<div class="m-2">{{ card|card_badge_inline:selected_cards|get_item:card_id_str }}</div>'
data-name="{{ card.name }}"
data-rarity="{{ card.rarity_icon }}"
data-cardset="{{ card.cardset }}">
{{ card.name }} {{ card.rarity_icon }} {{ card.cardset }}
data-name="{{ card.name }}">
{{ card.name }} ({{ card.id }})
</option>
{% else %}
<option
value="{{ card.pk }}:1"
data-card-id="{{ card.pk }}"
data-quantity="1"
data-rarity="{{ card.rarity.level }}"
data-cardset="{{ card.cardset.id }}"
data-packs="{% for pack in card.packs.all %}{{ pack.id }},{% endfor %}"
data-html-content='<div class="m-2">{{ card|card_badge_inline:"" }}</div>'
data-name="{{ card.name }}"
data-rarity="{{ card.rarity_icon }}"
data-cardset="{{ card.cardset }}">
{{ card.name }} {{ card.rarity_icon }} {{ card.cardset }}
data-name="{{ card.name }}">
{{ card.name }} ({{ card.id }})
</option>
{% endif %}
{% endwith %}

View file

@ -1,5 +1,5 @@
{% load cache %}
{% cache CACHE_TIMEOUT card_badge cache_key %}
{% cache CACHE_LONG_TIMEOUT "card_badge" card.pk %}
<a href="{{ url }}" @click.stop>
<div class="relative block">
{% if not expanded %}

View file

@ -5,7 +5,7 @@
<span class="label-text">{{ label }}</span>
</label>
<select name="{{ field_name }}" id="{{ field_id }}" class="select select-bordered w-full card-multiselect" data-placeholder="{{ placeholder }}" multiple x-cloak>
{% cache CACHE_TIMEOUT card_multiselect field_name label placeholder passed_cards_identifier selected_cards_key_part %}
{% cache CACHE_LONG_TIMEOUT "card_multiselect" field_name label placeholder passed_cards_identifier selected_cards_key_part %}
{% if has_passed_cards %}
{% include "templatetags/_card_multiselect_options.html" with cards_to_render=passed_cards selected_cards=selected_cards placeholder=placeholder %}
{% else %}

View file

@ -1,6 +1,6 @@
{% load gravatar card_badge cache %}
{% load gravatar card_badge cache i18n %}
{% cache CACHE_TIMEOUT trade_acceptance cache_key %}
{% cache CACHE_MEDIUM_TIMEOUT trade_acceptance cache_key %}
<div class="card card-border bg-base-100 shadow-lg max-w-90 mx-auto">
<!-- Header -->
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}">

View file

@ -1,6 +1,6 @@
{% load gravatar card_badge cache %}
{% cache CACHE_TIMEOUT trade_offer cache_key %}
{% cache CACHE_MEDIUM_TIMEOUT "trade_offer" offer_pk %}
<div x-data="{ flipped: {{flipped|lower}}, offerExpanded: {{flipped|yesno:'false,true'}}, acceptanceExpanded: {{flipped|lower}} }" x-ref="tradeOffer" class="transition-all duration-500 trade-offer-card">
<div class="flip-container">
<div class="flip-inner grid grid-cols-1 grid-rows-1 card bg-base-100 card-border shadow-lg w-90 transform transition-transform duration-500 ease-in-out{%if flipped %} rotate-y-180{% endif %}"

View file

@ -54,7 +54,12 @@
<div class="flex flex-row justify-around">
<!-- Has Side (inner grid of 2 columns) -->
<div class="grid grid-cols-2 content-start gap-1">
{% for card in have_cards_available %} {% card_badge card.card card.quantity %} {% empty %}
{% for card in have_cards_available %}
<div class="flex flex-col content-start gap-1">
<div class="font-bold">{{ card.card.rarity.icon }}</div>
{% card_badge card.card card.quantity %}
</div>
{% empty %}
<div class="text-xs text-center mb-2 ms-3 col-span-2">
None left.
</div>
@ -66,8 +71,15 @@
</div>
<!-- Wants Side (inner grid of 2 columns) -->
<div class="grid grid-cols-2 content-start gap-1">
{% for card in want_cards_available %} {% card_badge card.card card.quantity %} {% empty %}
<div class="text-xs text-center mb-2 ms-3 col-span-2">
{% for card in want_cards_available %}
<div class="flex flex-col content-start gap-1">
<div class="font-bold">{{ card.card.rarity.icon }}</div>
{% card_badge card.card card.quantity %}
</div>
{% empty %}
<div
class="text-xs text-center mb-2{% if expanded %} ms-8{% else %} me-4{% endif %}"
>
None left.
</div>
{% endfor %}
@ -78,7 +90,12 @@
<div class="flex flex-row gap-1 justify-around">
<!-- Has Side -->
<div class="flex flex-col content-start gap-1">
{% for card in have_cards_available %} {% card_badge card.card card.quantity %} {% empty %}
{% for card in have_cards_available %}
<div class="flex flex-col content-start gap-1">
<div class="font-bold">{{ card.card.rarity.icon }}</div>
{% card_badge card.card card.quantity %}
</div>
{% empty %}
<div class="text-xs text-center mb-2 ms-3">
None left.
</div>
@ -86,7 +103,12 @@
</div>
<!-- Wants Side -->
<div class="flex flex-col content-start gap-1">
{% for card in want_cards_available %} {% card_badge card.card card.quantity %} {% empty %}
{% for card in want_cards_available %}
<div class="flex flex-col content-start gap-1">
<div class="font-bold">{{ card.card.rarity.icon }}</div>
{% card_badge card.card card.quantity %}
</div>
{% empty %}
<div
class="text-xs text-center mb-2{% if expanded %} ms-8{% else %} me-4{% endif %}"
>

View file

@ -1,7 +1,107 @@
from django.contrib import admin
from .models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance
admin.site.register(TradeOffer)
admin.site.register(TradeOfferHaveCard)
admin.site.register(TradeOfferWantCard)
admin.site.register(TradeAcceptance)
from .models import TradeAcceptance, TradeOffer, TradeOfferHaveCard, TradeOfferWantCard
@admin.register(TradeOffer)
class TradeOfferAdmin(admin.ModelAdmin):
list_display = (
"hash",
"initiated_by",
"rarity_icon",
"rarity_level",
"is_closed",
"created_at",
"updated_at",
)
list_filter = ("is_closed", "rarity_level")
search_fields = ("hash", "initiated_by__friend_code", "initiated_by__in_game_name")
readonly_fields = ("hash", "created_at", "updated_at", "image")
list_select_related = ("initiated_by__user",)
autocomplete_fields = ("initiated_by",)
def get_queryset(self, request):
# The default manager already includes significant prefetching.
return super().get_queryset(request)
@admin.register(TradeOfferHaveCard)
class TradeOfferHaveCardAdmin(admin.ModelAdmin):
list_display = ("id", "trade_offer", "card", "quantity", "qty_accepted")
search_fields = ("trade_offer__hash", "card__translations__name")
autocomplete_fields = ("trade_offer", "card")
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.select_related(
"trade_offer",
"card__cardset",
"card__rarity",
)
.prefetch_related("card__translations")
)
@admin.register(TradeOfferWantCard)
class TradeOfferWantCardAdmin(admin.ModelAdmin):
list_display = ("id", "trade_offer", "card", "quantity", "qty_accepted")
search_fields = ("trade_offer__hash", "card__translations__name")
autocomplete_fields = ("trade_offer", "card")
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.select_related(
"trade_offer",
"card__cardset",
"card__rarity",
)
.prefetch_related("card__translations")
)
@admin.register(TradeAcceptance)
class TradeAcceptanceAdmin(admin.ModelAdmin):
list_display = (
"hash",
"trade_offer",
"accepted_by",
"state",
"created_at",
"updated_at",
)
list_filter = ("state",)
search_fields = (
"hash",
"trade_offer__hash",
"accepted_by__friend_code",
"accepted_by__in_game_name",
)
readonly_fields = ("hash", "created_at", "updated_at")
autocomplete_fields = (
"trade_offer",
"accepted_by",
"requested_card",
"offered_card",
)
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.select_related(
"trade_offer",
"accepted_by__user",
"requested_card",
"offered_card",
)
.prefetch_related(
"requested_card__translations",
"offered_card__translations",
"trade_offer__want_cards__translations",
"trade_offer__have_cards__translations",
)
)

View file

@ -6,4 +6,4 @@ class TradesConfig(AppConfig):
def ready(self):
# Implicitly connect signal handlers decorated with @receiver.
import pkmntrade_club.trades.signals
pass

View file

@ -1,10 +1,12 @@
from django import forms
from .models import TradeOffer, TradeAcceptance
from django.forms import ModelForm
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
from .models import TradeAcceptance, TradeOffer
class NoValidationMultipleChoiceField(forms.MultipleChoiceField):
def validate(self, value):
@ -126,7 +128,7 @@ class TradeOfferCreateForm(ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate choices from Card model using the new field 'rarity_level' instead of the removed relation.
cards = Card.objects.order_by("name", "rarity_level")
cards = Card.objects.order_by("name", "rarity__level")
choices = [(str(card.pk), card.name) for card in cards]
self.fields["have_cards"].choices = choices
self.fields["want_cards"].choices = choices

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1 on 2025-05-10 01:22
# Generated by Django 5.1 on 2025-06-15 03:44
import django.db.models.deletion
from django.db import migrations, models
@ -9,75 +9,191 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts', '0001_initial'),
('cards', '0001_initial'),
("accounts", "0001_initial"),
("cards", "0001_initial"),
]
operations = [
migrations.CreateModel(
name='TradeOffer',
name="TradeOffer",
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('is_closed', models.BooleanField(db_index=True, default=False)),
('hash', models.CharField(editable=False, max_length=9)),
('rarity_icon', models.CharField(max_length=8, null=True)),
('rarity_level', models.IntegerField(null=True)),
('image', models.ImageField(blank=True, null=True, upload_to='trade_offers/')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_trade_offers', to='accounts.friendcode')),
("id", models.AutoField(primary_key=True, serialize=False)),
("is_closed", models.BooleanField(db_index=True, default=False)),
("hash", models.CharField(editable=False, max_length=9)),
("rarity_icon", models.CharField(max_length=8, null=True)),
("rarity_level", models.IntegerField(null=True)),
(
"image",
models.ImageField(blank=True, null=True, upload_to="trade_offers/"),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"initiated_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="initiated_trade_offers",
to="accounts.friendcode",
),
),
],
),
migrations.CreateModel(
name='TradeAcceptance',
name="TradeAcceptance",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state', models.CharField(choices=[('ACCEPTED', 'Accepted'), ('SENT', 'Sent'), ('RECEIVED', 'Received'), ('THANKED_BY_INITIATOR', 'Thanked by Initiator'), ('THANKED_BY_ACCEPTOR', 'Thanked by Acceptor'), ('THANKED_BY_BOTH', 'Thanked by Both'), ('REJECTED_BY_INITIATOR', 'Rejected by Initiator'), ('REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor')], default='ACCEPTED', max_length=25)),
('hash', models.CharField(blank=True, editable=False, max_length=9)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('accepted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='trade_acceptances', to='accounts.friendcode')),
('offered_card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accepted_offered', to='cards.card')),
('requested_card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accepted_requested', to='cards.card')),
('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='acceptances', to='trades.tradeoffer')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"state",
models.CharField(
choices=[
("ACCEPTED", "Accepted"),
("SENT", "Sent"),
("RECEIVED", "Received"),
("THANKED_BY_INITIATOR", "Thanked by Initiator"),
("THANKED_BY_ACCEPTOR", "Thanked by Acceptor"),
("THANKED_BY_BOTH", "Thanked by Both"),
("REJECTED_BY_INITIATOR", "Rejected by Initiator"),
("REJECTED_BY_ACCEPTOR", "Rejected by Acceptor"),
],
default="ACCEPTED",
max_length=25,
),
),
("hash", models.CharField(blank=True, editable=False, max_length=9)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"accepted_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="trade_acceptances",
to="accounts.friendcode",
),
),
(
"offered_card",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="accepted_offered",
to="cards.card",
),
),
(
"requested_card",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="accepted_requested",
to="cards.card",
),
),
(
"trade_offer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="acceptances",
to="trades.tradeoffer",
),
),
],
),
migrations.CreateModel(
name='TradeOfferHaveCard',
name="TradeOfferHaveCard",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('qty_accepted', models.PositiveIntegerField(default=0, editable=False)),
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cards.card')),
('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trade_offer_have_cards', to='trades.tradeoffer')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("quantity", models.PositiveIntegerField(default=1)),
(
"qty_accepted",
models.PositiveIntegerField(default=0, editable=False),
),
(
"card",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="cards.card"
),
),
(
"trade_offer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="trade_offer_have_cards",
to="trades.tradeoffer",
),
),
],
options={
'ordering': ['card__name'],
'unique_together': {('trade_offer', 'card')},
"ordering": ["card__translations__name"],
"unique_together": {("trade_offer", "card")},
},
),
migrations.AddField(
model_name='tradeoffer',
name='have_cards',
field=models.ManyToManyField(related_name='trade_offers_have', through='trades.TradeOfferHaveCard', to='cards.card'),
model_name="tradeoffer",
name="have_cards",
field=models.ManyToManyField(
related_name="trade_offers_have",
through="trades.TradeOfferHaveCard",
to="cards.card",
),
),
migrations.CreateModel(
name='TradeOfferWantCard',
name="TradeOfferWantCard",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('qty_accepted', models.PositiveIntegerField(default=0, editable=False)),
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cards.card')),
('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trade_offer_want_cards', to='trades.tradeoffer')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("quantity", models.PositiveIntegerField(default=1)),
(
"qty_accepted",
models.PositiveIntegerField(default=0, editable=False),
),
(
"card",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="cards.card"
),
),
(
"trade_offer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="trade_offer_want_cards",
to="trades.tradeoffer",
),
),
],
options={
'ordering': ['card__name'],
'unique_together': {('trade_offer', 'card')},
"ordering": ["card__translations__name"],
"unique_together": {("trade_offer", "card")},
},
),
migrations.AddField(
model_name='tradeoffer',
name='want_cards',
field=models.ManyToManyField(related_name='trade_offers_want', through='trades.TradeOfferWantCard', to='cards.card'),
model_name="tradeoffer",
name="want_cards",
field=models.ManyToManyField(
related_name="trade_offers_want",
through="trades.TradeOfferWantCard",
to="cards.card",
),
),
]

View file

@ -1,13 +1,16 @@
from pkmntrade_club.cards.models import Card
from django.core.exceptions import PermissionDenied
from pkmntrade_club.cards.models import Card
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"))
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()

View file

@ -1,9 +1,12 @@
from django.db import models
from django.core.exceptions import ValidationError
from django.db.models import Prefetch
import hashlib
import uuid
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Prefetch
from pkmntrade_club.cards.models import Card
def generate_tradeoffer_hash():
"""
@ -25,31 +28,52 @@ class TradeOfferManager(models.Manager):
def get_queryset(self):
qs = super().get_queryset()
card_prefetch_selects = [
"card__rarity",
"card__cardset",
"card__card_type",
"card__pkmn_type",
"card__weakness_type",
]
card_prefetch_related = [
"card__translations",
"card__rarity__translations",
"card__cardset__translations",
"card__card_type__translations",
"card__pkmn_type__translations",
"card__weakness_type__translations",
"card__attacks__translations",
"card__abilities__translations",
]
# 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"
),
queryset=TradeOfferHaveCard.objects.select_related(
*card_prefetch_selects
).prefetch_related(*card_prefetch_related),
)
# 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"
),
queryset=TradeOfferWantCard.objects.select_related(
*card_prefetch_selects
).prefetch_related(*card_prefetch_related),
)
# Prefetch for acceptances
# Ensures related 'accepted_by__user', 'requested_card', 'offered_card' are fetched.
prefetch_acceptances = Prefetch(
"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__rarity",
"requested_card__cardset",
"offered_card__rarity",
"offered_card__cardset",
).prefetch_related(
"requested_card__translations", "offered_card__translations"
),
)
qs = qs.select_related(
@ -58,10 +82,15 @@ class TradeOfferManager(models.Manager):
prefetch_have_cards,
prefetch_want_cards,
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"),
# If direct access like offer.have_cards.all() is used, prefetch cards with their rarity
Prefetch(
"have_cards",
queryset=Card.objects.with_details(),
),
Prefetch(
"want_cards",
queryset=Card.objects.with_details(),
),
)
return qs.order_by("-updated_at") # Default ordering for TradeOffer querysets
@ -105,48 +134,46 @@ class TradeOffer(models.Model):
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.
Enforces that all cards in a trade offer share the same rarity.
Uses the first card's rarity details to update both fields.
"""
# Gather all cards from both sides.
cards = list(self.have_cards.all()) + list(self.want_cards.all())
# Gather all cards from both sides, using select_related to prevent N+1 queries on rarity.
cards = list(self.have_cards.select_related("rarity").all()) + list(
self.want_cards.select_related("rarity").all()
)
if not cards:
self.rarity_level = None
self.rarity_icon = None
super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"])
return
# Enforce same rarity across all cards.
rarity_levels = {card.rarity_level for card in cards}
rarity_levels = {card.rarity.level for card in cards}
if len(rarity_levels) > 1:
raise ValidationError(
"All cards in a trade offer must have the same rarity."
)
first_card = cards[0]
if first_card.rarity_level > 5:
if first_card.rarity.level > 5:
raise ValidationError("Cannot trade cards above one-star rarity.")
if (
self.rarity_level != first_card.rarity_level
or self.rarity_icon != first_card.rarity_icon
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
self.rarity_level = first_card.rarity.level
self.rarity_icon = first_card.rarity.icon
# Use super().save() here to avoid recursion.
super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"])
@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
]
# Returns a queryset of have_cards that still have available quantity.
return self.trade_offer_have_cards.filter(quantity__gt=F("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
]
# Returns a queryset of want_cards that still have available quantity.
return self.trade_offer_want_cards.filter(quantity__gt=F("qty_accepted"))
class TradeOfferHaveCard(models.Model):
@ -170,7 +197,7 @@ class TradeOfferHaveCard(models.Model):
return self.quantity - self.qty_accepted
def __str__(self):
return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity_icon} {self.card.name}"
return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity.icon} {self.card.name}"
def save(self, *args, **kwargs):
self.trade_offer.update_rarity_fields()
@ -183,7 +210,7 @@ class TradeOfferHaveCard(models.Model):
class Meta:
unique_together = ("trade_offer", "card")
ordering = ["card__name"]
ordering = ["card__translations__name"]
class TradeOfferWantCard(models.Model):
@ -204,7 +231,7 @@ class TradeOfferWantCard(models.Model):
return self.quantity - self.qty_accepted
def __str__(self):
return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity_icon} {self.card.name}"
return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity.icon} {self.card.name}"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
@ -217,7 +244,7 @@ class TradeOfferWantCard(models.Model):
class Meta:
unique_together = ("trade_offer", "card")
ordering = ["card__name"]
ordering = ["card__translations__name"]
class TradeAcceptance(models.Model):

View file

@ -1,15 +1,18 @@
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from django.contrib.sites.models import Site
from django.core.cache import cache
from django.core.mail import send_mail
from django.db.models import F
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from django.template.loader import render_to_string
from pkmntrade_club.accounts.models import CustomUser
from pkmntrade_club.trades.models import (
TradeAcceptance,
TradeOffer,
TradeOfferHaveCard,
TradeOfferWantCard,
TradeAcceptance,
)
from pkmntrade_club.accounts.models import CustomUser
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.contrib.sites.models import Site
POSITIVE_STATES = [
TradeAcceptance.AcceptanceState.ACCEPTED,
@ -109,10 +112,20 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
if not hasattr(instance, "_actioning_user"):
return
# check if were in debug mode
# if settings.DEBUG:
# print("DEBUG: skipping email notification in debug mode")
# return
# Re-fetch instance with related data to avoid N+1 queries.
instance = (
TradeAcceptance.objects.select_related(
"trade_offer__initiated_by__user",
"accepted_by__user",
"requested_card",
"offered_card",
)
.prefetch_related(
"requested_card__translations",
"offered_card__translations",
)
.get(pk=instance.pk)
)
acting_user = instance._actioning_user
del instance._actioning_user
@ -209,6 +222,11 @@ def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
if created:
return # No action on creation as terminal states are not expected.
# Re-fetch instance with related data to avoid N+1 queries.
instance = TradeAcceptance.objects.select_related(
"trade_offer__initiated_by__user", "accepted_by__user"
).get(pk=instance.pk)
thanks_delta = 0
rejection_delta_initiator = 0 # Delta for the initiator's reputation
rejection_delta_acceptor = 0 # Delta for the acceptor's reputation
@ -303,22 +321,30 @@ def trade_acceptance_reputation_delete(sender, instance, **kwargs):
)
@receiver(post_save, sender=TradeOffer)
@receiver(post_delete, sender=TradeOffer)
def on_trade_offer_change(sender, instance, **kwargs):
"""Invalidate cache for a trade offer when it's changed or deleted."""
cache.delete(f"trade_offer_{instance.pk}")
@receiver(post_save, sender=TradeOfferHaveCard)
@receiver(post_delete, sender=TradeOfferHaveCard)
@receiver(post_save, sender=TradeOfferWantCard)
@receiver(post_delete, sender=TradeOfferWantCard)
def on_trade_offer_card_change(sender, instance, **kwargs):
"""
Invalidate cache for a trade offer when one of its card relationships changes.
"""
if instance.trade_offer:
cache.delete(f"trade_offer_{instance.trade_offer.pk}")
@receiver(post_save, sender=TradeAcceptance)
@receiver(post_delete, sender=TradeAcceptance)
def bubble_up_trade_offer_updates(sender, instance, **kwargs):
def on_trade_acceptance_change(sender, instance, **kwargs):
"""
Bubble up updated_at to the TradeOffer model when related instances change.
Also invalidates any cached image by deleting the file.
Invalidate cache for a trade offer when one of its acceptances changes.
"""
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
elif trade_offer:
trade_offer.save(update_fields=["updated_at"])
if instance.trade_offer:
cache.delete(f"trade_offer_{instance.trade_offer.pk}")

View file

@ -54,8 +54,11 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
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)
.select_related("rarity")
.prefetch_related("translations")
.order_by("translations__name", "rarity__level")
)
friend_codes = self.request.user.friend_codes.all()
if "initiated_by" in self.request.GET:
@ -281,8 +284,11 @@ class TradeOfferSearchView(ListView):
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)
.select_related("rarity")
.prefetch_related("translations")
.order_by("translations__name", "rarity__level")
)
if self.request.method == "POST":
context["have_cards"] = self.request.POST.getlist("have_cards")
@ -514,6 +520,26 @@ class TradeAcceptanceUpdateView(
form_class = TradeAcceptanceTransitionForm
template_name = "trades/trade_acceptance_update.html"
def get_object(self, queryset=None):
"""
Return the object the view is displaying.
Apply select_related for efficiency.
"""
if queryset is None:
queryset = self.get_queryset()
pk = self.kwargs.get(self.pk_url_kwarg)
queryset = queryset.select_related(
"trade_offer__initiated_by__user",
"accepted_by__user",
"requested_card",
"offered_card",
).prefetch_related(
"requested_card__translations",
"offered_card__translations",
)
return queryset.get(pk=pk)
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
friend_codes = request.user.friend_codes.values_list("id", flat=True)
@ -698,7 +724,12 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
request.user.default_friend_code
or request.user.friend_codes.first()
),
"cards": Card.objects.all().order_by("name", "rarity_level"),
"cards": (
Card.objects.filter(rarity__level__lte=5)
.select_related("rarity")
.prefetch_related("translations")
.order_by("translations__name", "rarity__level")
),
}
return render(request, "trades/trade_offer_create.html", context)
messages.success(request, "Trade offer created successfully!")
@ -721,7 +752,12 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
request.user.default_friend_code
or request.user.friend_codes.first()
),
"cards": Card.objects.all().order_by("name", "rarity_level"),
"cards": (
Card.objects.filter(rarity__level__lte=5)
.select_related("rarity")
.prefetch_related("translations")
.order_by("translations__name", "rarity__level")
),
}
return render(request, "trades/trade_offer_create.html", context)
@ -754,7 +790,12 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
"friend_codes": request.user.friend_codes.all(),
"selected_friend_code": request.user.default_friend_code
or request.user.friend_codes.first(),
"cards": Card.objects.all().order_by("name", "rarity_level"),
"cards": (
Card.objects.filter(rarity__level__lte=5)
.select_related("rarity")
.prefetch_related("translations")
.order_by("translations__name", "rarity__level")
),
}
return render(request, "trades/trade_offer_create.html", context)
@ -765,7 +806,9 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
from pkmntrade_club.cards.models import Card
have_cards_ids = [card_id for card_id, _ in have_selections]
cards_have_qs = Card.objects.filter(pk__in=have_cards_ids)
cards_have_qs = Card.objects.filter(pk__in=have_cards_ids).select_related(
"rarity", "cardset"
)
cards_have_dict = {card.pk: card for card in cards_have_qs}
# Define a dummy wrapper for a trade offer card entry.
@ -782,7 +825,9 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
have_offer_cards.append(DummyOfferCard(card, quantity))
want_cards_ids = [card_id for card_id, _ in want_selections]
cards_want_qs = Card.objects.filter(pk__in=want_cards_ids)
cards_want_qs = Card.objects.filter(pk__in=want_cards_ids).select_related(
"rarity", "cardset"
)
cards_want_dict = {card.pk: card for card in cards_want_qs}
want_offer_cards = []
for card_id, quantity in want_selections: