diff --git a/.cursorrules b/.cursorrules index 5e49a06..8b27274 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,20 +25,29 @@ Error Handling and Validation - Customize error pages (e.g., 404, 500) to improve user experience and provide helpful information. - Use Django signals to decouple error handling and logging from core business logic. +Development, Testing, and Operations + +- Use Gatus for service health monitoring and status pages. +- Employ Locust for load testing to ensure application scalability and performance under stress. +- Utilize Playwright for end-to-end testing to simulate user interactions and validate application behavior from the user's perspective. + Dependencies - Django -- Django REST Framework (for API development) - Celery (for background tasks) - Redis (for caching and task queues) - PostgreSQL or MySQL (preferred databases for production) +- Granian / Gunicorn (for serving the application) +- Whitenoise (for serving static files) - Tailwind CSS for the frontend -- Django Crispy Forms for the frontend - Django Allauth for authentication -- Django DaisyUI for the frontend -- Django El Pagination for the frontend +- Django Crispy Forms for the frontend +- Crispy Tailwind for Tailwind-compatible Crispy Forms +- Django DaisyUI for the admin frontend - Django Widget Tweaks for the frontend -- Django Crispy Tailwind for the frontend +- django-debug-toolbar for debugging +- django-health-check for application health monitoring +- django-parler for multilingual support Django-Specific Guidelines @@ -46,17 +55,17 @@ Django-Specific Guidelines - Keep business logic in models and forms; keep views light and focused on request handling. - Use Django's URL dispatcher (urls.py) to define clear and RESTful URL patterns. - Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention). -- Use 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 ab3dd15..21371ea 100644 --- a/.github/workflows/build_deploy.yml +++ b/.github/workflows/build_deploy.yml @@ -103,6 +103,7 @@ 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 @@ -133,9 +134,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: @@ -173,8 +174,13 @@ 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 @@ -217,35 +223,40 @@ 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 }} - PROD: ${{ env.PROD }} + PRODrequire_var: ${{ env.PROD }} + GIT_SHA: ${{ github.sha }} + REPLICA_COUNT: ${{ env.REPLICA_COUNT }} + PRODUCTION_DOMAIN: ${{ vars.PRODUCTION_DOMAIN }} + STAGING_DOMAIN: ${{ vars.STAGING_DOMAIN }} run: | echo "✅ Exit script on any error" set -eu -o pipefail - ./scripts/deploy-to-server.sh + ./scripts/deploy-blue-green.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 }}' '${{ env.PROD }}' '$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 }}' '$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 138d284..d84abce 100755 --- a/manage.py +++ b/manage.py @@ -1,11 +1,15 @@ #!/usr/bin/env -S uv run """Django's command-line utility for administrative tasks.""" + import os import sys + def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings") + os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" + ) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) try: diff --git a/scripts/common-lib.sh b/scripts/common-lib.sh new file mode 120000 index 0000000..191fd48 --- /dev/null +++ b/scripts/common-lib.sh @@ -0,0 +1 @@ +../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 new file mode 100755 index 0000000..2392d67 --- /dev/null +++ b/scripts/deploy-blue-green.sh @@ -0,0 +1,207 @@ +#!/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 deleted file mode 100644 index 1a375ac..0000000 --- a/scripts/deploy-to-server.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/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 100644 new mode 100755 diff --git a/scripts/health-check-and-rollback.sh b/scripts/health-check-and-rollback.sh deleted file mode 100644 index b07607b..0000000 --- a/scripts/health-check-and-rollback.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/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 deleted file mode 100644 index f7e16b7..0000000 --- a/scripts/manage-releases.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/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 100644 new mode 100755 index dc1343f..54a0fca --- a/scripts/parse-repository-name.sh +++ b/scripts/parse-repository-name.sh @@ -16,14 +16,17 @@ 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 a URL" > /dev/stderr + echo "GITHUB_REPOSITORY ends in .git and is an HTTPS URI" > /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 URL" > /dev/stderr + echo "GITHUB_REPOSITORY ends in .git and is not a URI" > /dev/stderr REPO=$(echo "$GITHUB_REPOSITORY" | sed 's/\.git$//' | sed 's/[^a-zA-Z0-9\/-]/-/g') fi else - echo "GITHUB_REPOSITORY is not a URL" > /dev/stderr + echo "GITHUB_REPOSITORY is not a URI" > /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 deleted file mode 100644 index 15a41c4..0000000 --- a/scripts/prepare-deployment.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/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 deleted file mode 100644 index 42ee35c..0000000 --- a/scripts/retry.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/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 new file mode 100755 index 0000000..481ee3c --- /dev/null +++ b/scripts/rollback-deployment.sh @@ -0,0 +1,98 @@ +#!/bin/bash +set -euo pipefail + +# Rollback deployment by swapping colors +# Usage: ./rollback-deployment.sh + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common-lib.sh" + +validate_deployment_env + +# Get current state +STATE=$(get_deployment_state) + +echo "🔍 Current deployment state: $STATE" + +if [ "$STATE" = "none" ]; then + echo "❌ No active deployment found to rollback" + exit 1 +fi + +if [ "$STATE" != "both" ]; then + echo "❌ Rollback requires both colors to be running" + echo " Current state: only $STATE is running" + echo "" + echo " To perform a manual rollback:" + echo " 1. Find the previous release in ${RELEASES_PATH}/" + echo " 2. Update the symlink: ln -sfn ${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 deleted file mode 100644 index 163c7cf..0000000 --- a/seed/0002_Decks.json +++ /dev/null @@ -1,101 +0,0 @@ -[ - { - "model": "cards.deck", - "pk": 1, - "fields": { - "name": "Promo-A", - "cardset": "Promo-A", - "hex_color": "#1070EB", - "created_at": "2025-02-16T07:55:34.988Z", - "updated_at": "2025-02-16T07:55:34.988Z" - } - }, - { - "model": "cards.deck", - "pk": 2, - "fields": { - "name": "Genetic Apex: Mewtwo", - "cardset": "A1", - "hex_color": "#8040E0", - "created_at": "2025-02-16T07:54:57.445Z", - "updated_at": "2025-02-16T07:54:57.445Z" - } - }, - { - "model": "cards.deck", - "pk": 3, - "fields": { - "name": "Genetic Apex: Charizard", - "cardset": "A1", - "hex_color": "#E00202", - "created_at": "2025-02-16T07:54:52.381Z", - "updated_at": "2025-02-16T07:54:52.381Z" - } - }, - { - "model": "cards.deck", - "pk": 4, - "fields": { - "name": "Genetic Apex: Pikachu", - "cardset": "A1", - "hex_color": "#FCF326", - "created_at": "2025-02-16T07:55:05.097Z", - "updated_at": "2025-02-16T07:55:05.097Z" - } - }, - { - "model": "cards.deck", - "pk": 5, - "fields": { - "name": "Mythical Island", - "cardset": "A1a", - "hex_color": "#20AA80", - "created_at": "2025-02-16T07:55:11.916Z", - "updated_at": "2025-02-16T07:55:11.916Z" - } - }, - { - "model": "cards.deck", - "pk": 6, - "fields": { - "name": "Space-Time Smackdown: Dialga", - "cardset": "A2", - "hex_color": "#302FD9", - "created_at": "2025-02-16T07:55:17.582Z", - "updated_at": "2025-02-16T07:55:17.582Z" - } - }, - { - "model": "cards.deck", - "pk": 7, - "fields": { - "name": "Space-Time Smackdown: Palkia", - "cardset": "A2", - "hex_color": "#CF36E0", - "created_at": "2025-02-16T07:55:27.503Z", - "updated_at": "2025-02-16T07:55:27.503Z" - } - }, - { - "model": "cards.deck", - "pk": 8, - "fields": { - "name": "Triumphant Light", - "cardset": "A2a", - "hex_color": "#DF8D2C", - "created_at": "2025-03-26T12:25:17.706Z", - "updated_at": "2025-03-26T12:25:17.706Z" - } - }, - { - "model": "cards.deck", - "pk": 9, - "fields": { - "name": "Shining Revelry", - "cardset": "A2b", - "hex_color": "#D7FDFC", - "created_at": "2025-03-26T12:25:17.706Z", - "updated_at": "2025-03-26T12:25:17.706Z" - } - } -] diff --git a/seed/0002_RarityMappings.json b/seed/0002_RarityMappings.json new file mode 100644 index 0000000..472a287 --- /dev/null +++ b/seed/0002_RarityMappings.json @@ -0,0 +1 @@ +[{"model": "cards.raritymapping", "pk": 1, "fields": {"original_name": "Common", "mapped_name": "Common", "icon": "🔷", "level": 1, "created_at": "2025-06-15T03:51:40.147Z", "updated_at": "2025-06-15T03:51:40.147Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 2, "fields": {"original_name": "Uncommon", "mapped_name": "Uncommon", "icon": "🔷🔷", "level": 2, "created_at": "2025-06-15T03:53:12.209Z", "updated_at": "2025-06-15T03:53:12.209Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 3, "fields": {"original_name": "Rare", "mapped_name": "Rare", "icon": "🔷🔷🔷", "level": 3, "created_at": "2025-06-15T03:53:31.267Z", "updated_at": "2025-06-15T03:53:31.267Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 4, "fields": {"original_name": "Rare EX", "mapped_name": "Double Rare", "icon": "🔷🔷🔷🔷", "level": 4, "created_at": "2025-06-15T03:53:54.712Z", "updated_at": "2025-06-15T03:53:54.712Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 5, "fields": {"original_name": "Full Art", "mapped_name": "Art Rare", "icon": "⭐️", "level": 5, "created_at": "2025-06-15T03:54:26.671Z", "updated_at": "2025-06-15T03:54:26.671Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 6, "fields": {"original_name": "Full Art EX/Support", "mapped_name": "Super Rare", "icon": "⭐️⭐️", "level": 6, "created_at": "2025-06-15T03:54:58.835Z", "updated_at": "2025-06-15T03:54:58.835Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 7, "fields": {"original_name": "Immersive", "mapped_name": "Immersive Rare", "icon": "⭐️⭐️⭐️", "level": 7, "created_at": "2025-06-15T03:55:25.941Z", "updated_at": "2025-06-15T03:59:14.725Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 8, "fields": {"original_name": "Gold Crown", "mapped_name": "Ultra Rare", "icon": "👑", "level": 10, "created_at": "2025-06-15T03:56:05.786Z", "updated_at": "2025-06-15T03:56:32.728Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 9, "fields": {"original_name": "One shiny star", "mapped_name": "Shiny Rare", "icon": "✨", "level": 8, "created_at": "2025-06-15T03:57:03.342Z", "updated_at": "2025-06-15T03:59:04.136Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 10, "fields": {"original_name": "Two shiny stars", "mapped_name": "Shiny Super Rare", "icon": "✨✨", "level": 9, "created_at": "2025-06-15T03:57:33.360Z", "updated_at": "2025-06-15T03:57:51.004Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 11, "fields": {"original_name": "Two shiny star", "mapped_name": "Shiny Super Rare", "icon": "✨✨", "level": 9, "created_at": "2025-06-15T03:58:10.204Z", "updated_at": "2025-06-15T03:58:10.204Z", "deleted_at": null}}] \ No newline at end of file diff --git a/seed/0003_CardSetColorMappings.json b/seed/0003_CardSetColorMappings.json new file mode 100644 index 0000000..4f70324 --- /dev/null +++ b/seed/0003_CardSetColorMappings.json @@ -0,0 +1,79 @@ +[ + { + "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 deleted file mode 100644 index df7f1ac..0000000 --- a/seed/0003_Cards.json +++ /dev/null @@ -1,12717 +0,0 @@ -[ - { - "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 c49b1d2..83420c6 100644 --- a/server/docker-compose_core.yml +++ b/server/docker-compose_core.yml @@ -53,6 +53,8 @@ services: done env_file: - .env + labels: + - "deployment.core=true" loba: image: haproxy:3.1 stop_signal: SIGTERM @@ -64,11 +66,14 @@ 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: @@ -91,6 +96,8 @@ 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 @@ -98,6 +105,8 @@ 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 @@ -105,6 +114,8 @@ services: volumes: - /var/run/docker.sock:/tmp/docker.sock:ro - ./:/gatekeeper + labels: + - "deployment.core=true" gatekeeper-manager: image: docker:latest restart: always @@ -115,6 +126,8 @@ services: environment: - REFRESH_INTERVAL=60 entrypoint: ["/bin/sh", "-c"] + labels: + - "deployment.core=true" command: - | set -eu -o pipefail @@ -239,7 +252,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 @@ -254,9 +267,21 @@ 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 \ No newline at end of file + - ./gatus:/config + +networks: + default: + name: pkmntrade-club_network + external: true \ No newline at end of file diff --git a/server/docker-compose_staging.yml b/server/docker-compose_staging.yml deleted file mode 100644 index 90020bc..0000000 --- a/server/docker-compose_staging.yml +++ /dev/null @@ -1,32 +0,0 @@ -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 6d453e0..15245e2 100644 --- a/server/docker-compose_web.yml +++ b/server/docker-compose_web.yml @@ -1,4 +1,7 @@ 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 @@ -6,31 +9,42 @@ x-common: &common services: web: <<: *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=${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 environment: - DEBUG=False - DISABLE_SIGNUPS=True - PUBLIC_HOST=pkmntrade.club - ALLOWED_HOSTS=pkmntrade.club,127.0.0.1 + - DEPLOYMENT_COLOR=${DEPLOYMENT_COLOR:-blue} labels: - - "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 + - "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 diff --git a/server/gatekeepers.template.yml b/server/gatekeepers.template.yml index 701fbf1..f4d3b8f 100644 --- a/server/gatekeepers.template.yml +++ b/server/gatekeepers.template.yml @@ -1,23 +1,52 @@ services: {{ $all_containers := whereLabelValueMatches . "enable_gatekeeper" "true" }} - {{ $all_containers = sortObjectsByKeysAsc $all_containers "Name" }} + + # 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" }} - {{ range $container := $all_containers }} + {{ range $container := $color_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 }} - {{ if eq $serviceLabel "web-staging" }} - {{ $port = ":8000" }} + 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 }} {{ 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 }} @@ -25,12 +54,6 @@ 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: @@ -39,7 +62,9 @@ services: - pkmntrade-club-gatekeeper-{{ $serviceLabel }} - gatekeeper-{{ $serviceLabel }} {{ end }} + {{ end }} + networks: default: - name: pkmntrade-club_default + name: pkmntrade-club_network external: true diff --git a/server/gatus/config.template.yaml b/server/gatus/config.template.yaml index 33351f4..8f01ea1 100644 --- a/server/gatus/config.template.yaml +++ b/server/gatus/config.template.yaml @@ -92,20 +92,15 @@ 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" }} @@ -113,7 +108,7 @@ endpoints: group: Main url: "http://{{ $container.Name }}:8000/health/" headers: - Host: "pkmntrade.club" + Host: "${DOMAIN_NAME}" interval: 60s conditions: - "[STATUS] == 200" @@ -122,21 +117,6 @@ 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 14db5f6..673c22a 100644 --- a/server/haproxy.cfg +++ b/server/haproxy.cfg @@ -25,32 +25,27 @@ 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 checks +frontend healthchecks bind :80 - default_backend basic_check + default_backend basic_loba_check -backend basic_check +backend basic_loba_check http-request return status 200 content-type "text/plain" lf-string "OK/HEALTHY" -backend pkmntrade.club +backend "${DOMAIN_NAME}" balance leastconn - http-request set-header Host pkmntrade.club - server-template gatekeeper-web- 4 gatekeeper-web:8000 check resolvers docker_resolver init-addr libc,none + http-request set-header Host "${DOMAIN_NAME}" + server-template gatekeeper-web- "${REPLICA_COUNT}" gatekeeper-web:8000 check resolvers docker_resolver init-addr none -backend staging.pkmntrade.club +backend "feedback.${BASE_DOMAIN_NAME}" balance leastconn - 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 + http-request set-header Host feedback."${BASE_DOMAIN_NAME}" + server-template gatekeeper-feedback- 1 gatekeeper-feedback:8000 check resolvers docker_resolver init-addr none -backend feedback.pkmntrade.club +backend "health.${BASE_DOMAIN_NAME}" balance leastconn - 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 + http-request set-header Host health."${BASE_DOMAIN_NAME}" + server-template gatekeeper-health- 1 gatekeeper-health:8000 check resolvers docker_resolver init-addr none #EOF - trailing newline required diff --git a/server/scripts/common-lib.sh b/server/scripts/common-lib.sh new file mode 100755 index 0000000..70fa5a4 --- /dev/null +++ b/server/scripts/common-lib.sh @@ -0,0 +1,382 @@ +#!/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 new file mode 100755 index 0000000..d0c563b --- /dev/null +++ b/server/scripts/manage.sh @@ -0,0 +1,377 @@ +#!/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 8d1f1f7..bcdfc83 100644 --- a/src/pkmntrade_club/__init__.py +++ b/src/pkmntrade_club/__init__.py @@ -2,4 +2,4 @@ from pkmntrade_club._version import __version__, get_version, get_version_info -__all__ = ['__version__', 'get_version', 'get_version_info'] +__all__ = ["__version__", "get_version", "get_version_info"] diff --git a/src/pkmntrade_club/_version.py b/src/pkmntrade_club/_version.py index 6f90c71..77921b5 100644 --- a/src/pkmntrade_club/_version.py +++ b/src/pkmntrade_club/_version.py @@ -1,5 +1,6 @@ from importlib.metadata import version, PackageNotFoundError from setuptools_scm import get_version + """ Version module for pkmntrade.club @@ -7,55 +8,57 @@ This module provides version information from git tags via setuptools-scm. """ try: - __version__ = version("pkmntrade-club") + __version__ = version("pkmntrade-club") except PackageNotFoundError: - # Package is not installed, try to get version from setuptools_scm - try: - __version__ = get_version(root='../../..', relative_to=__file__) - except (ImportError, LookupError): - __version__ = "0.0.0+unknown" + # Package is not installed, try to get version from setuptools_scm + try: + __version__ = get_version(root="../../..", relative_to=__file__) + except (ImportError, LookupError): + __version__ = "0.0.0+unknown" + def get_version(): - """Return the current version.""" - return __version__ + """Return the current version.""" + return __version__ + def get_version_info(): - """Return detailed version information.""" - import re - - # Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease") - match = re.match( - r'^(\d+)\.(\d+)\.(\d+)' - r'(?:\.dev(\d+))?' - r'(?:\+g([a-f0-9]+))?' - r'(?:-(.+))?$', - __version__ - ) - - if match: - major, minor, patch, dev, git_sha, prerelease = match.groups() + """Return detailed version information.""" + import re + + # Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease") + match = re.match( + r"^(\d+)\.(\d+)\.(\d+)" + r"(?:\.dev(\d+))?" + r"(?:\+g([a-f0-9]+))?" + r"(?:-(.+))?$", + __version__, + ) + + if match: + major, minor, patch, dev, git_sha, prerelease = match.groups() + return { + "version": __version__, + "major": int(major), + "minor": int(minor), + "patch": int(patch), + "dev": int(dev) if dev else None, + "git_sha": git_sha, + "prerelease": prerelease, + "is_release": dev is None and not prerelease, + "is_prerelease": bool(prerelease), + "is_dev": dev is not None, + } + return { - 'version': __version__, - 'major': int(major), - 'minor': int(minor), - 'patch': int(patch), - 'dev': int(dev) if dev else None, - 'git_sha': git_sha, - 'prerelease': prerelease, - 'is_release': dev is None and not prerelease, - 'is_prerelease': bool(prerelease), - 'is_dev': dev is not None + "version": __version__, + "major": 0, + "minor": 0, + "patch": 0, + "dev": None, + "git_sha": None, + "prerelease": None, + "is_release": False, + "is_prerelease": False, + "is_dev": True, } - - return { - 'version': __version__, - 'major': 0, - 'minor': 0, - 'patch': 0, - 'dev': None, - 'git_sha': None, - 'prerelease': None, - 'is_release': False, - 'is_prerelease': False, - 'is_dev': True -} \ No newline at end of file diff --git a/src/pkmntrade_club/accounts/adapter.py b/src/pkmntrade_club/accounts/adapter.py index b41e761..b39fed3 100644 --- a/src/pkmntrade_club/accounts/adapter.py +++ b/src/pkmntrade_club/accounts/adapter.py @@ -1,4 +1,3 @@ -from django.conf import settings from allauth.account.adapter import DefaultAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter @@ -10,4 +9,4 @@ class NoSignupAccountAdapter(DefaultAccountAdapter): class NoSignupSocialAccountAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request): - return False \ No newline at end of file + return False diff --git a/src/pkmntrade_club/accounts/admin.py b/src/pkmntrade_club/accounts/admin.py index f3bd5c3..7a004c7 100644 --- a/src/pkmntrade_club/accounts/admin.py +++ b/src/pkmntrade_club/accounts/admin.py @@ -1,9 +1,8 @@ from django.contrib import admin -from django.contrib.auth import get_user_model from django.contrib.auth.admin import UserAdmin -from .forms import CustomUserCreationForm, CustomUserChangeForm -from .models import CustomUser +from .forms import CustomUserChangeForm, CustomUserCreationForm +from .models import CustomUser, FriendCode class CustomUserAdmin(UserAdmin): @@ -28,3 +27,11 @@ class CustomUserAdmin(UserAdmin): admin.site.register(CustomUser, CustomUserAdmin) + + +@admin.register(FriendCode) +class FriendCodeAdmin(admin.ModelAdmin): + list_display = ("friend_code", "in_game_name", "user") + search_fields = ("friend_code", "in_game_name", "user__username", "user__email") + list_select_related = ("user",) + autocomplete_fields = ("user",) diff --git a/src/pkmntrade_club/accounts/apps.py b/src/pkmntrade_club/accounts/apps.py index df72a2b..408657e 100644 --- a/src/pkmntrade_club/accounts/apps.py +++ b/src/pkmntrade_club/accounts/apps.py @@ -2,5 +2,5 @@ from django.apps import AppConfig class AccountsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'pkmntrade_club.accounts' + default_auto_field = "django.db.models.BigAutoField" + name = "pkmntrade_club.accounts" diff --git a/src/pkmntrade_club/accounts/forms.py b/src/pkmntrade_club/accounts/forms.py index 6788d85..0aac8fe 100644 --- a/src/pkmntrade_club/accounts/forms.py +++ b/src/pkmntrade_club/accounts/forms.py @@ -2,15 +2,13 @@ from django import forms from django.contrib.auth.forms import UserCreationForm, UserChangeForm from .models import CustomUser, FriendCode from allauth.account.forms import SignupForm -from crispy_tailwind.tailwind import CSSContainer -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout, Field, Submit + class CustomUserChangeForm(UserChangeForm): - class Meta: model = CustomUser - fields = ['email'] + fields = ["email"] + class FriendCodeForm(forms.ModelForm): class Meta: @@ -27,23 +25,27 @@ class FriendCodeForm(forms.ModelForm): friend_code_formatted = f"{friend_code_clean[:4]}-{friend_code_clean[4:8]}-{friend_code_clean[8:12]}-{friend_code_clean[12:16]}" return friend_code_formatted -class CustomUserCreationForm(SignupForm): +class CustomUserCreationForm(SignupForm): class Meta(UserCreationForm.Meta): model = CustomUser - fields = ['email', 'username', 'friend_code'] + fields = ["email", "username", "friend_code"] email = forms.EmailField( required=True, label="Email", - widget=forms.TextInput(attrs={'placeholder': 'Email', 'class':'dark:bg-base-100'}) + widget=forms.TextInput( + attrs={"placeholder": "Email", "class": "dark:bg-base-100"} + ), ) username = forms.CharField( max_length=24, required=True, label="Username", - widget=forms.TextInput(attrs={'placeholder': 'Username', 'class':'dark:bg-base-100'}) + widget=forms.TextInput( + attrs={"placeholder": "Username", "class": "dark:bg-base-100"} + ), ) friend_code = forms.CharField( @@ -51,14 +53,18 @@ class CustomUserCreationForm(SignupForm): required=True, label="Friend Code", help_text="Enter your friend code in the format XXXX-XXXX-XXXX-XXXX.", - widget=forms.TextInput(attrs={'placeholder': 'XXXX-XXXX-XXXX-XXXX', 'class':'dark:bg-base-100'}) + widget=forms.TextInput( + attrs={"placeholder": "XXXX-XXXX-XXXX-XXXX", "class": "dark:bg-base-100"} + ), ) in_game_name = forms.CharField( max_length=16, required=True, label="In-Game Name", help_text="Enter your in-game name.", - widget=forms.TextInput(attrs={'placeholder': 'In-Game Name', 'class':'dark:bg-base-100'}) + widget=forms.TextInput( + attrs={"placeholder": "In-Game Name", "class": "dark:bg-base-100"} + ), ) def __init__(self, *args, **kwargs): @@ -78,13 +84,14 @@ class CustomUserCreationForm(SignupForm): friend_code_instance = FriendCode.objects.create( friend_code=self.cleaned_data["friend_code"], in_game_name=self.cleaned_data["in_game_name"], - user=user + user=user, ) user.default_friend_code = friend_code_instance user.save() return user + class UserSettingsForm(forms.ModelForm): class Meta: model = CustomUser - fields = ['show_friend_code_on_link_previews', 'enable_email_notifications'] \ No newline at end of file + fields = ["show_friend_code_on_link_previews", "enable_email_notifications"] diff --git a/src/pkmntrade_club/accounts/management/commands/clear_cache.py b/src/pkmntrade_club/accounts/management/commands/clear_cache.py index 8c6863f..96a2b7b 100644 --- a/src/pkmntrade_club/accounts/management/commands/clear_cache.py +++ b/src/pkmntrade_club/accounts/management/commands/clear_cache.py @@ -1,7 +1,8 @@ from django.core.management.base import BaseCommand from django.core.cache import cache + class Command(BaseCommand): def handle(self, *args, **kwargs): cache.clear() - self.stdout.write('Cleared cache\n') \ No newline at end of file + self.stdout.write("Cleared cache\n") diff --git a/src/pkmntrade_club/accounts/migrations/0001_initial.py b/src/pkmntrade_club/accounts/migrations/0001_initial.py index a706671..38c176e 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-05-17 02:07 +# Generated by Django 5.1 on 2025-06-15 03:44 import django.contrib.auth.models import django.contrib.auth.validators @@ -14,53 +14,183 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('auth', '0001_initial'), + ("auth", "0001_initial"), ] operations = [ migrations.CreateModel( - name='CustomUser', + name="CustomUser", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('show_friend_code_on_link_previews', models.BooleanField(default=False, help_text='This will primarily affect share link previews on X, Discord, etc.', verbose_name='Show Friend Code on Link Previews')), - ('enable_email_notifications', models.BooleanField(default=True, help_text='Receive trade notifications via email.', verbose_name='Enable Email Notifications')), - ('reputation_score', models.IntegerField(default=0)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "show_friend_code_on_link_previews", + models.BooleanField( + default=False, + help_text="This will primarily affect share link previews on X, Discord, etc.", + verbose_name="Show Friend Code on Link Previews", + ), + ), + ( + "enable_email_notifications", + models.BooleanField( + default=True, + help_text="Receive trade notifications via email.", + verbose_name="Enable Email Notifications", + ), + ), + ("reputation_score", models.IntegerField(default=0)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), ], options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, }, managers=[ - ('objects', django.contrib.auth.models.UserManager()), + ("objects", django.contrib.auth.models.UserManager()), ], ), migrations.CreateModel( - name='FriendCode', + name="FriendCode", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('friend_code', models.CharField(max_length=19, validators=[pkmntrade_club.accounts.models.validate_friend_code])), - ('in_game_name', models.CharField(max_length=14)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='friend_codes', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "friend_code", + models.CharField( + max_length=19, + validators=[ + pkmntrade_club.accounts.models.validate_friend_code + ], + ), + ), + ("in_game_name", models.CharField(max_length=14)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="friend_codes", + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.AddField( - model_name='customuser', - name='default_friend_code', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.friendcode'), + model_name="customuser", + name="default_friend_code", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="accounts.friendcode", + ), ), ] diff --git a/src/pkmntrade_club/accounts/models.py b/src/pkmntrade_club/accounts/models.py index 65610e7..a78a15b 100644 --- a/src/pkmntrade_club/accounts/models.py +++ b/src/pkmntrade_club/accounts/models.py @@ -3,24 +3,28 @@ from django.db import models from django.core.exceptions import ValidationError import re + def validate_friend_code(value): """Validate that friend code follows the format XXXX-XXXX-XXXX-XXXX where X is a digit.""" - if not re.match(r'^\d{4}-\d{4}-\d{4}-\d{4}$', value): + if not re.match(r"^\d{4}-\d{4}-\d{4}-\d{4}$", value): raise ValidationError( - 'Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit.' + "Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit." ) + class CustomUser(AbstractUser): - default_friend_code = models.ForeignKey("FriendCode", on_delete=models.SET_NULL, null=True, blank=True) + default_friend_code = models.ForeignKey( + "FriendCode", on_delete=models.SET_NULL, null=True, blank=True + ) show_friend_code_on_link_previews = models.BooleanField( default=False, verbose_name="Show Friend Code on Link Previews", - help_text="This will primarily affect share link previews on X, Discord, etc." + help_text="This will primarily affect share link previews on X, Discord, etc.", ) enable_email_notifications = models.BooleanField( default=True, verbose_name="Enable Email Notifications", - help_text="Receive trade notifications via email." + help_text="Receive trade notifications via email.", ) reputation_score = models.IntegerField(default=0) @@ -47,10 +51,13 @@ class CustomUser(AbstractUser): self.default_friend_code = other_codes.first() self.save(update_fields=["default_friend_code"]) + class FriendCode(models.Model): friend_code = models.CharField(max_length=19, validators=[validate_friend_code]) in_game_name = models.CharField(max_length=14, null=False, blank=False) - user = models.ForeignKey(CustomUser, on_delete=models.PROTECT, related_name='friend_codes') + user = models.ForeignKey( + CustomUser, on_delete=models.PROTECT, related_name="friend_codes" + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -67,4 +74,4 @@ class FriendCode(models.Model): self.user.save(update_fields=["default_friend_code"]) def __str__(self): - return self.friend_code \ No newline at end of file + return self.friend_code diff --git a/src/pkmntrade_club/accounts/templatetags/gravatar.py b/src/pkmntrade_club/accounts/templatetags/gravatar.py index c2005a0..a719cd6 100644 --- a/src/pkmntrade_club/accounts/templatetags/gravatar.py +++ b/src/pkmntrade_club/accounts/templatetags/gravatar.py @@ -6,15 +6,17 @@ from django.utils.safestring import mark_safe register = template.Library() + @register.filter def gravatar_hash(email): """ Returns the hash of the email. """ - email_encoded = email.strip().lower().encode('utf-8') + email_encoded = email.strip().lower().encode("utf-8") email_hash = hashlib.sha256(email_encoded).hexdigest() return email_hash + @register.filter def gravatar_url(email, size=20): """ @@ -23,20 +25,22 @@ def gravatar_url(email, size=20): """ default = "retro" email_hash = gravatar_hash(email) - params = urlencode({'d': default, 's': str(size)}) + params = urlencode({"d": default, "s": str(size)}) params = params.replace("&", "&") return f"https://www.gravatar.com/avatar/{email_hash}?{params}" + @register.filter def gravatar_profile_url(email=None): """ Returns the Gravatar Profile URL for a given email. """ if email is None: - return f"https://www.gravatar.com/profile" + return "https://www.gravatar.com/profile" email_hash = gravatar_hash(email) return f"https://secure.gravatar.com/{email_hash}" + @register.filter def gravatar(email, size=20): """ @@ -48,6 +52,7 @@ def gravatar(email, size=20): html = f'Gravatar' return mark_safe(html) + @register.filter def gravatar_no_hover(email, size=20): """ @@ -59,6 +64,7 @@ def gravatar_no_hover(email, size=20): html = f'Gravatar' return mark_safe(html) + @register.filter def gravatar_profile_data(email): """ diff --git a/src/pkmntrade_club/accounts/tests.py b/src/pkmntrade_club/accounts/tests.py index b6c8695..6d00785 100644 --- a/src/pkmntrade_club/accounts/tests.py +++ b/src/pkmntrade_club/accounts/tests.py @@ -9,34 +9,34 @@ from django.core.exceptions import ValidationError from django.contrib.sessions.middleware import SessionMiddleware from pkmntrade_club.accounts.models import FriendCode -from pkmntrade_club.accounts.forms import FriendCodeForm, CustomUserCreationForm, UserSettingsForm +from pkmntrade_club.accounts.forms import ( + FriendCodeForm, + CustomUserCreationForm, + UserSettingsForm, +) from pkmntrade_club.accounts.templatetags import gravatar from pkmntrade_club.trades.models import TradeOffer from tests.utils.rarity import RARITY_MAPPING # Create your tests here. + # ----------------------------- # Model Tests # ----------------------------- class CustomUserModelTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user( - username="testuser", - email="test@example.com", - password="password123" + username="testuser", email="test@example.com", password="password123" ) + def test_set_default_friend_code(self): """User can manually set a friend code as their default.""" fc1 = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", - user=self.user, - in_game_name="GameOne" + friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne" ) fc2 = FriendCode.objects.create( - friend_code="2345-6789-0123-4567", - user=self.user, - in_game_name="GameTwo" + friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo" ) # Manually set fc2 as default. self.user.set_default_friend_code(fc2) @@ -48,14 +48,10 @@ class CustomUserModelTests(TestCase): Attempting to set a friend code that does not belong to the user should raise an exception. """ other_user = get_user_model().objects.create_user( - username="otheruser", - email="other@example.com", - password="password456" + username="otheruser", email="other@example.com", password="password456" ) fc_other = FriendCode.objects.create( - friend_code="3456-7890-1234-5678", - user=other_user, - in_game_name="OtherGame" + friend_code="3456-7890-1234-5678", user=other_user, in_game_name="OtherGame" ) with self.assertRaises(ValidationError): self.user.set_default_friend_code(fc_other) @@ -66,14 +62,10 @@ class CustomUserModelTests(TestCase): the default should be reassigned to another friend code. """ fc1 = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", - user=self.user, - in_game_name="GameOne" + friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne" ) fc2 = FriendCode.objects.create( - friend_code="2345-6789-0123-4567", - user=self.user, - in_game_name="GameTwo" + friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo" ) # Set fc2 as default. self.user.set_default_friend_code(fc2) @@ -89,9 +81,7 @@ class CustomUserModelTests(TestCase): should be prohibited. """ fc = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", - user=self.user, - in_game_name="OnlyGame" + friend_code="1234-5678-9012-3456", user=self.user, in_game_name="OnlyGame" ) self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc) @@ -104,21 +94,19 @@ class CustomUserModelTests(TestCase): the current default should remain unchanged. """ fc1 = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", - user=self.user, - in_game_name="GameOne" + friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne" ) fc2 = FriendCode.objects.create( - friend_code="2345-6789-0123-4567", - user=self.user, - in_game_name="GameTwo" + friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo" ) # By default, fc1 is the default friend code. self.assertEqual(self.user.default_friend_code, fc1) try: self.user.remove_default_friend_code(fc2) - except Exception as e: - self.fail("remove_default_friend_code raised an exception when removing a non-default code.") + except Exception: + self.fail( + "remove_default_friend_code raised an exception when removing a non-default code." + ) self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc1) @@ -129,9 +117,7 @@ class CustomUserModelTests(TestCase): class FriendCodeModelTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user( - username="testuser2", - email="test2@example.com", - password="password123" + username="testuser2", email="test2@example.com", password="password123" ) def test_default_set_on_creation(self): @@ -142,7 +128,7 @@ class FriendCodeModelTests(TestCase): fc = FriendCode.objects.create( friend_code="1234-5678-9012-3456", user=self.user, - in_game_name="GameDefault" + in_game_name="GameDefault", ) self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc) @@ -155,14 +141,14 @@ class FriendCodeModelTests(TestCase): fc1 = FriendCode.objects.create( friend_code="1111-1111-1111-1111", user=self.user, - in_game_name="PrimaryGame" + in_game_name="PrimaryGame", ) # fc1 becomes the default automatically. self.assertEqual(self.user.default_friend_code, fc1) fc2 = FriendCode.objects.create( friend_code="2222-2222-2222-2222", user=self.user, - in_game_name="SecondaryGame" + in_game_name="SecondaryGame", ) self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc1) @@ -174,39 +160,34 @@ class FriendCodeModelTests(TestCase): class FriendCodeFormTests(TestCase): def test_valid_friend_code(self): """Ensure valid friend code is cleaned and formatted properly.""" - form_data = { - "friend_code": "1234567890123456", - "in_game_name": "GameTest" - } + form_data = {"friend_code": "1234567890123456", "in_game_name": "GameTest"} form = FriendCodeForm(data=form_data) self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data["friend_code"], "1234-5678-9012-3456") def test_invalid_friend_code_length(self): """Friend codes with incorrect length should cause validation errors.""" - form_data = { - "friend_code": "12345", - "in_game_name": "GameTest" - } + form_data = {"friend_code": "12345", "in_game_name": "GameTest"} form = FriendCodeForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) + self.assertIn( + "Friend code must be exactly 16 digits long.", form.errors["friend_code"] + ) def test_invalid_friend_code_characters(self): """Friend codes containing non-digit characters should cause validation errors.""" - form_data = { - "friend_code": "12345678901234ab", - "in_game_name": "GameTest" - } + form_data = {"friend_code": "12345678901234ab", "in_game_name": "GameTest"} form = FriendCodeForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) + self.assertIn( + "Friend code must be exactly 16 digits long.", form.errors["friend_code"] + ) def test_friend_code_with_whitespace(self): """Ensure that leading/trailing whitespace is stripped.""" form_data = { "friend_code": " 1234567890123456 ", - "in_game_name": "WhitespaceGame" + "in_game_name": "WhitespaceGame", } form = FriendCodeForm(data=form_data) self.assertTrue(form.is_valid()) @@ -216,7 +197,7 @@ class FriendCodeFormTests(TestCase): """Proper dashes in the input should be accepted.""" form_data = { "friend_code": "1234-5678-9012-3456", - "in_game_name": "ExtraDashGame" + "in_game_name": "ExtraDashGame", } form = FriendCodeForm(data=form_data) self.assertTrue(form.is_valid()) @@ -292,7 +273,9 @@ class CustomUserCreationFormTests(TestCase): } form = CustomUserCreationForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) + self.assertIn( + "Friend code must be exactly 16 digits long.", form.errors["friend_code"] + ) def test_invalid_custom_user_creation_password_mismatch(self): """ @@ -318,7 +301,7 @@ class UserSettingsFormTests(TestCase): self.user = get_user_model().objects.create_user( username="settingsuser", email="settings@example.com", - password="password123" + password="password123", ) def test_toggle_show_friend_code_on_link_previews(self): @@ -337,9 +320,7 @@ class UserSettingsFormTests(TestCase): class FriendCodeViewsTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user( - username="viewuser", - email="viewuser@example.com", - password="password123" + username="viewuser", email="viewuser@example.com", password="password123" ) # Log in this user. self.client.login(username="viewuser", password="password123") @@ -347,12 +328,12 @@ class FriendCodeViewsTests(TestCase): self.friend_code1 = FriendCode.objects.create( friend_code="7777-7777-7777-7777", user=self.user, - in_game_name="ViewGameOne" + in_game_name="ViewGameOne", ) self.friend_code2 = FriendCode.objects.create( friend_code="8888-8888-8888-8888", user=self.user, - in_game_name="ViewGameTwo" + in_game_name="ViewGameTwo", ) # By default, friend_code1 is the default. @@ -390,8 +371,7 @@ class FriendCodeViewsTests(TestCase): self.assertRedirects(response, reverse("list_friend_codes")) self.assertTrue( FriendCode.objects.filter( - user=self.user, - friend_code="9999-9999-9999-9999" + user=self.user, friend_code="9999-9999-9999-9999" ).exists() ) # Ensure that adding a new friend code does not change the default. @@ -404,10 +384,16 @@ class FriendCodeViewsTests(TestCase): data = {"friend_code": "invalidfriendcode", "in_game_name": "InvalidGame"} response = self.client.post(url, data) # Extract the form from the response's context. If response.context is a list, use its first element. - context = response.context[0] if isinstance(response.context, list) else response.context + context = ( + response.context[0] + if isinstance(response.context, list) + else response.context + ) form = context.get("form") self.assertIsNotNone(form, "Form not found in response context") - self.assertFormError(form, "friend_code", "Friend code must be exactly 16 digits long.") + self.assertFormError( + form, "friend_code", "Friend code must be exactly 16 digits long." + ) def test_edit_friend_code_view(self): """Test editing the in-game name of an existing friend code.""" @@ -425,14 +411,10 @@ class FriendCodeViewsTests(TestCase): def test_edit_friend_code_view_wrong_user(self): """A user should not be able to edit a friend code that does not belong to them.""" other_user = get_user_model().objects.create_user( - username="otheruser", - email="other@example.com", - password="password1234" + username="otheruser", email="other@example.com", password="password1234" ) friend_code_other = FriendCode.objects.create( - friend_code="0000-0000-0000-0000", - user=other_user, - in_game_name="OtherGame" + friend_code="0000-0000-0000-0000", user=other_user, in_game_name="OtherGame" ) url = reverse("edit_friend_code", kwargs={"pk": friend_code_other.pk}) response = self.client.get(url) @@ -443,25 +425,25 @@ class FriendCodeViewsTests(TestCase): url = reverse("edit_friend_code", kwargs={"pk": self.friend_code2.pk}) new_data = {"in_game_name": ""} # in_game_name is required. response = self.client.post(url, new_data) - context = response.context[0] if isinstance(response.context, list) else response.context + context = ( + response.context[0] + if isinstance(response.context, list) + else response.context + ) form = context.get("form") self.assertIsNotNone(form, "Form not found in response context") self.assertFormError(form, "in_game_name", "This field is required.") - + def test_delete_friend_code_view_only_code(self): """ If the user has only one friend code, deletion should be disabled. This test uses a new user with a single friend code. """ user_only = get_user_model().objects.create_user( - username="onlyuser", - email="onlyuser@example.com", - password="password123" + username="onlyuser", email="onlyuser@example.com", password="password123" ) friend_code_only = FriendCode.objects.create( - friend_code="4444-4444-4444-4444", - user=user_only, - in_game_name="SoloGame" + friend_code="4444-4444-4444-4444", user=user_only, in_game_name="SoloGame" ) self.client.logout() self.client.login(username="onlyuser", password="password123") @@ -492,7 +474,7 @@ class FriendCodeViewsTests(TestCase): initiated_by=self.friend_code2, is_closed=False, rarity_icon=RARITY_MAPPING[5], - rarity_level=5 + rarity_level=5, ) url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk}) response = self.client.post(url, {}) @@ -517,14 +499,10 @@ class FriendCodeViewsTests(TestCase): def test_change_default_friend_code_view_not_owned(self): """A friend code that does not belong to the current user should result in a 404.""" other_user = get_user_model().objects.create_user( - username="otheruser2", - email="other2@example.com", - password="password789" + username="otheruser2", email="other2@example.com", password="password789" ) friend_code_other = FriendCode.objects.create( - friend_code="1111-1111-1111-1111", - user=other_user, - in_game_name="NotMine" + friend_code="1111-1111-1111-1111", user=other_user, in_game_name="NotMine" ) url = reverse("change_default_friend_code", kwargs={"pk": friend_code_other.pk}) response = self.client.post(url, {}) @@ -561,12 +539,12 @@ class FriendCodeViewsTests(TestCase): other_user = get_user_model().objects.create_user( username="otherdeluser", email="otherdel@example.com", - password="password321" + password="password321", ) friend_code_other = FriendCode.objects.create( friend_code="2222-2222-2222-2222", user=other_user, - in_game_name="OtherDelete" + in_game_name="OtherDelete", ) url = reverse("delete_friend_code", kwargs={"pk": friend_code_other.pk}) response = self.client.get(url) diff --git a/src/pkmntrade_club/accounts/urls.py b/src/pkmntrade_club/accounts/urls.py index 8e9b106..202d789 100644 --- a/src/pkmntrade_club/accounts/urls.py +++ b/src/pkmntrade_club/accounts/urls.py @@ -9,8 +9,20 @@ from .views import ( urlpatterns = [ path("friend-codes/add/", AddFriendCodeView.as_view(), name="add_friend_code"), - path("friend-codes/edit//", EditFriendCodeView.as_view(), name="edit_friend_code"), - path("friend-codes/delete//", DeleteFriendCodeView.as_view(), name="delete_friend_code"), - path("friend-codes/default//", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"), + path( + "friend-codes/edit//", + EditFriendCodeView.as_view(), + name="edit_friend_code", + ), + path( + "friend-codes/delete//", + DeleteFriendCodeView.as_view(), + name="delete_friend_code", + ), + path( + "friend-codes/default//", + ChangeDefaultFriendCodeView.as_view(), + name="change_default_friend_code", + ), path("dashboard/", DashboardView.as_view(), name="dashboard"), -] \ No newline at end of file +] diff --git a/src/pkmntrade_club/accounts/views.py b/src/pkmntrade_club/accounts/views.py index fbecc1e..a170fb7 100644 --- a/src/pkmntrade_club/accounts/views.py +++ b/src/pkmntrade_club/accounts/views.py @@ -1,26 +1,35 @@ 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 pkmntrade_club.trades.mixins import FriendCodeRequiredMixin -from pkmntrade_club.common.mixins import ReusablePaginationMixin +from django.db.models import BooleanField, Case, Q, Value, When +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.http import urlencode +from django.views.generic import ( + CreateView, + DeleteView, + 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'})}" @@ -30,6 +39,7 @@ class AddFriendCodeView(LoginRequiredMixin, CreateView): messages.success(self.request, "Friend code added successfully.") return super().form_valid(form) + class DeleteFriendCodeView(LoginRequiredMixin, DeleteView): """ Remove an existing friend code. @@ -37,9 +47,11 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView): Also, prevent deletion if the friend code is either the only one or is set as the default friend code. """ + model = FriendCode template_name = "friend_codes/confirm_delete_friend_code.html" context_object_name = "friend_code" + def get_success_url(self): base_url = reverse("dashboard") return f"{base_url}?{urlencode({'tab': 'friend_codes'})}" @@ -81,48 +93,59 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView): if user.default_friend_code == self.object: messages.error( request, - "Cannot delete your default friend code. Please set a different default first." + "Cannot delete your default friend code. Please set a different default first.", ) return redirect(self.get_success_url()) - trade_offer_exists = TradeOffer.objects.filter(initiated_by_id=self.object.pk).exists() - trade_acceptance_exists = TradeAcceptance.objects.filter(accepted_by_id=self.object.pk).exists() + trade_offer_exists = TradeOffer.objects.filter( + initiated_by_id=self.object.pk + ).exists() + trade_acceptance_exists = TradeAcceptance.objects.filter( + accepted_by_id=self.object.pk + ).exists() if trade_offer_exists or trade_acceptance_exists: messages.error( request, - "Cannot remove this friend code because there are existing trade offers associated with it." + "Cannot remove this friend code because there are existing trade offers associated with it.", ) return redirect(self.get_success_url()) - + self.object.delete() messages.success(request, "Friend code removed successfully.") return redirect(self.get_success_url()) + class ChangeDefaultFriendCodeView(LoginRequiredMixin, View): """ Change the default friend code for the current user. """ + def post(self, request, *args, **kwargs): friend_code_id = kwargs.get("pk") - friend_code = get_object_or_404(FriendCode, pk=friend_code_id, user=request.user) + friend_code = get_object_or_404( + FriendCode, pk=friend_code_id, user=request.user + ) request.user.set_default_friend_code(friend_code) messages.success(request, "Default friend code updated successfully.") base_url = reverse("dashboard") query_string = urlencode({"tab": "friend_codes"}) return redirect(f"{base_url}?{query_string}") + class EditFriendCodeView(LoginRequiredMixin, UpdateView): """ Edit the in-game name for a friend code. The friend code itself is displayed as plain text. Also includes "Set Default" and "Delete" buttons in the template. """ + model = FriendCode # Only the in_game_name field is editable - fields = ['in_game_name'] + fields = ["in_game_name"] template_name = "friend_codes/edit_friend_code.html" context_object_name = "friend_code" + def get_success_url(self): base_url = reverse("dashboard") return f"{base_url}?{urlencode({'tab': 'friend_codes'})}" @@ -135,12 +158,16 @@ class EditFriendCodeView(LoginRequiredMixin, UpdateView): messages.success(self.request, "Friend code updated successfully.") return super().form_valid(form) -class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginationMixin, TemplateView): + +class DashboardView( + LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginationMixin, TemplateView +): template_name = "account/dashboard.html" def post(self, request, *args, **kwargs): - if 'update_settings' in request.POST: + if "update_settings" in request.POST: from pkmntrade_club.accounts.forms import UserSettingsForm + form = UserSettingsForm(request.POST, instance=request.user) if form.is_valid(): form.save() @@ -156,21 +183,28 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat try: selected_friend_code = friend_codes.get(pk=friend_code_param) except friend_codes.model.DoesNotExist: - selected_friend_code = self.request.user.default_friend_code or friend_codes.first() + selected_friend_code = ( + self.request.user.default_friend_code or friend_codes.first() + ) else: - selected_friend_code = self.request.user.default_friend_code or friend_codes.first() + selected_friend_code = ( + self.request.user.default_friend_code or friend_codes.first() + ) if not selected_friend_code: - raise PermissionDenied("You do not have an active friend code associated with your account.") + raise PermissionDenied( + "You do not have an active friend code associated with your account." + ) return selected_friend_code def get_dashboard_offers_paginated(self, page_param): selected_friend_code = self.get_selected_friend_code() - queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=False) + queryset = TradeOffer.objects.filter( + initiated_by=selected_friend_code, is_closed=False + ) object_list, pagination_context = self.paginate_data(queryset, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} def get_involved_acceptances(self, selected_friend_code): - from django.db.models import Q terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, @@ -178,21 +212,44 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat 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) - ).order_by("-updated_at") + 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") + ) 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} @@ -201,12 +258,19 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat selected_friend_code = self.get_selected_friend_code() involved = self.get_involved_acceptances(selected_friend_code) from django.db.models import Q + waiting = involved.filter( - Q(trade_offer__initiated_by=selected_friend_code, state__in=[ - TradeAcceptance.AcceptanceState.ACCEPTED, - TradeAcceptance.AcceptanceState.RECEIVED, - ]) | - Q(accepted_by=selected_friend_code, state__in=[TradeAcceptance.AcceptanceState.SENT]) + Q( + trade_offer__initiated_by=selected_friend_code, + state__in=[ + TradeAcceptance.AcceptanceState.ACCEPTED, + TradeAcceptance.AcceptanceState.RECEIVED, + ], + ) + | Q( + accepted_by=selected_friend_code, + state__in=[TradeAcceptance.AcceptanceState.SENT], + ) ) others = involved.exclude(pk__in=waiting.values("pk")) object_list, pagination_context = self.paginate_data(others, int(page_param)) @@ -214,42 +278,101 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat def get_closed_offers_paginated(self, page_param): selected_friend_code = self.get_selected_friend_code() - queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=True) + queryset = TradeOffer.objects.filter( + initiated_by=selected_friend_code, is_closed=True + ) object_list, pagination_context = self.paginate_data(queryset, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} def get_closed_acceptances_paginated(self, page_param): - from django.db.models import Q selected_friend_code = self.get_selected_friend_code() terminal_success_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, 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 - ).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, + ) + .select_related( + "trade_offer__initiated_by__user", + "accepted_by__user", + "requested_card__rarity", + "requested_card__cardset", + "offered_card__rarity", + "offered_card__cardset", + ) + .prefetch_related( + "requested_card__translations", + "offered_card__translations", + ) + .order_by("-updated_at") + ) + object_list, pagination_context = self.paginate_data( + acceptance_qs, int(page_param) + ) return {"object_list": object_list, "page_obj": pagination_context} def get_rejected_by_me_paginated(self, page_param): - from django.db.models import Q selected_friend_code = self.get_selected_friend_code() - rejection = TradeAcceptance.objects.filter( - 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") + 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") + ) 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) - ).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, + ) + ) + .select_related( + "trade_offer__initiated_by__user", + "accepted_by__user", + "requested_card__rarity", + "requested_card__cardset", + "offered_card__rarity", + "offered_card__cardset", + ) + .prefetch_related( + "requested_card__translations", + "offered_card__translations", + ) + .order_by("-updated_at") + ) object_list, pagination_context = self.paginate_data(rejection, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} @@ -258,19 +381,19 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat request = self.request selected_friend_code = self.get_selected_friend_code() context["selected_friend_code"] = selected_friend_code - + # Get the default friend code's primary key if it exists default_pk = getattr(request.user.default_friend_code, "pk", None) - + # Annotate friend codes with is_default flag context["friend_codes"] = request.user.friend_codes.all().annotate( is_default=Case( When(pk=default_pk, then=Value(True)), default=Value(False), - output_field=BooleanField() + output_field=BooleanField(), ) ) - + ajax_section = request.GET.get("ajax_section") if ajax_section == "dashboard_offers": offers_page = request.GET.get("page", 1) @@ -307,14 +430,28 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat else: rejected_by_them_page = request.GET.get("rejected_by_them_page", 1) - context["dashboard_offers_paginated"] = self.get_dashboard_offers_paginated(offers_page) - context["trade_acceptances_waiting_paginated"] = self.get_trade_acceptances_waiting_paginated(waiting_page) - context["other_party_trade_acceptances_paginated"] = self.get_other_party_trade_acceptances_paginated(other_page) - context["closed_offers_paginated"] = self.get_closed_offers_paginated(closed_offers_page) - context["closed_acceptances_paginated"] = self.get_closed_acceptances_paginated(closed_acceptances_page) - context["rejected_by_me_paginated"] = self.get_rejected_by_me_paginated(rejected_by_me_page) - context["rejected_by_them_paginated"] = self.get_rejected_by_them_paginated(rejected_by_them_page) - from pkmntrade_club.accounts.forms import UserSettingsForm + context["dashboard_offers_paginated"] = self.get_dashboard_offers_paginated( + offers_page + ) + context["trade_acceptances_waiting_paginated"] = ( + self.get_trade_acceptances_waiting_paginated(waiting_page) + ) + context["other_party_trade_acceptances_paginated"] = ( + self.get_other_party_trade_acceptances_paginated(other_page) + ) + context["closed_offers_paginated"] = self.get_closed_offers_paginated( + closed_offers_page + ) + context["closed_acceptances_paginated"] = self.get_closed_acceptances_paginated( + closed_acceptances_page + ) + context["rejected_by_me_paginated"] = self.get_rejected_by_me_paginated( + rejected_by_me_page + ) + context["rejected_by_them_paginated"] = self.get_rejected_by_them_paginated( + rejected_by_them_page + ) + context["settings_form"] = UserSettingsForm(instance=request.user) context["active_tab"] = request.GET.get("tab", "dash") return context @@ -327,9 +464,13 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat if ajax_section == "dashboard_offers": fragment_context = context.get("dashboard_offers_paginated", {}) elif ajax_section == "waiting_acceptances": - fragment_context = context.get("trade_acceptances_waiting_paginated", {}) + fragment_context = context.get( + "trade_acceptances_waiting_paginated", {} + ) elif ajax_section == "other_party_acceptances": - fragment_context = context.get("other_party_trade_acceptances_paginated", {}) + fragment_context = context.get( + "other_party_trade_acceptances_paginated", {} + ) elif ajax_section == "closed_offers": fragment_context = context.get("closed_offers_paginated", {}) elif ajax_section == "closed_acceptances": @@ -342,8 +483,12 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat fragment_context = {} if fragment_context: - return render(request, "trades/_trade_offer_list.html", { - "offers": fragment_context.get("object_list", []), - "page_obj": fragment_context.get("page_obj") - }) - return super().get(request, *args, **kwargs) \ No newline at end of file + return render( + request, + "trades/_trade_offer_list.html", + { + "offers": fragment_context.get("object_list", []), + "page_obj": fragment_context.get("page_obj"), + }, + ) + return super().get(request, *args, **kwargs) diff --git a/src/pkmntrade_club/cards/admin.py b/src/pkmntrade_club/cards/admin.py index b778a69..963a29c 100644 --- a/src/pkmntrade_club/cards/admin.py +++ b/src/pkmntrade_club/cards/admin.py @@ -1,7 +1,757 @@ -from django.contrib import admin -from .models import Deck, Card, DeckNameTranslation, CardNameTranslation +import hashlib +import io +import json +import os +import re # For parsing set name and ID +import zipfile +from dataclasses import dataclass -admin.site.register(Deck) -admin.site.register(Card) -admin.site.register(DeckNameTranslation) -admin.site.register(CardNameTranslation) \ No newline at end of file +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 diff --git a/src/pkmntrade_club/cards/migrations/0001_initial.py b/src/pkmntrade_club/cards/migrations/0001_initial.py index 03a0132..d25a66e 100644 --- a/src/pkmntrade_club/cards/migrations/0001_initial.py +++ b/src/pkmntrade_club/cards/migrations/0001_initial.py @@ -1,6 +1,8 @@ -# Generated by Django 5.1 on 2025-05-10 01:22 +# Generated by Django 5.1 on 2025-06-20 07:14 import django.db.models.deletion +import parler.fields +import parler.models from django.db import migrations, models @@ -8,64 +10,762 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Card', + name="Ability", fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('cardset', models.CharField(max_length=32)), - ('cardnum', models.IntegerField()), - ('style', models.CharField(max_length=128)), - ('rarity_icon', models.CharField(max_length=12)), - ('rarity_level', models.IntegerField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ("id", models.AutoField(primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), ], + options={ + "verbose_name": "Ability", + "verbose_name_plural": "Abilities", + }, + bases=(parler.models.TranslatableModelMixin, models.Model), ), migrations.CreateModel( - name='Deck', + name="Attack", fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('hex_color', models.CharField(max_length=9)), - ('cardset', models.CharField(max_length=8)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ("id", models.AutoField(primary_key=True, serialize=False)), + ( + "damage", + models.CharField( + blank=True, + help_text="Damage string, e.g., '40', '20x', '80+'.", + max_length=10, + null=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), ], + options={ + "verbose_name": "Attack", + "verbose_name_plural": "Attacks", + }, + bases=(parler.models.TranslatableModelMixin, models.Model), ), migrations.CreateModel( - name='CardNameTranslation', + name="CardSet", fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('language', models.CharField(max_length=64)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.card')), + ( + "id", + models.CharField( + help_text="The ID for the set, e.g., 'A1', 'A1a'.", + max_length=3, + primary_key=True, + serialize=False, + ), + ), + ( + "file_name", + models.CharField( + help_text="Original name of the JSON file, e.g., 'a1-genetic-apex.json'.", + max_length=32, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), ], + options={ + "verbose_name": "Card Set", + "verbose_name_plural": "Card Sets", + }, + bases=(parler.models.TranslatableModelMixin, models.Model), + ), + migrations.CreateModel( + name="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='card', - name='decks', - field=models.ManyToManyField(to='cards.deck'), + model_name="attack", + name="energy_cost", + field=models.ManyToManyField( + related_name="attacks", through="cards.AttackCost", to="cards.energy" + ), ), migrations.CreateModel( - name='DeckNameTranslation', + name="Pack", fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('language', models.CharField(max_length=64)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('deck', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.deck')), + ("id", models.AutoField(primary_key=True, serialize=False)), + ("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", + ), + ), ], + options={ + "verbose_name": "Pack", + "verbose_name_plural": "Packs", + }, + bases=(parler.models.TranslatableModelMixin, models.Model), ), - migrations.AlterUniqueTogether( - name='card', - unique_together={('cardset', 'cardnum')}, + 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), ), ] diff --git a/src/pkmntrade_club/cards/mixins.py b/src/pkmntrade_club/cards/mixins.py index 575d62c..18d887c 100644 --- a/src/pkmntrade_club/cards/mixins.py +++ b/src/pkmntrade_club/cards/mixins.py @@ -1,10 +1,12 @@ from math import ceil + class ReusablePaginationMixin: """ A mixin that encapsulates reusable pagination logic. Use in Django ListViews to generate custom pagination context. """ + per_page = 10 # Default; can be overridden in your view. def paginate_data(self, data_list, page_number): @@ -39,4 +41,4 @@ class ReusablePaginationMixin: "next_page": page_number + 1 if page_number < num_pages else num_pages, "paginator": {"num_pages": num_pages}, } - return items, pagination_context \ No newline at end of file + return items, pagination_context diff --git a/src/pkmntrade_club/cards/models.py b/src/pkmntrade_club/cards/models.py index 9f014ea..92c0601 100644 --- a/src/pkmntrade_club/cards/models.py +++ b/src/pkmntrade_club/cards/models.py @@ -1,53 +1,383 @@ from django.db import models -from django.db.models import Prefetch -from django.apps import apps +from django.utils.translation import gettext_lazy as _ +from parler.managers import TranslatableManager +from parler.models import TranslatableModel, TranslatedFields -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) - def __str__(self): - return self.name +class CardManager(TranslatableManager): + def with_details(self): + """ + Returns a Card queryset with all related fields pre-selected to avoid N+1 queries. + """ + return ( + self.get_queryset() + .select_related( + "rarity", + "cardset", + "card_type", + "pkmn_type", + "weakness_type", + ) + .prefetch_related( + "translations", + "rarity__translations", + "cardset__translations", + "card_type__translations", + "pkmn_type__translations", + "weakness_type__translations", + "attacks__translations", + "abilities__translations", + "packs__translations", + ) + ) -class 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) - 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) +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 + 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) -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) + class Meta: + verbose_name = _("Card Set") + verbose_name_plural = _("Card Sets") - class Meta: - unique_together = ('cardset', 'cardnum') + def __str__(self): + return f"{self.id} - {self.name}" - def __str__(self): - return f"{self.name} ({self.cardset} #{self.cardnum})" \ No newline at end of file + +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}'" diff --git a/src/pkmntrade_club/cards/signals.py b/src/pkmntrade_club/cards/signals.py index dd87f2a..ac03177 100644 --- a/src/pkmntrade_club/cards/signals.py +++ b/src/pkmntrade_club/cards/signals.py @@ -1,16 +1,12 @@ -from django.db.models.signals import m2m_changed -from django.dispatch import receiver -from .models import Card - def color_is_dark(bg_color): """ Determine if a given hexadecimal color is dark. This function accepts a 6-digit hex color string (with or without a leading '#'). It calculates the brightness using the formula: - + brightness = (0.299 * red) + (0.587 * green) + (0.114 * blue) - + A brightness value less than or equal to 186 indicates that the color is dark. Args: @@ -20,7 +16,7 @@ def color_is_dark(bg_color): bool: True if the color is dark (brightness <= 186), False otherwise. """ # Remove the leading '#' if it exists. - color = bg_color[1:7] if bg_color[0] == '#' else bg_color + color = bg_color[1:7] if bg_color[0] == "#" else bg_color # Convert the hex color components to integers. r = int(color[0:2], 16) @@ -29,23 +25,5 @@ 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 + return brightness <= 200 diff --git a/src/pkmntrade_club/cards/templatetags/card_badge.py b/src/pkmntrade_club/cards/templatetags/card_badge.py index ace3b17..c626cd9 100644 --- a/src/pkmntrade_club/cards/templatetags/card_badge.py +++ b/src/pkmntrade_club/cards/templatetags/card_badge.py @@ -1,46 +1,75 @@ from django import template from django.conf import settings from django.template.loader import render_to_string -from django.utils.safestring import mark_safe from django.urls import reverse_lazy +from django.utils.safestring import mark_safe register = template.Library() + +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): +def card_badge(context, card, quantity=None, expanded=False, clickable=True): """ Renders a card badge. """ - url = reverse_lazy('cards:card_detail', args=[card.pk]) + url = reverse_lazy("cards:detail", args=[card.pk]) + style = _get_gradient_style(card.cardset.hex_color) tag_context = { - 'quantity': quantity, - 'style': card.style, - 'name': card.name, - 'rarity': card.rarity_icon, - 'cardset': card.cardset, - 'expanded': expanded, - 'cache_key': f'card_badge_{card.pk}_{quantity}_{expanded}', - 'url': url, + "quantity": quantity, + "style": 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, } context.update(tag_context) return context + @register.filter def card_badge_inline(card, quantity=None): """ Renders an inline card badge by directly rendering the template. """ - url = reverse_lazy('cards:card_detail', args=[card.pk]) + url = reverse_lazy("cards:detail", args=[card.pk]) + style = _get_gradient_style(card.cardset.hex_color) tag_context = { - 'quantity': quantity, - 'style': card.style, - 'name': card.name, - 'rarity': card.rarity_icon, - 'cardset': card.cardset, - 'expanded': True, - 'cache_key': f'card_badge_{card.pk}_{quantity}_{True}', - 'CACHE_TIMEOUT': settings.CACHE_TIMEOUT, - 'url': url, + "quantity": quantity, + "style": 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, } html = render_to_string("templatetags/card_badge.html", tag_context) - return mark_safe(html) \ No newline at end of file + return mark_safe(html) diff --git a/src/pkmntrade_club/cards/templatetags/card_multiselect.py b/src/pkmntrade_club/cards/templatetags/card_multiselect.py index dc03500..e0d0fc7 100644 --- a/src/pkmntrade_club/cards/templatetags/card_multiselect.py +++ b/src/pkmntrade_club/cards/templatetags/card_multiselect.py @@ -1,72 +1,87 @@ -import uuid -from django import template -from pkmntrade_club.cards.models import Card -from django.db.models.query import QuerySet import json -import hashlib -import logging + +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 + register = template.Library() + @register.filter def get_item(dictionary, key): """Allows accessing dictionary items using a variable key in templates.""" return dictionary.get(key) + @register.simple_tag def fetch_all_cards(): """Simple tag to fetch all Card objects.""" - return Card.objects.order_by('pk').all() + return Card.objects.with_details().order_by("id").all() -@register.inclusion_tag('templatetags/card_multiselect.html', takes_context=True) -def card_multiselect(context, field_name, label, placeholder, cards=None, selected_values=None): + +@register.inclusion_tag("templatetags/card_multiselect.html", takes_context=True) +def card_multiselect( + context, field_name, label, placeholder, cards=None, selected_values=None +): """ - Prepares context for rendering a card multiselect input. - Database querying and rendering are handled within the template's cache block. + 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. """ if selected_values is None: selected_values = [] - 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 + # 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) + ) - 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' + # `cards` is for the non-JS fallback search result. + non_js_search_results = cards if isinstance(cards, QuerySet) else [] - selected_cards_key_part = json.dumps(selected_cards, sort_keys=True) + 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), + } + ) - has_passed_cards = isinstance(cards, QuerySet) + initial_selected_cards_json = json.dumps(initial_cards_data) - 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 + 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 diff --git a/src/pkmntrade_club/cards/tests.py b/src/pkmntrade_club/cards/tests.py index 75e73b8..85219a2 100644 --- a/src/pkmntrade_club/cards/tests.py +++ b/src/pkmntrade_club/cards/tests.py @@ -6,11 +6,21 @@ from django.urls import reverse from django.utils import timezone from pkmntrade_club.accounts.models import CustomUser, FriendCode -from pkmntrade_club.cards.models import Card, Deck, DeckNameTranslation, CardNameTranslation -from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard -from pkmntrade_club.cards.templatetags import card_badge, card_multiselect +from pkmntrade_club.cards.models import ( + Card, + Deck, + DeckNameTranslation, + CardNameTranslation, +) +from pkmntrade_club.trades.models import ( + TradeOffer, + TradeOfferHaveCard, + TradeOfferWantCard, +) +from pkmntrade_club.cards.templatetags import card_multiselect from tests.utils.rarity import RARITY_MAPPING + class CardsModelsTests(TestCase): def setUp(self): self.deck = Deck.objects.create( @@ -22,7 +32,7 @@ class CardsModelsTests(TestCase): cardnum=1, style="default", rarity_icon=RARITY_MAPPING[1], - rarity_level=1 + rarity_level=1, ) # Establish many-to-many relationship. self.card.decks.add(self.deck) @@ -44,7 +54,8 @@ class CardsModelsTests(TestCase): card_translation = CardNameTranslation.objects.create( name="Card Translated", card=self.card, language="en" ) - self.assertEqual(str(card_translation), "Card Translated") + self.assertEqual(str(card_translation), "Card Translated") + class CardTemplatetagsTests(TestCase): def setUp(self): @@ -55,12 +66,12 @@ class CardTemplatetagsTests(TestCase): cardnum=2, style="background: green;", rarity_icon="☆", - rarity_level=2 + rarity_level=2, ) def test_card_badge_inclusion_tag(self): """Test the card_badge inclusion tag renders correctly.""" - template_str = '{% load card_badge %}{% card_badge card quantity=3 %}' + template_str = "{% load card_badge %}{% card_badge card quantity=3 %}" t = Template(template_str) c = Context({"card": self.card}) rendered = t.render(c) @@ -71,7 +82,7 @@ class CardTemplatetagsTests(TestCase): def test_card_badge_inline_filter(self): """Test the card_badge_inline filter returns safe HTML with correct data.""" - template_str = '{% load card_badge %}{{ card|card_badge_inline:5 }}' + template_str = "{% load card_badge %}{{ card|card_badge_inline:5 }}" t = Template(template_str) c = Context({"card": self.card}) rendered = t.render(c) @@ -142,7 +153,8 @@ class CardTemplatetagsTests(TestCase): selected_values=[], ) # Verify that the context's cards match those in the database. - self.assertEqual(list(context["cards"]), default_cards) + self.assertEqual(list(context["cards"]), default_cards) + class CardsViewsTests(TestCase): def setUp(self): @@ -161,7 +173,7 @@ class CardsViewsTests(TestCase): cardnum=1, style="default", rarity_icon=RARITY_MAPPING[1], - rarity_level=1 + rarity_level=1, ) def test_card_detail_view_context(self): @@ -198,9 +210,7 @@ class CardsViewsTests(TestCase): Helper method to create a trade offer for the 'have' side with a custom updated_at. """ offer = TradeOffer.objects.create(initiated_by=self.friendcode) - TradeOfferHaveCard.objects.create( - trade_offer=offer, card=self.card, quantity=1 - ) + TradeOfferHaveCard.objects.create(trade_offer=offer, card=self.card, quantity=1) # Adjust updated_at so that ordering can be tested. new_time = timezone.now() + timedelta(minutes=updated_delta_minutes) TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time) @@ -212,9 +222,7 @@ class CardsViewsTests(TestCase): Helper method to create a trade offer for the 'want' side with a custom updated_at. """ offer = TradeOffer.objects.create(initiated_by=self.friendcode) - TradeOfferWantCard.objects.create( - trade_offer=offer, card=self.card, quantity=1 - ) + TradeOfferWantCard.objects.create(trade_offer=offer, card=self.card, quantity=1) new_time = timezone.now() + timedelta(minutes=updated_delta_minutes) TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time) offer.refresh_from_db() @@ -285,4 +293,4 @@ class CardsViewsTests(TestCase): trade_offers_oldest = response_oldest.context.get("trade_offers") self.assertEqual(len(trade_offers_oldest), 2) self.assertEqual(trade_offers_oldest[0].pk, offer1.pk) - self.assertEqual(trade_offers_oldest[1].pk, offer2.pk) \ No newline at end of file + self.assertEqual(trade_offers_oldest[1].pk, offer2.pk) diff --git a/src/pkmntrade_club/cards/urls.py b/src/pkmntrade_club/cards/urls.py index 599427f..8c0e3bf 100644 --- a/src/pkmntrade_club/cards/urls.py +++ b/src/pkmntrade_club/cards/urls.py @@ -1,16 +1,27 @@ from django.urls import path + from .views import ( CardDetailView, + CardListView, TradeOfferHaveCardListView, TradeOfferWantCardListView, - CardListView, + card_search, ) app_name = "cards" urlpatterns = [ - path('', CardListView.as_view(), name='card_list'), - path('/', CardDetailView.as_view(), name='card_detail'), - path('/trade-offers-have/', TradeOfferHaveCardListView.as_view(), name='card_trade_offer_have_list'), - path('/trade-offers-want/', TradeOfferWantCardListView.as_view(), name='card_trade_offer_want_list'), + path("", 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", + ), ] diff --git a/src/pkmntrade_club/cards/views.py b/src/pkmntrade_club/cards/views.py index 08a7bb0..515f070 100644 --- a/src/pkmntrade_club/cards/views.py +++ b/src/pkmntrade_club/cards/views.py @@ -1,30 +1,61 @@ -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.trades.models import TradeOffer -from pkmntrade_club.common.mixins import ReusablePaginationMixin -from django.views import View +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 pkmntrade_club.cards.models import Card +from pkmntrade_club.common.mixins import ReusablePaginationMixin +from pkmntrade_club.trades.models import TradeOffer + 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) @@ -48,6 +79,7 @@ class TradeOfferHaveCardListView(ReusablePaginationMixin, View): # Render the partial template to be injected via AJAX return render(request, "trades/_trade_offer_list.html", context) + class TradeOfferWantCardListView(ReusablePaginationMixin, View): def get(self, request, pk): card = get_object_or_404(Card, pk=pk) @@ -72,6 +104,8 @@ class TradeOfferWantCardListView(ReusablePaginationMixin, View): } # Render the partial template containing the new pagination controls return render(request, "trades/_trade_offer_list.html", context) + + class CardListView(ReusablePaginationMixin, ListView): model = Card # Removed built-in pagination; using custom mixin instead @@ -84,18 +118,43 @@ class CardListView(ReusablePaginationMixin, ListView): def get_ordering(self): order = self.request.GET.get("order", "absolute") + group_by = self.request.GET.get("group_by") + + # When grouping, the ordering must match the grouping attribute. + if group_by == "cardset": + return ("cardset__name",) + elif group_by == "rarity": + # Order by level (desc) then by icon/name to keep groups together. + return "-rarity__level", "rarity__icon" + # Note: Grouping by 'pack' is complex due to M2M and would require a custom implementation. + if order == "alphabetical": return "name" elif order == "rarity": - return "-rarity_level" + return "-rarity__level" else: # absolute ordering return "id" def get_queryset(self): qs = super().get_queryset() ordering = self.get_ordering() - qs = qs.order_by(ordering) - return qs.prefetch_related("decks").distinct() + # 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() + ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -104,45 +163,60 @@ class CardListView(ReusablePaginationMixin, ListView): context["order"] = order context["group_by"] = group_by - if group_by in ("deck", "cardset", "rarity"): - full_qs = self.get_queryset() - all_cards = list(full_qs) - flat_cards = [] - if group_by == "deck": - for card in all_cards: - for deck in card.decks.all(): - flat_cards.append({"group": deck.name, "card": card}) - flat_cards.sort(key=lambda x: x["group"].lower()) - elif group_by == "cardset": - for card in all_cards: - flat_cards.append({"group": card.cardset, "card": card}) - flat_cards.sort(key=lambda x: x["group"].lower()) - elif group_by == "rarity": - for card in all_cards: - flat_cards.append({"group": card.rarity_icon, "sort_group": card.rarity_level, "card": card}) - flat_cards.sort(key=lambda x: x["sort_group"], reverse=True) + # Unified pagination logic for all cases. + # The complex manual grouping logic has been removed for performance. + # The template should now use the `regroup` template tag for display. + page_number = self.get_page_number() + self.per_page = 36 + 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 - page_number = self.get_page_number() - self.per_page = 36 - page_flat_cards, pagination_context = self.paginate_data(flat_cards, page_number) - page_groups = [] - for item in page_flat_cards: - group_value = item["group"] - card_obj = item["card"] - if page_groups and page_groups[-1]["group"] == group_value: - page_groups[-1]["cards"].append(card_obj) - else: - page_groups.append({"group": group_value, "cards": [card_obj]}) - context["groups"] = page_groups - context["page_obj"] = pagination_context - context["total_cards"] = len(flat_cards) - context["object_list"] = full_qs - else: - page_number = self.get_page_number() - self.per_page = 36 - paginated_cards, pagination_context = self.paginate_data(self.get_queryset(), page_number) - context["cards"] = paginated_cards - context["page_obj"] = pagination_context - context["object_list"] = self.get_queryset() - return context \ No newline at end of file +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) diff --git a/src/pkmntrade_club/common/apps.py b/src/pkmntrade_club/common/apps.py index 6e2cde5..7427944 100644 --- a/src/pkmntrade_club/common/apps.py +++ b/src/pkmntrade_club/common/apps.py @@ -5,4 +5,4 @@ class CommonConfig(AppConfig): name = "pkmntrade_club.common" def ready(self): - pass \ No newline at end of file + pass diff --git a/src/pkmntrade_club/common/context_processors.py b/src/pkmntrade_club/common/context_processors.py index 7950ded..4ee3c97 100644 --- a/src/pkmntrade_club/common/context_processors.py +++ b/src/pkmntrade_club/common/context_processors.py @@ -1,12 +1,28 @@ +import random + from django.conf import settings + def cache_settings(request): + """ + Pass cache settings to the template context. + Applies jitter to the timeouts. + """ + jitter = settings.CACHE_JITTER return { - 'CACHE_TIMEOUT': settings.CACHE_TIMEOUT, + "CACHE_SHORT_TIMEOUT": settings.CACHE_SHORT_TIMEOUT + + random.randint(-jitter, jitter), + "CACHE_MEDIUM_TIMEOUT": settings.CACHE_MEDIUM_TIMEOUT + + random.randint(-jitter, jitter), + "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT + + random.randint(-jitter, jitter), + "CACHE_DEFAULT_TIMEOUT": settings.CACHE_DEFAULT_TIMEOUT + + random.randint(-jitter, jitter), } + def version_info(request): return { - 'VERSION': settings.VERSION, - 'VERSION_INFO': settings.VERSION_INFO, - } \ No newline at end of file + "VERSION": settings.VERSION, + "VERSION_INFO": settings.VERSION_INFO, + } diff --git a/src/pkmntrade_club/common/mixins.py b/src/pkmntrade_club/common/mixins.py index 6290fdc..0df467a 100644 --- a/src/pkmntrade_club/common/mixins.py +++ b/src/pkmntrade_club/common/mixins.py @@ -26,9 +26,13 @@ class ReusablePaginationMixin: "number": page.number, "has_previous": page.has_previous(), "has_next": page.has_next(), - "previous_page_number": page.previous_page_number() if page.has_previous() else 1, - "next_page_number": page.next_page_number() if page.has_next() else paginator.num_pages, + "previous_page_number": ( + page.previous_page_number() if page.has_previous() else 1 + ), + "next_page_number": ( + page.next_page_number() if page.has_next() else paginator.num_pages + ), "paginator": {"num_pages": paginator.num_pages}, - "count": paginator.count + "count": paginator.count, } - return page.object_list, pagination_context \ No newline at end of file + return page.object_list, pagination_context diff --git a/src/pkmntrade_club/common/templatetags/pagination_tags.py b/src/pkmntrade_club/common/templatetags/pagination_tags.py index a9a2890..0840881 100644 --- a/src/pkmntrade_club/common/templatetags/pagination_tags.py +++ b/src/pkmntrade_club/common/templatetags/pagination_tags.py @@ -2,9 +2,10 @@ from django import template register = template.Library() + @register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True) def render_pagination(context, page_obj, hide_if_one_page=True): """ Renders the pagination controls given a page_obj. Optionally hides the controls if there is only one page. """ - return {"page_obj": page_obj, "hide_if_one_page": hide_if_one_page} \ No newline at end of file + return {"page_obj": page_obj, "hide_if_one_page": hide_if_one_page} diff --git a/src/pkmntrade_club/django_project/__init__.py b/src/pkmntrade_club/django_project/__init__.py index 1e3599b..5568b6d 100644 --- a/src/pkmntrade_club/django_project/__init__.py +++ b/src/pkmntrade_club/django_project/__init__.py @@ -2,4 +2,4 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app -__all__ = ('celery_app',) \ No newline at end of file +__all__ = ("celery_app",) diff --git a/src/pkmntrade_club/django_project/asgi.py b/src/pkmntrade_club/django_project/asgi.py index 11400ef..715ac8b 100644 --- a/src/pkmntrade_club/django_project/asgi.py +++ b/src/pkmntrade_club/django_project/asgi.py @@ -2,6 +2,8 @@ import os from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pkmntrade_club.django_project.settings') +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" +) application = get_asgi_application() diff --git a/src/pkmntrade_club/django_project/celery.py b/src/pkmntrade_club/django_project/celery.py index 44c9d6c..7896883 100644 --- a/src/pkmntrade_club/django_project/celery.py +++ b/src/pkmntrade_club/django_project/celery.py @@ -3,15 +3,17 @@ import os from celery import Celery # Set the default Django settings module for the 'celery' program. -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pkmntrade_club.django_project.settings') +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" +) -app = Celery('django_project') +app = Celery("django_project") # Using a string here means the worker doesn't have to serialize # the configuration object to child processes. # - namespace='CELERY' means all celery-related configuration keys # should have a `CELERY_` prefix. -app.config_from_object('django.conf:settings', namespace='CELERY') +app.config_from_object("django.conf:settings", namespace="CELERY") # Load task modules from all registered Django apps. app.autodiscover_tasks() @@ -19,4 +21,4 @@ app.autodiscover_tasks() @app.task(bind=True, ignore_result=True) def debug_task(self): - print(f'Request: {self.request!r}') \ No newline at end of file + print(f"Request: {self.request!r}") diff --git a/src/pkmntrade_club/django_project/settings.py b/src/pkmntrade_club/django_project/settings.py index 550b184..43ec668 100644 --- a/src/pkmntrade_club/django_project/settings.py +++ b/src/pkmntrade_club/django_project/settings.py @@ -1,74 +1,90 @@ -import socket -from pathlib import Path -import environ -import os import logging +import os +import socket import sys +from pathlib import Path + +import environ from django.utils.translation import gettext_lazy as _ + from pkmntrade_club._version import __version__, get_version_info # set default values to local dev values 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, ''), - 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'), + 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"), ) 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", }, }, } @@ -77,14 +93,18 @@ LOGGING = { BASE_DIR = Path(__file__).resolve().parent.parent # Take environment variables from .env file -environ.Env.read_env(os.path.join(BASE_DIR, '.env')) +environ.Env.read_env(os.path.join(BASE_DIR, ".env")) -SCHEME = env('SCHEME') -PUBLIC_HOST = env('PUBLIC_HOST') -REDIS_URL = env('REDIS_URL') -CACHE_TIMEOUT = env('CACHE_TIMEOUT') -DISABLE_SIGNUPS = env('DISABLE_SIGNUPS') -DISABLE_CACHE = env('DISABLE_CACHE') +SCHEME = env("SCHEME") +PUBLIC_HOST = env("PUBLIC_HOST") +REDIS_URL = env("REDIS_URL") +CACHE_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") VERSION = __version__ VERSION_INFO = get_version_info() @@ -94,31 +114,38 @@ VERSION_INFO = get_version_info() # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = env('SECRET_KEY') +SECRET_KEY = env("SECRET_KEY") # https://docs.djangoproject.com/en/dev/ref/settings/#debug # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env('DEBUG') +DEBUG = env("DEBUG") # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = env('ALLOWED_HOSTS').split(',') +ALLOWED_HOSTS = env("ALLOWED_HOSTS").split(",") try: current_web_worker_hostname = socket.gethostname() ALLOWED_HOSTS.append(current_web_worker_hostname) - logging.getLogger(__name__).info(f"Added {current_web_worker_hostname} to allowed hosts.") + logging.getLogger(__name__).info( + f"Added {current_web_worker_hostname} to allowed hosts." + ) except Exception: - logging.getLogger(__name__).info(f"Error determining server hostname for allowed hosts.") + logging.getLogger(__name__).info( + "Error determining server hostname for allowed hosts." + ) CSRF_TRUSTED_ORIGINS = [f"{SCHEME}://{PUBLIC_HOST}"] +SHORTHAND_DATETIME_FORMAT = "Y-m-d P" +SHORTHAND_DATE_FORMAT = "Y-m-d" + 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 @@ -136,21 +163,22 @@ 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: @@ -160,12 +188,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 = [ @@ -188,22 +216,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 = [ @@ -226,7 +254,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 @@ -251,8 +279,10 @@ 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 @@ -261,7 +291,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/ @@ -296,23 +326,24 @@ STORAGES = { # Default primary key field type # https://docs.djangoproject.com/en/stable/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # django-crispy-forms # https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs -CRISPY_ALLOWED_TEMPLATE_PACKS = 'tailwind' +CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind" CRISPY_TEMPLATE_PACK = "tailwind" # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = env('DJANGO_EMAIL_HOST') -EMAIL_PORT = env('DJANGO_EMAIL_PORT') -EMAIL_HOST_USER = env('DJANGO_EMAIL_USER') -EMAIL_HOST_PASSWORD = env('DJANGO_EMAIL_PASSWORD') -EMAIL_USE_TLS = env('DJANGO_EMAIL_USE_TLS') +EMAIL_HOST = env("DJANGO_EMAIL_HOST") +EMAIL_PORT = env("DJANGO_EMAIL_PORT") +EMAIL_HOST_USER = env("DJANGO_EMAIL_USER") +EMAIL_HOST_PASSWORD = env("DJANGO_EMAIL_PASSWORD") +EMAIL_USE_TLS = env("DJANGO_EMAIL_USE_TLS") +EMAIL_SUBJECT_PREFIX = env("DJANGO_EMAIL_SUBJECT_PREFIX") # https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email -DEFAULT_FROM_EMAIL = env('DJANGO_DEFAULT_FROM_EMAIL') +DEFAULT_FROM_EMAIL = env("DJANGO_DEFAULT_FROM_EMAIL") # django-debug-toolbar # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html @@ -325,7 +356,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" @@ -334,6 +365,15 @@ 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" @@ -347,13 +387,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 @@ -374,6 +414,7 @@ SOCIALACCOUNT_ONLY = False SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SECURE = PUBLIC_HOST != "localhost" or PUBLIC_HOST != "127.0.0.1" # auto-detection doesn't work properly sometimes, so we'll just use the DEBUG setting DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG} diff --git a/src/pkmntrade_club/django_project/urls.py b/src/pkmntrade_club/django_project/urls.py index 61cac00..84a15be 100644 --- a/src/pkmntrade_club/django_project/urls.py +++ b/src/pkmntrade_club/django_project/urls.py @@ -4,11 +4,11 @@ from debug_toolbar.toolbar import debug_toolbar_urls urlpatterns = [ path("admin/", admin.site.urls), - path('account/', include('pkmntrade_club.accounts.urls')), + path("account/", include("pkmntrade_club.accounts.urls")), path("accounts/", include("allauth.urls")), path("", include("pkmntrade_club.home.urls")), path("cards/", include("pkmntrade_club.cards.urls")), - path("health/", include('health_check.urls')), + path("health/", include("health_check.urls")), path("trades/", include("pkmntrade_club.trades.urls")), path("__reload__/", include("django_browser_reload.urls")), ] + debug_toolbar_urls() diff --git a/src/pkmntrade_club/django_project/wsgi.py b/src/pkmntrade_club/django_project/wsgi.py index 4c5ab87..711707d 100644 --- a/src/pkmntrade_club/django_project/wsgi.py +++ b/src/pkmntrade_club/django_project/wsgi.py @@ -2,6 +2,8 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings") +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" +) app = get_wsgi_application() diff --git a/src/pkmntrade_club/home/tests.py b/src/pkmntrade_club/home/tests.py index 9c51303..a174be0 100644 --- a/src/pkmntrade_club/home/tests.py +++ b/src/pkmntrade_club/home/tests.py @@ -2,241 +2,221 @@ from django.test import TestCase, Client, RequestFactory from django.urls import reverse from django.contrib.auth import get_user_model from pkmntrade_club.cards.models import Card, Deck -from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard +from pkmntrade_club.trades.models import ( + TradeOffer, + TradeOfferHaveCard, + TradeOfferWantCard, +) from pkmntrade_club.accounts.models import FriendCode from pkmntrade_club.home.views import HomePageView -import json from collections import OrderedDict from unittest.mock import patch, MagicMock -from django.core.exceptions import ObjectDoesNotExist -import importlib from tests.utils.rarity import RARITY_MAPPING User = get_user_model() + class HomePageViewTests(TestCase): """Test suite for the HomePageView.""" - + @classmethod def setUpTestData(cls): """Set up data for all test methods.""" # Create a user cls.user = User.objects.create_user( - username='testuser', - email='testuser@example.com', - password='testpass123' + username="testuser", email="testuser@example.com", password="testpass123" ) - + # Create a friend code for the user cls.friend_code = FriendCode.objects.create( - user=cls.user, - friend_code='SW-1234-5678-9012', - in_game_name='TestTrainer' + user=cls.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer" ) - + # Create decks cls.deck1 = Deck.objects.create( - name='Test Deck 1', - hex_color='#FF0000', - cardset='TEST01' + name="Test Deck 1", hex_color="#FF0000", cardset="TEST01" ) - + # Create cards with different rarities cls.common_card = Card.objects.create( - name='Common Test Card', - cardset='TEST01', + name="Common Test Card", + cardset="TEST01", cardnum=1, - style='normal', - rarity_icon='★', - rarity_level=1 + style="normal", + rarity_icon="★", + rarity_level=1, ) cls.common_card.decks.add(cls.deck1) - + cls.rare_card = Card.objects.create( - name='Rare Test Card', - cardset='TEST01', + name="Rare Test Card", + cardset="TEST01", cardnum=2, - style='normal', - rarity_icon='★★★', - rarity_level=3 + style="normal", + rarity_icon="★★★", + rarity_level=3, ) cls.rare_card.decks.add(cls.deck1) - + cls.ultra_rare_card = Card.objects.create( - name='Ultra Rare Test Card', - cardset='TEST01', + name="Ultra Rare Test Card", + cardset="TEST01", cardnum=3, - style='normal', - rarity_icon='★★★★', - rarity_level=4 + style="normal", + rarity_icon="★★★★", + rarity_level=4, ) cls.ultra_rare_card.decks.add(cls.deck1) - + # Create trade offers with consistent rarities cls.common_trade = TradeOffer.objects.create( - initiated_by=cls.friend_code, - rarity_icon=RARITY_MAPPING[1], - rarity_level=1 + initiated_by=cls.friend_code, rarity_icon=RARITY_MAPPING[1], rarity_level=1 ) - + cls.rare_trade = TradeOffer.objects.create( - initiated_by=cls.friend_code, - rarity_icon=RARITY_MAPPING[3], - rarity_level=3 + initiated_by=cls.friend_code, rarity_icon=RARITY_MAPPING[3], rarity_level=3 ) - + # Add have and want cards with the SAME rarity for each trade TradeOfferHaveCard.objects.create( - trade_offer=cls.common_trade, - card=cls.common_card, - quantity=2 + trade_offer=cls.common_trade, card=cls.common_card, quantity=2 ) - + TradeOfferHaveCard.objects.create( - trade_offer=cls.rare_trade, - card=cls.rare_card, - quantity=1 + trade_offer=cls.rare_trade, card=cls.rare_card, quantity=1 ) - + # Add want cards with the SAME rarity as the have cards for each trade TradeOfferWantCard.objects.create( - trade_offer=cls.common_trade, - card=cls.common_card, - quantity=1 + trade_offer=cls.common_trade, card=cls.common_card, quantity=1 ) - + TradeOfferWantCard.objects.create( trade_offer=cls.rare_trade, card=cls.rare_card, # Changed from ultra_rare_card to match the rarity - quantity=1 + quantity=1, ) - + def setUp(self): """Set up before each test method.""" self.client = Client() - self.url = reverse('home') + self.url = reverse("home") self.factory = RequestFactory() - + def test_home_page_status_code(self): """Test that the home page returns a 200 status code.""" response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - + def test_home_page_template(self): """Test that the home page uses the correct template.""" response = self.client.get(self.url) - self.assertTemplateUsed(response, 'home/home.html') - + self.assertTemplateUsed(response, "home/home.html") + def test_home_page_context_cards(self): """Test that the home page contains all cards in the context.""" response = self.client.get(self.url) - self.assertIn('cards', response.context) - self.assertEqual(response.context['cards'].count(), 3) - + self.assertIn("cards", response.context) + self.assertEqual(response.context["cards"].count(), 3) + def test_home_page_context_recent_offers(self): """Test that the home page contains recent offers in the context.""" response = self.client.get(self.url) - self.assertIn('recent_offers', response.context) - self.assertEqual(len(response.context['recent_offers']), 2) + self.assertIn("recent_offers", response.context) + self.assertEqual(len(response.context["recent_offers"]), 2) # Recent offers should be ordered by most recent first - self.assertEqual(response.context['recent_offers'][0], self.rare_trade) - + self.assertEqual(response.context["recent_offers"][0], self.rare_trade) + def test_home_page_context_most_offered_cards(self): """Test that the home page contains most offered cards in the context.""" response = self.client.get(self.url) - self.assertIn('most_offered_cards', response.context) - most_offered = list(response.context['most_offered_cards']) + self.assertIn("most_offered_cards", response.context) + most_offered = list(response.context["most_offered_cards"]) self.assertEqual(len(most_offered), 2) # Common card should be most offered (quantity of 2) self.assertEqual(most_offered[0], self.common_card) - + def test_home_page_context_most_wanted_cards(self): """Test that the home page contains most wanted cards in the context.""" response = self.client.get(self.url) - self.assertIn('most_wanted_cards', response.context) - most_wanted = list(response.context['most_wanted_cards']) + self.assertIn("most_wanted_cards", response.context) + most_wanted = list(response.context["most_wanted_cards"]) self.assertEqual(len(most_wanted), 2) - + def test_home_page_context_least_offered_cards(self): """Test that the home page contains least offered cards in the context.""" response = self.client.get(self.url) - self.assertIn('least_offered_cards', response.context) - + self.assertIn("least_offered_cards", response.context) + def test_home_page_context_featured_offers(self): """Test that the home page contains featured offers in the context.""" response = self.client.get(self.url) - self.assertIn('featured_offers', response.context) - featured = response.context['featured_offers'] + self.assertIn("featured_offers", response.context) + featured = response.context["featured_offers"] # Should be an OrderedDict self.assertIsInstance(featured, OrderedDict) # Should contain "All" category self.assertIn("All", featured) # Should contain both rarity icons - self.assertIn('★★★', featured) - self.assertIn('★', featured) + self.assertIn("★★★", featured) + self.assertIn("★", featured) # Higher rarity should come before lower rarity keys = list(featured.keys()) # First key should be "All" self.assertEqual(keys[0], "All") # Higher rarity (★★★) should come before lower rarity (★) - self.assertIn('★★★', keys) - self.assertIn('★', keys) - self.assertTrue(keys.index('★★★') < keys.index('★')) - + self.assertIn("★★★", keys) + self.assertIn("★", keys) + self.assertTrue(keys.index("★★★") < keys.index("★")) + def test_closed_offers_not_shown(self): """Test that closed offers are not shown on the home page.""" # Close one of the trade offers self.common_trade.is_closed = True self.common_trade.save() - + response = self.client.get(self.url) - recent_offers = response.context['recent_offers'] + recent_offers = response.context["recent_offers"] # Should only show the rare trade now self.assertEqual(len(recent_offers), 1) self.assertEqual(recent_offers[0], self.rare_trade) - + def test_home_page_with_no_data(self): """Test home page rendering when there's no trade data.""" # Delete all trade offers TradeOffer.objects.all().delete() - + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) # Should have empty lists for offers - self.assertEqual(len(response.context['recent_offers']), 0) - + self.assertEqual(len(response.context["recent_offers"]), 0) + def test_home_page_with_authenticated_user(self): """Test that the home page works for authenticated users.""" - self.client.login(username='testuser', password='testpass123') + self.client.login(username="testuser", password="testpass123") response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - + def test_rarity_sorting_in_featured_offers(self): """Test that offers are sorted by rarity level in descending order.""" # Create a new ultra rare trade with consistent rarity ultra_trade = TradeOffer.objects.create( - initiated_by=self.friend_code, - rarity_icon='★★★★', - rarity_level=4 + initiated_by=self.friend_code, rarity_icon="★★★★", rarity_level=4 ) - + # Add have and want cards with the same rarity TradeOfferHaveCard.objects.create( - trade_offer=ultra_trade, - card=self.ultra_rare_card, - quantity=1 + trade_offer=ultra_trade, card=self.ultra_rare_card, quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=ultra_trade, - card=self.ultra_rare_card, - quantity=1 + trade_offer=ultra_trade, card=self.ultra_rare_card, quantity=1 ) - + response = self.client.get(self.url) - featured = response.context['featured_offers'] + featured = response.context["featured_offers"] keys = list(featured.keys()) - + # Order should be: "All", "★★★★" (level 4), "★★★" (level 3), "★" (level 1) self.assertEqual(keys[0], "All") self.assertEqual(keys[1], "★★★★") @@ -246,216 +226,202 @@ class HomePageViewTests(TestCase): class HomePageViewMockTests(TestCase): """Test suite using mocks for HomePageView.""" - + def setUp(self): self.factory = RequestFactory() self.view = HomePageView() - - @patch('trades.models.TradeOffer.objects') - @patch('cards.models.Card.objects') + + @patch("trades.models.TradeOffer.objects") + @patch("cards.models.Card.objects") def test_get_context_data_with_mocks(self, mock_card_objects, mock_offer_objects): """Test get_context_data using mocks.""" # Set up request - request = self.factory.get(reverse('home')) + request = self.factory.get(reverse("home")) self.view.request = request - + # Mock the queryset responses mock_offer_filter = MagicMock() mock_offer_objects.filter.return_value = mock_offer_filter mock_offer_filter.order_by.return_value = [] - + mock_card_filter = MagicMock() mock_card_objects.filter.return_value = mock_card_filter mock_card_objects.annotate.return_value = mock_card_filter mock_card_objects.all.return_value.order_by.return_value = [] mock_card_filter.annotate.return_value = mock_card_filter mock_card_filter.order_by.return_value = [] - + mock_offer_filter.values_list.return_value.distinct.return_value = [] - + # Call the method context = self.view.get_context_data() - + # Verify the expected context keys exist - self.assertIn('cards', context) - self.assertIn('recent_offers', context) - self.assertIn('most_offered_cards', context) - self.assertIn('most_wanted_cards', context) - self.assertIn('least_offered_cards', context) - self.assertIn('featured_offers', context) - - @patch('trades.models.TradeOffer.objects') + self.assertIn("cards", context) + self.assertIn("recent_offers", context) + self.assertIn("most_offered_cards", context) + self.assertIn("most_wanted_cards", context) + self.assertIn("least_offered_cards", context) + self.assertIn("featured_offers", context) + + @patch("trades.models.TradeOffer.objects") def test_empty_featured_offers(self, mock_offer_objects): """Test handling of empty featured offers.""" # Set up request - request = self.factory.get(reverse('home')) + request = self.factory.get(reverse("home")) self.view.request = request - + # Configure mock to return empty queryset mock_offer_filter = MagicMock() mock_offer_objects.filter.return_value = mock_offer_filter mock_offer_filter.order_by.return_value = [] mock_offer_filter.values_list.return_value.distinct.return_value = [] - + # Call the method context = self.view.get_context_data() - + # Verify the featured_offers is an OrderedDict but with just the "All" key - self.assertIsInstance(context['featured_offers'], OrderedDict) - self.assertIn("All", context['featured_offers']) - self.assertEqual(len(context['featured_offers']), 1) - - @patch('trades.models.TradeOffer.objects.filter') + self.assertIsInstance(context["featured_offers"], OrderedDict) + self.assertIn("All", context["featured_offers"]) + self.assertEqual(len(context["featured_offers"]), 1) + + @patch("trades.models.TradeOffer.objects.filter") def test_exception_handling(self, mock_filter): """Test that exceptions are handled gracefully.""" # Set up request - request = self.factory.get(reverse('home')) + request = self.factory.get(reverse("home")) self.view.request = request - + # Configure mock to raise an exception mock_filter.side_effect = Exception("Database error") - + # Call the method - should not raise an exception - with self.assertLogs(level='ERROR') as cm: + with self.assertLogs(level="ERROR") as cm: context = self.view.get_context_data() - + # Check if error was logged - self.assertIn("Unhandled error in HomePageView.get_context_data", cm.output[0]) - + self.assertIn( + "Unhandled error in HomePageView.get_context_data", cm.output[0] + ) + # Verify fallback values were set - self.assertEqual(len(context['cards']), 0) - self.assertEqual(len(context['recent_offers']), 0) - self.assertEqual(len(context['most_offered_cards']), 0) - self.assertEqual(len(context['most_wanted_cards']), 0) - self.assertEqual(len(context['least_offered_cards']), 0) - self.assertIsInstance(context['featured_offers'], OrderedDict) - self.assertEqual(len(context['featured_offers']), 1) - self.assertIn("All", context['featured_offers']) + self.assertEqual(len(context["cards"]), 0) + self.assertEqual(len(context["recent_offers"]), 0) + self.assertEqual(len(context["most_offered_cards"]), 0) + self.assertEqual(len(context["most_wanted_cards"]), 0) + self.assertEqual(len(context["least_offered_cards"]), 0) + self.assertIsInstance(context["featured_offers"], OrderedDict) + self.assertEqual(len(context["featured_offers"]), 1) + self.assertIn("All", context["featured_offers"]) + class HomePageEdgeCaseTests(TestCase): """Test edge cases for the home page.""" - + def setUp(self): self.client = Client() - self.url = reverse('home') - + self.url = reverse("home") + # Create a user self.user = User.objects.create_user( - username='testuser', - email='testuser@example.com', - password='testpass123' + username="testuser", email="testuser@example.com", password="testpass123" ) - + # Create a friend code for the user self.friend_code = FriendCode.objects.create( - user=self.user, - friend_code='SW-1234-5678-9012', - in_game_name='TestTrainer' + user=self.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer" ) - + def test_home_page_with_no_cards(self): """Test home page with no cards in the database.""" response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.context['cards']), 0) - + self.assertEqual(len(response.context["cards"]), 0) + def test_home_page_with_many_offers(self): """Test home page with many offers to verify pagination or limiting works.""" # Create a card card = Card.objects.create( - name='Test Card', - cardset='TEST01', + name="Test Card", + cardset="TEST01", cardnum=1, - style='normal', - rarity_icon='★', - rarity_level=1 + style="normal", + rarity_icon="★", + rarity_level=1, ) - + # Create 20 trade offers for i in range(20): trade = TradeOffer.objects.create( - initiated_by=self.friend_code, - rarity_icon='★', - rarity_level=1 + initiated_by=self.friend_code, rarity_icon="★", rarity_level=1 ) - + # Add have and want cards - TradeOfferHaveCard.objects.create( - trade_offer=trade, - card=card, - quantity=1 - ) - - TradeOfferWantCard.objects.create( - trade_offer=trade, - card=card, - quantity=1 - ) - + TradeOfferHaveCard.objects.create(trade_offer=trade, card=card, quantity=1) + + TradeOfferWantCard.objects.create(trade_offer=trade, card=card, quantity=1) + response = self.client.get(self.url) - + # Check that recent_offers is limited to 6 as per the view - self.assertEqual(len(response.context['recent_offers']), 6) - + self.assertEqual(len(response.context["recent_offers"]), 6) + def test_home_page_with_invalid_parameters(self): """Test home page with invalid GET parameters.""" # The view should ignore invalid parameters response = self.client.get(f"{self.url}?invalid=param&another=invalid") self.assertEqual(response.status_code, 200) - + def test_performance_with_large_dataset(self): """Test performance with a larger dataset (basic check).""" # Create a card card = Card.objects.create( - name='Performance Test Card', - cardset='PERF01', + name="Performance Test Card", + cardset="PERF01", cardnum=1, - style='normal', - rarity_icon='★', - rarity_level=1 + style="normal", + rarity_icon="★", + rarity_level=1, ) - + # Create 50 trade offers with different rarities for i in range(50): rarity_level = (i % 5) + 1 # 1-5 - rarity_icon = '★' * rarity_level - + rarity_icon = "★" * rarity_level + trade = TradeOffer.objects.create( initiated_by=self.friend_code, rarity_icon=rarity_icon, - rarity_level=rarity_level + rarity_level=rarity_level, ) - + # Add have and want cards with the same rarity rarity_card = Card.objects.create( - name=f'Performance Test Card {i}', - cardset='PERF01', - cardnum=i+10, - style='normal', + name=f"Performance Test Card {i}", + cardset="PERF01", + cardnum=i + 10, + style="normal", rarity_icon=rarity_icon, - rarity_level=rarity_level + rarity_level=rarity_level, ) - + TradeOfferHaveCard.objects.create( - trade_offer=trade, - card=rarity_card, - quantity=1 + trade_offer=trade, card=rarity_card, quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=trade, - card=rarity_card, - quantity=1 + trade_offer=trade, card=rarity_card, quantity=1 ) - + # Basic performance test - just checking it completes without timeout import time + start = time.time() response = self.client.get(self.url) end = time.time() - + self.assertEqual(response.status_code, 200) - + # Should be reasonably fast (adjust threshold as needed) execution_time = end - start self.assertLess(execution_time, 2.0) # Should complete in under 2 seconds @@ -463,129 +429,116 @@ class HomePageEdgeCaseTests(TestCase): class TemplateRenderingTests(TestCase): """Tests focused on template rendering.""" - + @classmethod def setUpTestData(cls): # Create a user cls.user = User.objects.create_user( - username='testuser', - email='testuser@example.com', - password='testpass123' + username="testuser", email="testuser@example.com", password="testpass123" ) - + # Create a friend code for the user cls.friend_code = FriendCode.objects.create( - user=cls.user, - friend_code='SW-1234-5678-9012', - in_game_name='TestTrainer' + user=cls.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer" ) - + # Create a card cls.card = Card.objects.create( - name='Test Card', - cardset='TEST01', + name="Test Card", + cardset="TEST01", cardnum=1, - style='normal', - rarity_icon='★', - rarity_level=1 + style="normal", + rarity_icon="★", + rarity_level=1, ) - + # Create a trade offer cls.trade = TradeOffer.objects.create( - initiated_by=cls.friend_code, - rarity_icon='★', - rarity_level=1 + initiated_by=cls.friend_code, rarity_icon="★", rarity_level=1 ) - + # Add have and want cards TradeOfferHaveCard.objects.create( - trade_offer=cls.trade, - card=cls.card, - quantity=1 + trade_offer=cls.trade, card=cls.card, quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=cls.trade, - card=cls.card, - quantity=1 + trade_offer=cls.trade, card=cls.card, quantity=1 ) - + def setUp(self): self.client = Client() self.factory = RequestFactory() - + def test_template_used(self): """Test that the correct template is used.""" - response = self.client.get(reverse('home')) - self.assertTemplateUsed(response, 'home/home.html') - + response = self.client.get(reverse("home")) + self.assertTemplateUsed(response, "home/home.html") + def test_context_variables_exist(self): """Test that all expected context variables exist.""" - response = self.client.get(reverse('home')) - + response = self.client.get(reverse("home")) + # Check all required context variables expected_keys = [ - 'cards', - 'recent_offers', - 'most_offered_cards', - 'most_wanted_cards', - 'least_offered_cards', - 'featured_offers', + "cards", + "recent_offers", + "most_offered_cards", + "most_wanted_cards", + "least_offered_cards", + "featured_offers", ] - + for key in expected_keys: self.assertIn(key, response.context) - + def test_view_with_pagination_params(self): """Test that view handles pagination parameters correctly, if applicable.""" # Create additional trade offers if pagination is implemented for i in range(10): trade = TradeOffer.objects.create( - initiated_by=self.friend_code, - rarity_icon='★', - rarity_level=1 + initiated_by=self.friend_code, rarity_icon="★", rarity_level=1 ) - + # Add have and want cards TradeOfferHaveCard.objects.create( - trade_offer=trade, - card=self.card, - quantity=1 + trade_offer=trade, card=self.card, quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=trade, - card=self.card, - quantity=1 + trade_offer=trade, card=self.card, quantity=1 ) - + # Test with page parameter response = self.client.get(f"{reverse('home')}?page=1") self.assertEqual(response.status_code, 200) - + # Test with invalid page parameter response = self.client.get(f"{reverse('home')}?page=999") - self.assertEqual(response.status_code, 200) # Should still render with default page - + self.assertEqual( + response.status_code, 200 + ) # Should still render with default page + # Test with non-numeric page parameter response = self.client.get(f"{reverse('home')}?page=abc") self.assertEqual(response.status_code, 200) # Should handle gracefully - - @patch('home.views.HomePageView.get_context_data') + + @patch("home.views.HomePageView.get_context_data") def test_view_renders_with_missing_context(self, mock_get_context): """Test that view renders even with incomplete context data.""" # Return incomplete context - mock_get_context.return_value = {'cards': []} - + mock_get_context.return_value = {"cards": []} + # Should still render without error even with missing context variables - response = self.client.get(reverse('home')) + response = self.client.get(reverse("home")) self.assertEqual(response.status_code, 200) - + def test_compatibility_with_multiple_django_versions(self): """Ensure compatibility with different Django versions.""" import django + # Simply log the Django version - the test itself verifies the page renders # with the current version django_version = django.get_version() - response = self.client.get(reverse('home')) + response = self.client.get(reverse("home")) self.assertEqual(response.status_code, 200) diff --git a/src/pkmntrade_club/home/views.py b/src/pkmntrade_club/home/views.py index cc05847..29fc6f7 100644 --- a/src/pkmntrade_club/home/views.py +++ b/src/pkmntrade_club/home/views.py @@ -1,54 +1,76 @@ -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 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 +from collections import OrderedDict + +from django.db.models import ( + Max, + Sum, +) +from django.db.models.functions import Coalesce +from django.views.generic import TemplateView + +from pkmntrade_club.cards.models import Card +from pkmntrade_club.trades.models import ( + TradeOffer, +) logger = logging.getLogger(__name__) + class HomePageView(TemplateView): template_name = "home/home.html" - #@silk_profile(name='Home Page') + # @silk_profile(name='Home Page') def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - + try: # Get all cards ordered by name, exclude cards with rarity level > 5 - context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level") - + context["cards"] = ( + Card.objects.with_details() + .filter(rarity__level__lte=5) + .order_by("translations__name", "rarity__level") + ) + # Reuse base trade offer queryset for market stats base_offer_qs = TradeOffer.objects.filter(is_closed=False) - + # Recent Offers try: recent_offers_qs = base_offer_qs.order_by("-created_at")[:6] context["recent_offers"] = recent_offers_qs - context["cache_key_recent_offers"] = f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}" + 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" except Exception as e: logger.error(f"Error fetching recent offers: {str(e)}") context["recent_offers"] = [] context["cache_key_recent_offers"] = "recent_offers_error" - + # Most Offered Cards try: most_offered_cards_qs = ( - Card.objects.filter(tradeofferhavecard__isnull=False).filter(rarity_level__lte=5) + Card.objects.with_details() + .filter(tradeofferhavecard__isnull=False) + .filter(rarity__level__lte=5) .annotate(offer_count=Sum("tradeofferhavecard__quantity")) .order_by("-offer_count")[:6] ) context["most_offered_cards"] = most_offered_cards_qs - context["cache_key_most_offered_cards"] = f"most_offered_cards_{most_offered_cards_qs.values_list('pk', 'updated_at')}" + 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" except Exception as e: logger.error(f"Error fetching most offered cards: {str(e)}") context["most_offered_cards"] = [] @@ -56,26 +78,46 @@ class HomePageView(TemplateView): # Most Wanted Cards try: most_wanted_cards_qs = ( - Card.objects.filter(tradeofferwantcard__isnull=False).filter(rarity_level__lte=5) + Card.objects.with_details() + .filter(tradeofferwantcard__isnull=False) + .filter(rarity__level__lte=5) .annotate(offer_count=Sum("tradeofferwantcard__quantity")) .order_by("-offer_count")[:6] ) context["most_wanted_cards"] = most_wanted_cards_qs - context["cache_key_most_wanted_cards"] = f"most_wanted_cards_{most_wanted_cards_qs.values_list('pk', 'updated_at')}" + 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" except Exception as e: logger.error(f"Error fetching most wanted cards: {str(e)}") context["most_wanted_cards"] = [] - + # Least Offered Cards try: least_offered_cards_qs = ( - Card.objects.filter(rarity_level__lte=5).annotate( + Card.objects.with_details() + .filter(rarity__level__lte=5) + .annotate( offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0) ) .order_by("offer_count")[:6] ) context["least_offered_cards"] = least_offered_cards_qs - context["cache_key_least_offered_cards"] = f"least_offered_cards_{least_offered_cards_qs.values_list('pk', 'updated_at')}" + 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" except Exception as e: logger.error(f"Error fetching least offered cards: {str(e)}") context["least_offered_cards"] = [] @@ -84,26 +126,37 @@ class HomePageView(TemplateView): featured = OrderedDict() # Featured "All" offers remains fixed at the top try: - featured["All"] = base_offer_qs.order_by("created_at")[:6] + all_featured = base_offer_qs.order_by("created_at")[:6] + featured["All"] = all_featured + latest_update = all_featured.aggregate(latest=Max("updated_at"))[ + "latest" + ] + if latest_update: + context["cache_key_featured_offers"] = ( + f"featured_all_{latest_update.timestamp()}" + ) + else: + context["cache_key_featured_offers"] = "featured_all_empty" except Exception as e: logger.error(f"Error fetching 'All' featured offers: {str(e)}") featured["All"] = [] - - # *** we only show All Featured Offers for now, + context["cache_key_featured_offers"] = "featured_all_error" + + # *** 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 @@ -111,19 +164,6 @@ class HomePageView(TemplateView): # logger.error(f"Error processing rarity-based featured offers: {str(e)}") context["featured_offers"] = featured - # Generate a cache key based on the pks and updated_at timestamps of all featured offers - # *** we will separate cache keys for each featured section later - all_offer_identifiers = [] - for section_name,section_offers in featured.items(): - # featured_section is a QuerySet. Fetch (pk, updated_at) tuples. - identifiers = section_offers.values_list('pk', 'updated_at') - # Format each tuple as "pk_timestamp" and add to the list - section_strings = [f"{section_name}_{pk}_{ts.timestamp()}" for pk, ts in identifiers] - all_offer_identifiers.extend(section_strings) - - # Join all identifiers into a single string, sorted for consistency regardless of order - combined_identifiers = "|".join(sorted(all_offer_identifiers)) - context["cache_key_featured_offers"] = f"featured_offers_{combined_identifiers}" except Exception as e: logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}") # Provide fallback empty data @@ -133,9 +173,9 @@ class HomePageView(TemplateView): context["most_wanted_cards"] = [] context["least_offered_cards"] = [] context["featured_offers"] = OrderedDict([("All", [])]) - + return context - + def get(self, request, *args, **kwargs): """Override get method to add caching""" return super().get(request, *args, **kwargs) diff --git a/src/pkmntrade_club/tests/utils/rarity.py b/src/pkmntrade_club/tests/utils/rarity.py index 24bf7dd..900eb00 100644 --- a/src/pkmntrade_club/tests/utils/rarity.py +++ b/src/pkmntrade_club/tests/utils/rarity.py @@ -6,5 +6,5 @@ RARITY_MAPPING = { 5: "⭐️", 6: "⭐️⭐️", 7: "⭐️⭐️⭐️", - 8: "👑" -} \ No newline at end of file + 8: "👑", +} diff --git a/src/pkmntrade_club/theme/apps.py b/src/pkmntrade_club/theme/apps.py index 71e85a3..8f60b15 100644 --- a/src/pkmntrade_club/theme/apps.py +++ b/src/pkmntrade_club/theme/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class ThemeConfig(AppConfig): - name = 'pkmntrade_club.theme' + name = "pkmntrade_club.theme" diff --git a/src/pkmntrade_club/theme/templates/admin/cards/importer_status.html b/src/pkmntrade_club/theme/templates/admin/cards/importer_status.html new file mode 100644 index 0000000..7bd2b47 --- /dev/null +++ b/src/pkmntrade_club/theme/templates/admin/cards/importer_status.html @@ -0,0 +1,38 @@ +{% 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 7098131..f07fed0 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:card_list' as cards_list_url %} +{% url 'cards:list' as cards_list_url %} {% url 'dashboard' as dashboard_url %} @@ -65,7 +65,7 @@ @@ -138,6 +138,8 @@ + + {% 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 96ed018..a45b270 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 155a68d..2ab713f 100644 --- a/src/pkmntrade_club/theme/templates/cards/card_list.html +++ b/src/pkmntrade_club/theme/templates/cards/card_list.html @@ -1,63 +1,48 @@ {% extends "base.html" %} {% load static card_badge %} + {% block content %} -
-
-
-

Cards

-
-
- - - - -
+
+

Card List

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