diff --git a/.cursorrules b/.cursorrules index 8b27274..5e49a06 100644 --- a/.cursorrules +++ b/.cursorrules @@ -10,9 +10,9 @@ Key Principles Django/Python -- Use Django's class-based views (CBVs) for more complex views; prefer function-based views (FBVs) for simpler logic. -- Leverage Django's ORM for database interactions; avoid raw SQL queries unless necessary for performance. -- Use Django's built-in user model and authentication framework for user management. +- Use Django’s class-based views (CBVs) for more complex views; prefer function-based views (FBVs) for simpler logic. +- Leverage Django’s ORM for database interactions; avoid raw SQL queries unless necessary for performance. +- Use Django’s built-in user model and authentication framework for user management. - Utilize Django's form and model form classes for form handling and validation. - Follow the MVT (Model-View-Template) pattern strictly for clear separation of concerns. - Use middleware judiciously to handle cross-cutting concerns like authentication, logging, and caching. @@ -25,29 +25,20 @@ Error Handling and Validation - Customize error pages (e.g., 404, 500) to improve user experience and provide helpful information. - Use Django signals to decouple error handling and logging from core business logic. -Development, Testing, and Operations - -- Use Gatus for service health monitoring and status pages. -- Employ Locust for load testing to ensure application scalability and performance under stress. -- Utilize Playwright for end-to-end testing to simulate user interactions and validate application behavior from the user's perspective. - Dependencies - Django +- Django REST Framework (for API development) - Celery (for background tasks) - Redis (for caching and task queues) - PostgreSQL or MySQL (preferred databases for production) -- Granian / Gunicorn (for serving the application) -- Whitenoise (for serving static files) - Tailwind CSS for the frontend -- Django Allauth for authentication - Django Crispy Forms for the frontend -- Crispy Tailwind for Tailwind-compatible Crispy Forms -- Django DaisyUI for the admin frontend +- Django Allauth for authentication +- Django DaisyUI for the frontend +- Django El Pagination for the frontend - Django Widget Tweaks for the frontend -- django-debug-toolbar for debugging -- django-health-check for application health monitoring -- django-parler for multilingual support +- Django Crispy Tailwind for the frontend Django-Specific Guidelines @@ -55,17 +46,17 @@ Django-Specific Guidelines - Keep business logic in models and forms; keep views light and focused on request handling. - Use Django's URL dispatcher (urls.py) to define clear and RESTful URL patterns. - Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention). -- Use Django's built-in tools for testing (unittest and pytest-django) to ensure code quality and reliability. -- Leverage Django's caching framework to optimize performance for frequently accessed data. -- Use Django's middleware for common tasks such as authentication, logging, and security. +- Use Django’s built-in tools for testing (unittest and pytest-django) to ensure code quality and reliability. +- Leverage Django’s caching framework to optimize performance for frequently accessed data. +- Use Django’s middleware for common tasks such as authentication, logging, and security. Performance Optimization - Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching. -- Use 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. - 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 diff --git a/.github/workflows/build_deploy.yml b/.github/workflows/build_deploy.yml index 21371ea..ab3dd15 100644 --- a/.github/workflows/build_deploy.yml +++ b/.github/workflows/build_deploy.yml @@ -103,7 +103,6 @@ jobs: - name: Extract version for Docker build id: extract_version run: | - pip uninstall setuptools pip install setuptools-scm VERSION=$(python -c "from setuptools_scm import get_version; print(get_version())") echo "VERSION=${VERSION}" >> $GITHUB_ENV @@ -134,9 +133,9 @@ jobs: # Job 2: Deploy (only runs on main branch or tags) deploy: - #needs: build + needs: build runs-on: ubuntu-latest - #if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) # Determine environment based on ref environment: ${{ (startsWith(github.ref, 'refs/tags/v') && !endsWith(github.ref, '-prerelease')) && 'production' || 'staging' }} steps: @@ -174,13 +173,8 @@ jobs: echo "📝 Setting deployment environment variables" echo "REPO_PROJECT_PATH=${REPO_PROJECT_PATH}" >> $GITHUB_ENV echo "REPO_NAME_ONLY=${REPO_NAME_ONLY}" >> $GITHUB_ENV - echo "REPO=${REPO}" >> $GITHUB_ENV echo "IMAGE_TAR_NAME=${REPO_NAME_ONLY}-${{ github.ref_name }}_${{ github.sha }}.tar" >> $GITHUB_ENV echo "PROD=${prod_value}" >> $GITHUB_ENV - echo "GIT_SHA=${{ github.sha }}" >> $GITHUB_ENV - echo "REPLICA_COUNT=${{ vars.REPLICA_COUNT }}" >> $GITHUB_ENV - echo "PRODUCTION_DOMAIN=${{ vars.PRODUCTION_DOMAIN }}" >> $GITHUB_ENV - echo "STAGING_DOMAIN=${{ vars.STAGING_DOMAIN }}" >> $GITHUB_ENV - name: Download container artifact uses: actions/download-artifact@v4 @@ -223,40 +217,35 @@ jobs: env: DOCKER_HOST: ssh://deploy REPO_PROJECT_PATH: ${{ env.REPO_PROJECT_PATH }} - REPO: ${{ env.REPO }} REPO_NAME_ONLY: ${{ env.REPO_NAME_ONLY }} IMAGE_TAR: ${{ runner.temp }}/${{ env.IMAGE_TAR_NAME }} - PRODrequire_var: ${{ env.PROD }} - GIT_SHA: ${{ github.sha }} - REPLICA_COUNT: ${{ env.REPLICA_COUNT }} - PRODUCTION_DOMAIN: ${{ vars.PRODUCTION_DOMAIN }} - STAGING_DOMAIN: ${{ vars.STAGING_DOMAIN }} + PROD: ${{ env.PROD }} run: | echo "✅ Exit script on any error" set -eu -o pipefail - ./scripts/deploy-blue-green.sh + ./scripts/deploy-to-server.sh - # - name: Health Check and Rollback - # run: | - # # Determine the correct URL based on environment - # if [ "${{ env.PROD }}" = "true" ]; then - # # Ensure PRODUCTION_DOMAIN is set - # if [ -z "${{ vars.PRODUCTION_DOMAIN }}" ]; then - # echo "Error: PRODUCTION_DOMAIN is not set" - # exit 1 - # fi - # HEALTH_CHECK_URL="https://${{ vars.PRODUCTION_DOMAIN }}/health/" - # else - # # Ensure STAGING_DOMAIN is set - # if [ -z "${{ vars.STAGING_DOMAIN }}" ]; then - # echo "Error: STAGING_DOMAIN is not set" - # exit 1 - # fi - # HEALTH_CHECK_URL="https://${{ vars.STAGING_DOMAIN }}/health/" - # fi + - name: Health Check and Rollback + run: | + # Determine the correct URL based on environment + if [ "${{ env.PROD }}" = "true" ]; then + # Ensure PRODUCTION_DOMAIN is set + if [ -z "${{ vars.PRODUCTION_DOMAIN }}" ]; then + echo "Error: PRODUCTION_DOMAIN is not set" + exit 1 + fi + HEALTH_CHECK_URL="https://${{ vars.PRODUCTION_DOMAIN }}/health/" + else + # Ensure STAGING_DOMAIN is set + if [ -z "${{ vars.STAGING_DOMAIN }}" ]; then + echo "Error: STAGING_DOMAIN is not set" + exit 1 + fi + HEALTH_CHECK_URL="https://${{ vars.STAGING_DOMAIN }}/health/" + fi - # # Copy script to remote and execute - # scp scripts/health-check-and-rollback.sh deploy:/tmp/ - # ssh deploy "chmod +x /tmp/health-check-and-rollback.sh" - # ssh deploy "/tmp/health-check-and-rollback.sh '${{ env.REPO_PROJECT_PATH }}' '$HEALTH_CHECK_URL' 30" - # ssh deploy "rm -f /tmp/health-check-and-rollback.sh" \ No newline at end of file + # Copy script to remote and execute + scp scripts/health-check-and-rollback.sh deploy:/tmp/ + ssh deploy "chmod +x /tmp/health-check-and-rollback.sh" + ssh deploy "/tmp/health-check-and-rollback.sh '${{ env.REPO_PROJECT_PATH }}' '${{ env.PROD }}' '$HEALTH_CHECK_URL' 30" + ssh deploy "rm -f /tmp/health-check-and-rollback.sh" \ No newline at end of file diff --git a/manage.py b/manage.py index d84abce..138d284 100755 --- a/manage.py +++ b/manage.py @@ -1,15 +1,11 @@ #!/usr/bin/env -S uv run """Django's command-line utility for administrative tasks.""" - import os import sys - def main(): """Run administrative tasks.""" - os.environ.setdefault( - "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" - ) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings") sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) try: diff --git a/scripts/common-lib.sh b/scripts/common-lib.sh deleted file mode 120000 index 191fd48..0000000 --- a/scripts/common-lib.sh +++ /dev/null @@ -1 +0,0 @@ -../server/scripts/common-lib.sh \ No newline at end of file diff --git a/scripts/deploy-blue-green.sh b/scripts/deploy-blue-green.sh deleted file mode 100755 index 2392d67..0000000 --- a/scripts/deploy-blue-green.sh +++ /dev/null @@ -1,207 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Blue-Green deployment script with versioned releases -# Usage: ./deploy-blue-green.sh - -# Source common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/common-lib.sh" - -# Validate required environment variables -require_var "DOCKER_HOST" -require_var "REPO_PROJECT_PATH" -require_var "REPO_NAME_ONLY" -require_var "REPO" -require_var "IMAGE_TAR" -require_var "ENV_FILE_BASE64" -require_var "CF_PEM_CERT" -require_var "CF_PEM_CA" -require_var "PROD" -require_var "PRODUCTION_DOMAIN" -require_var "STAGING_DOMAIN" -require_var "REPLICA_COUNT" - -validate_deployment_env - -echo "⚙️ Docker host: $DOCKER_HOST" - -# Generate deployment timestamp -DEPLOYMENT_TIMESTAMP=$(date +%Y%m%d_%H%M%S) -NEW_RELEASE_PATH="${RELEASES_PATH}/${DEPLOYMENT_TIMESTAMP}" - -# Use Git SHA for image tag (if available, otherwise use timestamp) -if [ -n "${GIT_SHA:-}" ]; then - IMAGE_TAG="sha-${GIT_SHA:0:7}" -else - # Fallback for local testing without GIT_SHA - IMAGE_TAG="local-${DEPLOYMENT_TIMESTAMP}" -fi - -# Check for deployment in progress -if is_deployment_in_progress; then - echo "⚠️ ERROR: Deployment appears to be in progress (both colors are running)" - echo " This might indicate a previous deployment didn't complete properly." - echo " Please check the deployment status and clean up any old containers." - echo " If you are sure that the deployment is complete, you can run the following command to clean up the old containers:" - echo " ssh deploy 'docker compose -p pkmntrade-club-blue down && docker compose -p pkmntrade-club-green down'" - exit 1 -fi - -# Get current and new colors -CURRENT_COLOR=$(get_current_color) -NEW_COLOR=$(switch_color "$CURRENT_COLOR") - -echo "📅 Deployment version: ${DEPLOYMENT_TIMESTAMP}" -echo "🏷️ Image tag: ${IMAGE_TAG}" -echo "🎨 Current: $CURRENT_COLOR → New: $NEW_COLOR" - -echo "🚀 Enable and start docker service" -retry run_on_target "sudo systemctl enable --now docker.service" - -echo "💾 Load the new docker image ($IMAGE_TAR)" -if [ ! -f "$IMAGE_TAR" ]; then - echo "Error: Docker image tar file not found: $IMAGE_TAR" - exit 1 -fi - -# Load the image - Docker handles the transfer via DOCKER_HOST -echo "📦 Loading Docker image..." -#retry docker load -i "$IMAGE_TAR" - -# Verify the expected image exists -echo "🔍 Verifying image ${REPO}:${IMAGE_TAG} exists..." -if ! docker images -q "${REPO}:${IMAGE_TAG}" | grep -q .; then - echo "❌ Expected image tag ${IMAGE_TAG} not found!" - echo "Available tags:" - docker images "${REPO}" --format "{{.Tag}}" - exit 1 -fi - -echo "📁 Create versioned release directory" -run_on_target "mkdir -p '${NEW_RELEASE_PATH}'" - -echo "💾 Copy new files to server" -if [ -d "./server" ]; then - retry scp -pr ./server/* "deploy:${NEW_RELEASE_PATH}/" -else - echo "⚠️ No server directory found, error" - exit 1 -fi - -echo "📝 Create new .env file with deployment configuration" -printf "%s" "${ENV_FILE_BASE64}" | base64 -d | run_on_target "cat > '${NEW_RELEASE_PATH}/.env' && chmod 600 '${NEW_RELEASE_PATH}/.env'" -# Add deployment color and image tag to .env -run_on_target "echo 'DEPLOYMENT_COLOR=${NEW_COLOR}' >> '${NEW_RELEASE_PATH}/.env'" -run_on_target "echo 'IMAGE_TAG=${IMAGE_TAG}' >> '${NEW_RELEASE_PATH}/.env'" - -# Add domain name based on environment -if [ "${PROD}" = "true" ]; then - DOMAIN_NAME="${PRODUCTION_DOMAIN:-pkmntrade.club}" -else - DOMAIN_NAME="${STAGING_DOMAIN:-staging.pkmntrade.club}" -fi -# if there is a third part to the domain name, remove it -BASE_DOMAIN_NAME="${BASE_DOMAIN:-pkmntrade.club}" -run_on_target "echo 'DOMAIN_NAME=${DOMAIN_NAME}' >> '${NEW_RELEASE_PATH}/.env'" -run_on_target "echo 'BASE_DOMAIN_NAME=${BASE_DOMAIN_NAME}' >> '${NEW_RELEASE_PATH}/.env'" -run_on_target "echo 'REPLICA_COUNT=${REPLICA_COUNT}' >> '${NEW_RELEASE_PATH}/.env'" - -echo "🔑 Set up certs" -run_on_target "mkdir -p '${NEW_RELEASE_PATH}/certs' && chmod 550 '${NEW_RELEASE_PATH}/certs' && chown 99:root '${NEW_RELEASE_PATH}/certs'" -printf "%s" "$CF_PEM_CERT" | run_on_target "cat > '${NEW_RELEASE_PATH}/certs/crt.pem' && chmod 440 '${NEW_RELEASE_PATH}/certs/crt.pem' && chown 99:root '${NEW_RELEASE_PATH}/certs/crt.pem'" -printf "%s" "$CF_PEM_CA" | run_on_target "cat > '${NEW_RELEASE_PATH}/certs/ca.pem' && chmod 440 '${NEW_RELEASE_PATH}/certs/ca.pem' && chown 99:root '${NEW_RELEASE_PATH}/certs/ca.pem'" - -echo "📝 Save deployment metadata" -run_on_target "echo '${DEPLOYMENT_TIMESTAMP}' > '${NEW_RELEASE_PATH}/.deployment_version'" -run_on_target "echo '${PROD}' > '${NEW_RELEASE_PATH}/.deployment_is_prod'" -run_on_target "echo '${NEW_COLOR}' > '${NEW_RELEASE_PATH}/.deployment_color'" -run_on_target "echo '${IMAGE_TAG}' > '${NEW_RELEASE_PATH}/.image_tag'" -run_on_target "echo '${GIT_SHA:-unknown}' > '${NEW_RELEASE_PATH}/.git_sha'" - -# Save previous version info for potential rollback -run_on_target "if [ -L '${CURRENT_LINK_PATH}' ]; then readlink -f '${CURRENT_LINK_PATH}' > '${NEW_RELEASE_PATH}/.previous_version'; fi" - -# export PREVIOUS_RELEASE_PATH -if [ "$CURRENT_COLOR" != "none" ]; then - PREVIOUS_RELEASE_PATH=$(run_on_target "cat ${NEW_RELEASE_PATH}/.previous_version") -else - PREVIOUS_RELEASE_PATH="" -fi -run_on_target "export PREVIOUS_RELEASE_PATH='${PREVIOUS_RELEASE_PATH}'" - -echo "🔗 Update current symlink to new release" -run_on_target "ln -sfn '${NEW_RELEASE_PATH}' '${CURRENT_LINK_PATH}'" - -# Get deployment configuration -PROJECT_NAME=$(get_project_name "$NEW_COLOR") -COMPOSE_FILES=$(get_compose_files) -WEB_SERVICE=$(get_web_service_name) - -# create network if it doesn't exist -echo "🔗 Creating network ${PROJECT_NAME}_network" -run_on_target "docker network create ${REPO_NAME_ONLY}_network >/dev/null 2>&1 || true" - -# Handle core services -if [ "$CURRENT_COLOR" = "none" ]; then - echo "🚀 Starting core services (first deployment)" - retry run_on_target "cd '${CURRENT_LINK_PATH}' && docker compose -f docker-compose_core.yml -p ${CORE_PROJECT_NAME} up -d" - sleep 10 # Give core services time to start -else - echo "ℹ️ Core services already running, checking for changes..." - PREVIOUS_SHA1=$(run_on_target "sha1sum '${PREVIOUS_RELEASE_PATH}/docker-compose_core.yml' | awk '{print \$1}'") - NEW_SHA1=$(run_on_target "sha1sum '${NEW_RELEASE_PATH}/docker-compose_core.yml' | awk '{print \$1}'") - echo "PREV_SHA1: ${PREVIOUS_SHA1}" - echo " NEW_SHA1: ${NEW_SHA1}" - if [ -n "$PREVIOUS_SHA1" ] && [ -n "$NEW_SHA1" ]; then - if [ "$PREVIOUS_SHA1" != "$NEW_SHA1" ]; then - echo "🚀 Core services have changed, restarting..." - retry run_on_target "cd '${CURRENT_LINK_PATH}' && docker compose -f docker-compose_core.yml -p ${CORE_PROJECT_NAME} down" - retry run_on_target "cd '${CURRENT_LINK_PATH}' && docker compose -f docker-compose_core.yml -p ${CORE_PROJECT_NAME} up -d" - else - echo "ℹ️ Core services have not changed, still restarting due to current folder change..." - retry run_on_target "cd '${CURRENT_LINK_PATH}' && docker compose -f docker-compose_core.yml -p ${CORE_PROJECT_NAME} down" - retry run_on_target "cd '${CURRENT_LINK_PATH}' && docker compose -f docker-compose_core.yml -p ${CORE_PROJECT_NAME} up -d" - fi - else - echo "❌ Current or previous core services not found, exiting..." - exit 1 - fi -fi - -echo "🚀 Start new ${NEW_COLOR} containers with image ${IMAGE_TAG}" -retry run_on_target "cd '${CURRENT_LINK_PATH}' && DEPLOYMENT_COLOR=${NEW_COLOR} IMAGE_TAG=${IMAGE_TAG} docker compose $COMPOSE_FILES -p ${PROJECT_NAME} up -d" - -# Wait for new containers to be healthy -if ! wait_for_healthy_containers "$PROJECT_NAME" "$WEB_SERVICE" "$REPLICA_COUNT"; then - echo "❌ New containers failed health checks. Cancelling deployment..." - run_on_target "cd '${CURRENT_LINK_PATH}' && docker compose $COMPOSE_FILES -p ${PROJECT_NAME} down" - #echo "🔄 Rolling back deployment..." - #TODO: implement rollback - exit 1 -fi - -echo "✅ New ${NEW_COLOR} deployment is healthy" - -# Refresh gatekeepers -refresh_gatekeepers - -# Wait for traffic to stabilize -wait_with_countdown 20 "⏳ Waiting for traffic to stabilize..." - -# Clean up old containers if this isn't the first deployment -if [ "$CURRENT_COLOR" != "none" ]; then - # Get the old image tag before cleanup - OLD_IMAGE_TAG=$(get_deployment_image_tag "$CURRENT_COLOR") - echo "📷 Old deployment was using image: ${OLD_IMAGE_TAG}" - - cleanup_color_containers "$CURRENT_COLOR" - echo "✅ Old containers removed" -fi - -echo "🗑️ Clean up old releases (keep last 5)" -run_on_target "cd '${RELEASES_PATH}' && ls -dt */ 2>/dev/null | tail -n +6 | xargs -r rm -rf || true" - -echo "✅ Blue-Green deployment completed" -echo " Active color: ${NEW_COLOR}" -echo " Image tag: ${IMAGE_TAG}" \ No newline at end of file diff --git a/scripts/deploy-to-server.sh b/scripts/deploy-to-server.sh new file mode 100644 index 0000000..1a375ac --- /dev/null +++ b/scripts/deploy-to-server.sh @@ -0,0 +1,124 @@ +#!/bin/bash +set -euo pipefail + +# Main deployment script with versioned releases +# Usage: ./deploy-to-server.sh + +# Source retry function +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/retry.sh" + +# Required environment variables (should be set by GitHub Actions) +: "${DOCKER_HOST:?Error: DOCKER_HOST not set}" +: "${REPO_PROJECT_PATH:?Error: REPO_PROJECT_PATH not set}" +: "${REPO_NAME_ONLY:?Error: REPO_NAME_ONLY not set}" +: "${IMAGE_TAR:?Error: IMAGE_TAR not set}" +: "${ENV_FILE_BASE64:?Error: ENV_FILE_BASE64 not set}" +: "${CF_PEM_CERT:?Error: CF_PEM_CERT not set}" +: "${CF_PEM_CA:?Error: CF_PEM_CA not set}" +: "${PROD:?Error: PROD not set}" + +echo "⚙️ Docker host: $DOCKER_HOST" + +# Generate deployment timestamp +DEPLOYMENT_TIMESTAMP=$(date +%Y%m%d_%H%M%S) +RELEASES_PATH="${REPO_PROJECT_PATH}/releases" +NEW_RELEASE_PATH="${RELEASES_PATH}/${DEPLOYMENT_TIMESTAMP}" +CURRENT_LINK_PATH="${REPO_PROJECT_PATH}/current" + +echo "📅 Deployment version: ${DEPLOYMENT_TIMESTAMP}" + +echo "🚀 Enable and start docker service" +retry ssh deploy "sudo systemctl enable --now docker.service" + +echo "💾 Load the new docker image ($IMAGE_TAR)" +if [ ! -f "$IMAGE_TAR" ]; then + echo "Error: Docker image tar file not found: $IMAGE_TAR" + exit 1 +fi +retry docker load -i "$IMAGE_TAR" + +echo "📁 Create versioned release directory" +ssh deploy "mkdir -p '${NEW_RELEASE_PATH}'" + +echo "💾 Copy new files to server" +# Check if server directory exists before copying +if [ -d "./server" ]; then + retry scp -pr ./server/* "deploy:${NEW_RELEASE_PATH}/" +else + echo "⚠️ No server directory found, error" + exit 1 +fi + +echo "📝 Create new .env file" +printf "%s" "${ENV_FILE_BASE64}" | base64 -d | ssh deploy "cat > '${NEW_RELEASE_PATH}/.env' && chmod 600 '${NEW_RELEASE_PATH}/.env'" + +echo "🔑 Set up certs" +ssh deploy "mkdir -p '${NEW_RELEASE_PATH}/certs' && chmod 550 '${NEW_RELEASE_PATH}/certs' && chown 99:root '${NEW_RELEASE_PATH}/certs'" +printf "%s" "$CF_PEM_CERT" | ssh deploy "cat > '${NEW_RELEASE_PATH}/certs/crt.pem' && chmod 440 '${NEW_RELEASE_PATH}/certs/crt.pem' && chown 99:root '${NEW_RELEASE_PATH}/certs/crt.pem'" +printf "%s" "$CF_PEM_CA" | ssh deploy "cat > '${NEW_RELEASE_PATH}/certs/ca.pem' && chmod 440 '${NEW_RELEASE_PATH}/certs/ca.pem' && chown 99:root '${NEW_RELEASE_PATH}/certs/ca.pem'" + +echo "🔄 Prepare deployment (stop current containers)" +# Copy script to remote and execute with parameters +scp "${SCRIPT_DIR}/prepare-deployment.sh" deploy:/tmp/ +ssh deploy "chmod +x /tmp/prepare-deployment.sh && /tmp/prepare-deployment.sh '${REPO_PROJECT_PATH}' '${PROD}' '${CURRENT_LINK_PATH}'" +ssh deploy "rm -f /tmp/prepare-deployment.sh" + +echo "📝 Save deployment metadata" +ssh deploy "echo '${DEPLOYMENT_TIMESTAMP}' > '${NEW_RELEASE_PATH}/.deployment_version'" +ssh deploy "echo '${PROD}' > '${NEW_RELEASE_PATH}/.deployment_env'" + +# Save previous version info for potential rollback +ssh deploy "if [ -L '${CURRENT_LINK_PATH}' ]; then readlink -f '${CURRENT_LINK_PATH}' > '${NEW_RELEASE_PATH}/.previous_version'; fi" + +echo "🔗 Update current symlink to new release" +ssh deploy "ln -sfn '${NEW_RELEASE_PATH}' '${CURRENT_LINK_PATH}'" + +# TODO: implement zero-downtime deployment +# echo "🚀 Start the new containers, zero-downtime" +# if [ "${PROD}" = true ]; then +# ssh deploy </dev/null | tail -n +6 | xargs -r rm -rf || true" + +echo "✅ Deployment completed. Version: ${DEPLOYMENT_TIMESTAMP}" \ No newline at end of file diff --git a/scripts/generate-docker-tags.sh b/scripts/generate-docker-tags.sh old mode 100755 new mode 100644 diff --git a/scripts/health-check-and-rollback.sh b/scripts/health-check-and-rollback.sh new file mode 100644 index 0000000..b07607b --- /dev/null +++ b/scripts/health-check-and-rollback.sh @@ -0,0 +1,102 @@ +#!/bin/bash +set -euo pipefail + +# Perform health check and rollback if necessary +# Usage: ./health-check-and-rollback.sh REPO_PROJECT_PATH PROD HEALTH_CHECK_URL [MAX_ATTEMPTS] + +if [ $# -lt 3 ]; then + echo "Error: Invalid number of arguments" + echo "Usage: $0 REPO_PROJECT_PATH PROD HEALTH_CHECK_URL [MAX_ATTEMPTS]" + exit 1 +fi + +REPO_PROJECT_PATH="$1" +PROD="$2" +HEALTH_CHECK_URL="$3" +MAX_ATTEMPTS="${4:-30}" + +CURRENT_LINK_PATH="${REPO_PROJECT_PATH}/current" +RELEASES_PATH="${REPO_PROJECT_PATH}/releases" + +echo "🏥 Performing health check..." +echo "Health check URL: $HEALTH_CHECK_URL" + +get_current_version() { + if [ -L "$CURRENT_LINK_PATH" ]; then + basename "$(readlink -f "$CURRENT_LINK_PATH")" + else + echo "unknown" + fi +} + +ATTEMPT=0 +while [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; do + # Check if the service is responding with 200 OK + HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 10 "$HEALTH_CHECK_URL" || echo '000') + + if [ "$HTTP_CODE" = "200" ]; then + echo "✅ Health check passed! (HTTP $HTTP_CODE)" + CURRENT_VERSION=$(get_current_version) + echo "📌 Current version: ${CURRENT_VERSION}" + exit 0 + fi + + ATTEMPT=$((ATTEMPT + 1)) + if [ "$ATTEMPT" -eq "$MAX_ATTEMPTS" ]; then + echo "❌ Health check failed after $MAX_ATTEMPTS attempts (Last HTTP code: $HTTP_CODE)" + echo "🔄 Rolling back deployment..." + + FAILED_VERSION=$(get_current_version) + echo "❌ Failed version: ${FAILED_VERSION}" + + # Check if we have a previous version to roll back to + if [ -f "${CURRENT_LINK_PATH}/.previous_version" ]; then + PREVIOUS_VERSION_PATH=$(cat "${CURRENT_LINK_PATH}/.previous_version") + PREVIOUS_VERSION=$(basename "$PREVIOUS_VERSION_PATH") + + if [ -d "$PREVIOUS_VERSION_PATH" ]; then + echo "🔄 Rolling back to version: ${PREVIOUS_VERSION}" + + # Stop failed deployment containers + cd "$CURRENT_LINK_PATH" + echo "Stopping failed deployment containers..." + docker compose -f docker-compose_web.yml -p pkmntrade-club down || true + if [ "$PROD" = "false" ]; then + docker compose -f docker-compose_staging.yml -p pkmntrade-club down || true + fi + docker compose -f docker-compose_core.yml -p pkmntrade-club down || true + + # Switch symlink back to previous version + ln -sfn "$PREVIOUS_VERSION_PATH" "$CURRENT_LINK_PATH" + + # Start previous version containers + cd "$CURRENT_LINK_PATH" + docker compose -f docker-compose_core.yml -p pkmntrade-club up -d --no-build + if [ "$PROD" = "true" ]; then + docker compose -f docker-compose_web.yml -p pkmntrade-club up -d --no-build + else + docker compose -f docker-compose_web.yml -f docker-compose_staging.yml -p pkmntrade-club up -d --no-build + fi + + echo "✅ Rollback completed to version: ${PREVIOUS_VERSION}" + + # Mark failed version + if [ -d "${RELEASES_PATH}/${FAILED_VERSION}" ]; then + touch "${RELEASES_PATH}/${FAILED_VERSION}/.failed" + echo "$(date): Health check failed, rolled back to ${PREVIOUS_VERSION}" > "${RELEASES_PATH}/${FAILED_VERSION}/.failure_reason" + fi + else + echo "❌ Previous version directory not found: $PREVIOUS_VERSION_PATH" + exit 1 + fi + else + echo "❌ No previous version information found. Cannot rollback!" + echo "💡 This might be the first deployment or the previous version info is missing." + exit 1 + fi + exit 1 + fi + + echo "⏳ Waiting for service to be healthy... (attempt $ATTEMPT/$MAX_ATTEMPTS, HTTP code: $HTTP_CODE)" + sleep 10 +done \ No newline at end of file diff --git a/scripts/manage-releases.sh b/scripts/manage-releases.sh new file mode 100644 index 0000000..f7e16b7 --- /dev/null +++ b/scripts/manage-releases.sh @@ -0,0 +1,120 @@ +#!/bin/bash +set -euo pipefail + +# Manage deployment releases +# Usage: ./manage-releases.sh REPO_PROJECT_PATH COMMAND [ARGS] + +if [ $# -lt 2 ]; then + echo "Error: Invalid number of arguments" + echo "Usage: $0 REPO_PROJECT_PATH COMMAND [ARGS]" + echo "Commands:" + echo " list - List all releases" + echo " current - Show current release" + echo " rollback VERSION - Rollback to specific version" + echo " cleanup [KEEP] - Clean up old releases (default: keep 5)" + exit 1 +fi + +REPO_PROJECT_PATH="$1" +COMMAND="$2" +CURRENT_LINK_PATH="${REPO_PROJECT_PATH}/current" +RELEASES_PATH="${REPO_PROJECT_PATH}/releases" + +case "$COMMAND" in + list) + echo "📋 Available releases:" + if [ -d "$RELEASES_PATH" ]; then + for release in $(ls -dt "${RELEASES_PATH}"/*/); do + version=$(basename "$release") + status="" + + # Check if it's current + if [ -L "$CURRENT_LINK_PATH" ] && [ "$(readlink -f "$CURRENT_LINK_PATH")" = "$(realpath "$release")" ]; then + status=" [CURRENT]" + fi + + # Check if it failed + if [ -f "${release}/.failed" ]; then + status="${status} [FAILED]" + fi + + echo " - ${version}${status}" + done + else + echo "No releases found" + fi + ;; + + current) + if [ -L "$CURRENT_LINK_PATH" ]; then + current_version=$(basename "$(readlink -f "$CURRENT_LINK_PATH")") + echo "📌 Current version: ${current_version}" + else + echo "❌ No current deployment found" + fi + ;; + + rollback) + if [ $# -lt 3 ]; then + echo "Error: VERSION required for rollback" + exit 1 + fi + TARGET_VERSION="$3" + TARGET_PATH="${RELEASES_PATH}/${TARGET_VERSION}" + + if [ ! -d "$TARGET_PATH" ]; then + echo "Error: Version ${TARGET_VERSION} not found" + exit 1 + fi + + echo "🔄 Rolling back to version: ${TARGET_VERSION}" + + # Read environment from target version + if [ -f "${TARGET_PATH}/.deployment_env" ]; then + PROD=$(cat "${TARGET_PATH}/.deployment_env") + else + echo "Warning: Could not determine environment, assuming staging" + PROD="false" + fi + + # Stop current containers + if [ -L "$CURRENT_LINK_PATH" ] && [ -d "$CURRENT_LINK_PATH" ]; then + cd "$CURRENT_LINK_PATH" + docker compose -f docker-compose_web.yml down || true + [ "$PROD" = "false" ] && docker compose -f docker-compose_staging.yml down || true + docker compose -f docker-compose_core.yml down || true + fi + + # Update symlink + ln -sfn "$TARGET_PATH" "$CURRENT_LINK_PATH" + + # Start containers + cd "$CURRENT_LINK_PATH" + docker compose -f docker-compose_core.yml up -d --no-build + if [ "$PROD" = "true" ]; then + docker compose -f docker-compose_web.yml up -d --no-build + else + docker compose -f docker-compose_web.yml -f docker-compose_staging.yml up -d --no-build + fi + + echo "✅ Rollback completed" + ;; + + cleanup) + KEEP_COUNT="${3:-5}" + echo "🗑️ Cleaning up old releases (keeping last ${KEEP_COUNT})" + + if [ -d "$RELEASES_PATH" ]; then + cd "$RELEASES_PATH" + ls -dt */ 2>/dev/null | tail -n +$((KEEP_COUNT + 1)) | xargs -r rm -rf || true + echo "✅ Cleanup completed" + else + echo "No releases directory found" + fi + ;; + + *) + echo "Error: Unknown command: $COMMAND" + exit 1 + ;; +esac \ No newline at end of file diff --git a/scripts/parse-repository-name.sh b/scripts/parse-repository-name.sh old mode 100755 new mode 100644 index 54a0fca..dc1343f --- a/scripts/parse-repository-name.sh +++ b/scripts/parse-repository-name.sh @@ -16,17 +16,14 @@ echo "GITHUB_REPOSITORY: $GITHUB_REPOSITORY" > /dev/stderr if [[ "$GITHUB_REPOSITORY" == *".git" ]]; then if [[ "$GITHUB_REPOSITORY" == "https://"* ]]; then - echo "GITHUB_REPOSITORY ends in .git and is an HTTPS URI" > /dev/stderr + echo "GITHUB_REPOSITORY ends in .git and is a URL" > /dev/stderr REPO=$(echo "$GITHUB_REPOSITORY" | sed 's/\.git$//' | cut -d'/' -f4-5 | sed 's/[^a-zA-Z0-9\/-]/-/g') - elif [[ "$GITHUB_REPOSITORY" == "git@"* ]]; then - echo "GITHUB_REPOSITORY ends in .git and is an SSH URI" > /dev/stderr - REPO=$(echo "$GITHUB_REPOSITORY" | sed 's/\.git$//' | cut -d':' -f2 | sed 's/[^a-zA-Z0-9\/-]/-/g') else - echo "GITHUB_REPOSITORY ends in .git and is not a URI" > /dev/stderr + echo "GITHUB_REPOSITORY ends in .git and is not a URL" > /dev/stderr REPO=$(echo "$GITHUB_REPOSITORY" | sed 's/\.git$//' | sed 's/[^a-zA-Z0-9\/-]/-/g') fi else - echo "GITHUB_REPOSITORY is not a URI" > /dev/stderr + echo "GITHUB_REPOSITORY is not a URL" > /dev/stderr REPO=$(echo "$GITHUB_REPOSITORY" | sed 's/[^a-zA-Z0-9\/-]/-/g') fi diff --git a/scripts/prepare-deployment.sh b/scripts/prepare-deployment.sh new file mode 100644 index 0000000..15a41c4 --- /dev/null +++ b/scripts/prepare-deployment.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -euo pipefail + +# Prepare deployment by stopping containers +# Usage: ./prepare-deployment.sh REPO_PROJECT_PATH PROD CURRENT_LINK_PATH + +if [ $# -ne 3 ]; then + echo "Error: Invalid number of arguments" + echo "Usage: $0 REPO_PROJECT_PATH PROD CURRENT_LINK_PATH" + exit 1 +fi + +REPO_PROJECT_PATH="$1" +PROD="$2" +CURRENT_LINK_PATH="$3" + +# Ensure base directory exists +if [ ! -d "$REPO_PROJECT_PATH" ]; then + echo "⚠️ Directory $REPO_PROJECT_PATH does not exist, creating it..." + mkdir -p "$REPO_PROJECT_PATH" +fi + +# If current symlink exists, stop containers in that directory +if [ -L "$CURRENT_LINK_PATH" ] && [ -d "$CURRENT_LINK_PATH" ]; then + echo "🛑 Stopping containers in current deployment..." + cd "$CURRENT_LINK_PATH" + + # Stop containers + if [ -f "docker-compose_web.yml" ]; then + docker compose -f docker-compose_web.yml -p pkmntrade-club down || true + fi + + if [ "$PROD" = "false" ] && [ -f "docker-compose_staging.yml" ]; then + docker compose -f docker-compose_staging.yml -p pkmntrade-club down || true + fi + + if [ -f "docker-compose_core.yml" ]; then + docker compose -f docker-compose_core.yml -p pkmntrade-club down || true + fi + + echo "✅ Containers stopped" +else + echo "ℹ️ No current deployment found (symlink doesn't exist or point to valid directory)" +fi \ No newline at end of file diff --git a/scripts/retry.sh b/scripts/retry.sh new file mode 100644 index 0000000..42ee35c --- /dev/null +++ b/scripts/retry.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Retry function with exponential backoff +# Usage: source retry.sh && retry + +retry() { + local max_attempts=3 + local delay=5 + local attempt=1 + + until "$@"; do + if [ "$attempt" -ge "$max_attempts" ]; then + echo "Command failed after $max_attempts attempts: $*" + return 1 + fi + + echo "Command failed (attempt $attempt/$max_attempts): $*" + echo "Retrying in $delay seconds..." + sleep "$delay" + attempt=$((attempt + 1)) + delay=$((delay * 2)) # Exponential backoff + done +} \ No newline at end of file diff --git a/scripts/rollback-deployment.sh b/scripts/rollback-deployment.sh deleted file mode 100755 index 481ee3c..0000000 --- a/scripts/rollback-deployment.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/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 ${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" \ No newline at end of file diff --git a/seed/0002_Decks.json b/seed/0002_Decks.json new file mode 100644 index 0000000..163c7cf --- /dev/null +++ b/seed/0002_Decks.json @@ -0,0 +1,101 @@ +[ + { + "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" + } + } +] diff --git a/seed/0002_RarityMappings.json b/seed/0002_RarityMappings.json deleted file mode 100644 index 472a287..0000000 --- a/seed/0002_RarityMappings.json +++ /dev/null @@ -1 +0,0 @@ -[{"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}}] \ No newline at end of file diff --git a/seed/0003_CardSetColorMappings.json b/seed/0003_CardSetColorMappings.json deleted file mode 100644 index 4f70324..0000000 --- a/seed/0003_CardSetColorMappings.json +++ /dev/null @@ -1,79 +0,0 @@ -[ - { - "model": "cards.cardsetcolormapping", - "pk": 1, - "fields": { - "cardset_id": "A3a", - "hex_color": "#FA1A1A", - "created_at": "2025-06-20T05:48:33.579Z", - "updated_at": "2025-06-20T06:07:05.636Z", - "deleted_at": null - } - }, - { - "model": "cards.cardsetcolormapping", - "pk": 2, - "fields": { - "cardset_id": "A3", - "hex_color": "#0B47C6", - "created_at": "2025-06-20T05:49:48.100Z", - "updated_at": "2025-06-20T06:06:37.342Z", - "deleted_at": null - } - }, - { - "model": "cards.cardsetcolormapping", - "pk": 3, - "fields": { - "cardset_id": "A2b", - "hex_color": "#B3D6EE", - "created_at": "2025-06-20T05:57:08.639Z", - "updated_at": "2025-06-20T06:06:19.207Z", - "deleted_at": null - } - }, - { - "model": "cards.cardsetcolormapping", - "pk": 4, - "fields": { - "cardset_id": "A2a", - "hex_color": "#EA9706", - "created_at": "2025-06-20T05:58:45.284Z", - "updated_at": "2025-06-20T06:05:40.057Z", - "deleted_at": null - } - }, - { - "model": "cards.cardsetcolormapping", - "pk": 5, - "fields": { - "cardset_id": "A2", - "hex_color": "#7A8696", - "created_at": "2025-06-20T05:59:26.177Z", - "updated_at": "2025-06-20T06:05:23.890Z", - "deleted_at": null - } - }, - { - "model": "cards.cardsetcolormapping", - "pk": 6, - "fields": { - "cardset_id": "A1a", - "hex_color": "#31DDAA", - "created_at": "2025-06-20T06:01:35.316Z", - "updated_at": "2025-06-20T06:05:06.221Z", - "deleted_at": null - } - }, - { - "model": "cards.cardsetcolormapping", - "pk": 7, - "fields": { - "cardset_id": "A1", - "hex_color": "#7911F0", - "created_at": "2025-06-20T06:03:51.759Z", - "updated_at": "2025-06-20T06:04:48.969Z", - "deleted_at": null - } - } -] \ No newline at end of file diff --git a/seed/0003_Cards.json b/seed/0003_Cards.json new file mode 100644 index 0000000..df7f1ac --- /dev/null +++ b/seed/0003_Cards.json @@ -0,0 +1,12717 @@ +[ + { + "model": "cards.card", + "pk": 1, + "fields": { + "cardnum": 1, + "cardset": "A1", + "name": "Bulbasaur", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 2, + "fields": { + "cardnum": 2, + "cardset": "A1", + "name": "Ivysaur", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 3, + "fields": { + "cardnum": 3, + "cardset": "A1", + "name": "Venusaur", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 4, + "fields": { + "cardnum": 4, + "cardset": "A1", + "name": "Venusaur EX", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 5, + "fields": { + "cardnum": 5, + "cardset": "A1", + "name": "Caterpie", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 6, + "fields": { + "cardnum": 6, + "cardset": "A1", + "name": "Metapod", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 7, + "fields": { + "cardnum": 7, + "cardset": "A1", + "name": "Butterfree", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 8, + "fields": { + "cardnum": 8, + "cardset": "A1", + "name": "Weedle", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 9, + "fields": { + "cardnum": 9, + "cardset": "A1", + "name": "Kakuna", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 10, + "fields": { + "cardnum": 10, + "cardset": "A1", + "name": "Beedrill", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 11, + "fields": { + "cardnum": 11, + "cardset": "A1", + "name": "Oddish", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 12, + "fields": { + "cardnum": 12, + "cardset": "A1", + "name": "Gloom", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 13, + "fields": { + "cardnum": 13, + "cardset": "A1", + "name": "Vileplume", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 14, + "fields": { + "cardnum": 14, + "cardset": "A1", + "name": "Paras", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 15, + "fields": { + "cardnum": 15, + "cardset": "A1", + "name": "Parasect", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 16, + "fields": { + "cardnum": 16, + "cardset": "A1", + "name": "Venonat", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 17, + "fields": { + "cardnum": 17, + "cardset": "A1", + "name": "Venomoth", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 18, + "fields": { + "cardnum": 18, + "cardset": "A1", + "name": "Bellsprout", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 19, + "fields": { + "cardnum": 19, + "cardset": "A1", + "name": "Weepinbell", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 20, + "fields": { + "cardnum": 20, + "cardset": "A1", + "name": "Victreebel", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 21, + "fields": { + "cardnum": 21, + "cardset": "A1", + "name": "Exeggcute", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 22, + "fields": { + "cardnum": 22, + "cardset": "A1", + "name": "Exeggutor", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 23, + "fields": { + "cardnum": 23, + "cardset": "A1", + "name": "Exeggutor EX", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 24, + "fields": { + "cardnum": 24, + "cardset": "A1", + "name": "Tangela", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 25, + "fields": { + "cardnum": 25, + "cardset": "A1", + "name": "Scyther", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 26, + "fields": { + "cardnum": 26, + "cardset": "A1", + "name": "Pinsir", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 27, + "fields": { + "cardnum": 27, + "cardset": "A1", + "name": "Cottonee", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 28, + "fields": { + "cardnum": 28, + "cardset": "A1", + "name": "Whimsicott", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 29, + "fields": { + "cardnum": 29, + "cardset": "A1", + "name": "Petilil", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 30, + "fields": { + "cardnum": 30, + "cardset": "A1", + "name": "Lilligant", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 31, + "fields": { + "cardnum": 31, + "cardset": "A1", + "name": "Skiddo", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 32, + "fields": { + "cardnum": 32, + "cardset": "A1", + "name": "Gogoat", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 33, + "fields": { + "cardnum": 33, + "cardset": "A1", + "name": "Charmander", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 34, + "fields": { + "cardnum": 34, + "cardset": "A1", + "name": "Charmeleon", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 35, + "fields": { + "cardnum": 35, + "cardset": "A1", + "name": "Charizard", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 36, + "fields": { + "cardnum": 36, + "cardset": "A1", + "name": "Charizard EX", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 37, + "fields": { + "cardnum": 37, + "cardset": "A1", + "name": "Vulpix", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 38, + "fields": { + "cardnum": 38, + "cardset": "A1", + "name": "Ninetales", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 39, + "fields": { + "cardnum": 39, + "cardset": "A1", + "name": "Growlithe", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 40, + "fields": { + "cardnum": 40, + "cardset": "A1", + "name": "Arcanine", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 41, + "fields": { + "cardnum": 41, + "cardset": "A1", + "name": "Arcanine EX", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 42, + "fields": { + "cardnum": 42, + "cardset": "A1", + "name": "Ponyta", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 43, + "fields": { + "cardnum": 43, + "cardset": "A1", + "name": "Rapidash", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 44, + "fields": { + "cardnum": 44, + "cardset": "A1", + "name": "Magmar", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 45, + "fields": { + "cardnum": 45, + "cardset": "A1", + "name": "Flareon", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 46, + "fields": { + "cardnum": 46, + "cardset": "A1", + "name": "Moltres", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 47, + "fields": { + "cardnum": 47, + "cardset": "A1", + "name": "Moltres EX", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 48, + "fields": { + "cardnum": 48, + "cardset": "A1", + "name": "Heatmor", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 49, + "fields": { + "cardnum": 49, + "cardset": "A1", + "name": "Salandit", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 50, + "fields": { + "cardnum": 50, + "cardset": "A1", + "name": "Salazzle", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 51, + "fields": { + "cardnum": 51, + "cardset": "A1", + "name": "Sizzlipede", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 52, + "fields": { + "cardnum": 52, + "cardset": "A1", + "name": "Centiskorch", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 53, + "fields": { + "cardnum": 53, + "cardset": "A1", + "name": "Squirtle", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 54, + "fields": { + "cardnum": 54, + "cardset": "A1", + "name": "Wartortle", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 55, + "fields": { + "cardnum": 55, + "cardset": "A1", + "name": "Blastoise", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 56, + "fields": { + "cardnum": 56, + "cardset": "A1", + "name": "Blastoise EX", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 57, + "fields": { + "cardnum": 57, + "cardset": "A1", + "name": "Psyduck", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 58, + "fields": { + "cardnum": 58, + "cardset": "A1", + "name": "Golduck", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 59, + "fields": { + "cardnum": 59, + "cardset": "A1", + "name": "Poliwag", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 60, + "fields": { + "cardnum": 60, + "cardset": "A1", + "name": "Poliwhirl", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 61, + "fields": { + "cardnum": 61, + "cardset": "A1", + "name": "Poliwrath", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 62, + "fields": { + "cardnum": 62, + "cardset": "A1", + "name": "Tentacool", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 63, + "fields": { + "cardnum": 63, + "cardset": "A1", + "name": "Tentacruel", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 64, + "fields": { + "cardnum": 64, + "cardset": "A1", + "name": "Seel", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 65, + "fields": { + "cardnum": 65, + "cardset": "A1", + "name": "Dewgong", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 66, + "fields": { + "cardnum": 66, + "cardset": "A1", + "name": "Shellder", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 67, + "fields": { + "cardnum": 67, + "cardset": "A1", + "name": "Cloyster", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 68, + "fields": { + "cardnum": 68, + "cardset": "A1", + "name": "Krabby", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 69, + "fields": { + "cardnum": 69, + "cardset": "A1", + "name": "Kingler", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 70, + "fields": { + "cardnum": 70, + "cardset": "A1", + "name": "Horsea", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 71, + "fields": { + "cardnum": 71, + "cardset": "A1", + "name": "Seadra", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 72, + "fields": { + "cardnum": 72, + "cardset": "A1", + "name": "Goldeen", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 73, + "fields": { + "cardnum": 73, + "cardset": "A1", + "name": "Seaking", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 74, + "fields": { + "cardnum": 74, + "cardset": "A1", + "name": "Staryu", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 75, + "fields": { + "cardnum": 75, + "cardset": "A1", + "name": "Starmie", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 76, + "fields": { + "cardnum": 76, + "cardset": "A1", + "name": "Starmie EX", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 77, + "fields": { + "cardnum": 77, + "cardset": "A1", + "name": "Magikarp", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 78, + "fields": { + "cardnum": 78, + "cardset": "A1", + "name": "Gyarados", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 79, + "fields": { + "cardnum": 79, + "cardset": "A1", + "name": "Lapras", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 80, + "fields": { + "cardnum": 80, + "cardset": "A1", + "name": "Vaporeon", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 81, + "fields": { + "cardnum": 81, + "cardset": "A1", + "name": "Omanyte", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 82, + "fields": { + "cardnum": 82, + "cardset": "A1", + "name": "Omastar", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 83, + "fields": { + "cardnum": 83, + "cardset": "A1", + "name": "Articuno", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 84, + "fields": { + "cardnum": 84, + "cardset": "A1", + "name": "Articuno EX", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 85, + "fields": { + "cardnum": 85, + "cardset": "A1", + "name": "Ducklett", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 86, + "fields": { + "cardnum": 86, + "cardset": "A1", + "name": "Swanna", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 87, + "fields": { + "cardnum": 87, + "cardset": "A1", + "name": "Froakie", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 88, + "fields": { + "cardnum": 88, + "cardset": "A1", + "name": "Frogadier", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 89, + "fields": { + "cardnum": 89, + "cardset": "A1", + "name": "Greninja", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 90, + "fields": { + "cardnum": 90, + "cardset": "A1", + "name": "Pyukumuku", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 91, + "fields": { + "cardnum": 91, + "cardset": "A1", + "name": "Bruxish", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 92, + "fields": { + "cardnum": 92, + "cardset": "A1", + "name": "Snom", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 93, + "fields": { + "cardnum": 93, + "cardset": "A1", + "name": "Frosmoth", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 94, + "fields": { + "cardnum": 94, + "cardset": "A1", + "name": "Pikachu", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 95, + "fields": { + "cardnum": 95, + "cardset": "A1", + "name": "Raichu", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 96, + "fields": { + "cardnum": 96, + "cardset": "A1", + "name": "Pikachu EX", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 97, + "fields": { + "cardnum": 97, + "cardset": "A1", + "name": "Magnemite", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 98, + "fields": { + "cardnum": 98, + "cardset": "A1", + "name": "Magneton", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 99, + "fields": { + "cardnum": 99, + "cardset": "A1", + "name": "Voltorb", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 100, + "fields": { + "cardnum": 100, + "cardset": "A1", + "name": "Electrode", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 101, + "fields": { + "cardnum": 101, + "cardset": "A1", + "name": "Electabuzz", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 102, + "fields": { + "cardnum": 102, + "cardset": "A1", + "name": "Jolteon", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 103, + "fields": { + "cardnum": 103, + "cardset": "A1", + "name": "Zapdos", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 104, + "fields": { + "cardnum": 104, + "cardset": "A1", + "name": "Zapdos EX", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 105, + "fields": { + "cardnum": 105, + "cardset": "A1", + "name": "Blitzle", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 106, + "fields": { + "cardnum": 106, + "cardset": "A1", + "name": "Zebstrika", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 107, + "fields": { + "cardnum": 107, + "cardset": "A1", + "name": "Tynamo", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 108, + "fields": { + "cardnum": 108, + "cardset": "A1", + "name": "Eelektrik", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 109, + "fields": { + "cardnum": 109, + "cardset": "A1", + "name": "Eelektross", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 110, + "fields": { + "cardnum": 110, + "cardset": "A1", + "name": "Helioptile", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 111, + "fields": { + "cardnum": 111, + "cardset": "A1", + "name": "Heliolisk", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 112, + "fields": { + "cardnum": 112, + "cardset": "A1", + "name": "Pincurchin", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 113, + "fields": { + "cardnum": 113, + "cardset": "A1", + "name": "Clefairy", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 114, + "fields": { + "cardnum": 114, + "cardset": "A1", + "name": "Clefable", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 115, + "fields": { + "cardnum": 115, + "cardset": "A1", + "name": "Abra", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 116, + "fields": { + "cardnum": 116, + "cardset": "A1", + "name": "Kadabra", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 117, + "fields": { + "cardnum": 117, + "cardset": "A1", + "name": "Alakazam", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 118, + "fields": { + "cardnum": 118, + "cardset": "A1", + "name": "Slowpoke", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 119, + "fields": { + "cardnum": 119, + "cardset": "A1", + "name": "Slowbro", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 120, + "fields": { + "cardnum": 120, + "cardset": "A1", + "name": "Gastly", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 121, + "fields": { + "cardnum": 121, + "cardset": "A1", + "name": "Haunter", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 122, + "fields": { + "cardnum": 122, + "cardset": "A1", + "name": "Gengar", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 123, + "fields": { + "cardnum": 123, + "cardset": "A1", + "name": "Gengar EX", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 124, + "fields": { + "cardnum": 124, + "cardset": "A1", + "name": "Drowzee", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 125, + "fields": { + "cardnum": 125, + "cardset": "A1", + "name": "Hypno", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 126, + "fields": { + "cardnum": 126, + "cardset": "A1", + "name": "Mr. Mime", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 127, + "fields": { + "cardnum": 127, + "cardset": "A1", + "name": "Jynx", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 128, + "fields": { + "cardnum": 128, + "cardset": "A1", + "name": "Mewtwo", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 129, + "fields": { + "cardnum": 129, + "cardset": "A1", + "name": "Mewtwo EX", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 130, + "fields": { + "cardnum": 130, + "cardset": "A1", + "name": "Ralts", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 131, + "fields": { + "cardnum": 131, + "cardset": "A1", + "name": "Kirlia", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 132, + "fields": { + "cardnum": 132, + "cardset": "A1", + "name": "Gardevoir", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 133, + "fields": { + "cardnum": 133, + "cardset": "A1", + "name": "Woobat", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 134, + "fields": { + "cardnum": 134, + "cardset": "A1", + "name": "Swoobat", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 135, + "fields": { + "cardnum": 135, + "cardset": "A1", + "name": "Golett", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 136, + "fields": { + "cardnum": 136, + "cardset": "A1", + "name": "Golurk", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 137, + "fields": { + "cardnum": 137, + "cardset": "A1", + "name": "Sandshrew", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 138, + "fields": { + "cardnum": 138, + "cardset": "A1", + "name": "Sandslash", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 139, + "fields": { + "cardnum": 139, + "cardset": "A1", + "name": "Diglett", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 140, + "fields": { + "cardnum": 140, + "cardset": "A1", + "name": "Dugtrio", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 141, + "fields": { + "cardnum": 141, + "cardset": "A1", + "name": "Mankey", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 142, + "fields": { + "cardnum": 142, + "cardset": "A1", + "name": "Primeape", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 143, + "fields": { + "cardnum": 143, + "cardset": "A1", + "name": "Machop", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 144, + "fields": { + "cardnum": 144, + "cardset": "A1", + "name": "Machoke", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 145, + "fields": { + "cardnum": 145, + "cardset": "A1", + "name": "Machamp", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 146, + "fields": { + "cardnum": 146, + "cardset": "A1", + "name": "Machamp EX", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 147, + "fields": { + "cardnum": 147, + "cardset": "A1", + "name": "Geodude", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 148, + "fields": { + "cardnum": 148, + "cardset": "A1", + "name": "Graveler", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 149, + "fields": { + "cardnum": 149, + "cardset": "A1", + "name": "Golem", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 150, + "fields": { + "cardnum": 150, + "cardset": "A1", + "name": "Onix", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 151, + "fields": { + "cardnum": 151, + "cardset": "A1", + "name": "Cubone", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 152, + "fields": { + "cardnum": 152, + "cardset": "A1", + "name": "Marowak", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 153, + "fields": { + "cardnum": 153, + "cardset": "A1", + "name": "Marowak EX", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 154, + "fields": { + "cardnum": 154, + "cardset": "A1", + "name": "Hitmonlee", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 155, + "fields": { + "cardnum": 155, + "cardset": "A1", + "name": "Hitmonchan", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 156, + "fields": { + "cardnum": 156, + "cardset": "A1", + "name": "Rhyhorn", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 157, + "fields": { + "cardnum": 157, + "cardset": "A1", + "name": "Rhydon", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 158, + "fields": { + "cardnum": 158, + "cardset": "A1", + "name": "Kabuto", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 159, + "fields": { + "cardnum": 159, + "cardset": "A1", + "name": "Kabutops", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 160, + "fields": { + "cardnum": 160, + "cardset": "A1", + "name": "Mienfoo", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 161, + "fields": { + "cardnum": 161, + "cardset": "A1", + "name": "Mienshao", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 162, + "fields": { + "cardnum": 162, + "cardset": "A1", + "name": "Clobbopus", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 163, + "fields": { + "cardnum": 163, + "cardset": "A1", + "name": "Grapploct", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 164, + "fields": { + "cardnum": 164, + "cardset": "A1", + "name": "Ekans", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 165, + "fields": { + "cardnum": 165, + "cardset": "A1", + "name": "Arbok", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 166, + "fields": { + "cardnum": 166, + "cardset": "A1", + "name": "Nidoran♀", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 167, + "fields": { + "cardnum": 167, + "cardset": "A1", + "name": "Nidorina", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 168, + "fields": { + "cardnum": 168, + "cardset": "A1", + "name": "Nidoqueen", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 169, + "fields": { + "cardnum": 169, + "cardset": "A1", + "name": "Nidoran♂", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 170, + "fields": { + "cardnum": 170, + "cardset": "A1", + "name": "Nidorino", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 171, + "fields": { + "cardnum": 171, + "cardset": "A1", + "name": "Nidoking", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 172, + "fields": { + "cardnum": 172, + "cardset": "A1", + "name": "Zubat", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 173, + "fields": { + "cardnum": 173, + "cardset": "A1", + "name": "Golbat", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 174, + "fields": { + "cardnum": 174, + "cardset": "A1", + "name": "Grimer", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 175, + "fields": { + "cardnum": 175, + "cardset": "A1", + "name": "Muk", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 176, + "fields": { + "cardnum": 176, + "cardset": "A1", + "name": "Koffing", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 177, + "fields": { + "cardnum": 177, + "cardset": "A1", + "name": "Weezing", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 178, + "fields": { + "cardnum": 178, + "cardset": "A1", + "name": "Mawile", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 179, + "fields": { + "cardnum": 179, + "cardset": "A1", + "name": "Pawniard", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 180, + "fields": { + "cardnum": 180, + "cardset": "A1", + "name": "Bisharp", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 181, + "fields": { + "cardnum": 181, + "cardset": "A1", + "name": "Meltan", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 182, + "fields": { + "cardnum": 182, + "cardset": "A1", + "name": "Melmetal", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 183, + "fields": { + "cardnum": 183, + "cardset": "A1", + "name": "Dratini", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 184, + "fields": { + "cardnum": 184, + "cardset": "A1", + "name": "Dragonair", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 185, + "fields": { + "cardnum": 185, + "cardset": "A1", + "name": "Dragonite", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 186, + "fields": { + "cardnum": 186, + "cardset": "A1", + "name": "Pidgey", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 187, + "fields": { + "cardnum": 187, + "cardset": "A1", + "name": "Pidgeotto", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 188, + "fields": { + "cardnum": 188, + "cardset": "A1", + "name": "Pidgeot", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 189, + "fields": { + "cardnum": 189, + "cardset": "A1", + "name": "Rattata", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 190, + "fields": { + "cardnum": 190, + "cardset": "A1", + "name": "Raticate", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 191, + "fields": { + "cardnum": 191, + "cardset": "A1", + "name": "Spearow", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 192, + "fields": { + "cardnum": 192, + "cardset": "A1", + "name": "Fearow", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 193, + "fields": { + "cardnum": 193, + "cardset": "A1", + "name": "Jigglypuff", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 194, + "fields": { + "cardnum": 194, + "cardset": "A1", + "name": "Wigglytuff", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 195, + "fields": { + "cardnum": 195, + "cardset": "A1", + "name": "Wigglytuff EX", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 196, + "fields": { + "cardnum": 196, + "cardset": "A1", + "name": "Meowth", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 197, + "fields": { + "cardnum": 197, + "cardset": "A1", + "name": "Persian", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 198, + "fields": { + "cardnum": 198, + "cardset": "A1", + "name": "Farfetch'd", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 199, + "fields": { + "cardnum": 199, + "cardset": "A1", + "name": "Doduo", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 200, + "fields": { + "cardnum": 200, + "cardset": "A1", + "name": "Dodrio", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 201, + "fields": { + "cardnum": 201, + "cardset": "A1", + "name": "Lickitung", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 202, + "fields": { + "cardnum": 202, + "cardset": "A1", + "name": "Chansey", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 203, + "fields": { + "cardnum": 203, + "cardset": "A1", + "name": "Kangaskhan", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 204, + "fields": { + "cardnum": 204, + "cardset": "A1", + "name": "Tauros", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 205, + "fields": { + "cardnum": 205, + "cardset": "A1", + "name": "Ditto", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 206, + "fields": { + "cardnum": 206, + "cardset": "A1", + "name": "Eevee", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 207, + "fields": { + "cardnum": 207, + "cardset": "A1", + "name": "Eevee", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 208, + "fields": { + "cardnum": 208, + "cardset": "A1", + "name": "Eevee", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 209, + "fields": { + "cardnum": 209, + "cardset": "A1", + "name": "Porygon", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 210, + "fields": { + "cardnum": 210, + "cardset": "A1", + "name": "Aerodactyl", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 211, + "fields": { + "cardnum": 211, + "cardset": "A1", + "name": "Snorlax", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 212, + "fields": { + "cardnum": 212, + "cardset": "A1", + "name": "Minccino", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 213, + "fields": { + "cardnum": 213, + "cardset": "A1", + "name": "Cinccino", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 214, + "fields": { + "cardnum": 214, + "cardset": "A1", + "name": "Wooloo", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 215, + "fields": { + "cardnum": 215, + "cardset": "A1", + "name": "Dubwool", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 216, + "fields": { + "cardnum": 216, + "cardset": "A1", + "name": "Helix Fossil", + "decks": [ + 4 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 217, + "fields": { + "cardnum": 217, + "cardset": "A1", + "name": "Dome Fossil", + "decks": [ + 3 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 218, + "fields": { + "cardnum": 218, + "cardset": "A1", + "name": "Old Amber", + "decks": [ + 2 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 219, + "fields": { + "cardnum": 219, + "cardset": "A1", + "name": "Erika", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 220, + "fields": { + "cardnum": 220, + "cardset": "A1", + "name": "Misty", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 221, + "fields": { + "cardnum": 221, + "cardset": "A1", + "name": "Blaine", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 222, + "fields": { + "cardnum": 222, + "cardset": "A1", + "name": "Koga", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 223, + "fields": { + "cardnum": 223, + "cardset": "A1", + "name": "Giovanni", + "decks": [ + 2 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 224, + "fields": { + "cardnum": 224, + "cardset": "A1", + "name": "Brock", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 225, + "fields": { + "cardnum": 225, + "cardset": "A1", + "name": "Sabrina", + "decks": [ + 3 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 226, + "fields": { + "cardnum": 226, + "cardset": "A1", + "name": "Lt. Surge", + "decks": [ + 4 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 227, + "fields": { + "cardnum": 227, + "cardset": "A1", + "name": "Bulbasaur", + "decks": [ + 2 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 228, + "fields": { + "cardnum": 228, + "cardset": "A1", + "name": "Gloom", + "decks": [ + 3 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 229, + "fields": { + "cardnum": 229, + "cardset": "A1", + "name": "Pinsir", + "decks": [ + 3 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 230, + "fields": { + "cardnum": 230, + "cardset": "A1", + "name": "Charmander", + "decks": [ + 3 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 231, + "fields": { + "cardnum": 231, + "cardset": "A1", + "name": "Rapidash", + "decks": [ + 3 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 232, + "fields": { + "cardnum": 232, + "cardset": "A1", + "name": "Squirtle", + "decks": [ + 4 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 233, + "fields": { + "cardnum": 233, + "cardset": "A1", + "name": "Gyarados", + "decks": [ + 4 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 234, + "fields": { + "cardnum": 234, + "cardset": "A1", + "name": "Lapras", + "decks": [ + 3 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 235, + "fields": { + "cardnum": 235, + "cardset": "A1", + "name": "Electrode", + "decks": [ + 4 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 236, + "fields": { + "cardnum": 236, + "cardset": "A1", + "name": "Alakazam", + "decks": [ + 3 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 237, + "fields": { + "cardnum": 237, + "cardset": "A1", + "name": "Slowpoke", + "decks": [ + 3 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 238, + "fields": { + "cardnum": 238, + "cardset": "A1", + "name": "Diglett", + "decks": [ + 4 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 239, + "fields": { + "cardnum": 239, + "cardset": "A1", + "name": "Cubone", + "decks": [ + 2 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 240, + "fields": { + "cardnum": 240, + "cardset": "A1", + "name": "Nidoqueen", + "decks": [ + 4 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 241, + "fields": { + "cardnum": 241, + "cardset": "A1", + "name": "Nidoking", + "decks": [ + 4 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 242, + "fields": { + "cardnum": 242, + "cardset": "A1", + "name": "Golbat", + "decks": [ + 2 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 243, + "fields": { + "cardnum": 243, + "cardset": "A1", + "name": "Weezing", + "decks": [ + 2 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 244, + "fields": { + "cardnum": 244, + "cardset": "A1", + "name": "Dragonite", + "decks": [ + 2 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 245, + "fields": { + "cardnum": 245, + "cardset": "A1", + "name": "Pidgeot", + "decks": [ + 2 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 246, + "fields": { + "cardnum": 246, + "cardset": "A1", + "name": "Meowth", + "decks": [ + 3 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 247, + "fields": { + "cardnum": 247, + "cardset": "A1", + "name": "Ditto", + "decks": [ + 2 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 248, + "fields": { + "cardnum": 248, + "cardset": "A1", + "name": "Eevee", + "decks": [ + 4 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 249, + "fields": { + "cardnum": 249, + "cardset": "A1", + "name": "Porygon", + "decks": [ + 2 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 250, + "fields": { + "cardnum": 250, + "cardset": "A1", + "name": "Snorlax", + "decks": [ + 4 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 251, + "fields": { + "cardnum": 251, + "cardset": "A1", + "name": "Venusaur EX", + "decks": [ + 2 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 252, + "fields": { + "cardnum": 252, + "cardset": "A1", + "name": "Exeggutor EX", + "decks": [ + 3 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 253, + "fields": { + "cardnum": 253, + "cardset": "A1", + "name": "Charizard EX", + "decks": [ + 3 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 254, + "fields": { + "cardnum": 254, + "cardset": "A1", + "name": "Arcanine EX", + "decks": [ + 4 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 255, + "fields": { + "cardnum": 255, + "cardset": "A1", + "name": "Moltres EX", + "decks": [ + 3 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 256, + "fields": { + "cardnum": 256, + "cardset": "A1", + "name": "Blastoise EX", + "decks": [ + 4 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 257, + "fields": { + "cardnum": 257, + "cardset": "A1", + "name": "Starmie EX", + "decks": [ + 3 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 258, + "fields": { + "cardnum": 258, + "cardset": "A1", + "name": "Articuno EX", + "decks": [ + 2 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 259, + "fields": { + "cardnum": 259, + "cardset": "A1", + "name": "Pikachu EX", + "decks": [ + 4 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 260, + "fields": { + "cardnum": 260, + "cardset": "A1", + "name": "Zapdos EX", + "decks": [ + 4 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 261, + "fields": { + "cardnum": 261, + "cardset": "A1", + "name": "Gengar EX", + "decks": [ + 2 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 262, + "fields": { + "cardnum": 262, + "cardset": "A1", + "name": "Mewtwo EX", + "decks": [ + 2 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 263, + "fields": { + "cardnum": 263, + "cardset": "A1", + "name": "Machamp EX", + "decks": [ + 3 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 264, + "fields": { + "cardnum": 264, + "cardset": "A1", + "name": "Marowak EX", + "decks": [ + 2 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 265, + "fields": { + "cardnum": 265, + "cardset": "A1", + "name": "Wigglytuff EX", + "decks": [ + 4 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 266, + "fields": { + "cardnum": 266, + "cardset": "A1", + "name": "Erika", + "decks": [ + 3 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 267, + "fields": { + "cardnum": 267, + "cardset": "A1", + "name": "Misty", + "decks": [ + 4 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 268, + "fields": { + "cardnum": 268, + "cardset": "A1", + "name": "Blaine", + "decks": [ + 3 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 269, + "fields": { + "cardnum": 269, + "cardset": "A1", + "name": "Koga", + "decks": [ + 2 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 270, + "fields": { + "cardnum": 270, + "cardset": "A1", + "name": "Giovanni", + "decks": [ + 2 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 271, + "fields": { + "cardnum": 271, + "cardset": "A1", + "name": "Brock", + "decks": [ + 4 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 272, + "fields": { + "cardnum": 272, + "cardset": "A1", + "name": "Sabrina", + "decks": [ + 3 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 273, + "fields": { + "cardnum": 273, + "cardset": "A1", + "name": "Lt. Surge", + "decks": [ + 4 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 274, + "fields": { + "cardnum": 274, + "cardset": "A1", + "name": "Moltres EX", + "decks": [ + 3 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 275, + "fields": { + "cardnum": 275, + "cardset": "A1", + "name": "Articuno EX", + "decks": [ + 2 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 276, + "fields": { + "cardnum": 276, + "cardset": "A1", + "name": "Zapdos EX", + "decks": [ + 4 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 277, + "fields": { + "cardnum": 277, + "cardset": "A1", + "name": "Gengar EX", + "decks": [ + 2 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 278, + "fields": { + "cardnum": 278, + "cardset": "A1", + "name": "Machamp EX", + "decks": [ + 3 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 279, + "fields": { + "cardnum": 279, + "cardset": "A1", + "name": "Wigglytuff EX", + "decks": [ + 4 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 280, + "fields": { + "cardnum": 280, + "cardset": "A1", + "name": "Charizard EX", + "decks": [ + 3 + ], + "rarity_icon": "⭐️⭐️⭐️", + "rarity_level": 7, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 281, + "fields": { + "cardnum": 281, + "cardset": "A1", + "name": "Pikachu EX", + "decks": [ + 4 + ], + "rarity_icon": "⭐️⭐️⭐️", + "rarity_level": 7, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 282, + "fields": { + "cardnum": 282, + "cardset": "A1", + "name": "Mewtwo EX", + "decks": [ + 2 + ], + "rarity_icon": "⭐️⭐️⭐️", + "rarity_level": 7, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 283, + "fields": { + "cardnum": 283, + "cardset": "A1", + "name": "Mew", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "⭐️⭐️⭐️", + "rarity_level": 7, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 284, + "fields": { + "cardnum": 284, + "cardset": "A1", + "name": "Charizard EX", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "👑", + "rarity_level": 8, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 285, + "fields": { + "cardnum": 285, + "cardset": "A1", + "name": "Pikachu EX", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "👑", + "rarity_level": 8, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 286, + "fields": { + "cardnum": 286, + "cardset": "A1", + "name": "Mewtwo EX", + "decks": [ + 2, + 3, + 4 + ], + "rarity_icon": "👑", + "rarity_level": 8, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 287, + "fields": { + "cardnum": 1, + "cardset": "A1a", + "name": "Exeggcute", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 288, + "fields": { + "cardnum": 2, + "cardset": "A1a", + "name": "Exeggutor", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 289, + "fields": { + "cardnum": 3, + "cardset": "A1a", + "name": "Celebi EX", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 290, + "fields": { + "cardnum": 4, + "cardset": "A1a", + "name": "Snivy", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 291, + "fields": { + "cardnum": 5, + "cardset": "A1a", + "name": "Servine", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 292, + "fields": { + "cardnum": 6, + "cardset": "A1a", + "name": "Serperior", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 293, + "fields": { + "cardnum": 7, + "cardset": "A1a", + "name": "Morelull", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 294, + "fields": { + "cardnum": 8, + "cardset": "A1a", + "name": "Shiinotic", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 295, + "fields": { + "cardnum": 9, + "cardset": "A1a", + "name": "Dhelmise", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 296, + "fields": { + "cardnum": 10, + "cardset": "A1a", + "name": "Ponyta", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 297, + "fields": { + "cardnum": 11, + "cardset": "A1a", + "name": "Rapidash", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 298, + "fields": { + "cardnum": 12, + "cardset": "A1a", + "name": "Magmar", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 299, + "fields": { + "cardnum": 13, + "cardset": "A1a", + "name": "Larvesta", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 300, + "fields": { + "cardnum": 14, + "cardset": "A1a", + "name": "Volcarona", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 301, + "fields": { + "cardnum": 15, + "cardset": "A1a", + "name": "Salandit", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 302, + "fields": { + "cardnum": 16, + "cardset": "A1a", + "name": "Salazzle", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 303, + "fields": { + "cardnum": 17, + "cardset": "A1a", + "name": "Magikarp", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 304, + "fields": { + "cardnum": 18, + "cardset": "A1a", + "name": "Gyarados EX", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 305, + "fields": { + "cardnum": 19, + "cardset": "A1a", + "name": "Vaporeon", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 306, + "fields": { + "cardnum": 20, + "cardset": "A1a", + "name": "Finneon", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 307, + "fields": { + "cardnum": 21, + "cardset": "A1a", + "name": "Lumineon", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 308, + "fields": { + "cardnum": 22, + "cardset": "A1a", + "name": "Chewtle", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 309, + "fields": { + "cardnum": 23, + "cardset": "A1a", + "name": "Drednaw", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 310, + "fields": { + "cardnum": 24, + "cardset": "A1a", + "name": "Cramorant", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 311, + "fields": { + "cardnum": 25, + "cardset": "A1a", + "name": "Pikachu", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 312, + "fields": { + "cardnum": 26, + "cardset": "A1a", + "name": "Raichu", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 313, + "fields": { + "cardnum": 27, + "cardset": "A1a", + "name": "Electabuzz", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 314, + "fields": { + "cardnum": 28, + "cardset": "A1a", + "name": "Joltik", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 315, + "fields": { + "cardnum": 29, + "cardset": "A1a", + "name": "Galvantula", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 316, + "fields": { + "cardnum": 30, + "cardset": "A1a", + "name": "Dedenne", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 317, + "fields": { + "cardnum": 31, + "cardset": "A1a", + "name": "Mew", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 318, + "fields": { + "cardnum": 32, + "cardset": "A1a", + "name": "Mew EX", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 319, + "fields": { + "cardnum": 33, + "cardset": "A1a", + "name": "Sigilyph", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 320, + "fields": { + "cardnum": 34, + "cardset": "A1a", + "name": "Elgyem", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 321, + "fields": { + "cardnum": 35, + "cardset": "A1a", + "name": "Beheeyem", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 322, + "fields": { + "cardnum": 36, + "cardset": "A1a", + "name": "Flabébé", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 323, + "fields": { + "cardnum": 37, + "cardset": "A1a", + "name": "Floette", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 324, + "fields": { + "cardnum": 38, + "cardset": "A1a", + "name": "Florges", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 325, + "fields": { + "cardnum": 39, + "cardset": "A1a", + "name": "Swirlix", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 326, + "fields": { + "cardnum": 40, + "cardset": "A1a", + "name": "Slurpuff", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 327, + "fields": { + "cardnum": 41, + "cardset": "A1a", + "name": "Mankey", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 328, + "fields": { + "cardnum": 42, + "cardset": "A1a", + "name": "Primeape", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 329, + "fields": { + "cardnum": 43, + "cardset": "A1a", + "name": "Geodude", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 330, + "fields": { + "cardnum": 44, + "cardset": "A1a", + "name": "Graveler", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 331, + "fields": { + "cardnum": 45, + "cardset": "A1a", + "name": "Golem", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 332, + "fields": { + "cardnum": 46, + "cardset": "A1a", + "name": "Aerodactyl EX", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 333, + "fields": { + "cardnum": 47, + "cardset": "A1a", + "name": "Marshadow", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 334, + "fields": { + "cardnum": 48, + "cardset": "A1a", + "name": "Stonjourner", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 335, + "fields": { + "cardnum": 49, + "cardset": "A1a", + "name": "Koffing", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 336, + "fields": { + "cardnum": 50, + "cardset": "A1a", + "name": "Weezing", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 337, + "fields": { + "cardnum": 51, + "cardset": "A1a", + "name": "Purrloin", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 338, + "fields": { + "cardnum": 52, + "cardset": "A1a", + "name": "Liepard", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 339, + "fields": { + "cardnum": 53, + "cardset": "A1a", + "name": "Venipede", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 340, + "fields": { + "cardnum": 54, + "cardset": "A1a", + "name": "Whirlipede", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 341, + "fields": { + "cardnum": 55, + "cardset": "A1a", + "name": "Scolipede", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 342, + "fields": { + "cardnum": 56, + "cardset": "A1a", + "name": "Druddigon", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 343, + "fields": { + "cardnum": 57, + "cardset": "A1a", + "name": "Pidgey", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 344, + "fields": { + "cardnum": 58, + "cardset": "A1a", + "name": "Pidgeotto", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 345, + "fields": { + "cardnum": 59, + "cardset": "A1a", + "name": "Pidgeot EX", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 346, + "fields": { + "cardnum": 60, + "cardset": "A1a", + "name": "Tauros", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 347, + "fields": { + "cardnum": 61, + "cardset": "A1a", + "name": "Eevee", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 348, + "fields": { + "cardnum": 62, + "cardset": "A1a", + "name": "Chatot", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 349, + "fields": { + "cardnum": 63, + "cardset": "A1a", + "name": "Old Amber", + "decks": [ + 5 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 350, + "fields": { + "cardnum": 64, + "cardset": "A1a", + "name": "Pokémon Flute", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 351, + "fields": { + "cardnum": 65, + "cardset": "A1a", + "name": "Mythical Slab", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 352, + "fields": { + "cardnum": 66, + "cardset": "A1a", + "name": "Budding Expeditioner", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 353, + "fields": { + "cardnum": 67, + "cardset": "A1a", + "name": "Blue", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 354, + "fields": { + "cardnum": 68, + "cardset": "A1a", + "name": "Leaf", + "decks": [ + 5 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 355, + "fields": { + "cardnum": 69, + "cardset": "A1a", + "name": "Exeggutor", + "decks": [ + 5 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 356, + "fields": { + "cardnum": 70, + "cardset": "A1a", + "name": "Serperior", + "decks": [ + 5 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 357, + "fields": { + "cardnum": 71, + "cardset": "A1a", + "name": "Salandit", + "decks": [ + 5 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 358, + "fields": { + "cardnum": 72, + "cardset": "A1a", + "name": "Vaporeon", + "decks": [ + 5 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 359, + "fields": { + "cardnum": 73, + "cardset": "A1a", + "name": "Dedenne", + "decks": [ + 5 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 360, + "fields": { + "cardnum": 74, + "cardset": "A1a", + "name": "Marshadow", + "decks": [ + 5 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 361, + "fields": { + "cardnum": 75, + "cardset": "A1a", + "name": "Celebi EX", + "decks": [ + 5 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 362, + "fields": { + "cardnum": 76, + "cardset": "A1a", + "name": "Gyarados EX", + "decks": [ + 5 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 363, + "fields": { + "cardnum": 77, + "cardset": "A1a", + "name": "Mew EX", + "decks": [ + 5 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 364, + "fields": { + "cardnum": 78, + "cardset": "A1a", + "name": "Aerodactyl EX", + "decks": [ + 5 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 365, + "fields": { + "cardnum": 79, + "cardset": "A1a", + "name": "Pidgeot EX", + "decks": [ + 5 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 366, + "fields": { + "cardnum": 80, + "cardset": "A1a", + "name": "Budding Expeditioner", + "decks": [ + 5 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 367, + "fields": { + "cardnum": 81, + "cardset": "A1a", + "name": "Blue", + "decks": [ + 5 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 368, + "fields": { + "cardnum": 82, + "cardset": "A1a", + "name": "Leaf", + "decks": [ + 5 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 369, + "fields": { + "cardnum": 83, + "cardset": "A1a", + "name": "Mew EX", + "decks": [ + 5 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 370, + "fields": { + "cardnum": 84, + "cardset": "A1a", + "name": "Aerodactyl EX", + "decks": [ + 5 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 371, + "fields": { + "cardnum": 85, + "cardset": "A1a", + "name": "Celebi EX", + "decks": [ + 5 + ], + "rarity_icon": "⭐️⭐️⭐️", + "rarity_level": 7, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 372, + "fields": { + "cardnum": 86, + "cardset": "A1a", + "name": "Mew EX", + "decks": [ + 5 + ], + "rarity_icon": "👑", + "rarity_level": 8, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 373, + "fields": { + "cardnum": 1, + "cardset": "A2", + "name": "Oddish", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 374, + "fields": { + "cardnum": 2, + "cardset": "A2", + "name": "Gloom", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 375, + "fields": { + "cardnum": 3, + "cardset": "A2", + "name": "Bellossom", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 376, + "fields": { + "cardnum": 4, + "cardset": "A2", + "name": "Tangela", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 377, + "fields": { + "cardnum": 5, + "cardset": "A2", + "name": "Tangrowth", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 378, + "fields": { + "cardnum": 6, + "cardset": "A2", + "name": "Yanma", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 379, + "fields": { + "cardnum": 7, + "cardset": "A2", + "name": "Yanmega EX", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 380, + "fields": { + "cardnum": 8, + "cardset": "A2", + "name": "Roselia", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 381, + "fields": { + "cardnum": 9, + "cardset": "A2", + "name": "Roserade", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 382, + "fields": { + "cardnum": 10, + "cardset": "A2", + "name": "Turtwig", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 383, + "fields": { + "cardnum": 11, + "cardset": "A2", + "name": "Grotle", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 384, + "fields": { + "cardnum": 12, + "cardset": "A2", + "name": "Torterra", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 385, + "fields": { + "cardnum": 13, + "cardset": "A2", + "name": "Kricketot", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 386, + "fields": { + "cardnum": 14, + "cardset": "A2", + "name": "Kricketune", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 387, + "fields": { + "cardnum": 15, + "cardset": "A2", + "name": "Burmy", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 388, + "fields": { + "cardnum": 16, + "cardset": "A2", + "name": "Wormadam", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 389, + "fields": { + "cardnum": 17, + "cardset": "A2", + "name": "Combee", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 390, + "fields": { + "cardnum": 18, + "cardset": "A2", + "name": "Vespiquen", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 391, + "fields": { + "cardnum": 19, + "cardset": "A2", + "name": "Carnivine", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 392, + "fields": { + "cardnum": 20, + "cardset": "A2", + "name": "Leafeon", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 393, + "fields": { + "cardnum": 21, + "cardset": "A2", + "name": "Mow Rotom", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 394, + "fields": { + "cardnum": 22, + "cardset": "A2", + "name": "Shaymin", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 395, + "fields": { + "cardnum": 23, + "cardset": "A2", + "name": "Magmar", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 396, + "fields": { + "cardnum": 24, + "cardset": "A2", + "name": "Magmortar", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 397, + "fields": { + "cardnum": 25, + "cardset": "A2", + "name": "Slugma", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 398, + "fields": { + "cardnum": 26, + "cardset": "A2", + "name": "Magcargo", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 399, + "fields": { + "cardnum": 27, + "cardset": "A2", + "name": "Chimchar", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 400, + "fields": { + "cardnum": 28, + "cardset": "A2", + "name": "Monferno", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 401, + "fields": { + "cardnum": 29, + "cardset": "A2", + "name": "Infernape EX", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 402, + "fields": { + "cardnum": 30, + "cardset": "A2", + "name": "Heat Rotom", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 403, + "fields": { + "cardnum": 31, + "cardset": "A2", + "name": "Swinub", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 404, + "fields": { + "cardnum": 32, + "cardset": "A2", + "name": "Piloswine", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 405, + "fields": { + "cardnum": 33, + "cardset": "A2", + "name": "Mamoswine", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 406, + "fields": { + "cardnum": 34, + "cardset": "A2", + "name": "Regice", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 407, + "fields": { + "cardnum": 35, + "cardset": "A2", + "name": "Piplup", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 408, + "fields": { + "cardnum": 36, + "cardset": "A2", + "name": "Prinplup", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 409, + "fields": { + "cardnum": 37, + "cardset": "A2", + "name": "Empoleon", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 410, + "fields": { + "cardnum": 38, + "cardset": "A2", + "name": "Buizel", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 411, + "fields": { + "cardnum": 39, + "cardset": "A2", + "name": "Floatzel", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 412, + "fields": { + "cardnum": 40, + "cardset": "A2", + "name": "Shellos", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 413, + "fields": { + "cardnum": 41, + "cardset": "A2", + "name": "Gastrodon", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 414, + "fields": { + "cardnum": 42, + "cardset": "A2", + "name": "Finneon", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 415, + "fields": { + "cardnum": 43, + "cardset": "A2", + "name": "Lumineon", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 416, + "fields": { + "cardnum": 44, + "cardset": "A2", + "name": "Snover", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 417, + "fields": { + "cardnum": 45, + "cardset": "A2", + "name": "Abomasnow", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 418, + "fields": { + "cardnum": 46, + "cardset": "A2", + "name": "Glaceon", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 419, + "fields": { + "cardnum": 47, + "cardset": "A2", + "name": "Wash Rotom", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 420, + "fields": { + "cardnum": 48, + "cardset": "A2", + "name": "Frost Rotom", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 421, + "fields": { + "cardnum": 49, + "cardset": "A2", + "name": "Palkia EX", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 422, + "fields": { + "cardnum": 50, + "cardset": "A2", + "name": "Manaphy", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 423, + "fields": { + "cardnum": 51, + "cardset": "A2", + "name": "Magnemite", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 424, + "fields": { + "cardnum": 52, + "cardset": "A2", + "name": "Magneton", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 425, + "fields": { + "cardnum": 53, + "cardset": "A2", + "name": "Magnezone", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 426, + "fields": { + "cardnum": 54, + "cardset": "A2", + "name": "Voltorb", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 427, + "fields": { + "cardnum": 55, + "cardset": "A2", + "name": "Electrode", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 428, + "fields": { + "cardnum": 56, + "cardset": "A2", + "name": "Electabuzz", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 429, + "fields": { + "cardnum": 57, + "cardset": "A2", + "name": "Electivire", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 430, + "fields": { + "cardnum": 58, + "cardset": "A2", + "name": "Shinx", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 431, + "fields": { + "cardnum": 59, + "cardset": "A2", + "name": "Luxio", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 432, + "fields": { + "cardnum": 60, + "cardset": "A2", + "name": "Luxray", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 433, + "fields": { + "cardnum": 61, + "cardset": "A2", + "name": "Pachirisu EX", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 434, + "fields": { + "cardnum": 62, + "cardset": "A2", + "name": "Rotom", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 435, + "fields": { + "cardnum": 63, + "cardset": "A2", + "name": "Togepi", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 436, + "fields": { + "cardnum": 64, + "cardset": "A2", + "name": "Togetic", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 437, + "fields": { + "cardnum": 65, + "cardset": "A2", + "name": "Togekiss", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 438, + "fields": { + "cardnum": 66, + "cardset": "A2", + "name": "Misdreavus", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 439, + "fields": { + "cardnum": 67, + "cardset": "A2", + "name": "Mismagius EX", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 440, + "fields": { + "cardnum": 68, + "cardset": "A2", + "name": "Ralts", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 441, + "fields": { + "cardnum": 69, + "cardset": "A2", + "name": "Kirlia", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 442, + "fields": { + "cardnum": 70, + "cardset": "A2", + "name": "Duskull", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 443, + "fields": { + "cardnum": 71, + "cardset": "A2", + "name": "Dusclops", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 444, + "fields": { + "cardnum": 72, + "cardset": "A2", + "name": "Dusknoir", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 445, + "fields": { + "cardnum": 73, + "cardset": "A2", + "name": "Drifloon", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 446, + "fields": { + "cardnum": 74, + "cardset": "A2", + "name": "Drifblim", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 447, + "fields": { + "cardnum": 75, + "cardset": "A2", + "name": "Uxie", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 448, + "fields": { + "cardnum": 76, + "cardset": "A2", + "name": "Mesprit", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 449, + "fields": { + "cardnum": 77, + "cardset": "A2", + "name": "Azelf", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 450, + "fields": { + "cardnum": 78, + "cardset": "A2", + "name": "Giratina", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 451, + "fields": { + "cardnum": 79, + "cardset": "A2", + "name": "Cresselia", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 452, + "fields": { + "cardnum": 80, + "cardset": "A2", + "name": "Rhyhorn", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 453, + "fields": { + "cardnum": 81, + "cardset": "A2", + "name": "Rhydon", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 454, + "fields": { + "cardnum": 82, + "cardset": "A2", + "name": "Rhyperior", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 455, + "fields": { + "cardnum": 83, + "cardset": "A2", + "name": "Gligar", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 456, + "fields": { + "cardnum": 84, + "cardset": "A2", + "name": "Gliscor", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 457, + "fields": { + "cardnum": 85, + "cardset": "A2", + "name": "Hitmontop", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 458, + "fields": { + "cardnum": 86, + "cardset": "A2", + "name": "Nosepass", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 459, + "fields": { + "cardnum": 87, + "cardset": "A2", + "name": "Regirock", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 460, + "fields": { + "cardnum": 88, + "cardset": "A2", + "name": "Cranidos", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 461, + "fields": { + "cardnum": 89, + "cardset": "A2", + "name": "Rampardos", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 462, + "fields": { + "cardnum": 90, + "cardset": "A2", + "name": "Wormadam", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 463, + "fields": { + "cardnum": 91, + "cardset": "A2", + "name": "Riolu", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 464, + "fields": { + "cardnum": 92, + "cardset": "A2", + "name": "Lucario", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 465, + "fields": { + "cardnum": 93, + "cardset": "A2", + "name": "Hippopotas", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 466, + "fields": { + "cardnum": 94, + "cardset": "A2", + "name": "Hippowdon", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 467, + "fields": { + "cardnum": 95, + "cardset": "A2", + "name": "Gallade EX", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 468, + "fields": { + "cardnum": 96, + "cardset": "A2", + "name": "Murkrow", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 469, + "fields": { + "cardnum": 97, + "cardset": "A2", + "name": "Honchkrow", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 470, + "fields": { + "cardnum": 98, + "cardset": "A2", + "name": "Sneasel", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 471, + "fields": { + "cardnum": 99, + "cardset": "A2", + "name": "Weavile EX", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 472, + "fields": { + "cardnum": 100, + "cardset": "A2", + "name": "Poochyena", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 473, + "fields": { + "cardnum": 101, + "cardset": "A2", + "name": "Mightyena", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 474, + "fields": { + "cardnum": 102, + "cardset": "A2", + "name": "Stunky", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 475, + "fields": { + "cardnum": 103, + "cardset": "A2", + "name": "Skuntank", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 476, + "fields": { + "cardnum": 104, + "cardset": "A2", + "name": "Spiritomb", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 477, + "fields": { + "cardnum": 105, + "cardset": "A2", + "name": "Skorupi", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 478, + "fields": { + "cardnum": 106, + "cardset": "A2", + "name": "Drapion", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 479, + "fields": { + "cardnum": 107, + "cardset": "A2", + "name": "Croagunk", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 480, + "fields": { + "cardnum": 108, + "cardset": "A2", + "name": "Toxicroak", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 481, + "fields": { + "cardnum": 109, + "cardset": "A2", + "name": "Darkrai", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 482, + "fields": { + "cardnum": 110, + "cardset": "A2", + "name": "Darkrai EX", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 483, + "fields": { + "cardnum": 111, + "cardset": "A2", + "name": "Skarmory", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 484, + "fields": { + "cardnum": 112, + "cardset": "A2", + "name": "Registeel", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 485, + "fields": { + "cardnum": 113, + "cardset": "A2", + "name": "Shieldon", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 486, + "fields": { + "cardnum": 114, + "cardset": "A2", + "name": "Bastiodon", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 487, + "fields": { + "cardnum": 115, + "cardset": "A2", + "name": "Wormadam", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 488, + "fields": { + "cardnum": 116, + "cardset": "A2", + "name": "Bronzor", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 489, + "fields": { + "cardnum": 117, + "cardset": "A2", + "name": "Bronzong", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 490, + "fields": { + "cardnum": 118, + "cardset": "A2", + "name": "Probopass", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 491, + "fields": { + "cardnum": 119, + "cardset": "A2", + "name": "Dialga EX", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 492, + "fields": { + "cardnum": 120, + "cardset": "A2", + "name": "Heatran", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 493, + "fields": { + "cardnum": 121, + "cardset": "A2", + "name": "Gible", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 494, + "fields": { + "cardnum": 122, + "cardset": "A2", + "name": "Gabite", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 495, + "fields": { + "cardnum": 123, + "cardset": "A2", + "name": "Garchomp", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 496, + "fields": { + "cardnum": 124, + "cardset": "A2", + "name": "Lickitung", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 497, + "fields": { + "cardnum": 125, + "cardset": "A2", + "name": "Lickilicky EX", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷🔷", + "rarity_level": 4, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 498, + "fields": { + "cardnum": 126, + "cardset": "A2", + "name": "Eevee", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 499, + "fields": { + "cardnum": 127, + "cardset": "A2", + "name": "Porygon", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 500, + "fields": { + "cardnum": 128, + "cardset": "A2", + "name": "Porygon2", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 501, + "fields": { + "cardnum": 129, + "cardset": "A2", + "name": "Porygon-Z", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 502, + "fields": { + "cardnum": 130, + "cardset": "A2", + "name": "Aipom", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 503, + "fields": { + "cardnum": 131, + "cardset": "A2", + "name": "Ambipom", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 504, + "fields": { + "cardnum": 132, + "cardset": "A2", + "name": "Starly", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 505, + "fields": { + "cardnum": 133, + "cardset": "A2", + "name": "Staravia", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 506, + "fields": { + "cardnum": 134, + "cardset": "A2", + "name": "Staraptor", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 507, + "fields": { + "cardnum": 135, + "cardset": "A2", + "name": "Bidoof", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 508, + "fields": { + "cardnum": 136, + "cardset": "A2", + "name": "Bibarel", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 509, + "fields": { + "cardnum": 137, + "cardset": "A2", + "name": "Buneary", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 510, + "fields": { + "cardnum": 138, + "cardset": "A2", + "name": "Lopunny", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 511, + "fields": { + "cardnum": 139, + "cardset": "A2", + "name": "Glameow", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 512, + "fields": { + "cardnum": 140, + "cardset": "A2", + "name": "Purugly", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 513, + "fields": { + "cardnum": 141, + "cardset": "A2", + "name": "Chatot", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 514, + "fields": { + "cardnum": 142, + "cardset": "A2", + "name": "Fan Rotom", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 515, + "fields": { + "cardnum": 143, + "cardset": "A2", + "name": "Regigigas", + "decks": [ + 6, + 7 + ], + "rarity_icon": "🔷🔷🔷", + "rarity_level": 3, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 516, + "fields": { + "cardnum": 144, + "cardset": "A2", + "name": "Skull Fossil", + "decks": [ + 6 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 517, + "fields": { + "cardnum": 145, + "cardset": "A2", + "name": "Armor Fossil", + "decks": [ + 7 + ], + "rarity_icon": "🔷", + "rarity_level": 1, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 518, + "fields": { + "cardnum": 146, + "cardset": "A2", + "name": "Pokémon Communication", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 519, + "fields": { + "cardnum": 147, + "cardset": "A2", + "name": "Giant Cape", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 520, + "fields": { + "cardnum": 148, + "cardset": "A2", + "name": "Rocky Helmet", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 521, + "fields": { + "cardnum": 149, + "cardset": "A2", + "name": "Lum Berry", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 522, + "fields": { + "cardnum": 150, + "cardset": "A2", + "name": "Cyrus", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 523, + "fields": { + "cardnum": 151, + "cardset": "A2", + "name": "Team Galactic Grunt", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 524, + "fields": { + "cardnum": 152, + "cardset": "A2", + "name": "Cynthia", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 525, + "fields": { + "cardnum": 153, + "cardset": "A2", + "name": "Volkner", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 526, + "fields": { + "cardnum": 154, + "cardset": "A2", + "name": "Dawn", + "decks": [ + 6 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 527, + "fields": { + "cardnum": 155, + "cardset": "A2", + "name": "Mars", + "decks": [ + 7 + ], + "rarity_icon": "🔷🔷", + "rarity_level": 2, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 528, + "fields": { + "cardnum": 156, + "cardset": "A2", + "name": "Tangrowth", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 529, + "fields": { + "cardnum": 157, + "cardset": "A2", + "name": "Combee", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 530, + "fields": { + "cardnum": 158, + "cardset": "A2", + "name": "Carnivine", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 531, + "fields": { + "cardnum": 159, + "cardset": "A2", + "name": "Shaymin", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 532, + "fields": { + "cardnum": 160, + "cardset": "A2", + "name": "Mamoswine", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 533, + "fields": { + "cardnum": 161, + "cardset": "A2", + "name": "Gastrodon", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 534, + "fields": { + "cardnum": 162, + "cardset": "A2", + "name": "Manaphy", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 535, + "fields": { + "cardnum": 163, + "cardset": "A2", + "name": "Shinx", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 536, + "fields": { + "cardnum": 164, + "cardset": "A2", + "name": "Rotom", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 537, + "fields": { + "cardnum": 165, + "cardset": "A2", + "name": "Drifloon", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 538, + "fields": { + "cardnum": 166, + "cardset": "A2", + "name": "Mesprit", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 539, + "fields": { + "cardnum": 167, + "cardset": "A2", + "name": "Giratina", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 540, + "fields": { + "cardnum": 168, + "cardset": "A2", + "name": "Cresselia", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 541, + "fields": { + "cardnum": 169, + "cardset": "A2", + "name": "Rhyperior", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 542, + "fields": { + "cardnum": 170, + "cardset": "A2", + "name": "Lucario", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 543, + "fields": { + "cardnum": 171, + "cardset": "A2", + "name": "Hippopotas", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 544, + "fields": { + "cardnum": 172, + "cardset": "A2", + "name": "Spiritomb", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 545, + "fields": { + "cardnum": 173, + "cardset": "A2", + "name": "Croagunk", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 546, + "fields": { + "cardnum": 174, + "cardset": "A2", + "name": "Heatran", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 547, + "fields": { + "cardnum": 175, + "cardset": "A2", + "name": "Garchomp", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 548, + "fields": { + "cardnum": 176, + "cardset": "A2", + "name": "Staraptor", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 549, + "fields": { + "cardnum": 177, + "cardset": "A2", + "name": "Bidoof", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 550, + "fields": { + "cardnum": 178, + "cardset": "A2", + "name": "Glameow", + "decks": [ + 7 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 551, + "fields": { + "cardnum": 179, + "cardset": "A2", + "name": "Regigigas", + "decks": [ + 6 + ], + "rarity_icon": "⭐️", + "rarity_level": 5, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 552, + "fields": { + "cardnum": 180, + "cardset": "A2", + "name": "Yanmega EX", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 553, + "fields": { + "cardnum": 181, + "cardset": "A2", + "name": "Infernape EX", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 554, + "fields": { + "cardnum": 182, + "cardset": "A2", + "name": "Palkia EX", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 555, + "fields": { + "cardnum": 183, + "cardset": "A2", + "name": "Pachirisu EX", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 556, + "fields": { + "cardnum": 184, + "cardset": "A2", + "name": "Mismagius EX", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 557, + "fields": { + "cardnum": 185, + "cardset": "A2", + "name": "Gallade EX", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 558, + "fields": { + "cardnum": 186, + "cardset": "A2", + "name": "Weavile EX", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 559, + "fields": { + "cardnum": 187, + "cardset": "A2", + "name": "Darkrai EX", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 560, + "fields": { + "cardnum": 188, + "cardset": "A2", + "name": "Dialga EX", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 561, + "fields": { + "cardnum": 189, + "cardset": "A2", + "name": "Lickilicky EX", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 562, + "fields": { + "cardnum": 190, + "cardset": "A2", + "name": "Cyrus", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 563, + "fields": { + "cardnum": 191, + "cardset": "A2", + "name": "Team Galactic Grunt", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 564, + "fields": { + "cardnum": 192, + "cardset": "A2", + "name": "Cynthia", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 565, + "fields": { + "cardnum": 193, + "cardset": "A2", + "name": "Volkner", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 566, + "fields": { + "cardnum": 194, + "cardset": "A2", + "name": "Dawn", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 567, + "fields": { + "cardnum": 195, + "cardset": "A2", + "name": "Mars", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 568, + "fields": { + "cardnum": 196, + "cardset": "A2", + "name": "Yanmega EX", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 569, + "fields": { + "cardnum": 197, + "cardset": "A2", + "name": "Infernape EX", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 570, + "fields": { + "cardnum": 198, + "cardset": "A2", + "name": "Pachirisu EX", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 571, + "fields": { + "cardnum": 199, + "cardset": "A2", + "name": "Mismagius EX", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 572, + "fields": { + "cardnum": 200, + "cardset": "A2", + "name": "Gallade EX", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 573, + "fields": { + "cardnum": 201, + "cardset": "A2", + "name": "Weavile EX", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 574, + "fields": { + "cardnum": 202, + "cardset": "A2", + "name": "Darkrai EX", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 575, + "fields": { + "cardnum": 203, + "cardset": "A2", + "name": "Lickilicky EX", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️", + "rarity_level": 6, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 576, + "fields": { + "cardnum": 204, + "cardset": "A2", + "name": "Palkia EX", + "decks": [ + 7 + ], + "rarity_icon": "⭐️⭐️⭐️", + "rarity_level": 7, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 577, + "fields": { + "cardnum": 205, + "cardset": "A2", + "name": "Dialga EX", + "decks": [ + 6 + ], + "rarity_icon": "⭐️⭐️⭐️", + "rarity_level": 7, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 578, + "fields": { + "cardnum": 206, + "cardset": "A2", + "name": "Palkia EX", + "decks": [ + 6, + 7 + ], + "rarity_icon": "👑", + "rarity_level": 8, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 579, + "fields": { + "cardnum": 207, + "cardset": "A2", + "name": "Dialga EX", + "decks": [ + 6, + 7 + ], + "rarity_icon": "👑", + "rarity_level": 8, + "created_at": "2025-02-17T02:44:18.706Z", + "updated_at": "2025-02-17T02:44:18.706Z" + } + }, + { + "model": "cards.card", + "pk": 580, + "fields": { + "cardnum": 1, + "cardset": "A2a", + "name": "Heracross", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 581, + "fields": { + "cardnum": 2, + "cardset": "A2a", + "name": "Burmy", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 582, + "fields": { + "cardnum": 3, + "cardset": "A2a", + "name": "Mothim", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 583, + "fields": { + "cardnum": 4, + "cardset": "A2a", + "name": "Combee", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 584, + "fields": { + "cardnum": 5, + "cardset": "A2a", + "name": "Vespiquen", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 585, + "fields": { + "cardnum": 6, + "cardset": "A2a", + "name": "Cherubi", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 586, + "fields": { + "cardnum": 7, + "cardset": "A2a", + "name": "Cherrim", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 587, + "fields": { + "cardnum": 8, + "cardset": "A2a", + "name": "Cherrim", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 588, + "fields": { + "cardnum": 9, + "cardset": "A2a", + "name": "Carnivine", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 589, + "fields": { + "cardnum": 10, + "cardset": "A2a", + "name": "Leafeon EX", + "decks": [ + 8 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 590, + "fields": { + "cardnum": 11, + "cardset": "A2a", + "name": "Houndour", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 591, + "fields": { + "cardnum": 12, + "cardset": "A2a", + "name": "Houndoom", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 592, + "fields": { + "cardnum": 13, + "cardset": "A2a", + "name": "Heatran", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 593, + "fields": { + "cardnum": 14, + "cardset": "A2a", + "name": "Marill", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 594, + "fields": { + "cardnum": 15, + "cardset": "A2a", + "name": "Azumarill", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 595, + "fields": { + "cardnum": 16, + "cardset": "A2a", + "name": "Barboach", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 596, + "fields": { + "cardnum": 17, + "cardset": "A2a", + "name": "Whiscash", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 597, + "fields": { + "cardnum": 18, + "cardset": "A2a", + "name": "Snorunt", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 598, + "fields": { + "cardnum": 19, + "cardset": "A2a", + "name": "Froslass", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 599, + "fields": { + "cardnum": 20, + "cardset": "A2a", + "name": "Snover", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 600, + "fields": { + "cardnum": 21, + "cardset": "A2a", + "name": "Abomasnow", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 601, + "fields": { + "cardnum": 22, + "cardset": "A2a", + "name": "Glaceon EX", + "decks": [ + 8 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 602, + "fields": { + "cardnum": 23, + "cardset": "A2a", + "name": "Origin Forme Palkia", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 603, + "fields": { + "cardnum": 24, + "cardset": "A2a", + "name": "Phione", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 604, + "fields": { + "cardnum": 25, + "cardset": "A2a", + "name": "Pikachu", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 605, + "fields": { + "cardnum": 26, + "cardset": "A2a", + "name": "Raichu", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 606, + "fields": { + "cardnum": 27, + "cardset": "A2a", + "name": "Electrike", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 607, + "fields": { + "cardnum": 28, + "cardset": "A2a", + "name": "Manectric", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 608, + "fields": { + "cardnum": 29, + "cardset": "A2a", + "name": "Clefairy", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 609, + "fields": { + "cardnum": 30, + "cardset": "A2a", + "name": "Clefable", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 610, + "fields": { + "cardnum": 31, + "cardset": "A2a", + "name": "Gastly", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 611, + "fields": { + "cardnum": 32, + "cardset": "A2a", + "name": "Haunter", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 612, + "fields": { + "cardnum": 33, + "cardset": "A2a", + "name": "Gengar", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 613, + "fields": { + "cardnum": 34, + "cardset": "A2a", + "name": "Unown", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 614, + "fields": { + "cardnum": 35, + "cardset": "A2a", + "name": "Rotom", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 615, + "fields": { + "cardnum": 36, + "cardset": "A2a", + "name": "Sudowoodo", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 616, + "fields": { + "cardnum": 37, + "cardset": "A2a", + "name": "Phanpy", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 617, + "fields": { + "cardnum": 38, + "cardset": "A2a", + "name": "Donphan", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 618, + "fields": { + "cardnum": 39, + "cardset": "A2a", + "name": "Larvitar", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 619, + "fields": { + "cardnum": 40, + "cardset": "A2a", + "name": "Pupitar", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 620, + "fields": { + "cardnum": 41, + "cardset": "A2a", + "name": "Tyranitar", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 621, + "fields": { + "cardnum": 42, + "cardset": "A2a", + "name": "Nosepass", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 622, + "fields": { + "cardnum": 43, + "cardset": "A2a", + "name": "Meditite", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 623, + "fields": { + "cardnum": 44, + "cardset": "A2a", + "name": "Medicham", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 624, + "fields": { + "cardnum": 45, + "cardset": "A2a", + "name": "Gible", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 625, + "fields": { + "cardnum": 46, + "cardset": "A2a", + "name": "Gabite", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 626, + "fields": { + "cardnum": 47, + "cardset": "A2a", + "name": "Garchomp EX", + "decks": [ + 8 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 627, + "fields": { + "cardnum": 48, + "cardset": "A2a", + "name": "Zubat", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 628, + "fields": { + "cardnum": 49, + "cardset": "A2a", + "name": "Golbat", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 629, + "fields": { + "cardnum": 50, + "cardset": "A2a", + "name": "Crobat", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 630, + "fields": { + "cardnum": 51, + "cardset": "A2a", + "name": "Croagunk", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 631, + "fields": { + "cardnum": 52, + "cardset": "A2a", + "name": "Toxicroak", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 632, + "fields": { + "cardnum": 53, + "cardset": "A2a", + "name": "Magnemite", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 633, + "fields": { + "cardnum": 54, + "cardset": "A2a", + "name": "Magneton", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 634, + "fields": { + "cardnum": 55, + "cardset": "A2a", + "name": "Magnezone", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 635, + "fields": { + "cardnum": 56, + "cardset": "A2a", + "name": "Mawile", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 636, + "fields": { + "cardnum": 57, + "cardset": "A2a", + "name": "Probopass EX", + "decks": [ + 8 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 637, + "fields": { + "cardnum": 58, + "cardset": "A2a", + "name": "Bronzor", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 638, + "fields": { + "cardnum": 59, + "cardset": "A2a", + "name": "Bronzong", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 639, + "fields": { + "cardnum": 60, + "cardset": "A2a", + "name": "Origin Forme Dialga", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 640, + "fields": { + "cardnum": 61, + "cardset": "A2a", + "name": "Giratina", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 641, + "fields": { + "cardnum": 62, + "cardset": "A2a", + "name": "Eevee", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 642, + "fields": { + "cardnum": 63, + "cardset": "A2a", + "name": "Snorlax", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 643, + "fields": { + "cardnum": 64, + "cardset": "A2a", + "name": "Hoothoot", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 644, + "fields": { + "cardnum": 65, + "cardset": "A2a", + "name": "Noctowl", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 645, + "fields": { + "cardnum": 66, + "cardset": "A2a", + "name": "Starly", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 646, + "fields": { + "cardnum": 67, + "cardset": "A2a", + "name": "Staravia", + "decks": [ + 8 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 647, + "fields": { + "cardnum": 68, + "cardset": "A2a", + "name": "Staraptor", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 648, + "fields": { + "cardnum": 69, + "cardset": "A2a", + "name": "Shaymin", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 649, + "fields": { + "cardnum": 70, + "cardset": "A2a", + "name": "Arceus", + "decks": [ + 8 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 650, + "fields": { + "cardnum": 71, + "cardset": "A2a", + "name": "Arceus EX", + "decks": [ + 8 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 651, + "fields": { + "cardnum": 72, + "cardset": "A2a", + "name": "Irida", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 652, + "fields": { + "cardnum": 73, + "cardset": "A2a", + "name": "Celestic Town Elder", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 653, + "fields": { + "cardnum": 74, + "cardset": "A2a", + "name": "Barry", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 654, + "fields": { + "cardnum": 75, + "cardset": "A2a", + "name": "Adaman", + "decks": [ + 8 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 655, + "fields": { + "cardnum": 76, + "cardset": "A2a", + "name": "Houndoom", + "decks": [ + 8 + ], + "rarity_level": 5, + "rarity_icon": "⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 656, + "fields": { + "cardnum": 77, + "cardset": "A2a", + "name": "Marill", + "decks": [ + 8 + ], + "rarity_level": 5, + "rarity_icon": "⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 657, + "fields": { + "cardnum": 78, + "cardset": "A2a", + "name": "Unown", + "decks": [ + 8 + ], + "rarity_level": 5, + "rarity_icon": "⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 658, + "fields": { + "cardnum": 79, + "cardset": "A2a", + "name": "Sudowoodo", + "decks": [ + 8 + ], + "rarity_level": 5, + "rarity_icon": "⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 659, + "fields": { + "cardnum": 80, + "cardset": "A2a", + "name": "Magnemite", + "decks": [ + 8 + ], + "rarity_level": 5, + "rarity_icon": "⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 660, + "fields": { + "cardnum": 81, + "cardset": "A2a", + "name": "Shaymin", + "decks": [ + 8 + ], + "rarity_level": 5, + "rarity_icon": "⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 661, + "fields": { + "cardnum": 82, + "cardset": "A2a", + "name": "Leafeon EX", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 662, + "fields": { + "cardnum": 83, + "cardset": "A2a", + "name": "Glaceon EX", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 663, + "fields": { + "cardnum": 84, + "cardset": "A2a", + "name": "Garchomp EX", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 664, + "fields": { + "cardnum": 85, + "cardset": "A2a", + "name": "Probopass EX", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 665, + "fields": { + "cardnum": 86, + "cardset": "A2a", + "name": "Arceus EX", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 666, + "fields": { + "cardnum": 87, + "cardset": "A2a", + "name": "Irida", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 667, + "fields": { + "cardnum": 88, + "cardset": "A2a", + "name": "Celestic Town Elder", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 668, + "fields": { + "cardnum": 89, + "cardset": "A2a", + "name": "Barry", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 669, + "fields": { + "cardnum": 90, + "cardset": "A2a", + "name": "Adaman", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 670, + "fields": { + "cardnum": 91, + "cardset": "A2a", + "name": "Leafeon EX", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 671, + "fields": { + "cardnum": 92, + "cardset": "A2a", + "name": "Glaceon EX", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 672, + "fields": { + "cardnum": 93, + "cardset": "A2a", + "name": "Garchomp EX", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 673, + "fields": { + "cardnum": 94, + "cardset": "A2a", + "name": "Probopass EX", + "decks": [ + 8 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 674, + "fields": { + "cardnum": 95, + "cardset": "A2a", + "name": "Arceus EX", + "decks": [ + 8 + ], + "rarity_level": 7, + "rarity_icon": "⭐️⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 675, + "fields": { + "cardnum": 96, + "cardset": "A2a", + "name": "Arceus EX", + "decks": [ + 8 + ], + "rarity_level": 8, + "rarity_icon": "👑", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 676, + "fields": { + "cardnum": 1, + "cardset": "A2b", + "name": "Weedle", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 677, + "fields": { + "cardnum": 2, + "cardset": "A2b", + "name": "Kakuna", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 678, + "fields": { + "cardnum": 3, + "cardset": "A2b", + "name": "Beedrill EX", + "decks": [ + 9 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 679, + "fields": { + "cardnum": 4, + "cardset": "A2b", + "name": "Pinsir", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 680, + "fields": { + "cardnum": 5, + "cardset": "A2b", + "name": "Sprigatito", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 681, + "fields": { + "cardnum": 6, + "cardset": "A2b", + "name": "Floragato", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 682, + "fields": { + "cardnum": 7, + "cardset": "A2b", + "name": "Meowscarada", + "decks": [ + 9 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 683, + "fields": { + "cardnum": 8, + "cardset": "A2b", + "name": "Charmander", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 684, + "fields": { + "cardnum": 9, + "cardset": "A2b", + "name": "Charmeleon", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 685, + "fields": { + "cardnum": 10, + "cardset": "A2b", + "name": "Charizard EX", + "decks": [ + 9 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 686, + "fields": { + "cardnum": 11, + "cardset": "A2b", + "name": "Magmar", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 687, + "fields": { + "cardnum": 12, + "cardset": "A2b", + "name": "Magmortar", + "decks": [ + 9 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 688, + "fields": { + "cardnum": 13, + "cardset": "A2b", + "name": "Paldean Tauros", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 689, + "fields": { + "cardnum": 14, + "cardset": "A2b", + "name": "Tentacool", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 690, + "fields": { + "cardnum": 15, + "cardset": "A2b", + "name": "Tentacruel", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 691, + "fields": { + "cardnum": 16, + "cardset": "A2b", + "name": "Buizel", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 692, + "fields": { + "cardnum": 17, + "cardset": "A2b", + "name": "Floatzel", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 693, + "fields": { + "cardnum": 18, + "cardset": "A2b", + "name": "Wiglett", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 694, + "fields": { + "cardnum": 19, + "cardset": "A2b", + "name": "Wugtrio EX", + "decks": [ + 9 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 695, + "fields": { + "cardnum": 20, + "cardset": "A2b", + "name": "Dondozo", + "decks": [ + 9 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 696, + "fields": { + "cardnum": 21, + "cardset": "A2b", + "name": "Tatsugiri", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 697, + "fields": { + "cardnum": 22, + "cardset": "A2b", + "name": "Pikachu EX", + "decks": [ + 9 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 698, + "fields": { + "cardnum": 23, + "cardset": "A2b", + "name": "Voltorb", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 699, + "fields": { + "cardnum": 24, + "cardset": "A2b", + "name": "Electrode", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 700, + "fields": { + "cardnum": 25, + "cardset": "A2b", + "name": "Pachirisu", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 701, + "fields": { + "cardnum": 26, + "cardset": "A2b", + "name": "Pawmi", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 702, + "fields": { + "cardnum": 27, + "cardset": "A2b", + "name": "Pawmo", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 703, + "fields": { + "cardnum": 28, + "cardset": "A2b", + "name": "Pawmot", + "decks": [ + 9 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 704, + "fields": { + "cardnum": 29, + "cardset": "A2b", + "name": "Abra", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 705, + "fields": { + "cardnum": 30, + "cardset": "A2b", + "name": "Kadabra", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 706, + "fields": { + "cardnum": 31, + "cardset": "A2b", + "name": "Alakazam", + "decks": [ + 9 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 707, + "fields": { + "cardnum": 32, + "cardset": "A2b", + "name": "Mr. Mime", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 708, + "fields": { + "cardnum": 33, + "cardset": "A2b", + "name": "Drifloon", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 709, + "fields": { + "cardnum": 34, + "cardset": "A2b", + "name": "Drifblim", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 710, + "fields": { + "cardnum": 35, + "cardset": "A2b", + "name": "Giratina EX", + "decks": [ + 9 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 711, + "fields": { + "cardnum": 36, + "cardset": "A2b", + "name": "Gimmighoul", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 712, + "fields": { + "cardnum": 37, + "cardset": "A2b", + "name": "Machop", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 713, + "fields": { + "cardnum": 38, + "cardset": "A2b", + "name": "Machoke", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 714, + "fields": { + "cardnum": 39, + "cardset": "A2b", + "name": "Machamp", + "decks": [ + 9 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 715, + "fields": { + "cardnum": 40, + "cardset": "A2b", + "name": "Hitmonlee", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 716, + "fields": { + "cardnum": 41, + "cardset": "A2b", + "name": "Hitmonchan", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 717, + "fields": { + "cardnum": 42, + "cardset": "A2b", + "name": "Riolu", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 718, + "fields": { + "cardnum": 43, + "cardset": "A2b", + "name": "Lucario EX", + "decks": [ + 9 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 719, + "fields": { + "cardnum": 44, + "cardset": "A2b", + "name": "Flamigo", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 720, + "fields": { + "cardnum": 45, + "cardset": "A2b", + "name": "Ekans", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 721, + "fields": { + "cardnum": 46, + "cardset": "A2b", + "name": "Arbok", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 722, + "fields": { + "cardnum": 47, + "cardset": "A2b", + "name": "Paldean Wooper", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 723, + "fields": { + "cardnum": 48, + "cardset": "A2b", + "name": "Paldean Clodsire EX", + "decks": [ + 9 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 724, + "fields": { + "cardnum": 49, + "cardset": "A2b", + "name": "Spiritomb", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 725, + "fields": { + "cardnum": 50, + "cardset": "A2b", + "name": "Shroodle", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 726, + "fields": { + "cardnum": 51, + "cardset": "A2b", + "name": "Grafaiai", + "decks": [ + 9 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 727, + "fields": { + "cardnum": 52, + "cardset": "A2b", + "name": "Tinkatink", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 728, + "fields": { + "cardnum": 53, + "cardset": "A2b", + "name": "Tinkatuff", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 729, + "fields": { + "cardnum": 54, + "cardset": "A2b", + "name": "Tinkaton EX", + "decks": [ + 9 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 730, + "fields": { + "cardnum": 55, + "cardset": "A2b", + "name": "Varoom", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 731, + "fields": { + "cardnum": 56, + "cardset": "A2b", + "name": "Revavroom", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 732, + "fields": { + "cardnum": 57, + "cardset": "A2b", + "name": "Gholdengo", + "decks": [ + 9 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 733, + "fields": { + "cardnum": 58, + "cardset": "A2b", + "name": "Rattata", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 734, + "fields": { + "cardnum": 59, + "cardset": "A2b", + "name": "Raticate", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 735, + "fields": { + "cardnum": 60, + "cardset": "A2b", + "name": "Jigglypuff", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 736, + "fields": { + "cardnum": 61, + "cardset": "A2b", + "name": "Wigglytuff", + "decks": [ + 9 + ], + "rarity_level": 3, + "rarity_icon": "🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 737, + "fields": { + "cardnum": 62, + "cardset": "A2b", + "name": "Lickitung", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 738, + "fields": { + "cardnum": 63, + "cardset": "A2b", + "name": "Lickilicky", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 739, + "fields": { + "cardnum": 64, + "cardset": "A2b", + "name": "Bidoof", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 740, + "fields": { + "cardnum": 65, + "cardset": "A2b", + "name": "Bibarel EX", + "decks": [ + 9 + ], + "rarity_level": 4, + "rarity_icon": "🔷🔷🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 741, + "fields": { + "cardnum": 66, + "cardset": "A2b", + "name": "Buneary", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 742, + "fields": { + "cardnum": 67, + "cardset": "A2b", + "name": "Lopunny", + "decks": [ + 9 + ], + "rarity_level": 1, + "rarity_icon": "🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 743, + "fields": { + "cardnum": 68, + "cardset": "A2b", + "name": "Cyclizar", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 744, + "fields": { + "cardnum": 69, + "cardset": "A2b", + "name": "Iono", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 745, + "fields": { + "cardnum": 70, + "cardset": "A2b", + "name": "Pokémon Center Lady", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 746, + "fields": { + "cardnum": 71, + "cardset": "A2b", + "name": "Red", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 747, + "fields": { + "cardnum": 72, + "cardset": "A2b", + "name": "Team Rocket Grunt", + "decks": [ + 9 + ], + "rarity_level": 2, + "rarity_icon": "🔷🔷", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 748, + "fields": { + "cardnum": 73, + "cardset": "A2b", + "name": "Meowscarada", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 749, + "fields": { + "cardnum": 74, + "cardset": "A2b", + "name": "Buizel", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 750, + "fields": { + "cardnum": 75, + "cardset": "A2b", + "name": "Tatsugiri", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 751, + "fields": { + "cardnum": 76, + "cardset": "A2b", + "name": "Grafaiai", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 752, + "fields": { + "cardnum": 77, + "cardset": "A2b", + "name": "Gholdengo", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 753, + "fields": { + "cardnum": 78, + "cardset": "A2b", + "name": "Wigglytuff", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 754, + "fields": { + "cardnum": 79, + "cardset": "A2b", + "name": "Beedrill EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 755, + "fields": { + "cardnum": 80, + "cardset": "A2b", + "name": "Charizard EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 756, + "fields": { + "cardnum": 81, + "cardset": "A2b", + "name": "Wugtrio EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 757, + "fields": { + "cardnum": 82, + "cardset": "A2b", + "name": "Pikachu EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 758, + "fields": { + "cardnum": 83, + "cardset": "A2b", + "name": "Giratina EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 759, + "fields": { + "cardnum": 84, + "cardset": "A2b", + "name": "Lucario EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 760, + "fields": { + "cardnum": 85, + "cardset": "A2b", + "name": "Paldean Clodsire EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 761, + "fields": { + "cardnum": 86, + "cardset": "A2b", + "name": "Tinkaton EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 762, + "fields": { + "cardnum": 87, + "cardset": "A2b", + "name": "Bibarel EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 763, + "fields": { + "cardnum": 88, + "cardset": "A2b", + "name": "Iono", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 764, + "fields": { + "cardnum": 89, + "cardset": "A2b", + "name": "Pokémon Center Lady", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 765, + "fields": { + "cardnum": 90, + "cardset": "A2b", + "name": "Red", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 766, + "fields": { + "cardnum": 91, + "cardset": "A2b", + "name": "Team Rocket Grunt", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 767, + "fields": { + "cardnum": 92, + "cardset": "A2b", + "name": "Pikachu EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 768, + "fields": { + "cardnum": 93, + "cardset": "A2b", + "name": "Paldean Clodsire EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 769, + "fields": { + "cardnum": 94, + "cardset": "A2b", + "name": "Tinkaton EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 770, + "fields": { + "cardnum": 95, + "cardset": "A2b", + "name": "Bibarel EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 771, + "fields": { + "cardnum": 96, + "cardset": "A2b", + "name": "Giratina EX", + "decks": [ + 9 + ], + "rarity_level": 7, + "rarity_icon": "⭐️⭐️⭐️", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 772, + "fields": { + "cardnum": 97, + "cardset": "A2b", + "name": "Weedle", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 773, + "fields": { + "cardnum": 98, + "cardset": "A2b", + "name": "Kakuna", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 774, + "fields": { + "cardnum": 99, + "cardset": "A2b", + "name": "Charmander", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 775, + "fields": { + "cardnum": 100, + "cardset": "A2b", + "name": "Charmeleon", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 776, + "fields": { + "cardnum": 101, + "cardset": "A2b", + "name": "Wiglett", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 777, + "fields": { + "cardnum": 102, + "cardset": "A2b", + "name": "Dondozo", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 778, + "fields": { + "cardnum": 103, + "cardset": "A2b", + "name": "Pachirisu", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 779, + "fields": { + "cardnum": 104, + "cardset": "A2b", + "name": "Riolu", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 780, + "fields": { + "cardnum": 105, + "cardset": "A2b", + "name": "Varoom", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 781, + "fields": { + "cardnum": 106, + "cardset": "A2b", + "name": "Revavroom", + "decks": [ + 9 + ], + "rarity_level": 5, + "rarity_icon": "✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 782, + "fields": { + "cardnum": 107, + "cardset": "A2b", + "name": "Beedrill EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "✨✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 783, + "fields": { + "cardnum": 108, + "cardset": "A2b", + "name": "Charizard EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "✨✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 784, + "fields": { + "cardnum": 109, + "cardset": "A2b", + "name": "Wugtrio EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "✨✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 785, + "fields": { + "cardnum": 110, + "cardset": "A2b", + "name": "Lucario EX", + "decks": [ + 9 + ], + "rarity_level": 6, + "rarity_icon": "✨✨", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + }, + { + "model": "cards.card", + "pk": 786, + "fields": { + "cardnum": 111, + "cardset": "A2b", + "name": "Pokémon Ball", + "decks": [ + 9 + ], + "rarity_level": 8, + "rarity_icon": "👑", + "created_at": "2025-03-26T12:25:17.706Z", + "updated_at": "2025-03-26T12:25:17.706Z" + } + } +] \ No newline at end of file diff --git a/server/docker-compose_core.yml b/server/docker-compose_core.yml index 83420c6..c49b1d2 100644 --- a/server/docker-compose_core.yml +++ b/server/docker-compose_core.yml @@ -53,8 +53,6 @@ services: done env_file: - .env - labels: - - "deployment.core=true" loba: image: haproxy:3.1 stop_signal: SIGTERM @@ -66,14 +64,11 @@ services: volumes: - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg - ./certs:/certs - labels: - - "deployment.core=true" feedback: restart: always image: getfider/fider:stable labels: - "enable_gatekeeper=true" - - "deployment.core=true" env_file: - .env # cadvisor: @@ -96,8 +91,6 @@ services: timeout: 5s retries: 5 start_period: 10s - labels: - - "deployment.core=true" dockergen-health: image: nginxproxy/docker-gen:latest command: -wait 15s -watch /gatus/config.template.yaml /gatus/config.yaml @@ -105,8 +98,6 @@ services: volumes: - /var/run/docker.sock:/tmp/docker.sock:ro - ./gatus:/gatus - labels: - - "deployment.core=true" dockergen-gatekeeper: image: nginxproxy/docker-gen:latest command: -wait 15s -watch /gatekeeper/gatekeepers.template.yml /gatekeeper/gatekeepers.yml -notify-sighup pkmntrade-club-gatekeeper-manager-1 @@ -114,8 +105,6 @@ services: volumes: - /var/run/docker.sock:/tmp/docker.sock:ro - ./:/gatekeeper - labels: - - "deployment.core=true" gatekeeper-manager: image: docker:latest restart: always @@ -126,8 +115,6 @@ services: environment: - REFRESH_INTERVAL=60 entrypoint: ["/bin/sh", "-c"] - labels: - - "deployment.core=true" command: - | set -eu -o pipefail @@ -252,7 +239,7 @@ services: echo "$(date +'%Y-%m-%d %H:%M:%S') [INFO]: Periodic healthcheck and refresh triggered." if [ ! -f "$$COMPOSE_FILE_PATH" ]; then - echo "$(date +'%Y-%m-%d %H:%M:%S') [ERROR]: gatekeepers.yml has not been generated after $$REFRESH_INTERVAL seconds. Please check dockergen-gatekeeper is running correctly. Exiting." + echo "$(date +'%Y-%m-%d %H:%M:%S') [ERROR]: Gatekeepers.yml has not been generated after $$REFRESH_INTERVAL seconds. Please check dockergen-gatekeeper is running correctly. Exiting." exit 1 fi @@ -267,21 +254,9 @@ services: restart: always labels: - "enable_gatekeeper=true" - - "deployment.core=true" - # healthcheck: - # test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - # interval: 10s - # timeout: 5s - # retries: 5 - # start_period: 10s env_file: - .env environment: - GATUS_DELAY_START_SECONDS=30 volumes: - - ./gatus:/config - -networks: - default: - name: pkmntrade-club_network - external: true \ No newline at end of file + - ./gatus:/config \ No newline at end of file diff --git a/server/docker-compose_staging.yml b/server/docker-compose_staging.yml new file mode 100644 index 0000000..90020bc --- /dev/null +++ b/server/docker-compose_staging.yml @@ -0,0 +1,32 @@ +x-common: &common + image: badbl0cks/pkmntrade-club:staging + restart: always + env_file: + - .env +services: + web-staging: + <<: *common + environment: + - DEBUG=False + - DISABLE_SIGNUPS=True + - PUBLIC_HOST=staging.pkmntrade.club + - ALLOWED_HOSTS=staging.pkmntrade.club,127.0.0.1 + labels: + - "enable_gatekeeper=true" + deploy: + mode: replicated + replicas: 2 + # healthcheck: + # test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/health"] + # interval: 30s + # timeout: 10s + # retries: 3 + # start_period: 30s + celery-staging: + <<: *common + environment: + - DEBUG=False + - DISABLE_SIGNUPS=True + - PUBLIC_HOST=staging.pkmntrade.club + - ALLOWED_HOSTS=staging.pkmntrade.club,127.0.0.1 + command: ["celery", "-A", "pkmntrade_club.django_project", "worker", "-l", "INFO", "-B", "-E"] \ No newline at end of file diff --git a/server/docker-compose_web.yml b/server/docker-compose_web.yml index 15245e2..6d453e0 100644 --- a/server/docker-compose_web.yml +++ b/server/docker-compose_web.yml @@ -1,7 +1,4 @@ x-common: &common - image: badbl0cks/pkmntrade-club:${IMAGE_TAG:-stable} - #image: ghcr.io/xe/x/httpdebug - #entrypoint: ["/ko-app/httpdebug", "--bind", ":8000"] restart: always env_file: - .env @@ -9,42 +6,31 @@ x-common: &common services: web: <<: *common - environment: - - DEBUG=False - - DISABLE_SIGNUPS=True - - PUBLIC_HOST=${DOMAIN_NAME} - - ALLOWED_HOSTS=${DOMAIN_NAME},127.0.0.1 - - DEPLOYMENT_COLOR=${DEPLOYMENT_COLOR:-blue} - labels: - - "enable_gatekeeper=true" - - "deployment.color=${DEPLOYMENT_COLOR:-blue}" - - "deployment.image_tag=${IMAGE_TAG:-stable}" - deploy: - mode: replicated - replicas: ${REPLICA_COUNT} - healthcheck: - test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/health/"] - interval: 5s - timeout: 3s - retries: 2 - start_period: 60s - stop_grace_period: 200s # 20s buffer + 180s workers-kill-timeout - - celery: - <<: *common + image: ghcr.io/xe/x/httpdebug + entrypoint: ["/ko-app/httpdebug", "--bind", ":8000"] + #image: badbl0cks/pkmntrade-club:stable environment: - DEBUG=False - DISABLE_SIGNUPS=True - PUBLIC_HOST=pkmntrade.club - ALLOWED_HOSTS=pkmntrade.club,127.0.0.1 - - DEPLOYMENT_COLOR=${DEPLOYMENT_COLOR:-blue} labels: - - "deployment.color=${DEPLOYMENT_COLOR:-blue}" - - "deployment.image_tag=${IMAGE_TAG:-stable}" - command: ["celery", "-A", "pkmntrade_club.django_project", "worker", "-l", "INFO", "-B", "-E"] - stop_grace_period: 200s # match our longest stop_grace_period (currently web service is 200s) - -networks: - default: - name: pkmntrade-club_network - external: true \ No newline at end of file + - "enable_gatekeeper=true" + deploy: + mode: replicated + replicas: 4 + # healthcheck: + # test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/health"] + # interval: 30s + # timeout: 10s + # retries: 3 + # start_period: 30s + # celery: + # <<: *common + # image: badbl0cks/pkmntrade-club:stable + # environment: + # - DEBUG=False + # - DISABLE_SIGNUPS=True + # - PUBLIC_HOST=pkmntrade.club + # - ALLOWED_HOSTS=pkmntrade.club,127.0.0.1 + # command: ["celery", "-A", "pkmntrade_club.django_project", "worker", "-l", "INFO", "-B", "-E"] \ No newline at end of file diff --git a/server/gatekeepers.template.yml b/server/gatekeepers.template.yml index f4d3b8f..701fbf1 100644 --- a/server/gatekeepers.template.yml +++ b/server/gatekeepers.template.yml @@ -1,52 +1,23 @@ services: {{ $all_containers := whereLabelValueMatches . "enable_gatekeeper" "true" }} - - # During deployment, both blue and green containers might exist - # So we generate gatekeepers for ALL containers with deployment.color label - {{ $color_containers := whereLabelExists $all_containers "deployment.color" }} - {{ $color_containers = sortObjectsByKeysAsc $color_containers "Name" }} + {{ $all_containers = sortObjectsByKeysAsc $all_containers "Name" }} - {{ range $container := $color_containers }} + {{ range $container := $all_containers }} {{ $serviceLabel := index $container.Labels "com.docker.compose.service" }} {{ $containerNumber := index $container.Labels "com.docker.compose.container-number" }} - {{ $deploymentColor := index $container.Labels "deployment.color" }} {{ $port := "" }} {{ if eq $serviceLabel "web" }} {{ $port = ":8000" }} {{ end }} - gatekeeper-{{ $serviceLabel }}-{{ $deploymentColor }}-{{ $containerNumber }}: - image: ghcr.io/techarohq/anubis:latest - container_name: pkmntrade-club-gatekeeper-{{ $serviceLabel }}-{{ $deploymentColor }}-{{ $containerNumber }} - env_file: - - .env - environment: - - TARGET=http://{{ $container.Name }}{{ $port }} - - DEPLOYMENT_COLOR={{ $deploymentColor }} - - TARGET_HOST=${DOMAIN_NAME} - labels: - - gatekeeper=true - - deployment.color={{ $deploymentColor }} - networks: - default: - aliases: - - pkmntrade-club-gatekeeper-{{ $serviceLabel }} - - gatekeeper-{{ $serviceLabel }} + {{ if eq $serviceLabel "web-staging" }} + {{ $port = ":8000" }} {{ end }} - - # Always include non-color-specific services - {{ $static_containers := whereLabelValueMatches . "enable_gatekeeper" "true" }} - {{ $static_containers = whereLabelDoesNotExist $static_containers "deployment.color" }} - {{ range $container := $static_containers }} - {{ $serviceLabel := index $container.Labels "com.docker.compose.service" }} - {{ $containerNumber := index $container.Labels "com.docker.compose.container-number" }} - {{ $port := "" }} {{ if eq $serviceLabel "feedback" }} {{ $port = ":3000" }} {{ end }} {{ if eq $serviceLabel "health" }} {{ $port = ":8080" }} {{ end }} - {{ if or (eq $serviceLabel "feedback") (eq $serviceLabel "health") }} gatekeeper-{{ $serviceLabel }}-{{ $containerNumber }}: image: ghcr.io/techarohq/anubis:latest container_name: pkmntrade-club-gatekeeper-{{ $serviceLabel }}-{{ $containerNumber }} @@ -54,6 +25,12 @@ services: - .env environment: - TARGET=http://{{ $container.Name }}{{ $port }} + {{ if eq $serviceLabel "web" }} + - TARGET_HOST=pkmntrade.club # pass this host to django, which checks it with ALLOWED_HOSTS + {{ end }} + {{ if eq $serviceLabel "web-staging" }} + - TARGET_HOST=staging.pkmntrade.club # pass this host to django, which checks it with ALLOWED_HOSTS + {{ end }} labels: - gatekeeper=true networks: @@ -62,9 +39,7 @@ services: - pkmntrade-club-gatekeeper-{{ $serviceLabel }} - gatekeeper-{{ $serviceLabel }} {{ end }} - {{ end }} - networks: default: - name: pkmntrade-club_network + name: pkmntrade-club_default external: true diff --git a/server/gatus/config.template.yaml b/server/gatus/config.template.yaml index 8f01ea1..33351f4 100644 --- a/server/gatus/config.template.yaml +++ b/server/gatus/config.template.yaml @@ -92,15 +92,20 @@ endpoints: - type: email {{ $all_containers := . }} {{ $web_containers := list }} + {{ $web_staging_containers := list }} {{ range $container := $all_containers }} {{ $serviceLabel := index $container.Labels "com.docker.compose.service" }} {{ if eq $serviceLabel "web" }} {{ $web_containers = append $web_containers $container }} {{ end }} + {{ if eq $serviceLabel "web-staging" }} + {{ $web_staging_containers = append $web_staging_containers $container }} + {{ end }} {{ end }} {{ $web_containers = sortObjectsByKeysAsc $web_containers "Name" }} + {{ $web_staging_containers = sortObjectsByKeysAsc $web_staging_containers "Name" }} {{ range $container := $web_containers }} {{ $containerNumber := index $container.Labels "com.docker.compose.container-number" }} @@ -108,7 +113,7 @@ endpoints: group: Main url: "http://{{ $container.Name }}:8000/health/" headers: - Host: "${DOMAIN_NAME}" + Host: "pkmntrade.club" interval: 60s conditions: - "[STATUS] == 200" @@ -117,6 +122,21 @@ endpoints: - type: email {{ end }} + {{ range $container := $web_staging_containers }} + {{ $containerNumber := index $container.Labels "com.docker.compose.container-number" }} + - name: "Web Worker {{ $containerNumber }}" + group: Staging + url: "http://{{ $container.Name }}:8000/health/" + headers: + Host: "staging.pkmntrade.club" + interval: 60s + conditions: + - "[STATUS] == 200" + # - "[BODY] == OK/HEALTHY" + alerts: + - type: email + {{ end }} + alerting: email: from: "${GATUS_SMTP_FROM}" diff --git a/server/haproxy.cfg b/server/haproxy.cfg index 673c22a..14db5f6 100644 --- a/server/haproxy.cfg +++ b/server/haproxy.cfg @@ -25,27 +25,32 @@ frontend haproxy_entrypoint bind :443 ssl crt /certs/crt.pem verify required ca-file /certs/ca.pem use_backend %[req.hdr(host),lower,word(1,:)] # strip out port from host -frontend healthchecks +frontend checks bind :80 - default_backend basic_loba_check + default_backend basic_check -backend basic_loba_check +backend basic_check http-request return status 200 content-type "text/plain" lf-string "OK/HEALTHY" -backend "${DOMAIN_NAME}" +backend pkmntrade.club balance leastconn - http-request set-header Host "${DOMAIN_NAME}" - server-template gatekeeper-web- "${REPLICA_COUNT}" gatekeeper-web:8000 check resolvers docker_resolver init-addr none + http-request set-header Host pkmntrade.club + server-template gatekeeper-web- 4 gatekeeper-web:8000 check resolvers docker_resolver init-addr libc,none -backend "feedback.${BASE_DOMAIN_NAME}" +backend staging.pkmntrade.club balance leastconn - http-request set-header Host feedback."${BASE_DOMAIN_NAME}" - server-template gatekeeper-feedback- 1 gatekeeper-feedback:8000 check resolvers docker_resolver init-addr none + http-request set-header Host staging.pkmntrade.club + server-template gatekeeper-web-staging- 4 gatekeeper-web-staging:8000 check resolvers docker_resolver init-addr libc,none -backend "health.${BASE_DOMAIN_NAME}" +backend feedback.pkmntrade.club balance leastconn - http-request set-header Host health."${BASE_DOMAIN_NAME}" - server-template gatekeeper-health- 1 gatekeeper-health:8000 check resolvers docker_resolver init-addr none + http-request set-header Host feedback.pkmntrade.club + server-template gatekeeper-feedback- 4 gatekeeper-feedback:8000 check resolvers docker_resolver init-addr libc,none + +backend health.pkmntrade.club + balance leastconn + http-request set-header Host health.pkmntrade.club + server-template gatekeeper-health- 4 gatekeeper-health:8000 check resolvers docker_resolver init-addr libc,none #EOF - trailing newline required diff --git a/server/scripts/common-lib.sh b/server/scripts/common-lib.sh deleted file mode 100755 index 70fa5a4..0000000 --- a/server/scripts/common-lib.sh +++ /dev/null @@ -1,382 +0,0 @@ -#!/bin/bash -# Common library for deployment scripts -# Source this file in other scripts: source "${SCRIPT_DIR}/common-lib.sh" - -# Common constants -readonly BLUE_COLOR="blue" -readonly GREEN_COLOR="green" -readonly CORE_PROJECT_NAME="pkmntrade-club" -readonly DEPLOYMENT_LABEL="deployment.color" -readonly RETRY_MAX_ATTEMPTS="${RETRY_MAX_ATTEMPTS:-5}" -readonly RETRY_DELAY="${RETRY_DELAY:-5}" - -# Dry run helper function -# Usage: execute_if_not_dry "description" command [args...] -execute_if_not_dry() { - local description="$1" - shift - - if [ "$DRY_RUN" = true ]; then - indent_output echo "[DRY RUN] Would execute: $description" - indent_output echo " Command: $*" - else - "$@" - fi -} - -# Execute with error handling -# Usage: execute_or_fail "description" command [args...] -execute_or_fail() { - local description="$1" - shift - - if [ "$DRY_RUN" = true ]; then - indent_output echo "[DRY RUN] Would execute: $description" - indent_output echo " Command: $*" - else - if ! "$@"; then - echo "❌ Error: Failed to $description" - exit 1 - fi - fi -} - -# Execute with warning on failure (non-critical operations) -# Usage: execute_or_warn "description" command [args...] -execute_or_warn() { - local description="$1" - shift - - if [ "$DRY_RUN" = true ]; then - indent_output echo "[DRY RUN] Would execute: $description" - indent_output echo " Command: $*" - else - if ! "$@"; then - echo "⚠️ Warning: Failed to $description (continuing anyway)" - fi - fi -} - -# Retry a command with exponential backoff -retry() { - local max_attempts=$RETRY_MAX_ATTEMPTS - local delay=$RETRY_DELAY - local attempt=1 - local exit_code=0 - - until "$@"; do - exit_code=$? - - if [ "$attempt" -ge "$max_attempts" ]; then - echo "❌ Command failed after $max_attempts attempts: $*" >&2 - return $exit_code - fi - - echo "⚠️ Attempt $attempt failed, retrying in ${delay}s..." >&2 - sleep "$delay" - - # Exponential backoff - delay=$((delay * 2)) - attempt=$((attempt + 1)) - done - - if [ $attempt -gt 1 ]; then - echo "✅ Command succeeded after $attempt attempts" - fi - - return 0 -} - -run_on_target() { - # if DEPLOY_HOST is set, we are not on remote - if [[ -n "${DEPLOY_HOST}" ]]; then - ssh deploy "$*" - else - bash -c -- "$*" - fi -} - -# Function to check if a variable is set -require_var() { - local var_name=$1 - local var_value=${!var_name} - - if [ -z "$var_value" ]; then - echo "Error: ${var_name} not set" >&2 - exit 1 - fi -} - -# Function to get deployment color based on running containers -get_current_color() { - local blue_count=$(docker ps --filter "label=${DEPLOYMENT_LABEL}=${BLUE_COLOR}" -q 2>/dev/null | wc -l) - local green_count=$(docker ps --filter "label=${DEPLOYMENT_LABEL}=${GREEN_COLOR}" -q 2>/dev/null | wc -l) - - if [ "$blue_count" -gt 0 ] && [ "$green_count" -eq 0 ]; then - echo "$BLUE_COLOR" - elif [ "$green_count" -gt 0 ] && [ "$blue_count" -eq 0 ]; then - echo "$GREEN_COLOR" - elif [ "$blue_count" -gt 0 ] && [ "$green_count" -gt 0 ]; then - # Both colors running - return the newer one - local blue_newest=$(docker inspect --format='{{.Created}}' "$(docker ps -q --filter "label=${DEPLOYMENT_LABEL}=${BLUE_COLOR}" | head -1)" 2>/dev/null || echo '1970-01-01') - local green_newest=$(docker inspect --format='{{.Created}}' "$(docker ps -q --filter "label=${DEPLOYMENT_LABEL}=${GREEN_COLOR}" | head -1)" 2>/dev/null || echo '1970-01-01') - - if [[ "$blue_newest" > "$green_newest" ]]; then - echo "$BLUE_COLOR" - else - echo "$GREEN_COLOR" - fi - else - echo "none" - fi -} - -# Function to get deployment state (none, blue, green, both) -get_deployment_state() { - local blue_count=$(docker ps --filter "label=${DEPLOYMENT_LABEL}=${BLUE_COLOR}" -q 2>/dev/null | wc -l) - local green_count=$(docker ps --filter "label=${DEPLOYMENT_LABEL}=${GREEN_COLOR}" -q 2>/dev/null | wc -l) - - if [ "$blue_count" -gt 0 ] && [ "$green_count" -gt 0 ]; then - echo "both" - elif [ "$blue_count" -gt 0 ]; then - echo "$BLUE_COLOR" - elif [ "$green_count" -gt 0 ]; then - echo "$GREEN_COLOR" - else - echo "none" - fi -} - -# Function to check if deployment is in progress -is_deployment_in_progress() { - local blue_count=$(docker ps --filter "label=${DEPLOYMENT_LABEL}=${BLUE_COLOR}" -q 2>/dev/null | wc -l) - local green_count=$(docker ps --filter "label=${DEPLOYMENT_LABEL}=${GREEN_COLOR}" -q 2>/dev/null | wc -l) - - if [ "$blue_count" -gt 0 ] && [ "$green_count" -gt 0 ]; then - return 0 # true - deployment in progress - else - return 1 # false - no deployment in progress - fi -} - -# Function to switch color -switch_color() { - local current=$1 - if [ "$current" = "$BLUE_COLOR" ]; then - echo "$GREEN_COLOR" - else - echo "$BLUE_COLOR" - fi -} - -# Function to get project name for a color -get_project_name() { - local color=$1 - echo "${CORE_PROJECT_NAME}-${color}" -} - -# Function to get compose files based on PROD setting -get_compose_files() { - # Always use the same docker-compose file for both staging and production - echo "-f docker-compose_web.yml" -} - -# Function to refresh gatekeepers -refresh_gatekeepers() { - echo "🔄 Refreshing gatekeepers..." - docker kill -s SIGHUP ${CORE_PROJECT_NAME}-gatekeeper-manager-1 2>/dev/null || true -} - -# Function to count containers by filter -count_containers() { - local filters=$1 - docker ps ${filters} -q 2>/dev/null | wc -l | tr -d '\n' || echo 0 -} - -get_previous_release_path() { - local current_link_path=$1 - local previous_release_path=$(run_on_target "cat '${current_link_path}/.previous_version'") - echo "${previous_release_path}" -} - -# Function to stop and remove the previous release's containers for a color -cleanup_color_containers() { - local color=$1 - local project_name=$(get_project_name "$color") - # Use CLEANUP_RELEASE_PATH if set, otherwise default to the previous release. - # This is crucial for rollbacks to use the correct compose file for cleanup. - local release_path=${CLEANUP_RELEASE_PATH:-$(get_previous_release_path "${CURRENT_LINK_PATH}")} - - echo "🛑 Stopping $color containers from release: ${release_path}" - run_on_target "cd '${release_path}' && docker compose -p '${project_name}' stop --timeout 30 2>/dev/null || true" - - echo "🗑️ Removing $color containers from release: ${release_path}" - run_on_target "cd '${release_path}' && docker compose -p '${project_name}' down --remove-orphans 2>/dev/null || true" -} - -# Function to wait with countdown -wait_with_countdown() { - local seconds=$1 - local message=$2 - - echo -n "$message" - for ((i=seconds; i>0; i--)); do - echo -n " $i" - sleep 1 - done - echo " done!" -} - -get_web_service_name() { - echo "web" # hardcoded for now -} -# Standard environment validation -validate_deployment_env() { - require_var "REPO_PROJECT_PATH" - require_var "PROD" - require_var "REPLICA_COUNT" - if [ "$PROD" = "true" ]; then - require_var "PRODUCTION_DOMAIN" - else - require_var "STAGING_DOMAIN" - fi - - # Set derived variables - export CURRENT_LINK_PATH="${REPO_PROJECT_PATH}/current" - export RELEASES_PATH="${REPO_PROJECT_PATH}/releases" - export REPLICA_COUNT="${REPLICA_COUNT}" -} - -get_health_check_status() { - # TODO: instead get the health check status from gatus container's api - local statuses=$(docker ps --format '{{.Names}} {{.Status}}') - local unhealthy_count=0 - local IFS=$'\n' - for status in $statuses; do - local name=$(echo $status | cut -d' ' -f1) - local status=$(echo $status | cut -d' ' -f2-) - if [[ "$status" == *"unhealthy"* ]]; then - unhealthy_count=$((unhealthy_count + 1)) - echo "❌ Unhealthy: $name [$status]" - else - echo "✅ Healthy: $name [$status]" - fi - done - return $unhealthy_count -} - -# Function to wait for containers to be healthy -wait_for_healthy_containers() { - local project_name=$1 - local service_name=$2 - local expected_count=$3 - local max_attempts=60 # 5 minutes with 5-second intervals - local attempt=0 - - echo "⏳ Waiting for $service_name containers to be healthy..." - - while [ $attempt -lt $max_attempts ]; do - healthy_count=$(count_containers "--filter label=com.docker.compose.project=${project_name} --filter label=com.docker.compose.service=${service_name} --filter health=healthy") - - if [[ "$healthy_count" -eq "$expected_count" ]]; then - echo "✅ All $service_name containers are healthy ($healthy_count/$expected_count)" - return 0 - fi - - echo "⏳ Healthy containers: $healthy_count/$expected_count (attempt $((attempt+1))/$max_attempts)" - sleep 5 - attempt=$((attempt + 1)) - done - - echo "❌ Timeout waiting for $service_name containers to be healthy" - return 1 -} - -list_releases() { - local REPO_PROJECT_PATH=$1 - local RELEASES_PATH="${REPO_PROJECT_PATH}/releases" - local CURRENT_LINK_PATH="${REPO_PROJECT_PATH}/current" - - echo "📋 Available releases:" - if [ -d "$RELEASES_PATH" ]; then - for release in $(ls -dt ${RELEASES_PATH}/*); do - version=$(basename "$release") - status="" - - # Check if it's current - if [ -L "$CURRENT_LINK_PATH" ] && [ "$(readlink -f "$CURRENT_LINK_PATH")" = "$(realpath "$release")" ]; then - status=" [CURRENT]" - fi - - # Check if it failed - if [ -f "${release}/.failed" ]; then - status="${status} [FAILED]" - fi - - indent_output echo "- ${version}${status}" - done - else - indent_output echo "No releases found" - fi -} - -# Function to get image tag from deployment -get_deployment_image_tag() { - local color=$1 - local container=$(docker ps --filter "label=com.docker.compose.project=${CORE_PROJECT_NAME}-${color}" --format '{{.Names}}'| head -1) - if [ -n "$container" ]; then - docker inspect "${container}" --format '{{index .Config.Labels "deployment.image_tag"}}' - else - echo "unknown" - fi -} - -# Function to run a command and prefix its output -# Usage: prefix_output "PREFIX" command [args...] -# Example: prefix_output " | " docker ps -# Example: prefix_output " => " docker compose ps -prefix_output() { - local prefix=" " - - if [ $# -lt 2 ]; then - echo "Error: prefix_output requires at least 2 arguments" >&2 - return 1 - fi - - prefix="$1" - shift - - # Run the command and prefix each line of output - "$@" 2>&1 | sed "s/^/${prefix}/" - - # Return the exit code of the original command (not sed) - return ${PIPESTATUS[0]} -} - -# Function to run a command and indent its output -# Usage: indent_output [INDENT_STRING] command [args...] -# Example: indent_output docker ps # Uses default 2 spaces -# Example: indent_output " " docker ps # Uses 4 spaces -indent_output() { - local indent=" " # Default to 2 spaces - - # Check if first argument looks like an indent string (starts with spaces or tabs) - if [[ "$1" =~ ^[[:space:]]+$ ]]; then - indent="$1" - shift - fi - - # Use prefix_output with the indent string - prefix_output "$indent" "$@" -} - -# Function to run command with header and indented output -# Usage: run_with_header "HEADER" command [args...] -# Example: run_with_header "Docker Containers:" docker ps -run_with_header() { - local header="$1" - shift - - echo "$header" - indent_output " " "$@" -} \ No newline at end of file diff --git a/server/scripts/manage.sh b/server/scripts/manage.sh deleted file mode 100755 index d0c563b..0000000 --- a/server/scripts/manage.sh +++ /dev/null @@ -1,377 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Manage deployment releases -# Usage: ./manage.sh [--dry-run] COMMAND [ARGS] - -# Source common functions -SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" -source "${SCRIPT_DIR}/common-lib.sh" - -# Global variables -DRY_RUN=false -COMMAND="" -ARGS=() - -# Parse global options -while [[ $# -gt 0 ]]; do - case "$1" in - --dry-run) - DRY_RUN=true - shift - ;; - -*) - echo "Error: Unknown option: $1" - echo "Usage: $0 [--dry-run] COMMAND [ARGS]" - exit 1 - ;; - *) - # First non-option argument is the command - if [ -z "$COMMAND" ]; then - COMMAND="$1" - else - # Rest are command arguments - ARGS+=("$1") - fi - shift - ;; - esac -done - -if [ -z "$COMMAND" ]; then - echo "Error: No command specified" - echo "Usage: $0 [--dry-run] COMMAND [ARGS]" - echo "Commands:" - indent_output echo "status - Show deployment status" - indent_output echo "list - List all releases" - indent_output echo "version - Show current release" - indent_output echo "switch VERSION - Switch to a specific release version" - indent_output echo "cleanup [KEEP] - Clean up old releases (default: keep 5)" - echo "" - echo "Global options:" - indent_output echo "--dry-run - Show what would happen without making changes" - exit 1 -fi - -REPO_PROJECT_PATH="$(realpath "${SCRIPT_DIR}/../../../")" -CURRENT_LINK_PATH="${REPO_PROJECT_PATH}/current" -RELEASES_PATH="${REPO_PROJECT_PATH}/releases" - -# Announce dry-run mode if active -if [ "$DRY_RUN" = true ]; then - echo "🔍 DRY RUN MODE - No changes will be made" - echo "" -fi - -case "$COMMAND" in - status) - echo "🔍 Deployment Status" - echo "====================" - - # Check if deployment is downgraded - if [ -d "$RELEASES_PATH" ] && [ -L "$CURRENT_LINK_PATH" ]; then - CURRENT_RELEASE_DIR_NAME=$(basename "$(readlink -f "$CURRENT_LINK_PATH")") - - # Find the latest release by modification time. - LATEST_RELEASE_DIR_NAME=$(find "$RELEASES_PATH" -maxdepth 1 -mindepth 1 -type d -printf '%T@ %f\n' | sort -nr | head -n 1 | cut -d' ' -f2-) - - if [ -n "$LATEST_RELEASE_DIR_NAME" ]; then - if [ "$CURRENT_RELEASE_DIR_NAME" == "$LATEST_RELEASE_DIR_NAME" ]; then - echo "✅ Deployment is on the latest release (${LATEST_RELEASE_DIR_NAME})." - else - echo "⚠️ Deployment is downgraded." - indent_output echo "Current: ${CURRENT_RELEASE_DIR_NAME}" - indent_output echo "Latest: ${LATEST_RELEASE_DIR_NAME}" - fi - else - # This case happens if RELEASES_PATH is empty - echo "ℹ️ No releases found in ${RELEASES_PATH}." - fi - elif [ ! -L "$CURRENT_LINK_PATH" ]; then - echo "ℹ️ No current deployment symlink found." - else # RELEASES_PATH does not exist - echo "ℹ️ Releases directory not found at ${RELEASES_PATH}." - fi - echo "" # Add a newline for spacing - - # Get current state - CURRENT_STATE=$(get_deployment_state) - - if [ "$CURRENT_STATE" = "both" ]; then - echo "🟡 Deployment State: both" - elif [ "$CURRENT_STATE" = "blue" ]; then - echo "🔵 Deployment State: blue" - elif [ "$CURRENT_STATE" = "green" ]; then - echo "🟢 Deployment State: green" - else - indent_output echo "Deployment State: none" - fi - - echo "⚙️ Core Containers:" - indent_output docker ps --filter 'label=deployment.core=true' --format 'table {{.Names}}\t{{.Status}}\t{{.CreatedAt}}' - - # Show containers by color with image info - echo "🔵 Blue Containers:" - BLUE_COUNT=$(count_containers "--filter label=deployment.color=blue") - # make sure BLUE_COUNT is a number - BLUE_COUNT=$(echo "$BLUE_COUNT" | tr -d '\n') - if [ "$BLUE_COUNT" -gt 0 ]; then - BLUE_IMAGE=$(get_deployment_image_tag "blue") - indent_output echo "Image: ${BLUE_IMAGE}" - indent_output docker ps --filter 'label=deployment.color=blue' --format 'table {{.Names}}\t{{.Status}}\t{{.CreatedAt}}' - else - indent_output echo "No blue containers running" - fi - - echo "🟢 Green Containers:" - GREEN_COUNT=$(count_containers "--filter label=deployment.color=green") - if [ "$GREEN_COUNT" -gt 0 ]; then - GREEN_IMAGE=$(get_deployment_image_tag "green") - indent_output echo "Image: ${GREEN_IMAGE}" - indent_output docker ps --filter 'label=deployment.color=green' --format 'table {{.Names}}\t{{.Status}}\t{{.CreatedAt}}' - else - indent_output echo "No green containers running" - fi - - list_releases "${REPO_PROJECT_PATH}" - - # Health check summary - echo "❤️ Health Check Summary:" - case "$CURRENT_STATE" in - "both") - indent_output echo "⚠️ WARNING: Both blue and green containers are running!" - indent_output echo "This might indicate an incomplete deployment." - ;; - "none") - indent_output echo "⚠️ WARNING: No web containers are running!" - ;; - *) - if [ "$CURRENT_STATE" = "blue" ]; then - indent_output echo "🔵 System is running on blue deployment" - else - indent_output echo "🟢 System is running on green deployment" - fi - indent_output echo "❤️ Overall Healthcheck:" - indent_output " " get_health_check_status - ;; - esac - - # Show resource usage - echo "📊 Resource Usage:" - indent_output docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemPerc}}\t{{.MemUsage}}\t{{.NetIO}}' - - # Show deployment images - echo "📦 Deployment Images:" - indent_output docker images 'badbl0cks/pkmntrade-club' --format 'table {{.Tag}}\t{{.ID}}\t{{.CreatedAt}}\t{{.Size}}' | grep -E '^TAG|sha-.{7} ' || indent_output echo "No deployment images found" - ;; - - list) - list_releases "${REPO_PROJECT_PATH}" - ;; - - version) - if [ -L "$CURRENT_LINK_PATH" ]; then - current_version=$(basename "$(readlink -f "$CURRENT_LINK_PATH")") - echo "📌 Current version: ${current_version}" - else - echo "❌ No current deployment found" - fi - ;; - - switch) - if [ ${#ARGS[@]} -lt 1 ]; then - echo "Error: VERSION required for switch" - echo "Usage: $0 [--dry-run] switch VERSION" - exit 1 - fi - - TARGET_VERSION="${ARGS[0]}" - TARGET_PATH="${RELEASES_PATH}/${TARGET_VERSION}" - - # Validate target version exists - if [ ! -d "$TARGET_PATH" ]; then - echo "❌ Error: Version ${TARGET_VERSION} not found" - echo "Available releases:" - list_releases "${REPO_PROJECT_PATH}" - exit 1 - fi - - # Get current version if exists - CURRENT_VERSION="none" - CURRENT_VERSION_PATH="" - if [ -L "$CURRENT_LINK_PATH" ]; then - CURRENT_VERSION_PATH=$(readlink -f "$CURRENT_LINK_PATH") - CURRENT_VERSION=$(basename "$CURRENT_VERSION_PATH") - fi - - # Edge case: trying to switch to the same version - if [ "$CURRENT_VERSION" == "$TARGET_VERSION" ]; then - echo "✅ Already on version ${TARGET_VERSION}. No action taken." - exit 0 - fi - - CURRENT_COLOR=$(get_current_color) - NEW_COLOR=$(switch_color "$CURRENT_COLOR") - - echo "🔄 Switch Plan:" - indent_output echo "Current version: ${CURRENT_VERSION}" - if [ "$CURRENT_VERSION" != "none" ]; then - indent_output echo "Current path: ${CURRENT_VERSION_PATH}" - indent_output echo "Current color: ${CURRENT_COLOR}" - fi - indent_output echo "Target version: ${TARGET_VERSION}" - indent_output echo "Target path: ${TARGET_PATH}" - indent_output echo "Target color: ${NEW_COLOR}" - - # Verify target release has necessary files - echo "📋 Checking target release integrity..." - MISSING_FILES=() - for file in "docker-compose_web.yml" "docker-compose_core.yml" ".env"; do - if [ ! -f "${TARGET_PATH}/${file}" ]; then - MISSING_FILES+=("$file") - fi - done - - if [ ${#MISSING_FILES[@]} -gt 0 ]; then - echo "❌ Error: Target release is missing required files:" - printf " - %s\n" "${MISSING_FILES[@]}" - exit 1 - fi - - # Get compose files based on environment - COMPOSE_FILES=$(get_compose_files) - - echo "🛑 Stopping current containers..." - if [ -d "$CURRENT_VERSION_PATH" ]; then - ( - cd "$CURRENT_VERSION_PATH" || exit 1 - WEB_PROJECT_NAME=$(get_project_name "$CURRENT_COLOR") - - indent_output echo "Stopping web containers for project: ${WEB_PROJECT_NAME}..." - execute_or_warn "stop web containers" docker compose ${COMPOSE_FILES} -p "${WEB_PROJECT_NAME}" down - - indent_output echo "Stopping core services for project: ${CORE_PROJECT_NAME}..." - execute_or_warn "stop core services" docker compose -f "docker-compose_core.yml" -p "${CORE_PROJECT_NAME}" down - ) - else - indent_output echo "No current deployment to stop" - fi - - echo "📝 Updating deployment metadata for ${TARGET_VERSION}..." - execute_or_fail "update .deployment_color to ${NEW_COLOR}" \ - bash -c "echo '${NEW_COLOR}' > '${TARGET_PATH}/.deployment_color'" - - execute_or_fail "update DEPLOYMENT_COLOR in .env" \ - bash -c "sed -i 's/^DEPLOYMENT_COLOR=.*/DEPLOYMENT_COLOR=${NEW_COLOR}/' '${TARGET_PATH}/.env'" - - # Update symlink - echo "🔗 Updating deployment symlink..." - execute_or_fail "update symlink from $CURRENT_LINK_PATH to $TARGET_PATH" \ - ln -sfn "$TARGET_PATH" "$CURRENT_LINK_PATH" - - # Start containers - echo "🚀 Starting containers from ${TARGET_VERSION}..." - ( - cd "$TARGET_PATH" || exit 1 - - TARGET_WEB_PROJECT_NAME=$(get_project_name "$NEW_COLOR") - - indent_output echo "Starting core services for project: ${CORE_PROJECT_NAME}..." - execute_or_fail "start core services" \ - docker compose -f "docker-compose_core.yml" -p "${CORE_PROJECT_NAME}" up -d - - indent_output echo "Starting web containers for project: ${TARGET_WEB_PROJECT_NAME}..." - execute_or_fail "start web containers" \ - docker compose ${COMPOSE_FILES} -p "${TARGET_WEB_PROJECT_NAME}" up -d - ) - - if [ "$DRY_RUN" = true ]; then - echo "" - echo "✅ Dry run completed - no changes made" - else - echo "✅ Switch completed to version: ${TARGET_VERSION}" - echo "Run '$0 status' to verify deployment health" - fi - ;; - - cleanup) - # Parse cleanup arguments - KEEP_COUNT=5 - for arg in "${ARGS[@]}"; do - if [[ "$arg" =~ ^[0-9]+$ ]]; then - KEEP_COUNT="$arg" - else - echo "Error: Invalid argument for cleanup: $arg" - echo "Usage: $0 [--dry-run] cleanup [KEEP_COUNT]" - exit 1 - fi - done - - echo "🗑️ Cleaning up old releases (keeping last ${KEEP_COUNT} and current)" - - if [ ! -L "$CURRENT_LINK_PATH" ]; then - echo "❌ No current deployment symlink found. Aborting cleanup." - exit 1 - fi - - CURRENT_RELEASE_DIR_NAME=$(basename "$(readlink -f "$CURRENT_LINK_PATH")") - echo "📌 Current release: ${CURRENT_RELEASE_DIR_NAME}" - - if [ -d "$RELEASES_PATH" ]; then - cd "$RELEASES_PATH" - - # Get a list of inactive release directories, sorted by modification time (newest first). - INACTIVE_RELEASES=$(find . -maxdepth 1 -mindepth 1 -type d \ - -not -name "$CURRENT_RELEASE_DIR_NAME" \ - -printf '%T@ %f\n' | sort -nr | cut -d' ' -f2-) - - if [ -z "$INACTIVE_RELEASES" ]; then - echo "No inactive releases found to clean up." - exit 0 - fi - - # Count total inactive releases - TOTAL_INACTIVE=$(echo "$INACTIVE_RELEASES" | wc -l | xargs) - echo "📊 Found ${TOTAL_INACTIVE} inactive release(s)" - - # Identify releases to delete by skipping the KEEP_COUNT newest ones. - RELEASES_TO_DELETE=$(echo "$INACTIVE_RELEASES" | tail -n +$((KEEP_COUNT + 1))) - - if [ -n "$RELEASES_TO_DELETE" ]; then - DELETE_COUNT=$(echo "$RELEASES_TO_DELETE" | wc -l | xargs) - echo "🗑️ The following ${DELETE_COUNT} old release(s) will be deleted:" - - # Show releases with their sizes - while IFS= read -r release; do - if [ -d "$release" ]; then - SIZE=$(du -sh "$release" 2>/dev/null | cut -f1) - indent_output echo "- $release (Size: $SIZE)" - fi - done <<< "$RELEASES_TO_DELETE" - - # Delete the releases - echo "" - while IFS= read -r release; do - execute_if_not_dry "delete release $release" rm -rf "$release" - done <<< "$RELEASES_TO_DELETE" - - if [ "$DRY_RUN" = true ]; then - echo "" - echo "✅ Dry run completed - no releases were deleted" - else - echo "✅ Cleanup completed - deleted ${DELETE_COUNT} release(s)" - fi - else - KEPT_COUNT=$(echo "$INACTIVE_RELEASES" | wc -l | tr -d '\n') - echo "No old releases to delete. Found ${KEPT_COUNT} inactive release(s), which is within the retention count of ${KEEP_COUNT}." - fi - else - echo "No releases directory found" - fi - ;; - - *) - echo "Error: Unknown command: $COMMAND" - exit 1 - ;; -esac \ No newline at end of file diff --git a/src/pkmntrade_club/__init__.py b/src/pkmntrade_club/__init__.py index bcdfc83..8d1f1f7 100644 --- a/src/pkmntrade_club/__init__.py +++ b/src/pkmntrade_club/__init__.py @@ -2,4 +2,4 @@ from pkmntrade_club._version import __version__, get_version, get_version_info -__all__ = ["__version__", "get_version", "get_version_info"] +__all__ = ['__version__', 'get_version', 'get_version_info'] diff --git a/src/pkmntrade_club/_version.py b/src/pkmntrade_club/_version.py index 77921b5..6f90c71 100644 --- a/src/pkmntrade_club/_version.py +++ b/src/pkmntrade_club/_version.py @@ -1,6 +1,5 @@ from importlib.metadata import version, PackageNotFoundError from setuptools_scm import get_version - """ Version module for pkmntrade.club @@ -8,57 +7,55 @@ This module provides version information from git tags via setuptools-scm. """ try: - __version__ = version("pkmntrade-club") + __version__ = version("pkmntrade-club") except PackageNotFoundError: - # Package is not installed, try to get version from setuptools_scm - try: - __version__ = get_version(root="../../..", relative_to=__file__) - except (ImportError, LookupError): - __version__ = "0.0.0+unknown" - + # Package is not installed, try to get version from setuptools_scm + try: + __version__ = get_version(root='../../..', relative_to=__file__) + except (ImportError, LookupError): + __version__ = "0.0.0+unknown" def get_version(): - """Return the current version.""" - return __version__ - + """Return the current version.""" + return __version__ def get_version_info(): - """Return detailed version information.""" - import re - - # Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease") - match = re.match( - r"^(\d+)\.(\d+)\.(\d+)" - r"(?:\.dev(\d+))?" - r"(?:\+g([a-f0-9]+))?" - r"(?:-(.+))?$", - __version__, - ) - - if match: - major, minor, patch, dev, git_sha, prerelease = match.groups() - return { - "version": __version__, - "major": int(major), - "minor": int(minor), - "patch": int(patch), - "dev": int(dev) if dev else None, - "git_sha": git_sha, - "prerelease": prerelease, - "is_release": dev is None and not prerelease, - "is_prerelease": bool(prerelease), - "is_dev": dev is not None, - } - + """Return detailed version information.""" + import re + + # Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease") + match = re.match( + r'^(\d+)\.(\d+)\.(\d+)' + r'(?:\.dev(\d+))?' + r'(?:\+g([a-f0-9]+))?' + r'(?:-(.+))?$', + __version__ + ) + + if match: + major, minor, patch, dev, git_sha, prerelease = match.groups() return { - "version": __version__, - "major": 0, - "minor": 0, - "patch": 0, - "dev": None, - "git_sha": None, - "prerelease": None, - "is_release": False, - "is_prerelease": False, - "is_dev": True, + 'version': __version__, + 'major': int(major), + 'minor': int(minor), + 'patch': int(patch), + 'dev': int(dev) if dev else None, + 'git_sha': git_sha, + 'prerelease': prerelease, + 'is_release': dev is None and not prerelease, + 'is_prerelease': bool(prerelease), + 'is_dev': dev is not None } + + return { + 'version': __version__, + 'major': 0, + 'minor': 0, + 'patch': 0, + 'dev': None, + 'git_sha': None, + 'prerelease': None, + 'is_release': False, + 'is_prerelease': False, + 'is_dev': True +} \ No newline at end of file diff --git a/src/pkmntrade_club/accounts/adapter.py b/src/pkmntrade_club/accounts/adapter.py index b39fed3..b41e761 100644 --- a/src/pkmntrade_club/accounts/adapter.py +++ b/src/pkmntrade_club/accounts/adapter.py @@ -1,3 +1,4 @@ +from django.conf import settings from allauth.account.adapter import DefaultAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter @@ -9,4 +10,4 @@ class NoSignupAccountAdapter(DefaultAccountAdapter): class NoSignupSocialAccountAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request): - return False + return False \ No newline at end of file diff --git a/src/pkmntrade_club/accounts/admin.py b/src/pkmntrade_club/accounts/admin.py index 7a004c7..f3bd5c3 100644 --- a/src/pkmntrade_club/accounts/admin.py +++ b/src/pkmntrade_club/accounts/admin.py @@ -1,8 +1,9 @@ from django.contrib import admin +from django.contrib.auth import get_user_model from django.contrib.auth.admin import UserAdmin -from .forms import CustomUserChangeForm, CustomUserCreationForm -from .models import CustomUser, FriendCode +from .forms import CustomUserCreationForm, CustomUserChangeForm +from .models import CustomUser class CustomUserAdmin(UserAdmin): @@ -27,11 +28,3 @@ class CustomUserAdmin(UserAdmin): admin.site.register(CustomUser, CustomUserAdmin) - - -@admin.register(FriendCode) -class FriendCodeAdmin(admin.ModelAdmin): - list_display = ("friend_code", "in_game_name", "user") - search_fields = ("friend_code", "in_game_name", "user__username", "user__email") - list_select_related = ("user",) - autocomplete_fields = ("user",) diff --git a/src/pkmntrade_club/accounts/apps.py b/src/pkmntrade_club/accounts/apps.py index 408657e..df72a2b 100644 --- a/src/pkmntrade_club/accounts/apps.py +++ b/src/pkmntrade_club/accounts/apps.py @@ -2,5 +2,5 @@ from django.apps import AppConfig class AccountsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "pkmntrade_club.accounts" + default_auto_field = 'django.db.models.BigAutoField' + name = 'pkmntrade_club.accounts' diff --git a/src/pkmntrade_club/accounts/forms.py b/src/pkmntrade_club/accounts/forms.py index 0aac8fe..6788d85 100644 --- a/src/pkmntrade_club/accounts/forms.py +++ b/src/pkmntrade_club/accounts/forms.py @@ -2,13 +2,15 @@ from django import forms from django.contrib.auth.forms import UserCreationForm, UserChangeForm from .models import CustomUser, FriendCode from allauth.account.forms import SignupForm - +from crispy_tailwind.tailwind import CSSContainer +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Field, Submit class CustomUserChangeForm(UserChangeForm): + class Meta: model = CustomUser - fields = ["email"] - + fields = ['email'] class FriendCodeForm(forms.ModelForm): class Meta: @@ -25,27 +27,23 @@ class FriendCodeForm(forms.ModelForm): friend_code_formatted = f"{friend_code_clean[:4]}-{friend_code_clean[4:8]}-{friend_code_clean[8:12]}-{friend_code_clean[12:16]}" return friend_code_formatted - class CustomUserCreationForm(SignupForm): + class Meta(UserCreationForm.Meta): model = CustomUser - fields = ["email", "username", "friend_code"] + fields = ['email', 'username', 'friend_code'] email = forms.EmailField( required=True, label="Email", - widget=forms.TextInput( - attrs={"placeholder": "Email", "class": "dark:bg-base-100"} - ), + widget=forms.TextInput(attrs={'placeholder': 'Email', 'class':'dark:bg-base-100'}) ) username = forms.CharField( max_length=24, required=True, label="Username", - widget=forms.TextInput( - attrs={"placeholder": "Username", "class": "dark:bg-base-100"} - ), + widget=forms.TextInput(attrs={'placeholder': 'Username', 'class':'dark:bg-base-100'}) ) friend_code = forms.CharField( @@ -53,18 +51,14 @@ class CustomUserCreationForm(SignupForm): required=True, label="Friend Code", help_text="Enter your friend code in the format XXXX-XXXX-XXXX-XXXX.", - widget=forms.TextInput( - attrs={"placeholder": "XXXX-XXXX-XXXX-XXXX", "class": "dark:bg-base-100"} - ), + widget=forms.TextInput(attrs={'placeholder': 'XXXX-XXXX-XXXX-XXXX', 'class':'dark:bg-base-100'}) ) in_game_name = forms.CharField( max_length=16, required=True, label="In-Game Name", help_text="Enter your in-game name.", - widget=forms.TextInput( - attrs={"placeholder": "In-Game Name", "class": "dark:bg-base-100"} - ), + widget=forms.TextInput(attrs={'placeholder': 'In-Game Name', 'class':'dark:bg-base-100'}) ) def __init__(self, *args, **kwargs): @@ -84,14 +78,13 @@ class CustomUserCreationForm(SignupForm): friend_code_instance = FriendCode.objects.create( friend_code=self.cleaned_data["friend_code"], in_game_name=self.cleaned_data["in_game_name"], - user=user, + user=user ) user.default_friend_code = friend_code_instance user.save() return user - class UserSettingsForm(forms.ModelForm): class Meta: model = CustomUser - fields = ["show_friend_code_on_link_previews", "enable_email_notifications"] + fields = ['show_friend_code_on_link_previews', 'enable_email_notifications'] \ No newline at end of file diff --git a/src/pkmntrade_club/accounts/management/commands/clear_cache.py b/src/pkmntrade_club/accounts/management/commands/clear_cache.py index 96a2b7b..8c6863f 100644 --- a/src/pkmntrade_club/accounts/management/commands/clear_cache.py +++ b/src/pkmntrade_club/accounts/management/commands/clear_cache.py @@ -1,8 +1,7 @@ from django.core.management.base import BaseCommand from django.core.cache import cache - class Command(BaseCommand): def handle(self, *args, **kwargs): cache.clear() - self.stdout.write("Cleared cache\n") + self.stdout.write('Cleared cache\n') \ No newline at end of file diff --git a/src/pkmntrade_club/accounts/migrations/0001_initial.py b/src/pkmntrade_club/accounts/migrations/0001_initial.py index 38c176e..a706671 100644 --- a/src/pkmntrade_club/accounts/migrations/0001_initial.py +++ b/src/pkmntrade_club/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1 on 2025-06-15 03:44 +# Generated by Django 5.1 on 2025-05-17 02:07 import django.contrib.auth.models import django.contrib.auth.validators @@ -14,183 +14,53 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("auth", "0001_initial"), + ('auth', '0001_initial'), ] operations = [ migrations.CreateModel( - name="CustomUser", + name='CustomUser', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("password", models.CharField(max_length=128, verbose_name="password")), - ( - "last_login", - models.DateTimeField( - blank=True, null=True, verbose_name="last login" - ), - ), - ( - "is_superuser", - models.BooleanField( - default=False, - help_text="Designates that this user has all permissions without explicitly assigning them.", - verbose_name="superuser status", - ), - ), - ( - "username", - models.CharField( - error_messages={ - "unique": "A user with that username already exists." - }, - help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - max_length=150, - unique=True, - validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() - ], - verbose_name="username", - ), - ), - ( - "first_name", - models.CharField( - blank=True, max_length=150, verbose_name="first name" - ), - ), - ( - "last_name", - models.CharField( - blank=True, max_length=150, verbose_name="last name" - ), - ), - ( - "email", - models.EmailField( - blank=True, max_length=254, verbose_name="email address" - ), - ), - ( - "is_staff", - models.BooleanField( - default=False, - help_text="Designates whether the user can log into this admin site.", - verbose_name="staff status", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", - verbose_name="active", - ), - ), - ( - "date_joined", - models.DateTimeField( - default=django.utils.timezone.now, verbose_name="date joined" - ), - ), - ( - "show_friend_code_on_link_previews", - models.BooleanField( - default=False, - help_text="This will primarily affect share link previews on X, Discord, etc.", - verbose_name="Show Friend Code on Link Previews", - ), - ), - ( - "enable_email_notifications", - models.BooleanField( - default=True, - help_text="Receive trade notifications via email.", - verbose_name="Enable Email Notifications", - ), - ), - ("reputation_score", models.IntegerField(default=0)), - ( - "groups", - models.ManyToManyField( - blank=True, - help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", - related_name="user_set", - related_query_name="user", - to="auth.group", - verbose_name="groups", - ), - ), - ( - "user_permissions", - models.ManyToManyField( - blank=True, - help_text="Specific permissions for this user.", - related_name="user_set", - related_query_name="user", - to="auth.permission", - verbose_name="user permissions", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('show_friend_code_on_link_previews', models.BooleanField(default=False, help_text='This will primarily affect share link previews on X, Discord, etc.', verbose_name='Show Friend Code on Link Previews')), + ('enable_email_notifications', models.BooleanField(default=True, help_text='Receive trade notifications via email.', verbose_name='Enable Email Notifications')), + ('reputation_score', models.IntegerField(default=0)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ], options={ - "verbose_name": "user", - "verbose_name_plural": "users", - "abstract": False, + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, }, managers=[ - ("objects", django.contrib.auth.models.UserManager()), + ('objects', django.contrib.auth.models.UserManager()), ], ), migrations.CreateModel( - name="FriendCode", + name='FriendCode', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "friend_code", - models.CharField( - max_length=19, - validators=[ - pkmntrade_club.accounts.models.validate_friend_code - ], - ), - ), - ("in_game_name", models.CharField(max_length=14)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="friend_codes", - to=settings.AUTH_USER_MODEL, - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('friend_code', models.CharField(max_length=19, validators=[pkmntrade_club.accounts.models.validate_friend_code])), + ('in_game_name', models.CharField(max_length=14)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='friend_codes', to=settings.AUTH_USER_MODEL)), ], ), migrations.AddField( - model_name="customuser", - name="default_friend_code", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="accounts.friendcode", - ), + model_name='customuser', + name='default_friend_code', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.friendcode'), ), ] diff --git a/src/pkmntrade_club/accounts/models.py b/src/pkmntrade_club/accounts/models.py index a78a15b..65610e7 100644 --- a/src/pkmntrade_club/accounts/models.py +++ b/src/pkmntrade_club/accounts/models.py @@ -3,28 +3,24 @@ from django.db import models from django.core.exceptions import ValidationError import re - def validate_friend_code(value): """Validate that friend code follows the format XXXX-XXXX-XXXX-XXXX where X is a digit.""" - if not re.match(r"^\d{4}-\d{4}-\d{4}-\d{4}$", value): + if not re.match(r'^\d{4}-\d{4}-\d{4}-\d{4}$', value): raise ValidationError( - "Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit." + 'Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit.' ) - class CustomUser(AbstractUser): - default_friend_code = models.ForeignKey( - "FriendCode", on_delete=models.SET_NULL, null=True, blank=True - ) + default_friend_code = models.ForeignKey("FriendCode", on_delete=models.SET_NULL, null=True, blank=True) show_friend_code_on_link_previews = models.BooleanField( default=False, verbose_name="Show Friend Code on Link Previews", - help_text="This will primarily affect share link previews on X, Discord, etc.", + help_text="This will primarily affect share link previews on X, Discord, etc." ) enable_email_notifications = models.BooleanField( default=True, verbose_name="Enable Email Notifications", - help_text="Receive trade notifications via email.", + help_text="Receive trade notifications via email." ) reputation_score = models.IntegerField(default=0) @@ -51,13 +47,10 @@ class CustomUser(AbstractUser): self.default_friend_code = other_codes.first() self.save(update_fields=["default_friend_code"]) - class FriendCode(models.Model): friend_code = models.CharField(max_length=19, validators=[validate_friend_code]) in_game_name = models.CharField(max_length=14, null=False, blank=False) - user = models.ForeignKey( - CustomUser, on_delete=models.PROTECT, related_name="friend_codes" - ) + user = models.ForeignKey(CustomUser, on_delete=models.PROTECT, related_name='friend_codes') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -74,4 +67,4 @@ class FriendCode(models.Model): self.user.save(update_fields=["default_friend_code"]) def __str__(self): - return self.friend_code + return self.friend_code \ No newline at end of file diff --git a/src/pkmntrade_club/accounts/templatetags/gravatar.py b/src/pkmntrade_club/accounts/templatetags/gravatar.py index a719cd6..c2005a0 100644 --- a/src/pkmntrade_club/accounts/templatetags/gravatar.py +++ b/src/pkmntrade_club/accounts/templatetags/gravatar.py @@ -6,17 +6,15 @@ from django.utils.safestring import mark_safe register = template.Library() - @register.filter def gravatar_hash(email): """ Returns the hash of the email. """ - email_encoded = email.strip().lower().encode("utf-8") + email_encoded = email.strip().lower().encode('utf-8') email_hash = hashlib.sha256(email_encoded).hexdigest() return email_hash - @register.filter def gravatar_url(email, size=20): """ @@ -25,22 +23,20 @@ def gravatar_url(email, size=20): """ default = "retro" email_hash = gravatar_hash(email) - params = urlencode({"d": default, "s": str(size)}) + params = urlencode({'d': default, 's': str(size)}) params = params.replace("&", "&") return f"https://www.gravatar.com/avatar/{email_hash}?{params}" - @register.filter def gravatar_profile_url(email=None): """ Returns the Gravatar Profile URL for a given email. """ if email is None: - return "https://www.gravatar.com/profile" + return f"https://www.gravatar.com/profile" email_hash = gravatar_hash(email) return f"https://secure.gravatar.com/{email_hash}" - @register.filter def gravatar(email, size=20): """ @@ -52,7 +48,6 @@ def gravatar(email, size=20): html = f'Gravatar' return mark_safe(html) - @register.filter def gravatar_no_hover(email, size=20): """ @@ -64,7 +59,6 @@ def gravatar_no_hover(email, size=20): html = f'Gravatar' return mark_safe(html) - @register.filter def gravatar_profile_data(email): """ diff --git a/src/pkmntrade_club/accounts/tests.py b/src/pkmntrade_club/accounts/tests.py index 6d00785..b6c8695 100644 --- a/src/pkmntrade_club/accounts/tests.py +++ b/src/pkmntrade_club/accounts/tests.py @@ -9,34 +9,34 @@ from django.core.exceptions import ValidationError from django.contrib.sessions.middleware import SessionMiddleware from pkmntrade_club.accounts.models import FriendCode -from pkmntrade_club.accounts.forms import ( - FriendCodeForm, - CustomUserCreationForm, - UserSettingsForm, -) +from pkmntrade_club.accounts.forms import FriendCodeForm, CustomUserCreationForm, UserSettingsForm from pkmntrade_club.accounts.templatetags import gravatar from pkmntrade_club.trades.models import TradeOffer from tests.utils.rarity import RARITY_MAPPING # Create your tests here. - # ----------------------------- # Model Tests # ----------------------------- class CustomUserModelTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user( - username="testuser", email="test@example.com", password="password123" + username="testuser", + email="test@example.com", + password="password123" ) - def test_set_default_friend_code(self): """User can manually set a friend code as their default.""" fc1 = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne" + friend_code="1234-5678-9012-3456", + user=self.user, + in_game_name="GameOne" ) fc2 = FriendCode.objects.create( - friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo" + friend_code="2345-6789-0123-4567", + user=self.user, + in_game_name="GameTwo" ) # Manually set fc2 as default. self.user.set_default_friend_code(fc2) @@ -48,10 +48,14 @@ class CustomUserModelTests(TestCase): Attempting to set a friend code that does not belong to the user should raise an exception. """ other_user = get_user_model().objects.create_user( - username="otheruser", email="other@example.com", password="password456" + username="otheruser", + email="other@example.com", + password="password456" ) fc_other = FriendCode.objects.create( - friend_code="3456-7890-1234-5678", user=other_user, in_game_name="OtherGame" + friend_code="3456-7890-1234-5678", + user=other_user, + in_game_name="OtherGame" ) with self.assertRaises(ValidationError): self.user.set_default_friend_code(fc_other) @@ -62,10 +66,14 @@ class CustomUserModelTests(TestCase): the default should be reassigned to another friend code. """ fc1 = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne" + friend_code="1234-5678-9012-3456", + user=self.user, + in_game_name="GameOne" ) fc2 = FriendCode.objects.create( - friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo" + friend_code="2345-6789-0123-4567", + user=self.user, + in_game_name="GameTwo" ) # Set fc2 as default. self.user.set_default_friend_code(fc2) @@ -81,7 +89,9 @@ class CustomUserModelTests(TestCase): should be prohibited. """ fc = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", user=self.user, in_game_name="OnlyGame" + friend_code="1234-5678-9012-3456", + user=self.user, + in_game_name="OnlyGame" ) self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc) @@ -94,19 +104,21 @@ class CustomUserModelTests(TestCase): the current default should remain unchanged. """ fc1 = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne" + friend_code="1234-5678-9012-3456", + user=self.user, + in_game_name="GameOne" ) fc2 = FriendCode.objects.create( - friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo" + friend_code="2345-6789-0123-4567", + user=self.user, + in_game_name="GameTwo" ) # By default, fc1 is the default friend code. self.assertEqual(self.user.default_friend_code, fc1) try: self.user.remove_default_friend_code(fc2) - except Exception: - self.fail( - "remove_default_friend_code raised an exception when removing a non-default code." - ) + except Exception as e: + self.fail("remove_default_friend_code raised an exception when removing a non-default code.") self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc1) @@ -117,7 +129,9 @@ class CustomUserModelTests(TestCase): class FriendCodeModelTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user( - username="testuser2", email="test2@example.com", password="password123" + username="testuser2", + email="test2@example.com", + password="password123" ) def test_default_set_on_creation(self): @@ -128,7 +142,7 @@ class FriendCodeModelTests(TestCase): fc = FriendCode.objects.create( friend_code="1234-5678-9012-3456", user=self.user, - in_game_name="GameDefault", + in_game_name="GameDefault" ) self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc) @@ -141,14 +155,14 @@ class FriendCodeModelTests(TestCase): fc1 = FriendCode.objects.create( friend_code="1111-1111-1111-1111", user=self.user, - in_game_name="PrimaryGame", + in_game_name="PrimaryGame" ) # fc1 becomes the default automatically. self.assertEqual(self.user.default_friend_code, fc1) fc2 = FriendCode.objects.create( friend_code="2222-2222-2222-2222", user=self.user, - in_game_name="SecondaryGame", + in_game_name="SecondaryGame" ) self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc1) @@ -160,34 +174,39 @@ class FriendCodeModelTests(TestCase): class FriendCodeFormTests(TestCase): def test_valid_friend_code(self): """Ensure valid friend code is cleaned and formatted properly.""" - form_data = {"friend_code": "1234567890123456", "in_game_name": "GameTest"} + form_data = { + "friend_code": "1234567890123456", + "in_game_name": "GameTest" + } form = FriendCodeForm(data=form_data) self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data["friend_code"], "1234-5678-9012-3456") def test_invalid_friend_code_length(self): """Friend codes with incorrect length should cause validation errors.""" - form_data = {"friend_code": "12345", "in_game_name": "GameTest"} + form_data = { + "friend_code": "12345", + "in_game_name": "GameTest" + } form = FriendCodeForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn( - "Friend code must be exactly 16 digits long.", form.errors["friend_code"] - ) + self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) def test_invalid_friend_code_characters(self): """Friend codes containing non-digit characters should cause validation errors.""" - form_data = {"friend_code": "12345678901234ab", "in_game_name": "GameTest"} + form_data = { + "friend_code": "12345678901234ab", + "in_game_name": "GameTest" + } form = FriendCodeForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn( - "Friend code must be exactly 16 digits long.", form.errors["friend_code"] - ) + self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) def test_friend_code_with_whitespace(self): """Ensure that leading/trailing whitespace is stripped.""" form_data = { "friend_code": " 1234567890123456 ", - "in_game_name": "WhitespaceGame", + "in_game_name": "WhitespaceGame" } form = FriendCodeForm(data=form_data) self.assertTrue(form.is_valid()) @@ -197,7 +216,7 @@ class FriendCodeFormTests(TestCase): """Proper dashes in the input should be accepted.""" form_data = { "friend_code": "1234-5678-9012-3456", - "in_game_name": "ExtraDashGame", + "in_game_name": "ExtraDashGame" } form = FriendCodeForm(data=form_data) self.assertTrue(form.is_valid()) @@ -273,9 +292,7 @@ class CustomUserCreationFormTests(TestCase): } form = CustomUserCreationForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn( - "Friend code must be exactly 16 digits long.", form.errors["friend_code"] - ) + self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) def test_invalid_custom_user_creation_password_mismatch(self): """ @@ -301,7 +318,7 @@ class UserSettingsFormTests(TestCase): self.user = get_user_model().objects.create_user( username="settingsuser", email="settings@example.com", - password="password123", + password="password123" ) def test_toggle_show_friend_code_on_link_previews(self): @@ -320,7 +337,9 @@ class UserSettingsFormTests(TestCase): class FriendCodeViewsTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user( - username="viewuser", email="viewuser@example.com", password="password123" + username="viewuser", + email="viewuser@example.com", + password="password123" ) # Log in this user. self.client.login(username="viewuser", password="password123") @@ -328,12 +347,12 @@ class FriendCodeViewsTests(TestCase): self.friend_code1 = FriendCode.objects.create( friend_code="7777-7777-7777-7777", user=self.user, - in_game_name="ViewGameOne", + in_game_name="ViewGameOne" ) self.friend_code2 = FriendCode.objects.create( friend_code="8888-8888-8888-8888", user=self.user, - in_game_name="ViewGameTwo", + in_game_name="ViewGameTwo" ) # By default, friend_code1 is the default. @@ -371,7 +390,8 @@ class FriendCodeViewsTests(TestCase): self.assertRedirects(response, reverse("list_friend_codes")) self.assertTrue( FriendCode.objects.filter( - user=self.user, friend_code="9999-9999-9999-9999" + user=self.user, + friend_code="9999-9999-9999-9999" ).exists() ) # Ensure that adding a new friend code does not change the default. @@ -384,16 +404,10 @@ class FriendCodeViewsTests(TestCase): data = {"friend_code": "invalidfriendcode", "in_game_name": "InvalidGame"} response = self.client.post(url, data) # Extract the form from the response's context. If response.context is a list, use its first element. - context = ( - response.context[0] - if isinstance(response.context, list) - else response.context - ) + context = response.context[0] if isinstance(response.context, list) else response.context form = context.get("form") self.assertIsNotNone(form, "Form not found in response context") - self.assertFormError( - form, "friend_code", "Friend code must be exactly 16 digits long." - ) + self.assertFormError(form, "friend_code", "Friend code must be exactly 16 digits long.") def test_edit_friend_code_view(self): """Test editing the in-game name of an existing friend code.""" @@ -411,10 +425,14 @@ class FriendCodeViewsTests(TestCase): def test_edit_friend_code_view_wrong_user(self): """A user should not be able to edit a friend code that does not belong to them.""" other_user = get_user_model().objects.create_user( - username="otheruser", email="other@example.com", password="password1234" + username="otheruser", + email="other@example.com", + password="password1234" ) friend_code_other = FriendCode.objects.create( - friend_code="0000-0000-0000-0000", user=other_user, in_game_name="OtherGame" + friend_code="0000-0000-0000-0000", + user=other_user, + in_game_name="OtherGame" ) url = reverse("edit_friend_code", kwargs={"pk": friend_code_other.pk}) response = self.client.get(url) @@ -425,25 +443,25 @@ class FriendCodeViewsTests(TestCase): url = reverse("edit_friend_code", kwargs={"pk": self.friend_code2.pk}) new_data = {"in_game_name": ""} # in_game_name is required. response = self.client.post(url, new_data) - context = ( - response.context[0] - if isinstance(response.context, list) - else response.context - ) + context = response.context[0] if isinstance(response.context, list) else response.context form = context.get("form") self.assertIsNotNone(form, "Form not found in response context") self.assertFormError(form, "in_game_name", "This field is required.") - + def test_delete_friend_code_view_only_code(self): """ If the user has only one friend code, deletion should be disabled. This test uses a new user with a single friend code. """ user_only = get_user_model().objects.create_user( - username="onlyuser", email="onlyuser@example.com", password="password123" + username="onlyuser", + email="onlyuser@example.com", + password="password123" ) friend_code_only = FriendCode.objects.create( - friend_code="4444-4444-4444-4444", user=user_only, in_game_name="SoloGame" + friend_code="4444-4444-4444-4444", + user=user_only, + in_game_name="SoloGame" ) self.client.logout() self.client.login(username="onlyuser", password="password123") @@ -474,7 +492,7 @@ class FriendCodeViewsTests(TestCase): initiated_by=self.friend_code2, is_closed=False, rarity_icon=RARITY_MAPPING[5], - rarity_level=5, + rarity_level=5 ) url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk}) response = self.client.post(url, {}) @@ -499,10 +517,14 @@ class FriendCodeViewsTests(TestCase): def test_change_default_friend_code_view_not_owned(self): """A friend code that does not belong to the current user should result in a 404.""" other_user = get_user_model().objects.create_user( - username="otheruser2", email="other2@example.com", password="password789" + username="otheruser2", + email="other2@example.com", + password="password789" ) friend_code_other = FriendCode.objects.create( - friend_code="1111-1111-1111-1111", user=other_user, in_game_name="NotMine" + friend_code="1111-1111-1111-1111", + user=other_user, + in_game_name="NotMine" ) url = reverse("change_default_friend_code", kwargs={"pk": friend_code_other.pk}) response = self.client.post(url, {}) @@ -539,12 +561,12 @@ class FriendCodeViewsTests(TestCase): other_user = get_user_model().objects.create_user( username="otherdeluser", email="otherdel@example.com", - password="password321", + password="password321" ) friend_code_other = FriendCode.objects.create( friend_code="2222-2222-2222-2222", user=other_user, - in_game_name="OtherDelete", + in_game_name="OtherDelete" ) url = reverse("delete_friend_code", kwargs={"pk": friend_code_other.pk}) response = self.client.get(url) diff --git a/src/pkmntrade_club/accounts/urls.py b/src/pkmntrade_club/accounts/urls.py index 202d789..8e9b106 100644 --- a/src/pkmntrade_club/accounts/urls.py +++ b/src/pkmntrade_club/accounts/urls.py @@ -9,20 +9,8 @@ from .views import ( urlpatterns = [ path("friend-codes/add/", AddFriendCodeView.as_view(), name="add_friend_code"), - path( - "friend-codes/edit//", - EditFriendCodeView.as_view(), - name="edit_friend_code", - ), - path( - "friend-codes/delete//", - DeleteFriendCodeView.as_view(), - name="delete_friend_code", - ), - path( - "friend-codes/default//", - ChangeDefaultFriendCodeView.as_view(), - name="change_default_friend_code", - ), + path("friend-codes/edit//", EditFriendCodeView.as_view(), name="edit_friend_code"), + path("friend-codes/delete//", DeleteFriendCodeView.as_view(), name="delete_friend_code"), + path("friend-codes/default//", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"), path("dashboard/", DashboardView.as_view(), name="dashboard"), -] +] \ No newline at end of file diff --git a/src/pkmntrade_club/accounts/views.py b/src/pkmntrade_club/accounts/views.py index a170fb7..fbecc1e 100644 --- a/src/pkmntrade_club/accounts/views.py +++ b/src/pkmntrade_club/accounts/views.py @@ -1,35 +1,26 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse_lazy +from django.shortcuts import redirect, get_object_or_404, render +from django.views.generic import ListView, CreateView, DeleteView, View, TemplateView, UpdateView +from pkmntrade_club.accounts.models import FriendCode, CustomUser +from pkmntrade_club.accounts.forms import FriendCodeForm, UserSettingsForm +from django.db.models import Case, When, Value, BooleanField +from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance from django.core.exceptions import PermissionDenied -from django.db.models import BooleanField, Case, Q, Value, When -from django.shortcuts import get_object_or_404, redirect, render +from pkmntrade_club.trades.mixins import FriendCodeRequiredMixin +from pkmntrade_club.common.mixins import ReusablePaginationMixin from django.urls import reverse from django.utils.http import urlencode -from django.views.generic import ( - CreateView, - DeleteView, - TemplateView, - UpdateView, - View, -) - -from pkmntrade_club.accounts.forms import FriendCodeForm, UserSettingsForm -from pkmntrade_club.accounts.models import FriendCode -from pkmntrade_club.common.mixins import ReusablePaginationMixin -from pkmntrade_club.trades.mixins import FriendCodeRequiredMixin -from pkmntrade_club.trades.models import TradeAcceptance, TradeOffer - class AddFriendCodeView(LoginRequiredMixin, CreateView): """ Add a new friend code for the current user. If the user does not yet have a default, the newly added code will automatically become the default. """ - model = FriendCode form_class = FriendCodeForm template_name = "friend_codes/add_friend_code.html" - def get_success_url(self): base_url = reverse("dashboard") return f"{base_url}?{urlencode({'tab': 'friend_codes'})}" @@ -39,7 +30,6 @@ class AddFriendCodeView(LoginRequiredMixin, CreateView): messages.success(self.request, "Friend code added successfully.") return super().form_valid(form) - class DeleteFriendCodeView(LoginRequiredMixin, DeleteView): """ Remove an existing friend code. @@ -47,11 +37,9 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView): Also, prevent deletion if the friend code is either the only one or is set as the default friend code. """ - model = FriendCode template_name = "friend_codes/confirm_delete_friend_code.html" context_object_name = "friend_code" - def get_success_url(self): base_url = reverse("dashboard") return f"{base_url}?{urlencode({'tab': 'friend_codes'})}" @@ -93,59 +81,48 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView): if user.default_friend_code == self.object: messages.error( request, - "Cannot delete your default friend code. Please set a different default first.", + "Cannot delete your default friend code. Please set a different default first." ) return redirect(self.get_success_url()) - trade_offer_exists = TradeOffer.objects.filter( - initiated_by_id=self.object.pk - ).exists() - trade_acceptance_exists = TradeAcceptance.objects.filter( - accepted_by_id=self.object.pk - ).exists() + trade_offer_exists = TradeOffer.objects.filter(initiated_by_id=self.object.pk).exists() + trade_acceptance_exists = TradeAcceptance.objects.filter(accepted_by_id=self.object.pk).exists() if trade_offer_exists or trade_acceptance_exists: messages.error( request, - "Cannot remove this friend code because there are existing trade offers associated with it.", + "Cannot remove this friend code because there are existing trade offers associated with it." ) return redirect(self.get_success_url()) - + self.object.delete() messages.success(request, "Friend code removed successfully.") return redirect(self.get_success_url()) - class ChangeDefaultFriendCodeView(LoginRequiredMixin, View): """ Change the default friend code for the current user. """ - def post(self, request, *args, **kwargs): friend_code_id = kwargs.get("pk") - friend_code = get_object_or_404( - FriendCode, pk=friend_code_id, user=request.user - ) + friend_code = get_object_or_404(FriendCode, pk=friend_code_id, user=request.user) request.user.set_default_friend_code(friend_code) messages.success(request, "Default friend code updated successfully.") base_url = reverse("dashboard") query_string = urlencode({"tab": "friend_codes"}) return redirect(f"{base_url}?{query_string}") - class EditFriendCodeView(LoginRequiredMixin, UpdateView): """ Edit the in-game name for a friend code. The friend code itself is displayed as plain text. Also includes "Set Default" and "Delete" buttons in the template. """ - model = FriendCode # Only the in_game_name field is editable - fields = ["in_game_name"] + fields = ['in_game_name'] template_name = "friend_codes/edit_friend_code.html" context_object_name = "friend_code" - def get_success_url(self): base_url = reverse("dashboard") return f"{base_url}?{urlencode({'tab': 'friend_codes'})}" @@ -158,16 +135,12 @@ class EditFriendCodeView(LoginRequiredMixin, UpdateView): messages.success(self.request, "Friend code updated successfully.") return super().form_valid(form) - -class DashboardView( - LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginationMixin, TemplateView -): +class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginationMixin, TemplateView): template_name = "account/dashboard.html" def post(self, request, *args, **kwargs): - if "update_settings" in request.POST: + if 'update_settings' in request.POST: from pkmntrade_club.accounts.forms import UserSettingsForm - form = UserSettingsForm(request.POST, instance=request.user) if form.is_valid(): form.save() @@ -183,28 +156,21 @@ class DashboardView( try: selected_friend_code = friend_codes.get(pk=friend_code_param) except friend_codes.model.DoesNotExist: - selected_friend_code = ( - self.request.user.default_friend_code or friend_codes.first() - ) + selected_friend_code = self.request.user.default_friend_code or friend_codes.first() else: - selected_friend_code = ( - self.request.user.default_friend_code or friend_codes.first() - ) + selected_friend_code = self.request.user.default_friend_code or friend_codes.first() if not selected_friend_code: - raise PermissionDenied( - "You do not have an active friend code associated with your account." - ) + raise PermissionDenied("You do not have an active friend code associated with your account.") return selected_friend_code def get_dashboard_offers_paginated(self, page_param): selected_friend_code = self.get_selected_friend_code() - queryset = TradeOffer.objects.filter( - initiated_by=selected_friend_code, is_closed=False - ) + queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=False) object_list, pagination_context = self.paginate_data(queryset, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} def get_involved_acceptances(self, selected_friend_code): + from django.db.models import Q terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, @@ -212,44 +178,21 @@ class DashboardView( TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, ] - involved = ( - TradeAcceptance.objects.filter( - Q(trade_offer__initiated_by=selected_friend_code) - | Q(accepted_by=selected_friend_code) - ) - .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") - ) + involved = TradeAcceptance.objects.filter( + Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code) + ).order_by("-updated_at") return involved.exclude(state__in=terminal_states) def get_trade_acceptances_waiting_paginated(self, page_param): selected_friend_code = self.get_selected_friend_code() involved = self.get_involved_acceptances(selected_friend_code) from django.db.models import Q - waiting = involved.filter( - Q( - trade_offer__initiated_by=selected_friend_code, - state__in=[ - TradeAcceptance.AcceptanceState.ACCEPTED, - TradeAcceptance.AcceptanceState.RECEIVED, - ], - ) - | Q( - accepted_by=selected_friend_code, - state__in=[TradeAcceptance.AcceptanceState.SENT], - ) + Q(trade_offer__initiated_by=selected_friend_code, state__in=[ + TradeAcceptance.AcceptanceState.ACCEPTED, + TradeAcceptance.AcceptanceState.RECEIVED, + ]) | + Q(accepted_by=selected_friend_code, state__in=[TradeAcceptance.AcceptanceState.SENT]) ) object_list, pagination_context = self.paginate_data(waiting, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} @@ -258,19 +201,12 @@ class DashboardView( selected_friend_code = self.get_selected_friend_code() involved = self.get_involved_acceptances(selected_friend_code) from django.db.models import Q - waiting = involved.filter( - Q( - trade_offer__initiated_by=selected_friend_code, - state__in=[ - TradeAcceptance.AcceptanceState.ACCEPTED, - TradeAcceptance.AcceptanceState.RECEIVED, - ], - ) - | Q( - accepted_by=selected_friend_code, - state__in=[TradeAcceptance.AcceptanceState.SENT], - ) + Q(trade_offer__initiated_by=selected_friend_code, state__in=[ + TradeAcceptance.AcceptanceState.ACCEPTED, + TradeAcceptance.AcceptanceState.RECEIVED, + ]) | + Q(accepted_by=selected_friend_code, state__in=[TradeAcceptance.AcceptanceState.SENT]) ) others = involved.exclude(pk__in=waiting.values("pk")) object_list, pagination_context = self.paginate_data(others, int(page_param)) @@ -278,101 +214,42 @@ class DashboardView( def get_closed_offers_paginated(self, page_param): selected_friend_code = self.get_selected_friend_code() - queryset = TradeOffer.objects.filter( - initiated_by=selected_friend_code, is_closed=True - ) + queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=True) object_list, pagination_context = self.paginate_data(queryset, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} def get_closed_acceptances_paginated(self, page_param): + from django.db.models import Q selected_friend_code = self.get_selected_friend_code() terminal_success_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, ] - acceptance_qs = ( - TradeAcceptance.objects.filter( - Q(trade_offer__initiated_by=selected_friend_code) - | Q(accepted_by=selected_friend_code), - state__in=terminal_success_states, - ) - .select_related( - "trade_offer__initiated_by__user", - "accepted_by__user", - "requested_card__rarity", - "requested_card__cardset", - "offered_card__rarity", - "offered_card__cardset", - ) - .prefetch_related( - "requested_card__translations", - "offered_card__translations", - ) - .order_by("-updated_at") - ) - object_list, pagination_context = self.paginate_data( - acceptance_qs, int(page_param) - ) + acceptance_qs = TradeAcceptance.objects.filter( + Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code), + state__in=terminal_success_states + ).order_by("-updated_at") + object_list, pagination_context = self.paginate_data(acceptance_qs, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} def get_rejected_by_me_paginated(self, page_param): + from django.db.models import Q selected_friend_code = self.get_selected_friend_code() - rejection = ( - TradeAcceptance.objects.filter( - Q( - trade_offer__initiated_by=selected_friend_code, - state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, - ) - | Q( - accepted_by=selected_friend_code, - state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, - ) - ) - .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") - ) + rejection = TradeAcceptance.objects.filter( + Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR) | + Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR) + ).order_by("-updated_at") object_list, pagination_context = self.paginate_data(rejection, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} def get_rejected_by_them_paginated(self, page_param): + from django.db.models import Q selected_friend_code = self.get_selected_friend_code() - rejection = ( - TradeAcceptance.objects.filter( - Q( - trade_offer__initiated_by=selected_friend_code, - state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, - ) - | Q( - accepted_by=selected_friend_code, - state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, - ) - ) - .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") - ) + rejection = TradeAcceptance.objects.filter( + Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR) | + Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR) + ).order_by("-updated_at") object_list, pagination_context = self.paginate_data(rejection, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} @@ -381,19 +258,19 @@ class DashboardView( request = self.request selected_friend_code = self.get_selected_friend_code() context["selected_friend_code"] = selected_friend_code - + # Get the default friend code's primary key if it exists default_pk = getattr(request.user.default_friend_code, "pk", None) - + # Annotate friend codes with is_default flag context["friend_codes"] = request.user.friend_codes.all().annotate( is_default=Case( When(pk=default_pk, then=Value(True)), default=Value(False), - output_field=BooleanField(), + output_field=BooleanField() ) ) - + ajax_section = request.GET.get("ajax_section") if ajax_section == "dashboard_offers": offers_page = request.GET.get("page", 1) @@ -430,28 +307,14 @@ class DashboardView( else: rejected_by_them_page = request.GET.get("rejected_by_them_page", 1) - context["dashboard_offers_paginated"] = self.get_dashboard_offers_paginated( - offers_page - ) - context["trade_acceptances_waiting_paginated"] = ( - self.get_trade_acceptances_waiting_paginated(waiting_page) - ) - context["other_party_trade_acceptances_paginated"] = ( - self.get_other_party_trade_acceptances_paginated(other_page) - ) - context["closed_offers_paginated"] = self.get_closed_offers_paginated( - closed_offers_page - ) - context["closed_acceptances_paginated"] = self.get_closed_acceptances_paginated( - closed_acceptances_page - ) - context["rejected_by_me_paginated"] = self.get_rejected_by_me_paginated( - rejected_by_me_page - ) - context["rejected_by_them_paginated"] = self.get_rejected_by_them_paginated( - rejected_by_them_page - ) - + context["dashboard_offers_paginated"] = self.get_dashboard_offers_paginated(offers_page) + context["trade_acceptances_waiting_paginated"] = self.get_trade_acceptances_waiting_paginated(waiting_page) + context["other_party_trade_acceptances_paginated"] = self.get_other_party_trade_acceptances_paginated(other_page) + context["closed_offers_paginated"] = self.get_closed_offers_paginated(closed_offers_page) + context["closed_acceptances_paginated"] = self.get_closed_acceptances_paginated(closed_acceptances_page) + context["rejected_by_me_paginated"] = self.get_rejected_by_me_paginated(rejected_by_me_page) + context["rejected_by_them_paginated"] = self.get_rejected_by_them_paginated(rejected_by_them_page) + from pkmntrade_club.accounts.forms import UserSettingsForm context["settings_form"] = UserSettingsForm(instance=request.user) context["active_tab"] = request.GET.get("tab", "dash") return context @@ -464,13 +327,9 @@ class DashboardView( if ajax_section == "dashboard_offers": fragment_context = context.get("dashboard_offers_paginated", {}) elif ajax_section == "waiting_acceptances": - fragment_context = context.get( - "trade_acceptances_waiting_paginated", {} - ) + fragment_context = context.get("trade_acceptances_waiting_paginated", {}) elif ajax_section == "other_party_acceptances": - fragment_context = context.get( - "other_party_trade_acceptances_paginated", {} - ) + fragment_context = context.get("other_party_trade_acceptances_paginated", {}) elif ajax_section == "closed_offers": fragment_context = context.get("closed_offers_paginated", {}) elif ajax_section == "closed_acceptances": @@ -483,12 +342,8 @@ class DashboardView( fragment_context = {} if fragment_context: - return render( - request, - "trades/_trade_offer_list.html", - { - "offers": fragment_context.get("object_list", []), - "page_obj": fragment_context.get("page_obj"), - }, - ) - return super().get(request, *args, **kwargs) + return render(request, "trades/_trade_offer_list.html", { + "offers": fragment_context.get("object_list", []), + "page_obj": fragment_context.get("page_obj") + }) + return super().get(request, *args, **kwargs) \ No newline at end of file diff --git a/src/pkmntrade_club/cards/admin.py b/src/pkmntrade_club/cards/admin.py index 963a29c..b778a69 100644 --- a/src/pkmntrade_club/cards/admin.py +++ b/src/pkmntrade_club/cards/admin.py @@ -1,757 +1,7 @@ -import hashlib -import io -import json -import os -import re # For parsing set name and ID -import zipfile -from dataclasses import dataclass +from django.contrib import admin +from .models import Deck, Card, DeckNameTranslation, CardNameTranslation -import requests -from django.conf import settings -from django.contrib import admin, messages -from django.contrib.admin.filters import RelatedFieldListFilter -from django.db import transaction -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, - CardSetColorMapping, - 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): - # Handle filenames like 'a1-genetic-apex' - match = re.match(r"^([a-zA-Z0-9]+)-(.+)$", set_string) - if match: - set_id = match.group(1) - set_id = set_id[0].upper() + set_id[1:] - name = match.group(2).replace("-", " ").title() - return name, set_id - - # Handle 'promo' filename, assuming 'PRO' as a 3-char ID - if set_string == "promo": - return "Promo-A", "PA" - - match = re.match(r"^(.*?)\s*\(([A-Za-z0-9]+)\)$", set_string) - if match: - name = match.group(1).strip() - set_id = match.group(2) - set_id = set_id[0].upper() + set_id[1:] - return name, set_id - match = re.match(r"^Promo-(.*?)$", set_string) - if match: - name = set_string - set_id = "P" + match.group(1) - return name, set_id - return set_string, None - - -def calculate_card_checksum(card_data): - """ - Calculates a SHA256 checksum for a card's data. - The data is first normalized by sorting lists and dictionary keys - to ensure consistent checksums for semantically identical cards. - """ - # Select and normalize fields that define the card's state - # Order of keys in `data_to_hash` and sorting of lists are important for consistency - data_to_hash = { - "id": card_data.get("id"), - "name": card_data.get("name"), - "type": card_data.get("type"), - "subtype": card_data.get("subtype"), - "rarity": card_data.get("rarity"), # Rarity name from JSON - "health": card_data.get("health"), - "evolvesFrom": card_data.get("evolvesFrom"), - "retreatCost": card_data.get("retreatCost"), - "element": card_data.get("element"), # Element name from JSON - "weakness": card_data.get("weakness"), # Weakness name from JSON - "pack": card_data.get("pack"), # Pack name from JSON - # For abilities and attacks, ensure stable order and content - "abilities": sorted( - [ - {"name": a.get("name"), "effect": a.get("effect")} - for a in card_data.get("abilities", []) - if a and a.get("name") # ensure ability itself and name exist - ], - key=lambda x: x["name"] if x and x.get("name") else "", - ), - "attacks": sorted( - [ - { - "name": atk.get("name"), - "effect": atk.get("effect", ""), - "damage": atk.get("damage", ""), - "cost": sorted( - atk.get("cost", []) if atk.get("cost") else [] - ), # Sort energy costs - } - for atk in card_data.get("attacks", []) - if atk and atk.get("name") # ensure attack itself and name exist - ], - key=lambda x: x["name"] if x and x.get("name") else "", - ), - } - - # Serialize to a canonical JSON string (sort keys, no indent, compact) - canonical_json = json.dumps(data_to_hash, sort_keys=True, separators=(",", ":")) - - sha256_hash = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest() - return sha256_hash - - -def _get_or_create_card_type(card_data): - card_type_obj, created = CardType.objects.language("en").get_or_create( - translations__name=card_data["type"], - translations__subtype=card_data.get("subtype", ""), - defaults={"name": card_data["type"], "subtype": card_data.get("subtype", "")}, - ) - if not created: - current_subtype = card_data.get("subtype") - if current_subtype is not None and card_type_obj.subtype != current_subtype: - card_type_obj.set_current_language("en") - card_type_obj.subtype = current_subtype - card_type_obj.save() - return card_type_obj - - -def _get_or_create_rarity(card_data, rarity_mappings_dict): - original_rarity_name_from_json = card_data.get("rarity") - - # Attempt to find a mapping for the original rarity name - mapping = rarity_mappings_dict.get(original_rarity_name_from_json) - - if mapping: - # Use mapped values - target_rarity_name = mapping.mapped_name - target_icon = mapping.icon - target_level = mapping.level - elif original_rarity_name_from_json: - # No mapping found, use the original name from JSON, default icon/level - target_rarity_name = original_rarity_name_from_json - target_icon = "x" # Default icon if no mapping - target_level = 0 # Default level if no mapping - else: - # Rarity is None or empty in JSON, treat as 'Promo' - target_rarity_name = "Promo" - # Check if 'Promo' itself has a mapping - promo_mapping = rarity_mappings_dict.get("Promo") - if promo_mapping: - target_icon = promo_mapping.icon - target_level = promo_mapping.level - else: - target_icon = "x" # Default icon for 'Promo' if no mapping for 'Promo' - target_level = 0 # Default level for 'Promo' if no mapping for 'Promo' - - # Get or create the Rarity object using the (potentially mapped) values - rarity_obj, created = Rarity.objects.language("en").get_or_create( - translations__name=target_rarity_name, - defaults={ - "name": target_rarity_name, - "icon": target_icon, - "level": target_level, - }, - ) - - # If the rarity already existed, check if its icon or level needs updating based on the mapping - if not created: - updated_fields = False - if rarity_obj.icon != target_icon: - rarity_obj.icon = target_icon - updated_fields = True - if rarity_obj.level != target_level: - rarity_obj.level = target_level - updated_fields = True - - if updated_fields: - rarity_obj.save() - - return rarity_obj - - -def _get_or_create_energy(energy_name): - if not energy_name: - return None - energy_obj, _ = Energy.objects.language("en").get_or_create( - translations__name=energy_name, defaults={"name": energy_name} - ) - return energy_obj - - -def _update_card_packs(card_obj, card_data, card_set): - card_obj.packs.clear() - pack_name_from_json = card_data.get("pack") - if pack_name_from_json: - card_set.set_current_language("en") - pack_full_name = f"{card_set.name}: {pack_name_from_json}" - - pack_obj, _ = Pack.objects.language("en").get_or_create( - translations__name=pack_name_from_json, - cardset=card_set, - defaults={ - "name": pack_name_from_json, - "full_name": pack_full_name, - }, - ) - card_obj.packs.add(pack_obj) - else: - all_packs_in_set = Pack.objects.filter(cardset=card_set) - if all_packs_in_set.exists(): - card_obj.packs.add(*all_packs_in_set) - - -def _update_card_abilities(card_obj, card_data): - card_obj.abilities.clear() - for ability_data in card_data.get("abilities", []): - ability_obj, created = Ability.objects.language("en").get_or_create( - translations__name=ability_data["name"], - defaults={"name": ability_data["name"], "effect": ability_data["effect"]}, - ) - if not created and ability_obj.effect != ability_data["effect"]: - ability_obj.set_current_language("en") - ability_obj.effect = ability_data["effect"] - ability_obj.save() - card_obj.abilities.add(ability_obj) - - -def _update_card_attacks_and_costs(card_obj, card_data): - card_obj.attacks.clear() - for attack_data in card_data.get("attacks", []): - attack_obj, created = Attack.objects.language("en").get_or_create( - translations__name=attack_data["name"], - defaults={ - "name": attack_data["name"], - "effect": attack_data.get("effect", ""), - "damage": attack_data.get("damage", ""), - }, - ) - - needs_save = False - if not created: - json_effect = attack_data.get("effect", "") - if attack_obj.effect != json_effect: - attack_obj.set_current_language("en") - attack_obj.effect = json_effect - needs_save = True - - json_damage = attack_data.get("damage", "") - if attack_obj.damage != json_damage: - attack_obj.damage = json_damage - needs_save = True - - if created or needs_save: - attack_obj.save() - - card_obj.attacks.add(attack_obj) - - attack_obj.energy_cost.clear() - energy_counts = {} - for cost_energy_name in attack_data.get("cost", []): - energy_counts[cost_energy_name] = energy_counts.get(cost_energy_name, 0) + 1 - - for energy_name, quantity in energy_counts.items(): - energy_obj = _get_or_create_energy(energy_name) - if energy_obj: - AttackCost.objects.update_or_create( - attack=attack_obj, - energy=energy_obj, - defaults={"quantity": quantity}, - ) - - -def _process_single_card_data( - card_data, - card_set, - stats_accumulator, - error_tracking, - rarity_mappings_dict, -): - """ - Processes a single card's data from a JSON file, creating or updating - the card and its related objects in the database. - """ - card_id = card_data["id"] - incoming_checksum = calculate_card_checksum(card_data) - error_tracking["card_id"] = card_id - - try: - existing_card = Card.objects.language("en").get(id=card_id) - if existing_card.checksum == incoming_checksum: - stats_accumulator["skipped_count"] += 1 - return - except Card.DoesNotExist: - existing_card = None - - card_type_obj = _get_or_create_card_type(card_data) - rarity_obj = _get_or_create_rarity(card_data, rarity_mappings_dict) - pkmn_type_obj = _get_or_create_energy(card_data.get("element")) - weakness_type_obj = _get_or_create_energy(card_data.get("weakness")) - - card_defaults = { - "name": card_data["name"], - "cardset": card_set, - "card_type": card_type_obj, - "rarity": rarity_obj, - "health": card_data.get("health"), - "evolves_from_name": card_data.get("evolvesFrom"), - "retreat_cost": card_data.get("retreatCost"), - "pkmn_type": pkmn_type_obj, - "weakness_type": weakness_type_obj, - "checksum": incoming_checksum, - } - - card_obj, card_created = Card.objects.language("en").update_or_create( - id=card_id, defaults=card_defaults - ) - - if card_created: - stats_accumulator["newly_imported_count"] += 1 - elif existing_card: - stats_accumulator["updated_count"] += 1 - # If not created and checksum differs, it's an update, which is handled by updated_count. - # update_or_create takes care of setting the new checksum via defaults. - - _update_card_packs(card_obj, card_data, card_set) - _update_card_abilities(card_obj, card_data) - _update_card_attacks_and_costs(card_obj, card_data) - - # The checksum is based on the incoming card_data. If the update_* functions - # modify the card_obj in a way that would change its representation - # based on the original card_data fields, the checksum logic is fine. - # If those functions derive new data that *should* be part of the checksum, - # the checksum calculation would need to happen *after* them, using the card_obj state. - # However, for skipping based on *incoming JSON data*, this approach is correct. - # The `update_or_create` will ensure the `checksum` field (which is part of `card_defaults`) is saved. - - -def _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 logic to perform the card import process. - This can be triggered from an admin view or a management command. - """ - stats = ImportResult() - try: - # Step 1: Pre-fetch all RarityMapping and ColorMapping objects into dictionaries - rarity_mappings = {m.original_name: m for m in RarityMapping.objects.all()} - color_mappings = {m.cardset_id: m for m in CardSetColorMapping.objects.all()} - - # Step 2: Decide whether to fetch from GitHub or local files - if settings.DEBUG: - card_files_source = _fetch_card_data_from_local_files() - source_message = "local files" - else: - card_files_source = _fetch_card_data_from_github_zip() - source_message = "the GitHub archive" - - all_files_data = list(card_files_source) - total_files = len(all_files_data) - - if not all_files_data: - stats.message = f"No JSON files found in {source_message} to import." - print(stats.message) - return stats - - print(f"Found {total_files} JSON files to process from {source_message}.") - - with transaction.atomic(): - stats_accumulator = { - "newly_imported_count": 0, - "updated_count": 0, - "skipped_count": 0, - } - - for idx, (file_name, data) in enumerate(all_files_data): - error_tracking = {"file_name": file_name, "card_id": "N/A"} - - print(f"Processing file: {file_name} ({idx + 1}/{total_files})") - - if not data: - print(f"Skipping empty file: {file_name}") - continue - - stats.files_processed_count += 1 - - set_name_from_file = os.path.splitext(file_name)[0] - parsed_set_name, parsed_set_id = parse_set_details(set_name_from_file) - - if not parsed_set_id: - raise ValueError( - f"Could not parse set ID from file name '{file_name}'." - ) - - card_set_defaults = {"name": parsed_set_name, "file_name": file_name} - card_set, card_set_created = CardSet.objects.language( - "en" - ).update_or_create(id=parsed_set_id, defaults=card_set_defaults) - - # If the card set color mapping exists and is different, update it. - color_mapping = color_mappings.get(card_set.id) - if color_mapping and card_set.hex_color != color_mapping.hex_color: - card_set.hex_color = color_mapping.hex_color - card_set.save() - - for card_data_item in data: - # print("Processing card: ", card_data_item["id"]) # This is very verbose - _process_single_card_data( - card_data_item, - card_set, - stats_accumulator, - error_tracking, - rarity_mappings, - ) - - print(f"Finished processing file: {file_name}") - - stats.newly_imported_count = stats_accumulator["newly_imported_count"] - stats.updated_count = stats_accumulator["updated_count"] - stats.skipped_count = stats_accumulator["skipped_count"] - - stats.message = ( - f"Import completed successfully. Processed {stats.files_processed_count} files. " - f"Imported {stats.newly_imported_count} new cards. " - f"Updated {stats.updated_count} existing cards. " - f"Skipped {stats.skipped_count} unchanged cards." - ) - print("Committing transaction.") - transaction.on_commit(lambda: print(stats.message)) - return stats - - except requests.exceptions.RequestException as e: - # Handle network-related errors for the download - stats.has_error = True - stats.message = f"Failed to download card data from GitHub: {e}" - print(stats.message) - return stats - - except Exception as e: - # 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)}" - stats.has_error = True - stats.message = ( - f"Import HALTED. All changes rolled back. Reason: {error_detail}" - ) - print(stats.message) - return stats - - -@admin.register(CardSet) -class CardSetAdmin(TranslatableAdmin): - list_display = ("id", "name", "file_name", "hex_color") - search_fields = ("translations__name",) - readonly_fields = ("id", "file_name", "created_at", "updated_at", "deleted_at") - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related("translations") - - -@admin.register(Pack) -class PackAdmin(TranslatableAdmin): - list_display = ("id", "full_name", "name", "cardset") - list_filter = ("cardset",) - search_fields = ("translations__name", "translations__full_name") - readonly_fields = ("id", "created_at", "updated_at") - - def get_queryset(self, request): - return ( - super() - .get_queryset(request) - .select_related("cardset") - .prefetch_related("translations", "cardset__translations") - ) - - -@admin.register(Energy) -class EnergyAdmin(TranslatableAdmin): - list_display = ("id", "name") - search_fields = ("translations__name",) - readonly_fields = ("id", "created_at", "updated_at", "deleted_at") - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related("translations") - - -@admin.register(Attack) -class AttackAdmin(TranslatableAdmin): - list_display = ("id", "name", "damage", "effect") - search_fields = ("translations__name",) - readonly_fields = ("id", "created_at", "updated_at", "deleted_at") - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related("translations") - - -@admin.register(Ability) -class AbilityAdmin(TranslatableAdmin): - list_display = ("id", "name", "effect") - search_fields = ("translations__name",) - readonly_fields = ("id", "created_at", "updated_at") - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related("translations") - - -@admin.register(Rarity) -class RarityAdmin(TranslatableAdmin): - list_display = ("id", "name", "icon", "level") - search_fields = ("translations__name",) - readonly_fields = ("id", "created_at", "updated_at", "deleted_at") - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related("translations") - - -@admin.register(CardType) -class CardTypeAdmin(TranslatableAdmin): - list_display = ("id", "name", "subtype") - search_fields = ("translations__name", "translations__subtype") - readonly_fields = ("id", "created_at", "updated_at", "deleted_at") - - def get_queryset(self, request): - return super().get_queryset(request).prefetch_related("translations") - - -@admin.register(Card) -class CardAdmin(TranslatableAdmin): - list_display = ( - "id", - "cardnum", - "name", - "cardset", - "card_type", - "rarity", - "health", - "pkmn_type", - ) - list_filter = ( - ("cardset", 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.register(RarityMapping) -class RarityMappingAdmin(admin.ModelAdmin): - list_display = ( - "original_name", - "mapped_name", - "icon", - "level", - "created_at", - "updated_at", - "deleted_at", - ) - search_fields = ("original_name", "mapped_name") - list_filter = ("level",) - readonly_fields = ("created_at", "updated_at", "deleted_at") - - -@admin.register(CardSetColorMapping) -class CardSetColorMappingAdmin(admin.ModelAdmin): - list_display = ( - "cardset_id", - "hex_color", - "created_at", - "updated_at", - "deleted_at", - ) - search_fields = ("cardset_id", "hex_color") - readonly_fields = ("created_at", "updated_at", "deleted_at") - - -def get_admin_urls(urls): - def importer_view(request): - context = { - "title": "Card Importer", - "site_header": admin.site.site_header, - "site_title": admin.site.site_title, - "index_title": admin.site.index_title, - "has_permission": admin.site.has_permission(request), - "app_label": "cards", - } - if request.method == "POST": - result = perform_card_import_logic() - - if result.has_error: - message = result.message - if result.files_processed_count > 0: - message += ( - f" Files attempted before halt: {result.files_processed_count}." - ) - messages.error(request, message) - else: - messages.success(request, result.message) - - return HttpResponseRedirect(request.path_info) - - return render(request, "admin/cards/importer_status.html", context) - - custom_urls = [ - path( - "cards/import/", - admin.site.admin_view(importer_view), - name="cards_full_importer", - ), - ] - return custom_urls + urls - - -original_get_urls = admin.site.get_urls - - -def new_get_urls(): - urls = original_get_urls() - return get_admin_urls(urls) - - -admin.site.get_urls = new_get_urls - - -# Restore admin sidebar link for the importer -original_get_app_list = admin.site.get_app_list - - -def new_get_app_list(request): - app_list = original_get_app_list(request) - for app in app_list: - if app.get("app_label") == "cards": - app["models"].insert( - 0, - { - "name": "Full Card Importer", - "object_name": "fullcardimporter", - "admin_url": reverse("admin:cards_full_importer"), - "view_only": True, - }, - ) - break - return app_list - - -admin.site.get_app_list = new_get_app_list +admin.site.register(Deck) +admin.site.register(Card) +admin.site.register(DeckNameTranslation) +admin.site.register(CardNameTranslation) \ No newline at end of file diff --git a/src/pkmntrade_club/cards/migrations/0001_initial.py b/src/pkmntrade_club/cards/migrations/0001_initial.py index d25a66e..03a0132 100644 --- a/src/pkmntrade_club/cards/migrations/0001_initial.py +++ b/src/pkmntrade_club/cards/migrations/0001_initial.py @@ -1,8 +1,6 @@ -# Generated by Django 5.1 on 2025-06-20 07:14 +# Generated by Django 5.1 on 2025-05-10 01:22 import django.db.models.deletion -import parler.fields -import parler.models from django.db import migrations, models @@ -10,762 +8,64 @@ class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + ] operations = [ migrations.CreateModel( - name="Ability", + name='Card', 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)), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('cardset', models.CharField(max_length=32)), + ('cardnum', models.IntegerField()), + ('style', models.CharField(max_length=128)), + ('rarity_icon', models.CharField(max_length=12)), + ('rarity_level', models.IntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ], - options={ - "verbose_name": "Ability", - "verbose_name_plural": "Abilities", - }, - bases=(parler.models.TranslatableModelMixin, models.Model), ), migrations.CreateModel( - name="Attack", + name='Deck', fields=[ - ("id", models.AutoField(primary_key=True, serialize=False)), - ( - "damage", - models.CharField( - blank=True, - help_text="Damage string, e.g., '40', '20x', '80+'.", - max_length=10, - null=True, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("deleted_at", models.DateTimeField(blank=True, null=True)), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('hex_color', models.CharField(max_length=9)), + ('cardset', models.CharField(max_length=8)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ], - options={ - "verbose_name": "Attack", - "verbose_name_plural": "Attacks", - }, - bases=(parler.models.TranslatableModelMixin, models.Model), ), migrations.CreateModel( - name="CardSet", + name='CardNameTranslation', fields=[ - ( - "id", - models.CharField( - help_text="The ID for the set, e.g., 'A1', 'A1a'.", - max_length=3, - primary_key=True, - serialize=False, - ), - ), - ( - "file_name", - models.CharField( - help_text="Original name of the JSON file, e.g., 'a1-genetic-apex.json'.", - max_length=32, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("deleted_at", models.DateTimeField(blank=True, null=True)), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('language', models.CharField(max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.card')), ], - options={ - "verbose_name": "Card Set", - "verbose_name_plural": "Card Sets", - }, - bases=(parler.models.TranslatableModelMixin, models.Model), - ), - migrations.CreateModel( - name="CardSetColorMapping", - fields=[ - ("id", models.AutoField(primary_key=True, serialize=False)), - ( - "cardset_id", - models.CharField( - help_text="The cardset ID to match (e.g., 'A1').", - max_length=10, - unique=True, - ), - ), - ( - "hex_color", - models.CharField( - help_text="The hex color code to use for this cardset.", - max_length=9, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("deleted_at", models.DateTimeField(blank=True, null=True)), - ], - options={ - "verbose_name": "Cardset Color Mapping", - "verbose_name_plural": "Cardset Color Mappings", - "ordering": ["cardset_id"], - }, - ), - migrations.CreateModel( - name="CardType", - fields=[ - ("id", models.AutoField(primary_key=True, serialize=False)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("deleted_at", models.DateTimeField(blank=True, null=True)), - ], - options={ - "verbose_name": "Card Type", - "verbose_name_plural": "Card Types", - }, - bases=(parler.models.TranslatableModelMixin, models.Model), - ), - migrations.CreateModel( - name="Energy", - fields=[ - ("id", models.AutoField(primary_key=True, serialize=False)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("deleted_at", models.DateTimeField(blank=True, null=True)), - ], - options={ - "verbose_name": "Energy", - "verbose_name_plural": "Energies", - }, - bases=(parler.models.TranslatableModelMixin, models.Model), - ), - migrations.CreateModel( - name="Rarity", - fields=[ - ("id", models.AutoField(primary_key=True, serialize=False)), - ("icon", models.CharField(max_length=12)), - ("level", models.PositiveIntegerField()), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("deleted_at", models.DateTimeField(blank=True, null=True)), - ], - options={ - "verbose_name": "Rarity", - "verbose_name_plural": "Rarities", - }, - bases=(parler.models.TranslatableModelMixin, models.Model), - ), - migrations.CreateModel( - name="RarityMapping", - fields=[ - ("id", models.AutoField(primary_key=True, serialize=False)), - ( - "original_name", - models.CharField( - help_text="The rarity name as it appears in the import source (e.g., JSON file).", - max_length=255, - unique=True, - ), - ), - ( - "mapped_name", - models.CharField( - help_text="The standardized rarity name to use in the system.", - max_length=32, - ), - ), - ( - "icon", - models.CharField( - help_text="The icon associated with this rarity.", max_length=12 - ), - ), - ( - "level", - models.PositiveIntegerField( - help_text="The level or order of this rarity." - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("deleted_at", models.DateTimeField(blank=True, null=True)), - ], - options={ - "verbose_name": "Rarity Mapping", - "verbose_name_plural": "Rarity Mappings", - "ordering": ["original_name"], - }, - ), - migrations.CreateModel( - name="AttackCost", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "quantity", - models.PositiveIntegerField( - default=1, - help_text="Quantity of this energy type required for the attack.", - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("deleted_at", models.DateTimeField(blank=True, null=True)), - ( - "attack", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="cards.attack" - ), - ), - ( - "energy", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="cards.energy" - ), - ), - ], - options={ - "verbose_name": "Attack Cost", - "verbose_name_plural": "Attack Costs", - "unique_together": {("attack", "energy")}, - }, ), migrations.AddField( - model_name="attack", - name="energy_cost", - field=models.ManyToManyField( - related_name="attacks", through="cards.AttackCost", to="cards.energy" - ), + model_name='card', + name='decks', + field=models.ManyToManyField(to='cards.deck'), ), migrations.CreateModel( - name="Pack", + name='DeckNameTranslation', 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)), - ( - "cardset", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="packs", - to="cards.cardset", - ), - ), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('language', models.CharField(max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deck', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.deck')), ], - 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, - ), - ), - ("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, - ), - ), - ( - "hex_color", - models.CharField( - blank=True, - help_text="The hex color code associated with this card set.", - max_length=9, - null=True, - ), - ), - ( - "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), + migrations.AlterUniqueTogether( + name='card', + unique_together={('cardset', 'cardnum')}, ), ] diff --git a/src/pkmntrade_club/cards/mixins.py b/src/pkmntrade_club/cards/mixins.py index 18d887c..575d62c 100644 --- a/src/pkmntrade_club/cards/mixins.py +++ b/src/pkmntrade_club/cards/mixins.py @@ -1,12 +1,10 @@ from math import ceil - class ReusablePaginationMixin: """ A mixin that encapsulates reusable pagination logic. Use in Django ListViews to generate custom pagination context. """ - per_page = 10 # Default; can be overridden in your view. def paginate_data(self, data_list, page_number): @@ -41,4 +39,4 @@ class ReusablePaginationMixin: "next_page": page_number + 1 if page_number < num_pages else num_pages, "paginator": {"num_pages": num_pages}, } - return items, pagination_context + return items, pagination_context \ No newline at end of file diff --git a/src/pkmntrade_club/cards/models.py b/src/pkmntrade_club/cards/models.py index 92c0601..9f014ea 100644 --- a/src/pkmntrade_club/cards/models.py +++ b/src/pkmntrade_club/cards/models.py @@ -1,383 +1,53 @@ from django.db import models -from django.utils.translation import gettext_lazy as _ -from parler.managers import TranslatableManager -from parler.models import TranslatableModel, TranslatedFields +from django.db.models import Prefetch +from django.apps import apps +class DeckNameTranslation(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=64) + deck = models.ForeignKey("Deck", on_delete=models.PROTECT, related_name='name_translations') + language = models.CharField(max_length=64) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) -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", - ) - ) + def __str__(self): + return self.name +class CardNameTranslation(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=64) + card = models.ForeignKey("Card", on_delete=models.PROTECT, related_name='name_translations') + language = models.CharField(max_length=64) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) -class CardSet(TranslatableModel): - """ - Represents a single JSON file from the repository, considered a 'cardset', e.g., "Genetic Apex (A1)", - or collection of cards. Each cardset file belongs to a specific CardSet and language. - """ + def __str__(self): + return self.name +class Deck(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=64) + hex_color = models.CharField(max_length=9) + cardset = models.CharField(max_length=8) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) - translations = TranslatedFields( - name=models.CharField( - max_length=32, - help_text=_("The full name of the set, e.g., 'Genetic Apex'."), - ), - hex_color=models.CharField( - max_length=9, - null=True, - blank=True, - help_text=_("The hex color code associated with this card set."), - ), - ) - id = models.CharField( - max_length=3, - primary_key=True, - help_text=_("The ID for the set, e.g., 'A1', 'A1a'."), - ) - file_name = models.CharField( - max_length=32, - help_text=_("Original name of the JSON file, e.g., 'a1-genetic-apex.json'."), - ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - deleted_at = models.DateTimeField(null=True, blank=True) + def __str__(self): + return self.name - class Meta: - verbose_name = _("Card Set") - verbose_name_plural = _("Card Sets") +class Card(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=64) + decks = models.ManyToManyField("Deck") + cardset = models.CharField(max_length=32) + cardnum = models.IntegerField() + style = models.CharField(max_length=128) + rarity_icon = models.CharField(max_length=12) + rarity_level = models.IntegerField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) - def __str__(self): - return f"{self.id} - {self.name}" + class Meta: + unique_together = ('cardset', 'cardnum') - -class Pack(TranslatableModel): - """ - Represents a single pack that is part of a cardset. E.g., "Genetic Apex: Mewtwo" - """ - - translations = TranslatedFields( - full_name=models.CharField( - max_length=32, - help_text=_("The full name of the pack, e.g., 'Genetic Apex: Mewtwo'."), - ), - name=models.CharField( - max_length=32, help_text=_("The pack name itself, e.g., 'Mewtwo'.") - ), - ) - id = models.AutoField(primary_key=True) - cardset = models.ForeignKey(CardSet, on_delete=models.CASCADE, related_name="packs") - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - deleted_at = models.DateTimeField(null=True, blank=True) - - class Meta: - verbose_name = _("Pack") - verbose_name_plural = _("Packs") - - def __str__(self): - return f"{self.full_name}" - - -class Energy(TranslatableModel): - """ - A type a Pokémon card can have. - """ - - translations = TranslatedFields( - name=models.CharField(max_length=32, help_text=_("The name of the energy.")) - ) - id = models.AutoField(primary_key=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - deleted_at = models.DateTimeField(null=True, blank=True) - - class Meta: - verbose_name = _("Energy") - verbose_name_plural = _("Energies") - - def __str__(self): - return f"{self.name}" - - -class AttackCost(models.Model): - """ - Intermediary model to store the quantity of each energy type for an attack's cost. - """ - - attack = models.ForeignKey("Attack", on_delete=models.CASCADE) - energy = models.ForeignKey(Energy, on_delete=models.CASCADE) - 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(null=True, blank=True) - - class Meta: - verbose_name = _("Attack Cost") - verbose_name_plural = _("Attack Costs") - unique_together = ("attack", "energy") - - def __str__(self): - return f"{self.attack.name} {_('requires')} {self.quantity} {self.energy.name}" - - -class Attack(TranslatableModel): - """ - An attack a Pokémon card can have. - """ - - translations = TranslatedFields( - name=models.CharField(max_length=32, help_text=_("The name of the attack.")), - effect=models.TextField(help_text=_("Description of the attack's effect.")), - ) - id = models.AutoField(primary_key=True) - damage = models.CharField( - max_length=10, - null=True, - blank=True, - help_text=_("Damage string, e.g., '40', '20x', '80+'."), - ) - energy_cost = models.ManyToManyField( - Energy, through=AttackCost, related_name="attacks" - ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - deleted_at = models.DateTimeField(null=True, blank=True) - - class Meta: - verbose_name = _("Attack") - verbose_name_plural = _("Attacks") - - def __str__(self): - return f"{self.name}" - - -class Ability(TranslatableModel): - """ - An ability a Pokémon card can have. - """ - - translations = TranslatedFields( - name=models.CharField(max_length=32, help_text=_("The name of the ability.")), - effect=models.TextField(help_text=_("Description of the ability's effect.")), - ) - id = models.AutoField(primary_key=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - deleted_at = models.DateTimeField(null=True, blank=True) - - class Meta: - verbose_name = _("Ability") - verbose_name_plural = _("Abilities") - - def __str__(self): - return f"{self.name}" - - -class Rarity(TranslatableModel): - """ - A rarity a Pokémon card can have. - """ - - translations = TranslatedFields( - name=models.CharField(max_length=32, help_text=_("The name of the rarity.")) - ) - id = models.AutoField(primary_key=True) - 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(null=True, blank=True) - - class Meta: - verbose_name = _("Rarity") - verbose_name_plural = _("Rarities") - - def __str__(self): - return f"{self.name}" - - -class CardType(TranslatableModel): - """ - A type a Pokémon card can have. - """ - - translations = TranslatedFields( - name=models.CharField(max_length=32, help_text=_("The name of the card type.")), - subtype=models.CharField( - max_length=32, - null=True, - blank=True, - help_text=_("The subtype of the card type."), - ), - ) - id = models.AutoField(primary_key=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - deleted_at = models.DateTimeField(null=True, blank=True) - - class Meta: - verbose_name = _("Card Type") - verbose_name_plural = _("Card Types") - - def __str__(self): - return f"{self.name}" - - -class Card(TranslatableModel): - """ - Represents a single, unique digital printing of a Pokémon card. - """ - - objects = CardManager() - - translations = TranslatedFields( - name=models.CharField(max_length=32, help_text=_("The name of the card.")), - evolves_from_name=models.CharField( - max_length=32, - null=True, - blank=True, - help_text=_("Name of the Pokémon this card evolves from."), - ), - ) - cardnum = models.AutoField(primary_key=True) - id = models.CharField( - max_length=10, - db_index=True, - help_text=_( - "The unique ID from the JSON source, cardset-cardnum (e.g., 'a1-001')." - ), - ) - checksum = models.CharField( - max_length=64, - null=True, - blank=True, - help_text=_("SHA256 checksum of the card data."), - db_index=True, - ) - health = models.PositiveIntegerField( - null=True, blank=True, help_text=_("HP of the Pokémon.") - ) - - retreat_cost = models.PositiveIntegerField( - null=True, blank=True, help_text=_("The number of retreat cost for the card.") - ) - weakness_type = models.ForeignKey( - Energy, - on_delete=models.CASCADE, - related_name="cards_weakness_type", - null=True, - blank=True, - ) - pkmn_type = models.ForeignKey( - Energy, - on_delete=models.CASCADE, - related_name="cards_pkmn_type", - null=True, - blank=True, - ) - card_type = models.ForeignKey( - CardType, on_delete=models.CASCADE, related_name="cards" - ) - packs = models.ManyToManyField(Pack, related_name="cards") - cardset = models.ForeignKey(CardSet, on_delete=models.CASCADE, related_name="cards") - abilities = models.ManyToManyField(Ability, blank=True, related_name="cards") - attacks = models.ManyToManyField(Attack, related_name="cards") - rarity = models.ForeignKey(Rarity, on_delete=models.CASCADE, related_name="cards") - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - deleted_at = models.DateTimeField(null=True, blank=True) - - class Meta: - verbose_name = _("Card") - verbose_name_plural = _("Cards") - - def __str__(self): - return f"{self.id} {self.name}" - - -class RarityMapping(models.Model): - """ - Maps an original rarity name from the import source to a standardized - rarity name, icon, and level to be used in the system. - """ - - id = models.AutoField(primary_key=True) - original_name = models.CharField( - max_length=255, - unique=True, - help_text=_( - "The rarity name as it appears in the import source (e.g., JSON file)." - ), - ) - mapped_name = models.CharField( - max_length=32, help_text=_("The standardized rarity name to use in the system.") - ) - icon = models.CharField( - max_length=12, help_text=_("The icon associated with this rarity.") - ) - 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(null=True, blank=True) - - class Meta: - verbose_name = _("Rarity Mapping") - verbose_name_plural = _("Rarity Mappings") - ordering = ["original_name"] - - def __str__(self): - return f"'{self.original_name}' -> '{self.mapped_name}' (L{self.level}, {self.icon})" - - -class CardSetColorMapping(models.Model): - """ - Maps a cardset ID to a hex color code. This is used to pre-map cardset - colors when an original hex color is not available from the import source. - """ - - id = models.AutoField(primary_key=True) - cardset_id = models.CharField( - max_length=10, - unique=True, - help_text=_("The cardset ID to match (e.g., 'A1')."), - ) - hex_color = models.CharField( - max_length=9, help_text=_("The hex color code to use for this cardset.") - ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - deleted_at = models.DateTimeField(null=True, blank=True) - - class Meta: - verbose_name = _("Cardset Color Mapping") - verbose_name_plural = _("Cardset Color Mappings") - ordering = ["cardset_id"] - - def __str__(self): - return f"'{self.cardset_id}' -> '{self.hex_color}'" + def __str__(self): + return f"{self.name} ({self.cardset} #{self.cardnum})" \ No newline at end of file diff --git a/src/pkmntrade_club/cards/signals.py b/src/pkmntrade_club/cards/signals.py index ac03177..dd87f2a 100644 --- a/src/pkmntrade_club/cards/signals.py +++ b/src/pkmntrade_club/cards/signals.py @@ -1,12 +1,16 @@ +from django.db.models.signals import m2m_changed +from django.dispatch import receiver +from .models import Card + def color_is_dark(bg_color): """ Determine if a given hexadecimal color is dark. This function accepts a 6-digit hex color string (with or without a leading '#'). It calculates the brightness using the formula: - + brightness = (0.299 * red) + (0.587 * green) + (0.114 * blue) - + A brightness value less than or equal to 186 indicates that the color is dark. Args: @@ -16,7 +20,7 @@ def color_is_dark(bg_color): bool: True if the color is dark (brightness <= 186), False otherwise. """ # Remove the leading '#' if it exists. - color = bg_color[1:7] if bg_color[0] == "#" else bg_color + color = bg_color[1:7] if bg_color[0] == '#' else bg_color # Convert the hex color components to integers. r = int(color[0:2], 16) @@ -25,5 +29,23 @@ def color_is_dark(bg_color): # Compute brightness based on weighted RGB values. brightness = (r * 0.299) + (g * 0.587) + (b * 0.114) - + return brightness <= 200 + +@receiver(m2m_changed, sender=Card.decks.through) +def update_card_style(sender, instance, action, **kwargs): + if action == "post_add": + decks = instance.decks.all() + num_decks = decks.count() + if num_decks == 1: + instance.style = "background-color: " + decks.first().hex_color + ";" + elif num_decks >= 2: + hex_colors = [deck.hex_color for deck in decks] + instance.style = f"background: linear-gradient(to right, {', '.join(hex_colors)});" + else: + instance.style = "background: linear-gradient(to right, #AAAAAA, #AAAAAA, #AAAAAA);" + if not color_is_dark(decks.first().hex_color): + instance.style += "color: var(--color-gray-700); text-shadow: 0 0 0 var(--color-gray-700);" + else: + instance.style += "text-shadow: 0 0 0 #fff;" + instance.save(update_fields=["style"]) \ No newline at end of file diff --git a/src/pkmntrade_club/cards/templatetags/card_badge.py b/src/pkmntrade_club/cards/templatetags/card_badge.py index c626cd9..ace3b17 100644 --- a/src/pkmntrade_club/cards/templatetags/card_badge.py +++ b/src/pkmntrade_club/cards/templatetags/card_badge.py @@ -1,75 +1,46 @@ from django import template from django.conf import settings from django.template.loader import render_to_string -from django.urls import reverse_lazy from django.utils.safestring import mark_safe +from django.urls import reverse_lazy register = template.Library() - -def _get_gradient_style(hex_color): - """ - Generates a gradient style from a single hex color. - """ - if not hex_color: - return "" - - try: - hex_color = hex_color.lstrip("#") - r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) - - # Create a slightly darker shade for the gradient - darker_r = max(0, r - 30) - darker_g = max(0, g - 30) - darker_b = max(0, b - 30) - darker_color = f"#{darker_r:02x}{darker_g:02x}{darker_b:02x}" - - return f"background-image: linear-gradient(to bottom right, #{hex_color}, {darker_color});" - except (ValueError, IndexError): - return "" - - @register.inclusion_tag("templatetags/card_badge.html", takes_context=True) -def card_badge(context, card, quantity=None, expanded=False, clickable=True): +def card_badge(context, card, quantity=None, expanded=False): """ Renders a card badge. """ - url = reverse_lazy("cards:detail", args=[card.pk]) - style = _get_gradient_style(card.cardset.hex_color) + url = reverse_lazy('cards:card_detail', args=[card.pk]) tag_context = { - "quantity": quantity, - "style": style, - "name": card.name, - "rarity": card.rarity.icon, - "cardset": card.cardset.id, - "expanded": expanded, - "cache_key": f"card_badge_{card.pk}_{quantity}_{expanded}_{card.cardset.hex_color}", - "url": url, - "clickable": clickable, - "closeable": True, - "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT, + 'quantity': quantity, + 'style': card.style, + 'name': card.name, + 'rarity': card.rarity_icon, + 'cardset': card.cardset, + 'expanded': expanded, + 'cache_key': f'card_badge_{card.pk}_{quantity}_{expanded}', + 'url': url, } context.update(tag_context) return context - @register.filter def card_badge_inline(card, quantity=None): """ Renders an inline card badge by directly rendering the template. """ - url = reverse_lazy("cards:detail", args=[card.pk]) - style = _get_gradient_style(card.cardset.hex_color) + url = reverse_lazy('cards:card_detail', args=[card.pk]) tag_context = { - "quantity": quantity, - "style": style, - "name": card.name, - "rarity": card.rarity, - "cardset": card.cardset, - "expanded": True, - "cache_key": f"card_badge_{card.pk}_{quantity}_{True}_{card.cardset.hex_color}", - "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT, - "url": url, + 'quantity': quantity, + 'style': card.style, + 'name': card.name, + 'rarity': card.rarity_icon, + 'cardset': card.cardset, + 'expanded': True, + 'cache_key': f'card_badge_{card.pk}_{quantity}_{True}', + 'CACHE_TIMEOUT': settings.CACHE_TIMEOUT, + 'url': url, } html = render_to_string("templatetags/card_badge.html", tag_context) - return mark_safe(html) + return mark_safe(html) \ No newline at end of file diff --git a/src/pkmntrade_club/cards/templatetags/card_multiselect.py b/src/pkmntrade_club/cards/templatetags/card_multiselect.py index e0d0fc7..dc03500 100644 --- a/src/pkmntrade_club/cards/templatetags/card_multiselect.py +++ b/src/pkmntrade_club/cards/templatetags/card_multiselect.py @@ -1,87 +1,72 @@ -import json - +import uuid from django import template -from django.conf import settings -from django.db.models import QuerySet -from django.template.loader import render_to_string -from django.urls import reverse - from pkmntrade_club.cards.models import Card - +from django.db.models.query import QuerySet +import json +import hashlib +import logging register = template.Library() - @register.filter def get_item(dictionary, key): """Allows accessing dictionary items using a variable key in templates.""" return dictionary.get(key) - @register.simple_tag def fetch_all_cards(): """Simple tag to fetch all Card objects.""" - return Card.objects.with_details().order_by("id").all() + return Card.objects.order_by('pk').all() - -@register.inclusion_tag("templatetags/card_multiselect.html", takes_context=True) -def card_multiselect( - context, field_name, label, placeholder, cards=None, selected_values=None -): +@register.inclusion_tag('templatetags/card_multiselect.html', takes_context=True) +def card_multiselect(context, field_name, label, placeholder, cards=None, selected_values=None): """ - Renders a card multiselect widget with client-side searching. - - For the JS-driven component, it prepares initial selected cards. - For the non-JS fallback, it accepts a `cards` queryset from a - server-side search. + Prepares context for rendering a card multiselect input. + Database querying and rendering are handled within the template's cache block. """ if selected_values is None: selected_values = [] - # Fetch full objects for any pre-selected cards for initial display. - initial_selected_cards = [] - if selected_values: - card_ids = [str(val) for val in selected_values] - initial_selected_cards = list( - Card.objects.with_details().filter(id__in=card_ids) - ) + selected_cards = {} + for val in selected_values: + parts = str(val).split(':') + if len(parts) >= 1 and parts[0]: + card_id = parts[0] + quantity = parts[1] if len(parts) > 1 else 1 + selected_cards[str(card_id)] = quantity - # `cards` is for the non-JS fallback search result. - non_js_search_results = cards if isinstance(cards, QuerySet) else [] + effective_field_name = field_name if field_name is not None else 'card_multiselect' + effective_label = label if label is not None else 'Card' + effective_placeholder = placeholder if placeholder is not None else 'Select Cards' - initial_cards_data = [] - for card in initial_selected_cards: - badge_context = { - "card": card, - "quantity": None, - "expanded": False, - "url": reverse("cards:detail", args=[card.pk]), - "style": card.style, - "name": card.name, - "rarity": card.rarity.icon if card.rarity else "", - "cardset": card.cardset.id, - "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT, - } - initial_cards_data.append( - { - "id": str(card.id), - "name": card.name, - "set_name": card.cardset.name, - "html": render_to_string("templatetags/card_badge.html", badge_context), - } - ) + selected_cards_key_part = json.dumps(selected_cards, sort_keys=True) - initial_selected_cards_json = json.dumps(initial_cards_data) + has_passed_cards = isinstance(cards, QuerySet) - context.update( - { - "field_name": field_name, - "field_id": f"id_{field_name}", - "label": label, - "placeholder": placeholder, - "initial_selected_cards": initial_selected_cards, - "initial_selected_cards_json": initial_selected_cards_json, - "non_js_search_results": non_js_search_results, - "has_non_js_results": bool(non_js_search_results), - } - ) - return context + if has_passed_cards: + try: + query_string = str(cards.query) + passed_cards_identifier = hashlib.sha256(query_string.encode('utf-8')).hexdigest() + except Exception as e: + logging.warning(f"Could not generate query hash for card_multiselect. Error: {e}") + passed_cards_identifier = 'specific_qs_fallback_' + str(uuid.uuid4()) + else: + passed_cards_identifier = 'all_cards' + + # Define the variables specific to this tag + tag_specific_context = { + 'field_name': effective_field_name, + 'field_id': effective_field_name, + 'label': effective_label, + 'placeholder': effective_placeholder, + 'passed_cards': cards if has_passed_cards else None, + 'has_passed_cards': has_passed_cards, + 'selected_cards': selected_cards, + 'selected_cards_key_part': selected_cards_key_part, + 'passed_cards_identifier': passed_cards_identifier, + } + + # Update the original context with the tag-specific variables + # This preserves CACHE_TIMEOUT and other parent context variables + context.update(tag_specific_context) + + return context # Return the MODIFIED original context \ No newline at end of file diff --git a/src/pkmntrade_club/cards/tests.py b/src/pkmntrade_club/cards/tests.py index 85219a2..75e73b8 100644 --- a/src/pkmntrade_club/cards/tests.py +++ b/src/pkmntrade_club/cards/tests.py @@ -6,21 +6,11 @@ from django.urls import reverse from django.utils import timezone from pkmntrade_club.accounts.models import CustomUser, FriendCode -from pkmntrade_club.cards.models import ( - Card, - Deck, - DeckNameTranslation, - CardNameTranslation, -) -from pkmntrade_club.trades.models import ( - TradeOffer, - TradeOfferHaveCard, - TradeOfferWantCard, -) -from pkmntrade_club.cards.templatetags import card_multiselect +from pkmntrade_club.cards.models import Card, Deck, DeckNameTranslation, CardNameTranslation +from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard +from pkmntrade_club.cards.templatetags import card_badge, card_multiselect from tests.utils.rarity import RARITY_MAPPING - class CardsModelsTests(TestCase): def setUp(self): self.deck = Deck.objects.create( @@ -32,7 +22,7 @@ class CardsModelsTests(TestCase): cardnum=1, style="default", rarity_icon=RARITY_MAPPING[1], - rarity_level=1, + rarity_level=1 ) # Establish many-to-many relationship. self.card.decks.add(self.deck) @@ -54,8 +44,7 @@ class CardsModelsTests(TestCase): card_translation = CardNameTranslation.objects.create( name="Card Translated", card=self.card, language="en" ) - self.assertEqual(str(card_translation), "Card Translated") - + self.assertEqual(str(card_translation), "Card Translated") class CardTemplatetagsTests(TestCase): def setUp(self): @@ -66,12 +55,12 @@ class CardTemplatetagsTests(TestCase): cardnum=2, style="background: green;", rarity_icon="☆", - rarity_level=2, + rarity_level=2 ) def test_card_badge_inclusion_tag(self): """Test the card_badge inclusion tag renders correctly.""" - template_str = "{% load card_badge %}{% card_badge card quantity=3 %}" + template_str = '{% load card_badge %}{% card_badge card quantity=3 %}' t = Template(template_str) c = Context({"card": self.card}) rendered = t.render(c) @@ -82,7 +71,7 @@ class CardTemplatetagsTests(TestCase): def test_card_badge_inline_filter(self): """Test the card_badge_inline filter returns safe HTML with correct data.""" - template_str = "{% load card_badge %}{{ card|card_badge_inline:5 }}" + template_str = '{% load card_badge %}{{ card|card_badge_inline:5 }}' t = Template(template_str) c = Context({"card": self.card}) rendered = t.render(c) @@ -153,8 +142,7 @@ class CardTemplatetagsTests(TestCase): selected_values=[], ) # Verify that the context's cards match those in the database. - self.assertEqual(list(context["cards"]), default_cards) - + self.assertEqual(list(context["cards"]), default_cards) class CardsViewsTests(TestCase): def setUp(self): @@ -173,7 +161,7 @@ class CardsViewsTests(TestCase): cardnum=1, style="default", rarity_icon=RARITY_MAPPING[1], - rarity_level=1, + rarity_level=1 ) def test_card_detail_view_context(self): @@ -210,7 +198,9 @@ class CardsViewsTests(TestCase): Helper method to create a trade offer for the 'have' side with a custom updated_at. """ offer = TradeOffer.objects.create(initiated_by=self.friendcode) - TradeOfferHaveCard.objects.create(trade_offer=offer, card=self.card, quantity=1) + TradeOfferHaveCard.objects.create( + trade_offer=offer, card=self.card, quantity=1 + ) # Adjust updated_at so that ordering can be tested. new_time = timezone.now() + timedelta(minutes=updated_delta_minutes) TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time) @@ -222,7 +212,9 @@ class CardsViewsTests(TestCase): Helper method to create a trade offer for the 'want' side with a custom updated_at. """ offer = TradeOffer.objects.create(initiated_by=self.friendcode) - TradeOfferWantCard.objects.create(trade_offer=offer, card=self.card, quantity=1) + TradeOfferWantCard.objects.create( + trade_offer=offer, card=self.card, quantity=1 + ) new_time = timezone.now() + timedelta(minutes=updated_delta_minutes) TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time) offer.refresh_from_db() @@ -293,4 +285,4 @@ class CardsViewsTests(TestCase): trade_offers_oldest = response_oldest.context.get("trade_offers") self.assertEqual(len(trade_offers_oldest), 2) self.assertEqual(trade_offers_oldest[0].pk, offer1.pk) - self.assertEqual(trade_offers_oldest[1].pk, offer2.pk) + self.assertEqual(trade_offers_oldest[1].pk, offer2.pk) \ No newline at end of file diff --git a/src/pkmntrade_club/cards/urls.py b/src/pkmntrade_club/cards/urls.py index 8c0e3bf..599427f 100644 --- a/src/pkmntrade_club/cards/urls.py +++ b/src/pkmntrade_club/cards/urls.py @@ -1,27 +1,16 @@ from django.urls import path - from .views import ( CardDetailView, - CardListView, TradeOfferHaveCardListView, TradeOfferWantCardListView, - card_search, + CardListView, ) app_name = "cards" urlpatterns = [ - path("", view=CardListView.as_view(), name="list"), - path("/", view=CardDetailView.as_view(), name="detail"), - path("api/search/", card_search, name="api_search"), - path( - "/trade-offers-have/", - TradeOfferHaveCardListView.as_view(), - name="card_trade_offer_have_list", - ), - path( - "/trade-offers-want/", - TradeOfferWantCardListView.as_view(), - name="card_trade_offer_want_list", - ), + path('', CardListView.as_view(), name='card_list'), + path('/', CardDetailView.as_view(), name='card_detail'), + path('/trade-offers-have/', TradeOfferHaveCardListView.as_view(), name='card_trade_offer_have_list'), + path('/trade-offers-want/', TradeOfferWantCardListView.as_view(), name='card_trade_offer_want_list'), ] diff --git a/src/pkmntrade_club/cards/views.py b/src/pkmntrade_club/cards/views.py index 515f070..08a7bb0 100644 --- a/src/pkmntrade_club/cards/views.py +++ b/src/pkmntrade_club/cards/views.py @@ -1,61 +1,30 @@ -from django.conf import settings -from django.db.models import Q -from django.http import JsonResponse -from django.shortcuts import get_object_or_404, render -from django.template.loader import render_to_string -from django.urls import reverse -from django.views import View -from django.views.generic import ( - DetailView, - ListView, -) - +from django.views.generic import TemplateView +from django.urls import reverse_lazy +from django.views.generic import UpdateView, DeleteView, CreateView, ListView, DetailView from pkmntrade_club.cards.models import Card -from pkmntrade_club.common.mixins import ReusablePaginationMixin from pkmntrade_club.trades.models import TradeOffer - +from pkmntrade_club.common.mixins import ReusablePaginationMixin +from django.views import View +from django.shortcuts import get_object_or_404, render class CardDetailView(DetailView): model = Card template_name = "cards/card_detail.html" context_object_name = "card" - def get_queryset(self): - qs = super().get_queryset() - # Prefetch all related data and translations to avoid N+1 queries in the template. - return qs.select_related( - "cardset", "rarity", "card_type", "pkmn_type", "weakness_type" - ).prefetch_related( - "translations", - "cardset__translations", - "rarity__translations", - "card_type__translations", - "pkmn_type__translations", - "weakness_type__translations", - "packs__translations", - "abilities__translations", - "attacks__translations", - "attacks__energy_cost__energy__translations", - ) - def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) card = self.get_object() # Count of trade offers where the card appears as a "have" in a trade. - context["trade_offer_have_count"] = ( - TradeOffer.objects.filter(trade_offer_have_cards__card=card) - .distinct() - .count() - ) + context['trade_offer_have_count'] = TradeOffer.objects.filter( + trade_offer_have_cards__card=card + ).distinct().count() # Count of trade offers where the card appears as a "want" in a trade. - context["trade_offer_want_count"] = ( - TradeOffer.objects.filter(trade_offer_want_cards__card=card) - .distinct() - .count() - ) + context['trade_offer_want_count'] = TradeOffer.objects.filter( + trade_offer_want_cards__card=card + ).distinct().count() return context - class TradeOfferHaveCardListView(ReusablePaginationMixin, View): def get(self, request, pk): card = get_object_or_404(Card, pk=pk) @@ -79,7 +48,6 @@ class TradeOfferHaveCardListView(ReusablePaginationMixin, View): # Render the partial template to be injected via AJAX return render(request, "trades/_trade_offer_list.html", context) - class TradeOfferWantCardListView(ReusablePaginationMixin, View): def get(self, request, pk): card = get_object_or_404(Card, pk=pk) @@ -104,8 +72,6 @@ class TradeOfferWantCardListView(ReusablePaginationMixin, View): } # Render the partial template containing the new pagination controls return render(request, "trades/_trade_offer_list.html", context) - - class CardListView(ReusablePaginationMixin, ListView): model = Card # Removed built-in pagination; using custom mixin instead @@ -118,43 +84,18 @@ class CardListView(ReusablePaginationMixin, ListView): def get_ordering(self): order = self.request.GET.get("order", "absolute") - group_by = self.request.GET.get("group_by") - - # When grouping, the ordering must match the grouping attribute. - if group_by == "cardset": - return ("cardset__name",) - elif group_by == "rarity": - # Order by level (desc) then by icon/name to keep groups together. - return "-rarity__level", "rarity__icon" - # Note: Grouping by 'pack' is complex due to M2M and would require a custom implementation. - if order == "alphabetical": return "name" elif order == "rarity": - return "-rarity__level" + return "-rarity_level" else: # absolute ordering return "id" def get_queryset(self): qs = super().get_queryset() ordering = self.get_ordering() - # Handle both single string and tuple orderings - if isinstance(ordering, tuple): - qs = qs.order_by(*ordering) - else: - qs = qs.order_by(ordering) - return ( - qs.select_related("cardset", "rarity", "card_type", "pkmn_type") - .prefetch_related( - "translations", - "cardset__translations", - "rarity__translations", - "card_type__translations", - "pkmn_type__translations", - "packs__translations", - ) - .distinct() - ) + qs = qs.order_by(ordering) + return qs.prefetch_related("decks").distinct() def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -163,60 +104,45 @@ class CardListView(ReusablePaginationMixin, ListView): context["order"] = order context["group_by"] = group_by - # Unified pagination logic for all cases. - # The complex manual grouping logic has been removed for performance. - # The template should now use the `regroup` template tag for display. - page_number = self.get_page_number() - self.per_page = 36 - queryset = self.get_queryset() - paginated_cards, pagination_context = self.paginate_data(queryset, page_number) - context["cards"] = paginated_cards - context["page_obj"] = pagination_context - context["object_list"] = queryset - return context + if group_by in ("deck", "cardset", "rarity"): + full_qs = self.get_queryset() + all_cards = list(full_qs) + flat_cards = [] + if group_by == "deck": + for card in all_cards: + for deck in card.decks.all(): + flat_cards.append({"group": deck.name, "card": card}) + flat_cards.sort(key=lambda x: x["group"].lower()) + elif group_by == "cardset": + for card in all_cards: + flat_cards.append({"group": card.cardset, "card": card}) + flat_cards.sort(key=lambda x: x["group"].lower()) + elif group_by == "rarity": + for card in all_cards: + flat_cards.append({"group": card.rarity_icon, "sort_group": card.rarity_level, "card": card}) + flat_cards.sort(key=lambda x: x["sort_group"], reverse=True) + page_number = self.get_page_number() + self.per_page = 36 + page_flat_cards, pagination_context = self.paginate_data(flat_cards, page_number) -def card_search(request): - """ - Searches for cards and returns a JSON list of objects, where each object - contains card data and its pre-rendered HTML badge. - """ - query = request.GET.get("q", "").strip() - results = [] - if query and len(query) >= 2: - selected_ids = request.GET.getlist("selected_ids[]") - - cards = ( - Card.objects.with_details() - .filter( - Q(translations__name__icontains=query) - | Q(cardset__translations__name__icontains=query) - ) - .exclude(id__in=selected_ids) - .order_by("translations__name", "cardset__translations__name")[:20] - ) - - for card in cards: - # Prepare the full context required by the card_badge.html template - badge_context = { - "card": card, - "quantity": None, - "expanded": True, - "clickable": False, - "url": reverse("cards:detail", args=[card.pk]), - "name": card.name, - "rarity": card.rarity.icon if card.rarity else "", - "cardset": card.cardset.id, - "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT, - } - results.append( - { - "id": str(card.id), - "name": card.name, - "set_name": card.cardset.name, - "html": render_to_string( - "templatetags/card_badge.html", badge_context - ), - } - ) - return JsonResponse(results, safe=False) + 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["page_obj"] = pagination_context + context["object_list"] = self.get_queryset() + return context \ No newline at end of file diff --git a/src/pkmntrade_club/common/apps.py b/src/pkmntrade_club/common/apps.py index 7427944..6e2cde5 100644 --- a/src/pkmntrade_club/common/apps.py +++ b/src/pkmntrade_club/common/apps.py @@ -5,4 +5,4 @@ class CommonConfig(AppConfig): name = "pkmntrade_club.common" def ready(self): - pass + pass \ No newline at end of file diff --git a/src/pkmntrade_club/common/context_processors.py b/src/pkmntrade_club/common/context_processors.py index 4ee3c97..7950ded 100644 --- a/src/pkmntrade_club/common/context_processors.py +++ b/src/pkmntrade_club/common/context_processors.py @@ -1,28 +1,12 @@ -import random - from django.conf import settings - def cache_settings(request): - """ - Pass cache settings to the template context. - Applies jitter to the timeouts. - """ - jitter = settings.CACHE_JITTER return { - "CACHE_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), + 'CACHE_TIMEOUT': settings.CACHE_TIMEOUT, } - def version_info(request): return { - "VERSION": settings.VERSION, - "VERSION_INFO": settings.VERSION_INFO, - } + 'VERSION': settings.VERSION, + 'VERSION_INFO': settings.VERSION_INFO, + } \ No newline at end of file diff --git a/src/pkmntrade_club/common/mixins.py b/src/pkmntrade_club/common/mixins.py index 0df467a..6290fdc 100644 --- a/src/pkmntrade_club/common/mixins.py +++ b/src/pkmntrade_club/common/mixins.py @@ -26,13 +26,9 @@ class ReusablePaginationMixin: "number": page.number, "has_previous": page.has_previous(), "has_next": page.has_next(), - "previous_page_number": ( - page.previous_page_number() if page.has_previous() else 1 - ), - "next_page_number": ( - page.next_page_number() if page.has_next() else paginator.num_pages - ), + "previous_page_number": page.previous_page_number() if page.has_previous() else 1, + "next_page_number": page.next_page_number() if page.has_next() else paginator.num_pages, "paginator": {"num_pages": paginator.num_pages}, - "count": paginator.count, + "count": paginator.count } - return page.object_list, pagination_context + return page.object_list, pagination_context \ No newline at end of file diff --git a/src/pkmntrade_club/common/templatetags/pagination_tags.py b/src/pkmntrade_club/common/templatetags/pagination_tags.py index 0840881..a9a2890 100644 --- a/src/pkmntrade_club/common/templatetags/pagination_tags.py +++ b/src/pkmntrade_club/common/templatetags/pagination_tags.py @@ -2,10 +2,9 @@ from django import template register = template.Library() - @register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True) def render_pagination(context, page_obj, hide_if_one_page=True): """ Renders the pagination controls given a page_obj. Optionally hides the controls if there is only one page. """ - return {"page_obj": page_obj, "hide_if_one_page": hide_if_one_page} + return {"page_obj": page_obj, "hide_if_one_page": hide_if_one_page} \ No newline at end of file diff --git a/src/pkmntrade_club/django_project/__init__.py b/src/pkmntrade_club/django_project/__init__.py index 5568b6d..1e3599b 100644 --- a/src/pkmntrade_club/django_project/__init__.py +++ b/src/pkmntrade_club/django_project/__init__.py @@ -2,4 +2,4 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app -__all__ = ("celery_app",) +__all__ = ('celery_app',) \ No newline at end of file diff --git a/src/pkmntrade_club/django_project/asgi.py b/src/pkmntrade_club/django_project/asgi.py index 715ac8b..11400ef 100644 --- a/src/pkmntrade_club/django_project/asgi.py +++ b/src/pkmntrade_club/django_project/asgi.py @@ -2,8 +2,6 @@ import os from django.core.asgi import get_asgi_application -os.environ.setdefault( - "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" -) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pkmntrade_club.django_project.settings') application = get_asgi_application() diff --git a/src/pkmntrade_club/django_project/celery.py b/src/pkmntrade_club/django_project/celery.py index 7896883..44c9d6c 100644 --- a/src/pkmntrade_club/django_project/celery.py +++ b/src/pkmntrade_club/django_project/celery.py @@ -3,17 +3,15 @@ import os from celery import Celery # Set the default Django settings module for the 'celery' program. -os.environ.setdefault( - "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" -) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pkmntrade_club.django_project.settings') -app = Celery("django_project") +app = Celery('django_project') # Using a string here means the worker doesn't have to serialize # the configuration object to child processes. # - namespace='CELERY' means all celery-related configuration keys # should have a `CELERY_` prefix. -app.config_from_object("django.conf:settings", namespace="CELERY") +app.config_from_object('django.conf:settings', namespace='CELERY') # Load task modules from all registered Django apps. app.autodiscover_tasks() @@ -21,4 +19,4 @@ app.autodiscover_tasks() @app.task(bind=True, ignore_result=True) def debug_task(self): - print(f"Request: {self.request!r}") + print(f'Request: {self.request!r}') \ No newline at end of file diff --git a/src/pkmntrade_club/django_project/settings.py b/src/pkmntrade_club/django_project/settings.py index 43ec668..550b184 100644 --- a/src/pkmntrade_club/django_project/settings.py +++ b/src/pkmntrade_club/django_project/settings.py @@ -1,90 +1,74 @@ -import logging -import os import socket -import sys from pathlib import Path - import environ +import os +import logging +import sys from django.utils.translation import gettext_lazy as _ - from pkmntrade_club._version import __version__, get_version_info # set default values to local dev values env = environ.Env( - DEBUG=( - bool, - False, - ), # MUST STAY FALSE FOR DEFAULT FOR SECURITY REASONS (e.g. if app can't access .env, prevent showing debug output) + DEBUG=(bool, False), # MUST STAY FALSE FOR DEFAULT FOR SECURITY REASONS (e.g. if app can't access .env, prevent showing debug output) DISABLE_SIGNUPS=(bool, True), DISABLE_CACHE=(bool, True), - DJANGO_DATABASE_URL=( - str, - "postgresql://postgres@localhost:5432/postgres?sslmode=disable", - ), - DJANGO_EMAIL_HOST=(str, ""), + DJANGO_DATABASE_URL=(str, 'postgresql://postgres@localhost:5432/postgres?sslmode=disable'), + DJANGO_EMAIL_HOST=(str, ''), DJANGO_EMAIL_PORT=(int, 587), - DJANGO_EMAIL_USER=(str, ""), - DJANGO_EMAIL_PASSWORD=(str, ""), + DJANGO_EMAIL_USER=(str, ''), + DJANGO_EMAIL_PASSWORD=(str, ''), DJANGO_EMAIL_USE_TLS=(bool, True), - DJANGO_DEFAULT_FROM_EMAIL=(str, ""), - DJANGO_EMAIL_SUBJECT_PREFIX=(str, ""), - SECRET_KEY=( - str, - "0000000000000000000000000000000000000000000000000000000000000000", - ), - ALLOWED_HOSTS=(str, "localhost,127.0.0.1"), - PUBLIC_HOST=(str, "localhost"), - ACCOUNT_EMAIL_VERIFICATION=(str, "none"), - SCHEME=(str, "http"), - REDIS_URL=(str, "redis://localhost:6379"), - 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"), + DJANGO_DEFAULT_FROM_EMAIL=(str, ''), + SECRET_KEY=(str, '0000000000000000000000000000000000000000000000000000000000000000'), + ALLOWED_HOSTS=(str, 'localhost,127.0.0.1'), + PUBLIC_HOST=(str, 'localhost'), + ACCOUNT_EMAIL_VERIFICATION=(str, 'none'), + SCHEME=(str, 'http'), + REDIS_URL=(str, 'redis://localhost:6379'), + CACHE_TIMEOUT=(int, 604800), + TIME_ZONE=(str, 'America/Los_Angeles'), ) LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "verbose": { - "format": "%(asctime)s %(name)-12s %(levelname)-8s %(message)s", + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '%(asctime)s %(name)-12s %(levelname)-8s %(message)s', }, }, - "handlers": { - "console": { - "level": "INFO", - "class": "logging.StreamHandler", - "stream": sys.stdout, - "formatter": "verbose", - "filters": [], + 'handlers': { + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'stream': sys.stdout, + 'formatter': 'verbose', + 'filters': [], }, }, - "loggers": { - "django": { - "handlers": ["console"], - "level": "INFO", + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': 'INFO', }, - "django.server": { - "handlers": ["console"], - "level": "INFO", - "propagate": False, + 'django.server': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, }, - "granian.access": { - "handlers": ["console"], - "level": "INFO", - "propagate": False, + 'granian.access': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, }, - "_granian": { - "handlers": ["console"], - "level": "INFO", - "propagate": False, + '_granian': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, }, - "": { - "handlers": ["console"], - "level": "INFO", + '': { + 'handlers': ['console'], + 'level': 'INFO', }, }, } @@ -93,18 +77,14 @@ LOGGING = { BASE_DIR = Path(__file__).resolve().parent.parent # Take environment variables from .env file -environ.Env.read_env(os.path.join(BASE_DIR, ".env")) +environ.Env.read_env(os.path.join(BASE_DIR, '.env')) -SCHEME = env("SCHEME") -PUBLIC_HOST = env("PUBLIC_HOST") -REDIS_URL = env("REDIS_URL") -CACHE_DEFAULT_TIMEOUT = env("CACHE_DEFAULT_TIMEOUT") -CACHE_SHORT_TIMEOUT = env("CACHE_SHORT_TIMEOUT") -CACHE_MEDIUM_TIMEOUT = env("CACHE_MEDIUM_TIMEOUT") -CACHE_LONG_TIMEOUT = env("CACHE_LONG_TIMEOUT") -CACHE_JITTER = env("CACHE_JITTER") -DISABLE_SIGNUPS = env("DISABLE_SIGNUPS") -DISABLE_CACHE = env("DISABLE_CACHE") +SCHEME = env('SCHEME') +PUBLIC_HOST = env('PUBLIC_HOST') +REDIS_URL = env('REDIS_URL') +CACHE_TIMEOUT = env('CACHE_TIMEOUT') +DISABLE_SIGNUPS = env('DISABLE_SIGNUPS') +DISABLE_CACHE = env('DISABLE_CACHE') VERSION = __version__ VERSION_INFO = get_version_info() @@ -114,38 +94,31 @@ VERSION_INFO = get_version_info() # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = env("SECRET_KEY") +SECRET_KEY = env('SECRET_KEY') # https://docs.djangoproject.com/en/dev/ref/settings/#debug # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env("DEBUG") +DEBUG = env('DEBUG') # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = env("ALLOWED_HOSTS").split(",") +ALLOWED_HOSTS = env('ALLOWED_HOSTS').split(',') try: current_web_worker_hostname = socket.gethostname() ALLOWED_HOSTS.append(current_web_worker_hostname) - logging.getLogger(__name__).info( - f"Added {current_web_worker_hostname} to allowed hosts." - ) + logging.getLogger(__name__).info(f"Added {current_web_worker_hostname} to allowed hosts.") except Exception: - logging.getLogger(__name__).info( - "Error determining server hostname for allowed hosts." - ) + logging.getLogger(__name__).info(f"Error determining server hostname for allowed hosts.") CSRF_TRUSTED_ORIGINS = [f"{SCHEME}://{PUBLIC_HOST}"] -SHORTHAND_DATETIME_FORMAT = "Y-m-d P" -SHORTHAND_DATE_FORMAT = "Y-m-d" - FIRST_PARTY_APPS = [ - "pkmntrade_club.accounts", - "pkmntrade_club.cards", - "pkmntrade_club.common", - "pkmntrade_club.home", - "pkmntrade_club.theme", - "pkmntrade_club.trades", + 'pkmntrade_club.accounts', + 'pkmntrade_club.cards', + 'pkmntrade_club.common', + 'pkmntrade_club.home', + 'pkmntrade_club.theme', + 'pkmntrade_club.trades', ] # Application definition @@ -163,22 +136,21 @@ INSTALLED_APPS = [ "django_celery_beat", "allauth", "allauth.account", - "allauth.socialaccount.providers.google", + 'allauth.socialaccount.providers.google', "crispy_forms", "crispy_tailwind", "tailwind", "django_linear_migrations", - "health_check", - "health_check.db", - "health_check.cache", - "health_check.storage", - "health_check.contrib.migrations", - "health_check.contrib.celery", - "health_check.contrib.celery_ping", - "health_check.contrib.psutil", - "health_check.contrib.redis", + 'health_check', + 'health_check.db', + 'health_check.cache', + 'health_check.storage', + 'health_check.contrib.migrations', + 'health_check.contrib.celery', + 'health_check.contrib.celery_ping', + 'health_check.contrib.psutil', + 'health_check.contrib.redis', "meta", - "parler", ] + FIRST_PARTY_APPS if DEBUG: @@ -188,12 +160,12 @@ if DEBUG: "debug_toolbar", ] -TAILWIND_APP_NAME = "theme" +TAILWIND_APP_NAME = 'theme' -META_SITE_NAME = "PKMN Trade Club" +META_SITE_NAME = 'PKMN Trade Club' META_SITE_PROTOCOL = SCHEME META_USE_SITES = True -META_IMAGE_URL = f"{SCHEME}://{PUBLIC_HOST}/" +META_IMAGE_URL = f'{SCHEME}://{PUBLIC_HOST}/' # https://docs.djangoproject.com/en/dev/ref/settings/#middleware MIDDLEWARE = [ @@ -216,22 +188,22 @@ if DEBUG: ] HEALTH_CHECK = { - "DISK_USAGE_MAX": 90, # percent - "MEMORY_MIN": 100, # in MB + 'DISK_USAGE_MAX': 90, # percent + 'MEMORY_MIN': 100, # in MB } DAISY_SETTINGS = { - "SITE_TITLE": "PKMN Trade Club Admin", - "DONT_SUPPORT_ME": True, + 'SITE_TITLE': 'PKMN Trade Club Admin', + 'DONT_SUPPORT_ME': True, } # https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf -ROOT_URLCONF = "pkmntrade_club.django_project.urls" +ROOT_URLCONF = 'pkmntrade_club.django_project.urls' # https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application -WSGI_APPLICATION = "pkmntrade_club.django_project.wsgi.app" +WSGI_APPLICATION = 'pkmntrade_club.django_project.wsgi.app' -ASGI_APPLICATION = "pkmntrade_club.django_project.asgi.application" +ASGI_APPLICATION = 'pkmntrade_club.django_project.asgi.application' # https://docs.djangoproject.com/en/dev/ref/settings/#templates TEMPLATES = [ @@ -254,7 +226,7 @@ TEMPLATES = [ # https://docs.djangoproject.com/en/dev/ref/settings/#databases DATABASES = { - "default": env.db(var="DJANGO_DATABASE_URL"), + 'default': env.db(var="DJANGO_DATABASE_URL"), } # Password validation @@ -279,10 +251,8 @@ AUTH_PASSWORD_VALIDATORS = [ # https://docs.djangoproject.com/en/dev/ref/settings/#language-code LANGUAGE_CODE = "en-us" -LANGUAGES = (("en", _("English")),) - # https://docs.djangoproject.com/en/dev/ref/settings/#time-zone -TIME_ZONE = env("TIME_ZONE") +TIME_ZONE = env('TIME_ZONE') # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-USE_I18N USE_I18N = True @@ -291,7 +261,7 @@ USE_I18N = True USE_TZ = True # https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths -LOCALE_PATHS = [BASE_DIR / "locale"] +LOCALE_PATHS = [BASE_DIR / 'locale'] # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.0/howto/static-files/ @@ -326,24 +296,23 @@ STORAGES = { # Default primary key field type # https://docs.djangoproject.com/en/stable/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # django-crispy-forms # https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs -CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind" +CRISPY_ALLOWED_TEMPLATE_PACKS = 'tailwind' CRISPY_TEMPLATE_PACK = "tailwind" # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = env("DJANGO_EMAIL_HOST") -EMAIL_PORT = env("DJANGO_EMAIL_PORT") -EMAIL_HOST_USER = env("DJANGO_EMAIL_USER") -EMAIL_HOST_PASSWORD = env("DJANGO_EMAIL_PASSWORD") -EMAIL_USE_TLS = env("DJANGO_EMAIL_USE_TLS") -EMAIL_SUBJECT_PREFIX = env("DJANGO_EMAIL_SUBJECT_PREFIX") +EMAIL_HOST = env('DJANGO_EMAIL_HOST') +EMAIL_PORT = env('DJANGO_EMAIL_PORT') +EMAIL_HOST_USER = env('DJANGO_EMAIL_USER') +EMAIL_HOST_PASSWORD = env('DJANGO_EMAIL_PASSWORD') +EMAIL_USE_TLS = env('DJANGO_EMAIL_USE_TLS') # https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email -DEFAULT_FROM_EMAIL = env("DJANGO_DEFAULT_FROM_EMAIL") +DEFAULT_FROM_EMAIL = env('DJANGO_DEFAULT_FROM_EMAIL') # django-debug-toolbar # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html @@ -356,7 +325,7 @@ INTERNAL_IPS = [ hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) for ip in ips: INTERNAL_IPS.append(ip) - INTERNAL_IPS.append(".".join(ip.rsplit(".")[:-1]) + ".1") + INTERNAL_IPS.append(".".join(ip.rsplit(".")[:-1])+ ".1") # https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model AUTH_USER_MODEL = "accounts.CustomUser" @@ -365,15 +334,6 @@ AUTH_USER_MODEL = "accounts.CustomUser" # https://docs.djangoproject.com/en/dev/ref/settings/#site-id SITE_ID = 1 -PARLER_LANGUAGES = { - SITE_ID: ({"code": "en"},), - "default": { - "fallbacks": ["en"], - "hide_untranslated": False, - }, -} - - # https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url LOGIN_REDIRECT_URL = "home" @@ -387,13 +347,13 @@ AUTHENTICATION_BACKENDS = ( ) # https://django-allauth.readthedocs.io/en/latest/configuration.html if DISABLE_SIGNUPS: - ACCOUNT_ADAPTER = "pkmntrade_club.accounts.adapter.NoSignupAccountAdapter" -SOCIALACCOUNT_ADAPTER = "pkmntrade_club.accounts.adapter.NoSignupSocialAccountAdapter" # always disable social account signups + ACCOUNT_ADAPTER = 'pkmntrade_club.accounts.adapter.NoSignupAccountAdapter' +SOCIALACCOUNT_ADAPTER = 'pkmntrade_club.accounts.adapter.NoSignupSocialAccountAdapter' # always disable social account signups ACCOUNT_SESSION_REMEMBER = True ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = True ACCOUNT_AUTHENTICATION_METHOD = "username_email" ACCOUNT_EMAIL_REQUIRED = True -ACCOUNT_EMAIL_VERIFICATION = env("ACCOUNT_EMAIL_VERIFICATION") +ACCOUNT_EMAIL_VERIFICATION = env('ACCOUNT_EMAIL_VERIFICATION') ACCOUNT_EMAIL_NOTIFICATIONS = True ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False ACCOUNT_DEFAULT_HTTP_PROTOCOL = SCHEME @@ -414,7 +374,6 @@ SOCIALACCOUNT_ONLY = False SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" SESSION_COOKIE_HTTPONLY = True -SESSION_COOKIE_SECURE = PUBLIC_HOST != "localhost" or PUBLIC_HOST != "127.0.0.1" # auto-detection doesn't work properly sometimes, so we'll just use the DEBUG setting DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG} diff --git a/src/pkmntrade_club/django_project/urls.py b/src/pkmntrade_club/django_project/urls.py index 84a15be..61cac00 100644 --- a/src/pkmntrade_club/django_project/urls.py +++ b/src/pkmntrade_club/django_project/urls.py @@ -4,11 +4,11 @@ from debug_toolbar.toolbar import debug_toolbar_urls urlpatterns = [ path("admin/", admin.site.urls), - path("account/", include("pkmntrade_club.accounts.urls")), + path('account/', include('pkmntrade_club.accounts.urls')), path("accounts/", include("allauth.urls")), path("", include("pkmntrade_club.home.urls")), path("cards/", include("pkmntrade_club.cards.urls")), - path("health/", include("health_check.urls")), + path("health/", include('health_check.urls')), path("trades/", include("pkmntrade_club.trades.urls")), path("__reload__/", include("django_browser_reload.urls")), ] + debug_toolbar_urls() diff --git a/src/pkmntrade_club/django_project/wsgi.py b/src/pkmntrade_club/django_project/wsgi.py index 711707d..4c5ab87 100644 --- a/src/pkmntrade_club/django_project/wsgi.py +++ b/src/pkmntrade_club/django_project/wsgi.py @@ -2,8 +2,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault( - "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" -) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings") app = get_wsgi_application() diff --git a/src/pkmntrade_club/home/tests.py b/src/pkmntrade_club/home/tests.py index a174be0..9c51303 100644 --- a/src/pkmntrade_club/home/tests.py +++ b/src/pkmntrade_club/home/tests.py @@ -2,221 +2,241 @@ from django.test import TestCase, Client, RequestFactory from django.urls import reverse from django.contrib.auth import get_user_model from pkmntrade_club.cards.models import Card, Deck -from pkmntrade_club.trades.models import ( - TradeOffer, - TradeOfferHaveCard, - TradeOfferWantCard, -) +from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard from pkmntrade_club.accounts.models import FriendCode from pkmntrade_club.home.views import HomePageView +import json from collections import OrderedDict from unittest.mock import patch, MagicMock +from django.core.exceptions import ObjectDoesNotExist +import importlib from tests.utils.rarity import RARITY_MAPPING User = get_user_model() - class HomePageViewTests(TestCase): """Test suite for the HomePageView.""" - + @classmethod def setUpTestData(cls): """Set up data for all test methods.""" # Create a user cls.user = User.objects.create_user( - username="testuser", email="testuser@example.com", password="testpass123" + username='testuser', + email='testuser@example.com', + password='testpass123' ) - + # Create a friend code for the user cls.friend_code = FriendCode.objects.create( - user=cls.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer" + user=cls.user, + friend_code='SW-1234-5678-9012', + in_game_name='TestTrainer' ) - + # Create decks cls.deck1 = Deck.objects.create( - name="Test Deck 1", hex_color="#FF0000", cardset="TEST01" + name='Test Deck 1', + hex_color='#FF0000', + cardset='TEST01' ) - + # Create cards with different rarities cls.common_card = Card.objects.create( - name="Common Test Card", - cardset="TEST01", + name='Common Test Card', + cardset='TEST01', cardnum=1, - style="normal", - rarity_icon="★", - rarity_level=1, + style='normal', + rarity_icon='★', + rarity_level=1 ) cls.common_card.decks.add(cls.deck1) - + cls.rare_card = Card.objects.create( - name="Rare Test Card", - cardset="TEST01", + name='Rare Test Card', + cardset='TEST01', cardnum=2, - style="normal", - rarity_icon="★★★", - rarity_level=3, + style='normal', + rarity_icon='★★★', + rarity_level=3 ) cls.rare_card.decks.add(cls.deck1) - + cls.ultra_rare_card = Card.objects.create( - name="Ultra Rare Test Card", - cardset="TEST01", + name='Ultra Rare Test Card', + cardset='TEST01', cardnum=3, - style="normal", - rarity_icon="★★★★", - rarity_level=4, + style='normal', + rarity_icon='★★★★', + rarity_level=4 ) cls.ultra_rare_card.decks.add(cls.deck1) - + # Create trade offers with consistent rarities cls.common_trade = TradeOffer.objects.create( - initiated_by=cls.friend_code, rarity_icon=RARITY_MAPPING[1], rarity_level=1 + initiated_by=cls.friend_code, + rarity_icon=RARITY_MAPPING[1], + rarity_level=1 ) - + cls.rare_trade = TradeOffer.objects.create( - initiated_by=cls.friend_code, rarity_icon=RARITY_MAPPING[3], rarity_level=3 + initiated_by=cls.friend_code, + rarity_icon=RARITY_MAPPING[3], + rarity_level=3 ) - + # Add have and want cards with the SAME rarity for each trade TradeOfferHaveCard.objects.create( - trade_offer=cls.common_trade, card=cls.common_card, quantity=2 + trade_offer=cls.common_trade, + card=cls.common_card, + quantity=2 ) - + TradeOfferHaveCard.objects.create( - trade_offer=cls.rare_trade, card=cls.rare_card, quantity=1 + trade_offer=cls.rare_trade, + card=cls.rare_card, + quantity=1 ) - + # Add want cards with the SAME rarity as the have cards for each trade TradeOfferWantCard.objects.create( - trade_offer=cls.common_trade, card=cls.common_card, quantity=1 + trade_offer=cls.common_trade, + card=cls.common_card, + quantity=1 ) - + TradeOfferWantCard.objects.create( trade_offer=cls.rare_trade, card=cls.rare_card, # Changed from ultra_rare_card to match the rarity - quantity=1, + quantity=1 ) - + def setUp(self): """Set up before each test method.""" self.client = Client() - self.url = reverse("home") + self.url = reverse('home') self.factory = RequestFactory() - + def test_home_page_status_code(self): """Test that the home page returns a 200 status code.""" response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - + def test_home_page_template(self): """Test that the home page uses the correct template.""" response = self.client.get(self.url) - self.assertTemplateUsed(response, "home/home.html") - + self.assertTemplateUsed(response, 'home/home.html') + def test_home_page_context_cards(self): """Test that the home page contains all cards in the context.""" response = self.client.get(self.url) - self.assertIn("cards", response.context) - self.assertEqual(response.context["cards"].count(), 3) - + self.assertIn('cards', response.context) + self.assertEqual(response.context['cards'].count(), 3) + def test_home_page_context_recent_offers(self): """Test that the home page contains recent offers in the context.""" response = self.client.get(self.url) - self.assertIn("recent_offers", response.context) - self.assertEqual(len(response.context["recent_offers"]), 2) + self.assertIn('recent_offers', response.context) + self.assertEqual(len(response.context['recent_offers']), 2) # Recent offers should be ordered by most recent first - self.assertEqual(response.context["recent_offers"][0], self.rare_trade) - + self.assertEqual(response.context['recent_offers'][0], self.rare_trade) + def test_home_page_context_most_offered_cards(self): """Test that the home page contains most offered cards in the context.""" response = self.client.get(self.url) - self.assertIn("most_offered_cards", response.context) - most_offered = list(response.context["most_offered_cards"]) + self.assertIn('most_offered_cards', response.context) + most_offered = list(response.context['most_offered_cards']) self.assertEqual(len(most_offered), 2) # Common card should be most offered (quantity of 2) self.assertEqual(most_offered[0], self.common_card) - + def test_home_page_context_most_wanted_cards(self): """Test that the home page contains most wanted cards in the context.""" response = self.client.get(self.url) - self.assertIn("most_wanted_cards", response.context) - most_wanted = list(response.context["most_wanted_cards"]) + self.assertIn('most_wanted_cards', response.context) + most_wanted = list(response.context['most_wanted_cards']) self.assertEqual(len(most_wanted), 2) - + def test_home_page_context_least_offered_cards(self): """Test that the home page contains least offered cards in the context.""" response = self.client.get(self.url) - self.assertIn("least_offered_cards", response.context) - + self.assertIn('least_offered_cards', response.context) + def test_home_page_context_featured_offers(self): """Test that the home page contains featured offers in the context.""" response = self.client.get(self.url) - self.assertIn("featured_offers", response.context) - featured = response.context["featured_offers"] + self.assertIn('featured_offers', response.context) + featured = response.context['featured_offers'] # Should be an OrderedDict self.assertIsInstance(featured, OrderedDict) # Should contain "All" category self.assertIn("All", featured) # Should contain both rarity icons - self.assertIn("★★★", featured) - self.assertIn("★", featured) + self.assertIn('★★★', featured) + self.assertIn('★', featured) # Higher rarity should come before lower rarity keys = list(featured.keys()) # First key should be "All" self.assertEqual(keys[0], "All") # Higher rarity (★★★) should come before lower rarity (★) - self.assertIn("★★★", keys) - self.assertIn("★", keys) - self.assertTrue(keys.index("★★★") < keys.index("★")) - + self.assertIn('★★★', keys) + self.assertIn('★', keys) + self.assertTrue(keys.index('★★★') < keys.index('★')) + def test_closed_offers_not_shown(self): """Test that closed offers are not shown on the home page.""" # Close one of the trade offers self.common_trade.is_closed = True self.common_trade.save() - + response = self.client.get(self.url) - recent_offers = response.context["recent_offers"] + recent_offers = response.context['recent_offers'] # Should only show the rare trade now self.assertEqual(len(recent_offers), 1) self.assertEqual(recent_offers[0], self.rare_trade) - + def test_home_page_with_no_data(self): """Test home page rendering when there's no trade data.""" # Delete all trade offers TradeOffer.objects.all().delete() - + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) # Should have empty lists for offers - self.assertEqual(len(response.context["recent_offers"]), 0) - + self.assertEqual(len(response.context['recent_offers']), 0) + def test_home_page_with_authenticated_user(self): """Test that the home page works for authenticated users.""" - self.client.login(username="testuser", password="testpass123") + self.client.login(username='testuser', password='testpass123') response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - + def test_rarity_sorting_in_featured_offers(self): """Test that offers are sorted by rarity level in descending order.""" # Create a new ultra rare trade with consistent rarity ultra_trade = TradeOffer.objects.create( - initiated_by=self.friend_code, rarity_icon="★★★★", rarity_level=4 + initiated_by=self.friend_code, + rarity_icon='★★★★', + rarity_level=4 ) - + # Add have and want cards with the same rarity TradeOfferHaveCard.objects.create( - trade_offer=ultra_trade, card=self.ultra_rare_card, quantity=1 + trade_offer=ultra_trade, + card=self.ultra_rare_card, + quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=ultra_trade, card=self.ultra_rare_card, quantity=1 + trade_offer=ultra_trade, + card=self.ultra_rare_card, + quantity=1 ) - + response = self.client.get(self.url) - featured = response.context["featured_offers"] + featured = response.context['featured_offers'] keys = list(featured.keys()) - + # Order should be: "All", "★★★★" (level 4), "★★★" (level 3), "★" (level 1) self.assertEqual(keys[0], "All") self.assertEqual(keys[1], "★★★★") @@ -226,202 +246,216 @@ class HomePageViewTests(TestCase): class HomePageViewMockTests(TestCase): """Test suite using mocks for HomePageView.""" - + def setUp(self): self.factory = RequestFactory() self.view = HomePageView() - - @patch("trades.models.TradeOffer.objects") - @patch("cards.models.Card.objects") + + @patch('trades.models.TradeOffer.objects') + @patch('cards.models.Card.objects') def test_get_context_data_with_mocks(self, mock_card_objects, mock_offer_objects): """Test get_context_data using mocks.""" # Set up request - request = self.factory.get(reverse("home")) + request = self.factory.get(reverse('home')) self.view.request = request - + # Mock the queryset responses mock_offer_filter = MagicMock() mock_offer_objects.filter.return_value = mock_offer_filter mock_offer_filter.order_by.return_value = [] - + mock_card_filter = MagicMock() mock_card_objects.filter.return_value = mock_card_filter mock_card_objects.annotate.return_value = mock_card_filter mock_card_objects.all.return_value.order_by.return_value = [] mock_card_filter.annotate.return_value = mock_card_filter mock_card_filter.order_by.return_value = [] - + mock_offer_filter.values_list.return_value.distinct.return_value = [] - + # Call the method context = self.view.get_context_data() - + # Verify the expected context keys exist - self.assertIn("cards", context) - self.assertIn("recent_offers", context) - self.assertIn("most_offered_cards", context) - self.assertIn("most_wanted_cards", context) - self.assertIn("least_offered_cards", context) - self.assertIn("featured_offers", context) - - @patch("trades.models.TradeOffer.objects") + self.assertIn('cards', context) + self.assertIn('recent_offers', context) + self.assertIn('most_offered_cards', context) + self.assertIn('most_wanted_cards', context) + self.assertIn('least_offered_cards', context) + self.assertIn('featured_offers', context) + + @patch('trades.models.TradeOffer.objects') def test_empty_featured_offers(self, mock_offer_objects): """Test handling of empty featured offers.""" # Set up request - request = self.factory.get(reverse("home")) + request = self.factory.get(reverse('home')) self.view.request = request - + # Configure mock to return empty queryset mock_offer_filter = MagicMock() mock_offer_objects.filter.return_value = mock_offer_filter mock_offer_filter.order_by.return_value = [] mock_offer_filter.values_list.return_value.distinct.return_value = [] - + # Call the method context = self.view.get_context_data() - + # Verify the featured_offers is an OrderedDict but with just the "All" key - self.assertIsInstance(context["featured_offers"], OrderedDict) - self.assertIn("All", context["featured_offers"]) - self.assertEqual(len(context["featured_offers"]), 1) - - @patch("trades.models.TradeOffer.objects.filter") + self.assertIsInstance(context['featured_offers'], OrderedDict) + self.assertIn("All", context['featured_offers']) + self.assertEqual(len(context['featured_offers']), 1) + + @patch('trades.models.TradeOffer.objects.filter') def test_exception_handling(self, mock_filter): """Test that exceptions are handled gracefully.""" # Set up request - request = self.factory.get(reverse("home")) + request = self.factory.get(reverse('home')) self.view.request = request - + # Configure mock to raise an exception mock_filter.side_effect = Exception("Database error") - + # Call the method - should not raise an exception - with self.assertLogs(level="ERROR") as cm: + with self.assertLogs(level='ERROR') as cm: context = self.view.get_context_data() - + # Check if error was logged - self.assertIn( - "Unhandled error in HomePageView.get_context_data", cm.output[0] - ) - + self.assertIn("Unhandled error in HomePageView.get_context_data", cm.output[0]) + # Verify fallback values were set - self.assertEqual(len(context["cards"]), 0) - self.assertEqual(len(context["recent_offers"]), 0) - self.assertEqual(len(context["most_offered_cards"]), 0) - self.assertEqual(len(context["most_wanted_cards"]), 0) - self.assertEqual(len(context["least_offered_cards"]), 0) - self.assertIsInstance(context["featured_offers"], OrderedDict) - self.assertEqual(len(context["featured_offers"]), 1) - self.assertIn("All", context["featured_offers"]) - + self.assertEqual(len(context['cards']), 0) + self.assertEqual(len(context['recent_offers']), 0) + self.assertEqual(len(context['most_offered_cards']), 0) + self.assertEqual(len(context['most_wanted_cards']), 0) + self.assertEqual(len(context['least_offered_cards']), 0) + self.assertIsInstance(context['featured_offers'], OrderedDict) + self.assertEqual(len(context['featured_offers']), 1) + self.assertIn("All", context['featured_offers']) class HomePageEdgeCaseTests(TestCase): """Test edge cases for the home page.""" - + def setUp(self): self.client = Client() - self.url = reverse("home") - + self.url = reverse('home') + # Create a user self.user = User.objects.create_user( - username="testuser", email="testuser@example.com", password="testpass123" + username='testuser', + email='testuser@example.com', + password='testpass123' ) - + # Create a friend code for the user self.friend_code = FriendCode.objects.create( - user=self.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer" + user=self.user, + friend_code='SW-1234-5678-9012', + in_game_name='TestTrainer' ) - + def test_home_page_with_no_cards(self): """Test home page with no cards in the database.""" response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.context["cards"]), 0) - + self.assertEqual(len(response.context['cards']), 0) + def test_home_page_with_many_offers(self): """Test home page with many offers to verify pagination or limiting works.""" # Create a card card = Card.objects.create( - name="Test Card", - cardset="TEST01", + name='Test Card', + cardset='TEST01', cardnum=1, - style="normal", - rarity_icon="★", - rarity_level=1, + style='normal', + rarity_icon='★', + rarity_level=1 ) - + # Create 20 trade offers for i in range(20): trade = TradeOffer.objects.create( - initiated_by=self.friend_code, rarity_icon="★", rarity_level=1 + initiated_by=self.friend_code, + rarity_icon='★', + rarity_level=1 ) - + # Add have and want cards - TradeOfferHaveCard.objects.create(trade_offer=trade, card=card, quantity=1) - - TradeOfferWantCard.objects.create(trade_offer=trade, card=card, quantity=1) - + TradeOfferHaveCard.objects.create( + trade_offer=trade, + card=card, + quantity=1 + ) + + TradeOfferWantCard.objects.create( + trade_offer=trade, + card=card, + quantity=1 + ) + response = self.client.get(self.url) - + # Check that recent_offers is limited to 6 as per the view - self.assertEqual(len(response.context["recent_offers"]), 6) - + self.assertEqual(len(response.context['recent_offers']), 6) + def test_home_page_with_invalid_parameters(self): """Test home page with invalid GET parameters.""" # The view should ignore invalid parameters response = self.client.get(f"{self.url}?invalid=param&another=invalid") self.assertEqual(response.status_code, 200) - + def test_performance_with_large_dataset(self): """Test performance with a larger dataset (basic check).""" # Create a card card = Card.objects.create( - name="Performance Test Card", - cardset="PERF01", + name='Performance Test Card', + cardset='PERF01', cardnum=1, - style="normal", - rarity_icon="★", - rarity_level=1, + style='normal', + rarity_icon='★', + rarity_level=1 ) - + # Create 50 trade offers with different rarities for i in range(50): rarity_level = (i % 5) + 1 # 1-5 - rarity_icon = "★" * rarity_level - + rarity_icon = '★' * rarity_level + trade = TradeOffer.objects.create( initiated_by=self.friend_code, rarity_icon=rarity_icon, - rarity_level=rarity_level, + rarity_level=rarity_level ) - + # Add have and want cards with the same rarity rarity_card = Card.objects.create( - name=f"Performance Test Card {i}", - cardset="PERF01", - cardnum=i + 10, - style="normal", + name=f'Performance Test Card {i}', + cardset='PERF01', + cardnum=i+10, + style='normal', rarity_icon=rarity_icon, - rarity_level=rarity_level, + rarity_level=rarity_level ) - + TradeOfferHaveCard.objects.create( - trade_offer=trade, card=rarity_card, quantity=1 + trade_offer=trade, + card=rarity_card, + quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=trade, card=rarity_card, quantity=1 + trade_offer=trade, + card=rarity_card, + quantity=1 ) - + # Basic performance test - just checking it completes without timeout import time - start = time.time() response = self.client.get(self.url) end = time.time() - + self.assertEqual(response.status_code, 200) - + # Should be reasonably fast (adjust threshold as needed) execution_time = end - start self.assertLess(execution_time, 2.0) # Should complete in under 2 seconds @@ -429,116 +463,129 @@ class HomePageEdgeCaseTests(TestCase): class TemplateRenderingTests(TestCase): """Tests focused on template rendering.""" - + @classmethod def setUpTestData(cls): # Create a user cls.user = User.objects.create_user( - username="testuser", email="testuser@example.com", password="testpass123" + username='testuser', + email='testuser@example.com', + password='testpass123' ) - + # Create a friend code for the user cls.friend_code = FriendCode.objects.create( - user=cls.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer" + user=cls.user, + friend_code='SW-1234-5678-9012', + in_game_name='TestTrainer' ) - + # Create a card cls.card = Card.objects.create( - name="Test Card", - cardset="TEST01", + name='Test Card', + cardset='TEST01', cardnum=1, - style="normal", - rarity_icon="★", - rarity_level=1, + style='normal', + rarity_icon='★', + rarity_level=1 ) - + # Create a trade offer cls.trade = TradeOffer.objects.create( - initiated_by=cls.friend_code, rarity_icon="★", rarity_level=1 + initiated_by=cls.friend_code, + rarity_icon='★', + rarity_level=1 ) - + # Add have and want cards TradeOfferHaveCard.objects.create( - trade_offer=cls.trade, card=cls.card, quantity=1 + trade_offer=cls.trade, + card=cls.card, + quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=cls.trade, card=cls.card, quantity=1 + trade_offer=cls.trade, + card=cls.card, + quantity=1 ) - + def setUp(self): self.client = Client() self.factory = RequestFactory() - + def test_template_used(self): """Test that the correct template is used.""" - response = self.client.get(reverse("home")) - self.assertTemplateUsed(response, "home/home.html") - + response = self.client.get(reverse('home')) + self.assertTemplateUsed(response, 'home/home.html') + def test_context_variables_exist(self): """Test that all expected context variables exist.""" - response = self.client.get(reverse("home")) - + response = self.client.get(reverse('home')) + # Check all required context variables expected_keys = [ - "cards", - "recent_offers", - "most_offered_cards", - "most_wanted_cards", - "least_offered_cards", - "featured_offers", + 'cards', + 'recent_offers', + 'most_offered_cards', + 'most_wanted_cards', + 'least_offered_cards', + 'featured_offers', ] - + for key in expected_keys: self.assertIn(key, response.context) - + def test_view_with_pagination_params(self): """Test that view handles pagination parameters correctly, if applicable.""" # Create additional trade offers if pagination is implemented for i in range(10): trade = TradeOffer.objects.create( - initiated_by=self.friend_code, rarity_icon="★", rarity_level=1 + initiated_by=self.friend_code, + rarity_icon='★', + rarity_level=1 ) - + # Add have and want cards TradeOfferHaveCard.objects.create( - trade_offer=trade, card=self.card, quantity=1 + trade_offer=trade, + card=self.card, + quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=trade, card=self.card, quantity=1 + trade_offer=trade, + card=self.card, + quantity=1 ) - + # Test with page parameter response = self.client.get(f"{reverse('home')}?page=1") self.assertEqual(response.status_code, 200) - + # Test with invalid page parameter response = self.client.get(f"{reverse('home')}?page=999") - self.assertEqual( - response.status_code, 200 - ) # Should still render with default page - + self.assertEqual(response.status_code, 200) # Should still render with default page + # Test with non-numeric page parameter response = self.client.get(f"{reverse('home')}?page=abc") self.assertEqual(response.status_code, 200) # Should handle gracefully - - @patch("home.views.HomePageView.get_context_data") + + @patch('home.views.HomePageView.get_context_data') def test_view_renders_with_missing_context(self, mock_get_context): """Test that view renders even with incomplete context data.""" # Return incomplete context - mock_get_context.return_value = {"cards": []} - + mock_get_context.return_value = {'cards': []} + # Should still render without error even with missing context variables - response = self.client.get(reverse("home")) + response = self.client.get(reverse('home')) self.assertEqual(response.status_code, 200) - + def test_compatibility_with_multiple_django_versions(self): """Ensure compatibility with different Django versions.""" import django - # Simply log the Django version - the test itself verifies the page renders # with the current version django_version = django.get_version() - response = self.client.get(reverse("home")) + response = self.client.get(reverse('home')) self.assertEqual(response.status_code, 200) diff --git a/src/pkmntrade_club/home/views.py b/src/pkmntrade_club/home/views.py index 29fc6f7..cc05847 100644 --- a/src/pkmntrade_club/home/views.py +++ b/src/pkmntrade_club/home/views.py @@ -1,76 +1,54 @@ -import logging -from collections import OrderedDict - -from django.db.models import ( - Max, - Sum, -) -from django.db.models.functions import Coalesce +from collections import defaultdict, OrderedDict from django.views.generic import TemplateView - +from django.urls import reverse_lazy +from django.db.models import Count, Q, Prefetch, Sum, F, IntegerField, Value, BooleanField, Case, When +from django.db.models.functions import Coalesce +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance, TradeOfferHaveCard, TradeOfferWantCard from pkmntrade_club.cards.models import Card -from pkmntrade_club.trades.models import ( - TradeOffer, -) +from django.utils.decorators import method_decorator +from django.template.response import TemplateResponse +from django.http import HttpResponseRedirect +import logging +from django.views import View +from django.http import HttpResponse +import contextlib logger = logging.getLogger(__name__) - class HomePageView(TemplateView): template_name = "home/home.html" - # @silk_profile(name='Home Page') + #@silk_profile(name='Home Page') def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - + try: # Get all cards ordered by name, exclude cards with rarity level > 5 - context["cards"] = ( - Card.objects.with_details() - .filter(rarity__level__lte=5) - .order_by("translations__name", "rarity__level") - ) - + context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level") + # Reuse base trade offer queryset for market stats base_offer_qs = TradeOffer.objects.filter(is_closed=False) - + # Recent Offers try: recent_offers_qs = base_offer_qs.order_by("-created_at")[:6] context["recent_offers"] = recent_offers_qs - latest_update = recent_offers_qs.aggregate(latest=Max("updated_at"))[ - "latest" - ] - if latest_update: - context["cache_key_recent_offers"] = ( - f"recent_offers_{latest_update.timestamp()}" - ) - else: - context["cache_key_recent_offers"] = "recent_offers_empty" + context["cache_key_recent_offers"] = f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}" except Exception as e: logger.error(f"Error fetching recent offers: {str(e)}") context["recent_offers"] = [] context["cache_key_recent_offers"] = "recent_offers_error" - + # Most Offered Cards try: most_offered_cards_qs = ( - Card.objects.with_details() - .filter(tradeofferhavecard__isnull=False) - .filter(rarity__level__lte=5) + Card.objects.filter(tradeofferhavecard__isnull=False).filter(rarity_level__lte=5) .annotate(offer_count=Sum("tradeofferhavecard__quantity")) .order_by("-offer_count")[:6] ) context["most_offered_cards"] = most_offered_cards_qs - latest_update = most_offered_cards_qs.aggregate( - latest=Max("updated_at") - )["latest"] - if latest_update: - context["cache_key_most_offered_cards"] = ( - f"most_offered_{latest_update.timestamp()}" - ) - else: - context["cache_key_most_offered_cards"] = "most_offered_empty" + context["cache_key_most_offered_cards"] = f"most_offered_cards_{most_offered_cards_qs.values_list('pk', 'updated_at')}" except Exception as e: logger.error(f"Error fetching most offered cards: {str(e)}") context["most_offered_cards"] = [] @@ -78,46 +56,26 @@ class HomePageView(TemplateView): # Most Wanted Cards try: most_wanted_cards_qs = ( - Card.objects.with_details() - .filter(tradeofferwantcard__isnull=False) - .filter(rarity__level__lte=5) + Card.objects.filter(tradeofferwantcard__isnull=False).filter(rarity_level__lte=5) .annotate(offer_count=Sum("tradeofferwantcard__quantity")) .order_by("-offer_count")[:6] ) context["most_wanted_cards"] = most_wanted_cards_qs - latest_update = most_wanted_cards_qs.aggregate( - latest=Max("updated_at") - )["latest"] - if latest_update: - context["cache_key_most_wanted_cards"] = ( - f"most_wanted_{latest_update.timestamp()}" - ) - else: - context["cache_key_most_wanted_cards"] = "most_wanted_empty" + context["cache_key_most_wanted_cards"] = f"most_wanted_cards_{most_wanted_cards_qs.values_list('pk', 'updated_at')}" except Exception as e: logger.error(f"Error fetching most wanted cards: {str(e)}") context["most_wanted_cards"] = [] - + # Least Offered Cards try: least_offered_cards_qs = ( - Card.objects.with_details() - .filter(rarity__level__lte=5) - .annotate( + Card.objects.filter(rarity_level__lte=5).annotate( offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0) ) .order_by("offer_count")[:6] ) context["least_offered_cards"] = least_offered_cards_qs - latest_update = least_offered_cards_qs.aggregate( - latest=Max("updated_at") - )["latest"] - if latest_update: - context["cache_key_least_offered_cards"] = ( - f"least_offered_{latest_update.timestamp()}" - ) - else: - context["cache_key_least_offered_cards"] = "least_offered_empty" + context["cache_key_least_offered_cards"] = f"least_offered_cards_{least_offered_cards_qs.values_list('pk', 'updated_at')}" except Exception as e: logger.error(f"Error fetching least offered cards: {str(e)}") context["least_offered_cards"] = [] @@ -126,37 +84,26 @@ class HomePageView(TemplateView): featured = OrderedDict() # Featured "All" offers remains fixed at the top try: - 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" + featured["All"] = base_offer_qs.order_by("created_at")[:6] except Exception as e: logger.error(f"Error fetching 'All' featured offers: {str(e)}") featured["All"] = [] - context["cache_key_featured_offers"] = "featured_all_error" - - # *** we only show All Featured Offers for now, + + # *** we only show All Featured Offers for now, # *** we will add rarity-tabbed featured offers later # try: # # Pull out distinct (rarity_level, rarity_icon) tuples # distinct_rarities = base_offer_qs.values_list("rarity_level", "rarity_icon").distinct() - + # # Prepare a list that holds tuples of (rarity_level, rarity_icon, offers) # rarity_offers = [] # for rarity_level, rarity_icon in distinct_rarities: # offers = base_offer_qs.filter(rarity_level=rarity_level).order_by("created_at")[:6] # rarity_offers.append((rarity_level, rarity_icon, offers)) - + # # Sort by rarity_level (from greatest to least) # rarity_offers.sort(key=lambda x: x[0], reverse=True) - + # # Add the sorted offers to the OrderedDict # for rarity_level, rarity_icon, offers in rarity_offers: # featured[rarity_icon] = offers @@ -164,6 +111,19 @@ class HomePageView(TemplateView): # logger.error(f"Error processing rarity-based featured offers: {str(e)}") context["featured_offers"] = featured + # Generate a cache key based on the pks and updated_at timestamps of all featured offers + # *** we will separate cache keys for each featured section later + all_offer_identifiers = [] + for section_name,section_offers in featured.items(): + # featured_section is a QuerySet. Fetch (pk, updated_at) tuples. + identifiers = section_offers.values_list('pk', 'updated_at') + # Format each tuple as "pk_timestamp" and add to the list + section_strings = [f"{section_name}_{pk}_{ts.timestamp()}" for pk, ts in identifiers] + all_offer_identifiers.extend(section_strings) + + # Join all identifiers into a single string, sorted for consistency regardless of order + combined_identifiers = "|".join(sorted(all_offer_identifiers)) + context["cache_key_featured_offers"] = f"featured_offers_{combined_identifiers}" except Exception as e: logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}") # Provide fallback empty data @@ -173,9 +133,9 @@ class HomePageView(TemplateView): context["most_wanted_cards"] = [] context["least_offered_cards"] = [] context["featured_offers"] = OrderedDict([("All", [])]) - + return context - + def get(self, request, *args, **kwargs): """Override get method to add caching""" return super().get(request, *args, **kwargs) diff --git a/src/pkmntrade_club/tests/utils/rarity.py b/src/pkmntrade_club/tests/utils/rarity.py index 900eb00..24bf7dd 100644 --- a/src/pkmntrade_club/tests/utils/rarity.py +++ b/src/pkmntrade_club/tests/utils/rarity.py @@ -6,5 +6,5 @@ RARITY_MAPPING = { 5: "⭐️", 6: "⭐️⭐️", 7: "⭐️⭐️⭐️", - 8: "👑", -} + 8: "👑" +} \ No newline at end of file diff --git a/src/pkmntrade_club/theme/apps.py b/src/pkmntrade_club/theme/apps.py index 8f60b15..71e85a3 100644 --- a/src/pkmntrade_club/theme/apps.py +++ b/src/pkmntrade_club/theme/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class ThemeConfig(AppConfig): - name = "pkmntrade_club.theme" + name = 'pkmntrade_club.theme' diff --git a/src/pkmntrade_club/theme/templates/admin/cards/importer_status.html b/src/pkmntrade_club/theme/templates/admin/cards/importer_status.html deleted file mode 100644 index 7bd2b47..0000000 --- a/src/pkmntrade_club/theme/templates/admin/cards/importer_status.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load i18n static %} - -{% block extrastyle %}{{ block.super }} - -{% endblock %} - -{% block coltype %}colM{% endblock %} - -{% block bodyclass %}{{ block.super }} dashboard{% endblock %} - -{% block breadcrumbs %} - -{% endblock %} - -{% block content %} -

{% translate 'Full Card Set Importer' %}

-

{% translate 'Click the button below to import all card data from the configured JSON file directory.' %}

-

{% translate 'This process will scan all .json files, create or update card sets, and then import or update all individual cards and their related data (packs, abilities, attacks, etc.).' %}

- -
- {% csrf_token %} - -
- - {% if messages %} -

{% translate 'Import Status:' %}

-
    - {% for message in messages %} - {{ message }} - {% endfor %} -
- {% endif %} -{% endblock %} diff --git a/src/pkmntrade_club/theme/templates/base.html b/src/pkmntrade_club/theme/templates/base.html index f07fed0..7098131 100644 --- a/src/pkmntrade_club/theme/templates/base.html +++ b/src/pkmntrade_club/theme/templates/base.html @@ -1,7 +1,7 @@ {% load static tailwind_tags gravatar %} {% url 'home' as home_url %} {% url 'trade_offer_list' as trade_offer_list_url %} -{% url 'cards:list' as cards_list_url %} +{% url 'cards:card_list' as cards_list_url %} {% url 'dashboard' as dashboard_url %} @@ -65,7 +65,7 @@ @@ -138,8 +138,6 @@ - - {% block javascript %}{% endblock %} \ No newline at end of file diff --git a/src/pkmntrade_club/theme/templates/cards/card_detail.html b/src/pkmntrade_club/theme/templates/cards/card_detail.html index a45b270..96ed018 100644 --- a/src/pkmntrade_club/theme/templates/cards/card_detail.html +++ b/src/pkmntrade_club/theme/templates/cards/card_detail.html @@ -5,7 +5,7 @@

{{card.name}}

-

{{ card.cardset }} #{{ card.cardnum }} • {{ card.rarity.icon }}

+

{{ card.cardset }} #{{ card.cardnum }} • {{ card.rarity_icon }}

diff --git a/src/pkmntrade_club/theme/templates/cards/card_list.html b/src/pkmntrade_club/theme/templates/cards/card_list.html index 2ab713f..155a68d 100644 --- a/src/pkmntrade_club/theme/templates/cards/card_list.html +++ b/src/pkmntrade_club/theme/templates/cards/card_list.html @@ -1,48 +1,63 @@ {% extends "base.html" %} {% load static card_badge %} - {% block content %} -
-

Card List

- -
- -
- - {% if group_by == 'rarity' %} - {% regroup cards by rarity as grouped_list %} - {% for group in grouped_list %} -
-

{{ group.grouper.icon }} {{ group.grouper.name }}

-
- {% for card in group.list %} - {% card_badge card expanded=True %} - {% endfor %} -
-
- {% endfor %} - {% elif group_by == 'cardset' %} - {% regroup cards by cardset as grouped_list %} - {% for group in grouped_list %} -
-

{{ group.grouper.name }}

-
- {% for card in group.list %} - {% card_badge card expanded=True %} - {% endfor %} -
-
- {% endfor %} - {% else %} -
- {% for card in cards %} - {% card_badge card expanded=True %} - {% endfor %} +
+
+
+

Cards

- {% endif %} - -
- {% include "templatetags/pagination_controls.html" with page_obj=page_obj %} +
+ + + + +
+
+ +
+ {% include "cards/_card_list.html" with cards=cards page_obj=page_obj %}
{% endblock %} \ No newline at end of file diff --git a/src/pkmntrade_club/theme/templates/home/home.html b/src/pkmntrade_club/theme/templates/home/home.html index 445f940..00de406 100644 --- a/src/pkmntrade_club/theme/templates/home/home.html +++ b/src/pkmntrade_club/theme/templates/home/home.html @@ -45,7 +45,7 @@ Welcome

Card Stats

- {% cache CACHE_MEDIUM_TIMEOUT most_offered_cards cache_key_most_offered_cards %} + {% cache CACHE_TIMEOUT most_offered_cards cache_key_most_offered_cards %}
@@ -58,7 +58,7 @@ Welcome
{% endcache %} - {% cache CACHE_MEDIUM_TIMEOUT most_wanted_cards cache_key_most_wanted_cards %} + {% cache CACHE_TIMEOUT most_wanted_cards cache_key_most_wanted_cards %}
@@ -71,7 +71,7 @@ Welcome
{% endcache %} - {% cache CACHE_MEDIUM_TIMEOUT least_offered_cards cache_key_least_offered_cards %} + {% cache CACHE_TIMEOUT least_offered_cards cache_key_least_offered_cards %}
@@ -90,7 +90,7 @@ Welcome
- {% cache CACHE_MEDIUM_TIMEOUT featured_offers cache_key_featured_offers %} + {% cache CACHE_TIMEOUT featured_offers cache_key_featured_offers %}
Featured Offers
@@ -109,7 +109,7 @@ Welcome
{% endcache %} - {% cache CACHE_MEDIUM_TIMEOUT recent_offers cache_key_recent_offers %} + {% cache CACHE_TIMEOUT recent_offers cache_key_recent_offers %}
Recent Offers
diff --git a/src/pkmntrade_club/theme/templatetags/_card_multiselect_options.html b/src/pkmntrade_club/theme/templatetags/_card_multiselect_options.html new file mode 100644 index 0000000..a1c0b3b --- /dev/null +++ b/src/pkmntrade_club/theme/templatetags/_card_multiselect_options.html @@ -0,0 +1,30 @@ +{% load card_badge card_multiselect %} + +{% for card in cards_to_render %} + {% with card_id_str=card.pk|stringformat:"s" %} {# Ensure card PK is string for lookup #} + {% if card_id_str in selected_cards %} + + {% else %} + + {% endif %} + {% endwith %} +{% endfor %} \ No newline at end of file diff --git a/src/pkmntrade_club/theme/templatetags/card_badge.html b/src/pkmntrade_club/theme/templatetags/card_badge.html index 63086be..721bec8 100644 --- a/src/pkmntrade_club/theme/templatetags/card_badge.html +++ b/src/pkmntrade_club/theme/templatetags/card_badge.html @@ -1,10 +1,6 @@ {% load cache %} -{% cache CACHE_LONG_TIMEOUT cache_key %} - - - {% if clickable %} - - {% endif %} +{% cache CACHE_TIMEOUT card_badge cache_key %} +
{% if not expanded %}
@@ -14,10 +10,6 @@
{{ quantity }}
- {% elif closeable == True %} -
- × -
{% endif %}
{% else %} @@ -27,17 +19,11 @@
{{ quantity }}
- {% elif closeable == True %} -
- × -
{% endif %}
{{ rarity }}
{{ cardset }}
{% endif %}
- {% if clickable %} - - {% endif %} + {% endcache %} \ No newline at end of file diff --git a/src/pkmntrade_club/theme/templatetags/card_multiselect.html b/src/pkmntrade_club/theme/templatetags/card_multiselect.html index 3bf46a1..f69fd1d 100644 --- a/src/pkmntrade_club/theme/templatetags/card_multiselect.html +++ b/src/pkmntrade_club/theme/templatetags/card_multiselect.html @@ -1,267 +1,16 @@ -{% load i18n card_badge %} +{% load cache card_badge %} +{% load cache card_multiselect %} -
- - - {# Hidden select that holds the actual form values #} - - - {# JS-powered search input and selected card display #} -
-
- - -
-
    - -
-
{% trans "Searching..." %}
-
- {% trans "No results found." %} -
-
-
- -
- -
-
- - {# Non-JS fallback search form #} - -
- - - \ No newline at end of file + + \ No newline at end of file diff --git a/src/pkmntrade_club/theme/templatetags/trade_acceptance.html b/src/pkmntrade_club/theme/templatetags/trade_acceptance.html index be35217..13157f3 100644 --- a/src/pkmntrade_club/theme/templatetags/trade_acceptance.html +++ b/src/pkmntrade_club/theme/templatetags/trade_acceptance.html @@ -1,6 +1,6 @@ -{% load gravatar card_badge cache i18n %} +{% load gravatar card_badge cache %} -{% cache CACHE_MEDIUM_TIMEOUT trade_acceptance cache_key %} +{% cache CACHE_TIMEOUT trade_acceptance cache_key %}
diff --git a/src/pkmntrade_club/theme/templatetags/trade_offer.html b/src/pkmntrade_club/theme/templatetags/trade_offer.html index 65246e1..adb011b 100644 --- a/src/pkmntrade_club/theme/templatetags/trade_offer.html +++ b/src/pkmntrade_club/theme/templatetags/trade_offer.html @@ -1,6 +1,6 @@ {% load gravatar card_badge cache %} -{% cache CACHE_MEDIUM_TIMEOUT "trade_offer" offer_pk %} +{% cache CACHE_TIMEOUT trade_offer cache_key %}
+ +{% endcache %} \ No newline at end of file diff --git a/src/pkmntrade_club/theme/templatetags/trade_offer_png.html b/src/pkmntrade_club/theme/templatetags/trade_offer_png.html index 45c2212..bcf9914 100644 --- a/src/pkmntrade_club/theme/templatetags/trade_offer_png.html +++ b/src/pkmntrade_club/theme/templatetags/trade_offer_png.html @@ -54,12 +54,7 @@
- {% for card in have_cards_available %} -
-
{{ card.card.rarity.icon }}
- {% card_badge card.card card.quantity %} -
- {% empty %} + {% for card in have_cards_available %} {% card_badge card.card card.quantity %} {% empty %}
None left.
@@ -71,15 +66,8 @@
- {% for card in want_cards_available %} -
-
{{ card.card.rarity.icon }}
- {% card_badge card.card card.quantity %} -
- {% empty %} -
+ {% for card in want_cards_available %} {% card_badge card.card card.quantity %} {% empty %} +
None left.
{% endfor %} @@ -90,12 +78,7 @@
- {% for card in have_cards_available %} -
-
{{ card.card.rarity.icon }}
- {% card_badge card.card card.quantity %} -
- {% empty %} + {% for card in have_cards_available %} {% card_badge card.card card.quantity %} {% empty %}
None left.
@@ -103,12 +86,7 @@
- {% for card in want_cards_available %} -
-
{{ card.card.rarity.icon }}
- {% card_badge card.card card.quantity %} -
- {% empty %} + {% for card in want_cards_available %} {% card_badge card.card card.quantity %} {% empty %}
diff --git a/src/pkmntrade_club/trades/admin.py b/src/pkmntrade_club/trades/admin.py index dcf85a4..2b3c7fb 100644 --- a/src/pkmntrade_club/trades/admin.py +++ b/src/pkmntrade_club/trades/admin.py @@ -1,107 +1,7 @@ from django.contrib import admin +from .models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance -from .models import TradeAcceptance, TradeOffer, TradeOfferHaveCard, TradeOfferWantCard - - -@admin.register(TradeOffer) -class TradeOfferAdmin(admin.ModelAdmin): - list_display = ( - "hash", - "initiated_by", - "rarity_icon", - "rarity_level", - "is_closed", - "created_at", - "updated_at", - ) - list_filter = ("is_closed", "rarity_level") - search_fields = ("hash", "initiated_by__friend_code", "initiated_by__in_game_name") - readonly_fields = ("hash", "created_at", "updated_at", "image") - list_select_related = ("initiated_by__user",) - autocomplete_fields = ("initiated_by",) - - def get_queryset(self, request): - # The default manager already includes significant prefetching. - return super().get_queryset(request) - - -@admin.register(TradeOfferHaveCard) -class TradeOfferHaveCardAdmin(admin.ModelAdmin): - list_display = ("id", "trade_offer", "card", "quantity", "qty_accepted") - search_fields = ("trade_offer__hash", "card__translations__name") - autocomplete_fields = ("trade_offer", "card") - - def get_queryset(self, request): - return ( - super() - .get_queryset(request) - .select_related( - "trade_offer", - "card__cardset", - "card__rarity", - ) - .prefetch_related("card__translations") - ) - - -@admin.register(TradeOfferWantCard) -class TradeOfferWantCardAdmin(admin.ModelAdmin): - list_display = ("id", "trade_offer", "card", "quantity", "qty_accepted") - search_fields = ("trade_offer__hash", "card__translations__name") - autocomplete_fields = ("trade_offer", "card") - - def get_queryset(self, request): - return ( - super() - .get_queryset(request) - .select_related( - "trade_offer", - "card__cardset", - "card__rarity", - ) - .prefetch_related("card__translations") - ) - - -@admin.register(TradeAcceptance) -class TradeAcceptanceAdmin(admin.ModelAdmin): - list_display = ( - "hash", - "trade_offer", - "accepted_by", - "state", - "created_at", - "updated_at", - ) - list_filter = ("state",) - search_fields = ( - "hash", - "trade_offer__hash", - "accepted_by__friend_code", - "accepted_by__in_game_name", - ) - readonly_fields = ("hash", "created_at", "updated_at") - autocomplete_fields = ( - "trade_offer", - "accepted_by", - "requested_card", - "offered_card", - ) - - def get_queryset(self, request): - return ( - super() - .get_queryset(request) - .select_related( - "trade_offer", - "accepted_by__user", - "requested_card", - "offered_card", - ) - .prefetch_related( - "requested_card__translations", - "offered_card__translations", - "trade_offer__want_cards__translations", - "trade_offer__have_cards__translations", - ) - ) +admin.site.register(TradeOffer) +admin.site.register(TradeOfferHaveCard) +admin.site.register(TradeOfferWantCard) +admin.site.register(TradeAcceptance) diff --git a/src/pkmntrade_club/trades/apps.py b/src/pkmntrade_club/trades/apps.py index 457cb87..a4c30a3 100644 --- a/src/pkmntrade_club/trades/apps.py +++ b/src/pkmntrade_club/trades/apps.py @@ -6,4 +6,4 @@ class TradesConfig(AppConfig): def ready(self): # Implicitly connect signal handlers decorated with @receiver. - pass + import pkmntrade_club.trades.signals diff --git a/src/pkmntrade_club/trades/forms.py b/src/pkmntrade_club/trades/forms.py index 21f7aaa..69f037c 100644 --- a/src/pkmntrade_club/trades/forms.py +++ b/src/pkmntrade_club/trades/forms.py @@ -1,23 +1,20 @@ from django import forms -from django.forms import ModelForm - +from django.core.exceptions import ValidationError +from .models import TradeOffer, TradeAcceptance from pkmntrade_club.accounts.models import FriendCode from pkmntrade_club.cards.models import Card +from django.forms import ModelForm from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard -from .models import TradeAcceptance, TradeOffer - - class NoValidationMultipleChoiceField(forms.MultipleChoiceField): def validate(self, value): # Override the validation to skip checking against defined choices pass - class TradeOfferAcceptForm(forms.Form): friend_code = forms.ModelChoiceField( queryset=FriendCode.objects.none(), - label="Select a Friend Code to Accept This Trade Offer", + label="Select a Friend Code to Accept This Trade Offer" ) def __init__(self, *args, **kwargs): @@ -26,7 +23,6 @@ class TradeOfferAcceptForm(forms.Form): super().__init__(*args, **kwargs) self.fields["friend_code"].queryset = friend_codes - class TradeAcceptanceCreateForm(forms.ModelForm): """ Form for creating a TradeAcceptance. @@ -36,19 +32,11 @@ class TradeAcceptanceCreateForm(forms.ModelForm): - default_friend_code (optional): the user's default FriendCode. It filters available requested and offered cards based on what's still available. """ - class Meta: model = TradeAcceptance fields = ["accepted_by", "requested_card", "offered_card"] - def __init__( - self, - *args, - trade_offer=None, - friend_codes=None, - default_friend_code=None, - **kwargs, - ): + def __init__(self, *args, trade_offer=None, friend_codes=None, default_friend_code=None, **kwargs): if trade_offer is None: raise ValueError("trade_offer must be provided to filter choices.") super().__init__(*args, **kwargs) @@ -64,23 +52,16 @@ class TradeAcceptanceCreateForm(forms.ModelForm): self.initial["accepted_by"] = friend_codes.first().pk self.fields["accepted_by"].widget = forms.HiddenInput() # Otherwise, if a default friend code is provided and it is in the queryset, preselect it. - elif ( - default_friend_code - and friend_codes.filter(pk=default_friend_code.pk).exists() - ): + elif default_friend_code and friend_codes.filter(pk=default_friend_code.pk).exists(): self.initial["accepted_by"] = default_friend_code.pk available_have_items = trade_offer.have_cards_available requested_card_pks = [item.card.pk for item in available_have_items] - self.fields["requested_card"].queryset = Card.objects.filter( - pk__in=requested_card_pks - ).order_by("name") + self.fields["requested_card"].queryset = Card.objects.filter(pk__in=requested_card_pks).order_by('name') available_want_items = trade_offer.want_cards_available offered_card_pks = [item.card.pk for item in available_want_items] - self.fields["offered_card"].queryset = Card.objects.filter( - pk__in=offered_card_pks - ).order_by("name") + self.fields["offered_card"].queryset = Card.objects.filter(pk__in=offered_card_pks).order_by('name') def clean(self): """ @@ -90,11 +71,9 @@ class TradeAcceptanceCreateForm(forms.ModelForm): self.instance.trade_offer = self.trade_offer return super().clean() - class ButtonRadioSelect(forms.RadioSelect): template_name = "widgets/button_radio_select.html" - class TradeAcceptanceTransitionForm(forms.Form): state = forms.ChoiceField(widget=forms.HiddenInput()) @@ -108,18 +87,13 @@ class TradeAcceptanceTransitionForm(forms.Form): raise ValueError("A TradeAcceptance instance must be provided") self.instance = instance self.user = user - + self.fields["state"].choices = instance.get_allowed_state_transitions(user) - class TradeOfferCreateForm(ModelForm): # Override the default fields to capture quantity info in the format 'card_id:quantity' - have_cards = NoValidationMultipleChoiceField( - widget=forms.SelectMultiple, required=True - ) - want_cards = NoValidationMultipleChoiceField( - widget=forms.SelectMultiple, required=True - ) + have_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True) + want_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True) class Meta: model = TradeOffer @@ -128,7 +102,7 @@ class TradeOfferCreateForm(ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Populate choices from Card model using the new field 'rarity_level' instead of the removed relation. - cards = Card.objects.order_by("name", "rarity__level") + cards = Card.objects.order_by("name", "rarity_level") choices = [(str(card.pk), card.name) for card in cards] self.fields["have_cards"].choices = choices self.fields["want_cards"].choices = choices @@ -137,10 +111,10 @@ class TradeOfferCreateForm(ModelForm): data = self.data.getlist("have_cards") parsed = {} for item in data: - if ":" not in item: + if ':' not in item: # Ignore any input without a colon. continue - parts = item.split(":") + parts = item.split(':') card_id = parts[0] try: # Only parse quantity when a colon is present. @@ -157,18 +131,16 @@ class TradeOfferCreateForm(ModelForm): ) # Ensure no more than 20 unique have cards are selected. if len(parsed) > 20: - raise forms.ValidationError( - "You can only select a maximum of 20 unique have cards." - ) + raise forms.ValidationError("You can only select a maximum of 20 unique have cards.") return parsed def clean_want_cards(self): data = self.data.getlist("want_cards") parsed = {} for item in data: - if ":" not in item: + if ':' not in item: continue - parts = item.split(":") + parts = item.split(':') card_id = parts[0] try: quantity = int(parts[1]) @@ -185,9 +157,7 @@ class TradeOfferCreateForm(ModelForm): ) # Ensure no more than 20 unique want cards are selected. if len(parsed) > 20: - raise forms.ValidationError( - "You can only select a maximum of 20 unique want cards." - ) + raise forms.ValidationError("You can only select a maximum of 20 unique want cards.") return parsed def save(self, commit=True): @@ -197,19 +167,15 @@ class TradeOfferCreateForm(ModelForm): # Clear any existing through model entries in case of update TradeOfferHaveCard.objects.filter(trade_offer=instance).delete() TradeOfferWantCard.objects.filter(trade_offer=instance).delete() - + # Create through entries for have_cards for card_id, quantity in self.cleaned_data["have_cards"].items(): card = Card.objects.get(pk=card_id) - TradeOfferHaveCard.objects.create( - trade_offer=instance, card=card, quantity=quantity - ) + TradeOfferHaveCard.objects.create(trade_offer=instance, card=card, quantity=quantity) # Create through entries for want_cards for card_id, quantity in self.cleaned_data["want_cards"].items(): card = Card.objects.get(pk=card_id) - TradeOfferWantCard.objects.create( - trade_offer=instance, card=card, quantity=quantity - ) + TradeOfferWantCard.objects.create(trade_offer=instance, card=card, quantity=quantity) return instance diff --git a/src/pkmntrade_club/trades/migrations/0001_initial.py b/src/pkmntrade_club/trades/migrations/0001_initial.py index 2852cdf..9e4f4c0 100644 --- a/src/pkmntrade_club/trades/migrations/0001_initial.py +++ b/src/pkmntrade_club/trades/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1 on 2025-06-15 03:44 +# Generated by Django 5.1 on 2025-05-10 01:22 import django.db.models.deletion from django.db import migrations, models @@ -9,191 +9,75 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("accounts", "0001_initial"), - ("cards", "0001_initial"), + ('accounts', '0001_initial'), + ('cards', '0001_initial'), ] operations = [ migrations.CreateModel( - name="TradeOffer", + name='TradeOffer', fields=[ - ("id", models.AutoField(primary_key=True, serialize=False)), - ("is_closed", models.BooleanField(db_index=True, default=False)), - ("hash", models.CharField(editable=False, max_length=9)), - ("rarity_icon", models.CharField(max_length=8, null=True)), - ("rarity_level", models.IntegerField(null=True)), - ( - "image", - models.ImageField(blank=True, null=True, upload_to="trade_offers/"), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "initiated_by", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="initiated_trade_offers", - to="accounts.friendcode", - ), - ), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('is_closed', models.BooleanField(db_index=True, default=False)), + ('hash', models.CharField(editable=False, max_length=9)), + ('rarity_icon', models.CharField(max_length=8, null=True)), + ('rarity_level', models.IntegerField(null=True)), + ('image', models.ImageField(blank=True, null=True, upload_to='trade_offers/')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_trade_offers', to='accounts.friendcode')), ], ), migrations.CreateModel( - name="TradeAcceptance", + name='TradeAcceptance', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "state", - models.CharField( - choices=[ - ("ACCEPTED", "Accepted"), - ("SENT", "Sent"), - ("RECEIVED", "Received"), - ("THANKED_BY_INITIATOR", "Thanked by Initiator"), - ("THANKED_BY_ACCEPTOR", "Thanked by Acceptor"), - ("THANKED_BY_BOTH", "Thanked by Both"), - ("REJECTED_BY_INITIATOR", "Rejected by Initiator"), - ("REJECTED_BY_ACCEPTOR", "Rejected by Acceptor"), - ], - default="ACCEPTED", - max_length=25, - ), - ), - ("hash", models.CharField(blank=True, editable=False, max_length=9)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "accepted_by", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="trade_acceptances", - to="accounts.friendcode", - ), - ), - ( - "offered_card", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="accepted_offered", - to="cards.card", - ), - ), - ( - "requested_card", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="accepted_requested", - to="cards.card", - ), - ), - ( - "trade_offer", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="acceptances", - to="trades.tradeoffer", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('state', models.CharField(choices=[('ACCEPTED', 'Accepted'), ('SENT', 'Sent'), ('RECEIVED', 'Received'), ('THANKED_BY_INITIATOR', 'Thanked by Initiator'), ('THANKED_BY_ACCEPTOR', 'Thanked by Acceptor'), ('THANKED_BY_BOTH', 'Thanked by Both'), ('REJECTED_BY_INITIATOR', 'Rejected by Initiator'), ('REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor')], default='ACCEPTED', max_length=25)), + ('hash', models.CharField(blank=True, editable=False, max_length=9)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('accepted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='trade_acceptances', to='accounts.friendcode')), + ('offered_card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accepted_offered', to='cards.card')), + ('requested_card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accepted_requested', to='cards.card')), + ('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='acceptances', to='trades.tradeoffer')), ], ), migrations.CreateModel( - name="TradeOfferHaveCard", + name='TradeOfferHaveCard', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("quantity", models.PositiveIntegerField(default=1)), - ( - "qty_accepted", - models.PositiveIntegerField(default=0, editable=False), - ), - ( - "card", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, to="cards.card" - ), - ), - ( - "trade_offer", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="trade_offer_have_cards", - to="trades.tradeoffer", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1)), + ('qty_accepted', models.PositiveIntegerField(default=0, editable=False)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cards.card')), + ('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trade_offer_have_cards', to='trades.tradeoffer')), ], options={ - "ordering": ["card__translations__name"], - "unique_together": {("trade_offer", "card")}, + 'ordering': ['card__name'], + 'unique_together': {('trade_offer', 'card')}, }, ), migrations.AddField( - model_name="tradeoffer", - name="have_cards", - field=models.ManyToManyField( - related_name="trade_offers_have", - through="trades.TradeOfferHaveCard", - to="cards.card", - ), + model_name='tradeoffer', + name='have_cards', + field=models.ManyToManyField(related_name='trade_offers_have', through='trades.TradeOfferHaveCard', to='cards.card'), ), migrations.CreateModel( - name="TradeOfferWantCard", + name='TradeOfferWantCard', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("quantity", models.PositiveIntegerField(default=1)), - ( - "qty_accepted", - models.PositiveIntegerField(default=0, editable=False), - ), - ( - "card", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, to="cards.card" - ), - ), - ( - "trade_offer", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="trade_offer_want_cards", - to="trades.tradeoffer", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1)), + ('qty_accepted', models.PositiveIntegerField(default=0, editable=False)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cards.card')), + ('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trade_offer_want_cards', to='trades.tradeoffer')), ], options={ - "ordering": ["card__translations__name"], - "unique_together": {("trade_offer", "card")}, + 'ordering': ['card__name'], + 'unique_together': {('trade_offer', 'card')}, }, ), migrations.AddField( - model_name="tradeoffer", - name="want_cards", - field=models.ManyToManyField( - related_name="trade_offers_want", - through="trades.TradeOfferWantCard", - to="cards.card", - ), + model_name='tradeoffer', + name='want_cards', + field=models.ManyToManyField(related_name='trade_offers_want', through='trades.TradeOfferWantCard', to='cards.card'), ), ] diff --git a/src/pkmntrade_club/trades/mixins.py b/src/pkmntrade_club/trades/mixins.py index 13ad244..36d4272 100644 --- a/src/pkmntrade_club/trades/mixins.py +++ b/src/pkmntrade_club/trades/mixins.py @@ -1,52 +1,37 @@ -from django.core.exceptions import PermissionDenied - from pkmntrade_club.cards.models import Card - +from django.core.exceptions import PermissionDenied class TradeOfferContextMixin: def get_context_data(self, **kwargs): # Start with any context passed in. context = kwargs.copy() # Include available cards requirements for multiselect fields. - context.setdefault( - "cards", Card.objects.all().order_by("name", "rarity__level") - ) - + context.setdefault("cards", Card.objects.all().order_by("name", "rarity_level")) + # Provide friend_codes and selected_friend_code as in TradeOfferCreateView friend_codes = self.request.user.friend_codes.all() context["friend_codes"] = friend_codes - + if "initiated_by" in self.request.GET: try: - selected_friend_code = friend_codes.get( - pk=self.request.GET.get("initiated_by") - ) + selected_friend_code = friend_codes.get(pk=self.request.GET.get("initiated_by")) except friend_codes.model.DoesNotExist: - selected_friend_code = ( - self.request.user.default_friend_code or friend_codes.first() - ) + selected_friend_code = self.request.user.default_friend_code or friend_codes.first() else: - selected_friend_code = ( - self.request.user.default_friend_code or friend_codes.first() - ) + selected_friend_code = self.request.user.default_friend_code or friend_codes.first() context["selected_friend_code"] = selected_friend_code - - return context - + + return context class FriendCodeRequiredMixin: """ Mixin to ensure the authenticated user has at least one friend code. This mixin must be placed after LoginRequiredMixin in the view's inheritance order. """ - def dispatch(self, request, *args, **kwargs): # Since LoginRequiredMixin guarantees that request.user is authenticated, # we assume request.user has the attribute `friend_codes`. If no friend code exists, # raise a PermissionDenied error. - if ( - not getattr(request.user, "friend_codes", None) - or not request.user.friend_codes.exists() - ): + if not getattr(request.user, 'friend_codes', None) or not request.user.friend_codes.exists(): raise PermissionDenied("No friend codes available for your account.") - return super().dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) \ No newline at end of file diff --git a/src/pkmntrade_club/trades/models.py b/src/pkmntrade_club/trades/models.py index 8df03cc..1e50eff 100644 --- a/src/pkmntrade_club/trades/models.py +++ b/src/pkmntrade_club/trades/models.py @@ -1,12 +1,12 @@ -import hashlib -import uuid - -from django.core.exceptions import ValidationError from django.db import models -from django.db.models import F, Prefetch - +from django.core.exceptions import ValidationError +from django.db.models import Q, Count, Prefetch, F, Sum, Max +import hashlib from pkmntrade_club.cards.models import Card - +from pkmntrade_club.accounts.models import FriendCode +from datetime import timedelta +from django.utils import timezone +import uuid def generate_tradeoffer_hash(): """ @@ -15,7 +15,6 @@ def generate_tradeoffer_hash(): """ return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "z" - def generate_tradeacceptance_hash(): """ Generates a unique 9-character hash for a TradeAcceptance. @@ -23,57 +22,34 @@ def generate_tradeacceptance_hash(): """ return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "y" - class TradeOfferManager(models.Manager): + def get_queryset(self): qs = super().get_queryset() - card_prefetch_selects = [ - "card__rarity", - "card__cardset", - "card__card_type", - "card__pkmn_type", - "card__weakness_type", - ] - card_prefetch_related = [ - "card__translations", - "card__rarity__translations", - "card__cardset__translations", - "card__card_type__translations", - "card__pkmn_type__translations", - "card__weakness_type__translations", - "card__attacks__translations", - "card__abilities__translations", - ] - # Prefetch for have_cards (through model: TradeOfferHaveCard) + # Ensures 'card' is select_related and 'Meta.ordering' is respected/applied. prefetch_have_cards = Prefetch( - "trade_offer_have_cards", - queryset=TradeOfferHaveCard.objects.select_related( - *card_prefetch_selects - ).prefetch_related(*card_prefetch_related), + 'trade_offer_have_cards', + queryset=TradeOfferHaveCard.objects.select_related('card').order_by('card__name') ) # Prefetch for want_cards (through model: TradeOfferWantCard) + # Ensures 'card' is select_related and 'Meta.ordering' is respected/applied. prefetch_want_cards = Prefetch( - "trade_offer_want_cards", - queryset=TradeOfferWantCard.objects.select_related( - *card_prefetch_selects - ).prefetch_related(*card_prefetch_related), + 'trade_offer_want_cards', + queryset=TradeOfferWantCard.objects.select_related('card').order_by('card__name') ) # Prefetch for acceptances + # Ensures related 'accepted_by__user', 'requested_card', 'offered_card' are fetched. prefetch_acceptances = Prefetch( - "acceptances", + 'acceptances', queryset=TradeAcceptance.objects.select_related( - "accepted_by__user", - "requested_card__rarity", - "requested_card__cardset", - "offered_card__rarity", - "offered_card__cardset", - ).prefetch_related( - "requested_card__translations", "offered_card__translations" - ), + 'accepted_by__user', + 'requested_card', + 'offered_card' + ).order_by('-created_at') # Sensible default ordering for acceptances ) qs = qs.select_related( @@ -82,19 +58,13 @@ class TradeOfferManager(models.Manager): prefetch_have_cards, prefetch_want_cards, prefetch_acceptances, - # If direct access like offer.have_cards.all() is used, prefetch cards with their rarity - Prefetch( - "have_cards", - queryset=Card.objects.with_details(), - ), - Prefetch( - "want_cards", - queryset=Card.objects.with_details(), - ), + # If direct access like offer.have_cards.all() (the M2M to Card, not through model) + # is heavily used AND causes N+1s (e.g. via __str__), uncomment these: + Prefetch('have_cards'), + Prefetch('want_cards'), ) - - return qs.order_by("-updated_at") # Default ordering for TradeOffer querysets - + + return qs.order_by("-updated_at") # Default ordering for TradeOffer querysets class TradeOffer(models.Model): objects = TradeOfferManager() @@ -105,16 +75,20 @@ class TradeOffer(models.Model): initiated_by = models.ForeignKey( "accounts.FriendCode", on_delete=models.PROTECT, - related_name="initiated_trade_offers", + related_name='initiated_trade_offers' ) rarity_icon = models.CharField(max_length=8, null=True) rarity_level = models.IntegerField(null=True) - image = models.ImageField(upload_to="trade_offers/", null=True, blank=True) + image = models.ImageField(upload_to='trade_offers/', null=True, blank=True) want_cards = models.ManyToManyField( - "cards.Card", related_name="trade_offers_want", through="TradeOfferWantCard" + "cards.Card", + related_name='trade_offers_want', + through="TradeOfferWantCard" ) have_cards = models.ManyToManyField( - "cards.Card", related_name="trade_offers_have", through="TradeOfferHaveCard" + "cards.Card", + related_name='trade_offers_have', + through="TradeOfferHaveCard" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -131,62 +105,49 @@ class TradeOffer(models.Model): def update_rarity_fields(self): """ - Recalculates and updates the rarity_level and rarity_icon fields based on + Recalculates and updates the rarity_level and rarity_icon fields based on the associated have_cards and want_cards. - Enforces that all cards in a trade offer share the same rarity. + Enforces that all cards in the trade offer share the same rarity. Uses the first card's rarity details to update both fields. """ - # Gather all cards from both sides, using select_related to prevent N+1 queries on rarity. - cards = list(self.have_cards.select_related("rarity").all()) + list( - self.want_cards.select_related("rarity").all() - ) + # Gather all cards from both sides. + cards = list(self.have_cards.all()) + list(self.want_cards.all()) if not cards: - self.rarity_level = None - self.rarity_icon = None - super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"]) return - # Enforce same rarity across all cards. - rarity_levels = {card.rarity.level for card in cards} + rarity_levels = {card.rarity_level for card in cards} if len(rarity_levels) > 1: - raise ValidationError( - "All cards in a trade offer must have the same rarity." - ) + raise ValidationError("All cards in a trade offer must have the same rarity.") first_card = cards[0] - if first_card.rarity.level > 5: + if first_card.rarity_level > 5: raise ValidationError("Cannot trade cards above one-star rarity.") - if ( - self.rarity_level != first_card.rarity.level - or self.rarity_icon != first_card.rarity.icon - ): - self.rarity_level = first_card.rarity.level - self.rarity_icon = first_card.rarity.icon + if self.rarity_level != first_card.rarity_level or self.rarity_icon != first_card.rarity_icon: + self.rarity_level = first_card.rarity_level + self.rarity_icon = first_card.rarity_icon # Use super().save() here to avoid recursion. super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"]) @property def have_cards_available(self): - # Returns a queryset of have_cards that still have available quantity. - return self.trade_offer_have_cards.filter(quantity__gt=F("qty_accepted")) + # Returns the list of have_cards (through objects) that still have available quantity. + return [item for item in self.trade_offer_have_cards.all() if item.quantity > item.qty_accepted] @property def want_cards_available(self): - # Returns a queryset of want_cards that still have available quantity. - return self.trade_offer_want_cards.filter(quantity__gt=F("qty_accepted")) - + # Returns the list of want_cards (through objects) that still have available quantity. + return [item for item in self.trade_offer_want_cards.all() if item.quantity > item.qty_accepted] class TradeOfferHaveCard(models.Model): """ Through model for TradeOffer.have_cards. Represents the card the initiator is offering along with the quantity available. """ - trade_offer = models.ForeignKey( TradeOffer, on_delete=models.CASCADE, - related_name="trade_offer_have_cards", - db_index=True, + related_name='trade_offer_have_cards', + db_index=True ) card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True) quantity = models.PositiveIntegerField(default=1) @@ -197,7 +158,7 @@ class TradeOfferHaveCard(models.Model): return self.quantity - self.qty_accepted def __str__(self): - return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity.icon} {self.card.name}" + return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity_icon} {self.card.name}" def save(self, *args, **kwargs): self.trade_offer.update_rarity_fields() @@ -210,17 +171,17 @@ class TradeOfferHaveCard(models.Model): class Meta: unique_together = ("trade_offer", "card") - ordering = ["card__translations__name"] - + ordering = ['card__name'] class TradeOfferWantCard(models.Model): """ Through model for TradeOffer.want_cards. Represents the card the initiator is requesting along with the quantity requested. """ - trade_offer = models.ForeignKey( - TradeOffer, on_delete=models.CASCADE, related_name="trade_offer_want_cards" + TradeOffer, + on_delete=models.CASCADE, + related_name='trade_offer_want_cards' ) card = models.ForeignKey("cards.Card", on_delete=models.PROTECT) quantity = models.PositiveIntegerField(default=1) @@ -231,7 +192,7 @@ class TradeOfferWantCard(models.Model): return self.quantity - self.qty_accepted def __str__(self): - return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity.icon} {self.card.name}" + return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity_icon} {self.card.name}" def save(self, *args, **kwargs): super().save(*args, **kwargs) @@ -244,20 +205,19 @@ class TradeOfferWantCard(models.Model): class Meta: unique_together = ("trade_offer", "card") - ordering = ["card__translations__name"] - + ordering = ['card__name'] class TradeAcceptance(models.Model): class AcceptanceState(models.TextChoices): - ACCEPTED = "ACCEPTED", "Accepted" - SENT = "SENT", "Sent" - RECEIVED = "RECEIVED", "Received" - THANKED_BY_INITIATOR = "THANKED_BY_INITIATOR", "Thanked by Initiator" - THANKED_BY_ACCEPTOR = "THANKED_BY_ACCEPTOR", "Thanked by Acceptor" - THANKED_BY_BOTH = "THANKED_BY_BOTH", "Thanked by Both" - REJECTED_BY_INITIATOR = "REJECTED_BY_INITIATOR", "Rejected by Initiator" - REJECTED_BY_ACCEPTOR = "REJECTED_BY_ACCEPTOR", "Rejected by Acceptor" - + ACCEPTED = 'ACCEPTED', 'Accepted' + SENT = 'SENT', 'Sent' + RECEIVED = 'RECEIVED', 'Received' + THANKED_BY_INITIATOR = 'THANKED_BY_INITIATOR', 'Thanked by Initiator' + THANKED_BY_ACCEPTOR = 'THANKED_BY_ACCEPTOR', 'Thanked by Acceptor' + THANKED_BY_BOTH = 'THANKED_BY_BOTH', 'Thanked by Both' + REJECTED_BY_INITIATOR = 'REJECTED_BY_INITIATOR', 'Rejected by Initiator' + REJECTED_BY_ACCEPTOR = 'REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor' + # DRY improvement: define active states once as a class-level constant. POSITIVE_STATES = [ AcceptanceState.ACCEPTED, @@ -269,21 +229,30 @@ class TradeAcceptance(models.Model): ] trade_offer = models.ForeignKey( - TradeOffer, on_delete=models.CASCADE, related_name="acceptances", db_index=True + TradeOffer, + on_delete=models.CASCADE, + related_name='acceptances', + db_index=True ) accepted_by = models.ForeignKey( "accounts.FriendCode", on_delete=models.PROTECT, - related_name="trade_acceptances", + related_name='trade_acceptances' ) requested_card = models.ForeignKey( - "cards.Card", on_delete=models.PROTECT, related_name="accepted_requested" + "cards.Card", + on_delete=models.PROTECT, + related_name='accepted_requested' ) offered_card = models.ForeignKey( - "cards.Card", on_delete=models.PROTECT, related_name="accepted_offered" + "cards.Card", + on_delete=models.PROTECT, + related_name='accepted_offered' ) state = models.CharField( - max_length=25, choices=AcceptanceState.choices, default=AcceptanceState.ACCEPTED + max_length=25, + choices=AcceptanceState.choices, + default=AcceptanceState.ACCEPTED ) hash = models.CharField(max_length=9, editable=False, blank=True) created_at = models.DateTimeField(auto_now_add=True) @@ -338,14 +307,11 @@ class TradeAcceptance(models.Model): return self.get_action_label_for_state(self.AcceptanceState.SENT) elif self.state == self.AcceptanceState.SENT: return self.get_action_label_for_state(self.AcceptanceState.RECEIVED) - elif ( - self.state == self.AcceptanceState.RECEIVED - or self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR - or self.state == self.AcceptanceState.THANKED_BY_INITIATOR - ): + elif self.state == self.AcceptanceState.RECEIVED or self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR or self.state == self.AcceptanceState.THANKED_BY_INITIATOR: return self.get_action_label_for_state(self.AcceptanceState.THANKED_BY_BOTH) else: return None + @classmethod def get_action_label_for_state_2(cls, state_value): @@ -365,20 +331,12 @@ class TradeAcceptance(models.Model): @property def is_initiator_state(self): - return self.state in [ - self.AcceptanceState.SENT.value, - self.AcceptanceState.THANKED_BY_INITIATOR.value, - self.AcceptanceState.THANKED_BY_BOTH.value, - ] - + return self.state in [self.AcceptanceState.SENT.value, self.AcceptanceState.THANKED_BY_INITIATOR.value, self.AcceptanceState.THANKED_BY_BOTH.value] + @property def is_acceptor_state(self): - return self.state in [ - self.AcceptanceState.ACCEPTED.value, - self.AcceptanceState.RECEIVED.value, - self.AcceptanceState.THANKED_BY_ACCEPTOR.value, - self.AcceptanceState.THANKED_BY_BOTH.value, - ] + return self.state in [self.AcceptanceState.ACCEPTED.value, self.AcceptanceState.RECEIVED.value, self.AcceptanceState.THANKED_BY_ACCEPTOR.value, self.AcceptanceState.THANKED_BY_BOTH.value] + @property def is_completed(self): @@ -410,30 +368,19 @@ class TradeAcceptance(models.Model): def clean(self): from django.core.exceptions import ValidationError - try: - have_card = self.trade_offer.trade_offer_have_cards.get( - card_id=self.requested_card_id - ) + have_card = self.trade_offer.trade_offer_have_cards.get(card_id=self.requested_card_id) except TradeOfferHaveCard.DoesNotExist: - raise ValidationError( - "The requested card must be one of the trade offer's available cards (have_cards)." - ) + raise ValidationError("The requested card must be one of the trade offer's available cards (have_cards).") try: - want_card = self.trade_offer.trade_offer_want_cards.get( - card_id=self.offered_card_id - ) + want_card = self.trade_offer.trade_offer_want_cards.get(card_id=self.offered_card_id) except TradeOfferWantCard.DoesNotExist: - raise ValidationError( - "The offered card must be one of the trade offer's requested cards (want_cards)." - ) + raise ValidationError("The offered card must be one of the trade offer's requested cards (want_cards).") # Only perform these validations on creation (when self.pk is None). if self.pk is None: if self.trade_offer.is_closed: - raise ValidationError( - "This trade offer is closed. No more acceptances are allowed." - ) + raise ValidationError("This trade offer is closed. No more acceptances are allowed.") # Use direct comparison with qty_accepted and quantity. if have_card.qty_accepted >= have_card.quantity: raise ValidationError("The requested card has no available quantity.") @@ -456,42 +403,26 @@ class TradeAcceptance(models.Model): ]: return 0 else: - return ( - next( - index - for index, choice in enumerate(self.AcceptanceState.choices) - if choice[0] == self.state - ) - + 1 - ) + return next(index for index, choice in enumerate(self.AcceptanceState.choices) if choice[0] == self.state) + 1 def update_state(self, new_state, user): if new_state not in [choice[0] for choice in self.AcceptanceState.choices]: raise ValueError(f"'{new_state}' is not a valid state.") - if ( - new_state == self.AcceptanceState.THANKED_BY_ACCEPTOR - and self.state == self.AcceptanceState.THANKED_BY_INITIATOR - ) or ( - new_state == self.AcceptanceState.THANKED_BY_INITIATOR - and self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR - ): + if (new_state == self.AcceptanceState.THANKED_BY_ACCEPTOR and self.state == self.AcceptanceState.THANKED_BY_INITIATOR) or \ + (new_state == self.AcceptanceState.THANKED_BY_INITIATOR and self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR): new_state = self.AcceptanceState.THANKED_BY_BOTH if self.state in [ self.AcceptanceState.THANKED_BY_BOTH, self.AcceptanceState.REJECTED_BY_INITIATOR, - self.AcceptanceState.REJECTED_BY_ACCEPTOR, + self.AcceptanceState.REJECTED_BY_ACCEPTOR ]: - raise ValueError( - f"No transitions allowed from the terminal state '{self.state}'." - ) + raise ValueError(f"No transitions allowed from the terminal state '{self.state}'.") allowed = [x for x, y in self.get_allowed_state_transitions(user)] if new_state not in allowed: - raise ValueError( - f"Transition from {self.state} to {new_state} is not allowed." - ) + raise ValueError(f"Transition from {self.state} to {new_state} is not allowed.") self._actioning_user = user self.state = new_state @@ -503,12 +434,10 @@ class TradeAcceptance(models.Model): super().save(*args, **kwargs) def __str__(self): - return ( - f"TradeAcceptance(offer_hash={self.trade_offer.hash}, " - f"accepted_by={self.accepted_by}, " - f"requested_card={self.requested_card}, " - f"offered_card={self.offered_card}, state={self.state})" - ) + return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, " + f"accepted_by={self.accepted_by}, " + f"requested_card={self.requested_card}, " + f"offered_card={self.offered_card}, state={self.state})") def get_allowed_state_transitions(self, user): if self.trade_offer.initiated_by in user.friend_codes.all(): @@ -524,7 +453,7 @@ class TradeAcceptance(models.Model): self.AcceptanceState.THANKED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_INITIATOR, }, - self.AcceptanceState.THANKED_BY_INITIATOR: {}, + self.AcceptanceState.THANKED_BY_INITIATOR: { }, self.AcceptanceState.THANKED_BY_ACCEPTOR: { self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.THANKED_BY_BOTH, @@ -540,10 +469,10 @@ class TradeAcceptance(models.Model): self.AcceptanceState.REJECTED_BY_ACCEPTOR, }, self.AcceptanceState.RECEIVED: { - self.AcceptanceState.THANKED_BY_ACCEPTOR, # allow early thanks (uses THANKED_BY_ACCEPTOR state) - self.AcceptanceState.REJECTED_BY_ACCEPTOR, + self.AcceptanceState.THANKED_BY_ACCEPTOR, #allow early thanks (uses THANKED_BY_ACCEPTOR state) + self.AcceptanceState.REJECTED_BY_ACCEPTOR }, - self.AcceptanceState.THANKED_BY_ACCEPTOR: {}, + self.AcceptanceState.THANKED_BY_ACCEPTOR: { }, self.AcceptanceState.THANKED_BY_INITIATOR: { self.AcceptanceState.THANKED_BY_BOTH, }, diff --git a/src/pkmntrade_club/trades/signals.py b/src/pkmntrade_club/trades/signals.py index 647b5bc..37caddb 100644 --- a/src/pkmntrade_club/trades/signals.py +++ b/src/pkmntrade_club/trades/signals.py @@ -1,18 +1,19 @@ +from django.db.models.signals import post_save, post_delete, pre_save +from django.dispatch import receiver +from django.db.models import F +from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance, TradeOffer +from django.db import transaction +from pkmntrade_club.accounts.models import CustomUser +from datetime import timedelta +from django.utils import timezone +import uuid +import hashlib +from django.core.mail import send_mail +from django.conf import settings +from django.template.loader import render_to_string from django.contrib.sites.models import Site from django.core.cache import cache -from django.core.mail import send_mail -from django.db.models import F -from django.db.models.signals import post_delete, post_save, pre_save -from django.dispatch import receiver -from django.template.loader import render_to_string - -from pkmntrade_club.accounts.models import CustomUser -from pkmntrade_club.trades.models import ( - TradeAcceptance, - TradeOffer, - TradeOfferHaveCard, - TradeOfferWantCard, -) +import logging POSITIVE_STATES = [ TradeAcceptance.AcceptanceState.ACCEPTED, @@ -23,20 +24,20 @@ POSITIVE_STATES = [ TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, ] - def adjust_qty_for_trade_offer(trade_offer, card, side, delta): """ Increment (or decrement) qty_accepted by delta for the given card on the specified side. """ - if side == "have": - TradeOfferHaveCard.objects.filter(trade_offer=trade_offer, card=card).update( - qty_accepted=F("qty_accepted") + delta - ) - elif side == "want": - TradeOfferWantCard.objects.filter(trade_offer=trade_offer, card=card).update( - qty_accepted=F("qty_accepted") + delta - ) - + if side == 'have': + TradeOfferHaveCard.objects.filter( + trade_offer=trade_offer, + card=card + ).update(qty_accepted=F('qty_accepted') + delta) + elif side == 'want': + TradeOfferWantCard.objects.filter( + trade_offer=trade_offer, + card=card + ).update(qty_accepted=F('qty_accepted') + delta) def update_trade_offer_closed_status(trade_offer): """ @@ -45,17 +46,18 @@ def update_trade_offer_closed_status(trade_offer): greater than or equal to quantity; otherwise, mark it as open. """ have_complete = not TradeOfferHaveCard.objects.filter( - trade_offer=trade_offer, qty_accepted__lt=F("quantity") + trade_offer=trade_offer, + qty_accepted__lt=F('quantity') ).exists() want_complete = not TradeOfferWantCard.objects.filter( - trade_offer=trade_offer, qty_accepted__lt=F("quantity") + trade_offer=trade_offer, + qty_accepted__lt=F('quantity') ).exists() closed = have_complete or want_complete if trade_offer.is_closed != closed: trade_offer.is_closed = closed trade_offer.save(update_fields=["is_closed"]) - @receiver(pre_save, sender=TradeAcceptance) def trade_acceptance_pre_save(sender, instance, **kwargs): # Skip signal processing during raw fixture load or when saving a new instance @@ -66,7 +68,6 @@ def trade_acceptance_pre_save(sender, instance, **kwargs): old_instance = TradeAcceptance.objects.get(pk=instance.pk) instance._old_state = old_instance.state - @receiver(post_save, sender=TradeAcceptance) def trade_acceptance_post_save(sender, instance, created, **kwargs): delta = 0 @@ -74,7 +75,7 @@ def trade_acceptance_post_save(sender, instance, created, **kwargs): if instance.state in POSITIVE_STATES: delta = 1 else: - old_state = getattr(instance, "_old_state", None) + old_state = getattr(instance, '_old_state', None) if old_state is not None: if old_state in POSITIVE_STATES and instance.state not in POSITIVE_STATES: delta = -1 @@ -83,49 +84,29 @@ def trade_acceptance_post_save(sender, instance, created, **kwargs): if delta != 0: trade_offer = instance.trade_offer - adjust_qty_for_trade_offer( - trade_offer, instance.requested_card, side="have", delta=delta - ) - adjust_qty_for_trade_offer( - trade_offer, instance.offered_card, side="want", delta=delta - ) + adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta) + adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta) update_trade_offer_closed_status(trade_offer) - @receiver(post_delete, sender=TradeAcceptance) def trade_acceptance_post_delete(sender, instance, **kwargs): if instance.state in POSITIVE_STATES: delta = -1 trade_offer = instance.trade_offer - adjust_qty_for_trade_offer( - trade_offer, instance.requested_card, side="have", delta=delta - ) - adjust_qty_for_trade_offer( - trade_offer, instance.offered_card, side="want", delta=delta - ) + adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta) + adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta) update_trade_offer_closed_status(trade_offer) - @receiver(post_save, sender=TradeAcceptance) def trade_acceptance_email_notification(sender, instance, created, **kwargs): # Only proceed if the update was triggered by an acting user. if not hasattr(instance, "_actioning_user"): return - # Re-fetch instance with related data to avoid N+1 queries. - instance = ( - TradeAcceptance.objects.select_related( - "trade_offer__initiated_by__user", - "accepted_by__user", - "requested_card", - "offered_card", - ) - .prefetch_related( - "requested_card__translations", - "offered_card__translations", - ) - .get(pk=instance.pk) - ) + # check if were in debug mode + # if settings.DEBUG: + # print("DEBUG: skipping email notification in debug mode") + # return acting_user = instance._actioning_user del instance._actioning_user @@ -151,6 +132,7 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs): else: return + # Determine the non-acting party: if instance.trade_offer.initiated_by.user.pk == acting_user.pk: # The initiator made the change; notify the acceptor. @@ -171,31 +153,17 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs): "want_card": instance.offered_card, "hash": instance.hash, "acting_user": acting_user.username, - "acting_user_ign": ( - instance.trade_offer.initiated_by.in_game_name - if is_initiator - else instance.accepted_by.in_game_name - ), + "acting_user_ign": instance.trade_offer.initiated_by.in_game_name if is_initiator else instance.accepted_by.in_game_name, "recipient_user": recipient_user.username, - "recipient_user_ign": ( - instance.accepted_by.in_game_name - if is_initiator - else instance.trade_offer.initiated_by.in_game_name - ), - "acting_user_friend_code": ( - instance.trade_offer.initiated_by.friend_code - if is_initiator - else instance.accepted_by.friend_code - ), + "recipient_user_ign": instance.accepted_by.in_game_name if is_initiator else instance.trade_offer.initiated_by.in_game_name, + "acting_user_friend_code": instance.trade_offer.initiated_by.friend_code if is_initiator else instance.accepted_by.friend_code, "is_initiator": is_initiator, "domain": "https://" + Site.objects.get_current().domain, "pk": instance.pk, } email_template = "email/trades/trade_update_" + state + ".txt" email_subject = render_to_string("email/common/subject.txt", email_context) - email_subject += render_to_string( - "email/trades/trade_update_" + state + "_subject.txt", email_context - ) + email_subject += render_to_string("email/trades/trade_update_" + state + "_subject.txt", email_context) email_body = render_to_string(email_template, email_context) send_mail( @@ -205,70 +173,46 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs): [recipient_user.email], ) - @receiver(post_save, sender=TradeAcceptance) def trade_acceptance_reputation_update(sender, instance, created, **kwargs): """ - Update the denormalized reputation score on the user model based on + Update the denormalized reputation score on the user model based on state transitions for TradeAcceptance. - + - THANKED_BY_BOTH: both the initiator and the acceptor receive +1 when transitioning into this state, and -1 when leaving it. - REJECTED_BY_INITIATOR: only the acceptor gets -1 when transitioning into it (and +1 when leaving it). - REJECTED_BY_ACCEPTOR: only the initiator gets -1 when transitioning into it (and +1 when leaving it). - + Creation events are ignored because trade acceptances are never created with a terminal state. """ if created: return # No action on creation as terminal states are not expected. - # Re-fetch instance with related data to avoid N+1 queries. - instance = TradeAcceptance.objects.select_related( - "trade_offer__initiated_by__user", "accepted_by__user" - ).get(pk=instance.pk) - thanks_delta = 0 rejection_delta_initiator = 0 # Delta for the initiator's reputation - rejection_delta_acceptor = 0 # Delta for the acceptor's reputation + rejection_delta_acceptor = 0 # Delta for the acceptor's reputation - old_state = getattr(instance, "_old_state", None) + old_state = getattr(instance, '_old_state', None) if old_state is None: return # Handle THANKED_BY_BOTH transitions - if ( - old_state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH - and instance.state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH - ): + if old_state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH and instance.state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH: thanks_delta = 1 - elif ( - old_state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH - and instance.state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH - ): + elif old_state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH and instance.state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH: thanks_delta = -1 # Handle REJECTED_BY_INITIATOR transitions (affects the acceptor) - if ( - old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR - and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR - ): + if old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR: rejection_delta_acceptor = -1 - elif ( - old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR - and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR - ): + elif old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR: rejection_delta_acceptor = 1 # Handle REJECTED_BY_ACCEPTOR transitions (affects the initiator) - if ( - old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR - and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR - ): + if old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR: rejection_delta_initiator = -1 - elif ( - old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR - and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR - ): + elif old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR: rejection_delta_initiator = 1 # Apply reputation updates: @@ -293,13 +237,12 @@ def trade_acceptance_reputation_update(sender, instance, created, **kwargs): reputation_score=F("reputation_score") + rejection_delta_initiator ) - @receiver(post_delete, sender=TradeAcceptance) def trade_acceptance_reputation_delete(sender, instance, **kwargs): """ When a TradeAcceptance is deleted, adjust the reputation score for the affected user(s) by reversing any reputation changes previously applied. - + - If the deleted instance was in THANKED_BY_BOTH: subtract 1 from both parties. - If it was in REJECTED_BY_INITIATOR: add 1 to the acceptor. - If it was in REJECTED_BY_ACCEPTOR: add 1 to the initiator. @@ -320,31 +263,20 @@ def trade_acceptance_reputation_delete(sender, instance, **kwargs): reputation_score=F("reputation_score") + 1 ) - -@receiver(post_save, sender=TradeOffer) -@receiver(post_delete, sender=TradeOffer) -def on_trade_offer_change(sender, instance, **kwargs): - """Invalidate cache for a trade offer when it's changed or deleted.""" - cache.delete(f"trade_offer_{instance.pk}") - - @receiver(post_save, sender=TradeOfferHaveCard) @receiver(post_delete, sender=TradeOfferHaveCard) @receiver(post_save, sender=TradeOfferWantCard) @receiver(post_delete, sender=TradeOfferWantCard) -def on_trade_offer_card_change(sender, instance, **kwargs): - """ - Invalidate cache for a trade offer when one of its card relationships changes. - """ - if instance.trade_offer: - cache.delete(f"trade_offer_{instance.trade_offer.pk}") - - @receiver(post_save, sender=TradeAcceptance) @receiver(post_delete, sender=TradeAcceptance) -def on_trade_acceptance_change(sender, instance, **kwargs): +def bubble_up_trade_offer_updates(sender, instance, **kwargs): """ - Invalidate cache for a trade offer when one of its acceptances changes. + Bubble up updated_at to the TradeOffer model when related instances change. + Also invalidates any cached image by deleting the file. """ - if instance.trade_offer: - cache.delete(f"trade_offer_{instance.trade_offer.pk}") + trade_offer = getattr(instance, 'trade_offer', None) + + if trade_offer and trade_offer.image: + trade_offer.image.delete(save=True) # deleting the image will trigger a save, which updates the updated_at field + elif trade_offer: + trade_offer.save(update_fields=['updated_at']) \ No newline at end of file diff --git a/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py b/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py index 56d92e1..2978445 100644 --- a/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py +++ b/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py @@ -1,10 +1,9 @@ from django import template from math import ceil - +from pkmntrade_club.trades.models import TradeAcceptance register = template.Library() - -@register.inclusion_tag("templatetags/trade_offer.html", takes_context=True) +@register.inclusion_tag('templatetags/trade_offer.html', takes_context=True) def render_trade_offer(context, offer): """ Renders a trade offer including detailed trade acceptance information. @@ -16,11 +15,14 @@ def render_trade_offer(context, offer): trade_offer_want_cards = list(offer.trade_offer_want_cards.all()) acceptances = list(offer.acceptances.all()) + have_cards_available = [ - card for card in trade_offer_have_cards if card.quantity > card.qty_accepted + card for card in trade_offer_have_cards + if card.quantity > card.qty_accepted ] want_cards_available = [ - card for card in trade_offer_want_cards if card.quantity > card.qty_accepted + card for card in trade_offer_want_cards + if card.quantity > card.qty_accepted ] if not have_cards_available or not want_cards_available: @@ -29,41 +31,37 @@ def render_trade_offer(context, offer): flipped = False tag_context = { - "offer_pk": offer.pk, - "flipped": flipped, - "offer_hash": offer.hash, - "rarity_icon": offer.rarity_icon, - "initiated_by_email": offer.initiated_by.user.email, - "initiated_by_username": offer.initiated_by.user.username, - "initiated_reputation": offer.initiated_by.user.reputation_score, - "acceptances": acceptances, - "have_cards_available": have_cards_available, - "want_cards_available": want_cards_available, - "num_cards_available": len(have_cards_available) + len(want_cards_available), - "on_detail_page": context.get("request").path.endswith( - "trades/" + str(offer.pk) + "/" - ), - "cache_key": f"trade_offer_{offer.pk}_{offer.updated_at.timestamp()}_{flipped}", + 'offer_pk': offer.pk, + 'flipped': flipped, + 'offer_hash': offer.hash, + 'rarity_icon': offer.rarity_icon, + 'initiated_by_email': offer.initiated_by.user.email, + 'initiated_by_username': offer.initiated_by.user.username, + 'initiated_reputation': offer.initiated_by.user.reputation_score, + 'acceptances': acceptances, + 'have_cards_available': have_cards_available, + 'want_cards_available': want_cards_available, + 'num_cards_available': len(have_cards_available) + len(want_cards_available), + 'on_detail_page': context.get("request").path.endswith("trades/"+str(offer.pk)+"/"), + 'cache_key': f'trade_offer_{offer.pk}_{offer.updated_at.timestamp()}_{flipped}', } context.update(tag_context) return context - -@register.inclusion_tag("templatetags/trade_acceptance.html", takes_context=True) +@register.inclusion_tag('templatetags/trade_acceptance.html', takes_context=True) def render_trade_acceptance(context, acceptance): """ Renders a simple trade acceptance view with a single row and simplified header/footer. """ tag_context = { "acceptance": acceptance, - "cache_key": f"trade_acceptance_{acceptance.pk}_{acceptance.updated_at.timestamp()}", + 'cache_key': f'trade_acceptance_{acceptance.pk}_{acceptance.updated_at.timestamp()}', } context.update(tag_context) return context - @register.filter def get_action_label(acceptance, state_value): """ @@ -71,27 +69,25 @@ def get_action_label(acceptance, state_value): """ return acceptance.get_action_label_for_state(state_value) - @register.filter def action_button_class(state_value): """ Returns daisyUI button classes based on the provided state value. """ mapping = { - "ACCEPTED": "btn btn-primary", - "SENT": "btn btn-info", - "RECEIVED": "btn btn-info", - "THANKED_BY_INITIATOR": "btn btn-success", - "THANKED_BY_ACCEPTOR": "btn btn-success", - "THANKED_BY_BOTH": "btn btn-success", - "REJECTED_BY_INITIATOR": "btn btn-error", - "REJECTED_BY_ACCEPTOR": "btn btn-error", + 'ACCEPTED': 'btn btn-primary', + 'SENT': 'btn btn-info', + 'RECEIVED': 'btn btn-info', + 'THANKED_BY_INITIATOR': 'btn btn-success', + 'THANKED_BY_ACCEPTOR': 'btn btn-success', + 'THANKED_BY_BOTH': 'btn btn-success', + 'REJECTED_BY_INITIATOR': 'btn btn-error', + 'REJECTED_BY_ACCEPTOR': 'btn btn-error', } # Return a default style if the state isn't in the mapping. - return mapping.get(state_value, "btn btn-outline") + return mapping.get(state_value, 'btn btn-outline') - -@register.inclusion_tag("templatetags/trade_offer_png.html", takes_context=True) +@register.inclusion_tag('templatetags/trade_offer_png.html', takes_context=True) def render_trade_offer_png(context, offer, show_friend_code=False): CARD_HEIGHT = 32 CARD_WIDTH = 160 @@ -100,29 +96,24 @@ def render_trade_offer_png(context, offer, show_friend_code=False): CARD_WIDTH_PADDING = 64 EXPANDED_CARD_WIDTH_PADDING = 80 CARD_COL_GAP = 4 - OUTPUT_PADDING = 24 # height padding is handled by the HTML + OUTPUT_PADDING = 24 # height padding is handled by the HTML have_cards_available = offer.have_cards_available want_cards_available = offer.want_cards_available num_cards = max(len(have_cards_available), len(want_cards_available)) expanded = (len(have_cards_available) + len(want_cards_available)) > 4 if expanded: - num_cards = ceil(num_cards / 2) # 2 cards per row if expanded - image_height = ( - (num_cards * CARD_HEIGHT) - + ((num_cards - 1) * CARD_COL_GAP) - + HEADER_HEIGHT - + FOOTER_HEIGHT - ) + num_cards = ceil(num_cards / 2) # 2 cards per row if expanded + image_height = (num_cards * CARD_HEIGHT) + ((num_cards - 1) * CARD_COL_GAP) + HEADER_HEIGHT + FOOTER_HEIGHT if expanded: image_width = (4 * CARD_WIDTH) + EXPANDED_CARD_WIDTH_PADDING else: image_width = (2 * CARD_WIDTH) + CARD_WIDTH_PADDING - image_width += OUTPUT_PADDING - image_height += OUTPUT_PADDING # height padding is handled by the HTML, but we need to also calculate it here for og meta tag use - + image_width += OUTPUT_PADDING + image_height += OUTPUT_PADDING # height padding is handled by the HTML, but we need to also calculate it here for og meta tag use + request = context.get("request") if request.get_host().startswith("localhost"): base_url = "http://{0}".format(request.get_host()) @@ -130,23 +121,23 @@ def render_trade_offer_png(context, offer, show_friend_code=False): base_url = "https://{0}".format(request.get_host()) tag_context = { - "offer_pk": offer.pk, - "offer_hash": offer.hash, - "rarity_icon": offer.rarity_icon, - "initiated_by_email": offer.initiated_by.user.email, - "initiated_by_username": offer.initiated_by.user.username, - "have_cards_available": have_cards_available, - "want_cards_available": want_cards_available, - "in_game_name": offer.initiated_by.in_game_name, - "friend_code": offer.initiated_by.friend_code, - "show_friend_code": show_friend_code, - "num_cards_available": len(have_cards_available) + len(want_cards_available), - "expanded": expanded, - "image_width": image_width, - "image_height": image_height, - "base_url": base_url, - "cache_key": f"trade_offer_png_{offer.pk}_{offer.updated_at.timestamp()}_{expanded}", + 'offer_pk': offer.pk, + 'offer_hash': offer.hash, + 'rarity_icon': offer.rarity_icon, + 'initiated_by_email': offer.initiated_by.user.email, + 'initiated_by_username': offer.initiated_by.user.username, + 'have_cards_available': have_cards_available, + 'want_cards_available': want_cards_available, + 'in_game_name': offer.initiated_by.in_game_name, + 'friend_code': offer.initiated_by.friend_code, + 'show_friend_code': show_friend_code, + 'num_cards_available': len(have_cards_available) + len(want_cards_available), + 'expanded': expanded, + 'image_width': image_width, + 'image_height': image_height, + 'base_url': base_url, + 'cache_key': f'trade_offer_png_{offer.pk}_{offer.updated_at.timestamp()}_{expanded}', } context.update(tag_context) - return context + return context \ No newline at end of file diff --git a/src/pkmntrade_club/trades/tests.py b/src/pkmntrade_club/trades/tests.py index a38de08..c188c16 100644 --- a/src/pkmntrade_club/trades/tests.py +++ b/src/pkmntrade_club/trades/tests.py @@ -20,7 +20,6 @@ from pkmntrade_club.trades.forms import ( ) from tests.utils.rarity import RARITY_MAPPING - # ------------------------------------------------------------------------ # Model Tests # ------------------------------------------------------------------------ @@ -36,29 +35,17 @@ class TradeOfferModelTest(TestCase): # Create cards with the same rarity (valid scenario) self.card1 = Card.objects.create( - name="Card1", - cardset="set1", - cardnum=1, - style="default", - rarity_icon=RARITY_MAPPING[1], - rarity_level=1, + name="Card1", cardset="set1", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[1], rarity_level=1 ) self.card2 = Card.objects.create( - name="Card2", - cardset="set1", - cardnum=2, - style="default", - rarity_icon=RARITY_MAPPING[1], - rarity_level=1, + name="Card2", cardset="set1", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[1], rarity_level=1 ) # Create a card with a different rarity (to test invalid trade offers) self.card3 = Card.objects.create( - name="Card3", - cardset="set1", - cardnum=3, - style="default", - rarity_icon=RARITY_MAPPING[8], - rarity_level=8, + name="Card3", cardset="set1", cardnum=3, style="default", + rarity_icon=RARITY_MAPPING[8], rarity_level=8 ) # Create a valid trade offer with consistent rarity details @@ -105,27 +92,17 @@ class TradeAcceptanceModelTest(TestCase): username="initiator", email="init@example.com", password="password" ) self.initiator_friend_code = FriendCode.objects.create( - friend_code="5555-6666-7777-8888", - in_game_name="InitInGame", - user=self.other_user, + friend_code="5555-6666-7777-8888", in_game_name="InitInGame", user=self.other_user ) # Create two cards (with the same rarity) self.card1 = Card.objects.create( - name="CardA", - cardset="setA", - cardnum=1, - style="default", - rarity_icon=RARITY_MAPPING[2], - rarity_level=2, + name="CardA", cardset="setA", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[2], rarity_level=2 ) self.card2 = Card.objects.create( - name="CardB", - cardset="setA", - cardnum=2, - style="default", - rarity_icon=RARITY_MAPPING[2], - rarity_level=2, + name="CardB", cardset="setA", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[2], rarity_level=2 ) # Create a trade offer by the initiator. @@ -173,7 +150,9 @@ class TradeAcceptanceModelTest(TestCase): self.acceptance.update_state( TradeAcceptance.AcceptanceState.SENT, user=self.other_user ) - self.assertEqual(self.acceptance.state, TradeAcceptance.AcceptanceState.SENT) + self.assertEqual( + self.acceptance.state, TradeAcceptance.AcceptanceState.SENT + ) def test_signal_adjusts_qty_accepted(self): """ @@ -227,20 +206,12 @@ class TradeOfferFormTest(TestCase): ) # Create two cards with the same rarity details. self.card1 = Card.objects.create( - name="FormCard1", - cardset="formset", - cardnum=1, - style="default", - rarity_icon=RARITY_MAPPING[3], - rarity_level=3, + name="FormCard1", cardset="formset", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[3], rarity_level=3 ) self.card2 = Card.objects.create( - name="FormCard2", - cardset="formset", - cardnum=2, - style="default", - rarity_icon=RARITY_MAPPING[3], - rarity_level=3, + name="FormCard2", cardset="formset", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[3], rarity_level=3 ) def test_trade_offer_create_form_valid(self): @@ -248,7 +219,7 @@ class TradeOfferFormTest(TestCase): A valid POST using colon-separated quantity strings should succeed. """ # Build a QueryDict with multiple values for each list field. - qd = QueryDict("", mutable=True) + qd = QueryDict('', mutable=True) qd.setlist("have_cards", [f"{self.card1.pk}:2"]) qd.setlist("want_cards", [f"{self.card2.pk}:3"]) # 'initiated_by' is a normal field so we can update it directly. @@ -260,7 +231,7 @@ class TradeOfferFormTest(TestCase): """ If quantity cannot be parsed as an integer a ValidationError should be raised. """ - qd = QueryDict("", mutable=True) + qd = QueryDict('', mutable=True) # Provide an invalid quantity ("two" instead of an integer). qd.setlist("have_cards", [f"{self.card1.pk}:two"]) qd.setlist("want_cards", [f"{self.card2.pk}:3"]) @@ -273,7 +244,7 @@ class TradeOfferFormTest(TestCase): """ An entry missing a colon should be ignored. """ - qd = QueryDict("", mutable=True) + qd = QueryDict('', mutable=True) # No colon present in the selections. qd.setlist("have_cards", [f"{self.card1.pk}"]) qd.setlist("want_cards", [f"{self.card2.pk}"]) @@ -312,7 +283,9 @@ class TradeOfferFormTest(TestCase): """Test that TradeOfferAcceptForm correctly sets the friend_code queryset.""" friend_codes = FriendCode.objects.filter(pk=self.friend_code.pk) form = TradeOfferAcceptForm(friend_codes=friend_codes) - self.assertEqual(list(form.fields["friend_code"].queryset), list(friend_codes)) + self.assertEqual( + list(form.fields["friend_code"].queryset), list(friend_codes) + ) def test_trade_acceptance_transition_form(self): """Test that the transition form provides only allowed transitions.""" @@ -339,10 +312,7 @@ class TradeOfferFormTest(TestCase): ) form = TradeAcceptanceTransitionForm(instance=acceptance, user=other_user) # Compare the form's state choices with the allowed transitions. - allowed = [ - choice[0] - for choice in acceptance.get_allowed_state_transitions(user=other_user) - ] + allowed = [choice[0] for choice in acceptance.get_allowed_state_transitions(user=other_user)] form_choices = [choice[0] for choice in form.fields["state"].choices] for choice in allowed: self.assertIn(choice, form_choices) @@ -367,20 +337,12 @@ class TradeViewsTest(TestCase): # Create sample cards. self.card1 = Card.objects.create( - name="ViewCard1", - cardset="setV", - cardnum=1, - style="default", - rarity_icon=RARITY_MAPPING[7], - rarity_level=7, + name="ViewCard1", cardset="setV", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[7], rarity_level=7 ) self.card2 = Card.objects.create( - name="ViewCard2", - cardset="setV", - cardnum=2, - style="default", - rarity_icon=RARITY_MAPPING[7], - rarity_level=7, + name="ViewCard2", cardset="setV", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[7], rarity_level=7 ) # Create a trade offer initiated by the logged-in user's friend code. self.trade_offer = TradeOffer.objects.create(initiated_by=self.friend_code) @@ -425,9 +387,7 @@ class TradeViewsTest(TestCase): Instead, if no active acceptances remain it should mark the offer as closed. """ # Create a trade offer with an active acceptance. - trade_offer_with_acceptance = TradeOffer.objects.create( - initiated_by=self.friend_code - ) + trade_offer_with_acceptance = TradeOffer.objects.create(initiated_by=self.friend_code) # Use quantity=2 so the trade offer isn't automatically closed when one acceptance is created TradeOfferHaveCard.objects.create( trade_offer=trade_offer_with_acceptance, card=self.card1, quantity=2 @@ -443,13 +403,10 @@ class TradeViewsTest(TestCase): offered_card=self.card2, state=TradeAcceptance.AcceptanceState.ACCEPTED, ) - delete_url = reverse( - "trade_offer_delete", kwargs={"pk": trade_offer_with_acceptance.pk} - ) + delete_url = reverse("trade_offer_delete", kwargs={"pk": trade_offer_with_acceptance.pk}) # --- Patch the view's get_object() method to return our trade offer --- from pkmntrade_club.trades.views import TradeOfferDeleteView - orig_get_object = TradeOfferDeleteView.get_object TradeOfferDeleteView.get_object = lambda self: trade_offer_with_acceptance @@ -488,27 +445,21 @@ class TradeViewsTest(TestCase): state=TradeAcceptance.AcceptanceState.ACCEPTED, ) update_url = reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}) - + # First, try an invalid state update. response = self.client.post(update_url, {"state": "INVALID_STATE"}) self.assertEqual(response.status_code, 200) - + form = response.context.get("form") self.assertIsNotNone(form, "Form should be present in the response context.") self.assertIn( - "state", - form.errors, - "Expected an error on the 'state' field when an invalid state is submitted.", - ) - self.assertTrue( - form.errors["state"], "The 'state' field should have error messages." + "state", form.errors, + "Expected an error on the 'state' field when an invalid state is submitted." ) + self.assertTrue(form.errors["state"], "The 'state' field should have error messages.") # Next, if there is an allowed valid transition, try it. - allowed_states = [ - choice[0] - for choice in acceptance.get_allowed_state_transitions(user=self.user) - ] + allowed_states = [choice[0] for choice in acceptance.get_allowed_state_transitions(user=self.user)] if allowed_states: valid_state = allowed_states[0] response = self.client.post(update_url, {"state": valid_state}) @@ -542,20 +493,12 @@ class TradeOfferSecurityTests(TestCase): # Create test cards with proper rarity levels self.card1 = Card.objects.create( - name="SecCard1", - cardset="secset", - cardnum=1, - style="default", - rarity_icon=RARITY_MAPPING[3], - rarity_level=3, + name="SecCard1", cardset="secset", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[3], rarity_level=3 ) self.card2 = Card.objects.create( - name="SecCard2", - cardset="secset", - cardnum=2, - style="default", - rarity_icon=RARITY_MAPPING[3], - rarity_level=3, + name="SecCard2", cardset="secset", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[3], rarity_level=3 ) # Create a trade offer by user1 @@ -593,14 +536,14 @@ class TradeOfferSecurityTests(TestCase): self.client.login(username="user3", password="password3") response = self.client.post( reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), - {"state": TradeAcceptance.AcceptanceState.SENT}, + {"state": TradeAcceptance.AcceptanceState.SENT} ) self.assertEqual(response.status_code, 403) def test_cross_user_friend_code_manipulation(self): """Test that users cannot use other users' friend codes.""" self.client.login(username="user2", password="password2") - + # Try to create a trade offer using user1's friend code response = self.client.get( reverse("trade_offer_create"), @@ -608,10 +551,12 @@ class TradeOfferSecurityTests(TestCase): "initiated_by": self.fc1.pk, # User1's friend code "have_cards": [f"{self.card1.pk}:1"], "want_cards": [f"{self.card2.pk}:1"], - }, + } ) self.assertEqual(response.status_code, 200) # Form should fail validation - self.assertFalse(TradeOffer.objects.filter(initiated_by=self.fc1).count() > 1) + self.assertFalse( + TradeOffer.objects.filter(initiated_by=self.fc1).count() > 1 + ) def test_authenticated_only_views(self): """Test that authenticated-only views are properly protected.""" @@ -619,20 +564,18 @@ class TradeOfferSecurityTests(TestCase): urls_to_test = [ reverse("trade_offer_create"), reverse("trade_offer_dashboard"), - reverse( - "trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk} - ), + reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), ] - + # First ensure we're logged out self.client.logout() - + for url in urls_to_test: response = self.client.get(url) self.assertRedirects( response, f"/accounts/login/?next={url}", - msg_prefix=f"URL {url} should require authentication", + msg_prefix=f"URL {url} should require authentication" ) @@ -645,31 +588,19 @@ class TradeOfferEdgeCasesTest(TestCase): self.friend_code = FriendCode.objects.create( friend_code="3333-4444-5555-6666", in_game_name="EdgeUser", user=self.user ) - + # Create test cards with different rarities using proper levels and icons self.common_card = Card.objects.create( - name="CommonCard", - cardset="edgeset", - cardnum=1, - style="default", - rarity_icon=RARITY_MAPPING[1], - rarity_level=1, + name="CommonCard", cardset="edgeset", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[1], rarity_level=1 ) self.rare_card = Card.objects.create( - name="RareCard", - cardset="edgeset", - cardnum=2, - style="default", - rarity_icon=RARITY_MAPPING[5], - rarity_level=5, + name="RareCard", cardset="edgeset", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[5], rarity_level=5 ) self.crown_card = Card.objects.create( - name="CrownCard", - cardset="edgeset", - cardnum=3, - style="default", - rarity_icon=RARITY_MAPPING[8], - rarity_level=8, + name="CrownCard", cardset="edgeset", cardnum=3, style="default", + rarity_icon=RARITY_MAPPING[8], rarity_level=8 ) self.client = Client() @@ -683,7 +614,7 @@ class TradeOfferEdgeCasesTest(TestCase): "initiated_by": self.friend_code.pk, "have_cards": [f"{self.common_card.pk}:0"], "want_cards": [f"{self.common_card.pk}:1"], - }, + } ) self.assertEqual(response.status_code, 200) self.assertFalse( @@ -698,7 +629,7 @@ class TradeOfferEdgeCasesTest(TestCase): "initiated_by": self.friend_code.pk, "have_cards": [f"{self.common_card.pk}:-1"], "want_cards": [f"{self.common_card.pk}:1"], - }, + } ) self.assertEqual(response.status_code, 200) self.assertFalse( @@ -713,7 +644,7 @@ class TradeOfferEdgeCasesTest(TestCase): "initiated_by": self.friend_code.pk, "have_cards": [f"{self.common_card.pk}:1"], "want_cards": [f"{self.crown_card.pk}:1"], - }, + } ) self.assertEqual(response.status_code, 200) self.assertFalse( @@ -726,9 +657,12 @@ class TradeOfferEdgeCasesTest(TestCase): reverse("trade_offer_create"), { "initiated_by": self.friend_code.pk, - "have_cards": [f"{self.common_card.pk}:1", f"{self.common_card.pk}:1"], + "have_cards": [ + f"{self.common_card.pk}:1", + f"{self.common_card.pk}:1" + ], "want_cards": [f"{self.common_card.pk}:1"], - }, + } ) self.assertEqual(response.status_code, 200) self.assertFalse( @@ -748,28 +682,16 @@ class TradeSearchTests(TestCase): # Create test cards with proper rarity levels self.card1 = Card.objects.create( - name="SearchCard1", - cardset="sc1", - cardnum=1, - style="default", - rarity_icon=RARITY_MAPPING[4], - rarity_level=4, + name="SearchCard1", cardset="sc1", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[4], rarity_level=4 ) self.card2 = Card.objects.create( - name="SearchCard2", - cardset="sc1", - cardnum=2, - style="default", - rarity_icon=RARITY_MAPPING[4], - rarity_level=4, + name="SearchCard2", cardset="sc1", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[4], rarity_level=4 ) self.card3 = Card.objects.create( - name="SearchCard3", - cardset="sc1", - cardnum=3, - style="default", - rarity_icon=RARITY_MAPPING[4], - rarity_level=4, + name="SearchCard3", cardset="sc1", cardnum=3, style="default", + rarity_icon=RARITY_MAPPING[4], rarity_level=4 ) # Create some trade offers @@ -797,7 +719,7 @@ class TradeSearchTests(TestCase): reverse("trade_offer_search"), { "have_cards": [f"{self.card2.pk}:1"], - }, + } ) self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) @@ -809,7 +731,7 @@ class TradeSearchTests(TestCase): reverse("trade_offer_search"), { "want_cards": [f"{self.card1.pk}:1"], - }, + } ) self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) @@ -821,7 +743,7 @@ class TradeSearchTests(TestCase): reverse("trade_offer_search"), { "have_cards": ["999999:1"], # Non-existent card ID - }, + } ) self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) @@ -831,12 +753,12 @@ class TradeSearchTests(TestCase): """Test that closed trades don't appear in search results.""" self.trade_offer1.is_closed = True self.trade_offer1.save() - + response = self.client.post( reverse("trade_offer_search"), { "have_cards": [f"{self.card2.pk}:1"], - }, + } ) self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) @@ -851,50 +773,30 @@ class TradeAcceptanceComplexTests(TestCase): self.acceptor = User.objects.create_user( username="acceptor", email="accept@example.com", password="password" ) - + self.initiator_fc = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", - in_game_name="InitUser", - user=self.initiator, + friend_code="1234-5678-9012-3456", in_game_name="InitUser", user=self.initiator ) self.acceptor_fc = FriendCode.objects.create( - friend_code="6543-2109-8765-4321", - in_game_name="AcceptUser", - user=self.acceptor, + friend_code="6543-2109-8765-4321", in_game_name="AcceptUser", user=self.acceptor ) # Create test cards with proper rarity levels self.card1 = Card.objects.create( - name="ComplexCard1", - cardset="cx1", - cardnum=1, - style="default", - rarity_icon=RARITY_MAPPING[6], - rarity_level=6, + name="ComplexCard1", cardset="cx1", cardnum=1, style="default", + rarity_icon=RARITY_MAPPING[6], rarity_level=6 ) self.card2 = Card.objects.create( - name="ComplexCard2", - cardset="cx1", - cardnum=2, - style="default", - rarity_icon=RARITY_MAPPING[6], - rarity_level=6, + name="ComplexCard2", cardset="cx1", cardnum=2, style="default", + rarity_icon=RARITY_MAPPING[6], rarity_level=6 ) self.card3 = Card.objects.create( - name="ComplexCard3", - cardset="cx1", - cardnum=3, - style="default", - rarity_icon=RARITY_MAPPING[6], - rarity_level=6, + name="ComplexCard3", cardset="cx1", cardnum=3, style="default", + rarity_icon=RARITY_MAPPING[6], rarity_level=6 ) self.card4 = Card.objects.create( - name="ComplexCard4", - cardset="cx1", - cardnum=4, - style="default", - rarity_icon=RARITY_MAPPING[6], - rarity_level=6, + name="ComplexCard4", cardset="cx1", cardnum=4, style="default", + rarity_icon=RARITY_MAPPING[6], rarity_level=6 ) # Create a trade offer with multiple quantities @@ -917,67 +819,58 @@ class TradeAcceptanceComplexTests(TestCase): def test_multiple_acceptances_quantity_limit(self): """Test that multiple acceptances cannot exceed the offer's quantity limit.""" self.client.login(username="acceptor", password="password") - + # Create first acceptance response1 = self.client.post( - reverse( - "trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk} - ), + reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), { "accepted_by": self.acceptor_fc.pk, "requested_card": self.card1.pk, "offered_card": self.card2.pk, - }, + } ) self.assertEqual(response1.status_code, 302) # Successful creation # Create second acceptance response2 = self.client.post( - reverse( - "trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk} - ), + reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), { "accepted_by": self.acceptor_fc.pk, "requested_card": self.card1.pk, "offered_card": self.card2.pk, - }, + } ) self.assertEqual(response2.status_code, 302) # Successful creation # Try to create a fourth acceptance (should fail as only 3 are allowed) response3 = self.client.post( - reverse( - "trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk} - ), + reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), { "accepted_by": self.acceptor_fc.pk, "requested_card": self.card1.pk, "offered_card": self.card2.pk, - }, + } ) self.assertEqual(response3.status_code, 302) # Successful creation response4 = self.client.post( - reverse( - "trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk} - ), + reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), { "accepted_by": self.acceptor_fc.pk, "requested_card": self.card1.pk, "offered_card": self.card2.pk, - }, + } ) self.assertEqual(response4.status_code, 200) # Should fail self.assertEqual( - self.trade_offer.acceptances.count(), - 3, - "Should not allow more acceptances than the quantity limit", + self.trade_offer.acceptances.count(), 3, + "Should not allow more acceptances than the quantity limit" ) def test_complex_state_transitions(self): """Test complex state transition scenarios.""" self.client.login(username="acceptor", password="password") - + # Create an acceptance acceptance = TradeAcceptance.objects.create( trade_offer=self.trade_offer, @@ -997,14 +890,14 @@ class TradeAcceptanceComplexTests(TestCase): for invalid_state in invalid_transitions: response = self.client.post( reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), - {"state": invalid_state}, + {"state": invalid_state} ) self.assertEqual(response.status_code, 200) # Should stay on form acceptance.refresh_from_db() self.assertEqual( acceptance.state, TradeAcceptance.AcceptanceState.ACCEPTED, - f"Invalid transition to {invalid_state} should not be allowed", + f"Invalid transition to {invalid_state} should not be allowed" ) # Test valid state transition sequence @@ -1019,12 +912,14 @@ class TradeAcceptanceComplexTests(TestCase): self.client.login(username=user.username, password="password") response = self.client.post( reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), - {"state": state}, + {"state": state} ) self.assertEqual(response.status_code, 302) # Should redirect on success acceptance.refresh_from_db() self.assertEqual( acceptance.state, state, - f"Valid transition to {state} should be allowed", + f"Valid transition to {state} should be allowed" ) + + diff --git a/src/pkmntrade_club/trades/urls.py b/src/pkmntrade_club/trades/urls.py index 4b63a25..ce4cf7a 100644 --- a/src/pkmntrade_club/trades/urls.py +++ b/src/pkmntrade_club/trades/urls.py @@ -13,24 +13,12 @@ from .views import ( urlpatterns = [ path("create/", TradeOfferCreateView.as_view(), name="trade_offer_create"), - path( - "create/confirm/", - TradeOfferCreateConfirmView.as_view(), - name="trade_offer_confirm_create", - ), + path("create/confirm/", TradeOfferCreateConfirmView.as_view(), name="trade_offer_confirm_create"), path("", TradeOfferAllListView.as_view(), name="trade_offer_list"), path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"), path("/", TradeOfferDetailView.as_view(), name="trade_offer_detail"), path(".png", TradeOfferPNGView.as_view(), name="trade_offer_png"), path("delete//", TradeOfferDeleteView.as_view(), name="trade_offer_delete"), - path( - "accept/", - TradeAcceptanceCreateView.as_view(), - name="trade_acceptance_create", - ), - path( - "update//", - TradeAcceptanceUpdateView.as_view(), - name="trade_acceptance_update", - ), + path("accept/", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"), + path("update//", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"), ] diff --git a/src/pkmntrade_club/trades/views.py b/src/pkmntrade_club/trades/views.py index c5a8ed6..8a25d45 100644 --- a/src/pkmntrade_club/trades/views.py +++ b/src/pkmntrade_club/trades/views.py @@ -1,35 +1,25 @@ -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import PermissionDenied, ValidationError -from django.http import HttpResponseRedirect -from django.shortcuts import render -from django.template.loader import render_to_string -from django.urls import reverse_lazy +from django.template import RequestContext +from django.views.generic import DeleteView, CreateView, ListView, DetailView, UpdateView from django.views import View -from django.views.generic import ( - CreateView, - DeleteView, - DetailView, - ListView, - UpdateView, -) +from django.urls import reverse_lazy +from django.http import HttpResponseRedirect +from django.contrib.auth.mixins import LoginRequiredMixin +from django.shortcuts import render +from django.core.exceptions import PermissionDenied, ValidationError +from django.core.paginator import Paginator +from django.contrib import messages from meta.views import Meta -from playwright.sync_api import sync_playwright - -from pkmntrade_club.common.mixins import ReusablePaginationMixin +from .models import TradeOffer, TradeAcceptance +from .forms import (TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm) +from django.template.loader import render_to_string from pkmntrade_club.trades.templatetags.trade_offer_tags import render_trade_offer_png - -from .forms import ( - TradeAcceptanceCreateForm, - TradeAcceptanceTransitionForm, - TradeOfferCreateForm, -) +from playwright.sync_api import sync_playwright +from django.conf import settings from .mixins import FriendCodeRequiredMixin -from .models import TradeAcceptance, TradeOffer - +from pkmntrade_club.common.mixins import ReusablePaginationMixin class TradeOfferCreateView(LoginRequiredMixin, CreateView): - http_method_names = ["get"] # restricts this view to GET only + http_method_names = ['get'] # restricts this view to GET only model = TradeOffer form_class = TradeOfferCreateForm template_name = "trades/trade_offer_create.html" @@ -52,33 +42,20 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) from pkmntrade_club.cards.models import Card - # Ensure available_cards is a proper QuerySet - context["cards"] = ( - Card.objects.filter(rarity__level__lte=5) - .select_related("rarity") - .prefetch_related("translations") - .order_by("translations__name", "rarity__level") - ) + context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level") friend_codes = self.request.user.friend_codes.all() if "initiated_by" in self.request.GET: try: - selected_friend_code = friend_codes.get( - pk=self.request.GET.get("initiated_by") - ) + selected_friend_code = friend_codes.get(pk=self.request.GET.get("initiated_by")) except friend_codes.model.DoesNotExist: - selected_friend_code = ( - self.request.user.default_friend_code or friend_codes.first() - ) + selected_friend_code = self.request.user.default_friend_code or friend_codes.first() else: - selected_friend_code = ( - self.request.user.default_friend_code or friend_codes.first() - ) + selected_friend_code = self.request.user.default_friend_code or friend_codes.first() context["friend_codes"] = friend_codes context["selected_friend_code"] = selected_friend_code return context - class TradeOfferAllListView(ReusablePaginationMixin, ListView): model = TradeOffer template_name = "trades/trade_offer_all_list.html" @@ -116,21 +93,14 @@ class TradeOfferAllListView(ReusablePaginationMixin, ListView): page_number = self.get_page_number() self.per_page = 10 - paginated_offers, pagination_context = self.paginate_data( - queryset, page_number - ) + paginated_offers, pagination_context = self.paginate_data(queryset, page_number) return render( self.request, "trades/_trade_offer_list.html", - { - "offers": paginated_offers, - "page_obj": pagination_context, - "expanded": expanded, - }, + {"offers": paginated_offers, "page_obj": pagination_context, "expanded": expanded} ) return super().render_to_response(context, **response_kwargs) - class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteView): model = TradeOffer success_url = reverse_lazy("trade_offer_list") @@ -138,12 +108,8 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi def dispatch(self, request, *args, **kwargs): self.object = super().get_object() - if self.object.initiated_by_id not in request.user.friend_codes.values_list( - "id", flat=True - ): - raise PermissionDenied( - "You are not authorized to delete or close this trade offer." - ) + if self.object.initiated_by_id not in request.user.friend_codes.values_list("id", flat=True): + raise PermissionDenied("You are not authorized to delete or close this trade offer.") return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): @@ -177,8 +143,8 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states) if active_acceptances.exists(): messages.error( - request, - "Cannot close this trade offer while there are active acceptances. Please reject all acceptances before closing, or finish the trades.", + request, + "Cannot close this trade offer while there are active acceptances. Please reject all acceptances before closing, or finish the trades." ) context = self.get_context_data() return self.render_to_response(context) @@ -192,7 +158,6 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi messages.success(request, "Trade offer has been deleted.") return super().delete(request, *args, **kwargs) - class TradeOfferSearchView(ListView): """ Reworked trade offer search view using POST. @@ -206,7 +171,6 @@ class TradeOfferSearchView(ListView): (_search_results.html) is rendered. On GET (initial page load), the search results queryset is empty. """ - model = TradeOffer context_object_name = "search_results" template_name = "trades/trade_offer_search.html" @@ -234,7 +198,7 @@ class TradeOfferSearchView(ListView): results.append((card_id, qty)) return results - # @silk_profile(name="Trade Offer Search- Get Queryset") + #@silk_profile(name="Trade Offer Search- Get Queryset") def get_queryset(self): # For a GET request (initial load), return an empty queryset. if self.request.method == "GET": @@ -251,7 +215,7 @@ class TradeOfferSearchView(ListView): qs = TradeOffer.objects.filter( is_closed=False, ) - + if self.request.user.is_authenticated: qs = qs.exclude(initiated_by__in=self.request.user.friend_codes.all()) @@ -273,23 +237,17 @@ class TradeOfferSearchView(ListView): return qs.distinct() - # @silk_profile(name="Trade Offer Search- Post") + #@silk_profile(name="Trade Offer Search- Post") def post(self, request, *args, **kwargs): # For POST, simply process the search through get(). return self.get(request, *args, **kwargs) - # @silk_profile(name="Trade Offer Search- Get Context Data") + #@silk_profile(name="Trade Offer Search- Get Context Data") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) from pkmntrade_club.cards.models import Card - # Populate available_cards to re-populate the multiselects. Exclude cards with rarity level > 5. - context["cards"] = ( - Card.objects.filter(rarity__level__lte=5) - .select_related("rarity") - .prefetch_related("translations") - .order_by("translations__name", "rarity__level") - ) + context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level") if self.request.method == "POST": context["have_cards"] = self.request.POST.getlist("have_cards") context["want_cards"] = self.request.POST.getlist("want_cards") @@ -298,40 +256,35 @@ class TradeOfferSearchView(ListView): context["want_cards"] = [] return context - # @silk_profile(name="Trade Offer Search- Render to Response") + #@silk_profile(name="Trade Offer Search- Render to Response") def render_to_response(self, context, **response_kwargs): """ Render the AJAX fragment if the request is AJAX; otherwise, render the complete page. """ if self.request.headers.get("X-Requested-With") == "XMLHttpRequest": from django.shortcuts import render - return render(self.request, "trades/_search_results.html", context) else: return super().render_to_response(context, **response_kwargs) - class TradeOfferDetailView(DetailView): """ Displays the details of a TradeOffer along with its active acceptances. If the offer is still open and the current user is not its initiator, an acceptance form is provided to create a new acceptance. """ - model = TradeOffer template_name = "trades/trade_offer_detail.html" - # @silk_profile(name="Trade Offer Detail- Get Context Data") + #@silk_profile(name="Trade Offer Detail- Get Context Data") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) trade_offer = self.get_object() screenshot_mode = self.request.GET.get("screenshot_mode") if screenshot_mode: - context["show_friend_code"] = ( - trade_offer.initiated_by.user.show_friend_code_on_link_previews - ) + context["show_friend_code"] = trade_offer.initiated_by.user.show_friend_code_on_link_previews context["screenshot_mode"] = screenshot_mode - + # Calculate the number of cards in each category. num_has = trade_offer.trade_offer_have_cards.count() num_wants = trade_offer.trade_offer_want_cards.count() @@ -362,14 +315,14 @@ class TradeOfferDetailView(DetailView): # compute the height from the width. image_width = base_width image_height = int(round(image_width / aspect_ratio)) - + # Build the meta tags with the computed dimensions. - title = f"Trade Offer from {trade_offer.initiated_by.in_game_name} ({trade_offer.initiated_by.friend_code})" + title = f'Trade Offer from {trade_offer.initiated_by.in_game_name} ({trade_offer.initiated_by.friend_code})' context["meta"] = Meta( title=title, - description=f"Has: {', '.join([card.card.name for card in trade_offer.trade_offer_have_cards.all()])} • \nWants: {', '.join([card.card.name for card in trade_offer.trade_offer_want_cards.all()])}", + description=f'Has: {", ".join([card.card.name for card in trade_offer.trade_offer_have_cards.all()])} • \nWants: {", ".join([card.card.name for card in trade_offer.trade_offer_want_cards.all()])}', image_object={ - "url": f"http://localhost:8000{reverse_lazy('trade_offer_png', kwargs={'pk': trade_offer.pk})}", + "url": f'http://localhost:8000{reverse_lazy("trade_offer_png", kwargs={"pk": trade_offer.pk})}', "type": "image/png", "width": image_width, "height": image_height, @@ -380,7 +333,7 @@ class TradeOfferDetailView(DetailView): use_facebook=True, use_schemaorg=True, ) - + # Define terminal (closed) acceptance states based on our new system: terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, @@ -389,41 +342,32 @@ class TradeOfferDetailView(DetailView): TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, ] - + # For example, if you want to separate active from terminal acceptances: context["acceptances"] = trade_offer.acceptances.all() - + # Option 1: Filter active acceptances using the queryset lookup. - context["active_acceptances"] = trade_offer.acceptances.exclude( - state__in=terminal_states - ) - + context["active_acceptances"] = trade_offer.acceptances.exclude(state__in=terminal_states) + if self.request.user.is_authenticated: user_friend_codes = self.request.user.friend_codes.all() # Add context flag and deletion URL if the current user is the initiator if trade_offer.initiated_by in user_friend_codes: context["is_initiator"] = True - context["delete_close_url"] = reverse_lazy( - "trade_offer_delete", kwargs={"pk": trade_offer.pk} - ) + context["delete_close_url"] = reverse_lazy("trade_offer_delete", kwargs={"pk": trade_offer.pk}) else: context["is_initiator"] = False # Determine the user's default friend code (or fallback as needed). - default_friend_code = ( - self.request.user.default_friend_code or user_friend_codes.first() - ) - + default_friend_code = self.request.user.default_friend_code or user_friend_codes.first() + # If the current user is not the initiator and the offer is open, allow a new acceptance. - if ( - trade_offer.initiated_by not in user_friend_codes - and not trade_offer.is_closed - ): + if trade_offer.initiated_by not in user_friend_codes and not trade_offer.is_closed: context["acceptance_form"] = TradeAcceptanceCreateForm( trade_offer=trade_offer, friend_codes=user_friend_codes, - default_friend_code=default_friend_code, + default_friend_code=default_friend_code ) else: context["is_initiator"] = False @@ -432,15 +376,11 @@ class TradeOfferDetailView(DetailView): return context - -class TradeAcceptanceCreateView( - LoginRequiredMixin, FriendCodeRequiredMixin, CreateView -): +class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, CreateView): """ View to create a new TradeAcceptance. The URL should provide 'offer_pk' so that the proper TradeOffer can be identified. """ - model = TradeAcceptance form_class = TradeAcceptanceCreateForm template_name = "trades/trade_acceptance_create.html" @@ -450,18 +390,16 @@ class TradeAcceptanceCreateView( return super().dispatch(request, *args, **kwargs) def get_trade_offer(self): - return TradeOffer.objects.get(pk=self.kwargs["offer_pk"]) + return TradeOffer.objects.get(pk=self.kwargs['offer_pk']) def get_form_kwargs(self): kwargs = super().get_form_kwargs() - if ( - self.trade_offer.initiated_by_id - in self.request.user.friend_codes.values_list("id", flat=True) - or self.trade_offer.is_closed - ): + if (self.trade_offer.initiated_by_id in + self.request.user.friend_codes.values_list("id", flat=True) or + self.trade_offer.is_closed): raise PermissionDenied("You cannot accept this trade offer.") - kwargs["trade_offer"] = self.trade_offer - kwargs["friend_codes"] = self.request.user.friend_codes.all() + kwargs['trade_offer'] = self.trade_offer + kwargs['friend_codes'] = self.request.user.friend_codes.all() return kwargs def get_context_data(self, **kwargs): @@ -492,13 +430,7 @@ class TradeAcceptanceCreateView( "acceptance_form": form, "friend_codes": friend_codes, "is_initiator": is_initiator, - "delete_close_url": ( - reverse_lazy( - "trade_offer_delete", kwargs={"pk": self.trade_offer.pk} - ) - if is_initiator - else None - ), + "delete_close_url": reverse_lazy("trade_offer_delete", kwargs={"pk": self.trade_offer.pk}) if is_initiator else None, } # Render the detail page with the form errors return render(self.request, "trades/trade_offer_detail.html", context) @@ -507,46 +439,20 @@ class TradeAcceptanceCreateView( def get_success_url(self): return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk}) - -class TradeAcceptanceUpdateView( - LoginRequiredMixin, FriendCodeRequiredMixin, UpdateView -): +class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, UpdateView): """ View to update the state of an existing TradeAcceptance. The allowed state transitions are provided via the form. """ - model = TradeAcceptance form_class = TradeAcceptanceTransitionForm template_name = "trades/trade_acceptance_update.html" - def get_object(self, queryset=None): - """ - Return the object the view is displaying. - Apply select_related for efficiency. - """ - if queryset is None: - queryset = self.get_queryset() - - pk = self.kwargs.get(self.pk_url_kwarg) - queryset = queryset.select_related( - "trade_offer__initiated_by__user", - "accepted_by__user", - "requested_card", - "offered_card", - ).prefetch_related( - "requested_card__translations", - "offered_card__translations", - ) - return queryset.get(pk=pk) - def dispatch(self, request, *args, **kwargs): self.object = self.get_object() friend_codes = request.user.friend_codes.values_list("id", flat=True) - if ( - self.object.accepted_by_id not in friend_codes - and self.object.trade_offer.initiated_by_id not in friend_codes - ): + if (self.object.accepted_by_id not in friend_codes and + self.object.trade_offer.initiated_by_id not in friend_codes): raise PermissionDenied("You are not authorized to update this acceptance.") return super().dispatch(request, *args, **kwargs) @@ -575,7 +481,6 @@ class TradeAcceptanceUpdateView( def get_success_url(self): return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk}) - class TradeOfferPNGView(View): """ Generate a PNG screenshot of the rendered trade offer detail page using Playwright. @@ -583,17 +488,15 @@ class TradeOfferPNGView(View): runs at a time for a given TradeOffer. The generated PNG is then cached in the TradeOffer model's `image` field (assumed to be an ImageField). """ - def get_lock_key(self, trade_offer_id): # Use the trade_offer_id as the lock key; adjust if needed. return trade_offer_id def get(self, request, *args, **kwargs): - from django.core.files.base import ContentFile - from django.http import HttpResponse from django.shortcuts import get_object_or_404 - - trade_offer = get_object_or_404(TradeOffer, pk=kwargs["pk"]) + from django.http import HttpResponse + from django.core.files.base import ContentFile + trade_offer = get_object_or_404(TradeOffer, pk=kwargs['pk']) # If the image is already generated and stored, serve it directly. if trade_offer.image and not request.GET.get("debug"): @@ -602,7 +505,6 @@ class TradeOfferPNGView(View): # Acquire PostgreSQL advisory lock to prevent concurrent generation. from django.db import connection - lock_key = self.get_lock_key(trade_offer.pk) with connection.cursor() as cursor: cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key]) @@ -612,22 +514,18 @@ class TradeOfferPNGView(View): if trade_offer.image and not request.GET.get("debug"): trade_offer.image.open() return HttpResponse(trade_offer.image.read(), content_type="image/png") - + tag_context = render_trade_offer_png( - {"request": request}, - trade_offer, - show_friend_code=trade_offer.initiated_by.user.show_friend_code_on_link_previews, + {'request': request}, trade_offer, show_friend_code=trade_offer.initiated_by.user.show_friend_code_on_link_previews ) - image_width = tag_context.get("image_width") - image_height = tag_context.get("image_height") + image_width = tag_context.get('image_width') + image_height = tag_context.get('image_height') if not image_width or not image_height: - raise ValueError( - "Could not determine image dimensions from tag_context" - ) + raise ValueError("Could not determine image dimensions from tag_context") html = render_to_string( "templatetags/trade_offer_png.html", context=tag_context, - request=request, + request=request ) # if query string has "debug", render the HTML instead of the PNG @@ -647,20 +545,13 @@ class TradeOfferPNGView(View): "--disable-audio-output", "--disable-webgl", "--no-first-run", - ], - ) - context_browser = browser.new_context( - viewport={"width": image_width, "height": image_height} + ] ) + context_browser = browser.new_context(viewport={"width": image_width, "height": image_height}) page = context_browser.new_page() page.on("console", lambda msg: print(f"Console {msg.type}: {msg.text}")) page.on("pageerror", lambda err: print(f"Page error: {err}")) - page.on( - "requestfailed", - lambda req: print( - f"Failed to load: {req.url} - {req.failure.error_text}" - ), - ) + page.on("requestfailed", lambda req: print(f"Failed to load: {req.url} - {req.failure.error_text}")) page.set_content(html, wait_until="networkidle") element = page.wait_for_selector(".trade-offer-card-screenshot") screenshot_bytes = element.screenshot(type="png", omit_background=True) @@ -676,13 +567,11 @@ class TradeOfferPNGView(View): with connection.cursor() as cursor: cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key]) - class TradeOfferCreateConfirmView(LoginRequiredMixin, View): """ Processes a two-step create for TradeOffer; on confirmation, commits the offer and shows form errors if any occur. """ - def post(self, request, *args, **kwargs): if "confirm" in request.POST: return self._commit_offer(request) @@ -716,26 +605,17 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View): } # Supply additional context required by trade_offer_create.html. from pkmntrade_club.cards.models import Card - context = { "form": form, "friend_codes": request.user.friend_codes.all(), "selected_friend_code": ( - request.user.default_friend_code - or request.user.friend_codes.first() - ), - "cards": ( - Card.objects.filter(rarity__level__lte=5) - .select_related("rarity") - .prefetch_related("translations") - .order_by("translations__name", "rarity__level") + request.user.default_friend_code or request.user.friend_codes.first() ), + "cards": Card.objects.all().order_by("name", "rarity_level"), } return render(request, "trades/trade_offer_create.html", context) messages.success(request, "Trade offer created successfully!") - return HttpResponseRedirect( - reverse_lazy("trade_offer_detail", kwargs={"pk": trade_offer.pk}) - ) + return HttpResponseRedirect(reverse_lazy("trade_offer_detail", kwargs={"pk": trade_offer.pk})) else: # When the form is not valid, update its initial data as well: form.initial = { @@ -744,23 +624,16 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View): "initiated_by": request.POST.get("initiated_by"), } from pkmntrade_club.cards.models import Card - context = { "form": form, "friend_codes": request.user.friend_codes.all(), "selected_friend_code": ( - request.user.default_friend_code - or request.user.friend_codes.first() - ), - "cards": ( - Card.objects.filter(rarity__level__lte=5) - .select_related("rarity") - .prefetch_related("translations") - .order_by("translations__name", "rarity__level") + request.user.default_friend_code or request.user.friend_codes.first() ), + "cards": Card.objects.all().order_by("name", "rarity_level"), } return render(request, "trades/trade_offer_create.html", context) - + def _redirect_to_edit(self, request): query_params = request.POST.copy() query_params.pop("csrfmiddlewaretoken", None) @@ -768,11 +641,10 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View): query_params.pop("confirm", None) query_params.pop("preview", None) from django.urls import reverse - base_url = reverse("trade_offer_create") url_with_params = f"{base_url}?{query_params.urlencode()}" return HttpResponseRedirect(url_with_params) - + def _preview_offer(self, request): form = TradeOfferCreateForm(request.POST) form.fields["initiated_by"].queryset = request.user.friend_codes.all() @@ -784,21 +656,15 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View): "initiated_by": request.POST.get("initiated_by"), } from pkmntrade_club.cards.models import Card - context = { "form": form, "friend_codes": request.user.friend_codes.all(), - "selected_friend_code": request.user.default_friend_code - or request.user.friend_codes.first(), - "cards": ( - Card.objects.filter(rarity__level__lte=5) - .select_related("rarity") - .prefetch_related("translations") - .order_by("translations__name", "rarity__level") - ), + "selected_friend_code": request.user.default_friend_code or request.user.friend_codes.first(), + "cards": Card.objects.all().order_by("name", "rarity_level"), } return render(request, "trades/trade_offer_create.html", context) + # Parse the card selections for "have" and "want" cards. have_selections = self._parse_card_selections("have_cards") want_selections = self._parse_card_selections("want_cards") @@ -806,9 +672,7 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View): from pkmntrade_club.cards.models import Card have_cards_ids = [card_id for card_id, _ in have_selections] - cards_have_qs = Card.objects.filter(pk__in=have_cards_ids).select_related( - "rarity", "cardset" - ) + cards_have_qs = Card.objects.filter(pk__in=have_cards_ids) cards_have_dict = {card.pk: card for card in cards_have_qs} # Define a dummy wrapper for a trade offer card entry. @@ -825,9 +689,7 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View): have_offer_cards.append(DummyOfferCard(card, quantity)) want_cards_ids = [card_id for card_id, _ in want_selections] - cards_want_qs = Card.objects.filter(pk__in=want_cards_ids).select_related( - "rarity", "cardset" - ) + cards_want_qs = Card.objects.filter(pk__in=want_cards_ids) cards_want_dict = {card.pk: card for card in cards_want_qs} want_offer_cards = [] for card_id, quantity in want_selections: diff --git a/uv.lock b/uv.lock index 449d8dd..fd3ec93 100644 --- a/uv.lock +++ b/uv.lock @@ -34,7 +34,7 @@ wheels = [ [[package]] name = "celery" -version = "5.5.3" +version = "5.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "billiard" }, @@ -46,9 +46,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/03/5d9c6c449248958f1a5870e633a29d7419ff3724c452a98ffd22688a1a6a/celery-5.5.2.tar.gz", hash = "sha256:4d6930f354f9d29295425d7a37261245c74a32807c45d764bedc286afd0e724e", size = 1666892 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775 }, + { url = "https://files.pythonhosted.org/packages/04/94/8e825ac1cf59d45d20c4345d4461e6b5263ae475f708d047c3dad0ac6401/celery-5.5.2-py3-none-any.whl", hash = "sha256:54425a067afdc88b57cd8d94ed4af2ffaf13ab8c7680041ac2c4ac44357bdf4c", size = 438626 }, ] [[package]] @@ -315,14 +315,14 @@ wheels = [ [[package]] name = "django-health-check" -version = "3.19.0" +version = "3.18.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/96/60db7257c05418b60ceb9d2c0a568e923394582111e809f1bb3749a7ee60/django_health_check-3.19.0.tar.gz", hash = "sha256:1a995ed4fa08a776beedff65f8f1ec0c22fb6764493f33fb1307fe4c6f23b8c3", size = 20088 } +sdist = { url = "https://files.pythonhosted.org/packages/66/e9/0699ea3debfda75e5960ff99f56974136380e6f8202d453de7357e1f67fc/django_health_check-3.18.3.tar.gz", hash = "sha256:18b75daca4551c69a43f804f9e41e23f5f5fb9efd06cf6a313b3d5031bb87bd0", size = 20919 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/35/c08be7e0012a7927c5f01185c0df39e0fa249cfc17234cce798c2afaf6bb/django_health_check-3.19.0-py3-none-any.whl", hash = "sha256:30b58d761f40fef47971b8dc145df15bdb71339108034860bbf1d505387aa1ec", size = 31969 }, + { url = "https://files.pythonhosted.org/packages/e2/1e/3b23b580762cca7456427731de9b90718d15eec02ebe096437469d767dfe/django_health_check-3.18.3-py2.py3-none-any.whl", hash = "sha256:f5f58762b80bdf7b12fad724761993d6e83540f97e2c95c42978f187e452fa07", size = 30331 }, ] [[package]] @@ -468,35 +468,36 @@ wheels = [ [[package]] name = "greenlet" -version = "3.2.3" +version = "3.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752 } +sdist = { url = "https://files.pythonhosted.org/packages/34/c1/a82edae11d46c0d83481aacaa1e578fea21d94a1ef400afd734d47ad95ad/greenlet-3.2.2.tar.gz", hash = "sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485", size = 185797 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992 }, - { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820 }, - { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046 }, - { url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701 }, - { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747 }, - { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461 }, - { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190 }, - { url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055 }, - { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817 }, - { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732 }, - { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033 }, - { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999 }, - { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368 }, - { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037 }, - { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402 }, - { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577 }, - { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121 }, - { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603 }, - { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479 }, - { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952 }, - { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917 }, - { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443 }, - { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995 }, - { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320 }, - { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 }, + { url = "https://files.pythonhosted.org/packages/2c/a1/88fdc6ce0df6ad361a30ed78d24c86ea32acb2b563f33e39e927b1da9ea0/greenlet-3.2.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:df4d1509efd4977e6a844ac96d8be0b9e5aa5d5c77aa27ca9f4d3f92d3fcf330", size = 270413 }, + { url = "https://files.pythonhosted.org/packages/a6/2e/6c1caffd65490c68cd9bcec8cb7feb8ac7b27d38ba1fea121fdc1f2331dc/greenlet-3.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da956d534a6d1b9841f95ad0f18ace637668f680b1339ca4dcfb2c1837880a0b", size = 637242 }, + { url = "https://files.pythonhosted.org/packages/98/28/088af2cedf8823b6b7ab029a5626302af4ca1037cf8b998bed3a8d3cb9e2/greenlet-3.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c7b15fb9b88d9ee07e076f5a683027bc3befd5bb5d25954bb633c385d8b737e", size = 651444 }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0116ab876bb0bc7a81eadc21c3f02cd6100dcd25a1cf2a085a130a63a26a/greenlet-3.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752f0e79785e11180ebd2e726c8a88109ded3e2301d40abced2543aa5d164275", size = 646067 }, + { url = "https://files.pythonhosted.org/packages/35/17/bb8f9c9580e28a94a9575da847c257953d5eb6e39ca888239183320c1c28/greenlet-3.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae572c996ae4b5e122331e12bbb971ea49c08cc7c232d1bd43150800a2d6c65", size = 648153 }, + { url = "https://files.pythonhosted.org/packages/2c/ee/7f31b6f7021b8df6f7203b53b9cc741b939a2591dcc6d899d8042fcf66f2/greenlet-3.2.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02f5972ff02c9cf615357c17ab713737cccfd0eaf69b951084a9fd43f39833d3", size = 603865 }, + { url = "https://files.pythonhosted.org/packages/b5/2d/759fa59323b521c6f223276a4fc3d3719475dc9ae4c44c2fe7fc750f8de0/greenlet-3.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4fefc7aa68b34b9224490dfda2e70ccf2131368493add64b4ef2d372955c207e", size = 1119575 }, + { url = "https://files.pythonhosted.org/packages/30/05/356813470060bce0e81c3df63ab8cd1967c1ff6f5189760c1a4734d405ba/greenlet-3.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a31ead8411a027c2c4759113cf2bd473690517494f3d6e4bf67064589afcd3c5", size = 1147460 }, + { url = "https://files.pythonhosted.org/packages/07/f4/b2a26a309a04fb844c7406a4501331b9400e1dd7dd64d3450472fd47d2e1/greenlet-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b24c7844c0a0afc3ccbeb0b807adeefb7eff2b5599229ecedddcfeb0ef333bec", size = 296239 }, + { url = "https://files.pythonhosted.org/packages/89/30/97b49779fff8601af20972a62cc4af0c497c1504dfbb3e93be218e093f21/greenlet-3.2.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3ab7194ee290302ca15449f601036007873028712e92ca15fc76597a0aeb4c59", size = 269150 }, + { url = "https://files.pythonhosted.org/packages/21/30/877245def4220f684bc2e01df1c2e782c164e84b32e07373992f14a2d107/greenlet-3.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc5c43bb65ec3669452af0ab10729e8fdc17f87a1f2ad7ec65d4aaaefabf6bf", size = 637381 }, + { url = "https://files.pythonhosted.org/packages/8e/16/adf937908e1f913856b5371c1d8bdaef5f58f251d714085abeea73ecc471/greenlet-3.2.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:decb0658ec19e5c1f519faa9a160c0fc85a41a7e6654b3ce1b44b939f8bf1325", size = 651427 }, + { url = "https://files.pythonhosted.org/packages/ad/49/6d79f58fa695b618654adac64e56aff2eeb13344dc28259af8f505662bb1/greenlet-3.2.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fadd183186db360b61cb34e81117a096bff91c072929cd1b529eb20dd46e6c5", size = 645795 }, + { url = "https://files.pythonhosted.org/packages/5a/e6/28ed5cb929c6b2f001e96b1d0698c622976cd8f1e41fe7ebc047fa7c6dd4/greenlet-3.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1919cbdc1c53ef739c94cf2985056bcc0838c1f217b57647cbf4578576c63825", size = 648398 }, + { url = "https://files.pythonhosted.org/packages/9d/70/b200194e25ae86bc57077f695b6cc47ee3118becf54130c5514456cf8dac/greenlet-3.2.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3885f85b61798f4192d544aac7b25a04ece5fe2704670b4ab73c2d2c14ab740d", size = 606795 }, + { url = "https://files.pythonhosted.org/packages/f8/c8/ba1def67513a941154ed8f9477ae6e5a03f645be6b507d3930f72ed508d3/greenlet-3.2.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:85f3e248507125bf4af607a26fd6cb8578776197bd4b66e35229cdf5acf1dfbf", size = 1117976 }, + { url = "https://files.pythonhosted.org/packages/c3/30/d0e88c1cfcc1b3331d63c2b54a0a3a4a950ef202fb8b92e772ca714a9221/greenlet-3.2.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1e76106b6fc55fa3d6fe1c527f95ee65e324a13b62e243f77b48317346559708", size = 1145509 }, + { url = "https://files.pythonhosted.org/packages/90/2e/59d6491834b6e289051b252cf4776d16da51c7c6ca6a87ff97e3a50aa0cd/greenlet-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421", size = 296023 }, + { url = "https://files.pythonhosted.org/packages/65/66/8a73aace5a5335a1cba56d0da71b7bd93e450f17d372c5b7c5fa547557e9/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba30e88607fb6990544d84caf3c706c4b48f629e18853fc6a646f82db9629418", size = 629911 }, + { url = "https://files.pythonhosted.org/packages/48/08/c8b8ebac4e0c95dcc68ec99198842e7db53eda4ab3fb0a4e785690883991/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:055916fafad3e3388d27dd68517478933a97edc2fc54ae79d3bec827de2c64c4", size = 635251 }, + { url = "https://files.pythonhosted.org/packages/37/26/7db30868f73e86b9125264d2959acabea132b444b88185ba5c462cb8e571/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2593283bf81ca37d27d110956b79e8723f9aa50c4bcdc29d3c0543d4743d2763", size = 632620 }, + { url = "https://files.pythonhosted.org/packages/10/ec/718a3bd56249e729016b0b69bee4adea0dfccf6ca43d147ef3b21edbca16/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89c69e9a10670eb7a66b8cef6354c24671ba241f46152dd3eed447f79c29fb5b", size = 628851 }, + { url = "https://files.pythonhosted.org/packages/9b/9d/d1c79286a76bc62ccdc1387291464af16a4204ea717f24e77b0acd623b99/greenlet-3.2.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02a98600899ca1ca5d3a2590974c9e3ec259503b2d6ba6527605fcd74e08e207", size = 593718 }, + { url = "https://files.pythonhosted.org/packages/cd/41/96ba2bf948f67b245784cd294b84e3d17933597dffd3acdb367a210d1949/greenlet-3.2.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b50a8c5c162469c3209e5ec92ee4f95c8231b11db6a04db09bbe338176723bb8", size = 1105752 }, + { url = "https://files.pythonhosted.org/packages/68/3b/3b97f9d33c1f2eb081759da62bd6162159db260f602f048bc2f36b4c453e/greenlet-3.2.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:45f9f4853fb4cc46783085261c9ec4706628f3b57de3e68bae03e8f8b3c0de51", size = 1125170 }, + { url = "https://files.pythonhosted.org/packages/31/df/b7d17d66c8d0f578d2885a3d8f565e9e4725eacc9d3fdc946d0031c055c4/greenlet-3.2.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:9ea5231428af34226c05f927e16fc7f6fa5e39e3ad3cd24ffa48ba53a47f4240", size = 269899 }, ] [[package]] @@ -522,17 +523,16 @@ wheels = [ [[package]] name = "kombu" -version = "5.5.4" +version = "5.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "amqp" }, - { name = "packaging" }, { name = "tzdata" }, { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992 } +sdist = { url = "https://files.pythonhosted.org/packages/60/0a/128b65651ed8120460fc5af754241ad595eac74993115ec0de4f2d7bc459/kombu-5.5.3.tar.gz", hash = "sha256:021a0e11fcfcd9b0260ef1fb64088c0e92beb976eb59c1dfca7ddd4ad4562ea2", size = 461784 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034 }, + { url = "https://files.pythonhosted.org/packages/5d/35/1407fb0b2f5b07b50cbaf97fce09ad87d3bfefbf64f7171a8651cd8d2f68/kombu-5.5.3-py3-none-any.whl", hash = "sha256:5b0dbceb4edee50aa464f59469d34b97864be09111338cfb224a10b6a163909b", size = 209921 }, ] [[package]] @@ -848,11 +848,11 @@ wheels = [ [[package]] name = "redis" -version = "6.2.0" +version = "6.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/9a/0551e01ba52b944f97480721656578c8a7c46b51b99d66814f85fe3a4f3e/redis-6.2.0.tar.gz", hash = "sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977", size = 4639129 } +sdist = { url = "https://files.pythonhosted.org/packages/a6/af/e875d57383653e5d9065df8552de1deb7576b4d3cf3af90cde2e79ff7f65/redis-6.1.0.tar.gz", hash = "sha256:c928e267ad69d3069af28a9823a07726edf72c7e37764f43dc0123f37928c075", size = 4629300 } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/67/e60968d3b0e077495a8fee89cf3f2373db98e528288a48f1ee44967f6e8c/redis-6.2.0-py3-none-any.whl", hash = "sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e", size = 278659 }, + { url = "https://files.pythonhosted.org/packages/28/5f/cf36360f80ae233bd1836442f5127818cfcfc7b1846179b60b2e9a4c45c9/redis-6.1.0-py3-none-any.whl", hash = "sha256:3b72622f3d3a89df2a6041e82acd896b0e67d9f54e9bcd906d091d23ba5219f6", size = 273750 }, ] [[package]] @@ -885,11 +885,11 @@ wheels = [ [[package]] name = "setuptools" -version = "80.9.0" +version = "80.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 } +sdist = { url = "https://files.pythonhosted.org/packages/8d/d2/ec1acaaff45caed5c2dedb33b67055ba9d4e96b091094df90762e60135fe/setuptools-80.8.0.tar.gz", hash = "sha256:49f7af965996f26d43c8ae34539c8d99c5042fbff34302ea151eaa9c207cd257", size = 1319720 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 }, + { url = "https://files.pythonhosted.org/packages/58/29/93c53c098d301132196c3238c312825324740851d77a8500a2462c0fd888/setuptools-80.8.0-py3-none-any.whl", hash = "sha256:95a60484590d24103af13b686121328cc2736bee85de8936383111e421b9edc0", size = 1201470 }, ] [[package]]