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:
parent
39a002e394
commit
af2f48a491
37 changed files with 2444 additions and 13565 deletions
35
.cursorrules
35
.cursorrules
|
|
@ -10,9 +10,9 @@ Key Principles
|
||||||
|
|
||||||
Django/Python
|
Django/Python
|
||||||
|
|
||||||
- Use Django’s class-based views (CBVs) for more complex views; prefer function-based views (FBVs) for simpler logic.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
Dependencies
|
||||||
|
|
||||||
- Django
|
- Django
|
||||||
- Django REST Framework (for API development)
|
|
||||||
- Celery (for background tasks)
|
- Celery (for background tasks)
|
||||||
- Redis (for caching and task queues)
|
- Redis (for caching and task queues)
|
||||||
- PostgreSQL or MySQL (preferred databases for production)
|
- PostgreSQL or MySQL (preferred databases for production)
|
||||||
|
- Granian / Gunicorn (for serving the application)
|
||||||
|
- Whitenoise (for serving static files)
|
||||||
- Tailwind CSS for the frontend
|
- Tailwind CSS for the frontend
|
||||||
- Django Crispy Forms for the frontend
|
|
||||||
- Django Allauth for authentication
|
- Django Allauth for authentication
|
||||||
- Django DaisyUI for the frontend
|
- Django Crispy Forms for the frontend
|
||||||
- Django El Pagination for the frontend
|
- Crispy Tailwind for Tailwind-compatible Crispy Forms
|
||||||
|
- Django DaisyUI for the admin frontend
|
||||||
- Django Widget Tweaks for the 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
|
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.
|
- 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.
|
- 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).
|
- Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention).
|
||||||
- Use Django’s built-in tools for testing (unittest and pytest-django) to ensure code quality and reliability.
|
- 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.
|
- 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.
|
- Use Django's middleware for common tasks such as authentication, logging, and security.
|
||||||
|
|
||||||
Performance Optimization
|
Performance Optimization
|
||||||
|
|
||||||
- Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching.
|
- Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching.
|
||||||
- Use Django’s 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.
|
- 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.
|
- Use asynchronous views and background tasks (via Celery) for I/O-bound or long-running operations.
|
||||||
- Optimize static file handling with Django’s 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
|
Key Conventions
|
||||||
|
|
||||||
|
|
|
||||||
98
scripts/rollback-deployment.sh
Executable file
98
scripts/rollback-deployment.sh
Executable 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"
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
1
seed/0002_RarityMappings.json
Normal file
1
seed/0002_RarityMappings.json
Normal 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}}]
|
||||||
12717
seed/0003_Cards.json
12717
seed/0003_Cards.json
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
|
||||||
from .forms import CustomUserCreationForm, CustomUserChangeForm
|
from .forms import CustomUserChangeForm, CustomUserCreationForm
|
||||||
from .models import CustomUser
|
from .models import CustomUser, FriendCode
|
||||||
|
|
||||||
|
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
|
|
@ -27,3 +27,11 @@ class CustomUserAdmin(UserAdmin):
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(CustomUser, CustomUserAdmin)
|
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",)
|
||||||
|
|
|
||||||
|
|
@ -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.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
|
|
@ -14,53 +14,183 @@ class Migration(migrations.Migration):
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('auth', '0001_initial'),
|
("auth", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='CustomUser',
|
name="CustomUser",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
"id",
|
||||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
models.BigAutoField(
|
||||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
auto_created=True,
|
||||||
('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')),
|
primary_key=True,
|
||||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
serialize=False,
|
||||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
verbose_name="ID",
|
||||||
('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')),
|
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||||
('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')),
|
"last_login",
|
||||||
('enable_email_notifications', models.BooleanField(default=True, help_text='Receive trade notifications via email.', verbose_name='Enable Email Notifications')),
|
models.DateTimeField(
|
||||||
('reputation_score', models.IntegerField(default=0)),
|
blank=True, null=True, verbose_name="last login"
|
||||||
('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')),
|
),
|
||||||
|
(
|
||||||
|
"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={
|
options={
|
||||||
'verbose_name': 'user',
|
"verbose_name": "user",
|
||||||
'verbose_name_plural': 'users',
|
"verbose_name_plural": "users",
|
||||||
'abstract': False,
|
"abstract": False,
|
||||||
},
|
},
|
||||||
managers=[
|
managers=[
|
||||||
('objects', django.contrib.auth.models.UserManager()),
|
("objects", django.contrib.auth.models.UserManager()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='FriendCode',
|
name="FriendCode",
|
||||||
fields=[
|
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])),
|
"id",
|
||||||
('in_game_name', models.CharField(max_length=14)),
|
models.BigAutoField(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
auto_created=True,
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
primary_key=True,
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='friend_codes', to=settings.AUTH_USER_MODEL)),
|
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(
|
migrations.AddField(
|
||||||
model_name='customuser',
|
model_name="customuser",
|
||||||
name='default_friend_code',
|
name="default_friend_code",
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.friendcode'),
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="accounts.friendcode",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,23 @@
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.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 (
|
from django.views.generic import (
|
||||||
CreateView,
|
CreateView,
|
||||||
DeleteView,
|
DeleteView,
|
||||||
View,
|
|
||||||
TemplateView,
|
TemplateView,
|
||||||
UpdateView,
|
UpdateView,
|
||||||
|
View,
|
||||||
)
|
)
|
||||||
from pkmntrade_club.accounts.models import FriendCode
|
|
||||||
from pkmntrade_club.accounts.forms import FriendCodeForm, UserSettingsForm
|
from pkmntrade_club.accounts.forms import FriendCodeForm, UserSettingsForm
|
||||||
from django.db.models import Case, When, Value, BooleanField
|
from pkmntrade_club.accounts.models import FriendCode
|
||||||
from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance
|
|
||||||
from django.core.exceptions import PermissionDenied
|
|
||||||
from pkmntrade_club.trades.mixins import FriendCodeRequiredMixin
|
|
||||||
from pkmntrade_club.common.mixins import ReusablePaginationMixin
|
from pkmntrade_club.common.mixins import ReusablePaginationMixin
|
||||||
from django.urls import reverse
|
from pkmntrade_club.trades.mixins import FriendCodeRequiredMixin
|
||||||
from django.utils.http import urlencode
|
from pkmntrade_club.trades.models import TradeAcceptance, TradeOffer
|
||||||
|
|
||||||
|
|
||||||
class AddFriendCodeView(LoginRequiredMixin, CreateView):
|
class AddFriendCodeView(LoginRequiredMixin, CreateView):
|
||||||
|
|
@ -204,8 +205,6 @@ class DashboardView(
|
||||||
return {"object_list": object_list, "page_obj": pagination_context}
|
return {"object_list": object_list, "page_obj": pagination_context}
|
||||||
|
|
||||||
def get_involved_acceptances(self, selected_friend_code):
|
def get_involved_acceptances(self, selected_friend_code):
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
terminal_states = [
|
terminal_states = [
|
||||||
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
||||||
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||||
|
|
@ -213,10 +212,25 @@ class DashboardView(
|
||||||
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||||
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||||
]
|
]
|
||||||
involved = TradeAcceptance.objects.filter(
|
involved = (
|
||||||
|
TradeAcceptance.objects.filter(
|
||||||
Q(trade_offer__initiated_by=selected_friend_code)
|
Q(trade_offer__initiated_by=selected_friend_code)
|
||||||
| Q(accepted_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)
|
return involved.exclude(state__in=terminal_states)
|
||||||
|
|
||||||
def get_trade_acceptances_waiting_paginated(self, page_param):
|
def get_trade_acceptances_waiting_paginated(self, page_param):
|
||||||
|
|
@ -271,29 +285,41 @@ class DashboardView(
|
||||||
return {"object_list": object_list, "page_obj": pagination_context}
|
return {"object_list": object_list, "page_obj": pagination_context}
|
||||||
|
|
||||||
def get_closed_acceptances_paginated(self, page_param):
|
def get_closed_acceptances_paginated(self, page_param):
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
selected_friend_code = self.get_selected_friend_code()
|
selected_friend_code = self.get_selected_friend_code()
|
||||||
terminal_success_states = [
|
terminal_success_states = [
|
||||||
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
||||||
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||||
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
||||||
]
|
]
|
||||||
acceptance_qs = TradeAcceptance.objects.filter(
|
acceptance_qs = (
|
||||||
|
TradeAcceptance.objects.filter(
|
||||||
Q(trade_offer__initiated_by=selected_friend_code)
|
Q(trade_offer__initiated_by=selected_friend_code)
|
||||||
| Q(accepted_by=selected_friend_code),
|
| Q(accepted_by=selected_friend_code),
|
||||||
state__in=terminal_success_states,
|
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(
|
object_list, pagination_context = self.paginate_data(
|
||||||
acceptance_qs, int(page_param)
|
acceptance_qs, int(page_param)
|
||||||
)
|
)
|
||||||
return {"object_list": object_list, "page_obj": pagination_context}
|
return {"object_list": object_list, "page_obj": pagination_context}
|
||||||
|
|
||||||
def get_rejected_by_me_paginated(self, page_param):
|
def get_rejected_by_me_paginated(self, page_param):
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
selected_friend_code = self.get_selected_friend_code()
|
selected_friend_code = self.get_selected_friend_code()
|
||||||
rejection = TradeAcceptance.objects.filter(
|
rejection = (
|
||||||
|
TradeAcceptance.objects.filter(
|
||||||
Q(
|
Q(
|
||||||
trade_offer__initiated_by=selected_friend_code,
|
trade_offer__initiated_by=selected_friend_code,
|
||||||
state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||||
|
|
@ -302,15 +328,28 @@ class DashboardView(
|
||||||
accepted_by=selected_friend_code,
|
accepted_by=selected_friend_code,
|
||||||
state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
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))
|
object_list, pagination_context = self.paginate_data(rejection, int(page_param))
|
||||||
return {"object_list": object_list, "page_obj": pagination_context}
|
return {"object_list": object_list, "page_obj": pagination_context}
|
||||||
|
|
||||||
def get_rejected_by_them_paginated(self, page_param):
|
def get_rejected_by_them_paginated(self, page_param):
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
selected_friend_code = self.get_selected_friend_code()
|
selected_friend_code = self.get_selected_friend_code()
|
||||||
rejection = TradeAcceptance.objects.filter(
|
rejection = (
|
||||||
|
TradeAcceptance.objects.filter(
|
||||||
Q(
|
Q(
|
||||||
trade_offer__initiated_by=selected_friend_code,
|
trade_offer__initiated_by=selected_friend_code,
|
||||||
state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||||
|
|
@ -319,7 +358,21 @@ class DashboardView(
|
||||||
accepted_by=selected_friend_code,
|
accepted_by=selected_friend_code,
|
||||||
state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
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))
|
object_list, pagination_context = self.paginate_data(rejection, int(page_param))
|
||||||
return {"object_list": object_list, "page_obj": pagination_context}
|
return {"object_list": object_list, "page_obj": pagination_context}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,68 @@
|
||||||
from django.contrib import admin, messages
|
import hashlib
|
||||||
from django.urls import path
|
import io
|
||||||
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 json
|
import json
|
||||||
import os
|
import os
|
||||||
import re # For parsing set name and ID
|
import re # For parsing set name and ID
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import requests
|
||||||
from django.conf import settings
|
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
|
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):
|
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:
|
if match:
|
||||||
name = match.group(1).strip()
|
name = match.group(1).strip()
|
||||||
set_id = match.group(2)
|
set_id = match.group(2)
|
||||||
return name, set_id
|
return name, set_id
|
||||||
match = re.match(r'^Promo-(.*?)$', set_string)
|
match = re.match(r"^Promo-(.*?)$", set_string)
|
||||||
if match:
|
if match:
|
||||||
name = set_string
|
name = set_string
|
||||||
set_id = 'P-' + match.group(1)
|
set_id = "P-" + match.group(1)
|
||||||
return name, set_id
|
return name, set_id
|
||||||
return set_string, None
|
return set_string, None
|
||||||
|
|
||||||
|
|
||||||
def calculate_card_checksum(card_data):
|
def calculate_card_checksum(card_data):
|
||||||
"""
|
"""
|
||||||
Calculates a SHA256 checksum for a card's 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
|
# 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
|
# Order of keys in `data_to_hash` and sorting of lists are important for consistency
|
||||||
data_to_hash = {
|
data_to_hash = {
|
||||||
'id': card_data.get('id'),
|
"id": card_data.get("id"),
|
||||||
'name': card_data.get('name'),
|
"name": card_data.get("name"),
|
||||||
'type': card_data.get('type'),
|
"type": card_data.get("type"),
|
||||||
'subtype': card_data.get('subtype'),
|
"subtype": card_data.get("subtype"),
|
||||||
'rarity': card_data.get('rarity'), # Rarity name from JSON
|
"rarity": card_data.get("rarity"), # Rarity name from JSON
|
||||||
'health': card_data.get('health'),
|
"health": card_data.get("health"),
|
||||||
'evolvesFrom': card_data.get('evolvesFrom'),
|
"evolvesFrom": card_data.get("evolvesFrom"),
|
||||||
'retreatCost': card_data.get('retreatCost'),
|
"retreatCost": card_data.get("retreatCost"),
|
||||||
'element': card_data.get('element'), # Element name from JSON
|
"element": card_data.get("element"), # Element name from JSON
|
||||||
'weakness': card_data.get('weakness'), # Weakness name from JSON
|
"weakness": card_data.get("weakness"), # Weakness name from JSON
|
||||||
'pack': card_data.get('pack'), # Pack name from JSON
|
"pack": card_data.get("pack"), # Pack name from JSON
|
||||||
# For abilities and attacks, ensure stable order and content
|
# For abilities and attacks, ensure stable order and content
|
||||||
'abilities': 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
|
{"name": a.get("name"), "effect": a.get("effect")}
|
||||||
], key=lambda x: x['name'] if x and x.get('name') else ''),
|
for a in card_data.get("abilities", [])
|
||||||
'attacks': sorted([
|
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'),
|
"name": atk.get("name"),
|
||||||
'effect': atk.get('effect', ''),
|
"effect": atk.get("effect", ""),
|
||||||
'damage': atk.get('damage', ''),
|
"damage": atk.get("damage", ""),
|
||||||
'cost': sorted(atk.get('cost', []) if atk.get('cost') else []) # Sort energy costs
|
"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
|
for atk in card_data.get("attacks", [])
|
||||||
], key=lambda x: x['name'] if x and x.get('name') else ''),
|
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)
|
# 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
|
return sha256_hash
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_card_type(card_data):
|
def _get_or_create_card_type(card_data):
|
||||||
card_type_obj, created = CardType_New.objects.language('en').get_or_create(
|
card_type_obj, created = CardType.objects.language("en").get_or_create(
|
||||||
translations__name=card_data['type'],
|
translations__name=card_data["type"],
|
||||||
translations__subtype=card_data.get('subtype', ''),
|
translations__subtype=card_data.get("subtype", ""),
|
||||||
defaults={'name': card_data['type'], 'subtype': card_data.get('subtype', '')}
|
defaults={"name": card_data["type"], "subtype": card_data.get("subtype", "")},
|
||||||
)
|
)
|
||||||
if not created:
|
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:
|
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.subtype = current_subtype
|
||||||
card_type_obj.save()
|
card_type_obj.save()
|
||||||
return card_type_obj
|
return card_type_obj
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_rarity(card_data, rarity_mappings_dict):
|
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
|
# Attempt to find a mapping for the original rarity name
|
||||||
mapping = rarity_mappings_dict.get(original_rarity_name_from_json)
|
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:
|
elif original_rarity_name_from_json:
|
||||||
# No mapping found, use the original name from JSON, default icon/level
|
# No mapping found, use the original name from JSON, default icon/level
|
||||||
target_rarity_name = original_rarity_name_from_json
|
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
|
target_level = 0 # Default level if no mapping
|
||||||
else:
|
else:
|
||||||
# Rarity is None or empty in JSON, treat as 'Promo'
|
# 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
|
# Check if 'Promo' itself has a mapping
|
||||||
promo_mapping = rarity_mappings_dict.get('Promo')
|
promo_mapping = rarity_mappings_dict.get("Promo")
|
||||||
if promo_mapping:
|
if promo_mapping:
|
||||||
target_icon = promo_mapping.icon
|
target_icon = promo_mapping.icon
|
||||||
target_level = promo_mapping.level
|
target_level = promo_mapping.level
|
||||||
else:
|
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'
|
target_level = 0 # Default level for 'Promo' if no mapping for 'Promo'
|
||||||
|
|
||||||
# Get or create the Rarity_New object using the (potentially mapped) values
|
# Get or create the Rarity object using the (potentially mapped) values
|
||||||
rarity_obj, created = Rarity_New.objects.language('en').get_or_create(
|
rarity_obj, created = Rarity.objects.language("en").get_or_create(
|
||||||
translations__name=target_rarity_name,
|
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
|
# 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
|
return rarity_obj
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_energy(energy_name):
|
def _get_or_create_energy(energy_name):
|
||||||
if not energy_name:
|
if not energy_name:
|
||||||
return None
|
return None
|
||||||
energy_obj, _ = Energy_New.objects.language('en').get_or_create(
|
energy_obj, _ = Energy.objects.language("en").get_or_create(
|
||||||
translations__name=energy_name,
|
translations__name=energy_name, defaults={"name": energy_name}
|
||||||
defaults={'name': energy_name}
|
|
||||||
)
|
)
|
||||||
return energy_obj
|
return energy_obj
|
||||||
|
|
||||||
|
|
||||||
def _update_card_packs(card_obj, card_data, card_set):
|
def _update_card_packs(card_obj, card_data, card_set):
|
||||||
card_obj.packs.clear()
|
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:
|
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_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,
|
translations__name=pack_name_from_json,
|
||||||
cardset=card_set,
|
cardset=card_set,
|
||||||
defaults={
|
defaults={
|
||||||
'name': pack_name_from_json,
|
"name": pack_name_from_json,
|
||||||
'full_name': pack_full_name,
|
"full_name": pack_full_name,
|
||||||
'hex_color': '#FFFFFF'
|
"hex_color": "#FFFFFF",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
card_obj.packs.add(pack_obj)
|
card_obj.packs.add(pack_obj)
|
||||||
else:
|
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():
|
if all_packs_in_set.exists():
|
||||||
card_obj.packs.add(*all_packs_in_set)
|
card_obj.packs.add(*all_packs_in_set)
|
||||||
|
|
||||||
|
|
||||||
def _update_card_abilities(card_obj, card_data):
|
def _update_card_abilities(card_obj, card_data):
|
||||||
card_obj.abilities.clear()
|
card_obj.abilities.clear()
|
||||||
for ability_data in card_data.get('abilities', []):
|
for ability_data in card_data.get("abilities", []):
|
||||||
ability_obj, created = Ability_New.objects.language('en').get_or_create(
|
ability_obj, created = Ability.objects.language("en").get_or_create(
|
||||||
translations__name=ability_data['name'],
|
translations__name=ability_data["name"],
|
||||||
defaults={'name': ability_data['name'], 'effect': ability_data['effect']}
|
defaults={"name": ability_data["name"], "effect": ability_data["effect"]},
|
||||||
)
|
)
|
||||||
if not created and ability_obj.effect != ability_data['effect']:
|
if not created and ability_obj.effect != ability_data["effect"]:
|
||||||
ability_obj.set_current_language('en')
|
ability_obj.set_current_language("en")
|
||||||
ability_obj.effect = ability_data['effect']
|
ability_obj.effect = ability_data["effect"]
|
||||||
ability_obj.save()
|
ability_obj.save()
|
||||||
card_obj.abilities.add(ability_obj)
|
card_obj.abilities.add(ability_obj)
|
||||||
|
|
||||||
|
|
||||||
def _update_card_attacks_and_costs(card_obj, card_data):
|
def _update_card_attacks_and_costs(card_obj, card_data):
|
||||||
card_obj.attacks.clear()
|
card_obj.attacks.clear()
|
||||||
for attack_data in card_data.get('attacks', []):
|
for attack_data in card_data.get("attacks", []):
|
||||||
attack_obj, created = Attack_New.objects.language('en').get_or_create(
|
attack_obj, created = Attack.objects.language("en").get_or_create(
|
||||||
translations__name=attack_data['name'],
|
translations__name=attack_data["name"],
|
||||||
defaults={
|
defaults={
|
||||||
'name': attack_data['name'],
|
"name": attack_data["name"],
|
||||||
'effect': attack_data.get('effect', ''),
|
"effect": attack_data.get("effect", ""),
|
||||||
'damage': attack_data.get('damage', '')
|
"damage": attack_data.get("damage", ""),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
needs_save = False
|
needs_save = False
|
||||||
if not created:
|
if not created:
|
||||||
json_effect = attack_data.get('effect', '')
|
json_effect = attack_data.get("effect", "")
|
||||||
if attack_obj.effect != json_effect:
|
if attack_obj.effect != json_effect:
|
||||||
attack_obj.set_current_language('en')
|
attack_obj.set_current_language("en")
|
||||||
attack_obj.effect = json_effect
|
attack_obj.effect = json_effect
|
||||||
needs_save = True
|
needs_save = True
|
||||||
|
|
||||||
json_damage = attack_data.get('damage', '')
|
json_damage = attack_data.get("damage", "")
|
||||||
if attack_obj.damage != json_damage:
|
if attack_obj.damage != json_damage:
|
||||||
attack_obj.damage = json_damage
|
attack_obj.damage = json_damage
|
||||||
needs_save = True
|
needs_save = True
|
||||||
|
|
@ -210,63 +263,65 @@ def _update_card_attacks_and_costs(card_obj, card_data):
|
||||||
|
|
||||||
attack_obj.energy_cost.clear()
|
attack_obj.energy_cost.clear()
|
||||||
energy_counts = {}
|
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
|
energy_counts[cost_energy_name] = energy_counts.get(cost_energy_name, 0) + 1
|
||||||
|
|
||||||
for energy_name, quantity in energy_counts.items():
|
for energy_name, quantity in energy_counts.items():
|
||||||
energy_obj = _get_or_create_energy(energy_name)
|
energy_obj = _get_or_create_energy(energy_name)
|
||||||
if energy_obj:
|
if energy_obj:
|
||||||
AttackCost_New.objects.update_or_create(
|
AttackCost.objects.update_or_create(
|
||||||
attack=attack_obj,
|
attack=attack_obj,
|
||||||
energy=energy_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.
|
Processes a single card's data from the JSON.
|
||||||
Updates stats_accumulator with newly_imported_count, updated_count, or skipped_count.
|
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.
|
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)
|
incoming_checksum = calculate_card_checksum(card_data)
|
||||||
error_tracking['card_id'] = card_id
|
error_tracking["card_id"] = card_id
|
||||||
|
|
||||||
try:
|
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:
|
if existing_card.checksum == incoming_checksum:
|
||||||
stats_accumulator['skipped_count'] += 1
|
stats_accumulator["skipped_count"] += 1
|
||||||
return
|
return
|
||||||
except Card_New.DoesNotExist:
|
except Card.DoesNotExist:
|
||||||
existing_card = None
|
existing_card = None
|
||||||
|
|
||||||
card_type_obj = _get_or_create_card_type(card_data)
|
card_type_obj = _get_or_create_card_type(card_data)
|
||||||
rarity_obj = _get_or_create_rarity(card_data, rarity_mappings_dict)
|
rarity_obj = _get_or_create_rarity(card_data, rarity_mappings_dict)
|
||||||
pkmn_type_obj = _get_or_create_energy(card_data.get('element'))
|
pkmn_type_obj = _get_or_create_energy(card_data.get("element"))
|
||||||
weakness_type_obj = _get_or_create_energy(card_data.get('weakness'))
|
weakness_type_obj = _get_or_create_energy(card_data.get("weakness"))
|
||||||
|
|
||||||
card_defaults = {
|
card_defaults = {
|
||||||
'name': card_data['name'],
|
"name": card_data["name"],
|
||||||
'cardset': card_set,
|
"cardset": card_set,
|
||||||
'card_type': card_type_obj,
|
"card_type": card_type_obj,
|
||||||
'rarity': rarity_obj,
|
"rarity": rarity_obj,
|
||||||
'health': card_data.get('health'),
|
"health": card_data.get("health"),
|
||||||
'evolves_from_name': card_data.get('evolvesFrom'),
|
"evolves_from_name": card_data.get("evolvesFrom"),
|
||||||
'retreat_cost': card_data.get('retreatCost'),
|
"retreat_cost": card_data.get("retreatCost"),
|
||||||
'pkmn_type': pkmn_type_obj,
|
"pkmn_type": pkmn_type_obj,
|
||||||
'weakness_type': weakness_type_obj,
|
"weakness_type": weakness_type_obj,
|
||||||
'checksum': incoming_checksum
|
"checksum": incoming_checksum,
|
||||||
}
|
}
|
||||||
|
|
||||||
card_obj, card_created = Card_New.objects.language('en').update_or_create(
|
card_obj, card_created = Card.objects.language("en").update_or_create(
|
||||||
id=card_id,
|
id=card_id, defaults=card_defaults
|
||||||
defaults=card_defaults
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if card_created:
|
if card_created:
|
||||||
stats_accumulator['newly_imported_count'] += 1
|
stats_accumulator["newly_imported_count"] += 1
|
||||||
elif existing_card:
|
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.
|
# 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.
|
# 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.
|
# 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.
|
# 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.
|
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.
|
Halts and rolls back on any error.
|
||||||
"""
|
"""
|
||||||
print("Card import process started.")
|
print("Card import process started.")
|
||||||
base_path = os.path.join(settings.BASE_DIR, 'REMOTE_GIT_REPOS', 'pokemon-tcg-pocket-card-database', 'cards', 'en')
|
result = ImportResult()
|
||||||
|
error_tracking = {"file_name": "N/A", "card_id": "N/A"}
|
||||||
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"}
|
|
||||||
|
|
||||||
# Fetch all rarity mappings once
|
# Fetch all rarity mappings once
|
||||||
rarity_mappings = RarityMapping.objects.all()
|
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.")
|
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:
|
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():
|
with transaction.atomic():
|
||||||
for idx, file_name in enumerate(json_files):
|
stats_accumulator = {
|
||||||
error_tracking['file_name'] = file_name
|
"newly_imported_count": 0,
|
||||||
error_tracking['card_id'] = "N/A"
|
"updated_count": 0,
|
||||||
file_path = os.path.join(base_path, file_name)
|
"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:
|
print(f"Processing file: {file_name} ({idx + 1}/{total_files})")
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
if not data:
|
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]
|
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:
|
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)
|
parsed_set_name, parsed_set_id = parse_set_details(set_info_str)
|
||||||
if not parsed_set_id:
|
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 = {
|
card_set_defaults = {"name": parsed_set_name, "file_name": file_name}
|
||||||
'name': parsed_set_name,
|
card_set, _ = CardSet.objects.language("en").update_or_create(
|
||||||
'file_name': file_name
|
id=parsed_set_id, defaults=card_set_defaults
|
||||||
}
|
|
||||||
card_set, _ = CardSet_New.objects.language('en').update_or_create(
|
|
||||||
id=parsed_set_id,
|
|
||||||
defaults=card_set_defaults
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for card_data_item in data:
|
for card_data_item in data:
|
||||||
print("Processing card: ", card_data_item['id'])
|
print("Processing card: ", card_data_item["id"])
|
||||||
_process_single_card_data(card_data_item, card_set, stats, error_tracking, rarity_mappings_dict)
|
_process_single_card_data(
|
||||||
|
card_data_item,
|
||||||
|
card_set,
|
||||||
|
stats_accumulator,
|
||||||
|
error_tracking,
|
||||||
|
rarity_mappings_dict,
|
||||||
|
)
|
||||||
|
|
||||||
print(f"Finished processing file: {file_name}")
|
print(f"Finished processing file: {file_name}")
|
||||||
|
|
||||||
success_message = (
|
result.newly_imported_count = stats_accumulator["newly_imported_count"]
|
||||||
f"Import completed successfully. Processed {stats['files_processed_count']} files. "
|
result.updated_count = stats_accumulator["updated_count"]
|
||||||
f"Imported {stats['newly_imported_count']} new cards. "
|
result.skipped_count = stats_accumulator["skipped_count"]
|
||||||
f"Updated {stats['updated_count']} existing cards. "
|
|
||||||
f"Skipped {stats['skipped_count']} unchanged cards."
|
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.")
|
print("Committing transaction.")
|
||||||
transaction.on_commit(lambda: print(success_message))
|
transaction.on_commit(lambda: print(result.message))
|
||||||
return stats['newly_imported_count'], stats['updated_count'], False, success_message, stats['files_processed_count'], stats['skipped_count']
|
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:
|
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)}"
|
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}"
|
result.has_error = True
|
||||||
print(halt_message)
|
result.message = (
|
||||||
# Return 0 for counts as the transaction is rolled back
|
f"Import HALTED. All changes rolled back. Reason: {error_detail}"
|
||||||
return 0, 0, True, halt_message, stats['files_processed_count'], stats.get('skipped_count', 0)
|
)
|
||||||
|
print(result.message)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
if admin.site.is_registered(CardSet_New): admin.site.unregister(CardSet_New)
|
@admin.register(CardSet)
|
||||||
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)
|
|
||||||
class CardSetAdmin(TranslatableAdmin):
|
class CardSetAdmin(TranslatableAdmin):
|
||||||
list_display = ('id', 'name', 'file_name')
|
list_display = ("id", "name", "file_name")
|
||||||
readonly_fields = ('id', 'file_name', 'created_at', 'updated_at')
|
search_fields = ("translations__name",)
|
||||||
search_fields = ('translations__name',)
|
readonly_fields = ("id", "file_name", "created_at", "updated_at", "deleted_at")
|
||||||
readonly_fields = ('id', '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):
|
class PackAdmin(TranslatableAdmin):
|
||||||
list_display = ('id', 'full_name', 'name', 'cardset', 'hex_color')
|
list_display = ("id", "full_name", "name", "cardset", "hex_color")
|
||||||
list_filter = ('cardset',)
|
list_filter = ("cardset",)
|
||||||
search_fields = ('translations__name', 'translations__full_name')
|
search_fields = ("translations__name", "translations__full_name")
|
||||||
readonly_fields = ('id', 'created_at', 'updated_at')
|
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):
|
class EnergyAdmin(TranslatableAdmin):
|
||||||
list_display = ('id', 'name')
|
list_display = ("id", "name")
|
||||||
search_fields = ('translations__name',)
|
search_fields = ("translations__name",)
|
||||||
readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at')
|
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):
|
class AttackAdmin(TranslatableAdmin):
|
||||||
list_display = ('id', 'name', 'damage', 'effect')
|
list_display = ("id", "name", "damage", "effect")
|
||||||
search_fields = ('translations__name',)
|
search_fields = ("translations__name",)
|
||||||
readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at')
|
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):
|
class AbilityAdmin(TranslatableAdmin):
|
||||||
list_display = ('id', 'name', 'effect')
|
list_display = ("id", "name", "effect")
|
||||||
search_fields = ('translations__name',)
|
search_fields = ("translations__name",)
|
||||||
readonly_fields = ('id', 'created_at', 'updated_at')
|
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):
|
class RarityAdmin(TranslatableAdmin):
|
||||||
list_display = ('id', 'name', 'icon', 'level')
|
list_display = ("id", "name", "icon", "level")
|
||||||
search_fields = ('translations__name',)
|
search_fields = ("translations__name",)
|
||||||
readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at')
|
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):
|
class CardTypeAdmin(TranslatableAdmin):
|
||||||
list_display = ('id', 'name', 'subtype')
|
list_display = ("id", "name", "subtype")
|
||||||
search_fields = ('translations__name', 'translations__subtype')
|
search_fields = ("translations__name", "translations__subtype")
|
||||||
readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at')
|
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):
|
class CardAdmin(TranslatableAdmin):
|
||||||
list_display = ('id', 'cardnum', 'name', 'cardset', 'card_type', 'rarity', 'health', 'pkmn_type')
|
list_display = (
|
||||||
list_filter = ('cardset', 'card_type', 'rarity', 'pkmn_type', 'packs')
|
"id",
|
||||||
search_fields = ('id', 'translations__name', 'cardset__translations__name', 'packs__translations__name')
|
"cardnum",
|
||||||
filter_horizontal = ('packs', 'abilities', 'attacks')
|
"name",
|
||||||
readonly_fields = ('id', 'cardnum', 'created_at', 'updated_at', 'deleted_at')
|
"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)
|
@admin.register(RarityMapping)
|
||||||
class RarityMappingAdmin(admin.ModelAdmin):
|
class RarityMappingAdmin(admin.ModelAdmin):
|
||||||
list_display = ('original_name', 'mapped_name', 'icon', 'level', 'created_at', 'updated_at', 'deleted_at')
|
list_display = (
|
||||||
search_fields = ('original_name', 'mapped_name')
|
"original_name",
|
||||||
list_filter = ('level',)
|
"mapped_name",
|
||||||
readonly_fields = ('created_at', 'updated_at', 'deleted_at')
|
"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 get_admin_urls(urls):
|
||||||
def importer_view(request):
|
def importer_view(request):
|
||||||
context = {
|
context = {
|
||||||
'title': 'Card Importer',
|
"title": "Card Importer",
|
||||||
'site_header': admin.site.site_header,
|
"site_header": admin.site.site_header,
|
||||||
'site_title': admin.site.site_title,
|
"site_title": admin.site.site_title,
|
||||||
'index_title': admin.site.index_title,
|
"index_title": admin.site.index_title,
|
||||||
'has_permission': admin.site.has_permission(request),
|
"has_permission": admin.site.has_permission(request),
|
||||||
'app_label': 'cards',
|
"app_label": "cards",
|
||||||
}
|
}
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
new, updated, has_error, message_text, files_processed, skipped = perform_card_import_logic()
|
result = perform_card_import_logic()
|
||||||
|
|
||||||
if has_error:
|
if result.has_error:
|
||||||
messages.error(request, message_text + f" Files attempted before halt: {files_processed}.")
|
message = result.message
|
||||||
|
if result.files_processed_count > 0:
|
||||||
|
message += (
|
||||||
|
f" Files attempted before halt: {result.files_processed_count}."
|
||||||
|
)
|
||||||
|
messages.error(request, message)
|
||||||
else:
|
else:
|
||||||
messages.success(request, message_text)
|
messages.success(request, result.message)
|
||||||
|
|
||||||
return HttpResponseRedirect(request.path_info)
|
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 = [
|
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
|
return custom_urls + urls
|
||||||
|
|
||||||
|
|
||||||
original_get_urls = admin.site.get_urls
|
original_get_urls = admin.site.get_urls
|
||||||
|
|
||||||
|
|
||||||
def new_get_urls():
|
def new_get_urls():
|
||||||
urls = original_get_urls()
|
urls = original_get_urls()
|
||||||
return get_admin_urls(urls)
|
return get_admin_urls(urls)
|
||||||
|
|
||||||
|
|
||||||
admin.site.get_urls = new_get_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
|
||||||
|
|
|
||||||
|
|
@ -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 django.db.models.deletion
|
||||||
|
import parler.fields
|
||||||
|
import parler.models
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -8,64 +10,733 @@ class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Card',
|
name="Ability",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
('name', models.CharField(max_length=64)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
('cardset', models.CharField(max_length=32)),
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
('cardnum', models.IntegerField()),
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
('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)),
|
|
||||||
],
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Ability",
|
||||||
|
"verbose_name_plural": "Abilities",
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatableModelMixin, models.Model),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Deck',
|
name="Attack",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
('name', models.CharField(max_length=64)),
|
(
|
||||||
('hex_color', models.CharField(max_length=9)),
|
"damage",
|
||||||
('cardset', models.CharField(max_length=8)),
|
models.CharField(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
blank=True,
|
||||||
('updated_at', models.DateTimeField(auto_now=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(
|
migrations.CreateModel(
|
||||||
name='CardNameTranslation',
|
name="CardSet",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
(
|
||||||
('name', models.CharField(max_length=64)),
|
"id",
|
||||||
('language', models.CharField(max_length=64)),
|
models.CharField(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
help_text="The ID for the set, e.g., 'A1', 'A1a'.",
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
max_length=3,
|
||||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.card')),
|
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(
|
migrations.AddField(
|
||||||
model_name='card',
|
model_name="attack",
|
||||||
name='decks',
|
name="energy_cost",
|
||||||
field=models.ManyToManyField(to='cards.deck'),
|
field=models.ManyToManyField(
|
||||||
|
related_name="attacks", through="cards.AttackCost", to="cards.energy"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='DeckNameTranslation',
|
name="Pack",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
('name', models.CharField(max_length=64)),
|
("hex_color", models.CharField(max_length=9)),
|
||||||
('language', models.CharField(max_length=64)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
('deck', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.deck')),
|
(
|
||||||
],
|
"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),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,35 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from parler.models import TranslatableModel, TranslatedFields
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
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):
|
class CardSet(TranslatableModel):
|
||||||
|
|
@ -29,8 +58,8 @@ class CardSet(TranslatableModel):
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Card Set (New)")
|
verbose_name = _("Card Set")
|
||||||
verbose_name_plural = _("Card Sets (New)")
|
verbose_name_plural = _("Card Sets")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.id} - {self.name}"
|
return f"{self.id} - {self.name}"
|
||||||
|
|
@ -58,8 +87,8 @@ class Pack(TranslatableModel):
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Pack (New)")
|
verbose_name = _("Pack")
|
||||||
verbose_name_plural = _("Packs (New)")
|
verbose_name_plural = _("Packs")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.full_name}"
|
return f"{self.full_name}"
|
||||||
|
|
@ -79,8 +108,8 @@ class Energy(TranslatableModel):
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Energy (New)")
|
verbose_name = _("Energy")
|
||||||
verbose_name_plural = _("Energies (New)")
|
verbose_name_plural = _("Energies")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name}"
|
return f"{self.name}"
|
||||||
|
|
@ -101,8 +130,8 @@ class AttackCost(models.Model):
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Attack Cost (New)")
|
verbose_name = _("Attack Cost")
|
||||||
verbose_name_plural = _("Attack Costs (New)")
|
verbose_name_plural = _("Attack Costs")
|
||||||
unique_together = ("attack", "energy")
|
unique_together = ("attack", "energy")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
@ -133,8 +162,8 @@ class Attack(TranslatableModel):
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Attack (New)")
|
verbose_name = _("Attack")
|
||||||
verbose_name_plural = _("Attacks (New)")
|
verbose_name_plural = _("Attacks")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name}"
|
return f"{self.name}"
|
||||||
|
|
@ -155,8 +184,8 @@ class Ability(TranslatableModel):
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Ability (New)")
|
verbose_name = _("Ability")
|
||||||
verbose_name_plural = _("Abilities (New)")
|
verbose_name_plural = _("Abilities")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name}"
|
return f"{self.name}"
|
||||||
|
|
@ -178,8 +207,8 @@ class Rarity(TranslatableModel):
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Rarity (New)")
|
verbose_name = _("Rarity")
|
||||||
verbose_name_plural = _("Rarities (New)")
|
verbose_name_plural = _("Rarities")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name}"
|
return f"{self.name}"
|
||||||
|
|
@ -205,8 +234,8 @@ class CardType(TranslatableModel):
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Card Type (New)")
|
verbose_name = _("Card Type")
|
||||||
verbose_name_plural = _("Card Types (New)")
|
verbose_name_plural = _("Card Types")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name}"
|
return f"{self.name}"
|
||||||
|
|
@ -217,6 +246,8 @@ class Card(TranslatableModel):
|
||||||
Represents a single, unique digital printing of a Pokémon card.
|
Represents a single, unique digital printing of a Pokémon card.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
objects = CardManager()
|
||||||
|
|
||||||
translations = TranslatedFields(
|
translations = TranslatedFields(
|
||||||
name=models.CharField(max_length=32, help_text=_("The name of the card.")),
|
name=models.CharField(max_length=32, help_text=_("The name of the card.")),
|
||||||
evolves_from_name=models.CharField(
|
evolves_from_name=models.CharField(
|
||||||
|
|
@ -271,13 +302,19 @@ class Card(TranslatableModel):
|
||||||
attacks = models.ManyToManyField(Attack, related_name="cards")
|
attacks = models.ManyToManyField(Attack, related_name="cards")
|
||||||
rarity = models.ForeignKey(Rarity, on_delete=models.CASCADE, 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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Card (New)")
|
verbose_name = _("Card")
|
||||||
verbose_name_plural = _("Cards (New)")
|
verbose_name_plural = _("Cards")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.id} {self.name}"
|
return f"{self.id} {self.name}"
|
||||||
|
|
|
||||||
|
|
@ -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 django.dispatch import receiver
|
||||||
|
|
||||||
|
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard
|
||||||
|
|
||||||
from .models import Card
|
from .models import Card
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -34,24 +38,68 @@ def color_is_dark(bg_color):
|
||||||
return brightness <= 200
|
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):
|
def update_card_style(sender, instance, action, **kwargs):
|
||||||
if action == "post_add":
|
if action == "post_add":
|
||||||
decks = instance.decks.all()
|
packs = instance.packs.all()
|
||||||
num_decks = decks.count()
|
num_packs = packs.count()
|
||||||
if num_decks == 1:
|
|
||||||
instance.style = "background-color: " + decks.first().hex_color + ";"
|
style_parts = []
|
||||||
elif num_decks >= 2:
|
|
||||||
hex_colors = [deck.hex_color for deck in decks]
|
if num_packs == 0:
|
||||||
instance.style = (
|
style_parts.append(
|
||||||
f"background: linear-gradient(to right, {', '.join(hex_colors)});"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
instance.style = (
|
|
||||||
"background: linear-gradient(to right, #AAAAAA, #AAAAAA, #AAAAAA);"
|
"background: linear-gradient(to right, #AAAAAA, #AAAAAA, #AAAAAA);"
|
||||||
)
|
)
|
||||||
if not color_is_dark(decks.first().hex_color):
|
style_parts.append("text-shadow: 0 0 0 #fff;")
|
||||||
instance.style += "color: var(--color-gray-700); text-shadow: 0 0 0 var(--color-gray-700);"
|
|
||||||
else:
|
else:
|
||||||
instance.style += "text-shadow: 0 0 0 #fff;"
|
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"])
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
@ -17,11 +17,12 @@ def card_badge(context, card, quantity=None, expanded=False):
|
||||||
"quantity": quantity,
|
"quantity": quantity,
|
||||||
"style": card.style,
|
"style": card.style,
|
||||||
"name": card.name,
|
"name": card.name,
|
||||||
"rarity": card.rarity_icon,
|
"rarity": card.rarity.icon,
|
||||||
"cardset": card.cardset,
|
"cardset": card.cardset.id,
|
||||||
"expanded": expanded,
|
"expanded": expanded,
|
||||||
"cache_key": f"card_badge_{card.pk}_{quantity}_{expanded}",
|
"cache_key": f"card_badge_{card.pk}_{quantity}_{expanded}",
|
||||||
"url": url,
|
"url": url,
|
||||||
|
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
|
||||||
}
|
}
|
||||||
context.update(tag_context)
|
context.update(tag_context)
|
||||||
return context
|
return context
|
||||||
|
|
@ -37,11 +38,11 @@ def card_badge_inline(card, quantity=None):
|
||||||
"quantity": quantity,
|
"quantity": quantity,
|
||||||
"style": card.style,
|
"style": card.style,
|
||||||
"name": card.name,
|
"name": card.name,
|
||||||
"rarity": card.rarity_icon,
|
"rarity": card.rarity,
|
||||||
"cardset": card.cardset,
|
"cardset": card.cardset,
|
||||||
"expanded": True,
|
"expanded": True,
|
||||||
"cache_key": f"card_badge_{card.pk}_{quantity}_{True}",
|
"cache_key": f"card_badge_{card.pk}_{quantity}_{True}",
|
||||||
"CACHE_TIMEOUT": settings.CACHE_TIMEOUT,
|
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
|
||||||
"url": url,
|
"url": url,
|
||||||
}
|
}
|
||||||
html = render_to_string("templatetags/card_badge.html", tag_context)
|
html = render_to_string("templatetags/card_badge.html", tag_context)
|
||||||
|
|
|
||||||
|
|
@ -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 hashlib
|
||||||
|
import json
|
||||||
import logging
|
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()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
@ -18,7 +20,7 @@ def get_item(dictionary, key):
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def fetch_all_cards():
|
def fetch_all_cards():
|
||||||
"""Simple tag to fetch all Card objects."""
|
"""Simple tag to fetch all Card objects."""
|
||||||
return Card.objects.order_by("pk").all()
|
return Card.objects.with_details().order_by("id").all()
|
||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag("templatetags/card_multiselect.html", takes_context=True)
|
@register.inclusion_tag("templatetags/card_multiselect.html", takes_context=True)
|
||||||
|
|
|
||||||
|
|
@ -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.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):
|
class CardDetailView(DetailView):
|
||||||
|
|
@ -14,6 +15,24 @@ class CardDetailView(DetailView):
|
||||||
template_name = "cards/card_detail.html"
|
template_name = "cards/card_detail.html"
|
||||||
context_object_name = "card"
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
card = self.get_object()
|
card = self.get_object()
|
||||||
|
|
@ -94,18 +113,43 @@ class CardListView(ReusablePaginationMixin, ListView):
|
||||||
|
|
||||||
def get_ordering(self):
|
def get_ordering(self):
|
||||||
order = self.request.GET.get("order", "absolute")
|
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":
|
if order == "alphabetical":
|
||||||
return "name"
|
return "name"
|
||||||
elif order == "rarity":
|
elif order == "rarity":
|
||||||
return "-rarity_level"
|
return "-rarity__level"
|
||||||
else: # absolute ordering
|
else: # absolute ordering
|
||||||
return "id"
|
return "id"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
ordering = self.get_ordering()
|
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)
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
@ -114,55 +158,14 @@ class CardListView(ReusablePaginationMixin, ListView):
|
||||||
context["order"] = order
|
context["order"] = order
|
||||||
context["group_by"] = group_by
|
context["group_by"] = group_by
|
||||||
|
|
||||||
if group_by in ("deck", "cardset", "rarity"):
|
# Unified pagination logic for all cases.
|
||||||
full_qs = self.get_queryset()
|
# The complex manual grouping logic has been removed for performance.
|
||||||
all_cards = list(full_qs)
|
# The template should now use the `regroup` template tag for display.
|
||||||
flat_cards = []
|
|
||||||
if group_by == "deck":
|
|
||||||
for card in all_cards:
|
|
||||||
for deck in card.decks.all():
|
|
||||||
flat_cards.append({"group": deck.name, "card": card})
|
|
||||||
flat_cards.sort(key=lambda x: x["group"].lower())
|
|
||||||
elif group_by == "cardset":
|
|
||||||
for card in all_cards:
|
|
||||||
flat_cards.append({"group": card.cardset, "card": card})
|
|
||||||
flat_cards.sort(key=lambda x: x["group"].lower())
|
|
||||||
elif group_by == "rarity":
|
|
||||||
for card in all_cards:
|
|
||||||
flat_cards.append(
|
|
||||||
{
|
|
||||||
"group": card.rarity_icon,
|
|
||||||
"sort_group": card.rarity_level,
|
|
||||||
"card": card,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
flat_cards.sort(key=lambda x: x["sort_group"], reverse=True)
|
|
||||||
|
|
||||||
page_number = self.get_page_number()
|
page_number = self.get_page_number()
|
||||||
self.per_page = 36
|
self.per_page = 36
|
||||||
page_flat_cards, pagination_context = self.paginate_data(
|
queryset = self.get_queryset()
|
||||||
flat_cards, page_number
|
paginated_cards, pagination_context = self.paginate_data(queryset, page_number)
|
||||||
)
|
|
||||||
|
|
||||||
page_groups = []
|
|
||||||
for item in page_flat_cards:
|
|
||||||
group_value = item["group"]
|
|
||||||
card_obj = item["card"]
|
|
||||||
if page_groups and page_groups[-1]["group"] == group_value:
|
|
||||||
page_groups[-1]["cards"].append(card_obj)
|
|
||||||
else:
|
|
||||||
page_groups.append({"group": group_value, "cards": [card_obj]})
|
|
||||||
context["groups"] = page_groups
|
|
||||||
context["page_obj"] = pagination_context
|
|
||||||
context["total_cards"] = len(flat_cards)
|
|
||||||
context["object_list"] = full_qs
|
|
||||||
else:
|
|
||||||
page_number = self.get_page_number()
|
|
||||||
self.per_page = 36
|
|
||||||
paginated_cards, pagination_context = self.paginate_data(
|
|
||||||
self.get_queryset(), page_number
|
|
||||||
)
|
|
||||||
context["cards"] = paginated_cards
|
context["cards"] = paginated_cards
|
||||||
context["page_obj"] = pagination_context
|
context["page_obj"] = pagination_context
|
||||||
context["object_list"] = self.get_queryset()
|
context["object_list"] = queryset
|
||||||
return context
|
return context
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
|
import random
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
def cache_settings(request):
|
def cache_settings(request):
|
||||||
|
"""
|
||||||
|
Pass cache settings to the template context.
|
||||||
|
Applies jitter to the timeouts.
|
||||||
|
"""
|
||||||
|
jitter = settings.CACHE_JITTER
|
||||||
return {
|
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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import socket
|
|
||||||
from pathlib import Path
|
|
||||||
import environ
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import environ
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from pkmntrade_club._version import __version__, get_version_info
|
from pkmntrade_club._version import __version__, get_version_info
|
||||||
|
|
||||||
# set default values to local dev values
|
# set default values to local dev values
|
||||||
|
|
@ -35,7 +37,11 @@ env = environ.Env(
|
||||||
ACCOUNT_EMAIL_VERIFICATION=(str, "none"),
|
ACCOUNT_EMAIL_VERIFICATION=(str, "none"),
|
||||||
SCHEME=(str, "http"),
|
SCHEME=(str, "http"),
|
||||||
REDIS_URL=(str, "redis://localhost:6379"),
|
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"),
|
TIME_ZONE=(str, "America/Los_Angeles"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -92,7 +98,11 @@ environ.Env.read_env(os.path.join(BASE_DIR, ".env"))
|
||||||
SCHEME = env("SCHEME")
|
SCHEME = env("SCHEME")
|
||||||
PUBLIC_HOST = env("PUBLIC_HOST")
|
PUBLIC_HOST = env("PUBLIC_HOST")
|
||||||
REDIS_URL = env("REDIS_URL")
|
REDIS_URL = env("REDIS_URL")
|
||||||
CACHE_TIMEOUT = env("CACHE_TIMEOUT")
|
CACHE_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_SIGNUPS = env("DISABLE_SIGNUPS")
|
||||||
DISABLE_CACHE = env("DISABLE_CACHE")
|
DISABLE_CACHE = env("DISABLE_CACHE")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
|
import logging
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from django.views.generic import TemplateView
|
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
|
Max,
|
||||||
Sum,
|
Sum,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce
|
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 (
|
from pkmntrade_club.trades.models import (
|
||||||
TradeOffer,
|
TradeOffer,
|
||||||
)
|
)
|
||||||
from pkmntrade_club.cards.models import Card
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -22,8 +25,10 @@ class HomePageView(TemplateView):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get all cards ordered by name, exclude cards with rarity level > 5
|
# Get all cards ordered by name, exclude cards with rarity level > 5
|
||||||
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by(
|
context["cards"] = (
|
||||||
"name", "rarity_level"
|
Card.objects.with_details()
|
||||||
|
.filter(rarity__level__lte=5)
|
||||||
|
.order_by("translations__name", "rarity__level")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reuse base trade offer queryset for market stats
|
# Reuse base trade offer queryset for market stats
|
||||||
|
|
@ -33,9 +38,15 @@ class HomePageView(TemplateView):
|
||||||
try:
|
try:
|
||||||
recent_offers_qs = base_offer_qs.order_by("-created_at")[:6]
|
recent_offers_qs = base_offer_qs.order_by("-created_at")[:6]
|
||||||
context["recent_offers"] = recent_offers_qs
|
context["recent_offers"] = recent_offers_qs
|
||||||
|
latest_update = recent_offers_qs.aggregate(latest=Max("updated_at"))[
|
||||||
|
"latest"
|
||||||
|
]
|
||||||
|
if latest_update:
|
||||||
context["cache_key_recent_offers"] = (
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching recent offers: {str(e)}")
|
logger.error(f"Error fetching recent offers: {str(e)}")
|
||||||
context["recent_offers"] = []
|
context["recent_offers"] = []
|
||||||
|
|
@ -44,15 +55,22 @@ class HomePageView(TemplateView):
|
||||||
# Most Offered Cards
|
# Most Offered Cards
|
||||||
try:
|
try:
|
||||||
most_offered_cards_qs = (
|
most_offered_cards_qs = (
|
||||||
Card.objects.filter(tradeofferhavecard__isnull=False)
|
Card.objects.with_details()
|
||||||
.filter(rarity_level__lte=5)
|
.filter(tradeofferhavecard__isnull=False)
|
||||||
|
.filter(rarity__level__lte=5)
|
||||||
.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
|
.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
|
||||||
.order_by("-offer_count")[:6]
|
.order_by("-offer_count")[:6]
|
||||||
)
|
)
|
||||||
context["most_offered_cards"] = most_offered_cards_qs
|
context["most_offered_cards"] = most_offered_cards_qs
|
||||||
|
latest_update = most_offered_cards_qs.aggregate(
|
||||||
|
latest=Max("updated_at")
|
||||||
|
)["latest"]
|
||||||
|
if latest_update:
|
||||||
context["cache_key_most_offered_cards"] = (
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching most offered cards: {str(e)}")
|
logger.error(f"Error fetching most offered cards: {str(e)}")
|
||||||
context["most_offered_cards"] = []
|
context["most_offered_cards"] = []
|
||||||
|
|
@ -60,15 +78,22 @@ class HomePageView(TemplateView):
|
||||||
# Most Wanted Cards
|
# Most Wanted Cards
|
||||||
try:
|
try:
|
||||||
most_wanted_cards_qs = (
|
most_wanted_cards_qs = (
|
||||||
Card.objects.filter(tradeofferwantcard__isnull=False)
|
Card.objects.with_details()
|
||||||
.filter(rarity_level__lte=5)
|
.filter(tradeofferwantcard__isnull=False)
|
||||||
|
.filter(rarity__level__lte=5)
|
||||||
.annotate(offer_count=Sum("tradeofferwantcard__quantity"))
|
.annotate(offer_count=Sum("tradeofferwantcard__quantity"))
|
||||||
.order_by("-offer_count")[:6]
|
.order_by("-offer_count")[:6]
|
||||||
)
|
)
|
||||||
context["most_wanted_cards"] = most_wanted_cards_qs
|
context["most_wanted_cards"] = most_wanted_cards_qs
|
||||||
|
latest_update = most_wanted_cards_qs.aggregate(
|
||||||
|
latest=Max("updated_at")
|
||||||
|
)["latest"]
|
||||||
|
if latest_update:
|
||||||
context["cache_key_most_wanted_cards"] = (
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching most wanted cards: {str(e)}")
|
logger.error(f"Error fetching most wanted cards: {str(e)}")
|
||||||
context["most_wanted_cards"] = []
|
context["most_wanted_cards"] = []
|
||||||
|
|
@ -76,16 +101,23 @@ class HomePageView(TemplateView):
|
||||||
# Least Offered Cards
|
# Least Offered Cards
|
||||||
try:
|
try:
|
||||||
least_offered_cards_qs = (
|
least_offered_cards_qs = (
|
||||||
Card.objects.filter(rarity_level__lte=5)
|
Card.objects.with_details()
|
||||||
|
.filter(rarity__level__lte=5)
|
||||||
.annotate(
|
.annotate(
|
||||||
offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0)
|
offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0)
|
||||||
)
|
)
|
||||||
.order_by("offer_count")[:6]
|
.order_by("offer_count")[:6]
|
||||||
)
|
)
|
||||||
context["least_offered_cards"] = least_offered_cards_qs
|
context["least_offered_cards"] = least_offered_cards_qs
|
||||||
|
latest_update = least_offered_cards_qs.aggregate(
|
||||||
|
latest=Max("updated_at")
|
||||||
|
)["latest"]
|
||||||
|
if latest_update:
|
||||||
context["cache_key_least_offered_cards"] = (
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching least offered cards: {str(e)}")
|
logger.error(f"Error fetching least offered cards: {str(e)}")
|
||||||
context["least_offered_cards"] = []
|
context["least_offered_cards"] = []
|
||||||
|
|
@ -94,10 +126,21 @@ class HomePageView(TemplateView):
|
||||||
featured = OrderedDict()
|
featured = OrderedDict()
|
||||||
# Featured "All" offers remains fixed at the top
|
# Featured "All" offers remains fixed at the top
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching 'All' featured offers: {str(e)}")
|
logger.error(f"Error fetching 'All' featured offers: {str(e)}")
|
||||||
featured["All"] = []
|
featured["All"] = []
|
||||||
|
context["cache_key_featured_offers"] = "featured_all_error"
|
||||||
|
|
||||||
# *** we only show All Featured Offers for now,
|
# *** we only show All Featured Offers for now,
|
||||||
# *** we will add rarity-tabbed featured offers later
|
# *** we will add rarity-tabbed featured offers later
|
||||||
|
|
@ -121,23 +164,6 @@ class HomePageView(TemplateView):
|
||||||
# logger.error(f"Error processing rarity-based featured offers: {str(e)}")
|
# logger.error(f"Error processing rarity-based featured offers: {str(e)}")
|
||||||
|
|
||||||
context["featured_offers"] = featured
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}")
|
logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}")
|
||||||
# Provide fallback empty data
|
# Provide fallback empty data
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,8 @@
|
||||||
<!-- Goatcounter: 100% privacy-first, no tracking analytics -->
|
<!-- Goatcounter: 100% privacy-first, no tracking analytics -->
|
||||||
<script data-goatcounter="https://stats.pkmntrade.club/count" async src="{% static 'js/count-v4.js' %}"></script>
|
<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 %}
|
{% block javascript %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<div class="flex items-center mb-6">
|
<div class="flex items-center mb-6">
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<h1 class="text-3xl font-bold">{{card.name}}</h1>
|
<h1 class="text-3xl font-bold">{{card.name}}</h1>
|
||||||
<h2 class="text-lg text-gray-500">{{ card.cardset }} #{{ card.cardnum }} • {{ card.rarity_icon }}</h2>
|
<h2 class="text-lg text-gray-500">{{ card.cardset }} #{{ card.cardnum }} • {{ card.rarity.icon }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,48 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static card_badge %}
|
{% load static card_badge %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto"
|
<div class="container mx-auto p-4">
|
||||||
x-data="{
|
<h1 class="text-3xl font-bold mb-4">Card List</h1>
|
||||||
order: '{{ order }}',
|
|
||||||
groupBy: '{{ group_by|default:'none' }}',
|
<div class="mb-4">
|
||||||
page: 1,
|
<!-- Sorting and Grouping controls can go here -->
|
||||||
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>
|
</div>
|
||||||
<div>
|
|
||||||
<!-- Sort Dropdown -->
|
{% if group_by == 'rarity' %}
|
||||||
<div class="dropdown dropdown-end m-1">
|
{% regroup cards by rarity as grouped_list %}
|
||||||
<div tabindex="0" class="btn">
|
{% for group in grouped_list %}
|
||||||
Sort by: <span x-text="order === 'absolute' ? 'Cardset' : (order === 'alphabetical' ? 'Alphabetical' : 'Rarity')"></span>
|
<div class="mb-6">
|
||||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<h2 class="text-2xl font-semibold mb-2">{{ group.grouper.icon }} {{ group.grouper.name }}</h2>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
</svg>
|
{% for card in group.list %}
|
||||||
</div>
|
{% card_badge card expanded=True %}
|
||||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-w-52">
|
{% endfor %}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<!-- Container for the partial card list -->
|
</div>
|
||||||
<div x-ref="cardList">
|
{% endfor %}
|
||||||
{% include "cards/_card_list.html" with cards=cards page_obj=page_obj %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<div class="mx-4 grid gap-3 grid-cols-[repeat(auto-fit,minmax(150px,1fr))] justify-items-center">
|
<div class="mx-4 grid gap-3 grid-cols-[repeat(auto-fit,minmax(150px,1fr))] justify-items-center">
|
||||||
{% for card in cards %}
|
{% for card in cards %}
|
||||||
{% card_badge card quantity=card.offer_count expanded=True %}
|
{% card_badge card quantity=card.offer_count expanded=True %}
|
||||||
|
<div class="text-sm font-semibold">{{ card.rarity.icon }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ Welcome
|
||||||
<h2 id="stats-heading" class="text-2xl font-semibold mb-4">Card Stats</h2>
|
<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">
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<!-- Most Offered Cards -->
|
<!-- 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>
|
||||||
<div class="card card-border bg-base-100 shadow-lg">
|
<div class="card card-border bg-base-100 shadow-lg">
|
||||||
<div class="card-header text-base-content p-4">
|
<div class="card-header text-base-content p-4">
|
||||||
|
|
@ -58,7 +58,7 @@ Welcome
|
||||||
</div>
|
</div>
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
<!-- Most Wanted Cards -->
|
<!-- 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>
|
||||||
<div class="card card-border bg-base-100 shadow-lg">
|
<div class="card card-border bg-base-100 shadow-lg">
|
||||||
<div class="card-header text-base-content p-4">
|
<div class="card-header text-base-content p-4">
|
||||||
|
|
@ -71,7 +71,7 @@ Welcome
|
||||||
</div>
|
</div>
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
<!-- Least Offered Cards (Last Group) -->
|
<!-- 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="col-span-2 md:col-span-1">
|
||||||
<div class="card card-border bg-base-100 shadow-lg">
|
<div class="card card-border bg-base-100 shadow-lg">
|
||||||
<div class="card-header text-base-content p-4">
|
<div class="card-header text-base-content p-4">
|
||||||
|
|
@ -90,7 +90,7 @@ Welcome
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<!-- Featured Offers -->
|
<!-- Featured Offers -->
|
||||||
{% cache CACHE_TIMEOUT featured_offers cache_key_featured_offers %}
|
{% cache CACHE_MEDIUM_TIMEOUT featured_offers cache_key_featured_offers %}
|
||||||
<div>
|
<div>
|
||||||
<div class="p-4 text-center ">
|
<div class="p-4 text-center ">
|
||||||
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Featured Offers</h5>
|
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Featured Offers</h5>
|
||||||
|
|
@ -109,7 +109,7 @@ Welcome
|
||||||
</div>
|
</div>
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
<!-- Recent Offers -->
|
<!-- Recent Offers -->
|
||||||
{% cache CACHE_TIMEOUT recent_offers cache_key_recent_offers %}
|
{% cache CACHE_MEDIUM_TIMEOUT recent_offers cache_key_recent_offers %}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-center p-4">
|
<div class="text-center p-4">
|
||||||
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Recent Offers</h5>
|
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Recent Offers</h5>
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,25 @@
|
||||||
value="{{ card.pk }}:{{ selected_cards|get_item:card_id_str }}"
|
value="{{ card.pk }}:{{ selected_cards|get_item:card_id_str }}"
|
||||||
data-card-id="{{ card.pk }}"
|
data-card-id="{{ card.pk }}"
|
||||||
data-quantity="{{ selected_cards|get_item:card_id_str }}"
|
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
|
selected
|
||||||
data-html-content='<div class="m-2">{{ card|card_badge_inline:selected_cards|get_item:card_id_str }}</div>'
|
data-html-content='<div class="m-2">{{ card|card_badge_inline:selected_cards|get_item:card_id_str }}</div>'
|
||||||
data-name="{{ card.name }}"
|
data-name="{{ card.name }}">
|
||||||
data-rarity="{{ card.rarity_icon }}"
|
{{ card.name }} ({{ card.id }})
|
||||||
data-cardset="{{ card.cardset }}">
|
|
||||||
{{ card.name }} {{ card.rarity_icon }} {{ card.cardset }}
|
|
||||||
</option>
|
</option>
|
||||||
{% else %}
|
{% else %}
|
||||||
<option
|
<option
|
||||||
value="{{ card.pk }}:1"
|
value="{{ card.pk }}:1"
|
||||||
data-card-id="{{ card.pk }}"
|
data-card-id="{{ card.pk }}"
|
||||||
data-quantity="1"
|
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-html-content='<div class="m-2">{{ card|card_badge_inline:"" }}</div>'
|
||||||
data-name="{{ card.name }}"
|
data-name="{{ card.name }}">
|
||||||
data-rarity="{{ card.rarity_icon }}"
|
{{ card.name }} ({{ card.id }})
|
||||||
data-cardset="{{ card.cardset }}">
|
|
||||||
{{ card.name }} {{ card.rarity_icon }} {{ card.cardset }}
|
|
||||||
</option>
|
</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{% load cache %}
|
{% load cache %}
|
||||||
{% cache CACHE_TIMEOUT card_badge cache_key %}
|
{% cache CACHE_LONG_TIMEOUT "card_badge" card.pk %}
|
||||||
<a href="{{ url }}" @click.stop>
|
<a href="{{ url }}" @click.stop>
|
||||||
<div class="relative block">
|
<div class="relative block">
|
||||||
{% if not expanded %}
|
{% if not expanded %}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<span class="label-text">{{ label }}</span>
|
<span class="label-text">{{ label }}</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="{{ field_name }}" id="{{ field_id }}" class="select select-bordered w-full card-multiselect" data-placeholder="{{ placeholder }}" multiple x-cloak>
|
<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 %}
|
{% if has_passed_cards %}
|
||||||
{% include "templatetags/_card_multiselect_options.html" with cards_to_render=passed_cards selected_cards=selected_cards placeholder=placeholder %}
|
{% include "templatetags/_card_multiselect_options.html" with cards_to_render=passed_cards selected_cards=selected_cards placeholder=placeholder %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<div class="card card-border bg-base-100 shadow-lg max-w-90 mx-auto">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}">
|
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{% load gravatar card_badge cache %}
|
{% 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 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-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 %}"
|
<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 %}"
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,12 @@
|
||||||
<div class="flex flex-row justify-around">
|
<div class="flex flex-row justify-around">
|
||||||
<!-- Has Side (inner grid of 2 columns) -->
|
<!-- Has Side (inner grid of 2 columns) -->
|
||||||
<div class="grid grid-cols-2 content-start gap-1">
|
<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">
|
<div class="text-xs text-center mb-2 ms-3 col-span-2">
|
||||||
None left.
|
None left.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -66,8 +71,15 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- Wants Side (inner grid of 2 columns) -->
|
<!-- Wants Side (inner grid of 2 columns) -->
|
||||||
<div class="grid grid-cols-2 content-start gap-1">
|
<div class="grid grid-cols-2 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="text-xs text-center mb-2 ms-3 col-span-2">
|
<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.
|
None left.
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -78,7 +90,12 @@
|
||||||
<div class="flex flex-row gap-1 justify-around">
|
<div class="flex flex-row gap-1 justify-around">
|
||||||
<!-- Has Side -->
|
<!-- Has Side -->
|
||||||
<div class="flex flex-col content-start gap-1">
|
<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">
|
<div class="text-xs text-center mb-2 ms-3">
|
||||||
None left.
|
None left.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,7 +103,12 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- Wants Side -->
|
<!-- Wants Side -->
|
||||||
<div class="flex flex-col content-start gap-1">
|
<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
|
<div
|
||||||
class="text-xs text-center mb-2{% if expanded %} ms-8{% else %} me-4{% endif %}"
|
class="text-xs text-center mb-2{% if expanded %} ms-8{% else %} me-4{% endif %}"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,107 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance
|
|
||||||
|
|
||||||
admin.site.register(TradeOffer)
|
from .models import TradeAcceptance, TradeOffer, TradeOfferHaveCard, TradeOfferWantCard
|
||||||
admin.site.register(TradeOfferHaveCard)
|
|
||||||
admin.site.register(TradeOfferWantCard)
|
|
||||||
admin.site.register(TradeAcceptance)
|
@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",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ class TradesConfig(AppConfig):
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
# Implicitly connect signal handlers decorated with @receiver.
|
# Implicitly connect signal handlers decorated with @receiver.
|
||||||
import pkmntrade_club.trades.signals
|
pass
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from .models import TradeOffer, TradeAcceptance
|
from django.forms import ModelForm
|
||||||
|
|
||||||
from pkmntrade_club.accounts.models import FriendCode
|
from pkmntrade_club.accounts.models import FriendCode
|
||||||
from pkmntrade_club.cards.models import Card
|
from pkmntrade_club.cards.models import Card
|
||||||
from django.forms import ModelForm
|
|
||||||
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard
|
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard
|
||||||
|
|
||||||
|
from .models import TradeAcceptance, TradeOffer
|
||||||
|
|
||||||
|
|
||||||
class NoValidationMultipleChoiceField(forms.MultipleChoiceField):
|
class NoValidationMultipleChoiceField(forms.MultipleChoiceField):
|
||||||
def validate(self, value):
|
def validate(self, value):
|
||||||
|
|
@ -126,7 +128,7 @@ class TradeOfferCreateForm(ModelForm):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Populate choices from Card model using the new field 'rarity_level' instead of the removed relation.
|
# 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]
|
choices = [(str(card.pk), card.name) for card in cards]
|
||||||
self.fields["have_cards"].choices = choices
|
self.fields["have_cards"].choices = choices
|
||||||
self.fields["want_cards"].choices = choices
|
self.fields["want_cards"].choices = choices
|
||||||
|
|
|
||||||
|
|
@ -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
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
@ -9,75 +9,191 @@ class Migration(migrations.Migration):
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('accounts', '0001_initial'),
|
("accounts", "0001_initial"),
|
||||||
('cards', '0001_initial'),
|
("cards", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TradeOffer',
|
name="TradeOffer",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
('is_closed', models.BooleanField(db_index=True, default=False)),
|
("is_closed", models.BooleanField(db_index=True, default=False)),
|
||||||
('hash', models.CharField(editable=False, max_length=9)),
|
("hash", models.CharField(editable=False, max_length=9)),
|
||||||
('rarity_icon', models.CharField(max_length=8, null=True)),
|
("rarity_icon", models.CharField(max_length=8, null=True)),
|
||||||
('rarity_level', models.IntegerField(null=True)),
|
("rarity_level", models.IntegerField(null=True)),
|
||||||
('image', models.ImageField(blank=True, null=True, upload_to='trade_offers/')),
|
(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
"image",
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
models.ImageField(blank=True, null=True, upload_to="trade_offers/"),
|
||||||
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_trade_offers', to='accounts.friendcode')),
|
),
|
||||||
|
("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(
|
migrations.CreateModel(
|
||||||
name='TradeAcceptance',
|
name="TradeAcceptance",
|
||||||
fields=[
|
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)),
|
"id",
|
||||||
('hash', models.CharField(blank=True, editable=False, max_length=9)),
|
models.BigAutoField(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
auto_created=True,
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
primary_key=True,
|
||||||
('accepted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='trade_acceptances', to='accounts.friendcode')),
|
serialize=False,
|
||||||
('offered_card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accepted_offered', to='cards.card')),
|
verbose_name="ID",
|
||||||
('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')),
|
),
|
||||||
|
(
|
||||||
|
"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(
|
migrations.CreateModel(
|
||||||
name='TradeOfferHaveCard',
|
name="TradeOfferHaveCard",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('quantity', models.PositiveIntegerField(default=1)),
|
"id",
|
||||||
('qty_accepted', models.PositiveIntegerField(default=0, editable=False)),
|
models.BigAutoField(
|
||||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cards.card')),
|
auto_created=True,
|
||||||
('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trade_offer_have_cards', to='trades.tradeoffer')),
|
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={
|
options={
|
||||||
'ordering': ['card__name'],
|
"ordering": ["card__translations__name"],
|
||||||
'unique_together': {('trade_offer', 'card')},
|
"unique_together": {("trade_offer", "card")},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='tradeoffer',
|
model_name="tradeoffer",
|
||||||
name='have_cards',
|
name="have_cards",
|
||||||
field=models.ManyToManyField(related_name='trade_offers_have', through='trades.TradeOfferHaveCard', to='cards.card'),
|
field=models.ManyToManyField(
|
||||||
|
related_name="trade_offers_have",
|
||||||
|
through="trades.TradeOfferHaveCard",
|
||||||
|
to="cards.card",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TradeOfferWantCard',
|
name="TradeOfferWantCard",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('quantity', models.PositiveIntegerField(default=1)),
|
"id",
|
||||||
('qty_accepted', models.PositiveIntegerField(default=0, editable=False)),
|
models.BigAutoField(
|
||||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cards.card')),
|
auto_created=True,
|
||||||
('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trade_offer_want_cards', to='trades.tradeoffer')),
|
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={
|
options={
|
||||||
'ordering': ['card__name'],
|
"ordering": ["card__translations__name"],
|
||||||
'unique_together': {('trade_offer', 'card')},
|
"unique_together": {("trade_offer", "card")},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='tradeoffer',
|
model_name="tradeoffer",
|
||||||
name='want_cards',
|
name="want_cards",
|
||||||
field=models.ManyToManyField(related_name='trade_offers_want', through='trades.TradeOfferWantCard', to='cards.card'),
|
field=models.ManyToManyField(
|
||||||
|
related_name="trade_offers_want",
|
||||||
|
through="trades.TradeOfferWantCard",
|
||||||
|
to="cards.card",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
from pkmntrade_club.cards.models import Card
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferContextMixin:
|
class TradeOfferContextMixin:
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
# Start with any context passed in.
|
# Start with any context passed in.
|
||||||
context = kwargs.copy()
|
context = kwargs.copy()
|
||||||
# Include available cards requirements for multiselect fields.
|
# Include available cards requirements for multiselect fields.
|
||||||
context.setdefault("cards", Card.objects.all().order_by("name", "rarity_level"))
|
context.setdefault(
|
||||||
|
"cards", Card.objects.all().order_by("name", "rarity__level")
|
||||||
|
)
|
||||||
|
|
||||||
# Provide friend_codes and selected_friend_code as in TradeOfferCreateView
|
# Provide friend_codes and selected_friend_code as in TradeOfferCreateView
|
||||||
friend_codes = self.request.user.friend_codes.all()
|
friend_codes = self.request.user.friend_codes.all()
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
from django.db import models
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.db.models import Prefetch
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import uuid
|
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():
|
def generate_tradeoffer_hash():
|
||||||
"""
|
"""
|
||||||
|
|
@ -25,31 +28,52 @@ class TradeOfferManager(models.Manager):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
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)
|
# Prefetch for have_cards (through model: TradeOfferHaveCard)
|
||||||
# Ensures 'card' is select_related and 'Meta.ordering' is respected/applied.
|
|
||||||
prefetch_have_cards = Prefetch(
|
prefetch_have_cards = Prefetch(
|
||||||
"trade_offer_have_cards",
|
"trade_offer_have_cards",
|
||||||
queryset=TradeOfferHaveCard.objects.select_related("card").order_by(
|
queryset=TradeOfferHaveCard.objects.select_related(
|
||||||
"card__name"
|
*card_prefetch_selects
|
||||||
),
|
).prefetch_related(*card_prefetch_related),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prefetch for want_cards (through model: TradeOfferWantCard)
|
# Prefetch for want_cards (through model: TradeOfferWantCard)
|
||||||
# Ensures 'card' is select_related and 'Meta.ordering' is respected/applied.
|
|
||||||
prefetch_want_cards = Prefetch(
|
prefetch_want_cards = Prefetch(
|
||||||
"trade_offer_want_cards",
|
"trade_offer_want_cards",
|
||||||
queryset=TradeOfferWantCard.objects.select_related("card").order_by(
|
queryset=TradeOfferWantCard.objects.select_related(
|
||||||
"card__name"
|
*card_prefetch_selects
|
||||||
),
|
).prefetch_related(*card_prefetch_related),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prefetch for acceptances
|
# Prefetch for acceptances
|
||||||
# Ensures related 'accepted_by__user', 'requested_card', 'offered_card' are fetched.
|
|
||||||
prefetch_acceptances = Prefetch(
|
prefetch_acceptances = Prefetch(
|
||||||
"acceptances",
|
"acceptances",
|
||||||
queryset=TradeAcceptance.objects.select_related(
|
queryset=TradeAcceptance.objects.select_related(
|
||||||
"accepted_by__user", "requested_card", "offered_card"
|
"accepted_by__user",
|
||||||
).order_by("-created_at"), # Sensible default ordering for acceptances
|
"requested_card__rarity",
|
||||||
|
"requested_card__cardset",
|
||||||
|
"offered_card__rarity",
|
||||||
|
"offered_card__cardset",
|
||||||
|
).prefetch_related(
|
||||||
|
"requested_card__translations", "offered_card__translations"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
qs = qs.select_related(
|
qs = qs.select_related(
|
||||||
|
|
@ -58,10 +82,15 @@ class TradeOfferManager(models.Manager):
|
||||||
prefetch_have_cards,
|
prefetch_have_cards,
|
||||||
prefetch_want_cards,
|
prefetch_want_cards,
|
||||||
prefetch_acceptances,
|
prefetch_acceptances,
|
||||||
# If direct access like offer.have_cards.all() (the M2M to Card, not through model)
|
# If direct access like offer.have_cards.all() is used, prefetch cards with their rarity
|
||||||
# is heavily used AND causes N+1s (e.g. via __str__), uncomment these:
|
Prefetch(
|
||||||
Prefetch("have_cards"),
|
"have_cards",
|
||||||
Prefetch("want_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
|
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
|
Recalculates and updates the rarity_level and rarity_icon fields based on
|
||||||
the associated have_cards and want_cards.
|
the associated have_cards and want_cards.
|
||||||
|
|
||||||
Enforces that all cards in the trade offer share the same rarity.
|
Enforces that all cards in a trade offer share the same rarity.
|
||||||
Uses the first card's rarity details to update both fields.
|
Uses the first card's rarity details to update both fields.
|
||||||
"""
|
"""
|
||||||
# Gather all cards from both sides.
|
# Gather all cards from both sides, using select_related to prevent N+1 queries on rarity.
|
||||||
cards = list(self.have_cards.all()) + list(self.want_cards.all())
|
cards = list(self.have_cards.select_related("rarity").all()) + list(
|
||||||
|
self.want_cards.select_related("rarity").all()
|
||||||
|
)
|
||||||
if not cards:
|
if not cards:
|
||||||
|
self.rarity_level = None
|
||||||
|
self.rarity_icon = None
|
||||||
|
super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"])
|
||||||
return
|
return
|
||||||
|
|
||||||
# Enforce same rarity across all cards.
|
# Enforce same rarity across all cards.
|
||||||
rarity_levels = {card.rarity_level for card in cards}
|
rarity_levels = {card.rarity.level for card in cards}
|
||||||
if len(rarity_levels) > 1:
|
if len(rarity_levels) > 1:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"All cards in a trade offer must have the same rarity."
|
"All cards in a trade offer must have the same rarity."
|
||||||
)
|
)
|
||||||
first_card = cards[0]
|
first_card = cards[0]
|
||||||
if first_card.rarity_level > 5:
|
if first_card.rarity.level > 5:
|
||||||
raise ValidationError("Cannot trade cards above one-star rarity.")
|
raise ValidationError("Cannot trade cards above one-star rarity.")
|
||||||
if (
|
if (
|
||||||
self.rarity_level != first_card.rarity_level
|
self.rarity_level != first_card.rarity.level
|
||||||
or self.rarity_icon != first_card.rarity_icon
|
or self.rarity_icon != first_card.rarity.icon
|
||||||
):
|
):
|
||||||
self.rarity_level = first_card.rarity_level
|
self.rarity_level = first_card.rarity.level
|
||||||
self.rarity_icon = first_card.rarity_icon
|
self.rarity_icon = first_card.rarity.icon
|
||||||
# Use super().save() here to avoid recursion.
|
# Use super().save() here to avoid recursion.
|
||||||
super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"])
|
super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def have_cards_available(self):
|
def have_cards_available(self):
|
||||||
# Returns the list of have_cards (through objects) that still have available quantity.
|
# Returns a queryset of have_cards that still have available quantity.
|
||||||
return [
|
return self.trade_offer_have_cards.filter(quantity__gt=F("qty_accepted"))
|
||||||
item
|
|
||||||
for item in self.trade_offer_have_cards.all()
|
|
||||||
if item.quantity > item.qty_accepted
|
|
||||||
]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def want_cards_available(self):
|
def want_cards_available(self):
|
||||||
# Returns the list of want_cards (through objects) that still have available quantity.
|
# Returns a queryset of want_cards that still have available quantity.
|
||||||
return [
|
return self.trade_offer_want_cards.filter(quantity__gt=F("qty_accepted"))
|
||||||
item
|
|
||||||
for item in self.trade_offer_want_cards.all()
|
|
||||||
if item.quantity > item.qty_accepted
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferHaveCard(models.Model):
|
class TradeOfferHaveCard(models.Model):
|
||||||
|
|
@ -170,7 +197,7 @@ class TradeOfferHaveCard(models.Model):
|
||||||
return self.quantity - self.qty_accepted
|
return self.quantity - self.qty_accepted
|
||||||
|
|
||||||
def __str__(self):
|
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):
|
def save(self, *args, **kwargs):
|
||||||
self.trade_offer.update_rarity_fields()
|
self.trade_offer.update_rarity_fields()
|
||||||
|
|
@ -183,7 +210,7 @@ class TradeOfferHaveCard(models.Model):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("trade_offer", "card")
|
unique_together = ("trade_offer", "card")
|
||||||
ordering = ["card__name"]
|
ordering = ["card__translations__name"]
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferWantCard(models.Model):
|
class TradeOfferWantCard(models.Model):
|
||||||
|
|
@ -204,7 +231,7 @@ class TradeOfferWantCard(models.Model):
|
||||||
return self.quantity - self.qty_accepted
|
return self.quantity - self.qty_accepted
|
||||||
|
|
||||||
def __str__(self):
|
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):
|
def save(self, *args, **kwargs):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
@ -217,7 +244,7 @@ class TradeOfferWantCard(models.Model):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("trade_offer", "card")
|
unique_together = ("trade_offer", "card")
|
||||||
ordering = ["card__name"]
|
ordering = ["card__translations__name"]
|
||||||
|
|
||||||
|
|
||||||
class TradeAcceptance(models.Model):
|
class TradeAcceptance(models.Model):
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
from django.db.models.signals import post_save, post_delete, pre_save
|
from django.contrib.sites.models import Site
|
||||||
from django.dispatch import receiver
|
from django.core.cache import cache
|
||||||
|
from django.core.mail import send_mail
|
||||||
from django.db.models import F
|
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 (
|
from pkmntrade_club.trades.models import (
|
||||||
|
TradeAcceptance,
|
||||||
|
TradeOffer,
|
||||||
TradeOfferHaveCard,
|
TradeOfferHaveCard,
|
||||||
TradeOfferWantCard,
|
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 = [
|
POSITIVE_STATES = [
|
||||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||||
|
|
@ -109,10 +112,20 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
|
||||||
if not hasattr(instance, "_actioning_user"):
|
if not hasattr(instance, "_actioning_user"):
|
||||||
return
|
return
|
||||||
|
|
||||||
# check if were in debug mode
|
# Re-fetch instance with related data to avoid N+1 queries.
|
||||||
# if settings.DEBUG:
|
instance = (
|
||||||
# print("DEBUG: skipping email notification in debug mode")
|
TradeAcceptance.objects.select_related(
|
||||||
# return
|
"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
|
acting_user = instance._actioning_user
|
||||||
del instance._actioning_user
|
del instance._actioning_user
|
||||||
|
|
@ -209,6 +222,11 @@ def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
|
||||||
if created:
|
if created:
|
||||||
return # No action on creation as terminal states are not expected.
|
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
|
thanks_delta = 0
|
||||||
rejection_delta_initiator = 0 # Delta for the initiator's reputation
|
rejection_delta_initiator = 0 # Delta for the initiator's reputation
|
||||||
rejection_delta_acceptor = 0 # Delta for the acceptor's reputation
|
rejection_delta_acceptor = 0 # Delta for the acceptor's reputation
|
||||||
|
|
@ -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_save, sender=TradeOfferHaveCard)
|
||||||
@receiver(post_delete, sender=TradeOfferHaveCard)
|
@receiver(post_delete, sender=TradeOfferHaveCard)
|
||||||
@receiver(post_save, sender=TradeOfferWantCard)
|
@receiver(post_save, sender=TradeOfferWantCard)
|
||||||
@receiver(post_delete, 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_save, sender=TradeAcceptance)
|
||||||
@receiver(post_delete, 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.
|
Invalidate cache for a trade offer when one of its acceptances changes.
|
||||||
Also invalidates any cached image by deleting the file.
|
|
||||||
"""
|
"""
|
||||||
trade_offer = getattr(instance, "trade_offer", None)
|
if instance.trade_offer:
|
||||||
|
cache.delete(f"trade_offer_{instance.trade_offer.pk}")
|
||||||
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"])
|
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,11 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
||||||
from pkmntrade_club.cards.models import Card
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
# Ensure available_cards is a proper QuerySet
|
# Ensure available_cards is a proper QuerySet
|
||||||
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by(
|
context["cards"] = (
|
||||||
"name", "rarity_level"
|
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()
|
friend_codes = self.request.user.friend_codes.all()
|
||||||
if "initiated_by" in self.request.GET:
|
if "initiated_by" in self.request.GET:
|
||||||
|
|
@ -281,8 +284,11 @@ class TradeOfferSearchView(ListView):
|
||||||
from pkmntrade_club.cards.models import Card
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
# Populate available_cards to re-populate the multiselects. Exclude cards with rarity level > 5.
|
# Populate available_cards to re-populate the multiselects. Exclude cards with rarity level > 5.
|
||||||
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by(
|
context["cards"] = (
|
||||||
"name", "rarity_level"
|
Card.objects.filter(rarity__level__lte=5)
|
||||||
|
.select_related("rarity")
|
||||||
|
.prefetch_related("translations")
|
||||||
|
.order_by("translations__name", "rarity__level")
|
||||||
)
|
)
|
||||||
if self.request.method == "POST":
|
if self.request.method == "POST":
|
||||||
context["have_cards"] = self.request.POST.getlist("have_cards")
|
context["have_cards"] = self.request.POST.getlist("have_cards")
|
||||||
|
|
@ -514,6 +520,26 @@ class TradeAcceptanceUpdateView(
|
||||||
form_class = TradeAcceptanceTransitionForm
|
form_class = TradeAcceptanceTransitionForm
|
||||||
template_name = "trades/trade_acceptance_update.html"
|
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):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
friend_codes = request.user.friend_codes.values_list("id", flat=True)
|
friend_codes = request.user.friend_codes.values_list("id", flat=True)
|
||||||
|
|
@ -698,7 +724,12 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
||||||
request.user.default_friend_code
|
request.user.default_friend_code
|
||||||
or request.user.friend_codes.first()
|
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)
|
return render(request, "trades/trade_offer_create.html", context)
|
||||||
messages.success(request, "Trade offer created successfully!")
|
messages.success(request, "Trade offer created successfully!")
|
||||||
|
|
@ -721,7 +752,12 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
||||||
request.user.default_friend_code
|
request.user.default_friend_code
|
||||||
or request.user.friend_codes.first()
|
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)
|
return render(request, "trades/trade_offer_create.html", context)
|
||||||
|
|
||||||
|
|
@ -754,7 +790,12 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
||||||
"friend_codes": request.user.friend_codes.all(),
|
"friend_codes": request.user.friend_codes.all(),
|
||||||
"selected_friend_code": request.user.default_friend_code
|
"selected_friend_code": request.user.default_friend_code
|
||||||
or request.user.friend_codes.first(),
|
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)
|
return render(request, "trades/trade_offer_create.html", context)
|
||||||
|
|
||||||
|
|
@ -765,7 +806,9 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
||||||
from pkmntrade_club.cards.models import Card
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
have_cards_ids = [card_id for card_id, _ in have_selections]
|
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}
|
cards_have_dict = {card.pk: card for card in cards_have_qs}
|
||||||
|
|
||||||
# Define a dummy wrapper for a trade offer card entry.
|
# 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))
|
have_offer_cards.append(DummyOfferCard(card, quantity))
|
||||||
|
|
||||||
want_cards_ids = [card_id for card_id, _ in want_selections]
|
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}
|
cards_want_dict = {card.pk: card for card in cards_want_qs}
|
||||||
want_offer_cards = []
|
want_offer_cards = []
|
||||||
for card_id, quantity in want_selections:
|
for card_id, quantity in want_selections:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue