From 30ce126a07b48e8c7491fd9f2073081fff4141b1 Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Thu, 12 Jun 2025 16:56:36 -0700 Subject: [PATCH 1/5] feat(deploy): implement blue-green deployment strategy This commit replaces the previous deployment mechanism with a blue-green strategy to lay the groundwork for zero-downtime deployments. Key changes: Introduces a deploy-blue-green.sh script to manage "blue" and "green" container sets, creating versioned releases. Updates the Anubis gatekeeper template to dynamically route traffic based on the active deployment color, allowing for seamless traffic switching. Modifies Docker Compose files to include color-specific labels and environment variables. Adapts the GitHub Actions workflow to execute the new blue-green deployment process. Removes the old, now-obsolete deployment and health check scripts. Note: Automated rollback on health check failure is not yet implemented. Downgrades can be performed manually by switching the active color. --- .github/workflows/build_deploy.yml | 65 +++-- scripts/common-lib.sh | 1 + scripts/deploy-blue-green.sh | 207 +++++++++++++++ scripts/deploy-to-server.sh | 124 --------- scripts/generate-docker-tags.sh | 0 scripts/health-check-and-rollback.sh | 102 ------- scripts/manage-releases.sh | 120 --------- scripts/parse-repository-name.sh | 9 +- scripts/prepare-deployment.sh | 44 --- scripts/retry.sh | 23 -- server/docker-compose_core.yml | 29 +- server/docker-compose_staging.yml | 32 --- server/docker-compose_web.yml | 58 ++-- server/gatekeepers.template.yml | 47 +++- server/gatus/config.template.yaml | 22 +- server/haproxy.cfg | 29 +- server/scripts/common-lib.sh | 382 +++++++++++++++++++++++++++ server/scripts/manage.sh | 377 ++++++++++++++++++++++++++ uv.lock | 86 +++--- 19 files changed, 1166 insertions(+), 591 deletions(-) create mode 120000 scripts/common-lib.sh create mode 100755 scripts/deploy-blue-green.sh delete mode 100644 scripts/deploy-to-server.sh mode change 100644 => 100755 scripts/generate-docker-tags.sh delete mode 100644 scripts/health-check-and-rollback.sh delete mode 100644 scripts/manage-releases.sh mode change 100644 => 100755 scripts/parse-repository-name.sh delete mode 100644 scripts/prepare-deployment.sh delete mode 100644 scripts/retry.sh delete mode 100644 server/docker-compose_staging.yml create mode 100755 server/scripts/common-lib.sh create mode 100755 server/scripts/manage.sh 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/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/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/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]] From 4b9e4f651e6d989148d3ccd32b7b8a772bf3a3c8 Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Thu, 12 Jun 2025 17:06:21 -0700 Subject: [PATCH 2/5] refactor(db): initial, incomplete work to update model and re-normalize fields --- src/pkmntrade_club/cards/admin.py | 486 +++++++++++++++++- src/pkmntrade_club/cards/models.py | 354 +++++++++++-- src/pkmntrade_club/django_project/settings.py | 22 + .../admin/cards/importer_status.html | 38 ++ 4 files changed, 850 insertions(+), 50 deletions(-) create mode 100644 src/pkmntrade_club/theme/templates/admin/cards/importer_status.html diff --git a/src/pkmntrade_club/cards/admin.py b/src/pkmntrade_club/cards/admin.py index b778a69..b3ce633 100644 --- a/src/pkmntrade_club/cards/admin.py +++ b/src/pkmntrade_club/cards/admin.py @@ -1,7 +1,481 @@ -from django.contrib import admin -from .models import Deck, Card, DeckNameTranslation, CardNameTranslation +from django.contrib import admin, messages +from django.urls import path +from django.shortcuts import render +from django.http import HttpResponseRedirect +from parler.admin import TranslatableAdmin +from .models import ( + CardSet_New, Pack_New, Energy_New, Attack_New, Ability_New, + Rarity_New, CardType_New, Card_New, AttackCost_New, RarityMapping +) -admin.site.register(Deck) -admin.site.register(Card) -admin.site.register(DeckNameTranslation) -admin.site.register(CardNameTranslation) \ No newline at end of file +import json +import os +import re # For parsing set name and ID +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from django.db import transaction +import hashlib + +def parse_set_details(set_string): + match = re.match(r'^(.*?)\s*\(([A-Za-z0-9]+)\)$', set_string) + if match: + name = match.group(1).strip() + set_id = match.group(2) + return name, set_id + match = re.match(r'^Promo-(.*?)$', set_string) + 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_New.objects.language('en').get_or_create( + translations__name=card_data['type'], + translations__subtype=card_data.get('subtype', ''), + defaults={'name': card_data['type'], 'subtype': card_data.get('subtype', '')} + ) + 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_New object using the (potentially mapped) values + rarity_obj, created = Rarity_New.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_New.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_New.objects.language('en').get_or_create( + translations__name=pack_name_from_json, + cardset=card_set, + defaults={ + 'name': pack_name_from_json, + 'full_name': pack_full_name, + 'hex_color': '#FFFFFF' + } + ) + card_obj.packs.add(pack_obj) + else: + all_packs_in_set = Pack_New.objects.filter(cardset=card_set) + if all_packs_in_set.exists(): + card_obj.packs.add(*all_packs_in_set) + +def _update_card_abilities(card_obj, card_data): + card_obj.abilities.clear() + for ability_data in card_data.get('abilities', []): + ability_obj, created = Ability_New.objects.language('en').get_or_create( + translations__name=ability_data['name'], + defaults={'name': ability_data['name'], 'effect': ability_data['effect']} + ) + if not created and ability_obj.effect != ability_data['effect']: + ability_obj.set_current_language('en') + ability_obj.effect = ability_data['effect'] + ability_obj.save() + card_obj.abilities.add(ability_obj) + +def _update_card_attacks_and_costs(card_obj, card_data): + card_obj.attacks.clear() + for attack_data in card_data.get('attacks', []): + attack_obj, created = Attack_New.objects.language('en').get_or_create( + translations__name=attack_data['name'], + 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_New.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 the JSON. + Updates stats_accumulator with newly_imported_count, updated_count, or skipped_count. + error_tracking is a dict {'file_name': ..., 'card_id': ...} for precise error reporting. + """ + card_id = card_data['id'] + incoming_checksum = calculate_card_checksum(card_data) + error_tracking['card_id'] = card_id + + try: + existing_card = Card_New.objects.language('en').get(id=card_id) + if existing_card.checksum == incoming_checksum: + stats_accumulator['skipped_count'] += 1 + return + except Card_New.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_New.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 perform_card_import_logic(): + """ + Main importer logic. Iterates through JSON files and processes them. + Halts and rolls back on any error. + """ + print("Card import process started.") + base_path = os.path.join(settings.BASE_DIR, 'REMOTE_GIT_REPOS', 'pokemon-tcg-pocket-card-database', 'cards', 'en') + + stats = {'newly_imported_count': 0, 'updated_count': 0, 'skipped_count': 0, 'files_processed_count': 0} + error_tracking = {'file_name': "N/A", 'card_id': "N/A"} + + # Fetch all rarity mappings once + rarity_mappings = RarityMapping.objects.all() + rarity_mappings_dict = {mapping.original_name: mapping for mapping in rarity_mappings} + print(f"Loaded {len(rarity_mappings_dict)} rarity mappings.") + + if not os.path.isdir(base_path): + message = f"Source directory not found: {base_path}. Import halted." + print(message) + return 0, 0, True, message, 0, 0 + + json_files = [f for f in os.listdir(base_path) if f.endswith('.json')] + json_files.sort() + if not json_files: + message = "No JSON files found in the source directory to import." + print(message) + return 0, 0, False, message, 0, 0 + + print(f"Found {len(json_files)} JSON files to process.") + + try: + with transaction.atomic(): + for idx, file_name in enumerate(json_files): + error_tracking['file_name'] = file_name + error_tracking['card_id'] = "N/A" + file_path = os.path.join(base_path, file_name) + + print(f"Processing file: {file_name} ({idx + 1}/{len(json_files)})") + + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if not data: + raise ValueError(f"JSON file {file_name} is empty or contains no data.") + + stats['files_processed_count'] += 1 + + first_card_data = data[0] + set_info_str = first_card_data.get('set') + if not set_info_str: + raise ValueError(f"Could not determine set information from first card in {file_name}.") + + parsed_set_name, parsed_set_id = parse_set_details(set_info_str) + if not parsed_set_id: + raise ValueError(f"Could not parse set ID from '{set_info_str}' in {file_name}.") + + card_set_defaults = { + 'name': parsed_set_name, + 'file_name': file_name + } + card_set, _ = CardSet_New.objects.language('en').update_or_create( + id=parsed_set_id, + defaults=card_set_defaults + ) + + for card_data_item in data: + print("Processing card: ", card_data_item['id']) + _process_single_card_data(card_data_item, card_set, stats, error_tracking, rarity_mappings_dict) + + print(f"Finished processing file: {file_name}") + + success_message = ( + f"Import completed successfully. Processed {stats['files_processed_count']} files. " + f"Imported {stats['newly_imported_count']} new cards. " + f"Updated {stats['updated_count']} existing cards. " + f"Skipped {stats['skipped_count']} unchanged cards." + ) + print("Committing transaction.") + transaction.on_commit(lambda: print(success_message)) + return stats['newly_imported_count'], stats['updated_count'], False, success_message, stats['files_processed_count'], stats['skipped_count'] + + except Exception as e: + # Any exception during the process will cause the transaction to roll back. + error_detail = f"Error during import (file: {error_tracking['file_name']}, card: {error_tracking['card_id']}): {str(e)}" + halt_message = f"Import HALTED. All changes rolled back. Reason: {error_detail}" + print(halt_message) + # Return 0 for counts as the transaction is rolled back + return 0, 0, True, halt_message, stats['files_processed_count'], stats.get('skipped_count', 0) + + +if admin.site.is_registered(CardSet_New): admin.site.unregister(CardSet_New) +if admin.site.is_registered(Pack_New): admin.site.unregister(Pack_New) +if admin.site.is_registered(Energy_New): admin.site.unregister(Energy_New) +if admin.site.is_registered(Attack_New): admin.site.unregister(Attack_New) +if admin.site.is_registered(Ability_New): admin.site.unregister(Ability_New) +if admin.site.is_registered(Rarity_New): admin.site.unregister(Rarity_New) +if admin.site.is_registered(CardType_New): admin.site.unregister(CardType_New) +if admin.site.is_registered(Card_New): admin.site.unregister(Card_New) +if admin.site.is_registered(AttackCost_New): admin.site.unregister(AttackCost_New) +if admin.site.is_registered(RarityMapping): admin.site.unregister(RarityMapping) + + +@admin.register(CardSet_New) +class CardSetAdmin(TranslatableAdmin): + list_display = ('id', 'name', 'file_name') + readonly_fields = ('id', 'file_name', 'created_at', 'updated_at') + search_fields = ('translations__name',) + readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + +@admin.register(Pack_New) +class PackAdmin(TranslatableAdmin): + list_display = ('id', 'full_name', 'name', 'cardset', 'hex_color') + list_filter = ('cardset',) + search_fields = ('translations__name', 'translations__full_name') + readonly_fields = ('id', 'created_at', 'updated_at') + +@admin.register(Energy_New) +class EnergyAdmin(TranslatableAdmin): + list_display = ('id', 'name') + search_fields = ('translations__name',) + readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + +@admin.register(Attack_New) +class AttackAdmin(TranslatableAdmin): + list_display = ('id', 'name', 'damage', 'effect') + search_fields = ('translations__name',) + readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + +@admin.register(Ability_New) +class AbilityAdmin(TranslatableAdmin): + list_display = ('id', 'name', 'effect') + search_fields = ('translations__name',) + readonly_fields = ('id', 'created_at', 'updated_at') + +@admin.register(Rarity_New) +class RarityAdmin(TranslatableAdmin): + list_display = ('id', 'name', 'icon', 'level') + search_fields = ('translations__name',) + readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + +@admin.register(CardType_New) +class CardTypeAdmin(TranslatableAdmin): + list_display = ('id', 'name', 'subtype') + search_fields = ('translations__name', 'translations__subtype') + readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + +@admin.register(Card_New) +class CardAdmin(TranslatableAdmin): + list_display = ('id', 'cardnum', 'name', 'cardset', 'card_type', 'rarity', 'health', 'pkmn_type') + list_filter = ('cardset', 'card_type', 'rarity', 'pkmn_type', 'packs') + search_fields = ('id', 'translations__name', 'cardset__translations__name', 'packs__translations__name') + filter_horizontal = ('packs', 'abilities', 'attacks') + readonly_fields = ('id', 'cardnum', 'created_at', 'updated_at', 'deleted_at') + +admin.site.register(AttackCost_New) + +@admin.register(RarityMapping) +class RarityMappingAdmin(admin.ModelAdmin): + list_display = ('original_name', 'mapped_name', 'icon', 'level', 'created_at', 'updated_at', 'deleted_at') + search_fields = ('original_name', 'mapped_name') + list_filter = ('level',) + readonly_fields = ('created_at', 'updated_at', 'deleted_at') + +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': + new, updated, has_error, message_text, files_processed, skipped = perform_card_import_logic() + + if has_error: + messages.error(request, message_text + f" Files attempted before halt: {files_processed}.") + else: + messages.success(request, message_text) + + 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 \ No newline at end of file diff --git a/src/pkmntrade_club/cards/models.py b/src/pkmntrade_club/cards/models.py index 9f014ea..b3376ef 100644 --- a/src/pkmntrade_club/cards/models.py +++ b/src/pkmntrade_club/cards/models.py @@ -1,53 +1,319 @@ from django.db import models -from django.db.models import Prefetch -from django.apps import apps +from parler.models import TranslatableModel, TranslatedFields +from django.utils.translation import gettext_lazy as _ -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 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. + """ -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) + translations = TranslatedFields( + name=models.CharField( + max_length=32, + help_text=_("The full name of the set, e.g., 'Genetic Apex'."), + ) + ) + id = models.CharField( + max_length=3, + primary_key=True, + help_text=_("The ID for the set, e.g., 'A1', 'A1a'."), + ) + file_name = models.CharField( + max_length=32, + help_text=_("Original name of the JSON file, e.g., 'a1-genetic-apex.json'."), + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(null=True, blank=True) - def __str__(self): - return self.name -class 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 Meta: + verbose_name = _("Card Set (New)") + verbose_name_plural = _("Card Sets (New)") - def __str__(self): - return self.name + def __str__(self): + return f"{self.id} - {self.name}" -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: - unique_together = ('cardset', 'cardnum') +class Pack(TranslatableModel): + """ + Represents a single pack that is part of a cardset. E.g., "Genetic Apex: Mewtwo" + """ - def __str__(self): - return f"{self.name} ({self.cardset} #{self.cardnum})" \ No newline at end of file + 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) + hex_color = models.CharField(max_length=9) + 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 (New)") + verbose_name_plural = _("Packs (New)") + + 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 (New)") + verbose_name_plural = _("Energies (New)") + + 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 (New)") + verbose_name_plural = _("Attack Costs (New)") + 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 (New)") + verbose_name_plural = _("Attacks (New)") + + 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 (New)") + verbose_name_plural = _("Abilities (New)") + + 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 (New)") + verbose_name_plural = _("Rarities (New)") + + 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 (New)") + verbose_name_plural = _("Card Types (New)") + + def __str__(self): + return f"{self.name}" + + +class Card(TranslatableModel): + """ + Represents a single, unique digital printing of a Pokรฉmon card. + """ + + 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 (New)") + verbose_name_plural = _("Cards (New)") + + 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})" diff --git a/src/pkmntrade_club/django_project/settings.py b/src/pkmntrade_club/django_project/settings.py index 550b184..d048108 100644 --- a/src/pkmntrade_club/django_project/settings.py +++ b/src/pkmntrade_club/django_project/settings.py @@ -19,6 +19,7 @@ env = environ.Env( DJANGO_EMAIL_PASSWORD=(str, ''), DJANGO_EMAIL_USE_TLS=(bool, True), DJANGO_DEFAULT_FROM_EMAIL=(str, ''), + DJANGO_EMAIL_SUBJECT_PREFIX=(str, ''), SECRET_KEY=(str, '0000000000000000000000000000000000000000000000000000000000000000'), ALLOWED_HOSTS=(str, 'localhost,127.0.0.1'), PUBLIC_HOST=(str, 'localhost'), @@ -112,6 +113,9 @@ except Exception: 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', @@ -151,6 +155,7 @@ INSTALLED_APPS = [ 'health_check.contrib.psutil', 'health_check.contrib.redis', "meta", + "parler", ] + FIRST_PARTY_APPS if DEBUG: @@ -251,6 +256,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') @@ -310,6 +319,7 @@ 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') @@ -334,6 +344,17 @@ 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" @@ -374,6 +395,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/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:' %}

+ + {% endif %} +{% endblock %} From 39a002e3946827e0380925ef3fe5d133554b60df Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Thu, 12 Jun 2025 20:53:38 -0700 Subject: [PATCH 3/5] style: standardize string formatting and improve readability across multiple files - Refactored string formatting in various files to use consistent double quotes. - Improved readability by adding newlines in function definitions and method calls. - Cleaned up unnecessary imports and ensured proper spacing for better code clarity. - Updated management commands and context processors for consistent formatting. - Enhanced the overall maintainability of the codebase by adhering to PEP 8 guidelines. - Applied Ruff linting and formatting --- manage.py | 6 +- src/pkmntrade_club/__init__.py | 2 +- src/pkmntrade_club/_version.py | 93 ++-- src/pkmntrade_club/accounts/adapter.py | 3 +- src/pkmntrade_club/accounts/admin.py | 1 - src/pkmntrade_club/accounts/apps.py | 4 +- src/pkmntrade_club/accounts/forms.py | 33 +- .../management/commands/clear_cache.py | 3 +- src/pkmntrade_club/accounts/models.py | 21 +- .../accounts/templatetags/gravatar.py | 12 +- src/pkmntrade_club/accounts/tests.py | 156 +++--- src/pkmntrade_club/accounts/urls.py | 20 +- src/pkmntrade_club/accounts/views.py | 200 +++++-- src/pkmntrade_club/cards/mixins.py | 4 +- src/pkmntrade_club/cards/models.py | 2 +- src/pkmntrade_club/cards/signals.py | 20 +- .../cards/templatetags/card_badge.py | 42 +- .../cards/templatetags/card_multiselect.py | 52 +- src/pkmntrade_club/cards/tests.py | 42 +- src/pkmntrade_club/cards/urls.py | 16 +- src/pkmntrade_club/cards/views.py | 46 +- src/pkmntrade_club/common/apps.py | 2 +- .../common/context_processors.py | 10 +- src/pkmntrade_club/common/mixins.py | 12 +- .../common/templatetags/pagination_tags.py | 3 +- src/pkmntrade_club/django_project/__init__.py | 2 +- src/pkmntrade_club/django_project/asgi.py | 4 +- src/pkmntrade_club/django_project/celery.py | 10 +- src/pkmntrade_club/django_project/settings.py | 231 ++++---- src/pkmntrade_club/django_project/urls.py | 4 +- src/pkmntrade_club/django_project/wsgi.py | 4 +- src/pkmntrade_club/home/tests.py | 507 ++++++++---------- src/pkmntrade_club/home/views.py | 88 +-- src/pkmntrade_club/tests/utils/rarity.py | 4 +- src/pkmntrade_club/theme/apps.py | 2 +- src/pkmntrade_club/trades/forms.py | 68 ++- src/pkmntrade_club/trades/mixins.py | 30 +- src/pkmntrade_club/trades/models.py | 218 +++++--- src/pkmntrade_club/trades/signals.py | 138 +++-- .../trades/templatetags/trade_offer_tags.py | 119 ++-- src/pkmntrade_club/trades/tests.py | 317 +++++++---- src/pkmntrade_club/trades/urls.py | 18 +- src/pkmntrade_club/trades/views.py | 251 ++++++--- 43 files changed, 1661 insertions(+), 1159 deletions(-) diff --git a/manage.py b/manage.py index 138d284..d84abce 100755 --- a/manage.py +++ b/manage.py @@ -1,11 +1,15 @@ #!/usr/bin/env -S uv run """Django's command-line utility for administrative tasks.""" + import os import sys + def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings") + os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" + ) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) try: diff --git a/src/pkmntrade_club/__init__.py b/src/pkmntrade_club/__init__.py index 8d1f1f7..bcdfc83 100644 --- a/src/pkmntrade_club/__init__.py +++ b/src/pkmntrade_club/__init__.py @@ -2,4 +2,4 @@ from pkmntrade_club._version import __version__, get_version, get_version_info -__all__ = ['__version__', 'get_version', 'get_version_info'] +__all__ = ["__version__", "get_version", "get_version_info"] diff --git a/src/pkmntrade_club/_version.py b/src/pkmntrade_club/_version.py index 6f90c71..77921b5 100644 --- a/src/pkmntrade_club/_version.py +++ b/src/pkmntrade_club/_version.py @@ -1,5 +1,6 @@ from importlib.metadata import version, PackageNotFoundError from setuptools_scm import get_version + """ Version module for pkmntrade.club @@ -7,55 +8,57 @@ This module provides version information from git tags via setuptools-scm. """ try: - __version__ = version("pkmntrade-club") + __version__ = version("pkmntrade-club") except PackageNotFoundError: - # Package is not installed, try to get version from setuptools_scm - try: - __version__ = get_version(root='../../..', relative_to=__file__) - except (ImportError, LookupError): - __version__ = "0.0.0+unknown" + # Package is not installed, try to get version from setuptools_scm + try: + __version__ = get_version(root="../../..", relative_to=__file__) + except (ImportError, LookupError): + __version__ = "0.0.0+unknown" + def get_version(): - """Return the current version.""" - return __version__ + """Return the current version.""" + return __version__ + def get_version_info(): - """Return detailed version information.""" - import re - - # Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease") - match = re.match( - r'^(\d+)\.(\d+)\.(\d+)' - r'(?:\.dev(\d+))?' - r'(?:\+g([a-f0-9]+))?' - r'(?:-(.+))?$', - __version__ - ) - - if match: - major, minor, patch, dev, git_sha, prerelease = match.groups() + """Return detailed version information.""" + import re + + # Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease") + match = re.match( + r"^(\d+)\.(\d+)\.(\d+)" + r"(?:\.dev(\d+))?" + r"(?:\+g([a-f0-9]+))?" + r"(?:-(.+))?$", + __version__, + ) + + if match: + major, minor, patch, dev, git_sha, prerelease = match.groups() + return { + "version": __version__, + "major": int(major), + "minor": int(minor), + "patch": int(patch), + "dev": int(dev) if dev else None, + "git_sha": git_sha, + "prerelease": prerelease, + "is_release": dev is None and not prerelease, + "is_prerelease": bool(prerelease), + "is_dev": dev is not None, + } + return { - 'version': __version__, - 'major': int(major), - 'minor': int(minor), - 'patch': int(patch), - 'dev': int(dev) if dev else None, - 'git_sha': git_sha, - 'prerelease': prerelease, - 'is_release': dev is None and not prerelease, - 'is_prerelease': bool(prerelease), - 'is_dev': dev is not None + "version": __version__, + "major": 0, + "minor": 0, + "patch": 0, + "dev": None, + "git_sha": None, + "prerelease": None, + "is_release": False, + "is_prerelease": False, + "is_dev": True, } - - return { - 'version': __version__, - 'major': 0, - 'minor': 0, - 'patch': 0, - 'dev': None, - 'git_sha': None, - 'prerelease': None, - 'is_release': False, - 'is_prerelease': False, - 'is_dev': True -} \ No newline at end of file diff --git a/src/pkmntrade_club/accounts/adapter.py b/src/pkmntrade_club/accounts/adapter.py index b41e761..b39fed3 100644 --- a/src/pkmntrade_club/accounts/adapter.py +++ b/src/pkmntrade_club/accounts/adapter.py @@ -1,4 +1,3 @@ -from django.conf import settings from allauth.account.adapter import DefaultAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter @@ -10,4 +9,4 @@ class NoSignupAccountAdapter(DefaultAccountAdapter): class NoSignupSocialAccountAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request): - return False \ No newline at end of file + return False diff --git a/src/pkmntrade_club/accounts/admin.py b/src/pkmntrade_club/accounts/admin.py index f3bd5c3..bd475e3 100644 --- a/src/pkmntrade_club/accounts/admin.py +++ b/src/pkmntrade_club/accounts/admin.py @@ -1,5 +1,4 @@ from django.contrib import admin -from django.contrib.auth import get_user_model from django.contrib.auth.admin import UserAdmin from .forms import CustomUserCreationForm, CustomUserChangeForm diff --git a/src/pkmntrade_club/accounts/apps.py b/src/pkmntrade_club/accounts/apps.py index df72a2b..408657e 100644 --- a/src/pkmntrade_club/accounts/apps.py +++ b/src/pkmntrade_club/accounts/apps.py @@ -2,5 +2,5 @@ from django.apps import AppConfig class AccountsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'pkmntrade_club.accounts' + default_auto_field = "django.db.models.BigAutoField" + name = "pkmntrade_club.accounts" diff --git a/src/pkmntrade_club/accounts/forms.py b/src/pkmntrade_club/accounts/forms.py index 6788d85..0aac8fe 100644 --- a/src/pkmntrade_club/accounts/forms.py +++ b/src/pkmntrade_club/accounts/forms.py @@ -2,15 +2,13 @@ from django import forms from django.contrib.auth.forms import UserCreationForm, UserChangeForm from .models import CustomUser, FriendCode from allauth.account.forms import SignupForm -from crispy_tailwind.tailwind import CSSContainer -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout, Field, Submit + class CustomUserChangeForm(UserChangeForm): - class Meta: model = CustomUser - fields = ['email'] + fields = ["email"] + class FriendCodeForm(forms.ModelForm): class Meta: @@ -27,23 +25,27 @@ class FriendCodeForm(forms.ModelForm): friend_code_formatted = f"{friend_code_clean[:4]}-{friend_code_clean[4:8]}-{friend_code_clean[8:12]}-{friend_code_clean[12:16]}" return friend_code_formatted -class CustomUserCreationForm(SignupForm): +class CustomUserCreationForm(SignupForm): class Meta(UserCreationForm.Meta): model = CustomUser - fields = ['email', 'username', 'friend_code'] + fields = ["email", "username", "friend_code"] email = forms.EmailField( required=True, label="Email", - widget=forms.TextInput(attrs={'placeholder': 'Email', 'class':'dark:bg-base-100'}) + widget=forms.TextInput( + attrs={"placeholder": "Email", "class": "dark:bg-base-100"} + ), ) username = forms.CharField( max_length=24, required=True, label="Username", - widget=forms.TextInput(attrs={'placeholder': 'Username', 'class':'dark:bg-base-100'}) + widget=forms.TextInput( + attrs={"placeholder": "Username", "class": "dark:bg-base-100"} + ), ) friend_code = forms.CharField( @@ -51,14 +53,18 @@ class CustomUserCreationForm(SignupForm): required=True, label="Friend Code", help_text="Enter your friend code in the format XXXX-XXXX-XXXX-XXXX.", - widget=forms.TextInput(attrs={'placeholder': 'XXXX-XXXX-XXXX-XXXX', 'class':'dark:bg-base-100'}) + widget=forms.TextInput( + attrs={"placeholder": "XXXX-XXXX-XXXX-XXXX", "class": "dark:bg-base-100"} + ), ) in_game_name = forms.CharField( max_length=16, required=True, label="In-Game Name", help_text="Enter your in-game name.", - widget=forms.TextInput(attrs={'placeholder': 'In-Game Name', 'class':'dark:bg-base-100'}) + widget=forms.TextInput( + attrs={"placeholder": "In-Game Name", "class": "dark:bg-base-100"} + ), ) def __init__(self, *args, **kwargs): @@ -78,13 +84,14 @@ class CustomUserCreationForm(SignupForm): friend_code_instance = FriendCode.objects.create( friend_code=self.cleaned_data["friend_code"], in_game_name=self.cleaned_data["in_game_name"], - user=user + user=user, ) user.default_friend_code = friend_code_instance user.save() return user + class UserSettingsForm(forms.ModelForm): class Meta: model = CustomUser - fields = ['show_friend_code_on_link_previews', 'enable_email_notifications'] \ No newline at end of file + fields = ["show_friend_code_on_link_previews", "enable_email_notifications"] diff --git a/src/pkmntrade_club/accounts/management/commands/clear_cache.py b/src/pkmntrade_club/accounts/management/commands/clear_cache.py index 8c6863f..96a2b7b 100644 --- a/src/pkmntrade_club/accounts/management/commands/clear_cache.py +++ b/src/pkmntrade_club/accounts/management/commands/clear_cache.py @@ -1,7 +1,8 @@ from django.core.management.base import BaseCommand from django.core.cache import cache + class Command(BaseCommand): def handle(self, *args, **kwargs): cache.clear() - self.stdout.write('Cleared cache\n') \ No newline at end of file + self.stdout.write("Cleared cache\n") diff --git a/src/pkmntrade_club/accounts/models.py b/src/pkmntrade_club/accounts/models.py index 65610e7..a78a15b 100644 --- a/src/pkmntrade_club/accounts/models.py +++ b/src/pkmntrade_club/accounts/models.py @@ -3,24 +3,28 @@ from django.db import models from django.core.exceptions import ValidationError import re + def validate_friend_code(value): """Validate that friend code follows the format XXXX-XXXX-XXXX-XXXX where X is a digit.""" - if not re.match(r'^\d{4}-\d{4}-\d{4}-\d{4}$', value): + if not re.match(r"^\d{4}-\d{4}-\d{4}-\d{4}$", value): raise ValidationError( - 'Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit.' + "Friend code must be in format XXXX-XXXX-XXXX-XXXX where X is a digit." ) + class CustomUser(AbstractUser): - default_friend_code = models.ForeignKey("FriendCode", on_delete=models.SET_NULL, null=True, blank=True) + default_friend_code = models.ForeignKey( + "FriendCode", on_delete=models.SET_NULL, null=True, blank=True + ) show_friend_code_on_link_previews = models.BooleanField( default=False, verbose_name="Show Friend Code on Link Previews", - help_text="This will primarily affect share link previews on X, Discord, etc." + help_text="This will primarily affect share link previews on X, Discord, etc.", ) enable_email_notifications = models.BooleanField( default=True, verbose_name="Enable Email Notifications", - help_text="Receive trade notifications via email." + help_text="Receive trade notifications via email.", ) reputation_score = models.IntegerField(default=0) @@ -47,10 +51,13 @@ class CustomUser(AbstractUser): self.default_friend_code = other_codes.first() self.save(update_fields=["default_friend_code"]) + class FriendCode(models.Model): friend_code = models.CharField(max_length=19, validators=[validate_friend_code]) in_game_name = models.CharField(max_length=14, null=False, blank=False) - user = models.ForeignKey(CustomUser, on_delete=models.PROTECT, related_name='friend_codes') + user = models.ForeignKey( + CustomUser, on_delete=models.PROTECT, related_name="friend_codes" + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -67,4 +74,4 @@ class FriendCode(models.Model): self.user.save(update_fields=["default_friend_code"]) def __str__(self): - return self.friend_code \ No newline at end of file + return self.friend_code diff --git a/src/pkmntrade_club/accounts/templatetags/gravatar.py b/src/pkmntrade_club/accounts/templatetags/gravatar.py index c2005a0..a719cd6 100644 --- a/src/pkmntrade_club/accounts/templatetags/gravatar.py +++ b/src/pkmntrade_club/accounts/templatetags/gravatar.py @@ -6,15 +6,17 @@ from django.utils.safestring import mark_safe register = template.Library() + @register.filter def gravatar_hash(email): """ Returns the hash of the email. """ - email_encoded = email.strip().lower().encode('utf-8') + email_encoded = email.strip().lower().encode("utf-8") email_hash = hashlib.sha256(email_encoded).hexdigest() return email_hash + @register.filter def gravatar_url(email, size=20): """ @@ -23,20 +25,22 @@ def gravatar_url(email, size=20): """ default = "retro" email_hash = gravatar_hash(email) - params = urlencode({'d': default, 's': str(size)}) + params = urlencode({"d": default, "s": str(size)}) params = params.replace("&", "&") return f"https://www.gravatar.com/avatar/{email_hash}?{params}" + @register.filter def gravatar_profile_url(email=None): """ Returns the Gravatar Profile URL for a given email. """ if email is None: - return f"https://www.gravatar.com/profile" + return "https://www.gravatar.com/profile" email_hash = gravatar_hash(email) return f"https://secure.gravatar.com/{email_hash}" + @register.filter def gravatar(email, size=20): """ @@ -48,6 +52,7 @@ def gravatar(email, size=20): html = f'Gravatar' return mark_safe(html) + @register.filter def gravatar_no_hover(email, size=20): """ @@ -59,6 +64,7 @@ def gravatar_no_hover(email, size=20): html = f'Gravatar' return mark_safe(html) + @register.filter def gravatar_profile_data(email): """ diff --git a/src/pkmntrade_club/accounts/tests.py b/src/pkmntrade_club/accounts/tests.py index b6c8695..6d00785 100644 --- a/src/pkmntrade_club/accounts/tests.py +++ b/src/pkmntrade_club/accounts/tests.py @@ -9,34 +9,34 @@ from django.core.exceptions import ValidationError from django.contrib.sessions.middleware import SessionMiddleware from pkmntrade_club.accounts.models import FriendCode -from pkmntrade_club.accounts.forms import FriendCodeForm, CustomUserCreationForm, UserSettingsForm +from pkmntrade_club.accounts.forms import ( + FriendCodeForm, + CustomUserCreationForm, + UserSettingsForm, +) from pkmntrade_club.accounts.templatetags import gravatar from pkmntrade_club.trades.models import TradeOffer from tests.utils.rarity import RARITY_MAPPING # Create your tests here. + # ----------------------------- # Model Tests # ----------------------------- class CustomUserModelTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user( - username="testuser", - email="test@example.com", - password="password123" + username="testuser", email="test@example.com", password="password123" ) + def test_set_default_friend_code(self): """User can manually set a friend code as their default.""" fc1 = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", - user=self.user, - in_game_name="GameOne" + friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne" ) fc2 = FriendCode.objects.create( - friend_code="2345-6789-0123-4567", - user=self.user, - in_game_name="GameTwo" + friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo" ) # Manually set fc2 as default. self.user.set_default_friend_code(fc2) @@ -48,14 +48,10 @@ class CustomUserModelTests(TestCase): Attempting to set a friend code that does not belong to the user should raise an exception. """ other_user = get_user_model().objects.create_user( - username="otheruser", - email="other@example.com", - password="password456" + username="otheruser", email="other@example.com", password="password456" ) fc_other = FriendCode.objects.create( - friend_code="3456-7890-1234-5678", - user=other_user, - in_game_name="OtherGame" + friend_code="3456-7890-1234-5678", user=other_user, in_game_name="OtherGame" ) with self.assertRaises(ValidationError): self.user.set_default_friend_code(fc_other) @@ -66,14 +62,10 @@ class CustomUserModelTests(TestCase): the default should be reassigned to another friend code. """ fc1 = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", - user=self.user, - in_game_name="GameOne" + friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne" ) fc2 = FriendCode.objects.create( - friend_code="2345-6789-0123-4567", - user=self.user, - in_game_name="GameTwo" + friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo" ) # Set fc2 as default. self.user.set_default_friend_code(fc2) @@ -89,9 +81,7 @@ class CustomUserModelTests(TestCase): should be prohibited. """ fc = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", - user=self.user, - in_game_name="OnlyGame" + friend_code="1234-5678-9012-3456", user=self.user, in_game_name="OnlyGame" ) self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc) @@ -104,21 +94,19 @@ class CustomUserModelTests(TestCase): the current default should remain unchanged. """ fc1 = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", - user=self.user, - in_game_name="GameOne" + friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne" ) fc2 = FriendCode.objects.create( - friend_code="2345-6789-0123-4567", - user=self.user, - in_game_name="GameTwo" + friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo" ) # By default, fc1 is the default friend code. self.assertEqual(self.user.default_friend_code, fc1) try: self.user.remove_default_friend_code(fc2) - except Exception as e: - self.fail("remove_default_friend_code raised an exception when removing a non-default code.") + except Exception: + self.fail( + "remove_default_friend_code raised an exception when removing a non-default code." + ) self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc1) @@ -129,9 +117,7 @@ class CustomUserModelTests(TestCase): class FriendCodeModelTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user( - username="testuser2", - email="test2@example.com", - password="password123" + username="testuser2", email="test2@example.com", password="password123" ) def test_default_set_on_creation(self): @@ -142,7 +128,7 @@ class FriendCodeModelTests(TestCase): fc = FriendCode.objects.create( friend_code="1234-5678-9012-3456", user=self.user, - in_game_name="GameDefault" + in_game_name="GameDefault", ) self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc) @@ -155,14 +141,14 @@ class FriendCodeModelTests(TestCase): fc1 = FriendCode.objects.create( friend_code="1111-1111-1111-1111", user=self.user, - in_game_name="PrimaryGame" + in_game_name="PrimaryGame", ) # fc1 becomes the default automatically. self.assertEqual(self.user.default_friend_code, fc1) fc2 = FriendCode.objects.create( friend_code="2222-2222-2222-2222", user=self.user, - in_game_name="SecondaryGame" + in_game_name="SecondaryGame", ) self.user.refresh_from_db() self.assertEqual(self.user.default_friend_code, fc1) @@ -174,39 +160,34 @@ class FriendCodeModelTests(TestCase): class FriendCodeFormTests(TestCase): def test_valid_friend_code(self): """Ensure valid friend code is cleaned and formatted properly.""" - form_data = { - "friend_code": "1234567890123456", - "in_game_name": "GameTest" - } + form_data = {"friend_code": "1234567890123456", "in_game_name": "GameTest"} form = FriendCodeForm(data=form_data) self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data["friend_code"], "1234-5678-9012-3456") def test_invalid_friend_code_length(self): """Friend codes with incorrect length should cause validation errors.""" - form_data = { - "friend_code": "12345", - "in_game_name": "GameTest" - } + form_data = {"friend_code": "12345", "in_game_name": "GameTest"} form = FriendCodeForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) + self.assertIn( + "Friend code must be exactly 16 digits long.", form.errors["friend_code"] + ) def test_invalid_friend_code_characters(self): """Friend codes containing non-digit characters should cause validation errors.""" - form_data = { - "friend_code": "12345678901234ab", - "in_game_name": "GameTest" - } + form_data = {"friend_code": "12345678901234ab", "in_game_name": "GameTest"} form = FriendCodeForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) + self.assertIn( + "Friend code must be exactly 16 digits long.", form.errors["friend_code"] + ) def test_friend_code_with_whitespace(self): """Ensure that leading/trailing whitespace is stripped.""" form_data = { "friend_code": " 1234567890123456 ", - "in_game_name": "WhitespaceGame" + "in_game_name": "WhitespaceGame", } form = FriendCodeForm(data=form_data) self.assertTrue(form.is_valid()) @@ -216,7 +197,7 @@ class FriendCodeFormTests(TestCase): """Proper dashes in the input should be accepted.""" form_data = { "friend_code": "1234-5678-9012-3456", - "in_game_name": "ExtraDashGame" + "in_game_name": "ExtraDashGame", } form = FriendCodeForm(data=form_data) self.assertTrue(form.is_valid()) @@ -292,7 +273,9 @@ class CustomUserCreationFormTests(TestCase): } form = CustomUserCreationForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn("Friend code must be exactly 16 digits long.", form.errors["friend_code"]) + self.assertIn( + "Friend code must be exactly 16 digits long.", form.errors["friend_code"] + ) def test_invalid_custom_user_creation_password_mismatch(self): """ @@ -318,7 +301,7 @@ class UserSettingsFormTests(TestCase): self.user = get_user_model().objects.create_user( username="settingsuser", email="settings@example.com", - password="password123" + password="password123", ) def test_toggle_show_friend_code_on_link_previews(self): @@ -337,9 +320,7 @@ class UserSettingsFormTests(TestCase): class FriendCodeViewsTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user( - username="viewuser", - email="viewuser@example.com", - password="password123" + username="viewuser", email="viewuser@example.com", password="password123" ) # Log in this user. self.client.login(username="viewuser", password="password123") @@ -347,12 +328,12 @@ class FriendCodeViewsTests(TestCase): self.friend_code1 = FriendCode.objects.create( friend_code="7777-7777-7777-7777", user=self.user, - in_game_name="ViewGameOne" + in_game_name="ViewGameOne", ) self.friend_code2 = FriendCode.objects.create( friend_code="8888-8888-8888-8888", user=self.user, - in_game_name="ViewGameTwo" + in_game_name="ViewGameTwo", ) # By default, friend_code1 is the default. @@ -390,8 +371,7 @@ class FriendCodeViewsTests(TestCase): self.assertRedirects(response, reverse("list_friend_codes")) self.assertTrue( FriendCode.objects.filter( - user=self.user, - friend_code="9999-9999-9999-9999" + user=self.user, friend_code="9999-9999-9999-9999" ).exists() ) # Ensure that adding a new friend code does not change the default. @@ -404,10 +384,16 @@ class FriendCodeViewsTests(TestCase): data = {"friend_code": "invalidfriendcode", "in_game_name": "InvalidGame"} response = self.client.post(url, data) # Extract the form from the response's context. If response.context is a list, use its first element. - context = response.context[0] if isinstance(response.context, list) else response.context + context = ( + response.context[0] + if isinstance(response.context, list) + else response.context + ) form = context.get("form") self.assertIsNotNone(form, "Form not found in response context") - self.assertFormError(form, "friend_code", "Friend code must be exactly 16 digits long.") + self.assertFormError( + form, "friend_code", "Friend code must be exactly 16 digits long." + ) def test_edit_friend_code_view(self): """Test editing the in-game name of an existing friend code.""" @@ -425,14 +411,10 @@ class FriendCodeViewsTests(TestCase): def test_edit_friend_code_view_wrong_user(self): """A user should not be able to edit a friend code that does not belong to them.""" other_user = get_user_model().objects.create_user( - username="otheruser", - email="other@example.com", - password="password1234" + username="otheruser", email="other@example.com", password="password1234" ) friend_code_other = FriendCode.objects.create( - friend_code="0000-0000-0000-0000", - user=other_user, - in_game_name="OtherGame" + friend_code="0000-0000-0000-0000", user=other_user, in_game_name="OtherGame" ) url = reverse("edit_friend_code", kwargs={"pk": friend_code_other.pk}) response = self.client.get(url) @@ -443,25 +425,25 @@ class FriendCodeViewsTests(TestCase): url = reverse("edit_friend_code", kwargs={"pk": self.friend_code2.pk}) new_data = {"in_game_name": ""} # in_game_name is required. response = self.client.post(url, new_data) - context = response.context[0] if isinstance(response.context, list) else response.context + context = ( + response.context[0] + if isinstance(response.context, list) + else response.context + ) form = context.get("form") self.assertIsNotNone(form, "Form not found in response context") self.assertFormError(form, "in_game_name", "This field is required.") - + def test_delete_friend_code_view_only_code(self): """ If the user has only one friend code, deletion should be disabled. This test uses a new user with a single friend code. """ user_only = get_user_model().objects.create_user( - username="onlyuser", - email="onlyuser@example.com", - password="password123" + username="onlyuser", email="onlyuser@example.com", password="password123" ) friend_code_only = FriendCode.objects.create( - friend_code="4444-4444-4444-4444", - user=user_only, - in_game_name="SoloGame" + friend_code="4444-4444-4444-4444", user=user_only, in_game_name="SoloGame" ) self.client.logout() self.client.login(username="onlyuser", password="password123") @@ -492,7 +474,7 @@ class FriendCodeViewsTests(TestCase): initiated_by=self.friend_code2, is_closed=False, rarity_icon=RARITY_MAPPING[5], - rarity_level=5 + rarity_level=5, ) url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk}) response = self.client.post(url, {}) @@ -517,14 +499,10 @@ class FriendCodeViewsTests(TestCase): def test_change_default_friend_code_view_not_owned(self): """A friend code that does not belong to the current user should result in a 404.""" other_user = get_user_model().objects.create_user( - username="otheruser2", - email="other2@example.com", - password="password789" + username="otheruser2", email="other2@example.com", password="password789" ) friend_code_other = FriendCode.objects.create( - friend_code="1111-1111-1111-1111", - user=other_user, - in_game_name="NotMine" + friend_code="1111-1111-1111-1111", user=other_user, in_game_name="NotMine" ) url = reverse("change_default_friend_code", kwargs={"pk": friend_code_other.pk}) response = self.client.post(url, {}) @@ -561,12 +539,12 @@ class FriendCodeViewsTests(TestCase): other_user = get_user_model().objects.create_user( username="otherdeluser", email="otherdel@example.com", - password="password321" + password="password321", ) friend_code_other = FriendCode.objects.create( friend_code="2222-2222-2222-2222", user=other_user, - in_game_name="OtherDelete" + in_game_name="OtherDelete", ) url = reverse("delete_friend_code", kwargs={"pk": friend_code_other.pk}) response = self.client.get(url) diff --git a/src/pkmntrade_club/accounts/urls.py b/src/pkmntrade_club/accounts/urls.py index 8e9b106..202d789 100644 --- a/src/pkmntrade_club/accounts/urls.py +++ b/src/pkmntrade_club/accounts/urls.py @@ -9,8 +9,20 @@ from .views import ( urlpatterns = [ path("friend-codes/add/", AddFriendCodeView.as_view(), name="add_friend_code"), - path("friend-codes/edit//", EditFriendCodeView.as_view(), name="edit_friend_code"), - path("friend-codes/delete//", DeleteFriendCodeView.as_view(), name="delete_friend_code"), - path("friend-codes/default//", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"), + path( + "friend-codes/edit//", + EditFriendCodeView.as_view(), + name="edit_friend_code", + ), + path( + "friend-codes/delete//", + DeleteFriendCodeView.as_view(), + name="delete_friend_code", + ), + path( + "friend-codes/default//", + ChangeDefaultFriendCodeView.as_view(), + name="change_default_friend_code", + ), path("dashboard/", DashboardView.as_view(), name="dashboard"), -] \ No newline at end of file +] diff --git a/src/pkmntrade_club/accounts/views.py b/src/pkmntrade_club/accounts/views.py index fbecc1e..34e81b7 100644 --- a/src/pkmntrade_club/accounts/views.py +++ b/src/pkmntrade_club/accounts/views.py @@ -1,9 +1,14 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse_lazy from django.shortcuts import redirect, get_object_or_404, render -from django.views.generic import ListView, CreateView, DeleteView, View, TemplateView, UpdateView -from pkmntrade_club.accounts.models import FriendCode, CustomUser +from django.views.generic import ( + CreateView, + DeleteView, + View, + TemplateView, + UpdateView, +) +from pkmntrade_club.accounts.models import FriendCode from pkmntrade_club.accounts.forms import FriendCodeForm, UserSettingsForm from django.db.models import Case, When, Value, BooleanField from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance @@ -13,14 +18,17 @@ from pkmntrade_club.common.mixins import ReusablePaginationMixin from django.urls import reverse from django.utils.http import urlencode + class AddFriendCodeView(LoginRequiredMixin, CreateView): """ Add a new friend code for the current user. If the user does not yet have a default, the newly added code will automatically become the default. """ + model = FriendCode form_class = FriendCodeForm template_name = "friend_codes/add_friend_code.html" + def get_success_url(self): base_url = reverse("dashboard") return f"{base_url}?{urlencode({'tab': 'friend_codes'})}" @@ -30,6 +38,7 @@ class AddFriendCodeView(LoginRequiredMixin, CreateView): messages.success(self.request, "Friend code added successfully.") return super().form_valid(form) + class DeleteFriendCodeView(LoginRequiredMixin, DeleteView): """ Remove an existing friend code. @@ -37,9 +46,11 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView): Also, prevent deletion if the friend code is either the only one or is set as the default friend code. """ + model = FriendCode template_name = "friend_codes/confirm_delete_friend_code.html" context_object_name = "friend_code" + def get_success_url(self): base_url = reverse("dashboard") return f"{base_url}?{urlencode({'tab': 'friend_codes'})}" @@ -81,48 +92,59 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView): if user.default_friend_code == self.object: messages.error( request, - "Cannot delete your default friend code. Please set a different default first." + "Cannot delete your default friend code. Please set a different default first.", ) return redirect(self.get_success_url()) - trade_offer_exists = TradeOffer.objects.filter(initiated_by_id=self.object.pk).exists() - trade_acceptance_exists = TradeAcceptance.objects.filter(accepted_by_id=self.object.pk).exists() + trade_offer_exists = TradeOffer.objects.filter( + initiated_by_id=self.object.pk + ).exists() + trade_acceptance_exists = TradeAcceptance.objects.filter( + accepted_by_id=self.object.pk + ).exists() if trade_offer_exists or trade_acceptance_exists: messages.error( request, - "Cannot remove this friend code because there are existing trade offers associated with it." + "Cannot remove this friend code because there are existing trade offers associated with it.", ) return redirect(self.get_success_url()) - + self.object.delete() messages.success(request, "Friend code removed successfully.") return redirect(self.get_success_url()) + class ChangeDefaultFriendCodeView(LoginRequiredMixin, View): """ Change the default friend code for the current user. """ + def post(self, request, *args, **kwargs): friend_code_id = kwargs.get("pk") - friend_code = get_object_or_404(FriendCode, pk=friend_code_id, user=request.user) + friend_code = get_object_or_404( + FriendCode, pk=friend_code_id, user=request.user + ) request.user.set_default_friend_code(friend_code) messages.success(request, "Default friend code updated successfully.") base_url = reverse("dashboard") query_string = urlencode({"tab": "friend_codes"}) return redirect(f"{base_url}?{query_string}") + class EditFriendCodeView(LoginRequiredMixin, UpdateView): """ Edit the in-game name for a friend code. The friend code itself is displayed as plain text. Also includes "Set Default" and "Delete" buttons in the template. """ + model = FriendCode # Only the in_game_name field is editable - fields = ['in_game_name'] + fields = ["in_game_name"] template_name = "friend_codes/edit_friend_code.html" context_object_name = "friend_code" + def get_success_url(self): base_url = reverse("dashboard") return f"{base_url}?{urlencode({'tab': 'friend_codes'})}" @@ -135,12 +157,16 @@ class EditFriendCodeView(LoginRequiredMixin, UpdateView): messages.success(self.request, "Friend code updated successfully.") return super().form_valid(form) -class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginationMixin, TemplateView): + +class DashboardView( + LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginationMixin, TemplateView +): template_name = "account/dashboard.html" def post(self, request, *args, **kwargs): - if 'update_settings' in request.POST: + if "update_settings" in request.POST: from pkmntrade_club.accounts.forms import UserSettingsForm + form = UserSettingsForm(request.POST, instance=request.user) if form.is_valid(): form.save() @@ -156,21 +182,30 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat try: selected_friend_code = friend_codes.get(pk=friend_code_param) except friend_codes.model.DoesNotExist: - selected_friend_code = self.request.user.default_friend_code or friend_codes.first() + selected_friend_code = ( + self.request.user.default_friend_code or friend_codes.first() + ) else: - selected_friend_code = self.request.user.default_friend_code or friend_codes.first() + selected_friend_code = ( + self.request.user.default_friend_code or friend_codes.first() + ) if not selected_friend_code: - raise PermissionDenied("You do not have an active friend code associated with your account.") + raise PermissionDenied( + "You do not have an active friend code associated with your account." + ) return selected_friend_code def get_dashboard_offers_paginated(self, page_param): selected_friend_code = self.get_selected_friend_code() - queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=False) + queryset = TradeOffer.objects.filter( + initiated_by=selected_friend_code, is_closed=False + ) object_list, pagination_context = self.paginate_data(queryset, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} def get_involved_acceptances(self, selected_friend_code): from django.db.models import Q + terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, @@ -179,7 +214,8 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, ] involved = TradeAcceptance.objects.filter( - Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code) + Q(trade_offer__initiated_by=selected_friend_code) + | Q(accepted_by=selected_friend_code) ).order_by("-updated_at") return involved.exclude(state__in=terminal_states) @@ -187,12 +223,19 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat selected_friend_code = self.get_selected_friend_code() involved = self.get_involved_acceptances(selected_friend_code) from django.db.models import Q + waiting = involved.filter( - Q(trade_offer__initiated_by=selected_friend_code, state__in=[ - TradeAcceptance.AcceptanceState.ACCEPTED, - TradeAcceptance.AcceptanceState.RECEIVED, - ]) | - Q(accepted_by=selected_friend_code, state__in=[TradeAcceptance.AcceptanceState.SENT]) + Q( + trade_offer__initiated_by=selected_friend_code, + state__in=[ + TradeAcceptance.AcceptanceState.ACCEPTED, + TradeAcceptance.AcceptanceState.RECEIVED, + ], + ) + | Q( + accepted_by=selected_friend_code, + state__in=[TradeAcceptance.AcceptanceState.SENT], + ) ) object_list, pagination_context = self.paginate_data(waiting, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} @@ -201,12 +244,19 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat selected_friend_code = self.get_selected_friend_code() involved = self.get_involved_acceptances(selected_friend_code) from django.db.models import Q + waiting = involved.filter( - Q(trade_offer__initiated_by=selected_friend_code, state__in=[ - TradeAcceptance.AcceptanceState.ACCEPTED, - TradeAcceptance.AcceptanceState.RECEIVED, - ]) | - Q(accepted_by=selected_friend_code, state__in=[TradeAcceptance.AcceptanceState.SENT]) + Q( + trade_offer__initiated_by=selected_friend_code, + state__in=[ + TradeAcceptance.AcceptanceState.ACCEPTED, + TradeAcceptance.AcceptanceState.RECEIVED, + ], + ) + | Q( + accepted_by=selected_friend_code, + state__in=[TradeAcceptance.AcceptanceState.SENT], + ) ) others = involved.exclude(pk__in=waiting.values("pk")) object_list, pagination_context = self.paginate_data(others, int(page_param)) @@ -214,12 +264,15 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat def get_closed_offers_paginated(self, page_param): selected_friend_code = self.get_selected_friend_code() - queryset = TradeOffer.objects.filter(initiated_by=selected_friend_code, is_closed=True) + queryset = TradeOffer.objects.filter( + initiated_by=selected_friend_code, is_closed=True + ) object_list, pagination_context = self.paginate_data(queryset, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} def get_closed_acceptances_paginated(self, page_param): from django.db.models import Q + selected_friend_code = self.get_selected_friend_code() terminal_success_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, @@ -227,28 +280,45 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, ] acceptance_qs = TradeAcceptance.objects.filter( - Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code), - state__in=terminal_success_states + Q(trade_offer__initiated_by=selected_friend_code) + | Q(accepted_by=selected_friend_code), + state__in=terminal_success_states, ).order_by("-updated_at") - object_list, pagination_context = self.paginate_data(acceptance_qs, int(page_param)) + object_list, pagination_context = self.paginate_data( + acceptance_qs, int(page_param) + ) return {"object_list": object_list, "page_obj": pagination_context} def get_rejected_by_me_paginated(self, page_param): from django.db.models import Q + selected_friend_code = self.get_selected_friend_code() rejection = TradeAcceptance.objects.filter( - Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR) | - Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR) + Q( + trade_offer__initiated_by=selected_friend_code, + state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, + ) + | Q( + accepted_by=selected_friend_code, + state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, + ) ).order_by("-updated_at") object_list, pagination_context = self.paginate_data(rejection, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} def get_rejected_by_them_paginated(self, page_param): from django.db.models import Q + selected_friend_code = self.get_selected_friend_code() rejection = TradeAcceptance.objects.filter( - Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR) | - Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR) + Q( + trade_offer__initiated_by=selected_friend_code, + state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, + ) + | Q( + accepted_by=selected_friend_code, + state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, + ) ).order_by("-updated_at") object_list, pagination_context = self.paginate_data(rejection, int(page_param)) return {"object_list": object_list, "page_obj": pagination_context} @@ -258,19 +328,19 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat request = self.request selected_friend_code = self.get_selected_friend_code() context["selected_friend_code"] = selected_friend_code - + # Get the default friend code's primary key if it exists default_pk = getattr(request.user.default_friend_code, "pk", None) - + # Annotate friend codes with is_default flag context["friend_codes"] = request.user.friend_codes.all().annotate( is_default=Case( When(pk=default_pk, then=Value(True)), default=Value(False), - output_field=BooleanField() + output_field=BooleanField(), ) ) - + ajax_section = request.GET.get("ajax_section") if ajax_section == "dashboard_offers": offers_page = request.GET.get("page", 1) @@ -307,14 +377,28 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat else: rejected_by_them_page = request.GET.get("rejected_by_them_page", 1) - context["dashboard_offers_paginated"] = self.get_dashboard_offers_paginated(offers_page) - context["trade_acceptances_waiting_paginated"] = self.get_trade_acceptances_waiting_paginated(waiting_page) - context["other_party_trade_acceptances_paginated"] = self.get_other_party_trade_acceptances_paginated(other_page) - context["closed_offers_paginated"] = self.get_closed_offers_paginated(closed_offers_page) - context["closed_acceptances_paginated"] = self.get_closed_acceptances_paginated(closed_acceptances_page) - context["rejected_by_me_paginated"] = self.get_rejected_by_me_paginated(rejected_by_me_page) - context["rejected_by_them_paginated"] = self.get_rejected_by_them_paginated(rejected_by_them_page) - from pkmntrade_club.accounts.forms import UserSettingsForm + context["dashboard_offers_paginated"] = self.get_dashboard_offers_paginated( + offers_page + ) + context["trade_acceptances_waiting_paginated"] = ( + self.get_trade_acceptances_waiting_paginated(waiting_page) + ) + context["other_party_trade_acceptances_paginated"] = ( + self.get_other_party_trade_acceptances_paginated(other_page) + ) + context["closed_offers_paginated"] = self.get_closed_offers_paginated( + closed_offers_page + ) + context["closed_acceptances_paginated"] = self.get_closed_acceptances_paginated( + closed_acceptances_page + ) + context["rejected_by_me_paginated"] = self.get_rejected_by_me_paginated( + rejected_by_me_page + ) + context["rejected_by_them_paginated"] = self.get_rejected_by_them_paginated( + rejected_by_them_page + ) + context["settings_form"] = UserSettingsForm(instance=request.user) context["active_tab"] = request.GET.get("tab", "dash") return context @@ -327,9 +411,13 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat if ajax_section == "dashboard_offers": fragment_context = context.get("dashboard_offers_paginated", {}) elif ajax_section == "waiting_acceptances": - fragment_context = context.get("trade_acceptances_waiting_paginated", {}) + fragment_context = context.get( + "trade_acceptances_waiting_paginated", {} + ) elif ajax_section == "other_party_acceptances": - fragment_context = context.get("other_party_trade_acceptances_paginated", {}) + fragment_context = context.get( + "other_party_trade_acceptances_paginated", {} + ) elif ajax_section == "closed_offers": fragment_context = context.get("closed_offers_paginated", {}) elif ajax_section == "closed_acceptances": @@ -342,8 +430,12 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat fragment_context = {} if fragment_context: - return render(request, "trades/_trade_offer_list.html", { - "offers": fragment_context.get("object_list", []), - "page_obj": fragment_context.get("page_obj") - }) - return super().get(request, *args, **kwargs) \ No newline at end of file + return render( + request, + "trades/_trade_offer_list.html", + { + "offers": fragment_context.get("object_list", []), + "page_obj": fragment_context.get("page_obj"), + }, + ) + return super().get(request, *args, **kwargs) diff --git a/src/pkmntrade_club/cards/mixins.py b/src/pkmntrade_club/cards/mixins.py index 575d62c..18d887c 100644 --- a/src/pkmntrade_club/cards/mixins.py +++ b/src/pkmntrade_club/cards/mixins.py @@ -1,10 +1,12 @@ from math import ceil + class ReusablePaginationMixin: """ A mixin that encapsulates reusable pagination logic. Use in Django ListViews to generate custom pagination context. """ + per_page = 10 # Default; can be overridden in your view. def paginate_data(self, data_list, page_number): @@ -39,4 +41,4 @@ class ReusablePaginationMixin: "next_page": page_number + 1 if page_number < num_pages else num_pages, "paginator": {"num_pages": num_pages}, } - return items, pagination_context \ No newline at end of file + return items, pagination_context diff --git a/src/pkmntrade_club/cards/models.py b/src/pkmntrade_club/cards/models.py index b3376ef..d443a6a 100644 --- a/src/pkmntrade_club/cards/models.py +++ b/src/pkmntrade_club/cards/models.py @@ -106,7 +106,7 @@ class AttackCost(models.Model): unique_together = ("attack", "energy") def __str__(self): - return f"{self.attack.name} {_("requires")} {self.quantity} {self.energy.name}" + return f"{self.attack.name} {_('requires')} {self.quantity} {self.energy.name}" class Attack(TranslatableModel): diff --git a/src/pkmntrade_club/cards/signals.py b/src/pkmntrade_club/cards/signals.py index dd87f2a..af9a8ee 100644 --- a/src/pkmntrade_club/cards/signals.py +++ b/src/pkmntrade_club/cards/signals.py @@ -2,15 +2,16 @@ from django.db.models.signals import m2m_changed from django.dispatch import receiver from .models import Card + def color_is_dark(bg_color): """ Determine if a given hexadecimal color is dark. This function accepts a 6-digit hex color string (with or without a leading '#'). It calculates the brightness using the formula: - + brightness = (0.299 * red) + (0.587 * green) + (0.114 * blue) - + A brightness value less than or equal to 186 indicates that the color is dark. Args: @@ -20,7 +21,7 @@ def color_is_dark(bg_color): bool: True if the color is dark (brightness <= 186), False otherwise. """ # Remove the leading '#' if it exists. - color = bg_color[1:7] if bg_color[0] == '#' else bg_color + color = bg_color[1:7] if bg_color[0] == "#" else bg_color # Convert the hex color components to integers. r = int(color[0:2], 16) @@ -29,9 +30,10 @@ def color_is_dark(bg_color): # Compute brightness based on weighted RGB values. brightness = (r * 0.299) + (g * 0.587) + (b * 0.114) - + return brightness <= 200 + @receiver(m2m_changed, sender=Card.decks.through) def update_card_style(sender, instance, action, **kwargs): if action == "post_add": @@ -41,11 +43,15 @@ def update_card_style(sender, instance, action, **kwargs): instance.style = "background-color: " + decks.first().hex_color + ";" elif num_decks >= 2: hex_colors = [deck.hex_color for deck in decks] - instance.style = f"background: linear-gradient(to right, {', '.join(hex_colors)});" + instance.style = ( + f"background: linear-gradient(to right, {', '.join(hex_colors)});" + ) else: - instance.style = "background: linear-gradient(to right, #AAAAAA, #AAAAAA, #AAAAAA);" + instance.style = ( + "background: linear-gradient(to right, #AAAAAA, #AAAAAA, #AAAAAA);" + ) if not color_is_dark(decks.first().hex_color): instance.style += "color: var(--color-gray-700); text-shadow: 0 0 0 var(--color-gray-700);" else: instance.style += "text-shadow: 0 0 0 #fff;" - instance.save(update_fields=["style"]) \ No newline at end of file + instance.save(update_fields=["style"]) diff --git a/src/pkmntrade_club/cards/templatetags/card_badge.py b/src/pkmntrade_club/cards/templatetags/card_badge.py index ace3b17..adc2930 100644 --- a/src/pkmntrade_club/cards/templatetags/card_badge.py +++ b/src/pkmntrade_club/cards/templatetags/card_badge.py @@ -6,41 +6,43 @@ from django.urls import reverse_lazy register = template.Library() + @register.inclusion_tag("templatetags/card_badge.html", takes_context=True) def card_badge(context, card, quantity=None, expanded=False): """ Renders a card badge. """ - url = reverse_lazy('cards:card_detail', args=[card.pk]) + url = reverse_lazy("cards:card_detail", args=[card.pk]) tag_context = { - 'quantity': quantity, - 'style': card.style, - 'name': card.name, - 'rarity': card.rarity_icon, - 'cardset': card.cardset, - 'expanded': expanded, - 'cache_key': f'card_badge_{card.pk}_{quantity}_{expanded}', - 'url': url, + "quantity": quantity, + "style": card.style, + "name": card.name, + "rarity": card.rarity_icon, + "cardset": card.cardset, + "expanded": expanded, + "cache_key": f"card_badge_{card.pk}_{quantity}_{expanded}", + "url": url, } context.update(tag_context) return context + @register.filter def card_badge_inline(card, quantity=None): """ Renders an inline card badge by directly rendering the template. """ - url = reverse_lazy('cards:card_detail', args=[card.pk]) + url = reverse_lazy("cards:card_detail", args=[card.pk]) tag_context = { - 'quantity': quantity, - 'style': card.style, - 'name': card.name, - 'rarity': card.rarity_icon, - 'cardset': card.cardset, - 'expanded': True, - 'cache_key': f'card_badge_{card.pk}_{quantity}_{True}', - 'CACHE_TIMEOUT': settings.CACHE_TIMEOUT, - 'url': url, + "quantity": quantity, + "style": card.style, + "name": card.name, + "rarity": card.rarity_icon, + "cardset": card.cardset, + "expanded": True, + "cache_key": f"card_badge_{card.pk}_{quantity}_{True}", + "CACHE_TIMEOUT": settings.CACHE_TIMEOUT, + "url": url, } html = render_to_string("templatetags/card_badge.html", tag_context) - return mark_safe(html) \ No newline at end of file + return mark_safe(html) diff --git a/src/pkmntrade_club/cards/templatetags/card_multiselect.py b/src/pkmntrade_club/cards/templatetags/card_multiselect.py index dc03500..65b43a3 100644 --- a/src/pkmntrade_club/cards/templatetags/card_multiselect.py +++ b/src/pkmntrade_club/cards/templatetags/card_multiselect.py @@ -5,20 +5,26 @@ from django.db.models.query import QuerySet import json import hashlib import logging + register = template.Library() + @register.filter def get_item(dictionary, key): """Allows accessing dictionary items using a variable key in templates.""" return dictionary.get(key) + @register.simple_tag def fetch_all_cards(): """Simple tag to fetch all Card objects.""" - return Card.objects.order_by('pk').all() + return Card.objects.order_by("pk").all() -@register.inclusion_tag('templatetags/card_multiselect.html', takes_context=True) -def card_multiselect(context, field_name, label, placeholder, cards=None, selected_values=None): + +@register.inclusion_tag("templatetags/card_multiselect.html", takes_context=True) +def card_multiselect( + context, field_name, label, placeholder, cards=None, selected_values=None +): """ Prepares context for rendering a card multiselect input. Database querying and rendering are handled within the template's cache block. @@ -28,15 +34,15 @@ def card_multiselect(context, field_name, label, placeholder, cards=None, select selected_cards = {} for val in selected_values: - parts = str(val).split(':') + parts = str(val).split(":") if len(parts) >= 1 and parts[0]: card_id = parts[0] quantity = parts[1] if len(parts) > 1 else 1 selected_cards[str(card_id)] = quantity - effective_field_name = field_name if field_name is not None else 'card_multiselect' - effective_label = label if label is not None else 'Card' - effective_placeholder = placeholder if placeholder is not None else 'Select Cards' + effective_field_name = field_name if field_name is not None else "card_multiselect" + effective_label = label if label is not None else "Card" + effective_placeholder = placeholder if placeholder is not None else "Select Cards" selected_cards_key_part = json.dumps(selected_cards, sort_keys=True) @@ -45,28 +51,32 @@ def card_multiselect(context, field_name, label, placeholder, cards=None, select if has_passed_cards: try: query_string = str(cards.query) - passed_cards_identifier = hashlib.sha256(query_string.encode('utf-8')).hexdigest() + passed_cards_identifier = hashlib.sha256( + query_string.encode("utf-8") + ).hexdigest() except Exception as e: - logging.warning(f"Could not generate query hash for card_multiselect. Error: {e}") - passed_cards_identifier = 'specific_qs_fallback_' + str(uuid.uuid4()) + logging.warning( + f"Could not generate query hash for card_multiselect. Error: {e}" + ) + passed_cards_identifier = "specific_qs_fallback_" + str(uuid.uuid4()) else: - passed_cards_identifier = 'all_cards' + passed_cards_identifier = "all_cards" # Define the variables specific to this tag tag_specific_context = { - 'field_name': effective_field_name, - 'field_id': effective_field_name, - 'label': effective_label, - 'placeholder': effective_placeholder, - 'passed_cards': cards if has_passed_cards else None, - 'has_passed_cards': has_passed_cards, - 'selected_cards': selected_cards, - 'selected_cards_key_part': selected_cards_key_part, - 'passed_cards_identifier': passed_cards_identifier, + "field_name": effective_field_name, + "field_id": effective_field_name, + "label": effective_label, + "placeholder": effective_placeholder, + "passed_cards": cards if has_passed_cards else None, + "has_passed_cards": has_passed_cards, + "selected_cards": selected_cards, + "selected_cards_key_part": selected_cards_key_part, + "passed_cards_identifier": passed_cards_identifier, } # Update the original context with the tag-specific variables # This preserves CACHE_TIMEOUT and other parent context variables context.update(tag_specific_context) - return context # Return the MODIFIED original context \ No newline at end of file + return context # Return the MODIFIED original context diff --git a/src/pkmntrade_club/cards/tests.py b/src/pkmntrade_club/cards/tests.py index 75e73b8..85219a2 100644 --- a/src/pkmntrade_club/cards/tests.py +++ b/src/pkmntrade_club/cards/tests.py @@ -6,11 +6,21 @@ from django.urls import reverse from django.utils import timezone from pkmntrade_club.accounts.models import CustomUser, FriendCode -from pkmntrade_club.cards.models import Card, Deck, DeckNameTranslation, CardNameTranslation -from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard -from pkmntrade_club.cards.templatetags import card_badge, card_multiselect +from pkmntrade_club.cards.models import ( + Card, + Deck, + DeckNameTranslation, + CardNameTranslation, +) +from pkmntrade_club.trades.models import ( + TradeOffer, + TradeOfferHaveCard, + TradeOfferWantCard, +) +from pkmntrade_club.cards.templatetags import card_multiselect from tests.utils.rarity import RARITY_MAPPING + class CardsModelsTests(TestCase): def setUp(self): self.deck = Deck.objects.create( @@ -22,7 +32,7 @@ class CardsModelsTests(TestCase): cardnum=1, style="default", rarity_icon=RARITY_MAPPING[1], - rarity_level=1 + rarity_level=1, ) # Establish many-to-many relationship. self.card.decks.add(self.deck) @@ -44,7 +54,8 @@ class CardsModelsTests(TestCase): card_translation = CardNameTranslation.objects.create( name="Card Translated", card=self.card, language="en" ) - self.assertEqual(str(card_translation), "Card Translated") + self.assertEqual(str(card_translation), "Card Translated") + class CardTemplatetagsTests(TestCase): def setUp(self): @@ -55,12 +66,12 @@ class CardTemplatetagsTests(TestCase): cardnum=2, style="background: green;", rarity_icon="โ˜†", - rarity_level=2 + rarity_level=2, ) def test_card_badge_inclusion_tag(self): """Test the card_badge inclusion tag renders correctly.""" - template_str = '{% load card_badge %}{% card_badge card quantity=3 %}' + template_str = "{% load card_badge %}{% card_badge card quantity=3 %}" t = Template(template_str) c = Context({"card": self.card}) rendered = t.render(c) @@ -71,7 +82,7 @@ class CardTemplatetagsTests(TestCase): def test_card_badge_inline_filter(self): """Test the card_badge_inline filter returns safe HTML with correct data.""" - template_str = '{% load card_badge %}{{ card|card_badge_inline:5 }}' + template_str = "{% load card_badge %}{{ card|card_badge_inline:5 }}" t = Template(template_str) c = Context({"card": self.card}) rendered = t.render(c) @@ -142,7 +153,8 @@ class CardTemplatetagsTests(TestCase): selected_values=[], ) # Verify that the context's cards match those in the database. - self.assertEqual(list(context["cards"]), default_cards) + self.assertEqual(list(context["cards"]), default_cards) + class CardsViewsTests(TestCase): def setUp(self): @@ -161,7 +173,7 @@ class CardsViewsTests(TestCase): cardnum=1, style="default", rarity_icon=RARITY_MAPPING[1], - rarity_level=1 + rarity_level=1, ) def test_card_detail_view_context(self): @@ -198,9 +210,7 @@ class CardsViewsTests(TestCase): Helper method to create a trade offer for the 'have' side with a custom updated_at. """ offer = TradeOffer.objects.create(initiated_by=self.friendcode) - TradeOfferHaveCard.objects.create( - trade_offer=offer, card=self.card, quantity=1 - ) + TradeOfferHaveCard.objects.create(trade_offer=offer, card=self.card, quantity=1) # Adjust updated_at so that ordering can be tested. new_time = timezone.now() + timedelta(minutes=updated_delta_minutes) TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time) @@ -212,9 +222,7 @@ class CardsViewsTests(TestCase): Helper method to create a trade offer for the 'want' side with a custom updated_at. """ offer = TradeOffer.objects.create(initiated_by=self.friendcode) - TradeOfferWantCard.objects.create( - trade_offer=offer, card=self.card, quantity=1 - ) + TradeOfferWantCard.objects.create(trade_offer=offer, card=self.card, quantity=1) new_time = timezone.now() + timedelta(minutes=updated_delta_minutes) TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time) offer.refresh_from_db() @@ -285,4 +293,4 @@ class CardsViewsTests(TestCase): trade_offers_oldest = response_oldest.context.get("trade_offers") self.assertEqual(len(trade_offers_oldest), 2) self.assertEqual(trade_offers_oldest[0].pk, offer1.pk) - self.assertEqual(trade_offers_oldest[1].pk, offer2.pk) \ No newline at end of file + self.assertEqual(trade_offers_oldest[1].pk, offer2.pk) diff --git a/src/pkmntrade_club/cards/urls.py b/src/pkmntrade_club/cards/urls.py index 599427f..3338d08 100644 --- a/src/pkmntrade_club/cards/urls.py +++ b/src/pkmntrade_club/cards/urls.py @@ -9,8 +9,16 @@ from .views import ( app_name = "cards" urlpatterns = [ - path('', CardListView.as_view(), name='card_list'), - path('/', CardDetailView.as_view(), name='card_detail'), - path('/trade-offers-have/', TradeOfferHaveCardListView.as_view(), name='card_trade_offer_have_list'), - path('/trade-offers-want/', TradeOfferWantCardListView.as_view(), name='card_trade_offer_want_list'), + path("", CardListView.as_view(), name="card_list"), + path("/", CardDetailView.as_view(), name="card_detail"), + path( + "/trade-offers-have/", + TradeOfferHaveCardListView.as_view(), + name="card_trade_offer_have_list", + ), + path( + "/trade-offers-want/", + TradeOfferWantCardListView.as_view(), + name="card_trade_offer_want_list", + ), ] diff --git a/src/pkmntrade_club/cards/views.py b/src/pkmntrade_club/cards/views.py index 08a7bb0..f4c1cf2 100644 --- a/src/pkmntrade_club/cards/views.py +++ b/src/pkmntrade_club/cards/views.py @@ -1,12 +1,14 @@ -from django.views.generic import TemplateView -from django.urls import reverse_lazy -from django.views.generic import UpdateView, DeleteView, CreateView, ListView, DetailView +from django.views.generic import ( + ListView, + DetailView, +) from pkmntrade_club.cards.models import Card from pkmntrade_club.trades.models import TradeOffer from pkmntrade_club.common.mixins import ReusablePaginationMixin from django.views import View from django.shortcuts import get_object_or_404, render + class CardDetailView(DetailView): model = Card template_name = "cards/card_detail.html" @@ -16,15 +18,20 @@ class CardDetailView(DetailView): context = super().get_context_data(**kwargs) card = self.get_object() # Count of trade offers where the card appears as a "have" in a trade. - context['trade_offer_have_count'] = TradeOffer.objects.filter( - trade_offer_have_cards__card=card - ).distinct().count() + context["trade_offer_have_count"] = ( + TradeOffer.objects.filter(trade_offer_have_cards__card=card) + .distinct() + .count() + ) # Count of trade offers where the card appears as a "want" in a trade. - context['trade_offer_want_count'] = TradeOffer.objects.filter( - trade_offer_want_cards__card=card - ).distinct().count() + context["trade_offer_want_count"] = ( + TradeOffer.objects.filter(trade_offer_want_cards__card=card) + .distinct() + .count() + ) return context + class TradeOfferHaveCardListView(ReusablePaginationMixin, View): def get(self, request, pk): card = get_object_or_404(Card, pk=pk) @@ -48,6 +55,7 @@ class TradeOfferHaveCardListView(ReusablePaginationMixin, View): # Render the partial template to be injected via AJAX return render(request, "trades/_trade_offer_list.html", context) + class TradeOfferWantCardListView(ReusablePaginationMixin, View): def get(self, request, pk): card = get_object_or_404(Card, pk=pk) @@ -72,6 +80,8 @@ class TradeOfferWantCardListView(ReusablePaginationMixin, View): } # Render the partial template containing the new pagination controls return render(request, "trades/_trade_offer_list.html", context) + + class CardListView(ReusablePaginationMixin, ListView): model = Card # Removed built-in pagination; using custom mixin instead @@ -119,12 +129,20 @@ class CardListView(ReusablePaginationMixin, ListView): flat_cards.sort(key=lambda x: x["group"].lower()) elif group_by == "rarity": for card in all_cards: - flat_cards.append({"group": card.rarity_icon, "sort_group": card.rarity_level, "card": card}) + flat_cards.append( + { + "group": card.rarity_icon, + "sort_group": card.rarity_level, + "card": card, + } + ) flat_cards.sort(key=lambda x: x["sort_group"], reverse=True) page_number = self.get_page_number() self.per_page = 36 - page_flat_cards, pagination_context = self.paginate_data(flat_cards, page_number) + page_flat_cards, pagination_context = self.paginate_data( + flat_cards, page_number + ) page_groups = [] for item in page_flat_cards: @@ -141,8 +159,10 @@ class CardListView(ReusablePaginationMixin, ListView): else: page_number = self.get_page_number() self.per_page = 36 - paginated_cards, pagination_context = self.paginate_data(self.get_queryset(), page_number) + paginated_cards, pagination_context = self.paginate_data( + self.get_queryset(), page_number + ) context["cards"] = paginated_cards context["page_obj"] = pagination_context context["object_list"] = self.get_queryset() - return context \ No newline at end of file + return context diff --git a/src/pkmntrade_club/common/apps.py b/src/pkmntrade_club/common/apps.py index 6e2cde5..7427944 100644 --- a/src/pkmntrade_club/common/apps.py +++ b/src/pkmntrade_club/common/apps.py @@ -5,4 +5,4 @@ class CommonConfig(AppConfig): name = "pkmntrade_club.common" def ready(self): - pass \ No newline at end of file + pass diff --git a/src/pkmntrade_club/common/context_processors.py b/src/pkmntrade_club/common/context_processors.py index 7950ded..b286201 100644 --- a/src/pkmntrade_club/common/context_processors.py +++ b/src/pkmntrade_club/common/context_processors.py @@ -1,12 +1,14 @@ from django.conf import settings + def cache_settings(request): return { - 'CACHE_TIMEOUT': settings.CACHE_TIMEOUT, + "CACHE_TIMEOUT": settings.CACHE_TIMEOUT, } + def version_info(request): return { - 'VERSION': settings.VERSION, - 'VERSION_INFO': settings.VERSION_INFO, - } \ No newline at end of file + "VERSION": settings.VERSION, + "VERSION_INFO": settings.VERSION_INFO, + } diff --git a/src/pkmntrade_club/common/mixins.py b/src/pkmntrade_club/common/mixins.py index 6290fdc..0df467a 100644 --- a/src/pkmntrade_club/common/mixins.py +++ b/src/pkmntrade_club/common/mixins.py @@ -26,9 +26,13 @@ class ReusablePaginationMixin: "number": page.number, "has_previous": page.has_previous(), "has_next": page.has_next(), - "previous_page_number": page.previous_page_number() if page.has_previous() else 1, - "next_page_number": page.next_page_number() if page.has_next() else paginator.num_pages, + "previous_page_number": ( + page.previous_page_number() if page.has_previous() else 1 + ), + "next_page_number": ( + page.next_page_number() if page.has_next() else paginator.num_pages + ), "paginator": {"num_pages": paginator.num_pages}, - "count": paginator.count + "count": paginator.count, } - return page.object_list, pagination_context \ No newline at end of file + return page.object_list, pagination_context diff --git a/src/pkmntrade_club/common/templatetags/pagination_tags.py b/src/pkmntrade_club/common/templatetags/pagination_tags.py index a9a2890..0840881 100644 --- a/src/pkmntrade_club/common/templatetags/pagination_tags.py +++ b/src/pkmntrade_club/common/templatetags/pagination_tags.py @@ -2,9 +2,10 @@ from django import template register = template.Library() + @register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True) def render_pagination(context, page_obj, hide_if_one_page=True): """ Renders the pagination controls given a page_obj. Optionally hides the controls if there is only one page. """ - return {"page_obj": page_obj, "hide_if_one_page": hide_if_one_page} \ No newline at end of file + return {"page_obj": page_obj, "hide_if_one_page": hide_if_one_page} diff --git a/src/pkmntrade_club/django_project/__init__.py b/src/pkmntrade_club/django_project/__init__.py index 1e3599b..5568b6d 100644 --- a/src/pkmntrade_club/django_project/__init__.py +++ b/src/pkmntrade_club/django_project/__init__.py @@ -2,4 +2,4 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app -__all__ = ('celery_app',) \ No newline at end of file +__all__ = ("celery_app",) diff --git a/src/pkmntrade_club/django_project/asgi.py b/src/pkmntrade_club/django_project/asgi.py index 11400ef..715ac8b 100644 --- a/src/pkmntrade_club/django_project/asgi.py +++ b/src/pkmntrade_club/django_project/asgi.py @@ -2,6 +2,8 @@ import os from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pkmntrade_club.django_project.settings') +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" +) application = get_asgi_application() diff --git a/src/pkmntrade_club/django_project/celery.py b/src/pkmntrade_club/django_project/celery.py index 44c9d6c..7896883 100644 --- a/src/pkmntrade_club/django_project/celery.py +++ b/src/pkmntrade_club/django_project/celery.py @@ -3,15 +3,17 @@ import os from celery import Celery # Set the default Django settings module for the 'celery' program. -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pkmntrade_club.django_project.settings') +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" +) -app = Celery('django_project') +app = Celery("django_project") # Using a string here means the worker doesn't have to serialize # the configuration object to child processes. # - namespace='CELERY' means all celery-related configuration keys # should have a `CELERY_` prefix. -app.config_from_object('django.conf:settings', namespace='CELERY') +app.config_from_object("django.conf:settings", namespace="CELERY") # Load task modules from all registered Django apps. app.autodiscover_tasks() @@ -19,4 +21,4 @@ app.autodiscover_tasks() @app.task(bind=True, ignore_result=True) def debug_task(self): - print(f'Request: {self.request!r}') \ No newline at end of file + print(f"Request: {self.request!r}") diff --git a/src/pkmntrade_club/django_project/settings.py b/src/pkmntrade_club/django_project/settings.py index d048108..eb152d8 100644 --- a/src/pkmntrade_club/django_project/settings.py +++ b/src/pkmntrade_club/django_project/settings.py @@ -9,67 +9,76 @@ from pkmntrade_club._version import __version__, get_version_info # set default values to local dev values env = environ.Env( - DEBUG=(bool, False), # MUST STAY FALSE FOR DEFAULT FOR SECURITY REASONS (e.g. if app can't access .env, prevent showing debug output) + DEBUG=( + bool, + False, + ), # MUST STAY FALSE FOR DEFAULT FOR SECURITY REASONS (e.g. if app can't access .env, prevent showing debug output) DISABLE_SIGNUPS=(bool, True), DISABLE_CACHE=(bool, True), - DJANGO_DATABASE_URL=(str, 'postgresql://postgres@localhost:5432/postgres?sslmode=disable'), - DJANGO_EMAIL_HOST=(str, ''), + DJANGO_DATABASE_URL=( + str, + "postgresql://postgres@localhost:5432/postgres?sslmode=disable", + ), + DJANGO_EMAIL_HOST=(str, ""), DJANGO_EMAIL_PORT=(int, 587), - DJANGO_EMAIL_USER=(str, ''), - DJANGO_EMAIL_PASSWORD=(str, ''), + DJANGO_EMAIL_USER=(str, ""), + DJANGO_EMAIL_PASSWORD=(str, ""), DJANGO_EMAIL_USE_TLS=(bool, True), - DJANGO_DEFAULT_FROM_EMAIL=(str, ''), - DJANGO_EMAIL_SUBJECT_PREFIX=(str, ''), - SECRET_KEY=(str, '0000000000000000000000000000000000000000000000000000000000000000'), - ALLOWED_HOSTS=(str, 'localhost,127.0.0.1'), - PUBLIC_HOST=(str, 'localhost'), - ACCOUNT_EMAIL_VERIFICATION=(str, 'none'), - SCHEME=(str, 'http'), - REDIS_URL=(str, 'redis://localhost:6379'), + DJANGO_DEFAULT_FROM_EMAIL=(str, ""), + DJANGO_EMAIL_SUBJECT_PREFIX=(str, ""), + SECRET_KEY=( + str, + "0000000000000000000000000000000000000000000000000000000000000000", + ), + ALLOWED_HOSTS=(str, "localhost,127.0.0.1"), + PUBLIC_HOST=(str, "localhost"), + ACCOUNT_EMAIL_VERIFICATION=(str, "none"), + SCHEME=(str, "http"), + REDIS_URL=(str, "redis://localhost:6379"), CACHE_TIMEOUT=(int, 604800), - TIME_ZONE=(str, 'America/Los_Angeles'), + TIME_ZONE=(str, "America/Los_Angeles"), ) LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '%(asctime)s %(name)-12s %(levelname)-8s %(message)s', + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(asctime)s %(name)-12s %(levelname)-8s %(message)s", }, }, - 'handlers': { - 'console': { - 'level': 'INFO', - 'class': 'logging.StreamHandler', - 'stream': sys.stdout, - 'formatter': 'verbose', - 'filters': [], + "handlers": { + "console": { + "level": "INFO", + "class": "logging.StreamHandler", + "stream": sys.stdout, + "formatter": "verbose", + "filters": [], }, }, - 'loggers': { - 'django': { - 'handlers': ['console'], - 'level': 'INFO', + "loggers": { + "django": { + "handlers": ["console"], + "level": "INFO", }, - 'django.server': { - 'handlers': ['console'], - 'level': 'INFO', - 'propagate': False, + "django.server": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, }, - 'granian.access': { - 'handlers': ['console'], - 'level': 'INFO', - 'propagate': False, + "granian.access": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, }, - '_granian': { - 'handlers': ['console'], - 'level': 'INFO', - 'propagate': False, + "_granian": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, }, - '': { - 'handlers': ['console'], - 'level': 'INFO', + "": { + "handlers": ["console"], + "level": "INFO", }, }, } @@ -78,14 +87,14 @@ LOGGING = { BASE_DIR = Path(__file__).resolve().parent.parent # Take environment variables from .env file -environ.Env.read_env(os.path.join(BASE_DIR, '.env')) +environ.Env.read_env(os.path.join(BASE_DIR, ".env")) -SCHEME = env('SCHEME') -PUBLIC_HOST = env('PUBLIC_HOST') -REDIS_URL = env('REDIS_URL') -CACHE_TIMEOUT = env('CACHE_TIMEOUT') -DISABLE_SIGNUPS = env('DISABLE_SIGNUPS') -DISABLE_CACHE = env('DISABLE_CACHE') +SCHEME = env("SCHEME") +PUBLIC_HOST = env("PUBLIC_HOST") +REDIS_URL = env("REDIS_URL") +CACHE_TIMEOUT = env("CACHE_TIMEOUT") +DISABLE_SIGNUPS = env("DISABLE_SIGNUPS") +DISABLE_CACHE = env("DISABLE_CACHE") VERSION = __version__ VERSION_INFO = get_version_info() @@ -95,34 +104,38 @@ VERSION_INFO = get_version_info() # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = env('SECRET_KEY') +SECRET_KEY = env("SECRET_KEY") # https://docs.djangoproject.com/en/dev/ref/settings/#debug # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env('DEBUG') +DEBUG = env("DEBUG") # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = env('ALLOWED_HOSTS').split(',') +ALLOWED_HOSTS = env("ALLOWED_HOSTS").split(",") try: current_web_worker_hostname = socket.gethostname() ALLOWED_HOSTS.append(current_web_worker_hostname) - logging.getLogger(__name__).info(f"Added {current_web_worker_hostname} to allowed hosts.") + logging.getLogger(__name__).info( + f"Added {current_web_worker_hostname} to allowed hosts." + ) except Exception: - logging.getLogger(__name__).info(f"Error determining server hostname for allowed hosts.") + logging.getLogger(__name__).info( + "Error determining server hostname for allowed hosts." + ) CSRF_TRUSTED_ORIGINS = [f"{SCHEME}://{PUBLIC_HOST}"] -SHORTHAND_DATETIME_FORMAT = 'Y-m-d P' -SHORTHAND_DATE_FORMAT = 'Y-m-d' +SHORTHAND_DATETIME_FORMAT = "Y-m-d P" +SHORTHAND_DATE_FORMAT = "Y-m-d" FIRST_PARTY_APPS = [ - 'pkmntrade_club.accounts', - 'pkmntrade_club.cards', - 'pkmntrade_club.common', - 'pkmntrade_club.home', - 'pkmntrade_club.theme', - 'pkmntrade_club.trades', + "pkmntrade_club.accounts", + "pkmntrade_club.cards", + "pkmntrade_club.common", + "pkmntrade_club.home", + "pkmntrade_club.theme", + "pkmntrade_club.trades", ] # Application definition @@ -140,20 +153,20 @@ INSTALLED_APPS = [ "django_celery_beat", "allauth", "allauth.account", - 'allauth.socialaccount.providers.google', + "allauth.socialaccount.providers.google", "crispy_forms", "crispy_tailwind", "tailwind", "django_linear_migrations", - 'health_check', - 'health_check.db', - 'health_check.cache', - 'health_check.storage', - 'health_check.contrib.migrations', - 'health_check.contrib.celery', - 'health_check.contrib.celery_ping', - 'health_check.contrib.psutil', - 'health_check.contrib.redis', + "health_check", + "health_check.db", + "health_check.cache", + "health_check.storage", + "health_check.contrib.migrations", + "health_check.contrib.celery", + "health_check.contrib.celery_ping", + "health_check.contrib.psutil", + "health_check.contrib.redis", "meta", "parler", ] + FIRST_PARTY_APPS @@ -165,12 +178,12 @@ if DEBUG: "debug_toolbar", ] -TAILWIND_APP_NAME = 'theme' +TAILWIND_APP_NAME = "theme" -META_SITE_NAME = 'PKMN Trade Club' +META_SITE_NAME = "PKMN Trade Club" META_SITE_PROTOCOL = SCHEME META_USE_SITES = True -META_IMAGE_URL = f'{SCHEME}://{PUBLIC_HOST}/' +META_IMAGE_URL = f"{SCHEME}://{PUBLIC_HOST}/" # https://docs.djangoproject.com/en/dev/ref/settings/#middleware MIDDLEWARE = [ @@ -193,22 +206,22 @@ if DEBUG: ] HEALTH_CHECK = { - 'DISK_USAGE_MAX': 90, # percent - 'MEMORY_MIN': 100, # in MB + "DISK_USAGE_MAX": 90, # percent + "MEMORY_MIN": 100, # in MB } DAISY_SETTINGS = { - 'SITE_TITLE': 'PKMN Trade Club Admin', - 'DONT_SUPPORT_ME': True, + "SITE_TITLE": "PKMN Trade Club Admin", + "DONT_SUPPORT_ME": True, } # https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf -ROOT_URLCONF = 'pkmntrade_club.django_project.urls' +ROOT_URLCONF = "pkmntrade_club.django_project.urls" # https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application -WSGI_APPLICATION = 'pkmntrade_club.django_project.wsgi.app' +WSGI_APPLICATION = "pkmntrade_club.django_project.wsgi.app" -ASGI_APPLICATION = 'pkmntrade_club.django_project.asgi.application' +ASGI_APPLICATION = "pkmntrade_club.django_project.asgi.application" # https://docs.djangoproject.com/en/dev/ref/settings/#templates TEMPLATES = [ @@ -231,7 +244,7 @@ TEMPLATES = [ # https://docs.djangoproject.com/en/dev/ref/settings/#databases DATABASES = { - 'default': env.db(var="DJANGO_DATABASE_URL"), + "default": env.db(var="DJANGO_DATABASE_URL"), } # Password validation @@ -256,12 +269,10 @@ AUTH_PASSWORD_VALIDATORS = [ # https://docs.djangoproject.com/en/dev/ref/settings/#language-code LANGUAGE_CODE = "en-us" -LANGUAGES = ( - ('en', _("English")), -) +LANGUAGES = (("en", _("English")),) # https://docs.djangoproject.com/en/dev/ref/settings/#time-zone -TIME_ZONE = env('TIME_ZONE') +TIME_ZONE = env("TIME_ZONE") # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-USE_I18N USE_I18N = True @@ -270,7 +281,7 @@ USE_I18N = True USE_TZ = True # https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths -LOCALE_PATHS = [BASE_DIR / 'locale'] +LOCALE_PATHS = [BASE_DIR / "locale"] # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.0/howto/static-files/ @@ -305,24 +316,24 @@ STORAGES = { # Default primary key field type # https://docs.djangoproject.com/en/stable/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # django-crispy-forms # https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs -CRISPY_ALLOWED_TEMPLATE_PACKS = 'tailwind' +CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind" CRISPY_TEMPLATE_PACK = "tailwind" # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = env('DJANGO_EMAIL_HOST') -EMAIL_PORT = env('DJANGO_EMAIL_PORT') -EMAIL_HOST_USER = env('DJANGO_EMAIL_USER') -EMAIL_HOST_PASSWORD = env('DJANGO_EMAIL_PASSWORD') -EMAIL_USE_TLS = env('DJANGO_EMAIL_USE_TLS') -EMAIL_SUBJECT_PREFIX = env('DJANGO_EMAIL_SUBJECT_PREFIX') +EMAIL_HOST = env("DJANGO_EMAIL_HOST") +EMAIL_PORT = env("DJANGO_EMAIL_PORT") +EMAIL_HOST_USER = env("DJANGO_EMAIL_USER") +EMAIL_HOST_PASSWORD = env("DJANGO_EMAIL_PASSWORD") +EMAIL_USE_TLS = env("DJANGO_EMAIL_USE_TLS") +EMAIL_SUBJECT_PREFIX = env("DJANGO_EMAIL_SUBJECT_PREFIX") # https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email -DEFAULT_FROM_EMAIL = env('DJANGO_DEFAULT_FROM_EMAIL') +DEFAULT_FROM_EMAIL = env("DJANGO_DEFAULT_FROM_EMAIL") # django-debug-toolbar # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html @@ -335,7 +346,7 @@ INTERNAL_IPS = [ hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) for ip in ips: INTERNAL_IPS.append(ip) - INTERNAL_IPS.append(".".join(ip.rsplit(".")[:-1])+ ".1") + INTERNAL_IPS.append(".".join(ip.rsplit(".")[:-1]) + ".1") # https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model AUTH_USER_MODEL = "accounts.CustomUser" @@ -345,12 +356,10 @@ AUTH_USER_MODEL = "accounts.CustomUser" SITE_ID = 1 PARLER_LANGUAGES = { - SITE_ID: ( - {'code': 'en'}, - ), - 'default': { - 'fallbacks': ['en'], - 'hide_untranslated': False, + SITE_ID: ({"code": "en"},), + "default": { + "fallbacks": ["en"], + "hide_untranslated": False, }, } @@ -368,13 +377,13 @@ AUTHENTICATION_BACKENDS = ( ) # https://django-allauth.readthedocs.io/en/latest/configuration.html if DISABLE_SIGNUPS: - ACCOUNT_ADAPTER = 'pkmntrade_club.accounts.adapter.NoSignupAccountAdapter' -SOCIALACCOUNT_ADAPTER = 'pkmntrade_club.accounts.adapter.NoSignupSocialAccountAdapter' # always disable social account signups + ACCOUNT_ADAPTER = "pkmntrade_club.accounts.adapter.NoSignupAccountAdapter" +SOCIALACCOUNT_ADAPTER = "pkmntrade_club.accounts.adapter.NoSignupSocialAccountAdapter" # always disable social account signups ACCOUNT_SESSION_REMEMBER = True ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = True ACCOUNT_AUTHENTICATION_METHOD = "username_email" ACCOUNT_EMAIL_REQUIRED = True -ACCOUNT_EMAIL_VERIFICATION = env('ACCOUNT_EMAIL_VERIFICATION') +ACCOUNT_EMAIL_VERIFICATION = env("ACCOUNT_EMAIL_VERIFICATION") ACCOUNT_EMAIL_NOTIFICATIONS = True ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False ACCOUNT_DEFAULT_HTTP_PROTOCOL = SCHEME @@ -395,7 +404,7 @@ SOCIALACCOUNT_ONLY = False SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" SESSION_COOKIE_HTTPONLY = True -SESSION_COOKIE_SECURE = PUBLIC_HOST != 'localhost' or PUBLIC_HOST != '127.0.0.1' +SESSION_COOKIE_SECURE = PUBLIC_HOST != "localhost" or PUBLIC_HOST != "127.0.0.1" # auto-detection doesn't work properly sometimes, so we'll just use the DEBUG setting DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG} diff --git a/src/pkmntrade_club/django_project/urls.py b/src/pkmntrade_club/django_project/urls.py index 61cac00..84a15be 100644 --- a/src/pkmntrade_club/django_project/urls.py +++ b/src/pkmntrade_club/django_project/urls.py @@ -4,11 +4,11 @@ from debug_toolbar.toolbar import debug_toolbar_urls urlpatterns = [ path("admin/", admin.site.urls), - path('account/', include('pkmntrade_club.accounts.urls')), + path("account/", include("pkmntrade_club.accounts.urls")), path("accounts/", include("allauth.urls")), path("", include("pkmntrade_club.home.urls")), path("cards/", include("pkmntrade_club.cards.urls")), - path("health/", include('health_check.urls')), + path("health/", include("health_check.urls")), path("trades/", include("pkmntrade_club.trades.urls")), path("__reload__/", include("django_browser_reload.urls")), ] + debug_toolbar_urls() diff --git a/src/pkmntrade_club/django_project/wsgi.py b/src/pkmntrade_club/django_project/wsgi.py index 4c5ab87..711707d 100644 --- a/src/pkmntrade_club/django_project/wsgi.py +++ b/src/pkmntrade_club/django_project/wsgi.py @@ -2,6 +2,8 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings") +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "pkmntrade_club.django_project.settings" +) app = get_wsgi_application() diff --git a/src/pkmntrade_club/home/tests.py b/src/pkmntrade_club/home/tests.py index 9c51303..a174be0 100644 --- a/src/pkmntrade_club/home/tests.py +++ b/src/pkmntrade_club/home/tests.py @@ -2,241 +2,221 @@ from django.test import TestCase, Client, RequestFactory from django.urls import reverse from django.contrib.auth import get_user_model from pkmntrade_club.cards.models import Card, Deck -from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard +from pkmntrade_club.trades.models import ( + TradeOffer, + TradeOfferHaveCard, + TradeOfferWantCard, +) from pkmntrade_club.accounts.models import FriendCode from pkmntrade_club.home.views import HomePageView -import json from collections import OrderedDict from unittest.mock import patch, MagicMock -from django.core.exceptions import ObjectDoesNotExist -import importlib from tests.utils.rarity import RARITY_MAPPING User = get_user_model() + class HomePageViewTests(TestCase): """Test suite for the HomePageView.""" - + @classmethod def setUpTestData(cls): """Set up data for all test methods.""" # Create a user cls.user = User.objects.create_user( - username='testuser', - email='testuser@example.com', - password='testpass123' + username="testuser", email="testuser@example.com", password="testpass123" ) - + # Create a friend code for the user cls.friend_code = FriendCode.objects.create( - user=cls.user, - friend_code='SW-1234-5678-9012', - in_game_name='TestTrainer' + user=cls.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer" ) - + # Create decks cls.deck1 = Deck.objects.create( - name='Test Deck 1', - hex_color='#FF0000', - cardset='TEST01' + name="Test Deck 1", hex_color="#FF0000", cardset="TEST01" ) - + # Create cards with different rarities cls.common_card = Card.objects.create( - name='Common Test Card', - cardset='TEST01', + name="Common Test Card", + cardset="TEST01", cardnum=1, - style='normal', - rarity_icon='โ˜…', - rarity_level=1 + style="normal", + rarity_icon="โ˜…", + rarity_level=1, ) cls.common_card.decks.add(cls.deck1) - + cls.rare_card = Card.objects.create( - name='Rare Test Card', - cardset='TEST01', + name="Rare Test Card", + cardset="TEST01", cardnum=2, - style='normal', - rarity_icon='โ˜…โ˜…โ˜…', - rarity_level=3 + style="normal", + rarity_icon="โ˜…โ˜…โ˜…", + rarity_level=3, ) cls.rare_card.decks.add(cls.deck1) - + cls.ultra_rare_card = Card.objects.create( - name='Ultra Rare Test Card', - cardset='TEST01', + name="Ultra Rare Test Card", + cardset="TEST01", cardnum=3, - style='normal', - rarity_icon='โ˜…โ˜…โ˜…โ˜…', - rarity_level=4 + style="normal", + rarity_icon="โ˜…โ˜…โ˜…โ˜…", + rarity_level=4, ) cls.ultra_rare_card.decks.add(cls.deck1) - + # Create trade offers with consistent rarities cls.common_trade = TradeOffer.objects.create( - initiated_by=cls.friend_code, - rarity_icon=RARITY_MAPPING[1], - rarity_level=1 + initiated_by=cls.friend_code, rarity_icon=RARITY_MAPPING[1], rarity_level=1 ) - + cls.rare_trade = TradeOffer.objects.create( - initiated_by=cls.friend_code, - rarity_icon=RARITY_MAPPING[3], - rarity_level=3 + initiated_by=cls.friend_code, rarity_icon=RARITY_MAPPING[3], rarity_level=3 ) - + # Add have and want cards with the SAME rarity for each trade TradeOfferHaveCard.objects.create( - trade_offer=cls.common_trade, - card=cls.common_card, - quantity=2 + trade_offer=cls.common_trade, card=cls.common_card, quantity=2 ) - + TradeOfferHaveCard.objects.create( - trade_offer=cls.rare_trade, - card=cls.rare_card, - quantity=1 + trade_offer=cls.rare_trade, card=cls.rare_card, quantity=1 ) - + # Add want cards with the SAME rarity as the have cards for each trade TradeOfferWantCard.objects.create( - trade_offer=cls.common_trade, - card=cls.common_card, - quantity=1 + trade_offer=cls.common_trade, card=cls.common_card, quantity=1 ) - + TradeOfferWantCard.objects.create( trade_offer=cls.rare_trade, card=cls.rare_card, # Changed from ultra_rare_card to match the rarity - quantity=1 + quantity=1, ) - + def setUp(self): """Set up before each test method.""" self.client = Client() - self.url = reverse('home') + self.url = reverse("home") self.factory = RequestFactory() - + def test_home_page_status_code(self): """Test that the home page returns a 200 status code.""" response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - + def test_home_page_template(self): """Test that the home page uses the correct template.""" response = self.client.get(self.url) - self.assertTemplateUsed(response, 'home/home.html') - + self.assertTemplateUsed(response, "home/home.html") + def test_home_page_context_cards(self): """Test that the home page contains all cards in the context.""" response = self.client.get(self.url) - self.assertIn('cards', response.context) - self.assertEqual(response.context['cards'].count(), 3) - + self.assertIn("cards", response.context) + self.assertEqual(response.context["cards"].count(), 3) + def test_home_page_context_recent_offers(self): """Test that the home page contains recent offers in the context.""" response = self.client.get(self.url) - self.assertIn('recent_offers', response.context) - self.assertEqual(len(response.context['recent_offers']), 2) + self.assertIn("recent_offers", response.context) + self.assertEqual(len(response.context["recent_offers"]), 2) # Recent offers should be ordered by most recent first - self.assertEqual(response.context['recent_offers'][0], self.rare_trade) - + self.assertEqual(response.context["recent_offers"][0], self.rare_trade) + def test_home_page_context_most_offered_cards(self): """Test that the home page contains most offered cards in the context.""" response = self.client.get(self.url) - self.assertIn('most_offered_cards', response.context) - most_offered = list(response.context['most_offered_cards']) + self.assertIn("most_offered_cards", response.context) + most_offered = list(response.context["most_offered_cards"]) self.assertEqual(len(most_offered), 2) # Common card should be most offered (quantity of 2) self.assertEqual(most_offered[0], self.common_card) - + def test_home_page_context_most_wanted_cards(self): """Test that the home page contains most wanted cards in the context.""" response = self.client.get(self.url) - self.assertIn('most_wanted_cards', response.context) - most_wanted = list(response.context['most_wanted_cards']) + self.assertIn("most_wanted_cards", response.context) + most_wanted = list(response.context["most_wanted_cards"]) self.assertEqual(len(most_wanted), 2) - + def test_home_page_context_least_offered_cards(self): """Test that the home page contains least offered cards in the context.""" response = self.client.get(self.url) - self.assertIn('least_offered_cards', response.context) - + self.assertIn("least_offered_cards", response.context) + def test_home_page_context_featured_offers(self): """Test that the home page contains featured offers in the context.""" response = self.client.get(self.url) - self.assertIn('featured_offers', response.context) - featured = response.context['featured_offers'] + self.assertIn("featured_offers", response.context) + featured = response.context["featured_offers"] # Should be an OrderedDict self.assertIsInstance(featured, OrderedDict) # Should contain "All" category self.assertIn("All", featured) # Should contain both rarity icons - self.assertIn('โ˜…โ˜…โ˜…', featured) - self.assertIn('โ˜…', featured) + self.assertIn("โ˜…โ˜…โ˜…", featured) + self.assertIn("โ˜…", featured) # Higher rarity should come before lower rarity keys = list(featured.keys()) # First key should be "All" self.assertEqual(keys[0], "All") # Higher rarity (โ˜…โ˜…โ˜…) should come before lower rarity (โ˜…) - self.assertIn('โ˜…โ˜…โ˜…', keys) - self.assertIn('โ˜…', keys) - self.assertTrue(keys.index('โ˜…โ˜…โ˜…') < keys.index('โ˜…')) - + self.assertIn("โ˜…โ˜…โ˜…", keys) + self.assertIn("โ˜…", keys) + self.assertTrue(keys.index("โ˜…โ˜…โ˜…") < keys.index("โ˜…")) + def test_closed_offers_not_shown(self): """Test that closed offers are not shown on the home page.""" # Close one of the trade offers self.common_trade.is_closed = True self.common_trade.save() - + response = self.client.get(self.url) - recent_offers = response.context['recent_offers'] + recent_offers = response.context["recent_offers"] # Should only show the rare trade now self.assertEqual(len(recent_offers), 1) self.assertEqual(recent_offers[0], self.rare_trade) - + def test_home_page_with_no_data(self): """Test home page rendering when there's no trade data.""" # Delete all trade offers TradeOffer.objects.all().delete() - + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) # Should have empty lists for offers - self.assertEqual(len(response.context['recent_offers']), 0) - + self.assertEqual(len(response.context["recent_offers"]), 0) + def test_home_page_with_authenticated_user(self): """Test that the home page works for authenticated users.""" - self.client.login(username='testuser', password='testpass123') + self.client.login(username="testuser", password="testpass123") response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - + def test_rarity_sorting_in_featured_offers(self): """Test that offers are sorted by rarity level in descending order.""" # Create a new ultra rare trade with consistent rarity ultra_trade = TradeOffer.objects.create( - initiated_by=self.friend_code, - rarity_icon='โ˜…โ˜…โ˜…โ˜…', - rarity_level=4 + initiated_by=self.friend_code, rarity_icon="โ˜…โ˜…โ˜…โ˜…", rarity_level=4 ) - + # Add have and want cards with the same rarity TradeOfferHaveCard.objects.create( - trade_offer=ultra_trade, - card=self.ultra_rare_card, - quantity=1 + trade_offer=ultra_trade, card=self.ultra_rare_card, quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=ultra_trade, - card=self.ultra_rare_card, - quantity=1 + trade_offer=ultra_trade, card=self.ultra_rare_card, quantity=1 ) - + response = self.client.get(self.url) - featured = response.context['featured_offers'] + featured = response.context["featured_offers"] keys = list(featured.keys()) - + # Order should be: "All", "โ˜…โ˜…โ˜…โ˜…" (level 4), "โ˜…โ˜…โ˜…" (level 3), "โ˜…" (level 1) self.assertEqual(keys[0], "All") self.assertEqual(keys[1], "โ˜…โ˜…โ˜…โ˜…") @@ -246,216 +226,202 @@ class HomePageViewTests(TestCase): class HomePageViewMockTests(TestCase): """Test suite using mocks for HomePageView.""" - + def setUp(self): self.factory = RequestFactory() self.view = HomePageView() - - @patch('trades.models.TradeOffer.objects') - @patch('cards.models.Card.objects') + + @patch("trades.models.TradeOffer.objects") + @patch("cards.models.Card.objects") def test_get_context_data_with_mocks(self, mock_card_objects, mock_offer_objects): """Test get_context_data using mocks.""" # Set up request - request = self.factory.get(reverse('home')) + request = self.factory.get(reverse("home")) self.view.request = request - + # Mock the queryset responses mock_offer_filter = MagicMock() mock_offer_objects.filter.return_value = mock_offer_filter mock_offer_filter.order_by.return_value = [] - + mock_card_filter = MagicMock() mock_card_objects.filter.return_value = mock_card_filter mock_card_objects.annotate.return_value = mock_card_filter mock_card_objects.all.return_value.order_by.return_value = [] mock_card_filter.annotate.return_value = mock_card_filter mock_card_filter.order_by.return_value = [] - + mock_offer_filter.values_list.return_value.distinct.return_value = [] - + # Call the method context = self.view.get_context_data() - + # Verify the expected context keys exist - self.assertIn('cards', context) - self.assertIn('recent_offers', context) - self.assertIn('most_offered_cards', context) - self.assertIn('most_wanted_cards', context) - self.assertIn('least_offered_cards', context) - self.assertIn('featured_offers', context) - - @patch('trades.models.TradeOffer.objects') + self.assertIn("cards", context) + self.assertIn("recent_offers", context) + self.assertIn("most_offered_cards", context) + self.assertIn("most_wanted_cards", context) + self.assertIn("least_offered_cards", context) + self.assertIn("featured_offers", context) + + @patch("trades.models.TradeOffer.objects") def test_empty_featured_offers(self, mock_offer_objects): """Test handling of empty featured offers.""" # Set up request - request = self.factory.get(reverse('home')) + request = self.factory.get(reverse("home")) self.view.request = request - + # Configure mock to return empty queryset mock_offer_filter = MagicMock() mock_offer_objects.filter.return_value = mock_offer_filter mock_offer_filter.order_by.return_value = [] mock_offer_filter.values_list.return_value.distinct.return_value = [] - + # Call the method context = self.view.get_context_data() - + # Verify the featured_offers is an OrderedDict but with just the "All" key - self.assertIsInstance(context['featured_offers'], OrderedDict) - self.assertIn("All", context['featured_offers']) - self.assertEqual(len(context['featured_offers']), 1) - - @patch('trades.models.TradeOffer.objects.filter') + self.assertIsInstance(context["featured_offers"], OrderedDict) + self.assertIn("All", context["featured_offers"]) + self.assertEqual(len(context["featured_offers"]), 1) + + @patch("trades.models.TradeOffer.objects.filter") def test_exception_handling(self, mock_filter): """Test that exceptions are handled gracefully.""" # Set up request - request = self.factory.get(reverse('home')) + request = self.factory.get(reverse("home")) self.view.request = request - + # Configure mock to raise an exception mock_filter.side_effect = Exception("Database error") - + # Call the method - should not raise an exception - with self.assertLogs(level='ERROR') as cm: + with self.assertLogs(level="ERROR") as cm: context = self.view.get_context_data() - + # Check if error was logged - self.assertIn("Unhandled error in HomePageView.get_context_data", cm.output[0]) - + self.assertIn( + "Unhandled error in HomePageView.get_context_data", cm.output[0] + ) + # Verify fallback values were set - self.assertEqual(len(context['cards']), 0) - self.assertEqual(len(context['recent_offers']), 0) - self.assertEqual(len(context['most_offered_cards']), 0) - self.assertEqual(len(context['most_wanted_cards']), 0) - self.assertEqual(len(context['least_offered_cards']), 0) - self.assertIsInstance(context['featured_offers'], OrderedDict) - self.assertEqual(len(context['featured_offers']), 1) - self.assertIn("All", context['featured_offers']) + self.assertEqual(len(context["cards"]), 0) + self.assertEqual(len(context["recent_offers"]), 0) + self.assertEqual(len(context["most_offered_cards"]), 0) + self.assertEqual(len(context["most_wanted_cards"]), 0) + self.assertEqual(len(context["least_offered_cards"]), 0) + self.assertIsInstance(context["featured_offers"], OrderedDict) + self.assertEqual(len(context["featured_offers"]), 1) + self.assertIn("All", context["featured_offers"]) + class HomePageEdgeCaseTests(TestCase): """Test edge cases for the home page.""" - + def setUp(self): self.client = Client() - self.url = reverse('home') - + self.url = reverse("home") + # Create a user self.user = User.objects.create_user( - username='testuser', - email='testuser@example.com', - password='testpass123' + username="testuser", email="testuser@example.com", password="testpass123" ) - + # Create a friend code for the user self.friend_code = FriendCode.objects.create( - user=self.user, - friend_code='SW-1234-5678-9012', - in_game_name='TestTrainer' + user=self.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer" ) - + def test_home_page_with_no_cards(self): """Test home page with no cards in the database.""" response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.context['cards']), 0) - + self.assertEqual(len(response.context["cards"]), 0) + def test_home_page_with_many_offers(self): """Test home page with many offers to verify pagination or limiting works.""" # Create a card card = Card.objects.create( - name='Test Card', - cardset='TEST01', + name="Test Card", + cardset="TEST01", cardnum=1, - style='normal', - rarity_icon='โ˜…', - rarity_level=1 + style="normal", + rarity_icon="โ˜…", + rarity_level=1, ) - + # Create 20 trade offers for i in range(20): trade = TradeOffer.objects.create( - initiated_by=self.friend_code, - rarity_icon='โ˜…', - rarity_level=1 + initiated_by=self.friend_code, rarity_icon="โ˜…", rarity_level=1 ) - + # Add have and want cards - TradeOfferHaveCard.objects.create( - trade_offer=trade, - card=card, - quantity=1 - ) - - TradeOfferWantCard.objects.create( - trade_offer=trade, - card=card, - quantity=1 - ) - + TradeOfferHaveCard.objects.create(trade_offer=trade, card=card, quantity=1) + + TradeOfferWantCard.objects.create(trade_offer=trade, card=card, quantity=1) + response = self.client.get(self.url) - + # Check that recent_offers is limited to 6 as per the view - self.assertEqual(len(response.context['recent_offers']), 6) - + self.assertEqual(len(response.context["recent_offers"]), 6) + def test_home_page_with_invalid_parameters(self): """Test home page with invalid GET parameters.""" # The view should ignore invalid parameters response = self.client.get(f"{self.url}?invalid=param&another=invalid") self.assertEqual(response.status_code, 200) - + def test_performance_with_large_dataset(self): """Test performance with a larger dataset (basic check).""" # Create a card card = Card.objects.create( - name='Performance Test Card', - cardset='PERF01', + name="Performance Test Card", + cardset="PERF01", cardnum=1, - style='normal', - rarity_icon='โ˜…', - rarity_level=1 + style="normal", + rarity_icon="โ˜…", + rarity_level=1, ) - + # Create 50 trade offers with different rarities for i in range(50): rarity_level = (i % 5) + 1 # 1-5 - rarity_icon = 'โ˜…' * rarity_level - + rarity_icon = "โ˜…" * rarity_level + trade = TradeOffer.objects.create( initiated_by=self.friend_code, rarity_icon=rarity_icon, - rarity_level=rarity_level + rarity_level=rarity_level, ) - + # Add have and want cards with the same rarity rarity_card = Card.objects.create( - name=f'Performance Test Card {i}', - cardset='PERF01', - cardnum=i+10, - style='normal', + name=f"Performance Test Card {i}", + cardset="PERF01", + cardnum=i + 10, + style="normal", rarity_icon=rarity_icon, - rarity_level=rarity_level + rarity_level=rarity_level, ) - + TradeOfferHaveCard.objects.create( - trade_offer=trade, - card=rarity_card, - quantity=1 + trade_offer=trade, card=rarity_card, quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=trade, - card=rarity_card, - quantity=1 + trade_offer=trade, card=rarity_card, quantity=1 ) - + # Basic performance test - just checking it completes without timeout import time + start = time.time() response = self.client.get(self.url) end = time.time() - + self.assertEqual(response.status_code, 200) - + # Should be reasonably fast (adjust threshold as needed) execution_time = end - start self.assertLess(execution_time, 2.0) # Should complete in under 2 seconds @@ -463,129 +429,116 @@ class HomePageEdgeCaseTests(TestCase): class TemplateRenderingTests(TestCase): """Tests focused on template rendering.""" - + @classmethod def setUpTestData(cls): # Create a user cls.user = User.objects.create_user( - username='testuser', - email='testuser@example.com', - password='testpass123' + username="testuser", email="testuser@example.com", password="testpass123" ) - + # Create a friend code for the user cls.friend_code = FriendCode.objects.create( - user=cls.user, - friend_code='SW-1234-5678-9012', - in_game_name='TestTrainer' + user=cls.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer" ) - + # Create a card cls.card = Card.objects.create( - name='Test Card', - cardset='TEST01', + name="Test Card", + cardset="TEST01", cardnum=1, - style='normal', - rarity_icon='โ˜…', - rarity_level=1 + style="normal", + rarity_icon="โ˜…", + rarity_level=1, ) - + # Create a trade offer cls.trade = TradeOffer.objects.create( - initiated_by=cls.friend_code, - rarity_icon='โ˜…', - rarity_level=1 + initiated_by=cls.friend_code, rarity_icon="โ˜…", rarity_level=1 ) - + # Add have and want cards TradeOfferHaveCard.objects.create( - trade_offer=cls.trade, - card=cls.card, - quantity=1 + trade_offer=cls.trade, card=cls.card, quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=cls.trade, - card=cls.card, - quantity=1 + trade_offer=cls.trade, card=cls.card, quantity=1 ) - + def setUp(self): self.client = Client() self.factory = RequestFactory() - + def test_template_used(self): """Test that the correct template is used.""" - response = self.client.get(reverse('home')) - self.assertTemplateUsed(response, 'home/home.html') - + response = self.client.get(reverse("home")) + self.assertTemplateUsed(response, "home/home.html") + def test_context_variables_exist(self): """Test that all expected context variables exist.""" - response = self.client.get(reverse('home')) - + response = self.client.get(reverse("home")) + # Check all required context variables expected_keys = [ - 'cards', - 'recent_offers', - 'most_offered_cards', - 'most_wanted_cards', - 'least_offered_cards', - 'featured_offers', + "cards", + "recent_offers", + "most_offered_cards", + "most_wanted_cards", + "least_offered_cards", + "featured_offers", ] - + for key in expected_keys: self.assertIn(key, response.context) - + def test_view_with_pagination_params(self): """Test that view handles pagination parameters correctly, if applicable.""" # Create additional trade offers if pagination is implemented for i in range(10): trade = TradeOffer.objects.create( - initiated_by=self.friend_code, - rarity_icon='โ˜…', - rarity_level=1 + initiated_by=self.friend_code, rarity_icon="โ˜…", rarity_level=1 ) - + # Add have and want cards TradeOfferHaveCard.objects.create( - trade_offer=trade, - card=self.card, - quantity=1 + trade_offer=trade, card=self.card, quantity=1 ) - + TradeOfferWantCard.objects.create( - trade_offer=trade, - card=self.card, - quantity=1 + trade_offer=trade, card=self.card, quantity=1 ) - + # Test with page parameter response = self.client.get(f"{reverse('home')}?page=1") self.assertEqual(response.status_code, 200) - + # Test with invalid page parameter response = self.client.get(f"{reverse('home')}?page=999") - self.assertEqual(response.status_code, 200) # Should still render with default page - + self.assertEqual( + response.status_code, 200 + ) # Should still render with default page + # Test with non-numeric page parameter response = self.client.get(f"{reverse('home')}?page=abc") self.assertEqual(response.status_code, 200) # Should handle gracefully - - @patch('home.views.HomePageView.get_context_data') + + @patch("home.views.HomePageView.get_context_data") def test_view_renders_with_missing_context(self, mock_get_context): """Test that view renders even with incomplete context data.""" # Return incomplete context - mock_get_context.return_value = {'cards': []} - + mock_get_context.return_value = {"cards": []} + # Should still render without error even with missing context variables - response = self.client.get(reverse('home')) + response = self.client.get(reverse("home")) self.assertEqual(response.status_code, 200) - + def test_compatibility_with_multiple_django_versions(self): """Ensure compatibility with different Django versions.""" import django + # Simply log the Django version - the test itself verifies the page renders # with the current version django_version = django.get_version() - response = self.client.get(reverse('home')) + response = self.client.get(reverse("home")) self.assertEqual(response.status_code, 200) diff --git a/src/pkmntrade_club/home/views.py b/src/pkmntrade_club/home/views.py index cc05847..22c66af 100644 --- a/src/pkmntrade_club/home/views.py +++ b/src/pkmntrade_club/home/views.py @@ -1,54 +1,58 @@ -from collections import defaultdict, OrderedDict +from collections import OrderedDict from django.views.generic import TemplateView -from django.urls import reverse_lazy -from django.db.models import Count, Q, Prefetch, Sum, F, IntegerField, Value, BooleanField, Case, When +from django.db.models import ( + Sum, +) from django.db.models.functions import Coalesce -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance, TradeOfferHaveCard, TradeOfferWantCard +from pkmntrade_club.trades.models import ( + TradeOffer, +) from pkmntrade_club.cards.models import Card -from django.utils.decorators import method_decorator -from django.template.response import TemplateResponse -from django.http import HttpResponseRedirect import logging -from django.views import View -from django.http import HttpResponse -import contextlib logger = logging.getLogger(__name__) + class HomePageView(TemplateView): template_name = "home/home.html" - #@silk_profile(name='Home Page') + # @silk_profile(name='Home Page') def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - + try: # Get all cards ordered by name, exclude cards with rarity level > 5 - context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level") - + context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by( + "name", "rarity_level" + ) + # Reuse base trade offer queryset for market stats base_offer_qs = TradeOffer.objects.filter(is_closed=False) - + # Recent Offers try: recent_offers_qs = base_offer_qs.order_by("-created_at")[:6] context["recent_offers"] = recent_offers_qs - context["cache_key_recent_offers"] = f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}" + context["cache_key_recent_offers"] = ( + f"recent_offers_{recent_offers_qs.values_list('pk', 'updated_at')}" + ) except Exception as e: logger.error(f"Error fetching recent offers: {str(e)}") context["recent_offers"] = [] context["cache_key_recent_offers"] = "recent_offers_error" - + # Most Offered Cards try: most_offered_cards_qs = ( - Card.objects.filter(tradeofferhavecard__isnull=False).filter(rarity_level__lte=5) + Card.objects.filter(tradeofferhavecard__isnull=False) + .filter(rarity_level__lte=5) .annotate(offer_count=Sum("tradeofferhavecard__quantity")) .order_by("-offer_count")[:6] ) context["most_offered_cards"] = most_offered_cards_qs - context["cache_key_most_offered_cards"] = f"most_offered_cards_{most_offered_cards_qs.values_list('pk', 'updated_at')}" + context["cache_key_most_offered_cards"] = ( + f"most_offered_cards_{most_offered_cards_qs.values_list('pk', 'updated_at')}" + ) except Exception as e: logger.error(f"Error fetching most offered cards: {str(e)}") context["most_offered_cards"] = [] @@ -56,26 +60,32 @@ class HomePageView(TemplateView): # Most Wanted Cards try: most_wanted_cards_qs = ( - Card.objects.filter(tradeofferwantcard__isnull=False).filter(rarity_level__lte=5) + Card.objects.filter(tradeofferwantcard__isnull=False) + .filter(rarity_level__lte=5) .annotate(offer_count=Sum("tradeofferwantcard__quantity")) .order_by("-offer_count")[:6] ) context["most_wanted_cards"] = most_wanted_cards_qs - context["cache_key_most_wanted_cards"] = f"most_wanted_cards_{most_wanted_cards_qs.values_list('pk', 'updated_at')}" + context["cache_key_most_wanted_cards"] = ( + f"most_wanted_cards_{most_wanted_cards_qs.values_list('pk', 'updated_at')}" + ) except Exception as e: logger.error(f"Error fetching most wanted cards: {str(e)}") context["most_wanted_cards"] = [] - + # Least Offered Cards try: least_offered_cards_qs = ( - Card.objects.filter(rarity_level__lte=5).annotate( + Card.objects.filter(rarity_level__lte=5) + .annotate( offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0) ) .order_by("offer_count")[:6] ) context["least_offered_cards"] = least_offered_cards_qs - context["cache_key_least_offered_cards"] = f"least_offered_cards_{least_offered_cards_qs.values_list('pk', 'updated_at')}" + context["cache_key_least_offered_cards"] = ( + f"least_offered_cards_{least_offered_cards_qs.values_list('pk', 'updated_at')}" + ) except Exception as e: logger.error(f"Error fetching least offered cards: {str(e)}") context["least_offered_cards"] = [] @@ -88,22 +98,22 @@ class HomePageView(TemplateView): except Exception as e: logger.error(f"Error fetching 'All' featured offers: {str(e)}") featured["All"] = [] - - # *** we only show All Featured Offers for now, + + # *** we only show All Featured Offers for now, # *** we will add rarity-tabbed featured offers later # try: # # Pull out distinct (rarity_level, rarity_icon) tuples # distinct_rarities = base_offer_qs.values_list("rarity_level", "rarity_icon").distinct() - + # # Prepare a list that holds tuples of (rarity_level, rarity_icon, offers) # rarity_offers = [] # for rarity_level, rarity_icon in distinct_rarities: # offers = base_offer_qs.filter(rarity_level=rarity_level).order_by("created_at")[:6] # rarity_offers.append((rarity_level, rarity_icon, offers)) - + # # Sort by rarity_level (from greatest to least) # rarity_offers.sort(key=lambda x: x[0], reverse=True) - + # # Add the sorted offers to the OrderedDict # for rarity_level, rarity_icon, offers in rarity_offers: # featured[rarity_icon] = offers @@ -114,16 +124,20 @@ class HomePageView(TemplateView): # Generate a cache key based on the pks and updated_at timestamps of all featured offers # *** we will separate cache keys for each featured section later all_offer_identifiers = [] - for section_name,section_offers in featured.items(): + for section_name, section_offers in featured.items(): # featured_section is a QuerySet. Fetch (pk, updated_at) tuples. - identifiers = section_offers.values_list('pk', 'updated_at') + identifiers = section_offers.values_list("pk", "updated_at") # Format each tuple as "pk_timestamp" and add to the list - section_strings = [f"{section_name}_{pk}_{ts.timestamp()}" for pk, ts in identifiers] + section_strings = [ + f"{section_name}_{pk}_{ts.timestamp()}" for pk, ts in identifiers + ] all_offer_identifiers.extend(section_strings) - + # Join all identifiers into a single string, sorted for consistency regardless of order combined_identifiers = "|".join(sorted(all_offer_identifiers)) - context["cache_key_featured_offers"] = f"featured_offers_{combined_identifiers}" + context["cache_key_featured_offers"] = ( + f"featured_offers_{combined_identifiers}" + ) except Exception as e: logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}") # Provide fallback empty data @@ -133,9 +147,9 @@ class HomePageView(TemplateView): context["most_wanted_cards"] = [] context["least_offered_cards"] = [] context["featured_offers"] = OrderedDict([("All", [])]) - + return context - + def get(self, request, *args, **kwargs): """Override get method to add caching""" return super().get(request, *args, **kwargs) diff --git a/src/pkmntrade_club/tests/utils/rarity.py b/src/pkmntrade_club/tests/utils/rarity.py index 24bf7dd..900eb00 100644 --- a/src/pkmntrade_club/tests/utils/rarity.py +++ b/src/pkmntrade_club/tests/utils/rarity.py @@ -6,5 +6,5 @@ RARITY_MAPPING = { 5: "โญ๏ธ", 6: "โญ๏ธโญ๏ธ", 7: "โญ๏ธโญ๏ธโญ๏ธ", - 8: "๐Ÿ‘‘" -} \ No newline at end of file + 8: "๐Ÿ‘‘", +} diff --git a/src/pkmntrade_club/theme/apps.py b/src/pkmntrade_club/theme/apps.py index 71e85a3..8f60b15 100644 --- a/src/pkmntrade_club/theme/apps.py +++ b/src/pkmntrade_club/theme/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class ThemeConfig(AppConfig): - name = 'pkmntrade_club.theme' + name = "pkmntrade_club.theme" diff --git a/src/pkmntrade_club/trades/forms.py b/src/pkmntrade_club/trades/forms.py index 69f037c..af2a3e2 100644 --- a/src/pkmntrade_club/trades/forms.py +++ b/src/pkmntrade_club/trades/forms.py @@ -1,20 +1,21 @@ from django import forms -from django.core.exceptions import ValidationError from .models import TradeOffer, TradeAcceptance from pkmntrade_club.accounts.models import FriendCode from pkmntrade_club.cards.models import Card from django.forms import ModelForm from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard + class NoValidationMultipleChoiceField(forms.MultipleChoiceField): def validate(self, value): # Override the validation to skip checking against defined choices pass + class TradeOfferAcceptForm(forms.Form): friend_code = forms.ModelChoiceField( queryset=FriendCode.objects.none(), - label="Select a Friend Code to Accept This Trade Offer" + label="Select a Friend Code to Accept This Trade Offer", ) def __init__(self, *args, **kwargs): @@ -23,6 +24,7 @@ class TradeOfferAcceptForm(forms.Form): super().__init__(*args, **kwargs) self.fields["friend_code"].queryset = friend_codes + class TradeAcceptanceCreateForm(forms.ModelForm): """ Form for creating a TradeAcceptance. @@ -32,11 +34,19 @@ class TradeAcceptanceCreateForm(forms.ModelForm): - default_friend_code (optional): the user's default FriendCode. It filters available requested and offered cards based on what's still available. """ + class Meta: model = TradeAcceptance fields = ["accepted_by", "requested_card", "offered_card"] - def __init__(self, *args, trade_offer=None, friend_codes=None, default_friend_code=None, **kwargs): + def __init__( + self, + *args, + trade_offer=None, + friend_codes=None, + default_friend_code=None, + **kwargs, + ): if trade_offer is None: raise ValueError("trade_offer must be provided to filter choices.") super().__init__(*args, **kwargs) @@ -52,16 +62,23 @@ class TradeAcceptanceCreateForm(forms.ModelForm): self.initial["accepted_by"] = friend_codes.first().pk self.fields["accepted_by"].widget = forms.HiddenInput() # Otherwise, if a default friend code is provided and it is in the queryset, preselect it. - elif default_friend_code and friend_codes.filter(pk=default_friend_code.pk).exists(): + elif ( + default_friend_code + and friend_codes.filter(pk=default_friend_code.pk).exists() + ): self.initial["accepted_by"] = default_friend_code.pk available_have_items = trade_offer.have_cards_available requested_card_pks = [item.card.pk for item in available_have_items] - self.fields["requested_card"].queryset = Card.objects.filter(pk__in=requested_card_pks).order_by('name') + self.fields["requested_card"].queryset = Card.objects.filter( + pk__in=requested_card_pks + ).order_by("name") available_want_items = trade_offer.want_cards_available offered_card_pks = [item.card.pk for item in available_want_items] - self.fields["offered_card"].queryset = Card.objects.filter(pk__in=offered_card_pks).order_by('name') + self.fields["offered_card"].queryset = Card.objects.filter( + pk__in=offered_card_pks + ).order_by("name") def clean(self): """ @@ -71,9 +88,11 @@ class TradeAcceptanceCreateForm(forms.ModelForm): self.instance.trade_offer = self.trade_offer return super().clean() + class ButtonRadioSelect(forms.RadioSelect): template_name = "widgets/button_radio_select.html" + class TradeAcceptanceTransitionForm(forms.Form): state = forms.ChoiceField(widget=forms.HiddenInput()) @@ -87,13 +106,18 @@ class TradeAcceptanceTransitionForm(forms.Form): raise ValueError("A TradeAcceptance instance must be provided") self.instance = instance self.user = user - + self.fields["state"].choices = instance.get_allowed_state_transitions(user) + class TradeOfferCreateForm(ModelForm): # Override the default fields to capture quantity info in the format 'card_id:quantity' - have_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True) - want_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True) + have_cards = NoValidationMultipleChoiceField( + widget=forms.SelectMultiple, required=True + ) + want_cards = NoValidationMultipleChoiceField( + widget=forms.SelectMultiple, required=True + ) class Meta: model = TradeOffer @@ -111,10 +135,10 @@ class TradeOfferCreateForm(ModelForm): data = self.data.getlist("have_cards") parsed = {} for item in data: - if ':' not in item: + if ":" not in item: # Ignore any input without a colon. continue - parts = item.split(':') + parts = item.split(":") card_id = parts[0] try: # Only parse quantity when a colon is present. @@ -131,16 +155,18 @@ class TradeOfferCreateForm(ModelForm): ) # Ensure no more than 20 unique have cards are selected. if len(parsed) > 20: - raise forms.ValidationError("You can only select a maximum of 20 unique have cards.") + raise forms.ValidationError( + "You can only select a maximum of 20 unique have cards." + ) return parsed def clean_want_cards(self): data = self.data.getlist("want_cards") parsed = {} for item in data: - if ':' not in item: + if ":" not in item: continue - parts = item.split(':') + parts = item.split(":") card_id = parts[0] try: quantity = int(parts[1]) @@ -157,7 +183,9 @@ class TradeOfferCreateForm(ModelForm): ) # Ensure no more than 20 unique want cards are selected. if len(parsed) > 20: - raise forms.ValidationError("You can only select a maximum of 20 unique want cards.") + raise forms.ValidationError( + "You can only select a maximum of 20 unique want cards." + ) return parsed def save(self, commit=True): @@ -167,15 +195,19 @@ class TradeOfferCreateForm(ModelForm): # Clear any existing through model entries in case of update TradeOfferHaveCard.objects.filter(trade_offer=instance).delete() TradeOfferWantCard.objects.filter(trade_offer=instance).delete() - + # Create through entries for have_cards for card_id, quantity in self.cleaned_data["have_cards"].items(): card = Card.objects.get(pk=card_id) - TradeOfferHaveCard.objects.create(trade_offer=instance, card=card, quantity=quantity) + TradeOfferHaveCard.objects.create( + trade_offer=instance, card=card, quantity=quantity + ) # Create through entries for want_cards for card_id, quantity in self.cleaned_data["want_cards"].items(): card = Card.objects.get(pk=card_id) - TradeOfferWantCard.objects.create(trade_offer=instance, card=card, quantity=quantity) + TradeOfferWantCard.objects.create( + trade_offer=instance, card=card, quantity=quantity + ) return instance diff --git a/src/pkmntrade_club/trades/mixins.py b/src/pkmntrade_club/trades/mixins.py index 36d4272..3e5caf9 100644 --- a/src/pkmntrade_club/trades/mixins.py +++ b/src/pkmntrade_club/trades/mixins.py @@ -1,37 +1,49 @@ from pkmntrade_club.cards.models import Card from django.core.exceptions import PermissionDenied + class TradeOfferContextMixin: def get_context_data(self, **kwargs): # Start with any context passed in. context = kwargs.copy() # Include available cards requirements for multiselect fields. context.setdefault("cards", Card.objects.all().order_by("name", "rarity_level")) - + # Provide friend_codes and selected_friend_code as in TradeOfferCreateView friend_codes = self.request.user.friend_codes.all() context["friend_codes"] = friend_codes - + if "initiated_by" in self.request.GET: try: - selected_friend_code = friend_codes.get(pk=self.request.GET.get("initiated_by")) + selected_friend_code = friend_codes.get( + pk=self.request.GET.get("initiated_by") + ) except friend_codes.model.DoesNotExist: - selected_friend_code = self.request.user.default_friend_code or friend_codes.first() + selected_friend_code = ( + self.request.user.default_friend_code or friend_codes.first() + ) else: - selected_friend_code = self.request.user.default_friend_code or friend_codes.first() + selected_friend_code = ( + self.request.user.default_friend_code or friend_codes.first() + ) context["selected_friend_code"] = selected_friend_code - - return context + + return context + class FriendCodeRequiredMixin: """ Mixin to ensure the authenticated user has at least one friend code. This mixin must be placed after LoginRequiredMixin in the view's inheritance order. """ + def dispatch(self, request, *args, **kwargs): # Since LoginRequiredMixin guarantees that request.user is authenticated, # we assume request.user has the attribute `friend_codes`. If no friend code exists, # raise a PermissionDenied error. - if not getattr(request.user, 'friend_codes', None) or not request.user.friend_codes.exists(): + if ( + not getattr(request.user, "friend_codes", None) + or not request.user.friend_codes.exists() + ): raise PermissionDenied("No friend codes available for your account.") - return super().dispatch(request, *args, **kwargs) \ No newline at end of file + return super().dispatch(request, *args, **kwargs) diff --git a/src/pkmntrade_club/trades/models.py b/src/pkmntrade_club/trades/models.py index 1e50eff..a78d451 100644 --- a/src/pkmntrade_club/trades/models.py +++ b/src/pkmntrade_club/trades/models.py @@ -1,13 +1,10 @@ from django.db import models from django.core.exceptions import ValidationError -from django.db.models import Q, Count, Prefetch, F, Sum, Max +from django.db.models import Prefetch import hashlib -from pkmntrade_club.cards.models import Card -from pkmntrade_club.accounts.models import FriendCode -from datetime import timedelta -from django.utils import timezone import uuid + def generate_tradeoffer_hash(): """ Generates a unique 9-character hash for a TradeOffer. @@ -15,6 +12,7 @@ def generate_tradeoffer_hash(): """ return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "z" + def generate_tradeacceptance_hash(): """ Generates a unique 9-character hash for a TradeAcceptance. @@ -22,34 +20,36 @@ def generate_tradeacceptance_hash(): """ return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "y" -class TradeOfferManager(models.Manager): +class TradeOfferManager(models.Manager): def get_queryset(self): qs = super().get_queryset() # Prefetch for have_cards (through model: TradeOfferHaveCard) # Ensures 'card' is select_related and 'Meta.ordering' is respected/applied. prefetch_have_cards = Prefetch( - 'trade_offer_have_cards', - queryset=TradeOfferHaveCard.objects.select_related('card').order_by('card__name') + "trade_offer_have_cards", + queryset=TradeOfferHaveCard.objects.select_related("card").order_by( + "card__name" + ), ) # Prefetch for want_cards (through model: TradeOfferWantCard) # Ensures 'card' is select_related and 'Meta.ordering' is respected/applied. prefetch_want_cards = Prefetch( - 'trade_offer_want_cards', - queryset=TradeOfferWantCard.objects.select_related('card').order_by('card__name') + "trade_offer_want_cards", + queryset=TradeOfferWantCard.objects.select_related("card").order_by( + "card__name" + ), ) # Prefetch for acceptances # Ensures related 'accepted_by__user', 'requested_card', 'offered_card' are fetched. prefetch_acceptances = Prefetch( - 'acceptances', + "acceptances", queryset=TradeAcceptance.objects.select_related( - 'accepted_by__user', - 'requested_card', - 'offered_card' - ).order_by('-created_at') # Sensible default ordering for acceptances + "accepted_by__user", "requested_card", "offered_card" + ).order_by("-created_at"), # Sensible default ordering for acceptances ) qs = qs.select_related( @@ -60,11 +60,12 @@ class TradeOfferManager(models.Manager): prefetch_acceptances, # If direct access like offer.have_cards.all() (the M2M to Card, not through model) # is heavily used AND causes N+1s (e.g. via __str__), uncomment these: - Prefetch('have_cards'), - Prefetch('want_cards'), + Prefetch("have_cards"), + Prefetch("want_cards"), ) - - return qs.order_by("-updated_at") # Default ordering for TradeOffer querysets + + return qs.order_by("-updated_at") # Default ordering for TradeOffer querysets + class TradeOffer(models.Model): objects = TradeOfferManager() @@ -75,20 +76,16 @@ class TradeOffer(models.Model): initiated_by = models.ForeignKey( "accounts.FriendCode", on_delete=models.PROTECT, - related_name='initiated_trade_offers' + related_name="initiated_trade_offers", ) rarity_icon = models.CharField(max_length=8, null=True) rarity_level = models.IntegerField(null=True) - image = models.ImageField(upload_to='trade_offers/', null=True, blank=True) + image = models.ImageField(upload_to="trade_offers/", null=True, blank=True) want_cards = models.ManyToManyField( - "cards.Card", - related_name='trade_offers_want', - through="TradeOfferWantCard" + "cards.Card", related_name="trade_offers_want", through="TradeOfferWantCard" ) have_cards = models.ManyToManyField( - "cards.Card", - related_name='trade_offers_have', - through="TradeOfferHaveCard" + "cards.Card", related_name="trade_offers_have", through="TradeOfferHaveCard" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -105,7 +102,7 @@ class TradeOffer(models.Model): def update_rarity_fields(self): """ - Recalculates and updates the rarity_level and rarity_icon fields based on + Recalculates and updates the rarity_level and rarity_icon fields based on the associated have_cards and want_cards. Enforces that all cards in the trade offer share the same rarity. @@ -118,11 +115,16 @@ class TradeOffer(models.Model): # Enforce same rarity across all cards. rarity_levels = {card.rarity_level for card in cards} if len(rarity_levels) > 1: - raise ValidationError("All cards in a trade offer must have the same rarity.") + raise ValidationError( + "All cards in a trade offer must have the same rarity." + ) first_card = cards[0] if first_card.rarity_level > 5: raise ValidationError("Cannot trade cards above one-star rarity.") - if self.rarity_level != first_card.rarity_level or self.rarity_icon != first_card.rarity_icon: + if ( + self.rarity_level != first_card.rarity_level + or self.rarity_icon != first_card.rarity_icon + ): self.rarity_level = first_card.rarity_level self.rarity_icon = first_card.rarity_icon # Use super().save() here to avoid recursion. @@ -131,23 +133,33 @@ class TradeOffer(models.Model): @property def have_cards_available(self): # Returns the list of have_cards (through objects) that still have available quantity. - return [item for item in self.trade_offer_have_cards.all() if item.quantity > item.qty_accepted] + return [ + item + for item in self.trade_offer_have_cards.all() + if item.quantity > item.qty_accepted + ] @property def want_cards_available(self): # Returns the list of want_cards (through objects) that still have available quantity. - return [item for item in self.trade_offer_want_cards.all() if item.quantity > item.qty_accepted] + return [ + item + for item in self.trade_offer_want_cards.all() + if item.quantity > item.qty_accepted + ] + class TradeOfferHaveCard(models.Model): """ Through model for TradeOffer.have_cards. Represents the card the initiator is offering along with the quantity available. """ + trade_offer = models.ForeignKey( TradeOffer, on_delete=models.CASCADE, - related_name='trade_offer_have_cards', - db_index=True + related_name="trade_offer_have_cards", + db_index=True, ) card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True) quantity = models.PositiveIntegerField(default=1) @@ -171,17 +183,17 @@ class TradeOfferHaveCard(models.Model): class Meta: unique_together = ("trade_offer", "card") - ordering = ['card__name'] + ordering = ["card__name"] + class TradeOfferWantCard(models.Model): """ Through model for TradeOffer.want_cards. Represents the card the initiator is requesting along with the quantity requested. """ + trade_offer = models.ForeignKey( - TradeOffer, - on_delete=models.CASCADE, - related_name='trade_offer_want_cards' + TradeOffer, on_delete=models.CASCADE, related_name="trade_offer_want_cards" ) card = models.ForeignKey("cards.Card", on_delete=models.PROTECT) quantity = models.PositiveIntegerField(default=1) @@ -205,19 +217,20 @@ class TradeOfferWantCard(models.Model): class Meta: unique_together = ("trade_offer", "card") - ordering = ['card__name'] + ordering = ["card__name"] + class TradeAcceptance(models.Model): class AcceptanceState(models.TextChoices): - ACCEPTED = 'ACCEPTED', 'Accepted' - SENT = 'SENT', 'Sent' - RECEIVED = 'RECEIVED', 'Received' - THANKED_BY_INITIATOR = 'THANKED_BY_INITIATOR', 'Thanked by Initiator' - THANKED_BY_ACCEPTOR = 'THANKED_BY_ACCEPTOR', 'Thanked by Acceptor' - THANKED_BY_BOTH = 'THANKED_BY_BOTH', 'Thanked by Both' - REJECTED_BY_INITIATOR = 'REJECTED_BY_INITIATOR', 'Rejected by Initiator' - REJECTED_BY_ACCEPTOR = 'REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor' - + ACCEPTED = "ACCEPTED", "Accepted" + SENT = "SENT", "Sent" + RECEIVED = "RECEIVED", "Received" + THANKED_BY_INITIATOR = "THANKED_BY_INITIATOR", "Thanked by Initiator" + THANKED_BY_ACCEPTOR = "THANKED_BY_ACCEPTOR", "Thanked by Acceptor" + THANKED_BY_BOTH = "THANKED_BY_BOTH", "Thanked by Both" + REJECTED_BY_INITIATOR = "REJECTED_BY_INITIATOR", "Rejected by Initiator" + REJECTED_BY_ACCEPTOR = "REJECTED_BY_ACCEPTOR", "Rejected by Acceptor" + # DRY improvement: define active states once as a class-level constant. POSITIVE_STATES = [ AcceptanceState.ACCEPTED, @@ -229,30 +242,21 @@ class TradeAcceptance(models.Model): ] trade_offer = models.ForeignKey( - TradeOffer, - on_delete=models.CASCADE, - related_name='acceptances', - db_index=True + TradeOffer, on_delete=models.CASCADE, related_name="acceptances", db_index=True ) accepted_by = models.ForeignKey( "accounts.FriendCode", on_delete=models.PROTECT, - related_name='trade_acceptances' + related_name="trade_acceptances", ) requested_card = models.ForeignKey( - "cards.Card", - on_delete=models.PROTECT, - related_name='accepted_requested' + "cards.Card", on_delete=models.PROTECT, related_name="accepted_requested" ) offered_card = models.ForeignKey( - "cards.Card", - on_delete=models.PROTECT, - related_name='accepted_offered' + "cards.Card", on_delete=models.PROTECT, related_name="accepted_offered" ) state = models.CharField( - max_length=25, - choices=AcceptanceState.choices, - default=AcceptanceState.ACCEPTED + max_length=25, choices=AcceptanceState.choices, default=AcceptanceState.ACCEPTED ) hash = models.CharField(max_length=9, editable=False, blank=True) created_at = models.DateTimeField(auto_now_add=True) @@ -307,11 +311,14 @@ class TradeAcceptance(models.Model): return self.get_action_label_for_state(self.AcceptanceState.SENT) elif self.state == self.AcceptanceState.SENT: return self.get_action_label_for_state(self.AcceptanceState.RECEIVED) - elif self.state == self.AcceptanceState.RECEIVED or self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR or self.state == self.AcceptanceState.THANKED_BY_INITIATOR: + elif ( + self.state == self.AcceptanceState.RECEIVED + or self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR + or self.state == self.AcceptanceState.THANKED_BY_INITIATOR + ): return self.get_action_label_for_state(self.AcceptanceState.THANKED_BY_BOTH) else: return None - @classmethod def get_action_label_for_state_2(cls, state_value): @@ -331,12 +338,20 @@ class TradeAcceptance(models.Model): @property def is_initiator_state(self): - return self.state in [self.AcceptanceState.SENT.value, self.AcceptanceState.THANKED_BY_INITIATOR.value, self.AcceptanceState.THANKED_BY_BOTH.value] - + return self.state in [ + self.AcceptanceState.SENT.value, + self.AcceptanceState.THANKED_BY_INITIATOR.value, + self.AcceptanceState.THANKED_BY_BOTH.value, + ] + @property def is_acceptor_state(self): - return self.state in [self.AcceptanceState.ACCEPTED.value, self.AcceptanceState.RECEIVED.value, self.AcceptanceState.THANKED_BY_ACCEPTOR.value, self.AcceptanceState.THANKED_BY_BOTH.value] - + return self.state in [ + self.AcceptanceState.ACCEPTED.value, + self.AcceptanceState.RECEIVED.value, + self.AcceptanceState.THANKED_BY_ACCEPTOR.value, + self.AcceptanceState.THANKED_BY_BOTH.value, + ] @property def is_completed(self): @@ -368,19 +383,30 @@ class TradeAcceptance(models.Model): def clean(self): from django.core.exceptions import ValidationError + try: - have_card = self.trade_offer.trade_offer_have_cards.get(card_id=self.requested_card_id) + have_card = self.trade_offer.trade_offer_have_cards.get( + card_id=self.requested_card_id + ) except TradeOfferHaveCard.DoesNotExist: - raise ValidationError("The requested card must be one of the trade offer's available cards (have_cards).") + raise ValidationError( + "The requested card must be one of the trade offer's available cards (have_cards)." + ) try: - want_card = self.trade_offer.trade_offer_want_cards.get(card_id=self.offered_card_id) + want_card = self.trade_offer.trade_offer_want_cards.get( + card_id=self.offered_card_id + ) except TradeOfferWantCard.DoesNotExist: - raise ValidationError("The offered card must be one of the trade offer's requested cards (want_cards).") + raise ValidationError( + "The offered card must be one of the trade offer's requested cards (want_cards)." + ) # Only perform these validations on creation (when self.pk is None). if self.pk is None: if self.trade_offer.is_closed: - raise ValidationError("This trade offer is closed. No more acceptances are allowed.") + raise ValidationError( + "This trade offer is closed. No more acceptances are allowed." + ) # Use direct comparison with qty_accepted and quantity. if have_card.qty_accepted >= have_card.quantity: raise ValidationError("The requested card has no available quantity.") @@ -403,26 +429,42 @@ class TradeAcceptance(models.Model): ]: return 0 else: - return next(index for index, choice in enumerate(self.AcceptanceState.choices) if choice[0] == self.state) + 1 + return ( + next( + index + for index, choice in enumerate(self.AcceptanceState.choices) + if choice[0] == self.state + ) + + 1 + ) def update_state(self, new_state, user): if new_state not in [choice[0] for choice in self.AcceptanceState.choices]: raise ValueError(f"'{new_state}' is not a valid state.") - if (new_state == self.AcceptanceState.THANKED_BY_ACCEPTOR and self.state == self.AcceptanceState.THANKED_BY_INITIATOR) or \ - (new_state == self.AcceptanceState.THANKED_BY_INITIATOR and self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR): + if ( + new_state == self.AcceptanceState.THANKED_BY_ACCEPTOR + and self.state == self.AcceptanceState.THANKED_BY_INITIATOR + ) or ( + new_state == self.AcceptanceState.THANKED_BY_INITIATOR + and self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR + ): new_state = self.AcceptanceState.THANKED_BY_BOTH if self.state in [ self.AcceptanceState.THANKED_BY_BOTH, self.AcceptanceState.REJECTED_BY_INITIATOR, - self.AcceptanceState.REJECTED_BY_ACCEPTOR + self.AcceptanceState.REJECTED_BY_ACCEPTOR, ]: - raise ValueError(f"No transitions allowed from the terminal state '{self.state}'.") + raise ValueError( + f"No transitions allowed from the terminal state '{self.state}'." + ) allowed = [x for x, y in self.get_allowed_state_transitions(user)] if new_state not in allowed: - raise ValueError(f"Transition from {self.state} to {new_state} is not allowed.") + raise ValueError( + f"Transition from {self.state} to {new_state} is not allowed." + ) self._actioning_user = user self.state = new_state @@ -434,10 +476,12 @@ class TradeAcceptance(models.Model): super().save(*args, **kwargs) def __str__(self): - return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, " - f"accepted_by={self.accepted_by}, " - f"requested_card={self.requested_card}, " - f"offered_card={self.offered_card}, state={self.state})") + return ( + f"TradeAcceptance(offer_hash={self.trade_offer.hash}, " + f"accepted_by={self.accepted_by}, " + f"requested_card={self.requested_card}, " + f"offered_card={self.offered_card}, state={self.state})" + ) def get_allowed_state_transitions(self, user): if self.trade_offer.initiated_by in user.friend_codes.all(): @@ -453,7 +497,7 @@ class TradeAcceptance(models.Model): self.AcceptanceState.THANKED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_INITIATOR, }, - self.AcceptanceState.THANKED_BY_INITIATOR: { }, + self.AcceptanceState.THANKED_BY_INITIATOR: {}, self.AcceptanceState.THANKED_BY_ACCEPTOR: { self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.THANKED_BY_BOTH, @@ -469,10 +513,10 @@ class TradeAcceptance(models.Model): self.AcceptanceState.REJECTED_BY_ACCEPTOR, }, self.AcceptanceState.RECEIVED: { - self.AcceptanceState.THANKED_BY_ACCEPTOR, #allow early thanks (uses THANKED_BY_ACCEPTOR state) - self.AcceptanceState.REJECTED_BY_ACCEPTOR + self.AcceptanceState.THANKED_BY_ACCEPTOR, # allow early thanks (uses THANKED_BY_ACCEPTOR state) + self.AcceptanceState.REJECTED_BY_ACCEPTOR, }, - self.AcceptanceState.THANKED_BY_ACCEPTOR: { }, + self.AcceptanceState.THANKED_BY_ACCEPTOR: {}, self.AcceptanceState.THANKED_BY_INITIATOR: { self.AcceptanceState.THANKED_BY_BOTH, }, diff --git a/src/pkmntrade_club/trades/signals.py b/src/pkmntrade_club/trades/signals.py index 37caddb..e11c6e3 100644 --- a/src/pkmntrade_club/trades/signals.py +++ b/src/pkmntrade_club/trades/signals.py @@ -1,19 +1,15 @@ from django.db.models.signals import post_save, post_delete, pre_save from django.dispatch import receiver from django.db.models import F -from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance, TradeOffer -from django.db import transaction +from pkmntrade_club.trades.models import ( + TradeOfferHaveCard, + TradeOfferWantCard, + TradeAcceptance, +) from pkmntrade_club.accounts.models import CustomUser -from datetime import timedelta -from django.utils import timezone -import uuid -import hashlib from django.core.mail import send_mail -from django.conf import settings from django.template.loader import render_to_string from django.contrib.sites.models import Site -from django.core.cache import cache -import logging POSITIVE_STATES = [ TradeAcceptance.AcceptanceState.ACCEPTED, @@ -24,20 +20,20 @@ POSITIVE_STATES = [ TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, ] + def adjust_qty_for_trade_offer(trade_offer, card, side, delta): """ Increment (or decrement) qty_accepted by delta for the given card on the specified side. """ - if side == 'have': - TradeOfferHaveCard.objects.filter( - trade_offer=trade_offer, - card=card - ).update(qty_accepted=F('qty_accepted') + delta) - elif side == 'want': - TradeOfferWantCard.objects.filter( - trade_offer=trade_offer, - card=card - ).update(qty_accepted=F('qty_accepted') + delta) + if side == "have": + TradeOfferHaveCard.objects.filter(trade_offer=trade_offer, card=card).update( + qty_accepted=F("qty_accepted") + delta + ) + elif side == "want": + TradeOfferWantCard.objects.filter(trade_offer=trade_offer, card=card).update( + qty_accepted=F("qty_accepted") + delta + ) + def update_trade_offer_closed_status(trade_offer): """ @@ -46,18 +42,17 @@ def update_trade_offer_closed_status(trade_offer): greater than or equal to quantity; otherwise, mark it as open. """ have_complete = not TradeOfferHaveCard.objects.filter( - trade_offer=trade_offer, - qty_accepted__lt=F('quantity') + trade_offer=trade_offer, qty_accepted__lt=F("quantity") ).exists() want_complete = not TradeOfferWantCard.objects.filter( - trade_offer=trade_offer, - qty_accepted__lt=F('quantity') + trade_offer=trade_offer, qty_accepted__lt=F("quantity") ).exists() closed = have_complete or want_complete if trade_offer.is_closed != closed: trade_offer.is_closed = closed trade_offer.save(update_fields=["is_closed"]) + @receiver(pre_save, sender=TradeAcceptance) def trade_acceptance_pre_save(sender, instance, **kwargs): # Skip signal processing during raw fixture load or when saving a new instance @@ -68,6 +63,7 @@ def trade_acceptance_pre_save(sender, instance, **kwargs): old_instance = TradeAcceptance.objects.get(pk=instance.pk) instance._old_state = old_instance.state + @receiver(post_save, sender=TradeAcceptance) def trade_acceptance_post_save(sender, instance, created, **kwargs): delta = 0 @@ -75,7 +71,7 @@ def trade_acceptance_post_save(sender, instance, created, **kwargs): if instance.state in POSITIVE_STATES: delta = 1 else: - old_state = getattr(instance, '_old_state', None) + old_state = getattr(instance, "_old_state", None) if old_state is not None: if old_state in POSITIVE_STATES and instance.state not in POSITIVE_STATES: delta = -1 @@ -84,19 +80,29 @@ def trade_acceptance_post_save(sender, instance, created, **kwargs): if delta != 0: trade_offer = instance.trade_offer - adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta) - adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta) + adjust_qty_for_trade_offer( + trade_offer, instance.requested_card, side="have", delta=delta + ) + adjust_qty_for_trade_offer( + trade_offer, instance.offered_card, side="want", delta=delta + ) update_trade_offer_closed_status(trade_offer) + @receiver(post_delete, sender=TradeAcceptance) def trade_acceptance_post_delete(sender, instance, **kwargs): if instance.state in POSITIVE_STATES: delta = -1 trade_offer = instance.trade_offer - adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta) - adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta) + adjust_qty_for_trade_offer( + trade_offer, instance.requested_card, side="have", delta=delta + ) + adjust_qty_for_trade_offer( + trade_offer, instance.offered_card, side="want", delta=delta + ) update_trade_offer_closed_status(trade_offer) + @receiver(post_save, sender=TradeAcceptance) def trade_acceptance_email_notification(sender, instance, created, **kwargs): # Only proceed if the update was triggered by an acting user. @@ -132,7 +138,6 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs): else: return - # Determine the non-acting party: if instance.trade_offer.initiated_by.user.pk == acting_user.pk: # The initiator made the change; notify the acceptor. @@ -153,17 +158,31 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs): "want_card": instance.offered_card, "hash": instance.hash, "acting_user": acting_user.username, - "acting_user_ign": instance.trade_offer.initiated_by.in_game_name if is_initiator else instance.accepted_by.in_game_name, + "acting_user_ign": ( + instance.trade_offer.initiated_by.in_game_name + if is_initiator + else instance.accepted_by.in_game_name + ), "recipient_user": recipient_user.username, - "recipient_user_ign": instance.accepted_by.in_game_name if is_initiator else instance.trade_offer.initiated_by.in_game_name, - "acting_user_friend_code": instance.trade_offer.initiated_by.friend_code if is_initiator else instance.accepted_by.friend_code, + "recipient_user_ign": ( + instance.accepted_by.in_game_name + if is_initiator + else instance.trade_offer.initiated_by.in_game_name + ), + "acting_user_friend_code": ( + instance.trade_offer.initiated_by.friend_code + if is_initiator + else instance.accepted_by.friend_code + ), "is_initiator": is_initiator, "domain": "https://" + Site.objects.get_current().domain, "pk": instance.pk, } email_template = "email/trades/trade_update_" + state + ".txt" email_subject = render_to_string("email/common/subject.txt", email_context) - email_subject += render_to_string("email/trades/trade_update_" + state + "_subject.txt", email_context) + email_subject += render_to_string( + "email/trades/trade_update_" + state + "_subject.txt", email_context + ) email_body = render_to_string(email_template, email_context) send_mail( @@ -173,17 +192,18 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs): [recipient_user.email], ) + @receiver(post_save, sender=TradeAcceptance) def trade_acceptance_reputation_update(sender, instance, created, **kwargs): """ - Update the denormalized reputation score on the user model based on + Update the denormalized reputation score on the user model based on state transitions for TradeAcceptance. - + - THANKED_BY_BOTH: both the initiator and the acceptor receive +1 when transitioning into this state, and -1 when leaving it. - REJECTED_BY_INITIATOR: only the acceptor gets -1 when transitioning into it (and +1 when leaving it). - REJECTED_BY_ACCEPTOR: only the initiator gets -1 when transitioning into it (and +1 when leaving it). - + Creation events are ignored because trade acceptances are never created with a terminal state. """ if created: @@ -191,28 +211,46 @@ def trade_acceptance_reputation_update(sender, instance, created, **kwargs): thanks_delta = 0 rejection_delta_initiator = 0 # Delta for the initiator's reputation - rejection_delta_acceptor = 0 # Delta for the acceptor's reputation + rejection_delta_acceptor = 0 # Delta for the acceptor's reputation - old_state = getattr(instance, '_old_state', None) + old_state = getattr(instance, "_old_state", None) if old_state is None: return # Handle THANKED_BY_BOTH transitions - if old_state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH and instance.state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH: + if ( + old_state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH + and instance.state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH + ): thanks_delta = 1 - elif old_state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH and instance.state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH: + elif ( + old_state == TradeAcceptance.AcceptanceState.THANKED_BY_BOTH + and instance.state != TradeAcceptance.AcceptanceState.THANKED_BY_BOTH + ): thanks_delta = -1 # Handle REJECTED_BY_INITIATOR transitions (affects the acceptor) - if old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR: + if ( + old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR + and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR + ): rejection_delta_acceptor = -1 - elif old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR: + elif ( + old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR + and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR + ): rejection_delta_acceptor = 1 # Handle REJECTED_BY_ACCEPTOR transitions (affects the initiator) - if old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR: + if ( + old_state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR + and instance.state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR + ): rejection_delta_initiator = -1 - elif old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR: + elif ( + old_state == TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR + and instance.state != TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR + ): rejection_delta_initiator = 1 # Apply reputation updates: @@ -237,12 +275,13 @@ def trade_acceptance_reputation_update(sender, instance, created, **kwargs): reputation_score=F("reputation_score") + rejection_delta_initiator ) + @receiver(post_delete, sender=TradeAcceptance) def trade_acceptance_reputation_delete(sender, instance, **kwargs): """ When a TradeAcceptance is deleted, adjust the reputation score for the affected user(s) by reversing any reputation changes previously applied. - + - If the deleted instance was in THANKED_BY_BOTH: subtract 1 from both parties. - If it was in REJECTED_BY_INITIATOR: add 1 to the acceptor. - If it was in REJECTED_BY_ACCEPTOR: add 1 to the initiator. @@ -263,6 +302,7 @@ def trade_acceptance_reputation_delete(sender, instance, **kwargs): reputation_score=F("reputation_score") + 1 ) + @receiver(post_save, sender=TradeOfferHaveCard) @receiver(post_delete, sender=TradeOfferHaveCard) @receiver(post_save, sender=TradeOfferWantCard) @@ -274,9 +314,11 @@ def bubble_up_trade_offer_updates(sender, instance, **kwargs): Bubble up updated_at to the TradeOffer model when related instances change. Also invalidates any cached image by deleting the file. """ - trade_offer = getattr(instance, 'trade_offer', None) + trade_offer = getattr(instance, "trade_offer", None) if trade_offer and trade_offer.image: - trade_offer.image.delete(save=True) # deleting the image will trigger a save, which updates the updated_at field + trade_offer.image.delete( + save=True + ) # deleting the image will trigger a save, which updates the updated_at field elif trade_offer: - trade_offer.save(update_fields=['updated_at']) \ No newline at end of file + trade_offer.save(update_fields=["updated_at"]) diff --git a/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py b/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py index 2978445..56d92e1 100644 --- a/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py +++ b/src/pkmntrade_club/trades/templatetags/trade_offer_tags.py @@ -1,9 +1,10 @@ from django import template from math import ceil -from pkmntrade_club.trades.models import TradeAcceptance + register = template.Library() -@register.inclusion_tag('templatetags/trade_offer.html', takes_context=True) + +@register.inclusion_tag("templatetags/trade_offer.html", takes_context=True) def render_trade_offer(context, offer): """ Renders a trade offer including detailed trade acceptance information. @@ -15,14 +16,11 @@ def render_trade_offer(context, offer): trade_offer_want_cards = list(offer.trade_offer_want_cards.all()) acceptances = list(offer.acceptances.all()) - have_cards_available = [ - card for card in trade_offer_have_cards - if card.quantity > card.qty_accepted + card for card in trade_offer_have_cards if card.quantity > card.qty_accepted ] want_cards_available = [ - card for card in trade_offer_want_cards - if card.quantity > card.qty_accepted + card for card in trade_offer_want_cards if card.quantity > card.qty_accepted ] if not have_cards_available or not want_cards_available: @@ -31,37 +29,41 @@ def render_trade_offer(context, offer): flipped = False tag_context = { - 'offer_pk': offer.pk, - 'flipped': flipped, - 'offer_hash': offer.hash, - 'rarity_icon': offer.rarity_icon, - 'initiated_by_email': offer.initiated_by.user.email, - 'initiated_by_username': offer.initiated_by.user.username, - 'initiated_reputation': offer.initiated_by.user.reputation_score, - 'acceptances': acceptances, - 'have_cards_available': have_cards_available, - 'want_cards_available': want_cards_available, - 'num_cards_available': len(have_cards_available) + len(want_cards_available), - 'on_detail_page': context.get("request").path.endswith("trades/"+str(offer.pk)+"/"), - 'cache_key': f'trade_offer_{offer.pk}_{offer.updated_at.timestamp()}_{flipped}', + "offer_pk": offer.pk, + "flipped": flipped, + "offer_hash": offer.hash, + "rarity_icon": offer.rarity_icon, + "initiated_by_email": offer.initiated_by.user.email, + "initiated_by_username": offer.initiated_by.user.username, + "initiated_reputation": offer.initiated_by.user.reputation_score, + "acceptances": acceptances, + "have_cards_available": have_cards_available, + "want_cards_available": want_cards_available, + "num_cards_available": len(have_cards_available) + len(want_cards_available), + "on_detail_page": context.get("request").path.endswith( + "trades/" + str(offer.pk) + "/" + ), + "cache_key": f"trade_offer_{offer.pk}_{offer.updated_at.timestamp()}_{flipped}", } context.update(tag_context) return context -@register.inclusion_tag('templatetags/trade_acceptance.html', takes_context=True) + +@register.inclusion_tag("templatetags/trade_acceptance.html", takes_context=True) def render_trade_acceptance(context, acceptance): """ Renders a simple trade acceptance view with a single row and simplified header/footer. """ tag_context = { "acceptance": acceptance, - 'cache_key': f'trade_acceptance_{acceptance.pk}_{acceptance.updated_at.timestamp()}', + "cache_key": f"trade_acceptance_{acceptance.pk}_{acceptance.updated_at.timestamp()}", } context.update(tag_context) return context + @register.filter def get_action_label(acceptance, state_value): """ @@ -69,25 +71,27 @@ def get_action_label(acceptance, state_value): """ return acceptance.get_action_label_for_state(state_value) + @register.filter def action_button_class(state_value): """ Returns daisyUI button classes based on the provided state value. """ mapping = { - 'ACCEPTED': 'btn btn-primary', - 'SENT': 'btn btn-info', - 'RECEIVED': 'btn btn-info', - 'THANKED_BY_INITIATOR': 'btn btn-success', - 'THANKED_BY_ACCEPTOR': 'btn btn-success', - 'THANKED_BY_BOTH': 'btn btn-success', - 'REJECTED_BY_INITIATOR': 'btn btn-error', - 'REJECTED_BY_ACCEPTOR': 'btn btn-error', + "ACCEPTED": "btn btn-primary", + "SENT": "btn btn-info", + "RECEIVED": "btn btn-info", + "THANKED_BY_INITIATOR": "btn btn-success", + "THANKED_BY_ACCEPTOR": "btn btn-success", + "THANKED_BY_BOTH": "btn btn-success", + "REJECTED_BY_INITIATOR": "btn btn-error", + "REJECTED_BY_ACCEPTOR": "btn btn-error", } # Return a default style if the state isn't in the mapping. - return mapping.get(state_value, 'btn btn-outline') + return mapping.get(state_value, "btn btn-outline") -@register.inclusion_tag('templatetags/trade_offer_png.html', takes_context=True) + +@register.inclusion_tag("templatetags/trade_offer_png.html", takes_context=True) def render_trade_offer_png(context, offer, show_friend_code=False): CARD_HEIGHT = 32 CARD_WIDTH = 160 @@ -96,24 +100,29 @@ def render_trade_offer_png(context, offer, show_friend_code=False): CARD_WIDTH_PADDING = 64 EXPANDED_CARD_WIDTH_PADDING = 80 CARD_COL_GAP = 4 - OUTPUT_PADDING = 24 # height padding is handled by the HTML + OUTPUT_PADDING = 24 # height padding is handled by the HTML have_cards_available = offer.have_cards_available want_cards_available = offer.want_cards_available num_cards = max(len(have_cards_available), len(want_cards_available)) expanded = (len(have_cards_available) + len(want_cards_available)) > 4 if expanded: - num_cards = ceil(num_cards / 2) # 2 cards per row if expanded - image_height = (num_cards * CARD_HEIGHT) + ((num_cards - 1) * CARD_COL_GAP) + HEADER_HEIGHT + FOOTER_HEIGHT + num_cards = ceil(num_cards / 2) # 2 cards per row if expanded + image_height = ( + (num_cards * CARD_HEIGHT) + + ((num_cards - 1) * CARD_COL_GAP) + + HEADER_HEIGHT + + FOOTER_HEIGHT + ) if expanded: image_width = (4 * CARD_WIDTH) + EXPANDED_CARD_WIDTH_PADDING else: image_width = (2 * CARD_WIDTH) + CARD_WIDTH_PADDING - image_width += OUTPUT_PADDING - image_height += OUTPUT_PADDING # height padding is handled by the HTML, but we need to also calculate it here for og meta tag use - + image_width += OUTPUT_PADDING + image_height += OUTPUT_PADDING # height padding is handled by the HTML, but we need to also calculate it here for og meta tag use + request = context.get("request") if request.get_host().startswith("localhost"): base_url = "http://{0}".format(request.get_host()) @@ -121,23 +130,23 @@ def render_trade_offer_png(context, offer, show_friend_code=False): base_url = "https://{0}".format(request.get_host()) tag_context = { - 'offer_pk': offer.pk, - 'offer_hash': offer.hash, - 'rarity_icon': offer.rarity_icon, - 'initiated_by_email': offer.initiated_by.user.email, - 'initiated_by_username': offer.initiated_by.user.username, - 'have_cards_available': have_cards_available, - 'want_cards_available': want_cards_available, - 'in_game_name': offer.initiated_by.in_game_name, - 'friend_code': offer.initiated_by.friend_code, - 'show_friend_code': show_friend_code, - 'num_cards_available': len(have_cards_available) + len(want_cards_available), - 'expanded': expanded, - 'image_width': image_width, - 'image_height': image_height, - 'base_url': base_url, - 'cache_key': f'trade_offer_png_{offer.pk}_{offer.updated_at.timestamp()}_{expanded}', + "offer_pk": offer.pk, + "offer_hash": offer.hash, + "rarity_icon": offer.rarity_icon, + "initiated_by_email": offer.initiated_by.user.email, + "initiated_by_username": offer.initiated_by.user.username, + "have_cards_available": have_cards_available, + "want_cards_available": want_cards_available, + "in_game_name": offer.initiated_by.in_game_name, + "friend_code": offer.initiated_by.friend_code, + "show_friend_code": show_friend_code, + "num_cards_available": len(have_cards_available) + len(want_cards_available), + "expanded": expanded, + "image_width": image_width, + "image_height": image_height, + "base_url": base_url, + "cache_key": f"trade_offer_png_{offer.pk}_{offer.updated_at.timestamp()}_{expanded}", } context.update(tag_context) - return context \ No newline at end of file + return context diff --git a/src/pkmntrade_club/trades/tests.py b/src/pkmntrade_club/trades/tests.py index c188c16..a38de08 100644 --- a/src/pkmntrade_club/trades/tests.py +++ b/src/pkmntrade_club/trades/tests.py @@ -20,6 +20,7 @@ from pkmntrade_club.trades.forms import ( ) from tests.utils.rarity import RARITY_MAPPING + # ------------------------------------------------------------------------ # Model Tests # ------------------------------------------------------------------------ @@ -35,17 +36,29 @@ class TradeOfferModelTest(TestCase): # Create cards with the same rarity (valid scenario) self.card1 = Card.objects.create( - name="Card1", cardset="set1", cardnum=1, style="default", - rarity_icon=RARITY_MAPPING[1], rarity_level=1 + name="Card1", + cardset="set1", + cardnum=1, + style="default", + rarity_icon=RARITY_MAPPING[1], + rarity_level=1, ) self.card2 = Card.objects.create( - name="Card2", cardset="set1", cardnum=2, style="default", - rarity_icon=RARITY_MAPPING[1], rarity_level=1 + name="Card2", + cardset="set1", + cardnum=2, + style="default", + rarity_icon=RARITY_MAPPING[1], + rarity_level=1, ) # Create a card with a different rarity (to test invalid trade offers) self.card3 = Card.objects.create( - name="Card3", cardset="set1", cardnum=3, style="default", - rarity_icon=RARITY_MAPPING[8], rarity_level=8 + name="Card3", + cardset="set1", + cardnum=3, + style="default", + rarity_icon=RARITY_MAPPING[8], + rarity_level=8, ) # Create a valid trade offer with consistent rarity details @@ -92,17 +105,27 @@ class TradeAcceptanceModelTest(TestCase): username="initiator", email="init@example.com", password="password" ) self.initiator_friend_code = FriendCode.objects.create( - friend_code="5555-6666-7777-8888", in_game_name="InitInGame", user=self.other_user + friend_code="5555-6666-7777-8888", + in_game_name="InitInGame", + user=self.other_user, ) # Create two cards (with the same rarity) self.card1 = Card.objects.create( - name="CardA", cardset="setA", cardnum=1, style="default", - rarity_icon=RARITY_MAPPING[2], rarity_level=2 + name="CardA", + cardset="setA", + cardnum=1, + style="default", + rarity_icon=RARITY_MAPPING[2], + rarity_level=2, ) self.card2 = Card.objects.create( - name="CardB", cardset="setA", cardnum=2, style="default", - rarity_icon=RARITY_MAPPING[2], rarity_level=2 + name="CardB", + cardset="setA", + cardnum=2, + style="default", + rarity_icon=RARITY_MAPPING[2], + rarity_level=2, ) # Create a trade offer by the initiator. @@ -150,9 +173,7 @@ class TradeAcceptanceModelTest(TestCase): self.acceptance.update_state( TradeAcceptance.AcceptanceState.SENT, user=self.other_user ) - self.assertEqual( - self.acceptance.state, TradeAcceptance.AcceptanceState.SENT - ) + self.assertEqual(self.acceptance.state, TradeAcceptance.AcceptanceState.SENT) def test_signal_adjusts_qty_accepted(self): """ @@ -206,12 +227,20 @@ class TradeOfferFormTest(TestCase): ) # Create two cards with the same rarity details. self.card1 = Card.objects.create( - name="FormCard1", cardset="formset", cardnum=1, style="default", - rarity_icon=RARITY_MAPPING[3], rarity_level=3 + name="FormCard1", + cardset="formset", + cardnum=1, + style="default", + rarity_icon=RARITY_MAPPING[3], + rarity_level=3, ) self.card2 = Card.objects.create( - name="FormCard2", cardset="formset", cardnum=2, style="default", - rarity_icon=RARITY_MAPPING[3], rarity_level=3 + name="FormCard2", + cardset="formset", + cardnum=2, + style="default", + rarity_icon=RARITY_MAPPING[3], + rarity_level=3, ) def test_trade_offer_create_form_valid(self): @@ -219,7 +248,7 @@ class TradeOfferFormTest(TestCase): A valid POST using colon-separated quantity strings should succeed. """ # Build a QueryDict with multiple values for each list field. - qd = QueryDict('', mutable=True) + qd = QueryDict("", mutable=True) qd.setlist("have_cards", [f"{self.card1.pk}:2"]) qd.setlist("want_cards", [f"{self.card2.pk}:3"]) # 'initiated_by' is a normal field so we can update it directly. @@ -231,7 +260,7 @@ class TradeOfferFormTest(TestCase): """ If quantity cannot be parsed as an integer a ValidationError should be raised. """ - qd = QueryDict('', mutable=True) + qd = QueryDict("", mutable=True) # Provide an invalid quantity ("two" instead of an integer). qd.setlist("have_cards", [f"{self.card1.pk}:two"]) qd.setlist("want_cards", [f"{self.card2.pk}:3"]) @@ -244,7 +273,7 @@ class TradeOfferFormTest(TestCase): """ An entry missing a colon should be ignored. """ - qd = QueryDict('', mutable=True) + qd = QueryDict("", mutable=True) # No colon present in the selections. qd.setlist("have_cards", [f"{self.card1.pk}"]) qd.setlist("want_cards", [f"{self.card2.pk}"]) @@ -283,9 +312,7 @@ class TradeOfferFormTest(TestCase): """Test that TradeOfferAcceptForm correctly sets the friend_code queryset.""" friend_codes = FriendCode.objects.filter(pk=self.friend_code.pk) form = TradeOfferAcceptForm(friend_codes=friend_codes) - self.assertEqual( - list(form.fields["friend_code"].queryset), list(friend_codes) - ) + self.assertEqual(list(form.fields["friend_code"].queryset), list(friend_codes)) def test_trade_acceptance_transition_form(self): """Test that the transition form provides only allowed transitions.""" @@ -312,7 +339,10 @@ class TradeOfferFormTest(TestCase): ) form = TradeAcceptanceTransitionForm(instance=acceptance, user=other_user) # Compare the form's state choices with the allowed transitions. - allowed = [choice[0] for choice in acceptance.get_allowed_state_transitions(user=other_user)] + allowed = [ + choice[0] + for choice in acceptance.get_allowed_state_transitions(user=other_user) + ] form_choices = [choice[0] for choice in form.fields["state"].choices] for choice in allowed: self.assertIn(choice, form_choices) @@ -337,12 +367,20 @@ class TradeViewsTest(TestCase): # Create sample cards. self.card1 = Card.objects.create( - name="ViewCard1", cardset="setV", cardnum=1, style="default", - rarity_icon=RARITY_MAPPING[7], rarity_level=7 + name="ViewCard1", + cardset="setV", + cardnum=1, + style="default", + rarity_icon=RARITY_MAPPING[7], + rarity_level=7, ) self.card2 = Card.objects.create( - name="ViewCard2", cardset="setV", cardnum=2, style="default", - rarity_icon=RARITY_MAPPING[7], rarity_level=7 + name="ViewCard2", + cardset="setV", + cardnum=2, + style="default", + rarity_icon=RARITY_MAPPING[7], + rarity_level=7, ) # Create a trade offer initiated by the logged-in user's friend code. self.trade_offer = TradeOffer.objects.create(initiated_by=self.friend_code) @@ -387,7 +425,9 @@ class TradeViewsTest(TestCase): Instead, if no active acceptances remain it should mark the offer as closed. """ # Create a trade offer with an active acceptance. - trade_offer_with_acceptance = TradeOffer.objects.create(initiated_by=self.friend_code) + trade_offer_with_acceptance = TradeOffer.objects.create( + initiated_by=self.friend_code + ) # Use quantity=2 so the trade offer isn't automatically closed when one acceptance is created TradeOfferHaveCard.objects.create( trade_offer=trade_offer_with_acceptance, card=self.card1, quantity=2 @@ -403,10 +443,13 @@ class TradeViewsTest(TestCase): offered_card=self.card2, state=TradeAcceptance.AcceptanceState.ACCEPTED, ) - delete_url = reverse("trade_offer_delete", kwargs={"pk": trade_offer_with_acceptance.pk}) + delete_url = reverse( + "trade_offer_delete", kwargs={"pk": trade_offer_with_acceptance.pk} + ) # --- Patch the view's get_object() method to return our trade offer --- from pkmntrade_club.trades.views import TradeOfferDeleteView + orig_get_object = TradeOfferDeleteView.get_object TradeOfferDeleteView.get_object = lambda self: trade_offer_with_acceptance @@ -445,21 +488,27 @@ class TradeViewsTest(TestCase): state=TradeAcceptance.AcceptanceState.ACCEPTED, ) update_url = reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}) - + # First, try an invalid state update. response = self.client.post(update_url, {"state": "INVALID_STATE"}) self.assertEqual(response.status_code, 200) - + form = response.context.get("form") self.assertIsNotNone(form, "Form should be present in the response context.") self.assertIn( - "state", form.errors, - "Expected an error on the 'state' field when an invalid state is submitted." + "state", + form.errors, + "Expected an error on the 'state' field when an invalid state is submitted.", + ) + self.assertTrue( + form.errors["state"], "The 'state' field should have error messages." ) - self.assertTrue(form.errors["state"], "The 'state' field should have error messages.") # Next, if there is an allowed valid transition, try it. - allowed_states = [choice[0] for choice in acceptance.get_allowed_state_transitions(user=self.user)] + allowed_states = [ + choice[0] + for choice in acceptance.get_allowed_state_transitions(user=self.user) + ] if allowed_states: valid_state = allowed_states[0] response = self.client.post(update_url, {"state": valid_state}) @@ -493,12 +542,20 @@ class TradeOfferSecurityTests(TestCase): # Create test cards with proper rarity levels self.card1 = Card.objects.create( - name="SecCard1", cardset="secset", cardnum=1, style="default", - rarity_icon=RARITY_MAPPING[3], rarity_level=3 + name="SecCard1", + cardset="secset", + cardnum=1, + style="default", + rarity_icon=RARITY_MAPPING[3], + rarity_level=3, ) self.card2 = Card.objects.create( - name="SecCard2", cardset="secset", cardnum=2, style="default", - rarity_icon=RARITY_MAPPING[3], rarity_level=3 + name="SecCard2", + cardset="secset", + cardnum=2, + style="default", + rarity_icon=RARITY_MAPPING[3], + rarity_level=3, ) # Create a trade offer by user1 @@ -536,14 +593,14 @@ class TradeOfferSecurityTests(TestCase): self.client.login(username="user3", password="password3") response = self.client.post( reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), - {"state": TradeAcceptance.AcceptanceState.SENT} + {"state": TradeAcceptance.AcceptanceState.SENT}, ) self.assertEqual(response.status_code, 403) def test_cross_user_friend_code_manipulation(self): """Test that users cannot use other users' friend codes.""" self.client.login(username="user2", password="password2") - + # Try to create a trade offer using user1's friend code response = self.client.get( reverse("trade_offer_create"), @@ -551,12 +608,10 @@ class TradeOfferSecurityTests(TestCase): "initiated_by": self.fc1.pk, # User1's friend code "have_cards": [f"{self.card1.pk}:1"], "want_cards": [f"{self.card2.pk}:1"], - } + }, ) self.assertEqual(response.status_code, 200) # Form should fail validation - self.assertFalse( - TradeOffer.objects.filter(initiated_by=self.fc1).count() > 1 - ) + self.assertFalse(TradeOffer.objects.filter(initiated_by=self.fc1).count() > 1) def test_authenticated_only_views(self): """Test that authenticated-only views are properly protected.""" @@ -564,18 +619,20 @@ class TradeOfferSecurityTests(TestCase): urls_to_test = [ reverse("trade_offer_create"), reverse("trade_offer_dashboard"), - reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), + reverse( + "trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk} + ), ] - + # First ensure we're logged out self.client.logout() - + for url in urls_to_test: response = self.client.get(url) self.assertRedirects( response, f"/accounts/login/?next={url}", - msg_prefix=f"URL {url} should require authentication" + msg_prefix=f"URL {url} should require authentication", ) @@ -588,19 +645,31 @@ class TradeOfferEdgeCasesTest(TestCase): self.friend_code = FriendCode.objects.create( friend_code="3333-4444-5555-6666", in_game_name="EdgeUser", user=self.user ) - + # Create test cards with different rarities using proper levels and icons self.common_card = Card.objects.create( - name="CommonCard", cardset="edgeset", cardnum=1, style="default", - rarity_icon=RARITY_MAPPING[1], rarity_level=1 + name="CommonCard", + cardset="edgeset", + cardnum=1, + style="default", + rarity_icon=RARITY_MAPPING[1], + rarity_level=1, ) self.rare_card = Card.objects.create( - name="RareCard", cardset="edgeset", cardnum=2, style="default", - rarity_icon=RARITY_MAPPING[5], rarity_level=5 + name="RareCard", + cardset="edgeset", + cardnum=2, + style="default", + rarity_icon=RARITY_MAPPING[5], + rarity_level=5, ) self.crown_card = Card.objects.create( - name="CrownCard", cardset="edgeset", cardnum=3, style="default", - rarity_icon=RARITY_MAPPING[8], rarity_level=8 + name="CrownCard", + cardset="edgeset", + cardnum=3, + style="default", + rarity_icon=RARITY_MAPPING[8], + rarity_level=8, ) self.client = Client() @@ -614,7 +683,7 @@ class TradeOfferEdgeCasesTest(TestCase): "initiated_by": self.friend_code.pk, "have_cards": [f"{self.common_card.pk}:0"], "want_cards": [f"{self.common_card.pk}:1"], - } + }, ) self.assertEqual(response.status_code, 200) self.assertFalse( @@ -629,7 +698,7 @@ class TradeOfferEdgeCasesTest(TestCase): "initiated_by": self.friend_code.pk, "have_cards": [f"{self.common_card.pk}:-1"], "want_cards": [f"{self.common_card.pk}:1"], - } + }, ) self.assertEqual(response.status_code, 200) self.assertFalse( @@ -644,7 +713,7 @@ class TradeOfferEdgeCasesTest(TestCase): "initiated_by": self.friend_code.pk, "have_cards": [f"{self.common_card.pk}:1"], "want_cards": [f"{self.crown_card.pk}:1"], - } + }, ) self.assertEqual(response.status_code, 200) self.assertFalse( @@ -657,12 +726,9 @@ class TradeOfferEdgeCasesTest(TestCase): reverse("trade_offer_create"), { "initiated_by": self.friend_code.pk, - "have_cards": [ - f"{self.common_card.pk}:1", - f"{self.common_card.pk}:1" - ], + "have_cards": [f"{self.common_card.pk}:1", f"{self.common_card.pk}:1"], "want_cards": [f"{self.common_card.pk}:1"], - } + }, ) self.assertEqual(response.status_code, 200) self.assertFalse( @@ -682,16 +748,28 @@ class TradeSearchTests(TestCase): # Create test cards with proper rarity levels self.card1 = Card.objects.create( - name="SearchCard1", cardset="sc1", cardnum=1, style="default", - rarity_icon=RARITY_MAPPING[4], rarity_level=4 + name="SearchCard1", + cardset="sc1", + cardnum=1, + style="default", + rarity_icon=RARITY_MAPPING[4], + rarity_level=4, ) self.card2 = Card.objects.create( - name="SearchCard2", cardset="sc1", cardnum=2, style="default", - rarity_icon=RARITY_MAPPING[4], rarity_level=4 + name="SearchCard2", + cardset="sc1", + cardnum=2, + style="default", + rarity_icon=RARITY_MAPPING[4], + rarity_level=4, ) self.card3 = Card.objects.create( - name="SearchCard3", cardset="sc1", cardnum=3, style="default", - rarity_icon=RARITY_MAPPING[4], rarity_level=4 + name="SearchCard3", + cardset="sc1", + cardnum=3, + style="default", + rarity_icon=RARITY_MAPPING[4], + rarity_level=4, ) # Create some trade offers @@ -719,7 +797,7 @@ class TradeSearchTests(TestCase): reverse("trade_offer_search"), { "have_cards": [f"{self.card2.pk}:1"], - } + }, ) self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) @@ -731,7 +809,7 @@ class TradeSearchTests(TestCase): reverse("trade_offer_search"), { "want_cards": [f"{self.card1.pk}:1"], - } + }, ) self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) @@ -743,7 +821,7 @@ class TradeSearchTests(TestCase): reverse("trade_offer_search"), { "have_cards": ["999999:1"], # Non-existent card ID - } + }, ) self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) @@ -753,12 +831,12 @@ class TradeSearchTests(TestCase): """Test that closed trades don't appear in search results.""" self.trade_offer1.is_closed = True self.trade_offer1.save() - + response = self.client.post( reverse("trade_offer_search"), { "have_cards": [f"{self.card2.pk}:1"], - } + }, ) self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name) @@ -773,30 +851,50 @@ class TradeAcceptanceComplexTests(TestCase): self.acceptor = User.objects.create_user( username="acceptor", email="accept@example.com", password="password" ) - + self.initiator_fc = FriendCode.objects.create( - friend_code="1234-5678-9012-3456", in_game_name="InitUser", user=self.initiator + friend_code="1234-5678-9012-3456", + in_game_name="InitUser", + user=self.initiator, ) self.acceptor_fc = FriendCode.objects.create( - friend_code="6543-2109-8765-4321", in_game_name="AcceptUser", user=self.acceptor + friend_code="6543-2109-8765-4321", + in_game_name="AcceptUser", + user=self.acceptor, ) # Create test cards with proper rarity levels self.card1 = Card.objects.create( - name="ComplexCard1", cardset="cx1", cardnum=1, style="default", - rarity_icon=RARITY_MAPPING[6], rarity_level=6 + name="ComplexCard1", + cardset="cx1", + cardnum=1, + style="default", + rarity_icon=RARITY_MAPPING[6], + rarity_level=6, ) self.card2 = Card.objects.create( - name="ComplexCard2", cardset="cx1", cardnum=2, style="default", - rarity_icon=RARITY_MAPPING[6], rarity_level=6 + name="ComplexCard2", + cardset="cx1", + cardnum=2, + style="default", + rarity_icon=RARITY_MAPPING[6], + rarity_level=6, ) self.card3 = Card.objects.create( - name="ComplexCard3", cardset="cx1", cardnum=3, style="default", - rarity_icon=RARITY_MAPPING[6], rarity_level=6 + name="ComplexCard3", + cardset="cx1", + cardnum=3, + style="default", + rarity_icon=RARITY_MAPPING[6], + rarity_level=6, ) self.card4 = Card.objects.create( - name="ComplexCard4", cardset="cx1", cardnum=4, style="default", - rarity_icon=RARITY_MAPPING[6], rarity_level=6 + name="ComplexCard4", + cardset="cx1", + cardnum=4, + style="default", + rarity_icon=RARITY_MAPPING[6], + rarity_level=6, ) # Create a trade offer with multiple quantities @@ -819,58 +917,67 @@ class TradeAcceptanceComplexTests(TestCase): def test_multiple_acceptances_quantity_limit(self): """Test that multiple acceptances cannot exceed the offer's quantity limit.""" self.client.login(username="acceptor", password="password") - + # Create first acceptance response1 = self.client.post( - reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), + reverse( + "trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk} + ), { "accepted_by": self.acceptor_fc.pk, "requested_card": self.card1.pk, "offered_card": self.card2.pk, - } + }, ) self.assertEqual(response1.status_code, 302) # Successful creation # Create second acceptance response2 = self.client.post( - reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), + reverse( + "trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk} + ), { "accepted_by": self.acceptor_fc.pk, "requested_card": self.card1.pk, "offered_card": self.card2.pk, - } + }, ) self.assertEqual(response2.status_code, 302) # Successful creation # Try to create a fourth acceptance (should fail as only 3 are allowed) response3 = self.client.post( - reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), + reverse( + "trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk} + ), { "accepted_by": self.acceptor_fc.pk, "requested_card": self.card1.pk, "offered_card": self.card2.pk, - } + }, ) self.assertEqual(response3.status_code, 302) # Successful creation response4 = self.client.post( - reverse("trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk}), + reverse( + "trade_acceptance_create", kwargs={"offer_pk": self.trade_offer.pk} + ), { "accepted_by": self.acceptor_fc.pk, "requested_card": self.card1.pk, "offered_card": self.card2.pk, - } + }, ) self.assertEqual(response4.status_code, 200) # Should fail self.assertEqual( - self.trade_offer.acceptances.count(), 3, - "Should not allow more acceptances than the quantity limit" + self.trade_offer.acceptances.count(), + 3, + "Should not allow more acceptances than the quantity limit", ) def test_complex_state_transitions(self): """Test complex state transition scenarios.""" self.client.login(username="acceptor", password="password") - + # Create an acceptance acceptance = TradeAcceptance.objects.create( trade_offer=self.trade_offer, @@ -890,14 +997,14 @@ class TradeAcceptanceComplexTests(TestCase): for invalid_state in invalid_transitions: response = self.client.post( reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), - {"state": invalid_state} + {"state": invalid_state}, ) self.assertEqual(response.status_code, 200) # Should stay on form acceptance.refresh_from_db() self.assertEqual( acceptance.state, TradeAcceptance.AcceptanceState.ACCEPTED, - f"Invalid transition to {invalid_state} should not be allowed" + f"Invalid transition to {invalid_state} should not be allowed", ) # Test valid state transition sequence @@ -912,14 +1019,12 @@ class TradeAcceptanceComplexTests(TestCase): self.client.login(username=user.username, password="password") response = self.client.post( reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}), - {"state": state} + {"state": state}, ) self.assertEqual(response.status_code, 302) # Should redirect on success acceptance.refresh_from_db() self.assertEqual( acceptance.state, state, - f"Valid transition to {state} should be allowed" + f"Valid transition to {state} should be allowed", ) - - diff --git a/src/pkmntrade_club/trades/urls.py b/src/pkmntrade_club/trades/urls.py index ce4cf7a..4b63a25 100644 --- a/src/pkmntrade_club/trades/urls.py +++ b/src/pkmntrade_club/trades/urls.py @@ -13,12 +13,24 @@ from .views import ( urlpatterns = [ path("create/", TradeOfferCreateView.as_view(), name="trade_offer_create"), - path("create/confirm/", TradeOfferCreateConfirmView.as_view(), name="trade_offer_confirm_create"), + path( + "create/confirm/", + TradeOfferCreateConfirmView.as_view(), + name="trade_offer_confirm_create", + ), path("", TradeOfferAllListView.as_view(), name="trade_offer_list"), path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"), path("/", TradeOfferDetailView.as_view(), name="trade_offer_detail"), path(".png", TradeOfferPNGView.as_view(), name="trade_offer_png"), path("delete//", TradeOfferDeleteView.as_view(), name="trade_offer_delete"), - path("accept/", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"), - path("update//", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"), + path( + "accept/", + TradeAcceptanceCreateView.as_view(), + name="trade_acceptance_create", + ), + path( + "update//", + TradeAcceptanceUpdateView.as_view(), + name="trade_acceptance_update", + ), ] diff --git a/src/pkmntrade_club/trades/views.py b/src/pkmntrade_club/trades/views.py index 8a25d45..5eb5c93 100644 --- a/src/pkmntrade_club/trades/views.py +++ b/src/pkmntrade_club/trades/views.py @@ -1,25 +1,35 @@ -from django.template import RequestContext -from django.views.generic import DeleteView, CreateView, ListView, DetailView, UpdateView -from django.views import View -from django.urls import reverse_lazy -from django.http import HttpResponseRedirect -from django.contrib.auth.mixins import LoginRequiredMixin -from django.shortcuts import render -from django.core.exceptions import PermissionDenied, ValidationError -from django.core.paginator import Paginator from django.contrib import messages -from meta.views import Meta -from .models import TradeOffer, TradeAcceptance -from .forms import (TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm) +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import PermissionDenied, ValidationError +from django.http import HttpResponseRedirect +from django.shortcuts import render from django.template.loader import render_to_string -from pkmntrade_club.trades.templatetags.trade_offer_tags import render_trade_offer_png +from django.urls import reverse_lazy +from django.views import View +from django.views.generic import ( + CreateView, + DeleteView, + DetailView, + ListView, + UpdateView, +) +from meta.views import Meta from playwright.sync_api import sync_playwright -from django.conf import settings -from .mixins import FriendCodeRequiredMixin + from pkmntrade_club.common.mixins import ReusablePaginationMixin +from pkmntrade_club.trades.templatetags.trade_offer_tags import render_trade_offer_png + +from .forms import ( + TradeAcceptanceCreateForm, + TradeAcceptanceTransitionForm, + TradeOfferCreateForm, +) +from .mixins import FriendCodeRequiredMixin +from .models import TradeAcceptance, TradeOffer + class TradeOfferCreateView(LoginRequiredMixin, CreateView): - http_method_names = ['get'] # restricts this view to GET only + http_method_names = ["get"] # restricts this view to GET only model = TradeOffer form_class = TradeOfferCreateForm template_name = "trades/trade_offer_create.html" @@ -42,20 +52,30 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) from pkmntrade_club.cards.models import Card + # Ensure available_cards is a proper QuerySet - context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level") + context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by( + "name", "rarity_level" + ) friend_codes = self.request.user.friend_codes.all() if "initiated_by" in self.request.GET: try: - selected_friend_code = friend_codes.get(pk=self.request.GET.get("initiated_by")) + selected_friend_code = friend_codes.get( + pk=self.request.GET.get("initiated_by") + ) except friend_codes.model.DoesNotExist: - selected_friend_code = self.request.user.default_friend_code or friend_codes.first() + selected_friend_code = ( + self.request.user.default_friend_code or friend_codes.first() + ) else: - selected_friend_code = self.request.user.default_friend_code or friend_codes.first() + selected_friend_code = ( + self.request.user.default_friend_code or friend_codes.first() + ) context["friend_codes"] = friend_codes context["selected_friend_code"] = selected_friend_code return context + class TradeOfferAllListView(ReusablePaginationMixin, ListView): model = TradeOffer template_name = "trades/trade_offer_all_list.html" @@ -93,14 +113,21 @@ class TradeOfferAllListView(ReusablePaginationMixin, ListView): page_number = self.get_page_number() self.per_page = 10 - paginated_offers, pagination_context = self.paginate_data(queryset, page_number) + paginated_offers, pagination_context = self.paginate_data( + queryset, page_number + ) return render( self.request, "trades/_trade_offer_list.html", - {"offers": paginated_offers, "page_obj": pagination_context, "expanded": expanded} + { + "offers": paginated_offers, + "page_obj": pagination_context, + "expanded": expanded, + }, ) return super().render_to_response(context, **response_kwargs) + class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteView): model = TradeOffer success_url = reverse_lazy("trade_offer_list") @@ -108,8 +135,12 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi def dispatch(self, request, *args, **kwargs): self.object = super().get_object() - if self.object.initiated_by_id not in request.user.friend_codes.values_list("id", flat=True): - raise PermissionDenied("You are not authorized to delete or close this trade offer.") + if self.object.initiated_by_id not in request.user.friend_codes.values_list( + "id", flat=True + ): + raise PermissionDenied( + "You are not authorized to delete or close this trade offer." + ) return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): @@ -143,8 +174,8 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi active_acceptances = trade_offer.acceptances.exclude(state__in=terminal_states) if active_acceptances.exists(): messages.error( - request, - "Cannot close this trade offer while there are active acceptances. Please reject all acceptances before closing, or finish the trades." + request, + "Cannot close this trade offer while there are active acceptances. Please reject all acceptances before closing, or finish the trades.", ) context = self.get_context_data() return self.render_to_response(context) @@ -158,6 +189,7 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi messages.success(request, "Trade offer has been deleted.") return super().delete(request, *args, **kwargs) + class TradeOfferSearchView(ListView): """ Reworked trade offer search view using POST. @@ -171,6 +203,7 @@ class TradeOfferSearchView(ListView): (_search_results.html) is rendered. On GET (initial page load), the search results queryset is empty. """ + model = TradeOffer context_object_name = "search_results" template_name = "trades/trade_offer_search.html" @@ -198,7 +231,7 @@ class TradeOfferSearchView(ListView): results.append((card_id, qty)) return results - #@silk_profile(name="Trade Offer Search- Get Queryset") + # @silk_profile(name="Trade Offer Search- Get Queryset") def get_queryset(self): # For a GET request (initial load), return an empty queryset. if self.request.method == "GET": @@ -215,7 +248,7 @@ class TradeOfferSearchView(ListView): qs = TradeOffer.objects.filter( is_closed=False, ) - + if self.request.user.is_authenticated: qs = qs.exclude(initiated_by__in=self.request.user.friend_codes.all()) @@ -237,17 +270,20 @@ class TradeOfferSearchView(ListView): return qs.distinct() - #@silk_profile(name="Trade Offer Search- Post") + # @silk_profile(name="Trade Offer Search- Post") def post(self, request, *args, **kwargs): # For POST, simply process the search through get(). return self.get(request, *args, **kwargs) - #@silk_profile(name="Trade Offer Search- Get Context Data") + # @silk_profile(name="Trade Offer Search- Get Context Data") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) from pkmntrade_club.cards.models import Card + # Populate available_cards to re-populate the multiselects. Exclude cards with rarity level > 5. - context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level") + context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by( + "name", "rarity_level" + ) if self.request.method == "POST": context["have_cards"] = self.request.POST.getlist("have_cards") context["want_cards"] = self.request.POST.getlist("want_cards") @@ -256,35 +292,40 @@ class TradeOfferSearchView(ListView): context["want_cards"] = [] return context - #@silk_profile(name="Trade Offer Search- Render to Response") + # @silk_profile(name="Trade Offer Search- Render to Response") def render_to_response(self, context, **response_kwargs): """ Render the AJAX fragment if the request is AJAX; otherwise, render the complete page. """ if self.request.headers.get("X-Requested-With") == "XMLHttpRequest": from django.shortcuts import render + return render(self.request, "trades/_search_results.html", context) else: return super().render_to_response(context, **response_kwargs) + class TradeOfferDetailView(DetailView): """ Displays the details of a TradeOffer along with its active acceptances. If the offer is still open and the current user is not its initiator, an acceptance form is provided to create a new acceptance. """ + model = TradeOffer template_name = "trades/trade_offer_detail.html" - #@silk_profile(name="Trade Offer Detail- Get Context Data") + # @silk_profile(name="Trade Offer Detail- Get Context Data") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) trade_offer = self.get_object() screenshot_mode = self.request.GET.get("screenshot_mode") if screenshot_mode: - context["show_friend_code"] = trade_offer.initiated_by.user.show_friend_code_on_link_previews + context["show_friend_code"] = ( + trade_offer.initiated_by.user.show_friend_code_on_link_previews + ) context["screenshot_mode"] = screenshot_mode - + # Calculate the number of cards in each category. num_has = trade_offer.trade_offer_have_cards.count() num_wants = trade_offer.trade_offer_want_cards.count() @@ -315,14 +356,14 @@ class TradeOfferDetailView(DetailView): # compute the height from the width. image_width = base_width image_height = int(round(image_width / aspect_ratio)) - + # Build the meta tags with the computed dimensions. - title = f'Trade Offer from {trade_offer.initiated_by.in_game_name} ({trade_offer.initiated_by.friend_code})' + title = f"Trade Offer from {trade_offer.initiated_by.in_game_name} ({trade_offer.initiated_by.friend_code})" context["meta"] = Meta( title=title, - description=f'Has: {", ".join([card.card.name for card in trade_offer.trade_offer_have_cards.all()])} โ€ข \nWants: {", ".join([card.card.name for card in trade_offer.trade_offer_want_cards.all()])}', + description=f"Has: {', '.join([card.card.name for card in trade_offer.trade_offer_have_cards.all()])} โ€ข \nWants: {', '.join([card.card.name for card in trade_offer.trade_offer_want_cards.all()])}", image_object={ - "url": f'http://localhost:8000{reverse_lazy("trade_offer_png", kwargs={"pk": trade_offer.pk})}', + "url": f"http://localhost:8000{reverse_lazy('trade_offer_png', kwargs={'pk': trade_offer.pk})}", "type": "image/png", "width": image_width, "height": image_height, @@ -333,7 +374,7 @@ class TradeOfferDetailView(DetailView): use_facebook=True, use_schemaorg=True, ) - + # Define terminal (closed) acceptance states based on our new system: terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, @@ -342,32 +383,41 @@ class TradeOfferDetailView(DetailView): TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, ] - + # For example, if you want to separate active from terminal acceptances: context["acceptances"] = trade_offer.acceptances.all() - + # Option 1: Filter active acceptances using the queryset lookup. - context["active_acceptances"] = trade_offer.acceptances.exclude(state__in=terminal_states) - + context["active_acceptances"] = trade_offer.acceptances.exclude( + state__in=terminal_states + ) + if self.request.user.is_authenticated: user_friend_codes = self.request.user.friend_codes.all() # Add context flag and deletion URL if the current user is the initiator if trade_offer.initiated_by in user_friend_codes: context["is_initiator"] = True - context["delete_close_url"] = reverse_lazy("trade_offer_delete", kwargs={"pk": trade_offer.pk}) + context["delete_close_url"] = reverse_lazy( + "trade_offer_delete", kwargs={"pk": trade_offer.pk} + ) else: context["is_initiator"] = False # Determine the user's default friend code (or fallback as needed). - default_friend_code = self.request.user.default_friend_code or user_friend_codes.first() - + default_friend_code = ( + self.request.user.default_friend_code or user_friend_codes.first() + ) + # If the current user is not the initiator and the offer is open, allow a new acceptance. - if trade_offer.initiated_by not in user_friend_codes and not trade_offer.is_closed: + if ( + trade_offer.initiated_by not in user_friend_codes + and not trade_offer.is_closed + ): context["acceptance_form"] = TradeAcceptanceCreateForm( trade_offer=trade_offer, friend_codes=user_friend_codes, - default_friend_code=default_friend_code + default_friend_code=default_friend_code, ) else: context["is_initiator"] = False @@ -376,11 +426,15 @@ class TradeOfferDetailView(DetailView): return context -class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, CreateView): + +class TradeAcceptanceCreateView( + LoginRequiredMixin, FriendCodeRequiredMixin, CreateView +): """ View to create a new TradeAcceptance. The URL should provide 'offer_pk' so that the proper TradeOffer can be identified. """ + model = TradeAcceptance form_class = TradeAcceptanceCreateForm template_name = "trades/trade_acceptance_create.html" @@ -390,16 +444,18 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre return super().dispatch(request, *args, **kwargs) def get_trade_offer(self): - return TradeOffer.objects.get(pk=self.kwargs['offer_pk']) + return TradeOffer.objects.get(pk=self.kwargs["offer_pk"]) def get_form_kwargs(self): kwargs = super().get_form_kwargs() - if (self.trade_offer.initiated_by_id in - self.request.user.friend_codes.values_list("id", flat=True) or - self.trade_offer.is_closed): + if ( + self.trade_offer.initiated_by_id + in self.request.user.friend_codes.values_list("id", flat=True) + or self.trade_offer.is_closed + ): raise PermissionDenied("You cannot accept this trade offer.") - kwargs['trade_offer'] = self.trade_offer - kwargs['friend_codes'] = self.request.user.friend_codes.all() + kwargs["trade_offer"] = self.trade_offer + kwargs["friend_codes"] = self.request.user.friend_codes.all() return kwargs def get_context_data(self, **kwargs): @@ -430,7 +486,13 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre "acceptance_form": form, "friend_codes": friend_codes, "is_initiator": is_initiator, - "delete_close_url": reverse_lazy("trade_offer_delete", kwargs={"pk": self.trade_offer.pk}) if is_initiator else None, + "delete_close_url": ( + reverse_lazy( + "trade_offer_delete", kwargs={"pk": self.trade_offer.pk} + ) + if is_initiator + else None + ), } # Render the detail page with the form errors return render(self.request, "trades/trade_offer_detail.html", context) @@ -439,11 +501,15 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre def get_success_url(self): return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk}) -class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, UpdateView): + +class TradeAcceptanceUpdateView( + LoginRequiredMixin, FriendCodeRequiredMixin, UpdateView +): """ View to update the state of an existing TradeAcceptance. The allowed state transitions are provided via the form. """ + model = TradeAcceptance form_class = TradeAcceptanceTransitionForm template_name = "trades/trade_acceptance_update.html" @@ -451,8 +517,10 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, Upd def dispatch(self, request, *args, **kwargs): self.object = self.get_object() friend_codes = request.user.friend_codes.values_list("id", flat=True) - if (self.object.accepted_by_id not in friend_codes and - self.object.trade_offer.initiated_by_id not in friend_codes): + if ( + self.object.accepted_by_id not in friend_codes + and self.object.trade_offer.initiated_by_id not in friend_codes + ): raise PermissionDenied("You are not authorized to update this acceptance.") return super().dispatch(request, *args, **kwargs) @@ -481,6 +549,7 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, Upd def get_success_url(self): return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk}) + class TradeOfferPNGView(View): """ Generate a PNG screenshot of the rendered trade offer detail page using Playwright. @@ -488,15 +557,17 @@ class TradeOfferPNGView(View): runs at a time for a given TradeOffer. The generated PNG is then cached in the TradeOffer model's `image` field (assumed to be an ImageField). """ + def get_lock_key(self, trade_offer_id): # Use the trade_offer_id as the lock key; adjust if needed. return trade_offer_id def get(self, request, *args, **kwargs): - from django.shortcuts import get_object_or_404 - from django.http import HttpResponse from django.core.files.base import ContentFile - trade_offer = get_object_or_404(TradeOffer, pk=kwargs['pk']) + from django.http import HttpResponse + from django.shortcuts import get_object_or_404 + + trade_offer = get_object_or_404(TradeOffer, pk=kwargs["pk"]) # If the image is already generated and stored, serve it directly. if trade_offer.image and not request.GET.get("debug"): @@ -505,6 +576,7 @@ class TradeOfferPNGView(View): # Acquire PostgreSQL advisory lock to prevent concurrent generation. from django.db import connection + lock_key = self.get_lock_key(trade_offer.pk) with connection.cursor() as cursor: cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key]) @@ -514,18 +586,22 @@ class TradeOfferPNGView(View): if trade_offer.image and not request.GET.get("debug"): trade_offer.image.open() return HttpResponse(trade_offer.image.read(), content_type="image/png") - + tag_context = render_trade_offer_png( - {'request': request}, trade_offer, show_friend_code=trade_offer.initiated_by.user.show_friend_code_on_link_previews + {"request": request}, + trade_offer, + show_friend_code=trade_offer.initiated_by.user.show_friend_code_on_link_previews, ) - image_width = tag_context.get('image_width') - image_height = tag_context.get('image_height') + image_width = tag_context.get("image_width") + image_height = tag_context.get("image_height") if not image_width or not image_height: - raise ValueError("Could not determine image dimensions from tag_context") + raise ValueError( + "Could not determine image dimensions from tag_context" + ) html = render_to_string( "templatetags/trade_offer_png.html", context=tag_context, - request=request + request=request, ) # if query string has "debug", render the HTML instead of the PNG @@ -545,13 +621,20 @@ class TradeOfferPNGView(View): "--disable-audio-output", "--disable-webgl", "--no-first-run", - ] + ], + ) + context_browser = browser.new_context( + viewport={"width": image_width, "height": image_height} ) - context_browser = browser.new_context(viewport={"width": image_width, "height": image_height}) page = context_browser.new_page() page.on("console", lambda msg: print(f"Console {msg.type}: {msg.text}")) page.on("pageerror", lambda err: print(f"Page error: {err}")) - page.on("requestfailed", lambda req: print(f"Failed to load: {req.url} - {req.failure.error_text}")) + page.on( + "requestfailed", + lambda req: print( + f"Failed to load: {req.url} - {req.failure.error_text}" + ), + ) page.set_content(html, wait_until="networkidle") element = page.wait_for_selector(".trade-offer-card-screenshot") screenshot_bytes = element.screenshot(type="png", omit_background=True) @@ -567,11 +650,13 @@ class TradeOfferPNGView(View): with connection.cursor() as cursor: cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key]) + class TradeOfferCreateConfirmView(LoginRequiredMixin, View): """ Processes a two-step create for TradeOffer; on confirmation, commits the offer and shows form errors if any occur. """ + def post(self, request, *args, **kwargs): if "confirm" in request.POST: return self._commit_offer(request) @@ -605,17 +690,21 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View): } # Supply additional context required by trade_offer_create.html. from pkmntrade_club.cards.models import Card + context = { "form": form, "friend_codes": request.user.friend_codes.all(), "selected_friend_code": ( - request.user.default_friend_code or request.user.friend_codes.first() + request.user.default_friend_code + or request.user.friend_codes.first() ), "cards": Card.objects.all().order_by("name", "rarity_level"), } return render(request, "trades/trade_offer_create.html", context) messages.success(request, "Trade offer created successfully!") - return HttpResponseRedirect(reverse_lazy("trade_offer_detail", kwargs={"pk": trade_offer.pk})) + return HttpResponseRedirect( + reverse_lazy("trade_offer_detail", kwargs={"pk": trade_offer.pk}) + ) else: # When the form is not valid, update its initial data as well: form.initial = { @@ -624,16 +713,18 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View): "initiated_by": request.POST.get("initiated_by"), } from pkmntrade_club.cards.models import Card + context = { "form": form, "friend_codes": request.user.friend_codes.all(), "selected_friend_code": ( - request.user.default_friend_code or request.user.friend_codes.first() + request.user.default_friend_code + or request.user.friend_codes.first() ), "cards": Card.objects.all().order_by("name", "rarity_level"), } return render(request, "trades/trade_offer_create.html", context) - + def _redirect_to_edit(self, request): query_params = request.POST.copy() query_params.pop("csrfmiddlewaretoken", None) @@ -641,10 +732,11 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View): query_params.pop("confirm", None) query_params.pop("preview", None) from django.urls import reverse + base_url = reverse("trade_offer_create") url_with_params = f"{base_url}?{query_params.urlencode()}" return HttpResponseRedirect(url_with_params) - + def _preview_offer(self, request): form = TradeOfferCreateForm(request.POST) form.fields["initiated_by"].queryset = request.user.friend_codes.all() @@ -656,15 +748,16 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View): "initiated_by": request.POST.get("initiated_by"), } from pkmntrade_club.cards.models import Card + context = { "form": form, "friend_codes": request.user.friend_codes.all(), - "selected_friend_code": request.user.default_friend_code or request.user.friend_codes.first(), + "selected_friend_code": request.user.default_friend_code + or request.user.friend_codes.first(), "cards": Card.objects.all().order_by("name", "rarity_level"), } return render(request, "trades/trade_offer_create.html", context) - # Parse the card selections for "have" and "want" cards. have_selections = self._parse_card_selections("have_cards") want_selections = self._parse_card_selections("want_cards") From af2f48a491c3f14f939e26e3d20f43882cc4c2f8 Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Thu, 19 Jun 2025 15:42:36 -0700 Subject: [PATCH 4/5] refactor(db): update cursor rules and enhance deployment rollback script - Standardized string formatting in cursor rules for consistency. - Added a new rollback deployment script to facilitate blue-green deployment strategy. - Removed outdated seed data files and introduced new rarity mappings for better data management. - Improved model relationships and query optimizations in various views and admin configurations. - Enhanced caching strategies across templates to improve performance and reduce load times, including jitter in cache settings for better performance. - Refactored card-related views and templates to utilize new model fields and relationships. --- .cursorrules | 35 +- scripts/rollback-deployment.sh | 98 + seed/0002_Decks.json | 101 - seed/0002_RarityMappings.json | 1 + seed/0003_Cards.json | 12717 ---------------- src/pkmntrade_club/accounts/admin.py | 12 +- .../accounts/migrations/0001_initial.py | 196 +- src/pkmntrade_club/accounts/views.py | 137 +- src/pkmntrade_club/cards/admin.py | 759 +- .../cards/migrations/0001_initial.py | 751 +- src/pkmntrade_club/cards/models.py | 75 +- src/pkmntrade_club/cards/signals.py | 80 +- .../cards/templatetags/card_badge.py | 11 +- .../cards/templatetags/card_multiselect.py | 14 +- src/pkmntrade_club/cards/views.py | 127 +- .../common/context_processors.py | 16 +- src/pkmntrade_club/django_project/settings.py | 22 +- src/pkmntrade_club/home/views.py | 106 +- src/pkmntrade_club/theme/templates/base.html | 2 + .../theme/templates/cards/card_detail.html | 2 +- .../theme/templates/cards/card_list.html | 97 +- .../theme/templates/home/_card_list.html | 1 + .../theme/templates/home/home.html | 10 +- .../_card_multiselect_options.html | 18 +- .../theme/templatetags/card_badge.html | 2 +- .../theme/templatetags/card_multiselect.html | 2 +- .../theme/templatetags/trade_acceptance.html | 4 +- .../theme/templatetags/trade_offer.html | 2 +- .../theme/templatetags/trade_offer_png.html | 32 +- src/pkmntrade_club/trades/admin.py | 110 +- src/pkmntrade_club/trades/apps.py | 2 +- src/pkmntrade_club/trades/forms.py | 8 +- .../trades/migrations/0001_initial.py | 206 +- src/pkmntrade_club/trades/mixins.py | 7 +- src/pkmntrade_club/trades/models.py | 113 +- src/pkmntrade_club/trades/signals.py | 70 +- src/pkmntrade_club/trades/views.py | 63 +- 37 files changed, 2444 insertions(+), 13565 deletions(-) create mode 100755 scripts/rollback-deployment.sh delete mode 100644 seed/0002_Decks.json create mode 100644 seed/0002_RarityMappings.json delete mode 100644 seed/0003_Cards.json 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/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_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/src/pkmntrade_club/accounts/admin.py b/src/pkmntrade_club/accounts/admin.py index bd475e3..7a004c7 100644 --- a/src/pkmntrade_club/accounts/admin.py +++ b/src/pkmntrade_club/accounts/admin.py @@ -1,8 +1,8 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin -from .forms import CustomUserCreationForm, CustomUserChangeForm -from .models import CustomUser +from .forms import CustomUserChangeForm, CustomUserCreationForm +from .models import CustomUser, FriendCode class CustomUserAdmin(UserAdmin): @@ -27,3 +27,11 @@ class CustomUserAdmin(UserAdmin): admin.site.register(CustomUser, CustomUserAdmin) + + +@admin.register(FriendCode) +class FriendCodeAdmin(admin.ModelAdmin): + list_display = ("friend_code", "in_game_name", "user") + search_fields = ("friend_code", "in_game_name", "user__username", "user__email") + list_select_related = ("user",) + autocomplete_fields = ("user",) 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/views.py b/src/pkmntrade_club/accounts/views.py index 34e81b7..a170fb7 100644 --- a/src/pkmntrade_club/accounts/views.py +++ b/src/pkmntrade_club/accounts/views.py @@ -1,22 +1,23 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.shortcuts import redirect, get_object_or_404, render +from django.core.exceptions import PermissionDenied +from django.db.models import BooleanField, Case, Q, Value, When +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.http import urlencode from django.views.generic import ( CreateView, DeleteView, - View, TemplateView, UpdateView, + View, ) -from pkmntrade_club.accounts.models import FriendCode + from pkmntrade_club.accounts.forms import FriendCodeForm, UserSettingsForm -from django.db.models import Case, When, Value, BooleanField -from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance -from django.core.exceptions import PermissionDenied -from pkmntrade_club.trades.mixins import FriendCodeRequiredMixin +from pkmntrade_club.accounts.models import FriendCode from pkmntrade_club.common.mixins import ReusablePaginationMixin -from django.urls import reverse -from django.utils.http import urlencode +from pkmntrade_club.trades.mixins import FriendCodeRequiredMixin +from pkmntrade_club.trades.models import TradeAcceptance, TradeOffer class AddFriendCodeView(LoginRequiredMixin, CreateView): @@ -204,8 +205,6 @@ class DashboardView( return {"object_list": object_list, "page_obj": pagination_context} def get_involved_acceptances(self, selected_friend_code): - from django.db.models import Q - terminal_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, @@ -213,10 +212,25 @@ class DashboardView( TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, ] - involved = TradeAcceptance.objects.filter( - 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): @@ -271,55 +285,94 @@ class DashboardView( return {"object_list": object_list, "page_obj": pagination_context} def get_closed_acceptances_paginated(self, page_param): - from django.db.models import Q - selected_friend_code = self.get_selected_friend_code() terminal_success_states = [ TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR, TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR, TradeAcceptance.AcceptanceState.THANKED_BY_BOTH, ] - acceptance_qs = TradeAcceptance.objects.filter( - Q(trade_offer__initiated_by=selected_friend_code) - | Q(accepted_by=selected_friend_code), - state__in=terminal_success_states, - ).order_by("-updated_at") + 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, + rejection = ( + TradeAcceptance.objects.filter( + Q( + trade_offer__initiated_by=selected_friend_code, + state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, + ) + | Q( + accepted_by=selected_friend_code, + state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, + ) ) - | Q( - 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", ) - ).order_by("-updated_at") + .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, + rejection = ( + TradeAcceptance.objects.filter( + Q( + trade_offer__initiated_by=selected_friend_code, + state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR, + ) + | Q( + accepted_by=selected_friend_code, + state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR, + ) ) - | Q( - 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", ) - ).order_by("-updated_at") + .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} diff --git a/src/pkmntrade_club/cards/admin.py b/src/pkmntrade_club/cards/admin.py index b3ce633..759cd4e 100644 --- a/src/pkmntrade_club/cards/admin.py +++ b/src/pkmntrade_club/cards/admin.py @@ -1,34 +1,68 @@ -from django.contrib import admin, messages -from django.urls import path -from django.shortcuts import render -from django.http import HttpResponseRedirect -from parler.admin import TranslatableAdmin -from .models import ( - CardSet_New, Pack_New, Energy_New, Attack_New, Ability_New, - Rarity_New, CardType_New, Card_New, AttackCost_New, RarityMapping -) - +import hashlib +import io import json import os -import re # For parsing set name and ID +import re # For parsing set name and ID +import zipfile +from dataclasses import dataclass + +import requests from django.conf import settings -from django.utils.translation import gettext_lazy as _ +from django.contrib import admin, messages +from django.contrib.admin.filters import RelatedFieldListFilter from django.db import transaction -import hashlib +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.urls import path, reverse +from parler.admin import TranslatableAdmin + +from .models import ( + Ability, + Attack, + AttackCost, + Card, + CardSet, + CardType, + Energy, + Pack, + Rarity, + RarityMapping, +) + + +@dataclass +class ImportResult: + """A data class to hold the results of the card import process.""" + + newly_imported_count: int = 0 + updated_count: int = 0 + skipped_count: int = 0 + files_processed_count: int = 0 + has_error: bool = False + message: str = "" + + +class PrefetchedSortedRelatedFieldListFilter(RelatedFieldListFilter): + def field_choices(self, field, request, model_admin): + related_manager = field.related_model._default_manager + queryset = related_manager.all().prefetch_related("translations") + return [(obj.pk, str(obj)) for obj in queryset] + def parse_set_details(set_string): - match = re.match(r'^(.*?)\s*\(([A-Za-z0-9]+)\)$', set_string) + match = re.match(r"^(.*?)\s*\(([A-Za-z0-9]+)\)$", set_string) if match: name = match.group(1).strip() set_id = match.group(2) return name, set_id - match = re.match(r'^Promo-(.*?)$', set_string) + match = re.match(r"^Promo-(.*?)$", set_string) if match: name = set_string - set_id = 'P-' + match.group(1) + set_id = "P-" + match.group(1) return name, set_id return set_string, None + def calculate_card_checksum(card_data): """ Calculates a SHA256 checksum for a card's data. @@ -38,55 +72,67 @@ def calculate_card_checksum(card_data): # Select and normalize fields that define the card's state # Order of keys in `data_to_hash` and sorting of lists are important for consistency data_to_hash = { - 'id': card_data.get('id'), - 'name': card_data.get('name'), - 'type': card_data.get('type'), - 'subtype': card_data.get('subtype'), - 'rarity': card_data.get('rarity'), # Rarity name from JSON - 'health': card_data.get('health'), - 'evolvesFrom': card_data.get('evolvesFrom'), - 'retreatCost': card_data.get('retreatCost'), - 'element': card_data.get('element'), # Element name from JSON - 'weakness': card_data.get('weakness'), # Weakness name from JSON - 'pack': card_data.get('pack'), # Pack name from JSON + "id": card_data.get("id"), + "name": card_data.get("name"), + "type": card_data.get("type"), + "subtype": card_data.get("subtype"), + "rarity": card_data.get("rarity"), # Rarity name from JSON + "health": card_data.get("health"), + "evolvesFrom": card_data.get("evolvesFrom"), + "retreatCost": card_data.get("retreatCost"), + "element": card_data.get("element"), # Element name from JSON + "weakness": card_data.get("weakness"), # Weakness name from JSON + "pack": card_data.get("pack"), # Pack name from JSON # For abilities and attacks, ensure stable order and content - 'abilities': sorted([ - {'name': a.get('name'), 'effect': a.get('effect')} - for a in card_data.get('abilities', []) if a and a.get('name') # ensure ability itself and name exist - ], key=lambda x: x['name'] if x and x.get('name') else ''), - 'attacks': sorted([ - { - '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 ''), + "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() + 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_New.objects.language('en').get_or_create( - translations__name=card_data['type'], - translations__subtype=card_data.get('subtype', ''), - defaults={'name': card_data['type'], 'subtype': card_data.get('subtype', '')} + card_type_obj, created = CardType.objects.language("en").get_or_create( + translations__name=card_data["type"], + translations__subtype=card_data.get("subtype", ""), + defaults={"name": card_data["type"], "subtype": card_data.get("subtype", "")}, ) if not created: - current_subtype = card_data.get('subtype') + current_subtype = card_data.get("subtype") if current_subtype is not None and card_type_obj.subtype != current_subtype: - card_type_obj.set_current_language('en') + card_type_obj.set_current_language("en") card_type_obj.subtype = current_subtype card_type_obj.save() return card_type_obj + def _get_or_create_rarity(card_data, rarity_mappings_dict): - original_rarity_name_from_json = card_data.get('rarity') + original_rarity_name_from_json = card_data.get("rarity") # Attempt to find a mapping for the original rarity name mapping = rarity_mappings_dict.get(original_rarity_name_from_json) @@ -99,24 +145,28 @@ def _get_or_create_rarity(card_data, rarity_mappings_dict): elif original_rarity_name_from_json: # No mapping found, use the original name from JSON, default icon/level target_rarity_name = original_rarity_name_from_json - target_icon = 'x' # Default icon if no mapping - target_level = 0 # Default level if no mapping + target_icon = "x" # Default icon if no mapping + target_level = 0 # Default level if no mapping else: # Rarity is None or empty in JSON, treat as 'Promo' - target_rarity_name = 'Promo' + target_rarity_name = "Promo" # Check if 'Promo' itself has a mapping - promo_mapping = rarity_mappings_dict.get('Promo') + promo_mapping = rarity_mappings_dict.get("Promo") if promo_mapping: target_icon = promo_mapping.icon target_level = promo_mapping.level else: - target_icon = 'x' # Default icon for 'Promo' if no mapping for 'Promo' - target_level = 0 # Default level for 'Promo' if no mapping for 'Promo' + target_icon = "x" # Default icon for 'Promo' if no mapping for 'Promo' + target_level = 0 # Default level for 'Promo' if no mapping for 'Promo' - # Get or create the Rarity_New object using the (potentially mapped) values - rarity_obj, created = Rarity_New.objects.language('en').get_or_create( + # Get or create the Rarity object using the (potentially mapped) values + rarity_obj, created = Rarity.objects.language("en").get_or_create( translations__name=target_rarity_name, - defaults={'name': target_rarity_name, 'icon': target_icon, 'level': target_level} + defaults={ + "name": target_rarity_name, + "icon": target_icon, + "level": target_level, + }, ) # If the rarity already existed, check if its icon or level needs updating based on the mapping @@ -128,145 +178,150 @@ def _get_or_create_rarity(card_data, rarity_mappings_dict): 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_New.objects.language('en').get_or_create( - translations__name=energy_name, - defaults={'name': energy_name} + energy_obj, _ = Energy.objects.language("en").get_or_create( + translations__name=energy_name, defaults={"name": energy_name} ) return energy_obj + def _update_card_packs(card_obj, card_data, card_set): card_obj.packs.clear() - pack_name_from_json = card_data.get('pack') + pack_name_from_json = card_data.get("pack") if pack_name_from_json: - card_set.set_current_language('en') + card_set.set_current_language("en") pack_full_name = f"{card_set.name}: {pack_name_from_json}" - pack_obj, _ = Pack_New.objects.language('en').get_or_create( + pack_obj, _ = Pack.objects.language("en").get_or_create( translations__name=pack_name_from_json, cardset=card_set, defaults={ - 'name': pack_name_from_json, - 'full_name': pack_full_name, - 'hex_color': '#FFFFFF' - } + "name": pack_name_from_json, + "full_name": pack_full_name, + "hex_color": "#FFFFFF", + }, ) card_obj.packs.add(pack_obj) else: - all_packs_in_set = Pack_New.objects.filter(cardset=card_set) + all_packs_in_set = Pack.objects.filter(cardset=card_set) if all_packs_in_set.exists(): card_obj.packs.add(*all_packs_in_set) + def _update_card_abilities(card_obj, card_data): card_obj.abilities.clear() - for ability_data in card_data.get('abilities', []): - ability_obj, created = Ability_New.objects.language('en').get_or_create( - translations__name=ability_data['name'], - defaults={'name': ability_data['name'], 'effect': ability_data['effect']} + for ability_data in card_data.get("abilities", []): + ability_obj, created = Ability.objects.language("en").get_or_create( + translations__name=ability_data["name"], + defaults={"name": ability_data["name"], "effect": ability_data["effect"]}, ) - if not created and ability_obj.effect != ability_data['effect']: - ability_obj.set_current_language('en') - ability_obj.effect = ability_data['effect'] + if not created and ability_obj.effect != ability_data["effect"]: + ability_obj.set_current_language("en") + ability_obj.effect = ability_data["effect"] ability_obj.save() card_obj.abilities.add(ability_obj) + def _update_card_attacks_and_costs(card_obj, card_data): card_obj.attacks.clear() - for attack_data in card_data.get('attacks', []): - attack_obj, created = Attack_New.objects.language('en').get_or_create( - translations__name=attack_data['name'], + for attack_data in card_data.get("attacks", []): + attack_obj, created = Attack.objects.language("en").get_or_create( + translations__name=attack_data["name"], defaults={ - 'name': attack_data['name'], - 'effect': attack_data.get('effect', ''), - 'damage': attack_data.get('damage', '') - } + "name": attack_data["name"], + "effect": attack_data.get("effect", ""), + "damage": attack_data.get("damage", ""), + }, ) - + needs_save = False if not created: - json_effect = attack_data.get('effect', '') + json_effect = attack_data.get("effect", "") if attack_obj.effect != json_effect: - attack_obj.set_current_language('en') + attack_obj.set_current_language("en") attack_obj.effect = json_effect needs_save = True - - json_damage = attack_data.get('damage', '') + + json_damage = attack_data.get("damage", "") if attack_obj.damage != json_damage: attack_obj.damage = json_damage needs_save = True - + if created or needs_save: - attack_obj.save() + attack_obj.save() card_obj.attacks.add(attack_obj) - attack_obj.energy_cost.clear() + attack_obj.energy_cost.clear() energy_counts = {} - for cost_energy_name in attack_data.get('cost', []): + for cost_energy_name in attack_data.get("cost", []): energy_counts[cost_energy_name] = energy_counts.get(cost_energy_name, 0) + 1 - + for energy_name, quantity in energy_counts.items(): energy_obj = _get_or_create_energy(energy_name) if energy_obj: - AttackCost_New.objects.update_or_create( + AttackCost.objects.update_or_create( attack=attack_obj, energy=energy_obj, - defaults={'quantity': quantity} + defaults={"quantity": quantity}, ) -def _process_single_card_data(card_data, card_set, stats_accumulator, error_tracking, rarity_mappings_dict): + +def _process_single_card_data( + card_data, card_set, stats_accumulator, error_tracking, rarity_mappings_dict +): """ Processes a single card's data from the JSON. Updates stats_accumulator with newly_imported_count, updated_count, or skipped_count. error_tracking is a dict {'file_name': ..., 'card_id': ...} for precise error reporting. """ - card_id = card_data['id'] + card_id = card_data["id"] incoming_checksum = calculate_card_checksum(card_data) - error_tracking['card_id'] = card_id + error_tracking["card_id"] = card_id try: - existing_card = Card_New.objects.language('en').get(id=card_id) + existing_card = Card.objects.language("en").get(id=card_id) if existing_card.checksum == incoming_checksum: - stats_accumulator['skipped_count'] += 1 - return - except Card_New.DoesNotExist: + 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')) - + pkmn_type_obj = _get_or_create_energy(card_data.get("element")) + weakness_type_obj = _get_or_create_energy(card_data.get("weakness")) + card_defaults = { - 'name': card_data['name'], - 'cardset': card_set, - 'card_type': card_type_obj, - 'rarity': rarity_obj, - 'health': card_data.get('health'), - 'evolves_from_name': card_data.get('evolvesFrom'), - 'retreat_cost': card_data.get('retreatCost'), - 'pkmn_type': pkmn_type_obj, - 'weakness_type': weakness_type_obj, - 'checksum': incoming_checksum + "name": card_data["name"], + "cardset": card_set, + "card_type": card_type_obj, + "rarity": rarity_obj, + "health": card_data.get("health"), + "evolves_from_name": card_data.get("evolvesFrom"), + "retreat_cost": card_data.get("retreatCost"), + "pkmn_type": pkmn_type_obj, + "weakness_type": weakness_type_obj, + "checksum": incoming_checksum, } - - card_obj, card_created = Card_New.objects.language('en').update_or_create( - id=card_id, - defaults=card_defaults + + card_obj, card_created = Card.objects.language("en").update_or_create( + id=card_id, defaults=card_defaults ) if card_created: - stats_accumulator['newly_imported_count'] += 1 - elif existing_card: - stats_accumulator['updated_count'] +=1 + 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. @@ -282,200 +337,400 @@ def _process_single_card_data(card_data, card_set, stats_accumulator, error_trac # However, for skipping based on *incoming JSON data*, this approach is correct. # The `update_or_create` will ensure the `checksum` field (which is part of `card_defaults`) is saved. -def perform_card_import_logic(): + +def _fetch_card_data_from_github_zip(): + """ + Downloads and extracts card data from the GitHub repository zip archive. + + Yields: + tuple: A tuple containing the file name (str) and its parsed JSON data (list). + Raises: + requests.exceptions.RequestException: If the download fails. + """ + owner = "hugoburguete" + repo = "pokemon-tcg-pocket-card-database" + branch = "main" + zip_url = f"https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip" + + print(f"Downloading card data from {zip_url}...") + response = requests.get(zip_url) + response.raise_for_status() # Will raise an exception for 4xx/5xx status + print("Download complete.") + + zip_file = zipfile.ZipFile(io.BytesIO(response.content)) + + # The root folder in the zip is usually `repo-name-branch-name` + path_prefix = f"{repo}-{branch}/cards/en/" + + json_file_paths = sorted( + [ + name + for name in zip_file.namelist() + if name.startswith(path_prefix) and name.endswith(".json") + ] + ) + + print(f"Found {len(json_file_paths)} JSON files in the archive.") + + for file_path in json_file_paths: + file_name = os.path.basename(file_path) + with zip_file.open(file_path) as f: + data = json.load(f) + yield file_name, data + + +def _fetch_card_data_from_local_files(): + """ + Reads card data from local JSON files for debugging purposes. + + Yields: + tuple: A tuple containing the file name (str) and its parsed JSON data (list). + """ + base_path = os.path.join( + settings.BASE_DIR, + "REMOTE_GIT_REPOS", + "pokemon-tcg-pocket-card-database", + "cards", + "en", + ) + print(f"DEBUG MODE: Reading card data from local path: {base_path}") + + if not os.path.isdir(base_path): + print(f"Source directory not found: {base_path}. Import halted.") + return # An empty generator + + json_files = sorted([f for f in os.listdir(base_path) if f.endswith(".json")]) + + print(f"Found {len(json_files)} local JSON files to process.") + + for file_name in json_files: + file_path = os.path.join(base_path, file_name) + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + yield file_name, data + + +def perform_card_import_logic() -> ImportResult: """ Main importer logic. Iterates through JSON files and processes them. + In DEBUG mode, it reads from local files. Otherwise, fetches from a remote GitHub repo. Halts and rolls back on any error. """ print("Card import process started.") - base_path = os.path.join(settings.BASE_DIR, 'REMOTE_GIT_REPOS', 'pokemon-tcg-pocket-card-database', 'cards', 'en') - - stats = {'newly_imported_count': 0, 'updated_count': 0, 'skipped_count': 0, 'files_processed_count': 0} - error_tracking = {'file_name': "N/A", 'card_id': "N/A"} + result = ImportResult() + error_tracking = {"file_name": "N/A", "card_id": "N/A"} # Fetch all rarity mappings once rarity_mappings = RarityMapping.objects.all() - rarity_mappings_dict = {mapping.original_name: mapping for mapping in rarity_mappings} + rarity_mappings_dict = { + mapping.original_name: mapping for mapping in rarity_mappings + } print(f"Loaded {len(rarity_mappings_dict)} rarity mappings.") - if not os.path.isdir(base_path): - message = f"Source directory not found: {base_path}. Import halted." - print(message) - return 0, 0, True, message, 0, 0 - - json_files = [f for f in os.listdir(base_path) if f.endswith('.json')] - json_files.sort() - if not json_files: - message = "No JSON files found in the source directory to import." - print(message) - return 0, 0, False, message, 0, 0 - - print(f"Found {len(json_files)} JSON files to process.") - try: - with transaction.atomic(): - for idx, file_name in enumerate(json_files): - error_tracking['file_name'] = file_name - error_tracking['card_id'] = "N/A" - file_path = os.path.join(base_path, file_name) - - print(f"Processing file: {file_name} ({idx + 1}/{len(json_files)})") + if settings.DEBUG: + card_data_iterator = _fetch_card_data_from_local_files() + source_message = "local files" + else: + # Fetch card data from the GitHub zip archive + card_data_iterator = _fetch_card_data_from_github_zip() + source_message = "the GitHub archive" - with open(file_path, 'r', encoding='utf-8') as f: - data = json.load(f) + all_files_data = list(card_data_iterator) + total_files = len(all_files_data) + + if not all_files_data: + result.message = f"No JSON files found in {source_message} to import." + print(result.message) + return result + + print(f"Found {total_files} JSON files to process from {source_message}.") + + with transaction.atomic(): + 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 + error_tracking["card_id"] = "N/A" + + print(f"Processing file: {file_name} ({idx + 1}/{total_files})") if not data: - raise ValueError(f"JSON file {file_name} is empty or contains no data.") + raise ValueError( + f"JSON file {file_name} is empty or contains no data." + ) - stats['files_processed_count'] += 1 + result.files_processed_count += 1 first_card_data = data[0] - set_info_str = first_card_data.get('set') + set_info_str = first_card_data.get("set") if not set_info_str: - raise ValueError(f"Could not determine set information from first card in {file_name}.") - - parsed_set_name, parsed_set_id = parse_set_details(set_info_str) - if not parsed_set_id: - raise ValueError(f"Could not parse set ID from '{set_info_str}' in {file_name}.") + raise ValueError( + f"Could not determine set information from first card in {file_name}." + ) - card_set_defaults = { - 'name': parsed_set_name, - 'file_name': file_name - } - card_set, _ = CardSet_New.objects.language('en').update_or_create( - id=parsed_set_id, - defaults=card_set_defaults + parsed_set_name, parsed_set_id = parse_set_details(set_info_str) + if not parsed_set_id: + raise ValueError( + f"Could not parse set ID from '{set_info_str}' in {file_name}." + ) + + card_set_defaults = {"name": parsed_set_name, "file_name": file_name} + card_set, _ = CardSet.objects.language("en").update_or_create( + id=parsed_set_id, defaults=card_set_defaults ) for card_data_item in data: - print("Processing card: ", card_data_item['id']) - _process_single_card_data(card_data_item, card_set, stats, error_tracking, rarity_mappings_dict) - + print("Processing card: ", card_data_item["id"]) + _process_single_card_data( + card_data_item, + card_set, + stats_accumulator, + error_tracking, + rarity_mappings_dict, + ) + print(f"Finished processing file: {file_name}") - success_message = ( - f"Import completed successfully. Processed {stats['files_processed_count']} files. " - f"Imported {stats['newly_imported_count']} new cards. " - f"Updated {stats['updated_count']} existing cards. " - f"Skipped {stats['skipped_count']} unchanged cards." + result.newly_imported_count = stats_accumulator["newly_imported_count"] + result.updated_count = stats_accumulator["updated_count"] + result.skipped_count = stats_accumulator["skipped_count"] + + result.message = ( + f"Import completed successfully. Processed {result.files_processed_count} files. " + f"Imported {result.newly_imported_count} new cards. " + f"Updated {result.updated_count} existing cards. " + f"Skipped {result.skipped_count} unchanged cards." ) print("Committing transaction.") - transaction.on_commit(lambda: print(success_message)) - return stats['newly_imported_count'], stats['updated_count'], False, success_message, stats['files_processed_count'], stats['skipped_count'] + transaction.on_commit(lambda: print(result.message)) + return result + + except requests.exceptions.RequestException as e: + # Handle network-related errors for the download + result.has_error = True + result.message = f"Failed to download card data from GitHub: {e}" + print(result.message) + return result except Exception as e: - # Any exception during the process will cause the transaction to roll back. + # Any other exception during the process will cause the transaction to roll back. error_detail = f"Error during import (file: {error_tracking['file_name']}, card: {error_tracking['card_id']}): {str(e)}" - halt_message = f"Import HALTED. All changes rolled back. Reason: {error_detail}" - print(halt_message) - # Return 0 for counts as the transaction is rolled back - return 0, 0, True, halt_message, stats['files_processed_count'], stats.get('skipped_count', 0) + result.has_error = True + result.message = ( + f"Import HALTED. All changes rolled back. Reason: {error_detail}" + ) + print(result.message) + return result -if admin.site.is_registered(CardSet_New): admin.site.unregister(CardSet_New) -if admin.site.is_registered(Pack_New): admin.site.unregister(Pack_New) -if admin.site.is_registered(Energy_New): admin.site.unregister(Energy_New) -if admin.site.is_registered(Attack_New): admin.site.unregister(Attack_New) -if admin.site.is_registered(Ability_New): admin.site.unregister(Ability_New) -if admin.site.is_registered(Rarity_New): admin.site.unregister(Rarity_New) -if admin.site.is_registered(CardType_New): admin.site.unregister(CardType_New) -if admin.site.is_registered(Card_New): admin.site.unregister(Card_New) -if admin.site.is_registered(AttackCost_New): admin.site.unregister(AttackCost_New) -if admin.site.is_registered(RarityMapping): admin.site.unregister(RarityMapping) - - -@admin.register(CardSet_New) +@admin.register(CardSet) class CardSetAdmin(TranslatableAdmin): - list_display = ('id', 'name', 'file_name') - readonly_fields = ('id', 'file_name', 'created_at', 'updated_at') - search_fields = ('translations__name',) - readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + list_display = ("id", "name", "file_name") + search_fields = ("translations__name",) + readonly_fields = ("id", "file_name", "created_at", "updated_at", "deleted_at") -@admin.register(Pack_New) + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related("translations") + + +@admin.register(Pack) class PackAdmin(TranslatableAdmin): - list_display = ('id', 'full_name', 'name', 'cardset', 'hex_color') - list_filter = ('cardset',) - search_fields = ('translations__name', 'translations__full_name') - readonly_fields = ('id', 'created_at', 'updated_at') + list_display = ("id", "full_name", "name", "cardset", "hex_color") + list_filter = ("cardset",) + search_fields = ("translations__name", "translations__full_name") + readonly_fields = ("id", "created_at", "updated_at") -@admin.register(Energy_New) + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .select_related("cardset") + .prefetch_related("translations", "cardset__translations") + ) + + +@admin.register(Energy) class EnergyAdmin(TranslatableAdmin): - list_display = ('id', 'name') - search_fields = ('translations__name',) - readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + list_display = ("id", "name") + search_fields = ("translations__name",) + readonly_fields = ("id", "created_at", "updated_at", "deleted_at") -@admin.register(Attack_New) + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related("translations") + + +@admin.register(Attack) class AttackAdmin(TranslatableAdmin): - list_display = ('id', 'name', 'damage', 'effect') - search_fields = ('translations__name',) - readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + list_display = ("id", "name", "damage", "effect") + search_fields = ("translations__name",) + readonly_fields = ("id", "created_at", "updated_at", "deleted_at") -@admin.register(Ability_New) + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related("translations") + + +@admin.register(Ability) class AbilityAdmin(TranslatableAdmin): - list_display = ('id', 'name', 'effect') - search_fields = ('translations__name',) - readonly_fields = ('id', 'created_at', 'updated_at') + list_display = ("id", "name", "effect") + search_fields = ("translations__name",) + readonly_fields = ("id", "created_at", "updated_at") -@admin.register(Rarity_New) + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related("translations") + + +@admin.register(Rarity) class RarityAdmin(TranslatableAdmin): - list_display = ('id', 'name', 'icon', 'level') - search_fields = ('translations__name',) - readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + list_display = ("id", "name", "icon", "level") + search_fields = ("translations__name",) + readonly_fields = ("id", "created_at", "updated_at", "deleted_at") -@admin.register(CardType_New) + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related("translations") + + +@admin.register(CardType) class CardTypeAdmin(TranslatableAdmin): - list_display = ('id', 'name', 'subtype') - search_fields = ('translations__name', 'translations__subtype') - readonly_fields = ('id', 'created_at', 'updated_at', 'deleted_at') + list_display = ("id", "name", "subtype") + search_fields = ("translations__name", "translations__subtype") + readonly_fields = ("id", "created_at", "updated_at", "deleted_at") -@admin.register(Card_New) + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related("translations") + + +@admin.register(Card) class CardAdmin(TranslatableAdmin): - list_display = ('id', 'cardnum', 'name', 'cardset', 'card_type', 'rarity', 'health', 'pkmn_type') - list_filter = ('cardset', 'card_type', 'rarity', 'pkmn_type', 'packs') - search_fields = ('id', 'translations__name', 'cardset__translations__name', 'packs__translations__name') - filter_horizontal = ('packs', 'abilities', 'attacks') - readonly_fields = ('id', 'cardnum', 'created_at', 'updated_at', 'deleted_at') + list_display = ( + "id", + "cardnum", + "name", + "cardset", + "card_type", + "rarity", + "health", + "pkmn_type", + ) + list_filter = ( + ("cardset", PrefetchedSortedRelatedFieldListFilter), + ("card_type", PrefetchedSortedRelatedFieldListFilter), + ("rarity", PrefetchedSortedRelatedFieldListFilter), + ("pkmn_type", PrefetchedSortedRelatedFieldListFilter), + ("packs", PrefetchedSortedRelatedFieldListFilter), + ) + search_fields = ( + "id", + "translations__name", + "cardset__translations__name", + "packs__translations__name", + ) + filter_horizontal = ("packs", "abilities", "attacks") + readonly_fields = ("id", "cardnum", "created_at", "updated_at", "deleted_at") + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.select_related( + "cardset", "card_type", "rarity", "pkmn_type" + ).prefetch_related( + "translations", + "cardset__translations", + "card_type__translations", + "rarity__translations", + "pkmn_type__translations", + ) + + +admin.site.register(AttackCost) -admin.site.register(AttackCost_New) @admin.register(RarityMapping) class RarityMappingAdmin(admin.ModelAdmin): - list_display = ('original_name', 'mapped_name', 'icon', 'level', 'created_at', 'updated_at', 'deleted_at') - search_fields = ('original_name', 'mapped_name') - list_filter = ('level',) - readonly_fields = ('created_at', 'updated_at', 'deleted_at') + list_display = ( + "original_name", + "mapped_name", + "icon", + "level", + "created_at", + "updated_at", + "deleted_at", + ) + search_fields = ("original_name", "mapped_name") + list_filter = ("level",) + readonly_fields = ("created_at", "updated_at", "deleted_at") + def get_admin_urls(urls): def importer_view(request): context = { - 'title': 'Card Importer', - 'site_header': admin.site.site_header, - 'site_title': admin.site.site_title, - 'index_title': admin.site.index_title, - 'has_permission': admin.site.has_permission(request), - 'app_label': 'cards', + "title": "Card Importer", + "site_header": admin.site.site_header, + "site_title": admin.site.site_title, + "index_title": admin.site.index_title, + "has_permission": admin.site.has_permission(request), + "app_label": "cards", } - if request.method == 'POST': - new, updated, has_error, message_text, files_processed, skipped = perform_card_import_logic() + if request.method == "POST": + result = perform_card_import_logic() - if has_error: - messages.error(request, message_text + f" Files attempted before halt: {files_processed}.") + if result.has_error: + message = result.message + if result.files_processed_count > 0: + message += ( + f" Files attempted before halt: {result.files_processed_count}." + ) + messages.error(request, message) else: - messages.success(request, message_text) + messages.success(request, result.message) return HttpResponseRedirect(request.path_info) - - return render(request, 'admin/cards/importer_status.html', context) + + return render(request, "admin/cards/importer_status.html", context) custom_urls = [ - path('cards/import/', admin.site.admin_view(importer_view), name='cards_full_importer'), + path( + "cards/import/", + admin.site.admin_view(importer_view), + name="cards_full_importer", + ), ] return custom_urls + urls + original_get_urls = admin.site.get_urls + def new_get_urls(): urls = original_get_urls() return get_admin_urls(urls) -admin.site.get_urls = new_get_urls \ No newline at end of file + +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..1536cf4 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-15 03:44 import django.db.models.deletion +import parler.fields +import parler.models from django.db import migrations, models @@ -8,64 +10,733 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Card', + name="Ability", fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('cardset', models.CharField(max_length=32)), - ('cardnum', models.IntegerField()), - ('style', models.CharField(max_length=128)), - ('rarity_icon', models.CharField(max_length=12)), - ('rarity_level', models.IntegerField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ("id", models.AutoField(primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), ], + options={ + "verbose_name": "Ability", + "verbose_name_plural": "Abilities", + }, + bases=(parler.models.TranslatableModelMixin, models.Model), ), migrations.CreateModel( - name='Deck', + name="Attack", fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('hex_color', models.CharField(max_length=9)), - ('cardset', models.CharField(max_length=8)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ("id", models.AutoField(primary_key=True, serialize=False)), + ( + "damage", + models.CharField( + blank=True, + help_text="Damage string, e.g., '40', '20x', '80+'.", + max_length=10, + null=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), ], + options={ + "verbose_name": "Attack", + "verbose_name_plural": "Attacks", + }, + bases=(parler.models.TranslatableModelMixin, models.Model), ), migrations.CreateModel( - name='CardNameTranslation', + name="CardSet", fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('language', models.CharField(max_length=64)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.card')), + ( + "id", + models.CharField( + help_text="The ID for the set, e.g., 'A1', 'A1a'.", + max_length=3, + primary_key=True, + serialize=False, + ), + ), + ( + "file_name", + models.CharField( + help_text="Original name of the JSON file, e.g., 'a1-genetic-apex.json'.", + max_length=32, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), ], + options={ + "verbose_name": "Card Set", + "verbose_name_plural": "Card Sets", + }, + bases=(parler.models.TranslatableModelMixin, models.Model), + ), + migrations.CreateModel( + name="CardType", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ], + options={ + "verbose_name": "Card Type", + "verbose_name_plural": "Card Types", + }, + bases=(parler.models.TranslatableModelMixin, models.Model), + ), + migrations.CreateModel( + name="Energy", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ], + options={ + "verbose_name": "Energy", + "verbose_name_plural": "Energies", + }, + bases=(parler.models.TranslatableModelMixin, models.Model), + ), + migrations.CreateModel( + name="Rarity", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("icon", models.CharField(max_length=12)), + ("level", models.PositiveIntegerField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ], + options={ + "verbose_name": "Rarity", + "verbose_name_plural": "Rarities", + }, + bases=(parler.models.TranslatableModelMixin, models.Model), + ), + migrations.CreateModel( + name="RarityMapping", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ( + "original_name", + models.CharField( + help_text="The rarity name as it appears in the import source (e.g., JSON file).", + max_length=255, + unique=True, + ), + ), + ( + "mapped_name", + models.CharField( + help_text="The standardized rarity name to use in the system.", + max_length=32, + ), + ), + ( + "icon", + models.CharField( + help_text="The icon associated with this rarity.", max_length=12 + ), + ), + ( + "level", + models.PositiveIntegerField( + help_text="The level or order of this rarity." + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ], + options={ + "verbose_name": "Rarity Mapping", + "verbose_name_plural": "Rarity Mappings", + "ordering": ["original_name"], + }, + ), + migrations.CreateModel( + name="AttackCost", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "quantity", + models.PositiveIntegerField( + default=1, + help_text="Quantity of this energy type required for the attack.", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ( + "attack", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="cards.attack" + ), + ), + ( + "energy", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="cards.energy" + ), + ), + ], + options={ + "verbose_name": "Attack Cost", + "verbose_name_plural": "Attack Costs", + "unique_together": {("attack", "energy")}, + }, ), migrations.AddField( - model_name='card', - name='decks', - field=models.ManyToManyField(to='cards.deck'), + model_name="attack", + name="energy_cost", + field=models.ManyToManyField( + related_name="attacks", through="cards.AttackCost", to="cards.energy" + ), ), migrations.CreateModel( - name='DeckNameTranslation', + name="Pack", fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('language', models.CharField(max_length=64)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('deck', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.deck')), + ("id", models.AutoField(primary_key=True, serialize=False)), + ("hex_color", models.CharField(max_length=9)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ( + "cardset", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="packs", + to="cards.cardset", + ), + ), ], + 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, + ), + ), + ( + "style", + models.CharField( + blank=True, + help_text="Inline CSS style for the card, used for dynamic styling.", + max_length=255, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ( + "abilities", + models.ManyToManyField( + blank=True, related_name="cards", to="cards.ability" + ), + ), + ( + "attacks", + models.ManyToManyField(related_name="cards", to="cards.attack"), + ), + ( + "cardset", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cards", + to="cards.cardset", + ), + ), + ( + "card_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cards", + to="cards.cardtype", + ), + ), + ( + "pkmn_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="cards_pkmn_type", + to="cards.energy", + ), + ), + ( + "weakness_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="cards_weakness_type", + to="cards.energy", + ), + ), + ( + "packs", + models.ManyToManyField(related_name="cards", to="cards.pack"), + ), + ( + "rarity", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cards", + to="cards.rarity", + ), + ), + ], + options={ + "verbose_name": "Card", + "verbose_name_plural": "Cards", + }, + bases=(parler.models.TranslatableModelMixin, models.Model), + ), + migrations.CreateModel( + name="AbilityTranslation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "language_code", + models.CharField( + db_index=True, max_length=15, verbose_name="Language" + ), + ), + ( + "name", + models.CharField( + help_text="The name of the ability.", max_length=32 + ), + ), + ( + "effect", + models.TextField(help_text="Description of the ability's effect."), + ), + ( + "master", + parler.fields.TranslationsForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="translations", + to="cards.ability", + ), + ), + ], + options={ + "verbose_name": "Ability Translation", + "db_table": "cards_ability_translation", + "db_tablespace": "", + "managed": True, + "default_permissions": (), + "unique_together": {("language_code", "master")}, + }, + bases=(parler.models.TranslatedFieldsModelMixin, models.Model), + ), + migrations.CreateModel( + name="AttackTranslation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "language_code", + models.CharField( + db_index=True, max_length=15, verbose_name="Language" + ), + ), + ( + "name", + models.CharField( + help_text="The name of the attack.", max_length=32 + ), + ), + ( + "effect", + models.TextField(help_text="Description of the attack's effect."), + ), + ( + "master", + parler.fields.TranslationsForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="translations", + to="cards.attack", + ), + ), + ], + options={ + "verbose_name": "Attack Translation", + "db_table": "cards_attack_translation", + "db_tablespace": "", + "managed": True, + "default_permissions": (), + "unique_together": {("language_code", "master")}, + }, + bases=(parler.models.TranslatedFieldsModelMixin, models.Model), + ), + migrations.CreateModel( + name="CardSetTranslation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "language_code", + models.CharField( + db_index=True, max_length=15, verbose_name="Language" + ), + ), + ( + "name", + models.CharField( + help_text="The full name of the set, e.g., 'Genetic Apex'.", + max_length=32, + ), + ), + ( + "master", + parler.fields.TranslationsForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="translations", + to="cards.cardset", + ), + ), + ], + options={ + "verbose_name": "Card Set Translation", + "db_table": "cards_cardset_translation", + "db_tablespace": "", + "managed": True, + "default_permissions": (), + "unique_together": {("language_code", "master")}, + }, + bases=(parler.models.TranslatedFieldsModelMixin, models.Model), + ), + migrations.CreateModel( + name="CardTranslation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "language_code", + models.CharField( + db_index=True, max_length=15, verbose_name="Language" + ), + ), + ( + "name", + models.CharField(help_text="The name of the card.", max_length=32), + ), + ( + "evolves_from_name", + models.CharField( + blank=True, + help_text="Name of the Pokรฉmon this card evolves from.", + max_length=32, + null=True, + ), + ), + ( + "master", + parler.fields.TranslationsForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="translations", + to="cards.card", + ), + ), + ], + options={ + "verbose_name": "Card Translation", + "db_table": "cards_card_translation", + "db_tablespace": "", + "managed": True, + "default_permissions": (), + "unique_together": {("language_code", "master")}, + }, + bases=(parler.models.TranslatedFieldsModelMixin, models.Model), + ), + migrations.CreateModel( + name="CardTypeTranslation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "language_code", + models.CharField( + db_index=True, max_length=15, verbose_name="Language" + ), + ), + ( + "name", + models.CharField( + help_text="The name of the card type.", max_length=32 + ), + ), + ( + "subtype", + models.CharField( + blank=True, + help_text="The subtype of the card type.", + max_length=32, + null=True, + ), + ), + ( + "master", + parler.fields.TranslationsForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="translations", + to="cards.cardtype", + ), + ), + ], + options={ + "verbose_name": "Card Type Translation", + "db_table": "cards_cardtype_translation", + "db_tablespace": "", + "managed": True, + "default_permissions": (), + "unique_together": {("language_code", "master")}, + }, + bases=(parler.models.TranslatedFieldsModelMixin, models.Model), + ), + migrations.CreateModel( + name="EnergyTranslation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "language_code", + models.CharField( + db_index=True, max_length=15, verbose_name="Language" + ), + ), + ( + "name", + models.CharField( + help_text="The name of the energy.", max_length=32 + ), + ), + ( + "master", + parler.fields.TranslationsForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="translations", + to="cards.energy", + ), + ), + ], + options={ + "verbose_name": "Energy Translation", + "db_table": "cards_energy_translation", + "db_tablespace": "", + "managed": True, + "default_permissions": (), + "unique_together": {("language_code", "master")}, + }, + bases=(parler.models.TranslatedFieldsModelMixin, models.Model), + ), + migrations.CreateModel( + name="PackTranslation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "language_code", + models.CharField( + db_index=True, max_length=15, verbose_name="Language" + ), + ), + ( + "full_name", + models.CharField( + help_text="The full name of the pack, e.g., 'Genetic Apex: Mewtwo'.", + max_length=32, + ), + ), + ( + "name", + models.CharField( + help_text="The pack name itself, e.g., 'Mewtwo'.", max_length=32 + ), + ), + ( + "master", + parler.fields.TranslationsForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="translations", + to="cards.pack", + ), + ), + ], + options={ + "verbose_name": "Pack Translation", + "db_table": "cards_pack_translation", + "db_tablespace": "", + "managed": True, + "default_permissions": (), + "unique_together": {("language_code", "master")}, + }, + bases=(parler.models.TranslatedFieldsModelMixin, models.Model), + ), + migrations.CreateModel( + name="RarityTranslation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "language_code", + models.CharField( + db_index=True, max_length=15, verbose_name="Language" + ), + ), + ( + "name", + models.CharField( + help_text="The name of the rarity.", max_length=32 + ), + ), + ( + "master", + parler.fields.TranslationsForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="translations", + to="cards.rarity", + ), + ), + ], + options={ + "verbose_name": "Rarity Translation", + "db_table": "cards_rarity_translation", + "db_tablespace": "", + "managed": True, + "default_permissions": (), + "unique_together": {("language_code", "master")}, + }, + bases=(parler.models.TranslatedFieldsModelMixin, models.Model), ), ] diff --git a/src/pkmntrade_club/cards/models.py b/src/pkmntrade_club/cards/models.py index d443a6a..7e05f57 100644 --- a/src/pkmntrade_club/cards/models.py +++ b/src/pkmntrade_club/cards/models.py @@ -1,6 +1,35 @@ from django.db import models -from parler.models import TranslatableModel, TranslatedFields from django.utils.translation import gettext_lazy as _ +from parler.managers import TranslatableManager +from parler.models import TranslatableModel, TranslatedFields + + +class CardManager(TranslatableManager): + def with_details(self): + """ + Returns a Card queryset with all related fields pre-selected to avoid N+1 queries. + """ + return ( + self.get_queryset() + .select_related( + "rarity", + "cardset", + "card_type", + "pkmn_type", + "weakness_type", + ) + .prefetch_related( + "translations", + "rarity__translations", + "cardset__translations", + "card_type__translations", + "pkmn_type__translations", + "weakness_type__translations", + "attacks__translations", + "abilities__translations", + "packs__translations", + ) + ) class CardSet(TranslatableModel): @@ -29,8 +58,8 @@ class CardSet(TranslatableModel): deleted_at = models.DateTimeField(null=True, blank=True) class Meta: - verbose_name = _("Card Set (New)") - verbose_name_plural = _("Card Sets (New)") + verbose_name = _("Card Set") + verbose_name_plural = _("Card Sets") def __str__(self): return f"{self.id} - {self.name}" @@ -58,8 +87,8 @@ class Pack(TranslatableModel): deleted_at = models.DateTimeField(null=True, blank=True) class Meta: - verbose_name = _("Pack (New)") - verbose_name_plural = _("Packs (New)") + verbose_name = _("Pack") + verbose_name_plural = _("Packs") def __str__(self): return f"{self.full_name}" @@ -79,8 +108,8 @@ class Energy(TranslatableModel): deleted_at = models.DateTimeField(null=True, blank=True) class Meta: - verbose_name = _("Energy (New)") - verbose_name_plural = _("Energies (New)") + verbose_name = _("Energy") + verbose_name_plural = _("Energies") def __str__(self): return f"{self.name}" @@ -101,8 +130,8 @@ class AttackCost(models.Model): deleted_at = models.DateTimeField(null=True, blank=True) class Meta: - verbose_name = _("Attack Cost (New)") - verbose_name_plural = _("Attack Costs (New)") + verbose_name = _("Attack Cost") + verbose_name_plural = _("Attack Costs") unique_together = ("attack", "energy") def __str__(self): @@ -133,8 +162,8 @@ class Attack(TranslatableModel): deleted_at = models.DateTimeField(null=True, blank=True) class Meta: - verbose_name = _("Attack (New)") - verbose_name_plural = _("Attacks (New)") + verbose_name = _("Attack") + verbose_name_plural = _("Attacks") def __str__(self): return f"{self.name}" @@ -155,8 +184,8 @@ class Ability(TranslatableModel): deleted_at = models.DateTimeField(null=True, blank=True) class Meta: - verbose_name = _("Ability (New)") - verbose_name_plural = _("Abilities (New)") + verbose_name = _("Ability") + verbose_name_plural = _("Abilities") def __str__(self): return f"{self.name}" @@ -178,8 +207,8 @@ class Rarity(TranslatableModel): deleted_at = models.DateTimeField(null=True, blank=True) class Meta: - verbose_name = _("Rarity (New)") - verbose_name_plural = _("Rarities (New)") + verbose_name = _("Rarity") + verbose_name_plural = _("Rarities") def __str__(self): return f"{self.name}" @@ -205,8 +234,8 @@ class CardType(TranslatableModel): deleted_at = models.DateTimeField(null=True, blank=True) class Meta: - verbose_name = _("Card Type (New)") - verbose_name_plural = _("Card Types (New)") + verbose_name = _("Card Type") + verbose_name_plural = _("Card Types") def __str__(self): return f"{self.name}" @@ -217,6 +246,8 @@ class Card(TranslatableModel): Represents a single, unique digital printing of a Pokรฉmon card. """ + objects = CardManager() + translations = TranslatedFields( name=models.CharField(max_length=32, help_text=_("The name of the card.")), evolves_from_name=models.CharField( @@ -271,13 +302,19 @@ class Card(TranslatableModel): attacks = models.ManyToManyField(Attack, related_name="cards") rarity = models.ForeignKey(Rarity, on_delete=models.CASCADE, related_name="cards") + style = models.CharField( + max_length=255, + blank=True, + help_text=_("Inline CSS style for the card, used for dynamic styling."), + ) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) deleted_at = models.DateTimeField(null=True, blank=True) class Meta: - verbose_name = _("Card (New)") - verbose_name_plural = _("Cards (New)") + verbose_name = _("Card") + verbose_name_plural = _("Cards") def __str__(self): return f"{self.id} {self.name}" diff --git a/src/pkmntrade_club/cards/signals.py b/src/pkmntrade_club/cards/signals.py index af9a8ee..64afbee 100644 --- a/src/pkmntrade_club/cards/signals.py +++ b/src/pkmntrade_club/cards/signals.py @@ -1,5 +1,9 @@ -from django.db.models.signals import m2m_changed +from django.core.cache import cache +from django.db.models.signals import m2m_changed, post_delete, post_save from django.dispatch import receiver + +from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard + from .models import Card @@ -34,24 +38,68 @@ def color_is_dark(bg_color): return brightness <= 200 -@receiver(m2m_changed, sender=Card.decks.through) +@receiver(m2m_changed, sender=Card.packs.through) def update_card_style(sender, instance, action, **kwargs): if action == "post_add": - decks = instance.decks.all() - num_decks = decks.count() - if num_decks == 1: - instance.style = "background-color: " + decks.first().hex_color + ";" - elif num_decks >= 2: - hex_colors = [deck.hex_color for deck in decks] - instance.style = ( - f"background: linear-gradient(to right, {', '.join(hex_colors)});" - ) - else: - instance.style = ( + packs = instance.packs.all() + num_packs = packs.count() + + style_parts = [] + + if num_packs == 0: + style_parts.append( "background: linear-gradient(to right, #AAAAAA, #AAAAAA, #AAAAAA);" ) - if not color_is_dark(decks.first().hex_color): - instance.style += "color: var(--color-gray-700); text-shadow: 0 0 0 var(--color-gray-700);" + style_parts.append("text-shadow: 0 0 0 #fff;") else: - instance.style += "text-shadow: 0 0 0 #fff;" + if num_packs == 1: + style_parts.append(f"background-color: {packs.first().hex_color};") + else: # num_packs >= 2 + hex_colors = [pack.hex_color for pack in packs] + gradient = f"linear-gradient(to right, {', '.join(hex_colors)})" + style_parts.append(f"background: {gradient};") + + if not color_is_dark(packs.first().hex_color): + style_parts.append("color: var(--color-gray-700);") + style_parts.append("text-shadow: 0 0 0 var(--color-gray-700);") + else: + style_parts.append("text-shadow: 0 0 0 #fff;") + + instance.style = "".join(style_parts) instance.save(update_fields=["style"]) + + +def invalidate_card_cache(instance): + """Invalidate cache for a card.""" + # Invalidate the card_badge cache + # The key is constructed as "card_badge_" + cache.delete(f"card_badge_{instance.pk}") + + # Invalidate card_multiselect cache by clearing all of them using a pattern. + # This is necessary as we can't easily reconstruct all possible keys in the signal. + cache.delete_pattern("card_multiselect_*") + + # Invalidate trade offers that contain this card in either have or want lists. + have_offers_pks = TradeOfferHaveCard.objects.filter(card=instance).values_list( + "trade_offer_id", flat=True + ) + want_offers_pks = TradeOfferWantCard.objects.filter(card=instance).values_list( + "trade_offer_id", flat=True + ) + + all_offer_pks = set(have_offers_pks) | set(want_offers_pks) + + for offer_pk in all_offer_pks: + cache.delete(f"trade_offer_{offer_pk}") + + +@receiver(post_save, sender=Card) +def on_card_save(sender, instance, **kwargs): + """Invalidate cache for a card when it's updated.""" + invalidate_card_cache(instance) + + +@receiver(post_delete, sender=Card) +def on_card_delete(sender, instance, **kwargs): + """Invalidate cache for a card when it's deleted.""" + invalidate_card_cache(instance) diff --git a/src/pkmntrade_club/cards/templatetags/card_badge.py b/src/pkmntrade_club/cards/templatetags/card_badge.py index adc2930..23c5d6c 100644 --- a/src/pkmntrade_club/cards/templatetags/card_badge.py +++ b/src/pkmntrade_club/cards/templatetags/card_badge.py @@ -1,8 +1,8 @@ from django import template from django.conf import settings from django.template.loader import render_to_string -from django.utils.safestring import mark_safe from django.urls import reverse_lazy +from django.utils.safestring import mark_safe register = template.Library() @@ -17,11 +17,12 @@ def card_badge(context, card, quantity=None, expanded=False): "quantity": quantity, "style": card.style, "name": card.name, - "rarity": card.rarity_icon, - "cardset": card.cardset, + "rarity": card.rarity.icon, + "cardset": card.cardset.id, "expanded": expanded, "cache_key": f"card_badge_{card.pk}_{quantity}_{expanded}", "url": url, + "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT, } context.update(tag_context) return context @@ -37,11 +38,11 @@ def card_badge_inline(card, quantity=None): "quantity": quantity, "style": card.style, "name": card.name, - "rarity": card.rarity_icon, + "rarity": card.rarity, "cardset": card.cardset, "expanded": True, "cache_key": f"card_badge_{card.pk}_{quantity}_{True}", - "CACHE_TIMEOUT": settings.CACHE_TIMEOUT, + "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT, "url": url, } html = render_to_string("templatetags/card_badge.html", tag_context) diff --git a/src/pkmntrade_club/cards/templatetags/card_multiselect.py b/src/pkmntrade_club/cards/templatetags/card_multiselect.py index 65b43a3..7f616ef 100644 --- a/src/pkmntrade_club/cards/templatetags/card_multiselect.py +++ b/src/pkmntrade_club/cards/templatetags/card_multiselect.py @@ -1,10 +1,12 @@ -import uuid -from django import template -from pkmntrade_club.cards.models import Card -from django.db.models.query import QuerySet -import json import hashlib +import json import logging +import uuid + +from django import template +from django.db.models.query import QuerySet + +from pkmntrade_club.cards.models import Card register = template.Library() @@ -18,7 +20,7 @@ def get_item(dictionary, key): @register.simple_tag def fetch_all_cards(): """Simple tag to fetch all Card objects.""" - return Card.objects.order_by("pk").all() + return Card.objects.with_details().order_by("id").all() @register.inclusion_tag("templatetags/card_multiselect.html", takes_context=True) diff --git a/src/pkmntrade_club/cards/views.py b/src/pkmntrade_club/cards/views.py index f4c1cf2..e5f7457 100644 --- a/src/pkmntrade_club/cards/views.py +++ b/src/pkmntrade_club/cards/views.py @@ -1,12 +1,13 @@ -from django.views.generic import ( - ListView, - DetailView, -) -from pkmntrade_club.cards.models import Card -from pkmntrade_club.trades.models import TradeOffer -from pkmntrade_club.common.mixins import ReusablePaginationMixin -from django.views import View from django.shortcuts import get_object_or_404, render +from django.views import View +from django.views.generic import ( + DetailView, + ListView, +) + +from pkmntrade_club.cards.models import Card +from pkmntrade_club.common.mixins import ReusablePaginationMixin +from pkmntrade_club.trades.models import TradeOffer class CardDetailView(DetailView): @@ -14,6 +15,24 @@ class CardDetailView(DetailView): template_name = "cards/card_detail.html" context_object_name = "card" + def get_queryset(self): + qs = super().get_queryset() + # Prefetch all related data and translations to avoid N+1 queries in the template. + return qs.select_related( + "cardset", "rarity", "card_type", "pkmn_type", "weakness_type" + ).prefetch_related( + "translations", + "cardset__translations", + "rarity__translations", + "card_type__translations", + "pkmn_type__translations", + "weakness_type__translations", + "packs__translations", + "abilities__translations", + "attacks__translations", + "attacks__energy_cost__energy__translations", + ) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) card = self.get_object() @@ -94,18 +113,43 @@ class CardListView(ReusablePaginationMixin, ListView): def get_ordering(self): order = self.request.GET.get("order", "absolute") + group_by = self.request.GET.get("group_by") + + # When grouping, the ordering must match the grouping attribute. + if group_by == "cardset": + return ("cardset__name",) + elif group_by == "rarity": + # Order by level (desc) then by icon/name to keep groups together. + return "-rarity__level", "rarity__icon" + # Note: Grouping by 'pack' is complex due to M2M and would require a custom implementation. + if order == "alphabetical": return "name" elif order == "rarity": - return "-rarity_level" + return "-rarity__level" else: # absolute ordering return "id" def get_queryset(self): qs = super().get_queryset() ordering = self.get_ordering() - 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) @@ -114,55 +158,14 @@ class CardListView(ReusablePaginationMixin, ListView): context["order"] = order context["group_by"] = group_by - if group_by in ("deck", "cardset", "rarity"): - full_qs = self.get_queryset() - all_cards = list(full_qs) - flat_cards = [] - if group_by == "deck": - for card in all_cards: - for deck in card.decks.all(): - flat_cards.append({"group": deck.name, "card": card}) - flat_cards.sort(key=lambda x: x["group"].lower()) - elif group_by == "cardset": - for card in all_cards: - flat_cards.append({"group": card.cardset, "card": card}) - flat_cards.sort(key=lambda x: x["group"].lower()) - elif group_by == "rarity": - for card in all_cards: - flat_cards.append( - { - "group": card.rarity_icon, - "sort_group": card.rarity_level, - "card": card, - } - ) - flat_cards.sort(key=lambda x: x["sort_group"], reverse=True) - - 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() + # 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 diff --git a/src/pkmntrade_club/common/context_processors.py b/src/pkmntrade_club/common/context_processors.py index b286201..4ee3c97 100644 --- a/src/pkmntrade_club/common/context_processors.py +++ b/src/pkmntrade_club/common/context_processors.py @@ -1,9 +1,23 @@ +import random + from django.conf import settings def cache_settings(request): + """ + Pass cache settings to the template context. + Applies jitter to the timeouts. + """ + jitter = settings.CACHE_JITTER return { - "CACHE_TIMEOUT": settings.CACHE_TIMEOUT, + "CACHE_SHORT_TIMEOUT": settings.CACHE_SHORT_TIMEOUT + + random.randint(-jitter, jitter), + "CACHE_MEDIUM_TIMEOUT": settings.CACHE_MEDIUM_TIMEOUT + + random.randint(-jitter, jitter), + "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT + + random.randint(-jitter, jitter), + "CACHE_DEFAULT_TIMEOUT": settings.CACHE_DEFAULT_TIMEOUT + + random.randint(-jitter, jitter), } diff --git a/src/pkmntrade_club/django_project/settings.py b/src/pkmntrade_club/django_project/settings.py index eb152d8..43ec668 100644 --- a/src/pkmntrade_club/django_project/settings.py +++ b/src/pkmntrade_club/django_project/settings.py @@ -1,10 +1,12 @@ -import socket -from pathlib import Path -import environ -import os import logging +import os +import socket import sys +from pathlib import Path + +import environ from django.utils.translation import gettext_lazy as _ + from pkmntrade_club._version import __version__, get_version_info # set default values to local dev values @@ -35,7 +37,11 @@ env = environ.Env( ACCOUNT_EMAIL_VERIFICATION=(str, "none"), SCHEME=(str, "http"), REDIS_URL=(str, "redis://localhost:6379"), - CACHE_TIMEOUT=(int, 604800), + CACHE_DEFAULT_TIMEOUT=(int, 60 * 5), # 5 minutes + CACHE_SHORT_TIMEOUT=(int, 60 * 5), # 5 minutes + CACHE_MEDIUM_TIMEOUT=(int, 60 * 60 * 1), # 1 hour + CACHE_LONG_TIMEOUT=(int, 60 * 60 * 24), # 24 hours + CACHE_JITTER=(int, 30), # 30 seconds TIME_ZONE=(str, "America/Los_Angeles"), ) @@ -92,7 +98,11 @@ environ.Env.read_env(os.path.join(BASE_DIR, ".env")) SCHEME = env("SCHEME") PUBLIC_HOST = env("PUBLIC_HOST") REDIS_URL = env("REDIS_URL") -CACHE_TIMEOUT = env("CACHE_TIMEOUT") +CACHE_DEFAULT_TIMEOUT = env("CACHE_DEFAULT_TIMEOUT") +CACHE_SHORT_TIMEOUT = env("CACHE_SHORT_TIMEOUT") +CACHE_MEDIUM_TIMEOUT = env("CACHE_MEDIUM_TIMEOUT") +CACHE_LONG_TIMEOUT = env("CACHE_LONG_TIMEOUT") +CACHE_JITTER = env("CACHE_JITTER") DISABLE_SIGNUPS = env("DISABLE_SIGNUPS") DISABLE_CACHE = env("DISABLE_CACHE") diff --git a/src/pkmntrade_club/home/views.py b/src/pkmntrade_club/home/views.py index 22c66af..29fc6f7 100644 --- a/src/pkmntrade_club/home/views.py +++ b/src/pkmntrade_club/home/views.py @@ -1,14 +1,17 @@ +import logging from collections import OrderedDict -from django.views.generic import TemplateView + from django.db.models import ( + Max, Sum, ) from django.db.models.functions import Coalesce +from django.views.generic import TemplateView + +from pkmntrade_club.cards.models import Card from pkmntrade_club.trades.models import ( TradeOffer, ) -from pkmntrade_club.cards.models import Card -import logging logger = logging.getLogger(__name__) @@ -22,8 +25,10 @@ class HomePageView(TemplateView): try: # Get all cards ordered by name, exclude cards with rarity level > 5 - context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by( - "name", "rarity_level" + context["cards"] = ( + Card.objects.with_details() + .filter(rarity__level__lte=5) + .order_by("translations__name", "rarity__level") ) # Reuse base trade offer queryset for market stats @@ -33,9 +38,15 @@ class HomePageView(TemplateView): try: recent_offers_qs = base_offer_qs.order_by("-created_at")[:6] context["recent_offers"] = recent_offers_qs - 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"] = [] @@ -44,15 +55,22 @@ class HomePageView(TemplateView): # Most Offered Cards try: most_offered_cards_qs = ( - Card.objects.filter(tradeofferhavecard__isnull=False) - .filter(rarity_level__lte=5) + Card.objects.with_details() + .filter(tradeofferhavecard__isnull=False) + .filter(rarity__level__lte=5) .annotate(offer_count=Sum("tradeofferhavecard__quantity")) .order_by("-offer_count")[:6] ) context["most_offered_cards"] = most_offered_cards_qs - 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"] = [] @@ -60,15 +78,22 @@ class HomePageView(TemplateView): # Most Wanted Cards try: most_wanted_cards_qs = ( - Card.objects.filter(tradeofferwantcard__isnull=False) - .filter(rarity_level__lte=5) + Card.objects.with_details() + .filter(tradeofferwantcard__isnull=False) + .filter(rarity__level__lte=5) .annotate(offer_count=Sum("tradeofferwantcard__quantity")) .order_by("-offer_count")[:6] ) context["most_wanted_cards"] = most_wanted_cards_qs - 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"] = [] @@ -76,16 +101,23 @@ class HomePageView(TemplateView): # Least Offered Cards try: least_offered_cards_qs = ( - Card.objects.filter(rarity_level__lte=5) + Card.objects.with_details() + .filter(rarity__level__lte=5) .annotate( offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0) ) .order_by("offer_count")[:6] ) context["least_offered_cards"] = least_offered_cards_qs - 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"] = [] @@ -94,10 +126,21 @@ class HomePageView(TemplateView): featured = OrderedDict() # Featured "All" offers remains fixed at the top try: - featured["All"] = base_offer_qs.order_by("created_at")[:6] + all_featured = base_offer_qs.order_by("created_at")[:6] + featured["All"] = all_featured + latest_update = all_featured.aggregate(latest=Max("updated_at"))[ + "latest" + ] + if latest_update: + context["cache_key_featured_offers"] = ( + f"featured_all_{latest_update.timestamp()}" + ) + else: + context["cache_key_featured_offers"] = "featured_all_empty" except Exception as e: logger.error(f"Error fetching 'All' featured offers: {str(e)}") featured["All"] = [] + context["cache_key_featured_offers"] = "featured_all_error" # *** we only show All Featured Offers for now, # *** we will add rarity-tabbed featured offers later @@ -121,23 +164,6 @@ class HomePageView(TemplateView): # logger.error(f"Error processing rarity-based featured offers: {str(e)}") context["featured_offers"] = featured - # Generate a cache key based on the pks and updated_at timestamps of all featured offers - # *** we will separate cache keys for each featured section later - all_offer_identifiers = [] - for section_name, section_offers in featured.items(): - # featured_section is a QuerySet. Fetch (pk, updated_at) tuples. - identifiers = section_offers.values_list("pk", "updated_at") - # Format each tuple as "pk_timestamp" and add to the list - section_strings = [ - f"{section_name}_{pk}_{ts.timestamp()}" for pk, ts in identifiers - ] - all_offer_identifiers.extend(section_strings) - - # Join all identifiers into a single string, sorted for consistency regardless of order - combined_identifiers = "|".join(sorted(all_offer_identifiers)) - context["cache_key_featured_offers"] = ( - f"featured_offers_{combined_identifiers}" - ) except Exception as e: logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}") # Provide fallback empty data diff --git a/src/pkmntrade_club/theme/templates/base.html b/src/pkmntrade_club/theme/templates/base.html index 7098131..c97662f 100644 --- a/src/pkmntrade_club/theme/templates/base.html +++ b/src/pkmntrade_club/theme/templates/base.html @@ -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/_card_list.html b/src/pkmntrade_club/theme/templates/home/_card_list.html index 2669cca..0681317 100644 --- a/src/pkmntrade_club/theme/templates/home/_card_list.html +++ b/src/pkmntrade_club/theme/templates/home/_card_list.html @@ -3,6 +3,7 @@
{% for card in cards %} {% card_badge card quantity=card.offer_count expanded=True %} +
{{ card.rarity.icon }}
{% endfor %}
{% else %} 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 index a1c0b3b..1361ea7 100644 --- a/src/pkmntrade_club/theme/templatetags/_card_multiselect_options.html +++ b/src/pkmntrade_club/theme/templatetags/_card_multiselect_options.html @@ -7,23 +7,25 @@ value="{{ card.pk }}:{{ selected_cards|get_item:card_id_str }}" data-card-id="{{ card.pk }}" data-quantity="{{ selected_cards|get_item:card_id_str }}" + data-rarity="{{ card.rarity.level }}" + data-cardset="{{ card.cardset.id }}" + data-packs="{% for pack in card.packs.all %}{{ pack.id }},{% endfor %}" selected data-html-content='
{{ card|card_badge_inline:selected_cards|get_item:card_id_str }}
' - data-name="{{ card.name }}" - data-rarity="{{ card.rarity_icon }}" - data-cardset="{{ card.cardset }}"> - {{ card.name }} {{ card.rarity_icon }} {{ card.cardset }} + data-name="{{ card.name }}"> + {{ card.name }} ({{ card.id }}) {% else %} {% endif %} {% endwith %} diff --git a/src/pkmntrade_club/theme/templatetags/card_badge.html b/src/pkmntrade_club/theme/templatetags/card_badge.html index 721bec8..f1f9774 100644 --- a/src/pkmntrade_club/theme/templatetags/card_badge.html +++ b/src/pkmntrade_club/theme/templatetags/card_badge.html @@ -1,5 +1,5 @@ {% load cache %} -{% cache CACHE_TIMEOUT card_badge cache_key %} +{% cache CACHE_LONG_TIMEOUT "card_badge" card.pk %}
{% if not expanded %} diff --git a/src/pkmntrade_club/theme/templatetags/card_multiselect.html b/src/pkmntrade_club/theme/templatetags/card_multiselect.html index f69fd1d..26b2920 100644 --- a/src/pkmntrade_club/theme/templatetags/card_multiselect.html +++ b/src/pkmntrade_club/theme/templatetags/card_multiselect.html @@ -5,7 +5,7 @@ {{ label }} - {% cache CACHE_LONG_TIMEOUT "card_multiselect" field_name label placeholder passed_cards_identifier selected_cards_key_part %} - {% if has_passed_cards %} - {% include "templatetags/_card_multiselect_options.html" with cards_to_render=passed_cards selected_cards=selected_cards placeholder=placeholder %} - {% else %} - {% fetch_all_cards as all_db_cards %} - {% include "templatetags/_card_multiselect_options.html" with cards_to_render=all_db_cards selected_cards=selected_cards placeholder=placeholder %} - {% endif %} - {% endcache %} - \ 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_offer_old.html b/src/pkmntrade_club/theme/templatetags/trade_offer_old.html deleted file mode 100644 index 528ffcc..0000000 --- a/src/pkmntrade_club/theme/templatetags/trade_offer_old.html +++ /dev/null @@ -1,260 +0,0 @@ -{% load gravatar card_badge cache %} - -{% cache 60 trade_offer offer_pk %} -
- -{% endcache %} \ No newline at end of file