Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecb060af6d | |||
| af2f48a491 | |||
| 39a002e394 | |||
| 4b9e4f651e | |||
| 30ce126a07 |
86 changed files with 6186 additions and 15295 deletions
35
.cursorrules
35
.cursorrules
|
|
@ -10,9 +10,9 @@ Key Principles
|
||||||
|
|
||||||
Django/Python
|
Django/Python
|
||||||
|
|
||||||
- Use Django’s class-based views (CBVs) for more complex views; prefer function-based views (FBVs) for simpler logic.
|
- Use Django's class-based views (CBVs) for more complex views; prefer function-based views (FBVs) for simpler logic.
|
||||||
- Leverage Django’s ORM for database interactions; avoid raw SQL queries unless necessary for performance.
|
- Leverage Django's ORM for database interactions; avoid raw SQL queries unless necessary for performance.
|
||||||
- Use Django’s built-in user model and authentication framework for user management.
|
- Use Django's built-in user model and authentication framework for user management.
|
||||||
- Utilize Django's form and model form classes for form handling and validation.
|
- Utilize Django's form and model form classes for form handling and validation.
|
||||||
- Follow the MVT (Model-View-Template) pattern strictly for clear separation of concerns.
|
- Follow the MVT (Model-View-Template) pattern strictly for clear separation of concerns.
|
||||||
- Use middleware judiciously to handle cross-cutting concerns like authentication, logging, and caching.
|
- Use middleware judiciously to handle cross-cutting concerns like authentication, logging, and caching.
|
||||||
|
|
@ -25,20 +25,29 @@ Error Handling and Validation
|
||||||
- Customize error pages (e.g., 404, 500) to improve user experience and provide helpful information.
|
- Customize error pages (e.g., 404, 500) to improve user experience and provide helpful information.
|
||||||
- Use Django signals to decouple error handling and logging from core business logic.
|
- Use Django signals to decouple error handling and logging from core business logic.
|
||||||
|
|
||||||
|
Development, Testing, and Operations
|
||||||
|
|
||||||
|
- Use Gatus for service health monitoring and status pages.
|
||||||
|
- Employ Locust for load testing to ensure application scalability and performance under stress.
|
||||||
|
- Utilize Playwright for end-to-end testing to simulate user interactions and validate application behavior from the user's perspective.
|
||||||
|
|
||||||
Dependencies
|
Dependencies
|
||||||
|
|
||||||
- Django
|
- Django
|
||||||
- Django REST Framework (for API development)
|
|
||||||
- Celery (for background tasks)
|
- Celery (for background tasks)
|
||||||
- Redis (for caching and task queues)
|
- Redis (for caching and task queues)
|
||||||
- PostgreSQL or MySQL (preferred databases for production)
|
- PostgreSQL or MySQL (preferred databases for production)
|
||||||
|
- Granian / Gunicorn (for serving the application)
|
||||||
|
- Whitenoise (for serving static files)
|
||||||
- Tailwind CSS for the frontend
|
- Tailwind CSS for the frontend
|
||||||
- Django Crispy Forms for the frontend
|
|
||||||
- Django Allauth for authentication
|
- Django Allauth for authentication
|
||||||
- Django DaisyUI for the frontend
|
- Django Crispy Forms for the frontend
|
||||||
- Django El Pagination for the frontend
|
- Crispy Tailwind for Tailwind-compatible Crispy Forms
|
||||||
|
- Django DaisyUI for the admin frontend
|
||||||
- Django Widget Tweaks for the frontend
|
- Django Widget Tweaks for the frontend
|
||||||
- Django Crispy Tailwind for the frontend
|
- django-debug-toolbar for debugging
|
||||||
|
- django-health-check for application health monitoring
|
||||||
|
- django-parler for multilingual support
|
||||||
|
|
||||||
Django-Specific Guidelines
|
Django-Specific Guidelines
|
||||||
|
|
||||||
|
|
@ -46,17 +55,17 @@ Django-Specific Guidelines
|
||||||
- Keep business logic in models and forms; keep views light and focused on request handling.
|
- Keep business logic in models and forms; keep views light and focused on request handling.
|
||||||
- Use Django's URL dispatcher (urls.py) to define clear and RESTful URL patterns.
|
- Use Django's URL dispatcher (urls.py) to define clear and RESTful URL patterns.
|
||||||
- Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention).
|
- Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention).
|
||||||
- Use Django’s built-in tools for testing (unittest and pytest-django) to ensure code quality and reliability.
|
- Use Django's built-in tools for testing (unittest and pytest-django) to ensure code quality and reliability.
|
||||||
- Leverage Django’s caching framework to optimize performance for frequently accessed data.
|
- Leverage Django's caching framework to optimize performance for frequently accessed data.
|
||||||
- Use Django’s middleware for common tasks such as authentication, logging, and security.
|
- Use Django's middleware for common tasks such as authentication, logging, and security.
|
||||||
|
|
||||||
Performance Optimization
|
Performance Optimization
|
||||||
|
|
||||||
- Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching.
|
- Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching.
|
||||||
- Use Django’s cache framework with backend support (e.g., Redis or Memcached) to reduce database load.
|
- Use Django's cache framework with backend support (e.g., Redis or Memcached) to reduce database load.
|
||||||
- Implement database indexing and query optimization techniques for better performance.
|
- Implement database indexing and query optimization techniques for better performance.
|
||||||
- Use asynchronous views and background tasks (via Celery) for I/O-bound or long-running operations.
|
- Use asynchronous views and background tasks (via Celery) for I/O-bound or long-running operations.
|
||||||
- Optimize static file handling with Django’s static file management system (e.g., WhiteNoise or CDN integration).
|
- Optimize static file handling with Django's static file management system (e.g., WhiteNoise or CDN integration).
|
||||||
|
|
||||||
Key Conventions
|
Key Conventions
|
||||||
|
|
||||||
|
|
|
||||||
65
.github/workflows/build_deploy.yml
vendored
65
.github/workflows/build_deploy.yml
vendored
|
|
@ -103,6 +103,7 @@ jobs:
|
||||||
- name: Extract version for Docker build
|
- name: Extract version for Docker build
|
||||||
id: extract_version
|
id: extract_version
|
||||||
run: |
|
run: |
|
||||||
|
pip uninstall setuptools
|
||||||
pip install setuptools-scm
|
pip install setuptools-scm
|
||||||
VERSION=$(python -c "from setuptools_scm import get_version; print(get_version())")
|
VERSION=$(python -c "from setuptools_scm import get_version; print(get_version())")
|
||||||
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
||||||
|
|
@ -133,9 +134,9 @@ jobs:
|
||||||
|
|
||||||
# Job 2: Deploy (only runs on main branch or tags)
|
# Job 2: Deploy (only runs on main branch or tags)
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
#needs: build
|
||||||
runs-on: ubuntu-latest
|
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
|
# Determine environment based on ref
|
||||||
environment: ${{ (startsWith(github.ref, 'refs/tags/v') && !endsWith(github.ref, '-prerelease')) && 'production' || 'staging' }}
|
environment: ${{ (startsWith(github.ref, 'refs/tags/v') && !endsWith(github.ref, '-prerelease')) && 'production' || 'staging' }}
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -173,8 +174,13 @@ jobs:
|
||||||
echo "📝 Setting deployment environment variables"
|
echo "📝 Setting deployment environment variables"
|
||||||
echo "REPO_PROJECT_PATH=${REPO_PROJECT_PATH}" >> $GITHUB_ENV
|
echo "REPO_PROJECT_PATH=${REPO_PROJECT_PATH}" >> $GITHUB_ENV
|
||||||
echo "REPO_NAME_ONLY=${REPO_NAME_ONLY}" >> $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 "IMAGE_TAR_NAME=${REPO_NAME_ONLY}-${{ github.ref_name }}_${{ github.sha }}.tar" >> $GITHUB_ENV
|
||||||
echo "PROD=${prod_value}" >> $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
|
- name: Download container artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
|
@ -217,35 +223,40 @@ jobs:
|
||||||
env:
|
env:
|
||||||
DOCKER_HOST: ssh://deploy
|
DOCKER_HOST: ssh://deploy
|
||||||
REPO_PROJECT_PATH: ${{ env.REPO_PROJECT_PATH }}
|
REPO_PROJECT_PATH: ${{ env.REPO_PROJECT_PATH }}
|
||||||
|
REPO: ${{ env.REPO }}
|
||||||
REPO_NAME_ONLY: ${{ env.REPO_NAME_ONLY }}
|
REPO_NAME_ONLY: ${{ env.REPO_NAME_ONLY }}
|
||||||
IMAGE_TAR: ${{ runner.temp }}/${{ env.IMAGE_TAR_NAME }}
|
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: |
|
run: |
|
||||||
echo "✅ Exit script on any error"
|
echo "✅ Exit script on any error"
|
||||||
set -eu -o pipefail
|
set -eu -o pipefail
|
||||||
./scripts/deploy-to-server.sh
|
./scripts/deploy-blue-green.sh
|
||||||
|
|
||||||
- name: Health Check and Rollback
|
# - name: Health Check and Rollback
|
||||||
run: |
|
# run: |
|
||||||
# Determine the correct URL based on environment
|
# # Determine the correct URL based on environment
|
||||||
if [ "${{ env.PROD }}" = "true" ]; then
|
# if [ "${{ env.PROD }}" = "true" ]; then
|
||||||
# Ensure PRODUCTION_DOMAIN is set
|
# # Ensure PRODUCTION_DOMAIN is set
|
||||||
if [ -z "${{ vars.PRODUCTION_DOMAIN }}" ]; then
|
# if [ -z "${{ vars.PRODUCTION_DOMAIN }}" ]; then
|
||||||
echo "Error: PRODUCTION_DOMAIN is not set"
|
# echo "Error: PRODUCTION_DOMAIN is not set"
|
||||||
exit 1
|
# exit 1
|
||||||
fi
|
# fi
|
||||||
HEALTH_CHECK_URL="https://${{ vars.PRODUCTION_DOMAIN }}/health/"
|
# HEALTH_CHECK_URL="https://${{ vars.PRODUCTION_DOMAIN }}/health/"
|
||||||
else
|
# else
|
||||||
# Ensure STAGING_DOMAIN is set
|
# # Ensure STAGING_DOMAIN is set
|
||||||
if [ -z "${{ vars.STAGING_DOMAIN }}" ]; then
|
# if [ -z "${{ vars.STAGING_DOMAIN }}" ]; then
|
||||||
echo "Error: STAGING_DOMAIN is not set"
|
# echo "Error: STAGING_DOMAIN is not set"
|
||||||
exit 1
|
# exit 1
|
||||||
fi
|
# fi
|
||||||
HEALTH_CHECK_URL="https://${{ vars.STAGING_DOMAIN }}/health/"
|
# HEALTH_CHECK_URL="https://${{ vars.STAGING_DOMAIN }}/health/"
|
||||||
fi
|
# fi
|
||||||
|
|
||||||
# Copy script to remote and execute
|
# # Copy script to remote and execute
|
||||||
scp scripts/health-check-and-rollback.sh deploy:/tmp/
|
# scp scripts/health-check-and-rollback.sh deploy:/tmp/
|
||||||
ssh deploy "chmod +x /tmp/health-check-and-rollback.sh"
|
# 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 "/tmp/health-check-and-rollback.sh '${{ env.REPO_PROJECT_PATH }}' '$HEALTH_CHECK_URL' 30"
|
||||||
ssh deploy "rm -f /tmp/health-check-and-rollback.sh"
|
# ssh deploy "rm -f /tmp/health-check-and-rollback.sh"
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
#!/usr/bin/env -S uv run
|
#!/usr/bin/env -S uv run
|
||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Run administrative tasks."""
|
"""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__))))
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
1
scripts/common-lib.sh
Symbolic link
1
scripts/common-lib.sh
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../server/scripts/common-lib.sh
|
||||||
207
scripts/deploy-blue-green.sh
Executable file
207
scripts/deploy-blue-green.sh
Executable file
|
|
@ -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}"
|
||||||
|
|
@ -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 <<EOF
|
|
||||||
# cd ${{ steps.meta.outputs.REPO_PROJECT_PATH}}
|
|
||||||
# old_container_id=$(docker compose -f docker-compose_web.yml ps -f name=web -q | tail -n1)
|
|
||||||
# docker compose -f docker-compose_web.yml up -d --no-build --no-recreate
|
|
||||||
# new_container_id=$(docker compose -f docker-compose_web.yml ps -f name=web -q | head -n1)
|
|
||||||
# # not needed, but might be useful at some point
|
|
||||||
# #new_container_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $new_container_id)
|
|
||||||
# #new_container_name=$(docker inspect -f '{{.Name}}' $new_container_id | cut -c2-)
|
|
||||||
# sleep 100 # change to wait for healthcheck in the future
|
|
||||||
# #docker compose -f docker-compose_core.yml kill -s SIGUSR2 loba
|
|
||||||
# docker stop $old_container_id
|
|
||||||
# docker rm $old_container_id
|
|
||||||
# #docker compose -f docker-compose_core.yml kill -s SIGUSR2 loba
|
|
||||||
# EOF
|
|
||||||
# else
|
|
||||||
# ssh deploy <<EOF
|
|
||||||
# cd ${{ steps.meta.outputs.REPO_PROJECT_PATH}}
|
|
||||||
# old_container_id=$(docker compose -f docker-compose_staging.yml ps -f name=web-staging -q | tail -n1)
|
|
||||||
# docker compose -f docker-compose_staging.yml up -d --no-build --no-recreate
|
|
||||||
# new_container_id=$(docker compose -f docker-compose_staging.yml ps -f name=web-staging -q | head -n1)
|
|
||||||
# # not needed, but might be useful at some point
|
|
||||||
# #new_container_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $new_container_id)
|
|
||||||
# #new_container_name=$(docker inspect -f '{{.Name}}' $new_container_id | cut -c2-)
|
|
||||||
# sleep 100 # change to wait for healthcheck in the future
|
|
||||||
# #docker compose -f docker-compose_core.yml kill -s SIGUSR2 loba
|
|
||||||
# docker stop $old_container_id
|
|
||||||
# docker rm $old_container_id
|
|
||||||
# #docker compose -f docker-compose_core.yml kill -s SIGUSR2 loba
|
|
||||||
# EOF
|
|
||||||
# fi
|
|
||||||
|
|
||||||
echo "🚀 Start the new containers"
|
|
||||||
if [ "$PROD" = "true" ]; then
|
|
||||||
retry ssh deploy "cd '${CURRENT_LINK_PATH}' && docker compose -f docker-compose_core.yml -f docker-compose_web.yml -p pkmntrade-club up -d --no-build"
|
|
||||||
else
|
|
||||||
retry ssh deploy "cd '${CURRENT_LINK_PATH}' && docker compose -f docker-compose_core.yml -f docker-compose_web.yml -f docker-compose_staging.yml -p pkmntrade-club up -d --no-build"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🧹 Prune unused Docker resources"
|
|
||||||
ssh deploy "docker system prune -f"
|
|
||||||
|
|
||||||
echo "🗑️ Clean up old releases (keep last 5)"
|
|
||||||
ssh deploy "cd '${RELEASES_PATH}' && ls -dt */ 2>/dev/null | tail -n +6 | xargs -r rm -rf || true"
|
|
||||||
|
|
||||||
echo "✅ Deployment completed. Version: ${DEPLOYMENT_TIMESTAMP}"
|
|
||||||
0
scripts/generate-docker-tags.sh
Normal file → Executable file
0
scripts/generate-docker-tags.sh
Normal file → Executable file
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
9
scripts/parse-repository-name.sh
Normal file → Executable file
9
scripts/parse-repository-name.sh
Normal file → Executable file
|
|
@ -16,14 +16,17 @@ echo "GITHUB_REPOSITORY: $GITHUB_REPOSITORY" > /dev/stderr
|
||||||
|
|
||||||
if [[ "$GITHUB_REPOSITORY" == *".git" ]]; then
|
if [[ "$GITHUB_REPOSITORY" == *".git" ]]; then
|
||||||
if [[ "$GITHUB_REPOSITORY" == "https://"* ]]; 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')
|
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
|
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')
|
REPO=$(echo "$GITHUB_REPOSITORY" | sed 's/\.git$//' | sed 's/[^a-zA-Z0-9\/-]/-/g')
|
||||||
fi
|
fi
|
||||||
else
|
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')
|
REPO=$(echo "$GITHUB_REPOSITORY" | sed 's/[^a-zA-Z0-9\/-]/-/g')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Retry function with exponential backoff
|
|
||||||
# Usage: source retry.sh && retry <command>
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
98
scripts/rollback-deployment.sh
Executable file
98
scripts/rollback-deployment.sh
Executable file
|
|
@ -0,0 +1,98 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Rollback deployment by swapping colors
|
||||||
|
# Usage: ./rollback-deployment.sh
|
||||||
|
|
||||||
|
# Source common functions
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "${SCRIPT_DIR}/common-lib.sh"
|
||||||
|
|
||||||
|
validate_deployment_env
|
||||||
|
|
||||||
|
# Get current state
|
||||||
|
STATE=$(get_deployment_state)
|
||||||
|
|
||||||
|
echo "🔍 Current deployment state: $STATE"
|
||||||
|
|
||||||
|
if [ "$STATE" = "none" ]; then
|
||||||
|
echo "❌ No active deployment found to rollback"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$STATE" != "both" ]; then
|
||||||
|
echo "❌ Rollback requires both colors to be running"
|
||||||
|
echo " Current state: only $STATE is running"
|
||||||
|
echo ""
|
||||||
|
echo " To perform a manual rollback:"
|
||||||
|
echo " 1. Find the previous release in ${RELEASES_PATH}/"
|
||||||
|
echo " 2. Update the symlink: ln -sfn <previous-release> ${CURRENT_LINK_PATH}"
|
||||||
|
echo " 3. Redeploy using: ./deploy-blue-green.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Both colors running - determine which is newer
|
||||||
|
CURRENT_COLOR=$(get_current_color)
|
||||||
|
ROLLBACK_COLOR=$(switch_color "$CURRENT_COLOR")
|
||||||
|
|
||||||
|
# Get image tags for both deployments
|
||||||
|
CURRENT_IMAGE=$(get_deployment_image_tag "$CURRENT_COLOR")
|
||||||
|
ROLLBACK_IMAGE=$(get_deployment_image_tag "$ROLLBACK_COLOR")
|
||||||
|
|
||||||
|
echo "🔄 Rolling back from $CURRENT_COLOR (newer) to $ROLLBACK_COLOR (older)"
|
||||||
|
echo " Current image: ${CURRENT_IMAGE}"
|
||||||
|
echo " Rollback image: ${ROLLBACK_IMAGE}"
|
||||||
|
|
||||||
|
# Verify the rollback image exists
|
||||||
|
if ! run_on_target "docker images -q 'badbl0cks/pkmntrade-club:${ROLLBACK_IMAGE}' | grep -q ."; then
|
||||||
|
echo "❌ Rollback image not found: badbl0cks/pkmntrade-club:${ROLLBACK_IMAGE}"
|
||||||
|
echo " The image may have been pruned. Cannot perform rollback."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify rollback color is healthy
|
||||||
|
ROLLBACK_PROJECT=$(get_project_name "$ROLLBACK_COLOR")
|
||||||
|
HEALTHY_COUNT=$(count_containers "label=com.docker.compose.project=${ROLLBACK_PROJECT} --filter status=running")
|
||||||
|
|
||||||
|
if [ "$HEALTHY_COUNT" -eq 0 ]; then
|
||||||
|
echo "❌ No healthy $ROLLBACK_COLOR containers found, cannot perform rollback"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Found $HEALTHY_COUNT healthy $ROLLBACK_COLOR containers"
|
||||||
|
|
||||||
|
# Store the release path of the current color before we change the symlink
|
||||||
|
CURRENT_RELEASE_PATH=$(readlink -f "${CURRENT_LINK_PATH}")
|
||||||
|
|
||||||
|
echo "🔄 Performing rollback..."
|
||||||
|
|
||||||
|
echo "🔎 Finding release for rollback color ($ROLLBACK_COLOR)..."
|
||||||
|
# Find the second newest release directory. This is assumed to be the rollback target.
|
||||||
|
ROLLBACK_RELEASE_PATH=$(ls -dt "${RELEASES_PATH}"/*/ | sed -n '2p' | tr -d '\n')
|
||||||
|
|
||||||
|
if [ -z "$ROLLBACK_RELEASE_PATH" ]; then
|
||||||
|
echo "❌ Could not find a previous release to rollback to in ${RELEASES_PATH}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " Found rollback release: ${ROLLBACK_RELEASE_PATH}"
|
||||||
|
|
||||||
|
echo "🔗 Switching 'current' symlink to point to rollback release..."
|
||||||
|
ln -sfn "${ROLLBACK_RELEASE_PATH}" "${CURRENT_LINK_PATH}"
|
||||||
|
|
||||||
|
# Refresh gatekeepers to switch traffic to the rollback color
|
||||||
|
refresh_gatekeepers
|
||||||
|
|
||||||
|
wait_with_countdown 10 "⏳ Waiting for traffic to stabilize on $ROLLBACK_COLOR..."
|
||||||
|
|
||||||
|
# Stop and clean up current color containers, using the correct release path
|
||||||
|
export CLEANUP_RELEASE_PATH="${CURRENT_RELEASE_PATH}"
|
||||||
|
cleanup_color_containers "$CURRENT_COLOR"
|
||||||
|
unset CLEANUP_RELEASE_PATH
|
||||||
|
|
||||||
|
# Refresh gatekeepers again to remove routes to the old color
|
||||||
|
refresh_gatekeepers
|
||||||
|
|
||||||
|
echo "✅ Rollback completed!"
|
||||||
|
echo " Active deployment: $ROLLBACK_COLOR"
|
||||||
|
echo ""
|
||||||
|
echo "📌 Note: The next deployment will now deploy as $CURRENT_COLOR"
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"model": "cards.deck",
|
|
||||||
"pk": 1,
|
|
||||||
"fields": {
|
|
||||||
"name": "Promo-A",
|
|
||||||
"cardset": "Promo-A",
|
|
||||||
"hex_color": "#1070EB",
|
|
||||||
"created_at": "2025-02-16T07:55:34.988Z",
|
|
||||||
"updated_at": "2025-02-16T07:55:34.988Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "cards.deck",
|
|
||||||
"pk": 2,
|
|
||||||
"fields": {
|
|
||||||
"name": "Genetic Apex: Mewtwo",
|
|
||||||
"cardset": "A1",
|
|
||||||
"hex_color": "#8040E0",
|
|
||||||
"created_at": "2025-02-16T07:54:57.445Z",
|
|
||||||
"updated_at": "2025-02-16T07:54:57.445Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "cards.deck",
|
|
||||||
"pk": 3,
|
|
||||||
"fields": {
|
|
||||||
"name": "Genetic Apex: Charizard",
|
|
||||||
"cardset": "A1",
|
|
||||||
"hex_color": "#E00202",
|
|
||||||
"created_at": "2025-02-16T07:54:52.381Z",
|
|
||||||
"updated_at": "2025-02-16T07:54:52.381Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "cards.deck",
|
|
||||||
"pk": 4,
|
|
||||||
"fields": {
|
|
||||||
"name": "Genetic Apex: Pikachu",
|
|
||||||
"cardset": "A1",
|
|
||||||
"hex_color": "#FCF326",
|
|
||||||
"created_at": "2025-02-16T07:55:05.097Z",
|
|
||||||
"updated_at": "2025-02-16T07:55:05.097Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "cards.deck",
|
|
||||||
"pk": 5,
|
|
||||||
"fields": {
|
|
||||||
"name": "Mythical Island",
|
|
||||||
"cardset": "A1a",
|
|
||||||
"hex_color": "#20AA80",
|
|
||||||
"created_at": "2025-02-16T07:55:11.916Z",
|
|
||||||
"updated_at": "2025-02-16T07:55:11.916Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "cards.deck",
|
|
||||||
"pk": 6,
|
|
||||||
"fields": {
|
|
||||||
"name": "Space-Time Smackdown: Dialga",
|
|
||||||
"cardset": "A2",
|
|
||||||
"hex_color": "#302FD9",
|
|
||||||
"created_at": "2025-02-16T07:55:17.582Z",
|
|
||||||
"updated_at": "2025-02-16T07:55:17.582Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "cards.deck",
|
|
||||||
"pk": 7,
|
|
||||||
"fields": {
|
|
||||||
"name": "Space-Time Smackdown: Palkia",
|
|
||||||
"cardset": "A2",
|
|
||||||
"hex_color": "#CF36E0",
|
|
||||||
"created_at": "2025-02-16T07:55:27.503Z",
|
|
||||||
"updated_at": "2025-02-16T07:55:27.503Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "cards.deck",
|
|
||||||
"pk": 8,
|
|
||||||
"fields": {
|
|
||||||
"name": "Triumphant Light",
|
|
||||||
"cardset": "A2a",
|
|
||||||
"hex_color": "#DF8D2C",
|
|
||||||
"created_at": "2025-03-26T12:25:17.706Z",
|
|
||||||
"updated_at": "2025-03-26T12:25:17.706Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "cards.deck",
|
|
||||||
"pk": 9,
|
|
||||||
"fields": {
|
|
||||||
"name": "Shining Revelry",
|
|
||||||
"cardset": "A2b",
|
|
||||||
"hex_color": "#D7FDFC",
|
|
||||||
"created_at": "2025-03-26T12:25:17.706Z",
|
|
||||||
"updated_at": "2025-03-26T12:25:17.706Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
1
seed/0002_RarityMappings.json
Normal file
1
seed/0002_RarityMappings.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[{"model": "cards.raritymapping", "pk": 1, "fields": {"original_name": "Common", "mapped_name": "Common", "icon": "🔷", "level": 1, "created_at": "2025-06-15T03:51:40.147Z", "updated_at": "2025-06-15T03:51:40.147Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 2, "fields": {"original_name": "Uncommon", "mapped_name": "Uncommon", "icon": "🔷🔷", "level": 2, "created_at": "2025-06-15T03:53:12.209Z", "updated_at": "2025-06-15T03:53:12.209Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 3, "fields": {"original_name": "Rare", "mapped_name": "Rare", "icon": "🔷🔷🔷", "level": 3, "created_at": "2025-06-15T03:53:31.267Z", "updated_at": "2025-06-15T03:53:31.267Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 4, "fields": {"original_name": "Rare EX", "mapped_name": "Double Rare", "icon": "🔷🔷🔷🔷", "level": 4, "created_at": "2025-06-15T03:53:54.712Z", "updated_at": "2025-06-15T03:53:54.712Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 5, "fields": {"original_name": "Full Art", "mapped_name": "Art Rare", "icon": "⭐️", "level": 5, "created_at": "2025-06-15T03:54:26.671Z", "updated_at": "2025-06-15T03:54:26.671Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 6, "fields": {"original_name": "Full Art EX/Support", "mapped_name": "Super Rare", "icon": "⭐️⭐️", "level": 6, "created_at": "2025-06-15T03:54:58.835Z", "updated_at": "2025-06-15T03:54:58.835Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 7, "fields": {"original_name": "Immersive", "mapped_name": "Immersive Rare", "icon": "⭐️⭐️⭐️", "level": 7, "created_at": "2025-06-15T03:55:25.941Z", "updated_at": "2025-06-15T03:59:14.725Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 8, "fields": {"original_name": "Gold Crown", "mapped_name": "Ultra Rare", "icon": "👑", "level": 10, "created_at": "2025-06-15T03:56:05.786Z", "updated_at": "2025-06-15T03:56:32.728Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 9, "fields": {"original_name": "One shiny star", "mapped_name": "Shiny Rare", "icon": "✨", "level": 8, "created_at": "2025-06-15T03:57:03.342Z", "updated_at": "2025-06-15T03:59:04.136Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 10, "fields": {"original_name": "Two shiny stars", "mapped_name": "Shiny Super Rare", "icon": "✨✨", "level": 9, "created_at": "2025-06-15T03:57:33.360Z", "updated_at": "2025-06-15T03:57:51.004Z", "deleted_at": null}}, {"model": "cards.raritymapping", "pk": 11, "fields": {"original_name": "Two shiny star", "mapped_name": "Shiny Super Rare", "icon": "✨✨", "level": 9, "created_at": "2025-06-15T03:58:10.204Z", "updated_at": "2025-06-15T03:58:10.204Z", "deleted_at": null}}]
|
||||||
79
seed/0003_CardSetColorMappings.json
Normal file
79
seed/0003_CardSetColorMappings.json
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "cards.cardsetcolormapping",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"cardset_id": "A3a",
|
||||||
|
"hex_color": "#FA1A1A",
|
||||||
|
"created_at": "2025-06-20T05:48:33.579Z",
|
||||||
|
"updated_at": "2025-06-20T06:07:05.636Z",
|
||||||
|
"deleted_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "cards.cardsetcolormapping",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"cardset_id": "A3",
|
||||||
|
"hex_color": "#0B47C6",
|
||||||
|
"created_at": "2025-06-20T05:49:48.100Z",
|
||||||
|
"updated_at": "2025-06-20T06:06:37.342Z",
|
||||||
|
"deleted_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "cards.cardsetcolormapping",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"cardset_id": "A2b",
|
||||||
|
"hex_color": "#B3D6EE",
|
||||||
|
"created_at": "2025-06-20T05:57:08.639Z",
|
||||||
|
"updated_at": "2025-06-20T06:06:19.207Z",
|
||||||
|
"deleted_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "cards.cardsetcolormapping",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"cardset_id": "A2a",
|
||||||
|
"hex_color": "#EA9706",
|
||||||
|
"created_at": "2025-06-20T05:58:45.284Z",
|
||||||
|
"updated_at": "2025-06-20T06:05:40.057Z",
|
||||||
|
"deleted_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "cards.cardsetcolormapping",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"cardset_id": "A2",
|
||||||
|
"hex_color": "#7A8696",
|
||||||
|
"created_at": "2025-06-20T05:59:26.177Z",
|
||||||
|
"updated_at": "2025-06-20T06:05:23.890Z",
|
||||||
|
"deleted_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "cards.cardsetcolormapping",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"cardset_id": "A1a",
|
||||||
|
"hex_color": "#31DDAA",
|
||||||
|
"created_at": "2025-06-20T06:01:35.316Z",
|
||||||
|
"updated_at": "2025-06-20T06:05:06.221Z",
|
||||||
|
"deleted_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "cards.cardsetcolormapping",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"cardset_id": "A1",
|
||||||
|
"hex_color": "#7911F0",
|
||||||
|
"created_at": "2025-06-20T06:03:51.759Z",
|
||||||
|
"updated_at": "2025-06-20T06:04:48.969Z",
|
||||||
|
"deleted_at": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
12717
seed/0003_Cards.json
12717
seed/0003_Cards.json
File diff suppressed because it is too large
Load diff
|
|
@ -53,6 +53,8 @@ services:
|
||||||
done
|
done
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
labels:
|
||||||
|
- "deployment.core=true"
|
||||||
loba:
|
loba:
|
||||||
image: haproxy:3.1
|
image: haproxy:3.1
|
||||||
stop_signal: SIGTERM
|
stop_signal: SIGTERM
|
||||||
|
|
@ -64,11 +66,14 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg
|
- ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg
|
||||||
- ./certs:/certs
|
- ./certs:/certs
|
||||||
|
labels:
|
||||||
|
- "deployment.core=true"
|
||||||
feedback:
|
feedback:
|
||||||
restart: always
|
restart: always
|
||||||
image: getfider/fider:stable
|
image: getfider/fider:stable
|
||||||
labels:
|
labels:
|
||||||
- "enable_gatekeeper=true"
|
- "enable_gatekeeper=true"
|
||||||
|
- "deployment.core=true"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
# cadvisor:
|
# cadvisor:
|
||||||
|
|
@ -91,6 +96,8 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
labels:
|
||||||
|
- "deployment.core=true"
|
||||||
dockergen-health:
|
dockergen-health:
|
||||||
image: nginxproxy/docker-gen:latest
|
image: nginxproxy/docker-gen:latest
|
||||||
command: -wait 15s -watch /gatus/config.template.yaml /gatus/config.yaml
|
command: -wait 15s -watch /gatus/config.template.yaml /gatus/config.yaml
|
||||||
|
|
@ -98,6 +105,8 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||||
- ./gatus:/gatus
|
- ./gatus:/gatus
|
||||||
|
labels:
|
||||||
|
- "deployment.core=true"
|
||||||
dockergen-gatekeeper:
|
dockergen-gatekeeper:
|
||||||
image: nginxproxy/docker-gen:latest
|
image: nginxproxy/docker-gen:latest
|
||||||
command: -wait 15s -watch /gatekeeper/gatekeepers.template.yml /gatekeeper/gatekeepers.yml -notify-sighup pkmntrade-club-gatekeeper-manager-1
|
command: -wait 15s -watch /gatekeeper/gatekeepers.template.yml /gatekeeper/gatekeepers.yml -notify-sighup pkmntrade-club-gatekeeper-manager-1
|
||||||
|
|
@ -105,6 +114,8 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||||
- ./:/gatekeeper
|
- ./:/gatekeeper
|
||||||
|
labels:
|
||||||
|
- "deployment.core=true"
|
||||||
gatekeeper-manager:
|
gatekeeper-manager:
|
||||||
image: docker:latest
|
image: docker:latest
|
||||||
restart: always
|
restart: always
|
||||||
|
|
@ -115,6 +126,8 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- REFRESH_INTERVAL=60
|
- REFRESH_INTERVAL=60
|
||||||
entrypoint: ["/bin/sh", "-c"]
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
labels:
|
||||||
|
- "deployment.core=true"
|
||||||
command:
|
command:
|
||||||
- |
|
- |
|
||||||
set -eu -o pipefail
|
set -eu -o pipefail
|
||||||
|
|
@ -239,7 +252,7 @@ services:
|
||||||
echo "$(date +'%Y-%m-%d %H:%M:%S') [INFO]: Periodic healthcheck and refresh triggered."
|
echo "$(date +'%Y-%m-%d %H:%M:%S') [INFO]: Periodic healthcheck and refresh triggered."
|
||||||
|
|
||||||
if [ ! -f "$$COMPOSE_FILE_PATH" ]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -254,9 +267,21 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
labels:
|
labels:
|
||||||
- "enable_gatekeeper=true"
|
- "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_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- GATUS_DELAY_START_SECONDS=30
|
- GATUS_DELAY_START_SECONDS=30
|
||||||
volumes:
|
volumes:
|
||||||
- ./gatus:/config
|
- ./gatus:/config
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: pkmntrade-club_network
|
||||||
|
external: true
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
x-common: &common
|
x-common: &common
|
||||||
|
image: badbl0cks/pkmntrade-club:${IMAGE_TAG:-stable}
|
||||||
|
#image: ghcr.io/xe/x/httpdebug
|
||||||
|
#entrypoint: ["/ko-app/httpdebug", "--bind", ":8000"]
|
||||||
restart: always
|
restart: always
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
@ -6,31 +9,42 @@ x-common: &common
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
<<: *common
|
<<: *common
|
||||||
image: ghcr.io/xe/x/httpdebug
|
environment:
|
||||||
entrypoint: ["/ko-app/httpdebug", "--bind", ":8000"]
|
- DEBUG=False
|
||||||
#image: badbl0cks/pkmntrade-club:stable
|
- 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:
|
environment:
|
||||||
- DEBUG=False
|
- DEBUG=False
|
||||||
- DISABLE_SIGNUPS=True
|
- DISABLE_SIGNUPS=True
|
||||||
- PUBLIC_HOST=pkmntrade.club
|
- PUBLIC_HOST=pkmntrade.club
|
||||||
- ALLOWED_HOSTS=pkmntrade.club,127.0.0.1
|
- ALLOWED_HOSTS=pkmntrade.club,127.0.0.1
|
||||||
|
- DEPLOYMENT_COLOR=${DEPLOYMENT_COLOR:-blue}
|
||||||
labels:
|
labels:
|
||||||
- "enable_gatekeeper=true"
|
- "deployment.color=${DEPLOYMENT_COLOR:-blue}"
|
||||||
deploy:
|
- "deployment.image_tag=${IMAGE_TAG:-stable}"
|
||||||
mode: replicated
|
command: ["celery", "-A", "pkmntrade_club.django_project", "worker", "-l", "INFO", "-B", "-E"]
|
||||||
replicas: 4
|
stop_grace_period: 200s # match our longest stop_grace_period (currently web service is 200s)
|
||||||
# healthcheck:
|
|
||||||
# test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/health"]
|
networks:
|
||||||
# interval: 30s
|
default:
|
||||||
# timeout: 10s
|
name: pkmntrade-club_network
|
||||||
# retries: 3
|
external: true
|
||||||
# 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"]
|
|
||||||
|
|
@ -1,23 +1,52 @@
|
||||||
services:
|
services:
|
||||||
{{ $all_containers := whereLabelValueMatches . "enable_gatekeeper" "true" }}
|
{{ $all_containers := whereLabelValueMatches . "enable_gatekeeper" "true" }}
|
||||||
{{ $all_containers = sortObjectsByKeysAsc $all_containers "Name" }}
|
|
||||||
|
|
||||||
{{ range $container := $all_containers }}
|
# 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 := $color_containers }}
|
||||||
{{ $serviceLabel := index $container.Labels "com.docker.compose.service" }}
|
{{ $serviceLabel := index $container.Labels "com.docker.compose.service" }}
|
||||||
{{ $containerNumber := index $container.Labels "com.docker.compose.container-number" }}
|
{{ $containerNumber := index $container.Labels "com.docker.compose.container-number" }}
|
||||||
|
{{ $deploymentColor := index $container.Labels "deployment.color" }}
|
||||||
{{ $port := "" }}
|
{{ $port := "" }}
|
||||||
{{ if eq $serviceLabel "web" }}
|
{{ if eq $serviceLabel "web" }}
|
||||||
{{ $port = ":8000" }}
|
{{ $port = ":8000" }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if eq $serviceLabel "web-staging" }}
|
gatekeeper-{{ $serviceLabel }}-{{ $deploymentColor }}-{{ $containerNumber }}:
|
||||||
{{ $port = ":8000" }}
|
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 }}
|
{{ 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" }}
|
{{ if eq $serviceLabel "feedback" }}
|
||||||
{{ $port = ":3000" }}
|
{{ $port = ":3000" }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if eq $serviceLabel "health" }}
|
{{ if eq $serviceLabel "health" }}
|
||||||
{{ $port = ":8080" }}
|
{{ $port = ":8080" }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ if or (eq $serviceLabel "feedback") (eq $serviceLabel "health") }}
|
||||||
gatekeeper-{{ $serviceLabel }}-{{ $containerNumber }}:
|
gatekeeper-{{ $serviceLabel }}-{{ $containerNumber }}:
|
||||||
image: ghcr.io/techarohq/anubis:latest
|
image: ghcr.io/techarohq/anubis:latest
|
||||||
container_name: pkmntrade-club-gatekeeper-{{ $serviceLabel }}-{{ $containerNumber }}
|
container_name: pkmntrade-club-gatekeeper-{{ $serviceLabel }}-{{ $containerNumber }}
|
||||||
|
|
@ -25,12 +54,6 @@ services:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- TARGET=http://{{ $container.Name }}{{ $port }}
|
- 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:
|
labels:
|
||||||
- gatekeeper=true
|
- gatekeeper=true
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -39,7 +62,9 @@ services:
|
||||||
- pkmntrade-club-gatekeeper-{{ $serviceLabel }}
|
- pkmntrade-club-gatekeeper-{{ $serviceLabel }}
|
||||||
- gatekeeper-{{ $serviceLabel }}
|
- gatekeeper-{{ $serviceLabel }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: pkmntrade-club_default
|
name: pkmntrade-club_network
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
|
|
@ -92,20 +92,15 @@ endpoints:
|
||||||
- type: email
|
- type: email
|
||||||
{{ $all_containers := . }}
|
{{ $all_containers := . }}
|
||||||
{{ $web_containers := list }}
|
{{ $web_containers := list }}
|
||||||
{{ $web_staging_containers := list }}
|
|
||||||
|
|
||||||
{{ range $container := $all_containers }}
|
{{ range $container := $all_containers }}
|
||||||
{{ $serviceLabel := index $container.Labels "com.docker.compose.service" }}
|
{{ $serviceLabel := index $container.Labels "com.docker.compose.service" }}
|
||||||
{{ if eq $serviceLabel "web" }}
|
{{ if eq $serviceLabel "web" }}
|
||||||
{{ $web_containers = append $web_containers $container }}
|
{{ $web_containers = append $web_containers $container }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if eq $serviceLabel "web-staging" }}
|
|
||||||
{{ $web_staging_containers = append $web_staging_containers $container }}
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ $web_containers = sortObjectsByKeysAsc $web_containers "Name" }}
|
{{ $web_containers = sortObjectsByKeysAsc $web_containers "Name" }}
|
||||||
{{ $web_staging_containers = sortObjectsByKeysAsc $web_staging_containers "Name" }}
|
|
||||||
|
|
||||||
{{ range $container := $web_containers }}
|
{{ range $container := $web_containers }}
|
||||||
{{ $containerNumber := index $container.Labels "com.docker.compose.container-number" }}
|
{{ $containerNumber := index $container.Labels "com.docker.compose.container-number" }}
|
||||||
|
|
@ -113,22 +108,7 @@ endpoints:
|
||||||
group: Main
|
group: Main
|
||||||
url: "http://{{ $container.Name }}:8000/health/"
|
url: "http://{{ $container.Name }}:8000/health/"
|
||||||
headers:
|
headers:
|
||||||
Host: "pkmntrade.club"
|
Host: "${DOMAIN_NAME}"
|
||||||
interval: 60s
|
|
||||||
conditions:
|
|
||||||
- "[STATUS] == 200"
|
|
||||||
# - "[BODY] == OK/HEALTHY"
|
|
||||||
alerts:
|
|
||||||
- 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
|
interval: 60s
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
|
|
|
||||||
|
|
@ -25,32 +25,27 @@ frontend haproxy_entrypoint
|
||||||
bind :443 ssl crt /certs/crt.pem verify required ca-file /certs/ca.pem
|
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
|
use_backend %[req.hdr(host),lower,word(1,:)] # strip out port from host
|
||||||
|
|
||||||
frontend checks
|
frontend healthchecks
|
||||||
bind :80
|
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"
|
http-request return status 200 content-type "text/plain" lf-string "OK/HEALTHY"
|
||||||
|
|
||||||
backend pkmntrade.club
|
backend "${DOMAIN_NAME}"
|
||||||
balance leastconn
|
balance leastconn
|
||||||
http-request set-header Host pkmntrade.club
|
http-request set-header Host "${DOMAIN_NAME}"
|
||||||
server-template gatekeeper-web- 4 gatekeeper-web:8000 check resolvers docker_resolver init-addr libc,none
|
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
|
balance leastconn
|
||||||
http-request set-header Host staging.pkmntrade.club
|
http-request set-header Host feedback."${BASE_DOMAIN_NAME}"
|
||||||
server-template gatekeeper-web-staging- 4 gatekeeper-web-staging:8000 check resolvers docker_resolver init-addr libc,none
|
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
|
balance leastconn
|
||||||
http-request set-header Host feedback.pkmntrade.club
|
http-request set-header Host health."${BASE_DOMAIN_NAME}"
|
||||||
server-template gatekeeper-feedback- 4 gatekeeper-feedback:8000 check resolvers docker_resolver init-addr libc,none
|
server-template gatekeeper-health- 1 gatekeeper-health:8000 check resolvers docker_resolver init-addr none
|
||||||
|
|
||||||
backend health.pkmntrade.club
|
|
||||||
balance leastconn
|
|
||||||
http-request set-header Host health.pkmntrade.club
|
|
||||||
server-template gatekeeper-health- 4 gatekeeper-health:8000 check resolvers docker_resolver init-addr libc,none
|
|
||||||
|
|
||||||
#EOF - trailing newline required
|
#EOF - trailing newline required
|
||||||
|
|
||||||
|
|
|
||||||
382
server/scripts/common-lib.sh
Executable file
382
server/scripts/common-lib.sh
Executable file
|
|
@ -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 " " "$@"
|
||||||
|
}
|
||||||
377
server/scripts/manage.sh
Executable file
377
server/scripts/manage.sh
Executable file
|
|
@ -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
|
||||||
|
|
@ -2,4 +2,4 @@
|
||||||
|
|
||||||
from pkmntrade_club._version import __version__, get_version, get_version_info
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from importlib.metadata import version, PackageNotFoundError
|
from importlib.metadata import version, PackageNotFoundError
|
||||||
from setuptools_scm import get_version
|
from setuptools_scm import get_version
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Version module for pkmntrade.club
|
Version module for pkmntrade.club
|
||||||
|
|
||||||
|
|
@ -11,51 +12,53 @@ try:
|
||||||
except PackageNotFoundError:
|
except PackageNotFoundError:
|
||||||
# Package is not installed, try to get version from setuptools_scm
|
# Package is not installed, try to get version from setuptools_scm
|
||||||
try:
|
try:
|
||||||
__version__ = get_version(root='../../..', relative_to=__file__)
|
__version__ = get_version(root="../../..", relative_to=__file__)
|
||||||
except (ImportError, LookupError):
|
except (ImportError, LookupError):
|
||||||
__version__ = "0.0.0+unknown"
|
__version__ = "0.0.0+unknown"
|
||||||
|
|
||||||
|
|
||||||
def get_version():
|
def get_version():
|
||||||
"""Return the current version."""
|
"""Return the current version."""
|
||||||
return __version__
|
return __version__
|
||||||
|
|
||||||
|
|
||||||
def get_version_info():
|
def get_version_info():
|
||||||
"""Return detailed version information."""
|
"""Return detailed version information."""
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease")
|
# Parse version string (e.g., "1.2.3", "1.2.3.dev4+gabc1234", "1.2.3-prerelease")
|
||||||
match = re.match(
|
match = re.match(
|
||||||
r'^(\d+)\.(\d+)\.(\d+)'
|
r"^(\d+)\.(\d+)\.(\d+)"
|
||||||
r'(?:\.dev(\d+))?'
|
r"(?:\.dev(\d+))?"
|
||||||
r'(?:\+g([a-f0-9]+))?'
|
r"(?:\+g([a-f0-9]+))?"
|
||||||
r'(?:-(.+))?$',
|
r"(?:-(.+))?$",
|
||||||
__version__
|
__version__,
|
||||||
)
|
)
|
||||||
|
|
||||||
if match:
|
if match:
|
||||||
major, minor, patch, dev, git_sha, prerelease = match.groups()
|
major, minor, patch, dev, git_sha, prerelease = match.groups()
|
||||||
return {
|
return {
|
||||||
'version': __version__,
|
"version": __version__,
|
||||||
'major': int(major),
|
"major": int(major),
|
||||||
'minor': int(minor),
|
"minor": int(minor),
|
||||||
'patch': int(patch),
|
"patch": int(patch),
|
||||||
'dev': int(dev) if dev else None,
|
"dev": int(dev) if dev else None,
|
||||||
'git_sha': git_sha,
|
"git_sha": git_sha,
|
||||||
'prerelease': prerelease,
|
"prerelease": prerelease,
|
||||||
'is_release': dev is None and not prerelease,
|
"is_release": dev is None and not prerelease,
|
||||||
'is_prerelease': bool(prerelease),
|
"is_prerelease": bool(prerelease),
|
||||||
'is_dev': dev is not None
|
"is_dev": dev is not None,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'version': __version__,
|
"version": __version__,
|
||||||
'major': 0,
|
"major": 0,
|
||||||
'minor': 0,
|
"minor": 0,
|
||||||
'patch': 0,
|
"patch": 0,
|
||||||
'dev': None,
|
"dev": None,
|
||||||
'git_sha': None,
|
"git_sha": None,
|
||||||
'prerelease': None,
|
"prerelease": None,
|
||||||
'is_release': False,
|
"is_release": False,
|
||||||
'is_prerelease': False,
|
"is_prerelease": False,
|
||||||
'is_dev': True
|
"is_dev": True,
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
from django.conf import settings
|
|
||||||
from allauth.account.adapter import DefaultAccountAdapter
|
from allauth.account.adapter import DefaultAccountAdapter
|
||||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
|
||||||
from .forms import CustomUserCreationForm, CustomUserChangeForm
|
from .forms import CustomUserChangeForm, CustomUserCreationForm
|
||||||
from .models import CustomUser
|
from .models import CustomUser, FriendCode
|
||||||
|
|
||||||
|
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
|
|
@ -28,3 +27,11 @@ class CustomUserAdmin(UserAdmin):
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(CustomUser, CustomUserAdmin)
|
admin.site.register(CustomUser, CustomUserAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FriendCode)
|
||||||
|
class FriendCodeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("friend_code", "in_game_name", "user")
|
||||||
|
search_fields = ("friend_code", "in_game_name", "user__username", "user__email")
|
||||||
|
list_select_related = ("user",)
|
||||||
|
autocomplete_fields = ("user",)
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class AccountsConfig(AppConfig):
|
class AccountsConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = 'pkmntrade_club.accounts'
|
name = "pkmntrade_club.accounts"
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,13 @@ from django import forms
|
||||||
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
|
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
|
||||||
from .models import CustomUser, FriendCode
|
from .models import CustomUser, FriendCode
|
||||||
from allauth.account.forms import SignupForm
|
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 CustomUserChangeForm(UserChangeForm):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
fields = ['email']
|
fields = ["email"]
|
||||||
|
|
||||||
|
|
||||||
class FriendCodeForm(forms.ModelForm):
|
class FriendCodeForm(forms.ModelForm):
|
||||||
class Meta:
|
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]}"
|
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
|
return friend_code_formatted
|
||||||
|
|
||||||
class CustomUserCreationForm(SignupForm):
|
|
||||||
|
|
||||||
|
class CustomUserCreationForm(SignupForm):
|
||||||
class Meta(UserCreationForm.Meta):
|
class Meta(UserCreationForm.Meta):
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
fields = ['email', 'username', 'friend_code']
|
fields = ["email", "username", "friend_code"]
|
||||||
|
|
||||||
email = forms.EmailField(
|
email = forms.EmailField(
|
||||||
required=True,
|
required=True,
|
||||||
label="Email",
|
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(
|
username = forms.CharField(
|
||||||
max_length=24,
|
max_length=24,
|
||||||
required=True,
|
required=True,
|
||||||
label="Username",
|
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(
|
friend_code = forms.CharField(
|
||||||
|
|
@ -51,14 +53,18 @@ class CustomUserCreationForm(SignupForm):
|
||||||
required=True,
|
required=True,
|
||||||
label="Friend Code",
|
label="Friend Code",
|
||||||
help_text="Enter your friend code in the format XXXX-XXXX-XXXX-XXXX.",
|
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(
|
in_game_name = forms.CharField(
|
||||||
max_length=16,
|
max_length=16,
|
||||||
required=True,
|
required=True,
|
||||||
label="In-Game Name",
|
label="In-Game Name",
|
||||||
help_text="Enter your 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):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
@ -78,13 +84,14 @@ class CustomUserCreationForm(SignupForm):
|
||||||
friend_code_instance = FriendCode.objects.create(
|
friend_code_instance = FriendCode.objects.create(
|
||||||
friend_code=self.cleaned_data["friend_code"],
|
friend_code=self.cleaned_data["friend_code"],
|
||||||
in_game_name=self.cleaned_data["in_game_name"],
|
in_game_name=self.cleaned_data["in_game_name"],
|
||||||
user=user
|
user=user,
|
||||||
)
|
)
|
||||||
user.default_friend_code = friend_code_instance
|
user.default_friend_code = friend_code_instance
|
||||||
user.save()
|
user.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
class UserSettingsForm(forms.ModelForm):
|
class UserSettingsForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
fields = ['show_friend_code_on_link_previews', 'enable_email_notifications']
|
fields = ["show_friend_code_on_link_previews", "enable_email_notifications"]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
cache.clear()
|
cache.clear()
|
||||||
self.stdout.write('Cleared cache\n')
|
self.stdout.write("Cleared cache\n")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated by Django 5.1 on 2025-05-17 02:07
|
# Generated by Django 5.1 on 2025-06-15 03:44
|
||||||
|
|
||||||
import django.contrib.auth.models
|
import django.contrib.auth.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
|
|
@ -14,53 +14,183 @@ class Migration(migrations.Migration):
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('auth', '0001_initial'),
|
("auth", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='CustomUser',
|
name="CustomUser",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
"id",
|
||||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
models.BigAutoField(
|
||||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
auto_created=True,
|
||||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
primary_key=True,
|
||||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
serialize=False,
|
||||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
verbose_name="ID",
|
||||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
),
|
||||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
),
|
||||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
(
|
||||||
('show_friend_code_on_link_previews', models.BooleanField(default=False, help_text='This will primarily affect share link previews on X, Discord, etc.', verbose_name='Show Friend Code on Link Previews')),
|
"last_login",
|
||||||
('enable_email_notifications', models.BooleanField(default=True, help_text='Receive trade notifications via email.', verbose_name='Enable Email Notifications')),
|
models.DateTimeField(
|
||||||
('reputation_score', models.IntegerField(default=0)),
|
blank=True, null=True, verbose_name="last login"
|
||||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
),
|
||||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
),
|
||||||
|
(
|
||||||
|
"is_superuser",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||||
|
verbose_name="superuser status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"username",
|
||||||
|
models.CharField(
|
||||||
|
error_messages={
|
||||||
|
"unique": "A user with that username already exists."
|
||||||
|
},
|
||||||
|
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||||
|
max_length=150,
|
||||||
|
unique=True,
|
||||||
|
validators=[
|
||||||
|
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||||
|
],
|
||||||
|
verbose_name="username",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"first_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=150, verbose_name="first name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"last_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=150, verbose_name="last name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"email",
|
||||||
|
models.EmailField(
|
||||||
|
blank=True, max_length=254, verbose_name="email address"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_staff",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates whether the user can log into this admin site.",
|
||||||
|
verbose_name="staff status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||||
|
verbose_name="active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"date_joined",
|
||||||
|
models.DateTimeField(
|
||||||
|
default=django.utils.timezone.now, verbose_name="date joined"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"show_friend_code_on_link_previews",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="This will primarily affect share link previews on X, Discord, etc.",
|
||||||
|
verbose_name="Show Friend Code on Link Previews",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"enable_email_notifications",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Receive trade notifications via email.",
|
||||||
|
verbose_name="Enable Email Notifications",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("reputation_score", models.IntegerField(default=0)),
|
||||||
|
(
|
||||||
|
"groups",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.group",
|
||||||
|
verbose_name="groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_permissions",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this user.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.permission",
|
||||||
|
verbose_name="user permissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'user',
|
"verbose_name": "user",
|
||||||
'verbose_name_plural': 'users',
|
"verbose_name_plural": "users",
|
||||||
'abstract': False,
|
"abstract": False,
|
||||||
},
|
},
|
||||||
managers=[
|
managers=[
|
||||||
('objects', django.contrib.auth.models.UserManager()),
|
("objects", django.contrib.auth.models.UserManager()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='FriendCode',
|
name="FriendCode",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('friend_code', models.CharField(max_length=19, validators=[pkmntrade_club.accounts.models.validate_friend_code])),
|
"id",
|
||||||
('in_game_name', models.CharField(max_length=14)),
|
models.BigAutoField(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
auto_created=True,
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
primary_key=True,
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='friend_codes', to=settings.AUTH_USER_MODEL)),
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"friend_code",
|
||||||
|
models.CharField(
|
||||||
|
max_length=19,
|
||||||
|
validators=[
|
||||||
|
pkmntrade_club.accounts.models.validate_friend_code
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("in_game_name", models.CharField(max_length=14)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="friend_codes",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='customuser',
|
model_name="customuser",
|
||||||
name='default_friend_code',
|
name="default_friend_code",
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.friendcode'),
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="accounts.friendcode",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,28 @@ from django.db import models
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
def validate_friend_code(value):
|
def validate_friend_code(value):
|
||||||
"""Validate that friend code follows the format XXXX-XXXX-XXXX-XXXX where X is a digit."""
|
"""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(
|
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):
|
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(
|
show_friend_code_on_link_previews = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name="Show Friend Code on Link Previews",
|
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(
|
enable_email_notifications = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
verbose_name="Enable Email Notifications",
|
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)
|
reputation_score = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
|
@ -47,10 +51,13 @@ class CustomUser(AbstractUser):
|
||||||
self.default_friend_code = other_codes.first()
|
self.default_friend_code = other_codes.first()
|
||||||
self.save(update_fields=["default_friend_code"])
|
self.save(update_fields=["default_friend_code"])
|
||||||
|
|
||||||
|
|
||||||
class FriendCode(models.Model):
|
class FriendCode(models.Model):
|
||||||
friend_code = models.CharField(max_length=19, validators=[validate_friend_code])
|
friend_code = models.CharField(max_length=19, validators=[validate_friend_code])
|
||||||
in_game_name = models.CharField(max_length=14, null=False, blank=False)
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,17 @@ from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def gravatar_hash(email):
|
def gravatar_hash(email):
|
||||||
"""
|
"""
|
||||||
Returns the hash of the 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()
|
email_hash = hashlib.sha256(email_encoded).hexdigest()
|
||||||
return email_hash
|
return email_hash
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def gravatar_url(email, size=20):
|
def gravatar_url(email, size=20):
|
||||||
"""
|
"""
|
||||||
|
|
@ -23,20 +25,22 @@ def gravatar_url(email, size=20):
|
||||||
"""
|
"""
|
||||||
default = "retro"
|
default = "retro"
|
||||||
email_hash = gravatar_hash(email)
|
email_hash = gravatar_hash(email)
|
||||||
params = urlencode({'d': default, 's': str(size)})
|
params = urlencode({"d": default, "s": str(size)})
|
||||||
params = params.replace("&", "&")
|
params = params.replace("&", "&")
|
||||||
return f"https://www.gravatar.com/avatar/{email_hash}?{params}"
|
return f"https://www.gravatar.com/avatar/{email_hash}?{params}"
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def gravatar_profile_url(email=None):
|
def gravatar_profile_url(email=None):
|
||||||
"""
|
"""
|
||||||
Returns the Gravatar Profile URL for a given email.
|
Returns the Gravatar Profile URL for a given email.
|
||||||
"""
|
"""
|
||||||
if email is None:
|
if email is None:
|
||||||
return f"https://www.gravatar.com/profile"
|
return "https://www.gravatar.com/profile"
|
||||||
email_hash = gravatar_hash(email)
|
email_hash = gravatar_hash(email)
|
||||||
return f"https://secure.gravatar.com/{email_hash}"
|
return f"https://secure.gravatar.com/{email_hash}"
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def gravatar(email, size=20):
|
def gravatar(email, size=20):
|
||||||
"""
|
"""
|
||||||
|
|
@ -48,6 +52,7 @@ def gravatar(email, size=20):
|
||||||
html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar"></img>'
|
html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar"></img>'
|
||||||
return mark_safe(html)
|
return mark_safe(html)
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def gravatar_no_hover(email, size=20):
|
def gravatar_no_hover(email, size=20):
|
||||||
"""
|
"""
|
||||||
|
|
@ -59,6 +64,7 @@ def gravatar_no_hover(email, size=20):
|
||||||
html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar" class="ignore"></img>'
|
html = f'<img src="{url}" width="{size}" height="{size}" alt="Gravatar" class="ignore"></img>'
|
||||||
return mark_safe(html)
|
return mark_safe(html)
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def gravatar_profile_data(email):
|
def gravatar_profile_data(email):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -9,34 +9,34 @@ from django.core.exceptions import ValidationError
|
||||||
from django.contrib.sessions.middleware import SessionMiddleware
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
|
|
||||||
from pkmntrade_club.accounts.models import FriendCode
|
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.accounts.templatetags import gravatar
|
||||||
from pkmntrade_club.trades.models import TradeOffer
|
from pkmntrade_club.trades.models import TradeOffer
|
||||||
from tests.utils.rarity import RARITY_MAPPING
|
from tests.utils.rarity import RARITY_MAPPING
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Model Tests
|
# Model Tests
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
class CustomUserModelTests(TestCase):
|
class CustomUserModelTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = get_user_model().objects.create_user(
|
self.user = get_user_model().objects.create_user(
|
||||||
username="testuser",
|
username="testuser", email="test@example.com", password="password123"
|
||||||
email="test@example.com",
|
|
||||||
password="password123"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_set_default_friend_code(self):
|
def test_set_default_friend_code(self):
|
||||||
"""User can manually set a friend code as their default."""
|
"""User can manually set a friend code as their default."""
|
||||||
fc1 = FriendCode.objects.create(
|
fc1 = FriendCode.objects.create(
|
||||||
friend_code="1234-5678-9012-3456",
|
friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne"
|
||||||
user=self.user,
|
|
||||||
in_game_name="GameOne"
|
|
||||||
)
|
)
|
||||||
fc2 = FriendCode.objects.create(
|
fc2 = FriendCode.objects.create(
|
||||||
friend_code="2345-6789-0123-4567",
|
friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo"
|
||||||
user=self.user,
|
|
||||||
in_game_name="GameTwo"
|
|
||||||
)
|
)
|
||||||
# Manually set fc2 as default.
|
# Manually set fc2 as default.
|
||||||
self.user.set_default_friend_code(fc2)
|
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.
|
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(
|
other_user = get_user_model().objects.create_user(
|
||||||
username="otheruser",
|
username="otheruser", email="other@example.com", password="password456"
|
||||||
email="other@example.com",
|
|
||||||
password="password456"
|
|
||||||
)
|
)
|
||||||
fc_other = FriendCode.objects.create(
|
fc_other = FriendCode.objects.create(
|
||||||
friend_code="3456-7890-1234-5678",
|
friend_code="3456-7890-1234-5678", user=other_user, in_game_name="OtherGame"
|
||||||
user=other_user,
|
|
||||||
in_game_name="OtherGame"
|
|
||||||
)
|
)
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
self.user.set_default_friend_code(fc_other)
|
self.user.set_default_friend_code(fc_other)
|
||||||
|
|
@ -66,14 +62,10 @@ class CustomUserModelTests(TestCase):
|
||||||
the default should be reassigned to another friend code.
|
the default should be reassigned to another friend code.
|
||||||
"""
|
"""
|
||||||
fc1 = FriendCode.objects.create(
|
fc1 = FriendCode.objects.create(
|
||||||
friend_code="1234-5678-9012-3456",
|
friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne"
|
||||||
user=self.user,
|
|
||||||
in_game_name="GameOne"
|
|
||||||
)
|
)
|
||||||
fc2 = FriendCode.objects.create(
|
fc2 = FriendCode.objects.create(
|
||||||
friend_code="2345-6789-0123-4567",
|
friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo"
|
||||||
user=self.user,
|
|
||||||
in_game_name="GameTwo"
|
|
||||||
)
|
)
|
||||||
# Set fc2 as default.
|
# Set fc2 as default.
|
||||||
self.user.set_default_friend_code(fc2)
|
self.user.set_default_friend_code(fc2)
|
||||||
|
|
@ -89,9 +81,7 @@ class CustomUserModelTests(TestCase):
|
||||||
should be prohibited.
|
should be prohibited.
|
||||||
"""
|
"""
|
||||||
fc = FriendCode.objects.create(
|
fc = FriendCode.objects.create(
|
||||||
friend_code="1234-5678-9012-3456",
|
friend_code="1234-5678-9012-3456", user=self.user, in_game_name="OnlyGame"
|
||||||
user=self.user,
|
|
||||||
in_game_name="OnlyGame"
|
|
||||||
)
|
)
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
self.assertEqual(self.user.default_friend_code, fc)
|
self.assertEqual(self.user.default_friend_code, fc)
|
||||||
|
|
@ -104,21 +94,19 @@ class CustomUserModelTests(TestCase):
|
||||||
the current default should remain unchanged.
|
the current default should remain unchanged.
|
||||||
"""
|
"""
|
||||||
fc1 = FriendCode.objects.create(
|
fc1 = FriendCode.objects.create(
|
||||||
friend_code="1234-5678-9012-3456",
|
friend_code="1234-5678-9012-3456", user=self.user, in_game_name="GameOne"
|
||||||
user=self.user,
|
|
||||||
in_game_name="GameOne"
|
|
||||||
)
|
)
|
||||||
fc2 = FriendCode.objects.create(
|
fc2 = FriendCode.objects.create(
|
||||||
friend_code="2345-6789-0123-4567",
|
friend_code="2345-6789-0123-4567", user=self.user, in_game_name="GameTwo"
|
||||||
user=self.user,
|
|
||||||
in_game_name="GameTwo"
|
|
||||||
)
|
)
|
||||||
# By default, fc1 is the default friend code.
|
# By default, fc1 is the default friend code.
|
||||||
self.assertEqual(self.user.default_friend_code, fc1)
|
self.assertEqual(self.user.default_friend_code, fc1)
|
||||||
try:
|
try:
|
||||||
self.user.remove_default_friend_code(fc2)
|
self.user.remove_default_friend_code(fc2)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
self.fail("remove_default_friend_code raised an exception when removing a non-default code.")
|
self.fail(
|
||||||
|
"remove_default_friend_code raised an exception when removing a non-default code."
|
||||||
|
)
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
self.assertEqual(self.user.default_friend_code, fc1)
|
self.assertEqual(self.user.default_friend_code, fc1)
|
||||||
|
|
||||||
|
|
@ -129,9 +117,7 @@ class CustomUserModelTests(TestCase):
|
||||||
class FriendCodeModelTests(TestCase):
|
class FriendCodeModelTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = get_user_model().objects.create_user(
|
self.user = get_user_model().objects.create_user(
|
||||||
username="testuser2",
|
username="testuser2", email="test2@example.com", password="password123"
|
||||||
email="test2@example.com",
|
|
||||||
password="password123"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_default_set_on_creation(self):
|
def test_default_set_on_creation(self):
|
||||||
|
|
@ -142,7 +128,7 @@ class FriendCodeModelTests(TestCase):
|
||||||
fc = FriendCode.objects.create(
|
fc = FriendCode.objects.create(
|
||||||
friend_code="1234-5678-9012-3456",
|
friend_code="1234-5678-9012-3456",
|
||||||
user=self.user,
|
user=self.user,
|
||||||
in_game_name="GameDefault"
|
in_game_name="GameDefault",
|
||||||
)
|
)
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
self.assertEqual(self.user.default_friend_code, fc)
|
self.assertEqual(self.user.default_friend_code, fc)
|
||||||
|
|
@ -155,14 +141,14 @@ class FriendCodeModelTests(TestCase):
|
||||||
fc1 = FriendCode.objects.create(
|
fc1 = FriendCode.objects.create(
|
||||||
friend_code="1111-1111-1111-1111",
|
friend_code="1111-1111-1111-1111",
|
||||||
user=self.user,
|
user=self.user,
|
||||||
in_game_name="PrimaryGame"
|
in_game_name="PrimaryGame",
|
||||||
)
|
)
|
||||||
# fc1 becomes the default automatically.
|
# fc1 becomes the default automatically.
|
||||||
self.assertEqual(self.user.default_friend_code, fc1)
|
self.assertEqual(self.user.default_friend_code, fc1)
|
||||||
fc2 = FriendCode.objects.create(
|
fc2 = FriendCode.objects.create(
|
||||||
friend_code="2222-2222-2222-2222",
|
friend_code="2222-2222-2222-2222",
|
||||||
user=self.user,
|
user=self.user,
|
||||||
in_game_name="SecondaryGame"
|
in_game_name="SecondaryGame",
|
||||||
)
|
)
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
self.assertEqual(self.user.default_friend_code, fc1)
|
self.assertEqual(self.user.default_friend_code, fc1)
|
||||||
|
|
@ -174,39 +160,34 @@ class FriendCodeModelTests(TestCase):
|
||||||
class FriendCodeFormTests(TestCase):
|
class FriendCodeFormTests(TestCase):
|
||||||
def test_valid_friend_code(self):
|
def test_valid_friend_code(self):
|
||||||
"""Ensure valid friend code is cleaned and formatted properly."""
|
"""Ensure valid friend code is cleaned and formatted properly."""
|
||||||
form_data = {
|
form_data = {"friend_code": "1234567890123456", "in_game_name": "GameTest"}
|
||||||
"friend_code": "1234567890123456",
|
|
||||||
"in_game_name": "GameTest"
|
|
||||||
}
|
|
||||||
form = FriendCodeForm(data=form_data)
|
form = FriendCodeForm(data=form_data)
|
||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
self.assertEqual(form.cleaned_data["friend_code"], "1234-5678-9012-3456")
|
self.assertEqual(form.cleaned_data["friend_code"], "1234-5678-9012-3456")
|
||||||
|
|
||||||
def test_invalid_friend_code_length(self):
|
def test_invalid_friend_code_length(self):
|
||||||
"""Friend codes with incorrect length should cause validation errors."""
|
"""Friend codes with incorrect length should cause validation errors."""
|
||||||
form_data = {
|
form_data = {"friend_code": "12345", "in_game_name": "GameTest"}
|
||||||
"friend_code": "12345",
|
|
||||||
"in_game_name": "GameTest"
|
|
||||||
}
|
|
||||||
form = FriendCodeForm(data=form_data)
|
form = FriendCodeForm(data=form_data)
|
||||||
self.assertFalse(form.is_valid())
|
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):
|
def test_invalid_friend_code_characters(self):
|
||||||
"""Friend codes containing non-digit characters should cause validation errors."""
|
"""Friend codes containing non-digit characters should cause validation errors."""
|
||||||
form_data = {
|
form_data = {"friend_code": "12345678901234ab", "in_game_name": "GameTest"}
|
||||||
"friend_code": "12345678901234ab",
|
|
||||||
"in_game_name": "GameTest"
|
|
||||||
}
|
|
||||||
form = FriendCodeForm(data=form_data)
|
form = FriendCodeForm(data=form_data)
|
||||||
self.assertFalse(form.is_valid())
|
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):
|
def test_friend_code_with_whitespace(self):
|
||||||
"""Ensure that leading/trailing whitespace is stripped."""
|
"""Ensure that leading/trailing whitespace is stripped."""
|
||||||
form_data = {
|
form_data = {
|
||||||
"friend_code": " 1234567890123456 ",
|
"friend_code": " 1234567890123456 ",
|
||||||
"in_game_name": "WhitespaceGame"
|
"in_game_name": "WhitespaceGame",
|
||||||
}
|
}
|
||||||
form = FriendCodeForm(data=form_data)
|
form = FriendCodeForm(data=form_data)
|
||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
|
|
@ -216,7 +197,7 @@ class FriendCodeFormTests(TestCase):
|
||||||
"""Proper dashes in the input should be accepted."""
|
"""Proper dashes in the input should be accepted."""
|
||||||
form_data = {
|
form_data = {
|
||||||
"friend_code": "1234-5678-9012-3456",
|
"friend_code": "1234-5678-9012-3456",
|
||||||
"in_game_name": "ExtraDashGame"
|
"in_game_name": "ExtraDashGame",
|
||||||
}
|
}
|
||||||
form = FriendCodeForm(data=form_data)
|
form = FriendCodeForm(data=form_data)
|
||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
|
|
@ -292,7 +273,9 @@ class CustomUserCreationFormTests(TestCase):
|
||||||
}
|
}
|
||||||
form = CustomUserCreationForm(data=form_data)
|
form = CustomUserCreationForm(data=form_data)
|
||||||
self.assertFalse(form.is_valid())
|
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):
|
def test_invalid_custom_user_creation_password_mismatch(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -318,7 +301,7 @@ class UserSettingsFormTests(TestCase):
|
||||||
self.user = get_user_model().objects.create_user(
|
self.user = get_user_model().objects.create_user(
|
||||||
username="settingsuser",
|
username="settingsuser",
|
||||||
email="settings@example.com",
|
email="settings@example.com",
|
||||||
password="password123"
|
password="password123",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_toggle_show_friend_code_on_link_previews(self):
|
def test_toggle_show_friend_code_on_link_previews(self):
|
||||||
|
|
@ -337,9 +320,7 @@ class UserSettingsFormTests(TestCase):
|
||||||
class FriendCodeViewsTests(TestCase):
|
class FriendCodeViewsTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = get_user_model().objects.create_user(
|
self.user = get_user_model().objects.create_user(
|
||||||
username="viewuser",
|
username="viewuser", email="viewuser@example.com", password="password123"
|
||||||
email="viewuser@example.com",
|
|
||||||
password="password123"
|
|
||||||
)
|
)
|
||||||
# Log in this user.
|
# Log in this user.
|
||||||
self.client.login(username="viewuser", password="password123")
|
self.client.login(username="viewuser", password="password123")
|
||||||
|
|
@ -347,12 +328,12 @@ class FriendCodeViewsTests(TestCase):
|
||||||
self.friend_code1 = FriendCode.objects.create(
|
self.friend_code1 = FriendCode.objects.create(
|
||||||
friend_code="7777-7777-7777-7777",
|
friend_code="7777-7777-7777-7777",
|
||||||
user=self.user,
|
user=self.user,
|
||||||
in_game_name="ViewGameOne"
|
in_game_name="ViewGameOne",
|
||||||
)
|
)
|
||||||
self.friend_code2 = FriendCode.objects.create(
|
self.friend_code2 = FriendCode.objects.create(
|
||||||
friend_code="8888-8888-8888-8888",
|
friend_code="8888-8888-8888-8888",
|
||||||
user=self.user,
|
user=self.user,
|
||||||
in_game_name="ViewGameTwo"
|
in_game_name="ViewGameTwo",
|
||||||
)
|
)
|
||||||
# By default, friend_code1 is the default.
|
# By default, friend_code1 is the default.
|
||||||
|
|
||||||
|
|
@ -390,8 +371,7 @@ class FriendCodeViewsTests(TestCase):
|
||||||
self.assertRedirects(response, reverse("list_friend_codes"))
|
self.assertRedirects(response, reverse("list_friend_codes"))
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
FriendCode.objects.filter(
|
FriendCode.objects.filter(
|
||||||
user=self.user,
|
user=self.user, friend_code="9999-9999-9999-9999"
|
||||||
friend_code="9999-9999-9999-9999"
|
|
||||||
).exists()
|
).exists()
|
||||||
)
|
)
|
||||||
# Ensure that adding a new friend code does not change the default.
|
# 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"}
|
data = {"friend_code": "invalidfriendcode", "in_game_name": "InvalidGame"}
|
||||||
response = self.client.post(url, data)
|
response = self.client.post(url, data)
|
||||||
# Extract the form from the response's context. If response.context is a list, use its first element.
|
# 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")
|
form = context.get("form")
|
||||||
self.assertIsNotNone(form, "Form not found in response context")
|
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):
|
def test_edit_friend_code_view(self):
|
||||||
"""Test editing the in-game name of an existing friend code."""
|
"""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):
|
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."""
|
"""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(
|
other_user = get_user_model().objects.create_user(
|
||||||
username="otheruser",
|
username="otheruser", email="other@example.com", password="password1234"
|
||||||
email="other@example.com",
|
|
||||||
password="password1234"
|
|
||||||
)
|
)
|
||||||
friend_code_other = FriendCode.objects.create(
|
friend_code_other = FriendCode.objects.create(
|
||||||
friend_code="0000-0000-0000-0000",
|
friend_code="0000-0000-0000-0000", user=other_user, in_game_name="OtherGame"
|
||||||
user=other_user,
|
|
||||||
in_game_name="OtherGame"
|
|
||||||
)
|
)
|
||||||
url = reverse("edit_friend_code", kwargs={"pk": friend_code_other.pk})
|
url = reverse("edit_friend_code", kwargs={"pk": friend_code_other.pk})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
@ -443,7 +425,11 @@ class FriendCodeViewsTests(TestCase):
|
||||||
url = reverse("edit_friend_code", kwargs={"pk": self.friend_code2.pk})
|
url = reverse("edit_friend_code", kwargs={"pk": self.friend_code2.pk})
|
||||||
new_data = {"in_game_name": ""} # in_game_name is required.
|
new_data = {"in_game_name": ""} # in_game_name is required.
|
||||||
response = self.client.post(url, new_data)
|
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")
|
form = context.get("form")
|
||||||
self.assertIsNotNone(form, "Form not found in response context")
|
self.assertIsNotNone(form, "Form not found in response context")
|
||||||
self.assertFormError(form, "in_game_name", "This field is required.")
|
self.assertFormError(form, "in_game_name", "This field is required.")
|
||||||
|
|
@ -454,14 +440,10 @@ class FriendCodeViewsTests(TestCase):
|
||||||
This test uses a new user with a single friend code.
|
This test uses a new user with a single friend code.
|
||||||
"""
|
"""
|
||||||
user_only = get_user_model().objects.create_user(
|
user_only = get_user_model().objects.create_user(
|
||||||
username="onlyuser",
|
username="onlyuser", email="onlyuser@example.com", password="password123"
|
||||||
email="onlyuser@example.com",
|
|
||||||
password="password123"
|
|
||||||
)
|
)
|
||||||
friend_code_only = FriendCode.objects.create(
|
friend_code_only = FriendCode.objects.create(
|
||||||
friend_code="4444-4444-4444-4444",
|
friend_code="4444-4444-4444-4444", user=user_only, in_game_name="SoloGame"
|
||||||
user=user_only,
|
|
||||||
in_game_name="SoloGame"
|
|
||||||
)
|
)
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
self.client.login(username="onlyuser", password="password123")
|
self.client.login(username="onlyuser", password="password123")
|
||||||
|
|
@ -492,7 +474,7 @@ class FriendCodeViewsTests(TestCase):
|
||||||
initiated_by=self.friend_code2,
|
initiated_by=self.friend_code2,
|
||||||
is_closed=False,
|
is_closed=False,
|
||||||
rarity_icon=RARITY_MAPPING[5],
|
rarity_icon=RARITY_MAPPING[5],
|
||||||
rarity_level=5
|
rarity_level=5,
|
||||||
)
|
)
|
||||||
url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk})
|
url = reverse("delete_friend_code", kwargs={"pk": self.friend_code2.pk})
|
||||||
response = self.client.post(url, {})
|
response = self.client.post(url, {})
|
||||||
|
|
@ -517,14 +499,10 @@ class FriendCodeViewsTests(TestCase):
|
||||||
def test_change_default_friend_code_view_not_owned(self):
|
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."""
|
"""A friend code that does not belong to the current user should result in a 404."""
|
||||||
other_user = get_user_model().objects.create_user(
|
other_user = get_user_model().objects.create_user(
|
||||||
username="otheruser2",
|
username="otheruser2", email="other2@example.com", password="password789"
|
||||||
email="other2@example.com",
|
|
||||||
password="password789"
|
|
||||||
)
|
)
|
||||||
friend_code_other = FriendCode.objects.create(
|
friend_code_other = FriendCode.objects.create(
|
||||||
friend_code="1111-1111-1111-1111",
|
friend_code="1111-1111-1111-1111", user=other_user, in_game_name="NotMine"
|
||||||
user=other_user,
|
|
||||||
in_game_name="NotMine"
|
|
||||||
)
|
)
|
||||||
url = reverse("change_default_friend_code", kwargs={"pk": friend_code_other.pk})
|
url = reverse("change_default_friend_code", kwargs={"pk": friend_code_other.pk})
|
||||||
response = self.client.post(url, {})
|
response = self.client.post(url, {})
|
||||||
|
|
@ -561,12 +539,12 @@ class FriendCodeViewsTests(TestCase):
|
||||||
other_user = get_user_model().objects.create_user(
|
other_user = get_user_model().objects.create_user(
|
||||||
username="otherdeluser",
|
username="otherdeluser",
|
||||||
email="otherdel@example.com",
|
email="otherdel@example.com",
|
||||||
password="password321"
|
password="password321",
|
||||||
)
|
)
|
||||||
friend_code_other = FriendCode.objects.create(
|
friend_code_other = FriendCode.objects.create(
|
||||||
friend_code="2222-2222-2222-2222",
|
friend_code="2222-2222-2222-2222",
|
||||||
user=other_user,
|
user=other_user,
|
||||||
in_game_name="OtherDelete"
|
in_game_name="OtherDelete",
|
||||||
)
|
)
|
||||||
url = reverse("delete_friend_code", kwargs={"pk": friend_code_other.pk})
|
url = reverse("delete_friend_code", kwargs={"pk": friend_code_other.pk})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,20 @@ from .views import (
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("friend-codes/add/", AddFriendCodeView.as_view(), name="add_friend_code"),
|
path("friend-codes/add/", AddFriendCodeView.as_view(), name="add_friend_code"),
|
||||||
path("friend-codes/edit/<int:pk>/", EditFriendCodeView.as_view(), name="edit_friend_code"),
|
path(
|
||||||
path("friend-codes/delete/<int:pk>/", DeleteFriendCodeView.as_view(), name="delete_friend_code"),
|
"friend-codes/edit/<int:pk>/",
|
||||||
path("friend-codes/default/<int:pk>/", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"),
|
EditFriendCodeView.as_view(),
|
||||||
|
name="edit_friend_code",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"friend-codes/delete/<int:pk>/",
|
||||||
|
DeleteFriendCodeView.as_view(),
|
||||||
|
name="delete_friend_code",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"friend-codes/default/<int:pk>/",
|
||||||
|
ChangeDefaultFriendCodeView.as_view(),
|
||||||
|
name="change_default_friend_code",
|
||||||
|
),
|
||||||
path("dashboard/", DashboardView.as_view(), name="dashboard"),
|
path("dashboard/", DashboardView.as_view(), name="dashboard"),
|
||||||
]
|
]
|
||||||
|
|
@ -1,26 +1,35 @@
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.urls import reverse_lazy
|
|
||||||
from django.shortcuts import redirect, get_object_or_404, render
|
|
||||||
from django.views.generic import ListView, CreateView, DeleteView, View, TemplateView, UpdateView
|
|
||||||
from pkmntrade_club.accounts.models import FriendCode, CustomUser
|
|
||||||
from pkmntrade_club.accounts.forms import FriendCodeForm, UserSettingsForm
|
|
||||||
from django.db.models import Case, When, Value, BooleanField
|
|
||||||
from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from pkmntrade_club.trades.mixins import FriendCodeRequiredMixin
|
from django.db.models import BooleanField, Case, Q, Value, When
|
||||||
from pkmntrade_club.common.mixins import ReusablePaginationMixin
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
|
from django.views.generic import (
|
||||||
|
CreateView,
|
||||||
|
DeleteView,
|
||||||
|
TemplateView,
|
||||||
|
UpdateView,
|
||||||
|
View,
|
||||||
|
)
|
||||||
|
|
||||||
|
from pkmntrade_club.accounts.forms import FriendCodeForm, UserSettingsForm
|
||||||
|
from pkmntrade_club.accounts.models import FriendCode
|
||||||
|
from pkmntrade_club.common.mixins import ReusablePaginationMixin
|
||||||
|
from pkmntrade_club.trades.mixins import FriendCodeRequiredMixin
|
||||||
|
from pkmntrade_club.trades.models import TradeAcceptance, TradeOffer
|
||||||
|
|
||||||
|
|
||||||
class AddFriendCodeView(LoginRequiredMixin, CreateView):
|
class AddFriendCodeView(LoginRequiredMixin, CreateView):
|
||||||
"""
|
"""
|
||||||
Add a new friend code for the current user. If the user does not yet have a default,
|
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.
|
the newly added code will automatically become the default.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = FriendCode
|
model = FriendCode
|
||||||
form_class = FriendCodeForm
|
form_class = FriendCodeForm
|
||||||
template_name = "friend_codes/add_friend_code.html"
|
template_name = "friend_codes/add_friend_code.html"
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
base_url = reverse("dashboard")
|
base_url = reverse("dashboard")
|
||||||
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
|
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
|
||||||
|
|
@ -30,6 +39,7 @@ class AddFriendCodeView(LoginRequiredMixin, CreateView):
|
||||||
messages.success(self.request, "Friend code added successfully.")
|
messages.success(self.request, "Friend code added successfully.")
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
|
class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
|
||||||
"""
|
"""
|
||||||
Remove an existing friend code.
|
Remove an existing friend code.
|
||||||
|
|
@ -37,9 +47,11 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
|
||||||
Also, prevent deletion if the friend code is either the only one or
|
Also, prevent deletion if the friend code is either the only one or
|
||||||
is set as the default friend code.
|
is set as the default friend code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = FriendCode
|
model = FriendCode
|
||||||
template_name = "friend_codes/confirm_delete_friend_code.html"
|
template_name = "friend_codes/confirm_delete_friend_code.html"
|
||||||
context_object_name = "friend_code"
|
context_object_name = "friend_code"
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
base_url = reverse("dashboard")
|
base_url = reverse("dashboard")
|
||||||
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
|
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
|
||||||
|
|
@ -81,17 +93,21 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
|
||||||
if user.default_friend_code == self.object:
|
if user.default_friend_code == self.object:
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
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())
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
trade_offer_exists = TradeOffer.objects.filter(initiated_by_id=self.object.pk).exists()
|
trade_offer_exists = TradeOffer.objects.filter(
|
||||||
trade_acceptance_exists = TradeAcceptance.objects.filter(accepted_by_id=self.object.pk).exists()
|
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:
|
if trade_offer_exists or trade_acceptance_exists:
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
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())
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
|
@ -99,30 +115,37 @@ class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
|
||||||
messages.success(request, "Friend code removed successfully.")
|
messages.success(request, "Friend code removed successfully.")
|
||||||
return redirect(self.get_success_url())
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
|
||||||
class ChangeDefaultFriendCodeView(LoginRequiredMixin, View):
|
class ChangeDefaultFriendCodeView(LoginRequiredMixin, View):
|
||||||
"""
|
"""
|
||||||
Change the default friend code for the current user.
|
Change the default friend code for the current user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
friend_code_id = kwargs.get("pk")
|
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)
|
request.user.set_default_friend_code(friend_code)
|
||||||
messages.success(request, "Default friend code updated successfully.")
|
messages.success(request, "Default friend code updated successfully.")
|
||||||
base_url = reverse("dashboard")
|
base_url = reverse("dashboard")
|
||||||
query_string = urlencode({"tab": "friend_codes"})
|
query_string = urlencode({"tab": "friend_codes"})
|
||||||
return redirect(f"{base_url}?{query_string}")
|
return redirect(f"{base_url}?{query_string}")
|
||||||
|
|
||||||
|
|
||||||
class EditFriendCodeView(LoginRequiredMixin, UpdateView):
|
class EditFriendCodeView(LoginRequiredMixin, UpdateView):
|
||||||
"""
|
"""
|
||||||
Edit the in-game name for a friend code.
|
Edit the in-game name for a friend code.
|
||||||
The friend code itself is displayed as plain text.
|
The friend code itself is displayed as plain text.
|
||||||
Also includes "Set Default" and "Delete" buttons in the template.
|
Also includes "Set Default" and "Delete" buttons in the template.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = FriendCode
|
model = FriendCode
|
||||||
# Only the in_game_name field is editable
|
# Only the in_game_name field is editable
|
||||||
fields = ['in_game_name']
|
fields = ["in_game_name"]
|
||||||
template_name = "friend_codes/edit_friend_code.html"
|
template_name = "friend_codes/edit_friend_code.html"
|
||||||
context_object_name = "friend_code"
|
context_object_name = "friend_code"
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
base_url = reverse("dashboard")
|
base_url = reverse("dashboard")
|
||||||
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
|
return f"{base_url}?{urlencode({'tab': 'friend_codes'})}"
|
||||||
|
|
@ -135,12 +158,16 @@ class EditFriendCodeView(LoginRequiredMixin, UpdateView):
|
||||||
messages.success(self.request, "Friend code updated successfully.")
|
messages.success(self.request, "Friend code updated successfully.")
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginationMixin, TemplateView):
|
|
||||||
|
class DashboardView(
|
||||||
|
LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginationMixin, TemplateView
|
||||||
|
):
|
||||||
template_name = "account/dashboard.html"
|
template_name = "account/dashboard.html"
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
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
|
from pkmntrade_club.accounts.forms import UserSettingsForm
|
||||||
|
|
||||||
form = UserSettingsForm(request.POST, instance=request.user)
|
form = UserSettingsForm(request.POST, instance=request.user)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
|
|
@ -156,21 +183,28 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
|
||||||
try:
|
try:
|
||||||
selected_friend_code = friend_codes.get(pk=friend_code_param)
|
selected_friend_code = friend_codes.get(pk=friend_code_param)
|
||||||
except friend_codes.model.DoesNotExist:
|
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:
|
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:
|
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
|
return selected_friend_code
|
||||||
|
|
||||||
def get_dashboard_offers_paginated(self, page_param):
|
def get_dashboard_offers_paginated(self, page_param):
|
||||||
selected_friend_code = self.get_selected_friend_code()
|
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))
|
object_list, pagination_context = self.paginate_data(queryset, int(page_param))
|
||||||
return {"object_list": object_list, "page_obj": pagination_context}
|
return {"object_list": object_list, "page_obj": pagination_context}
|
||||||
|
|
||||||
def get_involved_acceptances(self, selected_friend_code):
|
def get_involved_acceptances(self, selected_friend_code):
|
||||||
from django.db.models import Q
|
|
||||||
terminal_states = [
|
terminal_states = [
|
||||||
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
||||||
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||||
|
|
@ -178,21 +212,44 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
|
||||||
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||||
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||||
]
|
]
|
||||||
involved = TradeAcceptance.objects.filter(
|
involved = (
|
||||||
Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code)
|
TradeAcceptance.objects.filter(
|
||||||
).order_by("-updated_at")
|
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)
|
return involved.exclude(state__in=terminal_states)
|
||||||
|
|
||||||
def get_trade_acceptances_waiting_paginated(self, page_param):
|
def get_trade_acceptances_waiting_paginated(self, page_param):
|
||||||
selected_friend_code = self.get_selected_friend_code()
|
selected_friend_code = self.get_selected_friend_code()
|
||||||
involved = self.get_involved_acceptances(selected_friend_code)
|
involved = self.get_involved_acceptances(selected_friend_code)
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
waiting = involved.filter(
|
waiting = involved.filter(
|
||||||
Q(trade_offer__initiated_by=selected_friend_code, state__in=[
|
Q(
|
||||||
|
trade_offer__initiated_by=selected_friend_code,
|
||||||
|
state__in=[
|
||||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||||
TradeAcceptance.AcceptanceState.RECEIVED,
|
TradeAcceptance.AcceptanceState.RECEIVED,
|
||||||
]) |
|
],
|
||||||
Q(accepted_by=selected_friend_code, state__in=[TradeAcceptance.AcceptanceState.SENT])
|
)
|
||||||
|
| Q(
|
||||||
|
accepted_by=selected_friend_code,
|
||||||
|
state__in=[TradeAcceptance.AcceptanceState.SENT],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
object_list, pagination_context = self.paginate_data(waiting, int(page_param))
|
object_list, pagination_context = self.paginate_data(waiting, int(page_param))
|
||||||
return {"object_list": object_list, "page_obj": pagination_context}
|
return {"object_list": object_list, "page_obj": pagination_context}
|
||||||
|
|
@ -201,12 +258,19 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
|
||||||
selected_friend_code = self.get_selected_friend_code()
|
selected_friend_code = self.get_selected_friend_code()
|
||||||
involved = self.get_involved_acceptances(selected_friend_code)
|
involved = self.get_involved_acceptances(selected_friend_code)
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
waiting = involved.filter(
|
waiting = involved.filter(
|
||||||
Q(trade_offer__initiated_by=selected_friend_code, state__in=[
|
Q(
|
||||||
|
trade_offer__initiated_by=selected_friend_code,
|
||||||
|
state__in=[
|
||||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||||
TradeAcceptance.AcceptanceState.RECEIVED,
|
TradeAcceptance.AcceptanceState.RECEIVED,
|
||||||
]) |
|
],
|
||||||
Q(accepted_by=selected_friend_code, state__in=[TradeAcceptance.AcceptanceState.SENT])
|
)
|
||||||
|
| Q(
|
||||||
|
accepted_by=selected_friend_code,
|
||||||
|
state__in=[TradeAcceptance.AcceptanceState.SENT],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
others = involved.exclude(pk__in=waiting.values("pk"))
|
others = involved.exclude(pk__in=waiting.values("pk"))
|
||||||
object_list, pagination_context = self.paginate_data(others, int(page_param))
|
object_list, pagination_context = self.paginate_data(others, int(page_param))
|
||||||
|
|
@ -214,42 +278,101 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
|
||||||
|
|
||||||
def get_closed_offers_paginated(self, page_param):
|
def get_closed_offers_paginated(self, page_param):
|
||||||
selected_friend_code = self.get_selected_friend_code()
|
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))
|
object_list, pagination_context = self.paginate_data(queryset, int(page_param))
|
||||||
return {"object_list": object_list, "page_obj": pagination_context}
|
return {"object_list": object_list, "page_obj": pagination_context}
|
||||||
|
|
||||||
def get_closed_acceptances_paginated(self, page_param):
|
def get_closed_acceptances_paginated(self, page_param):
|
||||||
from django.db.models import Q
|
|
||||||
selected_friend_code = self.get_selected_friend_code()
|
selected_friend_code = self.get_selected_friend_code()
|
||||||
terminal_success_states = [
|
terminal_success_states = [
|
||||||
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
TradeAcceptance.AcceptanceState.THANKED_BY_INITIATOR,
|
||||||
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
TradeAcceptance.AcceptanceState.THANKED_BY_ACCEPTOR,
|
||||||
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
||||||
]
|
]
|
||||||
acceptance_qs = TradeAcceptance.objects.filter(
|
acceptance_qs = (
|
||||||
Q(trade_offer__initiated_by=selected_friend_code) | Q(accepted_by=selected_friend_code),
|
TradeAcceptance.objects.filter(
|
||||||
state__in=terminal_success_states
|
Q(trade_offer__initiated_by=selected_friend_code)
|
||||||
).order_by("-updated_at")
|
| Q(accepted_by=selected_friend_code),
|
||||||
object_list, pagination_context = self.paginate_data(acceptance_qs, int(page_param))
|
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}
|
return {"object_list": object_list, "page_obj": pagination_context}
|
||||||
|
|
||||||
def get_rejected_by_me_paginated(self, page_param):
|
def get_rejected_by_me_paginated(self, page_param):
|
||||||
from django.db.models import Q
|
|
||||||
selected_friend_code = self.get_selected_friend_code()
|
selected_friend_code = self.get_selected_friend_code()
|
||||||
rejection = TradeAcceptance.objects.filter(
|
rejection = (
|
||||||
Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR) |
|
TradeAcceptance.objects.filter(
|
||||||
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR)
|
Q(
|
||||||
).order_by("-updated_at")
|
trade_offer__initiated_by=selected_friend_code,
|
||||||
|
state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||||
|
)
|
||||||
|
| Q(
|
||||||
|
accepted_by=selected_friend_code,
|
||||||
|
state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.select_related(
|
||||||
|
"trade_offer__initiated_by__user",
|
||||||
|
"accepted_by__user",
|
||||||
|
"requested_card__rarity",
|
||||||
|
"requested_card__cardset",
|
||||||
|
"offered_card__rarity",
|
||||||
|
"offered_card__cardset",
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
"requested_card__translations",
|
||||||
|
"offered_card__translations",
|
||||||
|
)
|
||||||
|
.order_by("-updated_at")
|
||||||
|
)
|
||||||
object_list, pagination_context = self.paginate_data(rejection, int(page_param))
|
object_list, pagination_context = self.paginate_data(rejection, int(page_param))
|
||||||
return {"object_list": object_list, "page_obj": pagination_context}
|
return {"object_list": object_list, "page_obj": pagination_context}
|
||||||
|
|
||||||
def get_rejected_by_them_paginated(self, page_param):
|
def get_rejected_by_them_paginated(self, page_param):
|
||||||
from django.db.models import Q
|
|
||||||
selected_friend_code = self.get_selected_friend_code()
|
selected_friend_code = self.get_selected_friend_code()
|
||||||
rejection = TradeAcceptance.objects.filter(
|
rejection = (
|
||||||
Q(trade_offer__initiated_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR) |
|
TradeAcceptance.objects.filter(
|
||||||
Q(accepted_by=selected_friend_code, state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR)
|
Q(
|
||||||
).order_by("-updated_at")
|
trade_offer__initiated_by=selected_friend_code,
|
||||||
|
state=TradeAcceptance.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||||
|
)
|
||||||
|
| Q(
|
||||||
|
accepted_by=selected_friend_code,
|
||||||
|
state=TradeAcceptance.AcceptanceState.REJECTED_BY_INITIATOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.select_related(
|
||||||
|
"trade_offer__initiated_by__user",
|
||||||
|
"accepted_by__user",
|
||||||
|
"requested_card__rarity",
|
||||||
|
"requested_card__cardset",
|
||||||
|
"offered_card__rarity",
|
||||||
|
"offered_card__cardset",
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
"requested_card__translations",
|
||||||
|
"offered_card__translations",
|
||||||
|
)
|
||||||
|
.order_by("-updated_at")
|
||||||
|
)
|
||||||
object_list, pagination_context = self.paginate_data(rejection, int(page_param))
|
object_list, pagination_context = self.paginate_data(rejection, int(page_param))
|
||||||
return {"object_list": object_list, "page_obj": pagination_context}
|
return {"object_list": object_list, "page_obj": pagination_context}
|
||||||
|
|
||||||
|
|
@ -267,7 +390,7 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
|
||||||
is_default=Case(
|
is_default=Case(
|
||||||
When(pk=default_pk, then=Value(True)),
|
When(pk=default_pk, then=Value(True)),
|
||||||
default=Value(False),
|
default=Value(False),
|
||||||
output_field=BooleanField()
|
output_field=BooleanField(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -307,14 +430,28 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
|
||||||
else:
|
else:
|
||||||
rejected_by_them_page = request.GET.get("rejected_by_them_page", 1)
|
rejected_by_them_page = request.GET.get("rejected_by_them_page", 1)
|
||||||
|
|
||||||
context["dashboard_offers_paginated"] = self.get_dashboard_offers_paginated(offers_page)
|
context["dashboard_offers_paginated"] = self.get_dashboard_offers_paginated(
|
||||||
context["trade_acceptances_waiting_paginated"] = self.get_trade_acceptances_waiting_paginated(waiting_page)
|
offers_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["trade_acceptances_waiting_paginated"] = (
|
||||||
context["closed_acceptances_paginated"] = self.get_closed_acceptances_paginated(closed_acceptances_page)
|
self.get_trade_acceptances_waiting_paginated(waiting_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["other_party_trade_acceptances_paginated"] = (
|
||||||
from pkmntrade_club.accounts.forms import UserSettingsForm
|
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["settings_form"] = UserSettingsForm(instance=request.user)
|
||||||
context["active_tab"] = request.GET.get("tab", "dash")
|
context["active_tab"] = request.GET.get("tab", "dash")
|
||||||
return context
|
return context
|
||||||
|
|
@ -327,9 +464,13 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
|
||||||
if ajax_section == "dashboard_offers":
|
if ajax_section == "dashboard_offers":
|
||||||
fragment_context = context.get("dashboard_offers_paginated", {})
|
fragment_context = context.get("dashboard_offers_paginated", {})
|
||||||
elif ajax_section == "waiting_acceptances":
|
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":
|
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":
|
elif ajax_section == "closed_offers":
|
||||||
fragment_context = context.get("closed_offers_paginated", {})
|
fragment_context = context.get("closed_offers_paginated", {})
|
||||||
elif ajax_section == "closed_acceptances":
|
elif ajax_section == "closed_acceptances":
|
||||||
|
|
@ -342,8 +483,12 @@ class DashboardView(LoginRequiredMixin, FriendCodeRequiredMixin, ReusablePaginat
|
||||||
fragment_context = {}
|
fragment_context = {}
|
||||||
|
|
||||||
if fragment_context:
|
if fragment_context:
|
||||||
return render(request, "trades/_trade_offer_list.html", {
|
return render(
|
||||||
|
request,
|
||||||
|
"trades/_trade_offer_list.html",
|
||||||
|
{
|
||||||
"offers": fragment_context.get("object_list", []),
|
"offers": fragment_context.get("object_list", []),
|
||||||
"page_obj": fragment_context.get("page_obj")
|
"page_obj": fragment_context.get("page_obj"),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
@ -1,7 +1,757 @@
|
||||||
from django.contrib import admin
|
import hashlib
|
||||||
from .models import Deck, Card, DeckNameTranslation, CardNameTranslation
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re # For parsing set name and ID
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
admin.site.register(Deck)
|
import requests
|
||||||
admin.site.register(Card)
|
from django.conf import settings
|
||||||
admin.site.register(DeckNameTranslation)
|
from django.contrib import admin, messages
|
||||||
admin.site.register(CardNameTranslation)
|
from django.contrib.admin.filters import RelatedFieldListFilter
|
||||||
|
from django.db import transaction
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.urls import path, reverse
|
||||||
|
from parler.admin import TranslatableAdmin
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
Ability,
|
||||||
|
Attack,
|
||||||
|
AttackCost,
|
||||||
|
Card,
|
||||||
|
CardSet,
|
||||||
|
CardSetColorMapping,
|
||||||
|
CardType,
|
||||||
|
Energy,
|
||||||
|
Pack,
|
||||||
|
Rarity,
|
||||||
|
RarityMapping,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportResult:
|
||||||
|
"""A data class to hold the results of the card import process."""
|
||||||
|
|
||||||
|
newly_imported_count: int = 0
|
||||||
|
updated_count: int = 0
|
||||||
|
skipped_count: int = 0
|
||||||
|
files_processed_count: int = 0
|
||||||
|
has_error: bool = False
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PrefetchedSortedRelatedFieldListFilter(RelatedFieldListFilter):
|
||||||
|
def field_choices(self, field, request, model_admin):
|
||||||
|
related_manager = field.related_model._default_manager
|
||||||
|
queryset = related_manager.all().prefetch_related("translations")
|
||||||
|
return [(obj.pk, str(obj)) for obj in queryset]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_set_details(set_string):
|
||||||
|
# Handle filenames like 'a1-genetic-apex'
|
||||||
|
match = re.match(r"^([a-zA-Z0-9]+)-(.+)$", set_string)
|
||||||
|
if match:
|
||||||
|
set_id = match.group(1)
|
||||||
|
set_id = set_id[0].upper() + set_id[1:]
|
||||||
|
name = match.group(2).replace("-", " ").title()
|
||||||
|
return name, set_id
|
||||||
|
|
||||||
|
# Handle 'promo' filename, assuming 'PRO' as a 3-char ID
|
||||||
|
if set_string == "promo":
|
||||||
|
return "Promo-A", "PA"
|
||||||
|
|
||||||
|
match = re.match(r"^(.*?)\s*\(([A-Za-z0-9]+)\)$", set_string)
|
||||||
|
if match:
|
||||||
|
name = match.group(1).strip()
|
||||||
|
set_id = match.group(2)
|
||||||
|
set_id = set_id[0].upper() + set_id[1:]
|
||||||
|
return name, set_id
|
||||||
|
match = re.match(r"^Promo-(.*?)$", set_string)
|
||||||
|
if match:
|
||||||
|
name = set_string
|
||||||
|
set_id = "P" + match.group(1)
|
||||||
|
return name, set_id
|
||||||
|
return set_string, None
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_card_checksum(card_data):
|
||||||
|
"""
|
||||||
|
Calculates a SHA256 checksum for a card's data.
|
||||||
|
The data is first normalized by sorting lists and dictionary keys
|
||||||
|
to ensure consistent checksums for semantically identical cards.
|
||||||
|
"""
|
||||||
|
# Select and normalize fields that define the card's state
|
||||||
|
# Order of keys in `data_to_hash` and sorting of lists are important for consistency
|
||||||
|
data_to_hash = {
|
||||||
|
"id": card_data.get("id"),
|
||||||
|
"name": card_data.get("name"),
|
||||||
|
"type": card_data.get("type"),
|
||||||
|
"subtype": card_data.get("subtype"),
|
||||||
|
"rarity": card_data.get("rarity"), # Rarity name from JSON
|
||||||
|
"health": card_data.get("health"),
|
||||||
|
"evolvesFrom": card_data.get("evolvesFrom"),
|
||||||
|
"retreatCost": card_data.get("retreatCost"),
|
||||||
|
"element": card_data.get("element"), # Element name from JSON
|
||||||
|
"weakness": card_data.get("weakness"), # Weakness name from JSON
|
||||||
|
"pack": card_data.get("pack"), # Pack name from JSON
|
||||||
|
# For abilities and attacks, ensure stable order and content
|
||||||
|
"abilities": sorted(
|
||||||
|
[
|
||||||
|
{"name": a.get("name"), "effect": a.get("effect")}
|
||||||
|
for a in card_data.get("abilities", [])
|
||||||
|
if a and a.get("name") # ensure ability itself and name exist
|
||||||
|
],
|
||||||
|
key=lambda x: x["name"] if x and x.get("name") else "",
|
||||||
|
),
|
||||||
|
"attacks": sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": atk.get("name"),
|
||||||
|
"effect": atk.get("effect", ""),
|
||||||
|
"damage": atk.get("damage", ""),
|
||||||
|
"cost": sorted(
|
||||||
|
atk.get("cost", []) if atk.get("cost") else []
|
||||||
|
), # Sort energy costs
|
||||||
|
}
|
||||||
|
for atk in card_data.get("attacks", [])
|
||||||
|
if atk and atk.get("name") # ensure attack itself and name exist
|
||||||
|
],
|
||||||
|
key=lambda x: x["name"] if x and x.get("name") else "",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serialize to a canonical JSON string (sort keys, no indent, compact)
|
||||||
|
canonical_json = json.dumps(data_to_hash, sort_keys=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
sha256_hash = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()
|
||||||
|
return sha256_hash
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_card_type(card_data):
|
||||||
|
card_type_obj, created = CardType.objects.language("en").get_or_create(
|
||||||
|
translations__name=card_data["type"],
|
||||||
|
translations__subtype=card_data.get("subtype", ""),
|
||||||
|
defaults={"name": card_data["type"], "subtype": card_data.get("subtype", "")},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
current_subtype = card_data.get("subtype")
|
||||||
|
if current_subtype is not None and card_type_obj.subtype != current_subtype:
|
||||||
|
card_type_obj.set_current_language("en")
|
||||||
|
card_type_obj.subtype = current_subtype
|
||||||
|
card_type_obj.save()
|
||||||
|
return card_type_obj
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_rarity(card_data, rarity_mappings_dict):
|
||||||
|
original_rarity_name_from_json = card_data.get("rarity")
|
||||||
|
|
||||||
|
# Attempt to find a mapping for the original rarity name
|
||||||
|
mapping = rarity_mappings_dict.get(original_rarity_name_from_json)
|
||||||
|
|
||||||
|
if mapping:
|
||||||
|
# Use mapped values
|
||||||
|
target_rarity_name = mapping.mapped_name
|
||||||
|
target_icon = mapping.icon
|
||||||
|
target_level = mapping.level
|
||||||
|
elif original_rarity_name_from_json:
|
||||||
|
# No mapping found, use the original name from JSON, default icon/level
|
||||||
|
target_rarity_name = original_rarity_name_from_json
|
||||||
|
target_icon = "x" # Default icon if no mapping
|
||||||
|
target_level = 0 # Default level if no mapping
|
||||||
|
else:
|
||||||
|
# Rarity is None or empty in JSON, treat as 'Promo'
|
||||||
|
target_rarity_name = "Promo"
|
||||||
|
# Check if 'Promo' itself has a mapping
|
||||||
|
promo_mapping = rarity_mappings_dict.get("Promo")
|
||||||
|
if promo_mapping:
|
||||||
|
target_icon = promo_mapping.icon
|
||||||
|
target_level = promo_mapping.level
|
||||||
|
else:
|
||||||
|
target_icon = "x" # Default icon for 'Promo' if no mapping for 'Promo'
|
||||||
|
target_level = 0 # Default level for 'Promo' if no mapping for 'Promo'
|
||||||
|
|
||||||
|
# Get or create the Rarity object using the (potentially mapped) values
|
||||||
|
rarity_obj, created = Rarity.objects.language("en").get_or_create(
|
||||||
|
translations__name=target_rarity_name,
|
||||||
|
defaults={
|
||||||
|
"name": target_rarity_name,
|
||||||
|
"icon": target_icon,
|
||||||
|
"level": target_level,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the rarity already existed, check if its icon or level needs updating based on the mapping
|
||||||
|
if not created:
|
||||||
|
updated_fields = False
|
||||||
|
if rarity_obj.icon != target_icon:
|
||||||
|
rarity_obj.icon = target_icon
|
||||||
|
updated_fields = True
|
||||||
|
if rarity_obj.level != target_level:
|
||||||
|
rarity_obj.level = target_level
|
||||||
|
updated_fields = True
|
||||||
|
|
||||||
|
if updated_fields:
|
||||||
|
rarity_obj.save()
|
||||||
|
|
||||||
|
return rarity_obj
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_energy(energy_name):
|
||||||
|
if not energy_name:
|
||||||
|
return None
|
||||||
|
energy_obj, _ = Energy.objects.language("en").get_or_create(
|
||||||
|
translations__name=energy_name, defaults={"name": energy_name}
|
||||||
|
)
|
||||||
|
return energy_obj
|
||||||
|
|
||||||
|
|
||||||
|
def _update_card_packs(card_obj, card_data, card_set):
|
||||||
|
card_obj.packs.clear()
|
||||||
|
pack_name_from_json = card_data.get("pack")
|
||||||
|
if pack_name_from_json:
|
||||||
|
card_set.set_current_language("en")
|
||||||
|
pack_full_name = f"{card_set.name}: {pack_name_from_json}"
|
||||||
|
|
||||||
|
pack_obj, _ = Pack.objects.language("en").get_or_create(
|
||||||
|
translations__name=pack_name_from_json,
|
||||||
|
cardset=card_set,
|
||||||
|
defaults={
|
||||||
|
"name": pack_name_from_json,
|
||||||
|
"full_name": pack_full_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
card_obj.packs.add(pack_obj)
|
||||||
|
else:
|
||||||
|
all_packs_in_set = Pack.objects.filter(cardset=card_set)
|
||||||
|
if all_packs_in_set.exists():
|
||||||
|
card_obj.packs.add(*all_packs_in_set)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_card_abilities(card_obj, card_data):
|
||||||
|
card_obj.abilities.clear()
|
||||||
|
for ability_data in card_data.get("abilities", []):
|
||||||
|
ability_obj, created = Ability.objects.language("en").get_or_create(
|
||||||
|
translations__name=ability_data["name"],
|
||||||
|
defaults={"name": ability_data["name"], "effect": ability_data["effect"]},
|
||||||
|
)
|
||||||
|
if not created and ability_obj.effect != ability_data["effect"]:
|
||||||
|
ability_obj.set_current_language("en")
|
||||||
|
ability_obj.effect = ability_data["effect"]
|
||||||
|
ability_obj.save()
|
||||||
|
card_obj.abilities.add(ability_obj)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_card_attacks_and_costs(card_obj, card_data):
|
||||||
|
card_obj.attacks.clear()
|
||||||
|
for attack_data in card_data.get("attacks", []):
|
||||||
|
attack_obj, created = Attack.objects.language("en").get_or_create(
|
||||||
|
translations__name=attack_data["name"],
|
||||||
|
defaults={
|
||||||
|
"name": attack_data["name"],
|
||||||
|
"effect": attack_data.get("effect", ""),
|
||||||
|
"damage": attack_data.get("damage", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
needs_save = False
|
||||||
|
if not created:
|
||||||
|
json_effect = attack_data.get("effect", "")
|
||||||
|
if attack_obj.effect != json_effect:
|
||||||
|
attack_obj.set_current_language("en")
|
||||||
|
attack_obj.effect = json_effect
|
||||||
|
needs_save = True
|
||||||
|
|
||||||
|
json_damage = attack_data.get("damage", "")
|
||||||
|
if attack_obj.damage != json_damage:
|
||||||
|
attack_obj.damage = json_damage
|
||||||
|
needs_save = True
|
||||||
|
|
||||||
|
if created or needs_save:
|
||||||
|
attack_obj.save()
|
||||||
|
|
||||||
|
card_obj.attacks.add(attack_obj)
|
||||||
|
|
||||||
|
attack_obj.energy_cost.clear()
|
||||||
|
energy_counts = {}
|
||||||
|
for cost_energy_name in attack_data.get("cost", []):
|
||||||
|
energy_counts[cost_energy_name] = energy_counts.get(cost_energy_name, 0) + 1
|
||||||
|
|
||||||
|
for energy_name, quantity in energy_counts.items():
|
||||||
|
energy_obj = _get_or_create_energy(energy_name)
|
||||||
|
if energy_obj:
|
||||||
|
AttackCost.objects.update_or_create(
|
||||||
|
attack=attack_obj,
|
||||||
|
energy=energy_obj,
|
||||||
|
defaults={"quantity": quantity},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_single_card_data(
|
||||||
|
card_data,
|
||||||
|
card_set,
|
||||||
|
stats_accumulator,
|
||||||
|
error_tracking,
|
||||||
|
rarity_mappings_dict,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Processes a single card's data from a JSON file, creating or updating
|
||||||
|
the card and its related objects in the database.
|
||||||
|
"""
|
||||||
|
card_id = card_data["id"]
|
||||||
|
incoming_checksum = calculate_card_checksum(card_data)
|
||||||
|
error_tracking["card_id"] = card_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing_card = Card.objects.language("en").get(id=card_id)
|
||||||
|
if existing_card.checksum == incoming_checksum:
|
||||||
|
stats_accumulator["skipped_count"] += 1
|
||||||
|
return
|
||||||
|
except Card.DoesNotExist:
|
||||||
|
existing_card = None
|
||||||
|
|
||||||
|
card_type_obj = _get_or_create_card_type(card_data)
|
||||||
|
rarity_obj = _get_or_create_rarity(card_data, rarity_mappings_dict)
|
||||||
|
pkmn_type_obj = _get_or_create_energy(card_data.get("element"))
|
||||||
|
weakness_type_obj = _get_or_create_energy(card_data.get("weakness"))
|
||||||
|
|
||||||
|
card_defaults = {
|
||||||
|
"name": card_data["name"],
|
||||||
|
"cardset": card_set,
|
||||||
|
"card_type": card_type_obj,
|
||||||
|
"rarity": rarity_obj,
|
||||||
|
"health": card_data.get("health"),
|
||||||
|
"evolves_from_name": card_data.get("evolvesFrom"),
|
||||||
|
"retreat_cost": card_data.get("retreatCost"),
|
||||||
|
"pkmn_type": pkmn_type_obj,
|
||||||
|
"weakness_type": weakness_type_obj,
|
||||||
|
"checksum": incoming_checksum,
|
||||||
|
}
|
||||||
|
|
||||||
|
card_obj, card_created = Card.objects.language("en").update_or_create(
|
||||||
|
id=card_id, defaults=card_defaults
|
||||||
|
)
|
||||||
|
|
||||||
|
if card_created:
|
||||||
|
stats_accumulator["newly_imported_count"] += 1
|
||||||
|
elif existing_card:
|
||||||
|
stats_accumulator["updated_count"] += 1
|
||||||
|
# If not created and checksum differs, it's an update, which is handled by updated_count.
|
||||||
|
# update_or_create takes care of setting the new checksum via defaults.
|
||||||
|
|
||||||
|
_update_card_packs(card_obj, card_data, card_set)
|
||||||
|
_update_card_abilities(card_obj, card_data)
|
||||||
|
_update_card_attacks_and_costs(card_obj, card_data)
|
||||||
|
|
||||||
|
# The checksum is based on the incoming card_data. If the update_* functions
|
||||||
|
# modify the card_obj in a way that would change its representation
|
||||||
|
# based on the original card_data fields, the checksum logic is fine.
|
||||||
|
# If those functions derive new data that *should* be part of the checksum,
|
||||||
|
# the checksum calculation would need to happen *after* them, using the card_obj state.
|
||||||
|
# However, for skipping based on *incoming JSON data*, this approach is correct.
|
||||||
|
# The `update_or_create` will ensure the `checksum` field (which is part of `card_defaults`) is saved.
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_card_data_from_github_zip():
|
||||||
|
"""
|
||||||
|
Downloads and extracts card data from the GitHub repository zip archive.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
tuple: A tuple containing the file name (str) and its parsed JSON data (list).
|
||||||
|
Raises:
|
||||||
|
requests.exceptions.RequestException: If the download fails.
|
||||||
|
"""
|
||||||
|
owner = "hugoburguete"
|
||||||
|
repo = "pokemon-tcg-pocket-card-database"
|
||||||
|
branch = "main"
|
||||||
|
zip_url = f"https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip"
|
||||||
|
|
||||||
|
print(f"Downloading card data from {zip_url}...")
|
||||||
|
response = requests.get(zip_url)
|
||||||
|
response.raise_for_status() # Will raise an exception for 4xx/5xx status
|
||||||
|
print("Download complete.")
|
||||||
|
|
||||||
|
zip_file = zipfile.ZipFile(io.BytesIO(response.content))
|
||||||
|
|
||||||
|
# The root folder in the zip is usually `repo-name-branch-name`
|
||||||
|
path_prefix = f"{repo}-{branch}/cards/en/"
|
||||||
|
|
||||||
|
json_file_paths = sorted(
|
||||||
|
[
|
||||||
|
name
|
||||||
|
for name in zip_file.namelist()
|
||||||
|
if name.startswith(path_prefix) and name.endswith(".json")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Found {len(json_file_paths)} JSON files in the archive.")
|
||||||
|
|
||||||
|
for file_path in json_file_paths:
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
with zip_file.open(file_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
yield file_name, data
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_card_data_from_local_files():
|
||||||
|
"""
|
||||||
|
Reads card data from local JSON files for debugging purposes.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
tuple: A tuple containing the file name (str) and its parsed JSON data (list).
|
||||||
|
"""
|
||||||
|
base_path = os.path.join(
|
||||||
|
settings.BASE_DIR,
|
||||||
|
"REMOTE_GIT_REPOS",
|
||||||
|
"pokemon-tcg-pocket-card-database",
|
||||||
|
"cards",
|
||||||
|
"en",
|
||||||
|
)
|
||||||
|
print(f"DEBUG MODE: Reading card data from local path: {base_path}")
|
||||||
|
|
||||||
|
if not os.path.isdir(base_path):
|
||||||
|
print(f"Source directory not found: {base_path}. Import halted.")
|
||||||
|
return # An empty generator
|
||||||
|
|
||||||
|
json_files = sorted([f for f in os.listdir(base_path) if f.endswith(".json")])
|
||||||
|
|
||||||
|
print(f"Found {len(json_files)} local JSON files to process.")
|
||||||
|
|
||||||
|
for file_name in json_files:
|
||||||
|
file_path = os.path.join(base_path, file_name)
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
yield file_name, data
|
||||||
|
|
||||||
|
|
||||||
|
def perform_card_import_logic() -> ImportResult:
|
||||||
|
"""
|
||||||
|
Main logic to perform the card import process.
|
||||||
|
This can be triggered from an admin view or a management command.
|
||||||
|
"""
|
||||||
|
stats = ImportResult()
|
||||||
|
try:
|
||||||
|
# Step 1: Pre-fetch all RarityMapping and ColorMapping objects into dictionaries
|
||||||
|
rarity_mappings = {m.original_name: m for m in RarityMapping.objects.all()}
|
||||||
|
color_mappings = {m.cardset_id: m for m in CardSetColorMapping.objects.all()}
|
||||||
|
|
||||||
|
# Step 2: Decide whether to fetch from GitHub or local files
|
||||||
|
if settings.DEBUG:
|
||||||
|
card_files_source = _fetch_card_data_from_local_files()
|
||||||
|
source_message = "local files"
|
||||||
|
else:
|
||||||
|
card_files_source = _fetch_card_data_from_github_zip()
|
||||||
|
source_message = "the GitHub archive"
|
||||||
|
|
||||||
|
all_files_data = list(card_files_source)
|
||||||
|
total_files = len(all_files_data)
|
||||||
|
|
||||||
|
if not all_files_data:
|
||||||
|
stats.message = f"No JSON files found in {source_message} to import."
|
||||||
|
print(stats.message)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
print(f"Found {total_files} JSON files to process from {source_message}.")
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
stats_accumulator = {
|
||||||
|
"newly_imported_count": 0,
|
||||||
|
"updated_count": 0,
|
||||||
|
"skipped_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx, (file_name, data) in enumerate(all_files_data):
|
||||||
|
error_tracking = {"file_name": file_name, "card_id": "N/A"}
|
||||||
|
|
||||||
|
print(f"Processing file: {file_name} ({idx + 1}/{total_files})")
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
print(f"Skipping empty file: {file_name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
stats.files_processed_count += 1
|
||||||
|
|
||||||
|
set_name_from_file = os.path.splitext(file_name)[0]
|
||||||
|
parsed_set_name, parsed_set_id = parse_set_details(set_name_from_file)
|
||||||
|
|
||||||
|
if not parsed_set_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Could not parse set ID from file name '{file_name}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
card_set_defaults = {"name": parsed_set_name, "file_name": file_name}
|
||||||
|
card_set, card_set_created = CardSet.objects.language(
|
||||||
|
"en"
|
||||||
|
).update_or_create(id=parsed_set_id, defaults=card_set_defaults)
|
||||||
|
|
||||||
|
# If the card set color mapping exists and is different, update it.
|
||||||
|
color_mapping = color_mappings.get(card_set.id)
|
||||||
|
if color_mapping and card_set.hex_color != color_mapping.hex_color:
|
||||||
|
card_set.hex_color = color_mapping.hex_color
|
||||||
|
card_set.save()
|
||||||
|
|
||||||
|
for card_data_item in data:
|
||||||
|
# print("Processing card: ", card_data_item["id"]) # This is very verbose
|
||||||
|
_process_single_card_data(
|
||||||
|
card_data_item,
|
||||||
|
card_set,
|
||||||
|
stats_accumulator,
|
||||||
|
error_tracking,
|
||||||
|
rarity_mappings,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Finished processing file: {file_name}")
|
||||||
|
|
||||||
|
stats.newly_imported_count = stats_accumulator["newly_imported_count"]
|
||||||
|
stats.updated_count = stats_accumulator["updated_count"]
|
||||||
|
stats.skipped_count = stats_accumulator["skipped_count"]
|
||||||
|
|
||||||
|
stats.message = (
|
||||||
|
f"Import completed successfully. Processed {stats.files_processed_count} files. "
|
||||||
|
f"Imported {stats.newly_imported_count} new cards. "
|
||||||
|
f"Updated {stats.updated_count} existing cards. "
|
||||||
|
f"Skipped {stats.skipped_count} unchanged cards."
|
||||||
|
)
|
||||||
|
print("Committing transaction.")
|
||||||
|
transaction.on_commit(lambda: print(stats.message))
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
# Handle network-related errors for the download
|
||||||
|
stats.has_error = True
|
||||||
|
stats.message = f"Failed to download card data from GitHub: {e}"
|
||||||
|
print(stats.message)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Any other exception during the process will cause the transaction to roll back.
|
||||||
|
error_detail = f"Error during import (file: {error_tracking['file_name']}, card: {error_tracking['card_id']}): {str(e)}"
|
||||||
|
stats.has_error = True
|
||||||
|
stats.message = (
|
||||||
|
f"Import HALTED. All changes rolled back. Reason: {error_detail}"
|
||||||
|
)
|
||||||
|
print(stats.message)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CardSet)
|
||||||
|
class CardSetAdmin(TranslatableAdmin):
|
||||||
|
list_display = ("id", "name", "file_name", "hex_color")
|
||||||
|
search_fields = ("translations__name",)
|
||||||
|
readonly_fields = ("id", "file_name", "created_at", "updated_at", "deleted_at")
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).prefetch_related("translations")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Pack)
|
||||||
|
class PackAdmin(TranslatableAdmin):
|
||||||
|
list_display = ("id", "full_name", "name", "cardset")
|
||||||
|
list_filter = ("cardset",)
|
||||||
|
search_fields = ("translations__name", "translations__full_name")
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at")
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return (
|
||||||
|
super()
|
||||||
|
.get_queryset(request)
|
||||||
|
.select_related("cardset")
|
||||||
|
.prefetch_related("translations", "cardset__translations")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Energy)
|
||||||
|
class EnergyAdmin(TranslatableAdmin):
|
||||||
|
list_display = ("id", "name")
|
||||||
|
search_fields = ("translations__name",)
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at", "deleted_at")
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).prefetch_related("translations")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Attack)
|
||||||
|
class AttackAdmin(TranslatableAdmin):
|
||||||
|
list_display = ("id", "name", "damage", "effect")
|
||||||
|
search_fields = ("translations__name",)
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at", "deleted_at")
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).prefetch_related("translations")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Ability)
|
||||||
|
class AbilityAdmin(TranslatableAdmin):
|
||||||
|
list_display = ("id", "name", "effect")
|
||||||
|
search_fields = ("translations__name",)
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at")
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).prefetch_related("translations")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Rarity)
|
||||||
|
class RarityAdmin(TranslatableAdmin):
|
||||||
|
list_display = ("id", "name", "icon", "level")
|
||||||
|
search_fields = ("translations__name",)
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at", "deleted_at")
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).prefetch_related("translations")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CardType)
|
||||||
|
class CardTypeAdmin(TranslatableAdmin):
|
||||||
|
list_display = ("id", "name", "subtype")
|
||||||
|
search_fields = ("translations__name", "translations__subtype")
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at", "deleted_at")
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return super().get_queryset(request).prefetch_related("translations")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Card)
|
||||||
|
class CardAdmin(TranslatableAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"cardnum",
|
||||||
|
"name",
|
||||||
|
"cardset",
|
||||||
|
"card_type",
|
||||||
|
"rarity",
|
||||||
|
"health",
|
||||||
|
"pkmn_type",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
("cardset", PrefetchedSortedRelatedFieldListFilter),
|
||||||
|
("card_type", PrefetchedSortedRelatedFieldListFilter),
|
||||||
|
("rarity", PrefetchedSortedRelatedFieldListFilter),
|
||||||
|
("pkmn_type", PrefetchedSortedRelatedFieldListFilter),
|
||||||
|
("packs", PrefetchedSortedRelatedFieldListFilter),
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"id",
|
||||||
|
"translations__name",
|
||||||
|
"cardset__translations__name",
|
||||||
|
"packs__translations__name",
|
||||||
|
)
|
||||||
|
filter_horizontal = ("packs", "abilities", "attacks")
|
||||||
|
readonly_fields = ("id", "cardnum", "created_at", "updated_at", "deleted_at")
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
return qs.select_related(
|
||||||
|
"cardset", "card_type", "rarity", "pkmn_type"
|
||||||
|
).prefetch_related(
|
||||||
|
"translations",
|
||||||
|
"cardset__translations",
|
||||||
|
"card_type__translations",
|
||||||
|
"rarity__translations",
|
||||||
|
"pkmn_type__translations",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(AttackCost)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(RarityMapping)
|
||||||
|
class RarityMappingAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"original_name",
|
||||||
|
"mapped_name",
|
||||||
|
"icon",
|
||||||
|
"level",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"deleted_at",
|
||||||
|
)
|
||||||
|
search_fields = ("original_name", "mapped_name")
|
||||||
|
list_filter = ("level",)
|
||||||
|
readonly_fields = ("created_at", "updated_at", "deleted_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CardSetColorMapping)
|
||||||
|
class CardSetColorMappingAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"cardset_id",
|
||||||
|
"hex_color",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"deleted_at",
|
||||||
|
)
|
||||||
|
search_fields = ("cardset_id", "hex_color")
|
||||||
|
readonly_fields = ("created_at", "updated_at", "deleted_at")
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_urls(urls):
|
||||||
|
def importer_view(request):
|
||||||
|
context = {
|
||||||
|
"title": "Card Importer",
|
||||||
|
"site_header": admin.site.site_header,
|
||||||
|
"site_title": admin.site.site_title,
|
||||||
|
"index_title": admin.site.index_title,
|
||||||
|
"has_permission": admin.site.has_permission(request),
|
||||||
|
"app_label": "cards",
|
||||||
|
}
|
||||||
|
if request.method == "POST":
|
||||||
|
result = perform_card_import_logic()
|
||||||
|
|
||||||
|
if result.has_error:
|
||||||
|
message = result.message
|
||||||
|
if result.files_processed_count > 0:
|
||||||
|
message += (
|
||||||
|
f" Files attempted before halt: {result.files_processed_count}."
|
||||||
|
)
|
||||||
|
messages.error(request, message)
|
||||||
|
else:
|
||||||
|
messages.success(request, result.message)
|
||||||
|
|
||||||
|
return HttpResponseRedirect(request.path_info)
|
||||||
|
|
||||||
|
return render(request, "admin/cards/importer_status.html", context)
|
||||||
|
|
||||||
|
custom_urls = [
|
||||||
|
path(
|
||||||
|
"cards/import/",
|
||||||
|
admin.site.admin_view(importer_view),
|
||||||
|
name="cards_full_importer",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return custom_urls + urls
|
||||||
|
|
||||||
|
|
||||||
|
original_get_urls = admin.site.get_urls
|
||||||
|
|
||||||
|
|
||||||
|
def new_get_urls():
|
||||||
|
urls = original_get_urls()
|
||||||
|
return get_admin_urls(urls)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.get_urls = new_get_urls
|
||||||
|
|
||||||
|
|
||||||
|
# Restore admin sidebar link for the importer
|
||||||
|
original_get_app_list = admin.site.get_app_list
|
||||||
|
|
||||||
|
|
||||||
|
def new_get_app_list(request):
|
||||||
|
app_list = original_get_app_list(request)
|
||||||
|
for app in app_list:
|
||||||
|
if app.get("app_label") == "cards":
|
||||||
|
app["models"].insert(
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
"name": "Full Card Importer",
|
||||||
|
"object_name": "fullcardimporter",
|
||||||
|
"admin_url": reverse("admin:cards_full_importer"),
|
||||||
|
"view_only": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
return app_list
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.get_app_list = new_get_app_list
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# Generated by Django 5.1 on 2025-05-10 01:22
|
# Generated by Django 5.1 on 2025-06-20 07:14
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
import parler.fields
|
||||||
|
import parler.models
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -8,64 +10,762 @@ class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Card',
|
name="Ability",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
('name', models.CharField(max_length=64)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
('cardset', models.CharField(max_length=32)),
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
('cardnum', models.IntegerField()),
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
('style', models.CharField(max_length=128)),
|
|
||||||
('rarity_icon', models.CharField(max_length=12)),
|
|
||||||
('rarity_level', models.IntegerField()),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Ability",
|
||||||
|
"verbose_name_plural": "Abilities",
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatableModelMixin, models.Model),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Deck',
|
name="Attack",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
('name', models.CharField(max_length=64)),
|
(
|
||||||
('hex_color', models.CharField(max_length=9)),
|
"damage",
|
||||||
('cardset', models.CharField(max_length=8)),
|
models.CharField(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
blank=True,
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
help_text="Damage string, e.g., '40', '20x', '80+'.",
|
||||||
|
max_length=10,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
],
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Attack",
|
||||||
|
"verbose_name_plural": "Attacks",
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatableModelMixin, models.Model),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='CardNameTranslation',
|
name="CardSet",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
(
|
||||||
('name', models.CharField(max_length=64)),
|
"id",
|
||||||
('language', models.CharField(max_length=64)),
|
models.CharField(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
help_text="The ID for the set, e.g., 'A1', 'A1a'.",
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
max_length=3,
|
||||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.card')),
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"file_name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="Original name of the JSON file, e.g., 'a1-genetic-apex.json'.",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
],
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Card Set",
|
||||||
|
"verbose_name_plural": "Card Sets",
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatableModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CardSetColorMapping",
|
||||||
|
fields=[
|
||||||
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
(
|
||||||
|
"cardset_id",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The cardset ID to match (e.g., 'A1').",
|
||||||
|
max_length=10,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hex_color",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The hex color code to use for this cardset.",
|
||||||
|
max_length=9,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Cardset Color Mapping",
|
||||||
|
"verbose_name_plural": "Cardset Color Mappings",
|
||||||
|
"ordering": ["cardset_id"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CardType",
|
||||||
|
fields=[
|
||||||
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Card Type",
|
||||||
|
"verbose_name_plural": "Card Types",
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatableModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Energy",
|
||||||
|
fields=[
|
||||||
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Energy",
|
||||||
|
"verbose_name_plural": "Energies",
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatableModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Rarity",
|
||||||
|
fields=[
|
||||||
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
("icon", models.CharField(max_length=12)),
|
||||||
|
("level", models.PositiveIntegerField()),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Rarity",
|
||||||
|
"verbose_name_plural": "Rarities",
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatableModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="RarityMapping",
|
||||||
|
fields=[
|
||||||
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
(
|
||||||
|
"original_name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The rarity name as it appears in the import source (e.g., JSON file).",
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mapped_name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The standardized rarity name to use in the system.",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"icon",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The icon associated with this rarity.", max_length=12
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"level",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
help_text="The level or order of this rarity."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Rarity Mapping",
|
||||||
|
"verbose_name_plural": "Rarity Mappings",
|
||||||
|
"ordering": ["original_name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="AttackCost",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"quantity",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
help_text="Quantity of this energy type required for the attack.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"attack",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="cards.attack"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"energy",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="cards.energy"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Attack Cost",
|
||||||
|
"verbose_name_plural": "Attack Costs",
|
||||||
|
"unique_together": {("attack", "energy")},
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='card',
|
model_name="attack",
|
||||||
name='decks',
|
name="energy_cost",
|
||||||
field=models.ManyToManyField(to='cards.deck'),
|
field=models.ManyToManyField(
|
||||||
|
related_name="attacks", through="cards.AttackCost", to="cards.energy"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='DeckNameTranslation',
|
name="Pack",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
('name', models.CharField(max_length=64)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
('language', models.CharField(max_length=64)),
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
("deleted_at", models.DateTimeField(blank=True, null=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')),
|
"cardset",
|
||||||
],
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="packs",
|
||||||
|
to="cards.cardset",
|
||||||
),
|
),
|
||||||
migrations.AlterUniqueTogether(
|
),
|
||||||
name='card',
|
],
|
||||||
unique_together={('cardset', 'cardnum')},
|
options={
|
||||||
|
"verbose_name": "Pack",
|
||||||
|
"verbose_name_plural": "Packs",
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatableModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Card",
|
||||||
|
fields=[
|
||||||
|
("cardnum", models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
help_text="The unique ID from the JSON source, cardset-cardnum (e.g., 'a1-001').",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"checksum",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="SHA256 checksum of the card data.",
|
||||||
|
max_length=64,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"health",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
blank=True, help_text="HP of the Pokémon.", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"retreat_cost",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The number of retreat cost for the card.",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"abilities",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True, related_name="cards", to="cards.ability"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"attacks",
|
||||||
|
models.ManyToManyField(related_name="cards", to="cards.attack"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cardset",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="cards",
|
||||||
|
to="cards.cardset",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"card_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="cards",
|
||||||
|
to="cards.cardtype",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"pkmn_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="cards_pkmn_type",
|
||||||
|
to="cards.energy",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"weakness_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="cards_weakness_type",
|
||||||
|
to="cards.energy",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"packs",
|
||||||
|
models.ManyToManyField(related_name="cards", to="cards.pack"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"rarity",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="cards",
|
||||||
|
to="cards.rarity",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Card",
|
||||||
|
"verbose_name_plural": "Cards",
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatableModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="AbilityTranslation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language_code",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, max_length=15, verbose_name="Language"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The name of the ability.", max_length=32
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"effect",
|
||||||
|
models.TextField(help_text="Description of the ability's effect."),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"master",
|
||||||
|
parler.fields.TranslationsForeignKey(
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="translations",
|
||||||
|
to="cards.ability",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Ability Translation",
|
||||||
|
"db_table": "cards_ability_translation",
|
||||||
|
"db_tablespace": "",
|
||||||
|
"managed": True,
|
||||||
|
"default_permissions": (),
|
||||||
|
"unique_together": {("language_code", "master")},
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="AttackTranslation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language_code",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, max_length=15, verbose_name="Language"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The name of the attack.", max_length=32
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"effect",
|
||||||
|
models.TextField(help_text="Description of the attack's effect."),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"master",
|
||||||
|
parler.fields.TranslationsForeignKey(
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="translations",
|
||||||
|
to="cards.attack",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Attack Translation",
|
||||||
|
"db_table": "cards_attack_translation",
|
||||||
|
"db_tablespace": "",
|
||||||
|
"managed": True,
|
||||||
|
"default_permissions": (),
|
||||||
|
"unique_together": {("language_code", "master")},
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CardSetTranslation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language_code",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, max_length=15, verbose_name="Language"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The full name of the set, e.g., 'Genetic Apex'.",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hex_color",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The hex color code associated with this card set.",
|
||||||
|
max_length=9,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"master",
|
||||||
|
parler.fields.TranslationsForeignKey(
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="translations",
|
||||||
|
to="cards.cardset",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Card Set Translation",
|
||||||
|
"db_table": "cards_cardset_translation",
|
||||||
|
"db_tablespace": "",
|
||||||
|
"managed": True,
|
||||||
|
"default_permissions": (),
|
||||||
|
"unique_together": {("language_code", "master")},
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CardTranslation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language_code",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, max_length=15, verbose_name="Language"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(help_text="The name of the card.", max_length=32),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"evolves_from_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Name of the Pokémon this card evolves from.",
|
||||||
|
max_length=32,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"master",
|
||||||
|
parler.fields.TranslationsForeignKey(
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="translations",
|
||||||
|
to="cards.card",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Card Translation",
|
||||||
|
"db_table": "cards_card_translation",
|
||||||
|
"db_tablespace": "",
|
||||||
|
"managed": True,
|
||||||
|
"default_permissions": (),
|
||||||
|
"unique_together": {("language_code", "master")},
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CardTypeTranslation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language_code",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, max_length=15, verbose_name="Language"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The name of the card type.", max_length=32
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"subtype",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The subtype of the card type.",
|
||||||
|
max_length=32,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"master",
|
||||||
|
parler.fields.TranslationsForeignKey(
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="translations",
|
||||||
|
to="cards.cardtype",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Card Type Translation",
|
||||||
|
"db_table": "cards_cardtype_translation",
|
||||||
|
"db_tablespace": "",
|
||||||
|
"managed": True,
|
||||||
|
"default_permissions": (),
|
||||||
|
"unique_together": {("language_code", "master")},
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="EnergyTranslation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language_code",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, max_length=15, verbose_name="Language"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The name of the energy.", max_length=32
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"master",
|
||||||
|
parler.fields.TranslationsForeignKey(
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="translations",
|
||||||
|
to="cards.energy",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Energy Translation",
|
||||||
|
"db_table": "cards_energy_translation",
|
||||||
|
"db_tablespace": "",
|
||||||
|
"managed": True,
|
||||||
|
"default_permissions": (),
|
||||||
|
"unique_together": {("language_code", "master")},
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PackTranslation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language_code",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, max_length=15, verbose_name="Language"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"full_name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The full name of the pack, e.g., 'Genetic Apex: Mewtwo'.",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The pack name itself, e.g., 'Mewtwo'.", max_length=32
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"master",
|
||||||
|
parler.fields.TranslationsForeignKey(
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="translations",
|
||||||
|
to="cards.pack",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Pack Translation",
|
||||||
|
"db_table": "cards_pack_translation",
|
||||||
|
"db_tablespace": "",
|
||||||
|
"managed": True,
|
||||||
|
"default_permissions": (),
|
||||||
|
"unique_together": {("language_code", "master")},
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="RarityTranslation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language_code",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, max_length=15, verbose_name="Language"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="The name of the rarity.", max_length=32
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"master",
|
||||||
|
parler.fields.TranslationsForeignKey(
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="translations",
|
||||||
|
to="cards.rarity",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Rarity Translation",
|
||||||
|
"db_table": "cards_rarity_translation",
|
||||||
|
"db_tablespace": "",
|
||||||
|
"managed": True,
|
||||||
|
"default_permissions": (),
|
||||||
|
"unique_together": {("language_code", "master")},
|
||||||
|
},
|
||||||
|
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
from math import ceil
|
from math import ceil
|
||||||
|
|
||||||
|
|
||||||
class ReusablePaginationMixin:
|
class ReusablePaginationMixin:
|
||||||
"""
|
"""
|
||||||
A mixin that encapsulates reusable pagination logic.
|
A mixin that encapsulates reusable pagination logic.
|
||||||
Use in Django ListViews to generate custom pagination context.
|
Use in Django ListViews to generate custom pagination context.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
per_page = 10 # Default; can be overridden in your view.
|
per_page = 10 # Default; can be overridden in your view.
|
||||||
|
|
||||||
def paginate_data(self, data_list, page_number):
|
def paginate_data(self, data_list, page_number):
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,383 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Prefetch
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.apps import apps
|
from parler.managers import TranslatableManager
|
||||||
|
from parler.models import TranslatableModel, TranslatedFields
|
||||||
|
|
||||||
class DeckNameTranslation(models.Model):
|
|
||||||
id = models.AutoField(primary_key=True)
|
class CardManager(TranslatableManager):
|
||||||
name = models.CharField(max_length=64)
|
def with_details(self):
|
||||||
deck = models.ForeignKey("Deck", on_delete=models.PROTECT, related_name='name_translations')
|
"""
|
||||||
language = models.CharField(max_length=64)
|
Returns a Card queryset with all related fields pre-selected to avoid N+1 queries.
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
"""
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
return (
|
||||||
|
self.get_queryset()
|
||||||
def __str__(self):
|
.select_related(
|
||||||
return self.name
|
"rarity",
|
||||||
|
"cardset",
|
||||||
class CardNameTranslation(models.Model):
|
"card_type",
|
||||||
id = models.AutoField(primary_key=True)
|
"pkmn_type",
|
||||||
name = models.CharField(max_length=64)
|
"weakness_type",
|
||||||
card = models.ForeignKey("Card", on_delete=models.PROTECT, related_name='name_translations')
|
)
|
||||||
language = models.CharField(max_length=64)
|
.prefetch_related(
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
"translations",
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
"rarity__translations",
|
||||||
|
"cardset__translations",
|
||||||
def __str__(self):
|
"card_type__translations",
|
||||||
return self.name
|
"pkmn_type__translations",
|
||||||
class Deck(models.Model):
|
"weakness_type__translations",
|
||||||
id = models.AutoField(primary_key=True)
|
"attacks__translations",
|
||||||
name = models.CharField(max_length=64)
|
"abilities__translations",
|
||||||
hex_color = models.CharField(max_length=9)
|
"packs__translations",
|
||||||
cardset = models.CharField(max_length=8)
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
class CardSet(TranslatableModel):
|
||||||
return self.name
|
"""
|
||||||
|
Represents a single JSON file from the repository, considered a 'cardset', e.g., "Genetic Apex (A1)",
|
||||||
class Card(models.Model):
|
or collection of cards. Each cardset file belongs to a specific CardSet and language.
|
||||||
id = models.AutoField(primary_key=True)
|
"""
|
||||||
name = models.CharField(max_length=64)
|
|
||||||
decks = models.ManyToManyField("Deck")
|
translations = TranslatedFields(
|
||||||
cardset = models.CharField(max_length=32)
|
name=models.CharField(
|
||||||
cardnum = models.IntegerField()
|
max_length=32,
|
||||||
style = models.CharField(max_length=128)
|
help_text=_("The full name of the set, e.g., 'Genetic Apex'."),
|
||||||
rarity_icon = models.CharField(max_length=12)
|
),
|
||||||
rarity_level = models.IntegerField()
|
hex_color=models.CharField(
|
||||||
|
max_length=9,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("The hex color code associated with this card set."),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
id = models.CharField(
|
||||||
|
max_length=3,
|
||||||
|
primary_key=True,
|
||||||
|
help_text=_("The ID for the set, e.g., 'A1', 'A1a'."),
|
||||||
|
)
|
||||||
|
file_name = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
help_text=_("Original name of the JSON file, e.g., 'a1-genetic-apex.json'."),
|
||||||
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('cardset', 'cardnum')
|
verbose_name = _("Card Set")
|
||||||
|
verbose_name_plural = _("Card Sets")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.cardset} #{self.cardnum})"
|
return f"{self.id} - {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class Pack(TranslatableModel):
|
||||||
|
"""
|
||||||
|
Represents a single pack that is part of a cardset. E.g., "Genetic Apex: Mewtwo"
|
||||||
|
"""
|
||||||
|
|
||||||
|
translations = TranslatedFields(
|
||||||
|
full_name=models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
help_text=_("The full name of the pack, e.g., 'Genetic Apex: Mewtwo'."),
|
||||||
|
),
|
||||||
|
name=models.CharField(
|
||||||
|
max_length=32, help_text=_("The pack name itself, e.g., 'Mewtwo'.")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
cardset = models.ForeignKey(CardSet, on_delete=models.CASCADE, related_name="packs")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Pack")
|
||||||
|
verbose_name_plural = _("Packs")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.full_name}"
|
||||||
|
|
||||||
|
|
||||||
|
class Energy(TranslatableModel):
|
||||||
|
"""
|
||||||
|
A type a Pokémon card can have.
|
||||||
|
"""
|
||||||
|
|
||||||
|
translations = TranslatedFields(
|
||||||
|
name=models.CharField(max_length=32, help_text=_("The name of the energy."))
|
||||||
|
)
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Energy")
|
||||||
|
verbose_name_plural = _("Energies")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class AttackCost(models.Model):
|
||||||
|
"""
|
||||||
|
Intermediary model to store the quantity of each energy type for an attack's cost.
|
||||||
|
"""
|
||||||
|
|
||||||
|
attack = models.ForeignKey("Attack", on_delete=models.CASCADE)
|
||||||
|
energy = models.ForeignKey(Energy, on_delete=models.CASCADE)
|
||||||
|
quantity = models.PositiveIntegerField(
|
||||||
|
default=1, help_text=_("Quantity of this energy type required for the attack.")
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Attack Cost")
|
||||||
|
verbose_name_plural = _("Attack Costs")
|
||||||
|
unique_together = ("attack", "energy")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.attack.name} {_('requires')} {self.quantity} {self.energy.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class Attack(TranslatableModel):
|
||||||
|
"""
|
||||||
|
An attack a Pokémon card can have.
|
||||||
|
"""
|
||||||
|
|
||||||
|
translations = TranslatedFields(
|
||||||
|
name=models.CharField(max_length=32, help_text=_("The name of the attack.")),
|
||||||
|
effect=models.TextField(help_text=_("Description of the attack's effect.")),
|
||||||
|
)
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
damage = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Damage string, e.g., '40', '20x', '80+'."),
|
||||||
|
)
|
||||||
|
energy_cost = models.ManyToManyField(
|
||||||
|
Energy, through=AttackCost, related_name="attacks"
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Attack")
|
||||||
|
verbose_name_plural = _("Attacks")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class Ability(TranslatableModel):
|
||||||
|
"""
|
||||||
|
An ability a Pokémon card can have.
|
||||||
|
"""
|
||||||
|
|
||||||
|
translations = TranslatedFields(
|
||||||
|
name=models.CharField(max_length=32, help_text=_("The name of the ability.")),
|
||||||
|
effect=models.TextField(help_text=_("Description of the ability's effect.")),
|
||||||
|
)
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Ability")
|
||||||
|
verbose_name_plural = _("Abilities")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class Rarity(TranslatableModel):
|
||||||
|
"""
|
||||||
|
A rarity a Pokémon card can have.
|
||||||
|
"""
|
||||||
|
|
||||||
|
translations = TranslatedFields(
|
||||||
|
name=models.CharField(max_length=32, help_text=_("The name of the rarity."))
|
||||||
|
)
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
icon = models.CharField(max_length=12)
|
||||||
|
level = models.PositiveIntegerField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Rarity")
|
||||||
|
verbose_name_plural = _("Rarities")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class CardType(TranslatableModel):
|
||||||
|
"""
|
||||||
|
A type a Pokémon card can have.
|
||||||
|
"""
|
||||||
|
|
||||||
|
translations = TranslatedFields(
|
||||||
|
name=models.CharField(max_length=32, help_text=_("The name of the card type.")),
|
||||||
|
subtype=models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("The subtype of the card type."),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Card Type")
|
||||||
|
verbose_name_plural = _("Card Types")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class Card(TranslatableModel):
|
||||||
|
"""
|
||||||
|
Represents a single, unique digital printing of a Pokémon card.
|
||||||
|
"""
|
||||||
|
|
||||||
|
objects = CardManager()
|
||||||
|
|
||||||
|
translations = TranslatedFields(
|
||||||
|
name=models.CharField(max_length=32, help_text=_("The name of the card.")),
|
||||||
|
evolves_from_name=models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Name of the Pokémon this card evolves from."),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cardnum = models.AutoField(primary_key=True)
|
||||||
|
id = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
db_index=True,
|
||||||
|
help_text=_(
|
||||||
|
"The unique ID from the JSON source, cardset-cardnum (e.g., 'a1-001')."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
checksum = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("SHA256 checksum of the card data."),
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
health = models.PositiveIntegerField(
|
||||||
|
null=True, blank=True, help_text=_("HP of the Pokémon.")
|
||||||
|
)
|
||||||
|
|
||||||
|
retreat_cost = models.PositiveIntegerField(
|
||||||
|
null=True, blank=True, help_text=_("The number of retreat cost for the card.")
|
||||||
|
)
|
||||||
|
weakness_type = models.ForeignKey(
|
||||||
|
Energy,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="cards_weakness_type",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
pkmn_type = models.ForeignKey(
|
||||||
|
Energy,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="cards_pkmn_type",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
card_type = models.ForeignKey(
|
||||||
|
CardType, on_delete=models.CASCADE, related_name="cards"
|
||||||
|
)
|
||||||
|
packs = models.ManyToManyField(Pack, related_name="cards")
|
||||||
|
cardset = models.ForeignKey(CardSet, on_delete=models.CASCADE, related_name="cards")
|
||||||
|
abilities = models.ManyToManyField(Ability, blank=True, related_name="cards")
|
||||||
|
attacks = models.ManyToManyField(Attack, related_name="cards")
|
||||||
|
rarity = models.ForeignKey(Rarity, on_delete=models.CASCADE, related_name="cards")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Card")
|
||||||
|
verbose_name_plural = _("Cards")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.id} {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class RarityMapping(models.Model):
|
||||||
|
"""
|
||||||
|
Maps an original rarity name from the import source to a standardized
|
||||||
|
rarity name, icon, and level to be used in the system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
original_name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
help_text=_(
|
||||||
|
"The rarity name as it appears in the import source (e.g., JSON file)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mapped_name = models.CharField(
|
||||||
|
max_length=32, help_text=_("The standardized rarity name to use in the system.")
|
||||||
|
)
|
||||||
|
icon = models.CharField(
|
||||||
|
max_length=12, help_text=_("The icon associated with this rarity.")
|
||||||
|
)
|
||||||
|
level = models.PositiveIntegerField(
|
||||||
|
help_text=_("The level or order of this rarity.")
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Rarity Mapping")
|
||||||
|
verbose_name_plural = _("Rarity Mappings")
|
||||||
|
ordering = ["original_name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"'{self.original_name}' -> '{self.mapped_name}' (L{self.level}, {self.icon})"
|
||||||
|
|
||||||
|
|
||||||
|
class CardSetColorMapping(models.Model):
|
||||||
|
"""
|
||||||
|
Maps a cardset ID to a hex color code. This is used to pre-map cardset
|
||||||
|
colors when an original hex color is not available from the import source.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.AutoField(primary_key=True)
|
||||||
|
cardset_id = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
unique=True,
|
||||||
|
help_text=_("The cardset ID to match (e.g., 'A1')."),
|
||||||
|
)
|
||||||
|
hex_color = models.CharField(
|
||||||
|
max_length=9, help_text=_("The hex color code to use for this cardset.")
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Cardset Color Mapping")
|
||||||
|
verbose_name_plural = _("Cardset Color Mappings")
|
||||||
|
ordering = ["cardset_id"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"'{self.cardset_id}' -> '{self.hex_color}'"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
from django.db.models.signals import m2m_changed
|
|
||||||
from django.dispatch import receiver
|
|
||||||
from .models import Card
|
|
||||||
|
|
||||||
def color_is_dark(bg_color):
|
def color_is_dark(bg_color):
|
||||||
"""
|
"""
|
||||||
Determine if a given hexadecimal color is dark.
|
Determine if a given hexadecimal color is dark.
|
||||||
|
|
@ -20,7 +16,7 @@ def color_is_dark(bg_color):
|
||||||
bool: True if the color is dark (brightness <= 186), False otherwise.
|
bool: True if the color is dark (brightness <= 186), False otherwise.
|
||||||
"""
|
"""
|
||||||
# Remove the leading '#' if it exists.
|
# 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.
|
# Convert the hex color components to integers.
|
||||||
r = int(color[0:2], 16)
|
r = int(color[0:2], 16)
|
||||||
|
|
@ -31,21 +27,3 @@ def color_is_dark(bg_color):
|
||||||
brightness = (r * 0.299) + (g * 0.587) + (b * 0.114)
|
brightness = (r * 0.299) + (g * 0.587) + (b * 0.114)
|
||||||
|
|
||||||
return brightness <= 200
|
return brightness <= 200
|
||||||
|
|
||||||
@receiver(m2m_changed, sender=Card.decks.through)
|
|
||||||
def update_card_style(sender, instance, action, **kwargs):
|
|
||||||
if action == "post_add":
|
|
||||||
decks = instance.decks.all()
|
|
||||||
num_decks = decks.count()
|
|
||||||
if num_decks == 1:
|
|
||||||
instance.style = "background-color: " + decks.first().hex_color + ";"
|
|
||||||
elif num_decks >= 2:
|
|
||||||
hex_colors = [deck.hex_color for deck in decks]
|
|
||||||
instance.style = f"background: linear-gradient(to right, {', '.join(hex_colors)});"
|
|
||||||
else:
|
|
||||||
instance.style = "background: linear-gradient(to right, #AAAAAA, #AAAAAA, #AAAAAA);"
|
|
||||||
if not color_is_dark(decks.first().hex_color):
|
|
||||||
instance.style += "color: var(--color-gray-700); text-shadow: 0 0 0 var(--color-gray-700);"
|
|
||||||
else:
|
|
||||||
instance.style += "text-shadow: 0 0 0 #fff;"
|
|
||||||
instance.save(update_fields=["style"])
|
|
||||||
|
|
@ -1,46 +1,75 @@
|
||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_gradient_style(hex_color):
|
||||||
|
"""
|
||||||
|
Generates a gradient style from a single hex color.
|
||||||
|
"""
|
||||||
|
if not hex_color:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
hex_color = hex_color.lstrip("#")
|
||||||
|
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
||||||
|
|
||||||
|
# Create a slightly darker shade for the gradient
|
||||||
|
darker_r = max(0, r - 30)
|
||||||
|
darker_g = max(0, g - 30)
|
||||||
|
darker_b = max(0, b - 30)
|
||||||
|
darker_color = f"#{darker_r:02x}{darker_g:02x}{darker_b:02x}"
|
||||||
|
|
||||||
|
return f"background-image: linear-gradient(to bottom right, #{hex_color}, {darker_color});"
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag("templatetags/card_badge.html", takes_context=True)
|
@register.inclusion_tag("templatetags/card_badge.html", takes_context=True)
|
||||||
def card_badge(context, card, quantity=None, expanded=False):
|
def card_badge(context, card, quantity=None, expanded=False, clickable=True):
|
||||||
"""
|
"""
|
||||||
Renders a card badge.
|
Renders a card badge.
|
||||||
"""
|
"""
|
||||||
url = reverse_lazy('cards:card_detail', args=[card.pk])
|
url = reverse_lazy("cards:detail", args=[card.pk])
|
||||||
|
style = _get_gradient_style(card.cardset.hex_color)
|
||||||
tag_context = {
|
tag_context = {
|
||||||
'quantity': quantity,
|
"quantity": quantity,
|
||||||
'style': card.style,
|
"style": style,
|
||||||
'name': card.name,
|
"name": card.name,
|
||||||
'rarity': card.rarity_icon,
|
"rarity": card.rarity.icon,
|
||||||
'cardset': card.cardset,
|
"cardset": card.cardset.id,
|
||||||
'expanded': expanded,
|
"expanded": expanded,
|
||||||
'cache_key': f'card_badge_{card.pk}_{quantity}_{expanded}',
|
"cache_key": f"card_badge_{card.pk}_{quantity}_{expanded}_{card.cardset.hex_color}",
|
||||||
'url': url,
|
"url": url,
|
||||||
|
"clickable": clickable,
|
||||||
|
"closeable": True,
|
||||||
|
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
|
||||||
}
|
}
|
||||||
context.update(tag_context)
|
context.update(tag_context)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def card_badge_inline(card, quantity=None):
|
def card_badge_inline(card, quantity=None):
|
||||||
"""
|
"""
|
||||||
Renders an inline card badge by directly rendering the template.
|
Renders an inline card badge by directly rendering the template.
|
||||||
"""
|
"""
|
||||||
url = reverse_lazy('cards:card_detail', args=[card.pk])
|
url = reverse_lazy("cards:detail", args=[card.pk])
|
||||||
|
style = _get_gradient_style(card.cardset.hex_color)
|
||||||
tag_context = {
|
tag_context = {
|
||||||
'quantity': quantity,
|
"quantity": quantity,
|
||||||
'style': card.style,
|
"style": style,
|
||||||
'name': card.name,
|
"name": card.name,
|
||||||
'rarity': card.rarity_icon,
|
"rarity": card.rarity,
|
||||||
'cardset': card.cardset,
|
"cardset": card.cardset,
|
||||||
'expanded': True,
|
"expanded": True,
|
||||||
'cache_key': f'card_badge_{card.pk}_{quantity}_{True}',
|
"cache_key": f"card_badge_{card.pk}_{quantity}_{True}_{card.cardset.hex_color}",
|
||||||
'CACHE_TIMEOUT': settings.CACHE_TIMEOUT,
|
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
|
||||||
'url': url,
|
"url": url,
|
||||||
}
|
}
|
||||||
html = render_to_string("templatetags/card_badge.html", tag_context)
|
html = render_to_string("templatetags/card_badge.html", tag_context)
|
||||||
return mark_safe(html)
|
return mark_safe(html)
|
||||||
|
|
@ -1,72 +1,87 @@
|
||||||
import uuid
|
|
||||||
from django import template
|
|
||||||
from pkmntrade_club.cards.models import Card
|
|
||||||
from django.db.models.query import QuerySet
|
|
||||||
import json
|
import json
|
||||||
import hashlib
|
|
||||||
import logging
|
from django import template
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def get_item(dictionary, key):
|
def get_item(dictionary, key):
|
||||||
"""Allows accessing dictionary items using a variable key in templates."""
|
"""Allows accessing dictionary items using a variable key in templates."""
|
||||||
return dictionary.get(key)
|
return dictionary.get(key)
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def fetch_all_cards():
|
def fetch_all_cards():
|
||||||
"""Simple tag to fetch all Card objects."""
|
"""Simple tag to fetch all Card objects."""
|
||||||
return Card.objects.order_by('pk').all()
|
return Card.objects.with_details().order_by("id").all()
|
||||||
|
|
||||||
@register.inclusion_tag('templatetags/card_multiselect.html', takes_context=True)
|
|
||||||
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.
|
Renders a card multiselect widget with client-side searching.
|
||||||
Database querying and rendering are handled within the template's cache block.
|
|
||||||
|
For the JS-driven component, it prepares initial selected cards.
|
||||||
|
For the non-JS fallback, it accepts a `cards` queryset from a
|
||||||
|
server-side search.
|
||||||
"""
|
"""
|
||||||
if selected_values is None:
|
if selected_values is None:
|
||||||
selected_values = []
|
selected_values = []
|
||||||
|
|
||||||
selected_cards = {}
|
# Fetch full objects for any pre-selected cards for initial display.
|
||||||
for val in selected_values:
|
initial_selected_cards = []
|
||||||
parts = str(val).split(':')
|
if selected_values:
|
||||||
if len(parts) >= 1 and parts[0]:
|
card_ids = [str(val) for val in selected_values]
|
||||||
card_id = parts[0]
|
initial_selected_cards = list(
|
||||||
quantity = parts[1] if len(parts) > 1 else 1
|
Card.objects.with_details().filter(id__in=card_ids)
|
||||||
selected_cards[str(card_id)] = quantity
|
)
|
||||||
|
|
||||||
effective_field_name = field_name if field_name is not None else 'card_multiselect'
|
# `cards` is for the non-JS fallback search result.
|
||||||
effective_label = label if label is not None else 'Card'
|
non_js_search_results = cards if isinstance(cards, QuerySet) else []
|
||||||
effective_placeholder = placeholder if placeholder is not None else 'Select Cards'
|
|
||||||
|
|
||||||
selected_cards_key_part = json.dumps(selected_cards, sort_keys=True)
|
initial_cards_data = []
|
||||||
|
for card in initial_selected_cards:
|
||||||
has_passed_cards = isinstance(cards, QuerySet)
|
badge_context = {
|
||||||
|
"card": card,
|
||||||
if has_passed_cards:
|
"quantity": None,
|
||||||
try:
|
"expanded": False,
|
||||||
query_string = str(cards.query)
|
"url": reverse("cards:detail", args=[card.pk]),
|
||||||
passed_cards_identifier = hashlib.sha256(query_string.encode('utf-8')).hexdigest()
|
"style": card.style,
|
||||||
except Exception as e:
|
"name": card.name,
|
||||||
logging.warning(f"Could not generate query hash for card_multiselect. Error: {e}")
|
"rarity": card.rarity.icon if card.rarity else "",
|
||||||
passed_cards_identifier = 'specific_qs_fallback_' + str(uuid.uuid4())
|
"cardset": card.cardset.id,
|
||||||
else:
|
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
initial_cards_data.append(
|
||||||
|
{
|
||||||
|
"id": str(card.id),
|
||||||
|
"name": card.name,
|
||||||
|
"set_name": card.cardset.name,
|
||||||
|
"html": render_to_string("templatetags/card_badge.html", badge_context),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Update the original context with the tag-specific variables
|
initial_selected_cards_json = json.dumps(initial_cards_data)
|
||||||
# This preserves CACHE_TIMEOUT and other parent context variables
|
|
||||||
context.update(tag_specific_context)
|
|
||||||
|
|
||||||
return context # Return the MODIFIED original context
|
context.update(
|
||||||
|
{
|
||||||
|
"field_name": field_name,
|
||||||
|
"field_id": f"id_{field_name}",
|
||||||
|
"label": label,
|
||||||
|
"placeholder": placeholder,
|
||||||
|
"initial_selected_cards": initial_selected_cards,
|
||||||
|
"initial_selected_cards_json": initial_selected_cards_json,
|
||||||
|
"non_js_search_results": non_js_search_results,
|
||||||
|
"has_non_js_results": bool(non_js_search_results),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,21 @@ from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from pkmntrade_club.accounts.models import CustomUser, FriendCode
|
from pkmntrade_club.accounts.models import CustomUser, FriendCode
|
||||||
from pkmntrade_club.cards.models import Card, Deck, DeckNameTranslation, CardNameTranslation
|
from pkmntrade_club.cards.models import (
|
||||||
from pkmntrade_club.trades.models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard
|
Card,
|
||||||
from pkmntrade_club.cards.templatetags import card_badge, card_multiselect
|
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
|
from tests.utils.rarity import RARITY_MAPPING
|
||||||
|
|
||||||
|
|
||||||
class CardsModelsTests(TestCase):
|
class CardsModelsTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.deck = Deck.objects.create(
|
self.deck = Deck.objects.create(
|
||||||
|
|
@ -22,7 +32,7 @@ class CardsModelsTests(TestCase):
|
||||||
cardnum=1,
|
cardnum=1,
|
||||||
style="default",
|
style="default",
|
||||||
rarity_icon=RARITY_MAPPING[1],
|
rarity_icon=RARITY_MAPPING[1],
|
||||||
rarity_level=1
|
rarity_level=1,
|
||||||
)
|
)
|
||||||
# Establish many-to-many relationship.
|
# Establish many-to-many relationship.
|
||||||
self.card.decks.add(self.deck)
|
self.card.decks.add(self.deck)
|
||||||
|
|
@ -46,6 +56,7 @@ class CardsModelsTests(TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(str(card_translation), "Card Translated")
|
self.assertEqual(str(card_translation), "Card Translated")
|
||||||
|
|
||||||
|
|
||||||
class CardTemplatetagsTests(TestCase):
|
class CardTemplatetagsTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Create a dummy card to use in template tag tests.
|
# Create a dummy card to use in template tag tests.
|
||||||
|
|
@ -55,12 +66,12 @@ class CardTemplatetagsTests(TestCase):
|
||||||
cardnum=2,
|
cardnum=2,
|
||||||
style="background: green;",
|
style="background: green;",
|
||||||
rarity_icon="☆",
|
rarity_icon="☆",
|
||||||
rarity_level=2
|
rarity_level=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_card_badge_inclusion_tag(self):
|
def test_card_badge_inclusion_tag(self):
|
||||||
"""Test the card_badge inclusion tag renders correctly."""
|
"""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)
|
t = Template(template_str)
|
||||||
c = Context({"card": self.card})
|
c = Context({"card": self.card})
|
||||||
rendered = t.render(c)
|
rendered = t.render(c)
|
||||||
|
|
@ -71,7 +82,7 @@ class CardTemplatetagsTests(TestCase):
|
||||||
|
|
||||||
def test_card_badge_inline_filter(self):
|
def test_card_badge_inline_filter(self):
|
||||||
"""Test the card_badge_inline filter returns safe HTML with correct data."""
|
"""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)
|
t = Template(template_str)
|
||||||
c = Context({"card": self.card})
|
c = Context({"card": self.card})
|
||||||
rendered = t.render(c)
|
rendered = t.render(c)
|
||||||
|
|
@ -144,6 +155,7 @@ class CardTemplatetagsTests(TestCase):
|
||||||
# Verify that the context's cards match those in the database.
|
# 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):
|
class CardsViewsTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
|
|
@ -161,7 +173,7 @@ class CardsViewsTests(TestCase):
|
||||||
cardnum=1,
|
cardnum=1,
|
||||||
style="default",
|
style="default",
|
||||||
rarity_icon=RARITY_MAPPING[1],
|
rarity_icon=RARITY_MAPPING[1],
|
||||||
rarity_level=1
|
rarity_level=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_card_detail_view_context(self):
|
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.
|
Helper method to create a trade offer for the 'have' side with a custom updated_at.
|
||||||
"""
|
"""
|
||||||
offer = TradeOffer.objects.create(initiated_by=self.friendcode)
|
offer = TradeOffer.objects.create(initiated_by=self.friendcode)
|
||||||
TradeOfferHaveCard.objects.create(
|
TradeOfferHaveCard.objects.create(trade_offer=offer, card=self.card, quantity=1)
|
||||||
trade_offer=offer, card=self.card, quantity=1
|
|
||||||
)
|
|
||||||
# Adjust updated_at so that ordering can be tested.
|
# Adjust updated_at so that ordering can be tested.
|
||||||
new_time = timezone.now() + timedelta(minutes=updated_delta_minutes)
|
new_time = timezone.now() + timedelta(minutes=updated_delta_minutes)
|
||||||
TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time)
|
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.
|
Helper method to create a trade offer for the 'want' side with a custom updated_at.
|
||||||
"""
|
"""
|
||||||
offer = TradeOffer.objects.create(initiated_by=self.friendcode)
|
offer = TradeOffer.objects.create(initiated_by=self.friendcode)
|
||||||
TradeOfferWantCard.objects.create(
|
TradeOfferWantCard.objects.create(trade_offer=offer, card=self.card, quantity=1)
|
||||||
trade_offer=offer, card=self.card, quantity=1
|
|
||||||
)
|
|
||||||
new_time = timezone.now() + timedelta(minutes=updated_delta_minutes)
|
new_time = timezone.now() + timedelta(minutes=updated_delta_minutes)
|
||||||
TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time)
|
TradeOffer.objects.filter(pk=offer.pk).update(updated_at=new_time)
|
||||||
offer.refresh_from_db()
|
offer.refresh_from_db()
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,27 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
CardDetailView,
|
CardDetailView,
|
||||||
|
CardListView,
|
||||||
TradeOfferHaveCardListView,
|
TradeOfferHaveCardListView,
|
||||||
TradeOfferWantCardListView,
|
TradeOfferWantCardListView,
|
||||||
CardListView,
|
card_search,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "cards"
|
app_name = "cards"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', CardListView.as_view(), name='card_list'),
|
path("", view=CardListView.as_view(), name="list"),
|
||||||
path('<int:pk>/', CardDetailView.as_view(), name='card_detail'),
|
path("<str:pk>/", view=CardDetailView.as_view(), name="detail"),
|
||||||
path('<int:pk>/trade-offers-have/', TradeOfferHaveCardListView.as_view(), name='card_trade_offer_have_list'),
|
path("api/search/", card_search, name="api_search"),
|
||||||
path('<int:pk>/trade-offers-want/', TradeOfferWantCardListView.as_view(), name='card_trade_offer_want_list'),
|
path(
|
||||||
|
"<int:pk>/trade-offers-have/",
|
||||||
|
TradeOfferHaveCardListView.as_view(),
|
||||||
|
name="card_trade_offer_have_list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<int:pk>/trade-offers-want/",
|
||||||
|
TradeOfferWantCardListView.as_view(),
|
||||||
|
name="card_trade_offer_want_list",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,61 @@
|
||||||
from django.views.generic import TemplateView
|
from django.conf import settings
|
||||||
from django.urls import reverse_lazy
|
from django.db.models import Q
|
||||||
from django.views.generic import UpdateView, DeleteView, CreateView, ListView, DetailView
|
from django.http import JsonResponse
|
||||||
from pkmntrade_club.cards.models import Card
|
|
||||||
from pkmntrade_club.trades.models import TradeOffer
|
|
||||||
from pkmntrade_club.common.mixins import ReusablePaginationMixin
|
|
||||||
from django.views import View
|
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.views import View
|
||||||
|
from django.views.generic import (
|
||||||
|
DetailView,
|
||||||
|
ListView,
|
||||||
|
)
|
||||||
|
|
||||||
|
from pkmntrade_club.cards.models import Card
|
||||||
|
from pkmntrade_club.common.mixins import ReusablePaginationMixin
|
||||||
|
from pkmntrade_club.trades.models import TradeOffer
|
||||||
|
|
||||||
|
|
||||||
class CardDetailView(DetailView):
|
class CardDetailView(DetailView):
|
||||||
model = Card
|
model = Card
|
||||||
template_name = "cards/card_detail.html"
|
template_name = "cards/card_detail.html"
|
||||||
context_object_name = "card"
|
context_object_name = "card"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = super().get_queryset()
|
||||||
|
# Prefetch all related data and translations to avoid N+1 queries in the template.
|
||||||
|
return qs.select_related(
|
||||||
|
"cardset", "rarity", "card_type", "pkmn_type", "weakness_type"
|
||||||
|
).prefetch_related(
|
||||||
|
"translations",
|
||||||
|
"cardset__translations",
|
||||||
|
"rarity__translations",
|
||||||
|
"card_type__translations",
|
||||||
|
"pkmn_type__translations",
|
||||||
|
"weakness_type__translations",
|
||||||
|
"packs__translations",
|
||||||
|
"abilities__translations",
|
||||||
|
"attacks__translations",
|
||||||
|
"attacks__energy_cost__energy__translations",
|
||||||
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
card = self.get_object()
|
card = self.get_object()
|
||||||
# Count of trade offers where the card appears as a "have" in a trade.
|
# Count of trade offers where the card appears as a "have" in a trade.
|
||||||
context['trade_offer_have_count'] = TradeOffer.objects.filter(
|
context["trade_offer_have_count"] = (
|
||||||
trade_offer_have_cards__card=card
|
TradeOffer.objects.filter(trade_offer_have_cards__card=card)
|
||||||
).distinct().count()
|
.distinct()
|
||||||
|
.count()
|
||||||
|
)
|
||||||
# Count of trade offers where the card appears as a "want" in a trade.
|
# Count of trade offers where the card appears as a "want" in a trade.
|
||||||
context['trade_offer_want_count'] = TradeOffer.objects.filter(
|
context["trade_offer_want_count"] = (
|
||||||
trade_offer_want_cards__card=card
|
TradeOffer.objects.filter(trade_offer_want_cards__card=card)
|
||||||
).distinct().count()
|
.distinct()
|
||||||
|
.count()
|
||||||
|
)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferHaveCardListView(ReusablePaginationMixin, View):
|
class TradeOfferHaveCardListView(ReusablePaginationMixin, View):
|
||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
card = get_object_or_404(Card, pk=pk)
|
card = get_object_or_404(Card, pk=pk)
|
||||||
|
|
@ -48,6 +79,7 @@ class TradeOfferHaveCardListView(ReusablePaginationMixin, View):
|
||||||
# Render the partial template to be injected via AJAX
|
# Render the partial template to be injected via AJAX
|
||||||
return render(request, "trades/_trade_offer_list.html", context)
|
return render(request, "trades/_trade_offer_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferWantCardListView(ReusablePaginationMixin, View):
|
class TradeOfferWantCardListView(ReusablePaginationMixin, View):
|
||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
card = get_object_or_404(Card, pk=pk)
|
card = get_object_or_404(Card, pk=pk)
|
||||||
|
|
@ -72,6 +104,8 @@ class TradeOfferWantCardListView(ReusablePaginationMixin, View):
|
||||||
}
|
}
|
||||||
# Render the partial template containing the new pagination controls
|
# Render the partial template containing the new pagination controls
|
||||||
return render(request, "trades/_trade_offer_list.html", context)
|
return render(request, "trades/_trade_offer_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
class CardListView(ReusablePaginationMixin, ListView):
|
class CardListView(ReusablePaginationMixin, ListView):
|
||||||
model = Card
|
model = Card
|
||||||
# Removed built-in pagination; using custom mixin instead
|
# Removed built-in pagination; using custom mixin instead
|
||||||
|
|
@ -84,18 +118,43 @@ class CardListView(ReusablePaginationMixin, ListView):
|
||||||
|
|
||||||
def get_ordering(self):
|
def get_ordering(self):
|
||||||
order = self.request.GET.get("order", "absolute")
|
order = self.request.GET.get("order", "absolute")
|
||||||
|
group_by = self.request.GET.get("group_by")
|
||||||
|
|
||||||
|
# When grouping, the ordering must match the grouping attribute.
|
||||||
|
if group_by == "cardset":
|
||||||
|
return ("cardset__name",)
|
||||||
|
elif group_by == "rarity":
|
||||||
|
# Order by level (desc) then by icon/name to keep groups together.
|
||||||
|
return "-rarity__level", "rarity__icon"
|
||||||
|
# Note: Grouping by 'pack' is complex due to M2M and would require a custom implementation.
|
||||||
|
|
||||||
if order == "alphabetical":
|
if order == "alphabetical":
|
||||||
return "name"
|
return "name"
|
||||||
elif order == "rarity":
|
elif order == "rarity":
|
||||||
return "-rarity_level"
|
return "-rarity__level"
|
||||||
else: # absolute ordering
|
else: # absolute ordering
|
||||||
return "id"
|
return "id"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
ordering = self.get_ordering()
|
ordering = self.get_ordering()
|
||||||
|
# Handle both single string and tuple orderings
|
||||||
|
if isinstance(ordering, tuple):
|
||||||
|
qs = qs.order_by(*ordering)
|
||||||
|
else:
|
||||||
qs = qs.order_by(ordering)
|
qs = qs.order_by(ordering)
|
||||||
return qs.prefetch_related("decks").distinct()
|
return (
|
||||||
|
qs.select_related("cardset", "rarity", "card_type", "pkmn_type")
|
||||||
|
.prefetch_related(
|
||||||
|
"translations",
|
||||||
|
"cardset__translations",
|
||||||
|
"rarity__translations",
|
||||||
|
"card_type__translations",
|
||||||
|
"pkmn_type__translations",
|
||||||
|
"packs__translations",
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
@ -104,45 +163,60 @@ class CardListView(ReusablePaginationMixin, ListView):
|
||||||
context["order"] = order
|
context["order"] = order
|
||||||
context["group_by"] = group_by
|
context["group_by"] = group_by
|
||||||
|
|
||||||
if group_by in ("deck", "cardset", "rarity"):
|
# Unified pagination logic for all cases.
|
||||||
full_qs = self.get_queryset()
|
# The complex manual grouping logic has been removed for performance.
|
||||||
all_cards = list(full_qs)
|
# The template should now use the `regroup` template tag for display.
|
||||||
flat_cards = []
|
|
||||||
if group_by == "deck":
|
|
||||||
for card in all_cards:
|
|
||||||
for deck in card.decks.all():
|
|
||||||
flat_cards.append({"group": deck.name, "card": card})
|
|
||||||
flat_cards.sort(key=lambda x: x["group"].lower())
|
|
||||||
elif group_by == "cardset":
|
|
||||||
for card in all_cards:
|
|
||||||
flat_cards.append({"group": card.cardset, "card": card})
|
|
||||||
flat_cards.sort(key=lambda x: x["group"].lower())
|
|
||||||
elif group_by == "rarity":
|
|
||||||
for card in all_cards:
|
|
||||||
flat_cards.append({"group": card.rarity_icon, "sort_group": card.rarity_level, "card": card})
|
|
||||||
flat_cards.sort(key=lambda x: x["sort_group"], reverse=True)
|
|
||||||
|
|
||||||
page_number = self.get_page_number()
|
page_number = self.get_page_number()
|
||||||
self.per_page = 36
|
self.per_page = 36
|
||||||
page_flat_cards, pagination_context = self.paginate_data(flat_cards, page_number)
|
queryset = self.get_queryset()
|
||||||
|
paginated_cards, pagination_context = self.paginate_data(queryset, page_number)
|
||||||
page_groups = []
|
|
||||||
for item in page_flat_cards:
|
|
||||||
group_value = item["group"]
|
|
||||||
card_obj = item["card"]
|
|
||||||
if page_groups and page_groups[-1]["group"] == group_value:
|
|
||||||
page_groups[-1]["cards"].append(card_obj)
|
|
||||||
else:
|
|
||||||
page_groups.append({"group": group_value, "cards": [card_obj]})
|
|
||||||
context["groups"] = page_groups
|
|
||||||
context["page_obj"] = pagination_context
|
|
||||||
context["total_cards"] = len(flat_cards)
|
|
||||||
context["object_list"] = full_qs
|
|
||||||
else:
|
|
||||||
page_number = self.get_page_number()
|
|
||||||
self.per_page = 36
|
|
||||||
paginated_cards, pagination_context = self.paginate_data(self.get_queryset(), page_number)
|
|
||||||
context["cards"] = paginated_cards
|
context["cards"] = paginated_cards
|
||||||
context["page_obj"] = pagination_context
|
context["page_obj"] = pagination_context
|
||||||
context["object_list"] = self.get_queryset()
|
context["object_list"] = queryset
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def card_search(request):
|
||||||
|
"""
|
||||||
|
Searches for cards and returns a JSON list of objects, where each object
|
||||||
|
contains card data and its pre-rendered HTML badge.
|
||||||
|
"""
|
||||||
|
query = request.GET.get("q", "").strip()
|
||||||
|
results = []
|
||||||
|
if query and len(query) >= 2:
|
||||||
|
selected_ids = request.GET.getlist("selected_ids[]")
|
||||||
|
|
||||||
|
cards = (
|
||||||
|
Card.objects.with_details()
|
||||||
|
.filter(
|
||||||
|
Q(translations__name__icontains=query)
|
||||||
|
| Q(cardset__translations__name__icontains=query)
|
||||||
|
)
|
||||||
|
.exclude(id__in=selected_ids)
|
||||||
|
.order_by("translations__name", "cardset__translations__name")[:20]
|
||||||
|
)
|
||||||
|
|
||||||
|
for card in cards:
|
||||||
|
# Prepare the full context required by the card_badge.html template
|
||||||
|
badge_context = {
|
||||||
|
"card": card,
|
||||||
|
"quantity": None,
|
||||||
|
"expanded": True,
|
||||||
|
"clickable": False,
|
||||||
|
"url": reverse("cards:detail", args=[card.pk]),
|
||||||
|
"name": card.name,
|
||||||
|
"rarity": card.rarity.icon if card.rarity else "",
|
||||||
|
"cardset": card.cardset.id,
|
||||||
|
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
|
||||||
|
}
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"id": str(card.id),
|
||||||
|
"name": card.name,
|
||||||
|
"set_name": card.cardset.name,
|
||||||
|
"html": render_to_string(
|
||||||
|
"templatetags/card_badge.html", badge_context
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return JsonResponse(results, safe=False)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,28 @@
|
||||||
|
import random
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
def cache_settings(request):
|
def cache_settings(request):
|
||||||
|
"""
|
||||||
|
Pass cache settings to the template context.
|
||||||
|
Applies jitter to the timeouts.
|
||||||
|
"""
|
||||||
|
jitter = settings.CACHE_JITTER
|
||||||
return {
|
return {
|
||||||
'CACHE_TIMEOUT': settings.CACHE_TIMEOUT,
|
"CACHE_SHORT_TIMEOUT": settings.CACHE_SHORT_TIMEOUT
|
||||||
|
+ random.randint(-jitter, jitter),
|
||||||
|
"CACHE_MEDIUM_TIMEOUT": settings.CACHE_MEDIUM_TIMEOUT
|
||||||
|
+ random.randint(-jitter, jitter),
|
||||||
|
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT
|
||||||
|
+ random.randint(-jitter, jitter),
|
||||||
|
"CACHE_DEFAULT_TIMEOUT": settings.CACHE_DEFAULT_TIMEOUT
|
||||||
|
+ random.randint(-jitter, jitter),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def version_info(request):
|
def version_info(request):
|
||||||
return {
|
return {
|
||||||
'VERSION': settings.VERSION,
|
"VERSION": settings.VERSION,
|
||||||
'VERSION_INFO': settings.VERSION_INFO,
|
"VERSION_INFO": settings.VERSION_INFO,
|
||||||
}
|
}
|
||||||
|
|
@ -26,9 +26,13 @@ class ReusablePaginationMixin:
|
||||||
"number": page.number,
|
"number": page.number,
|
||||||
"has_previous": page.has_previous(),
|
"has_previous": page.has_previous(),
|
||||||
"has_next": page.has_next(),
|
"has_next": page.has_next(),
|
||||||
"previous_page_number": page.previous_page_number() if page.has_previous() else 1,
|
"previous_page_number": (
|
||||||
"next_page_number": page.next_page_number() if page.has_next() else paginator.num_pages,
|
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},
|
"paginator": {"num_pages": paginator.num_pages},
|
||||||
"count": paginator.count
|
"count": paginator.count,
|
||||||
}
|
}
|
||||||
return page.object_list, pagination_context
|
return page.object_list, pagination_context
|
||||||
|
|
@ -2,6 +2,7 @@ from django import template
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True)
|
@register.inclusion_tag("templatetags/pagination_controls.html", takes_context=True)
|
||||||
def render_pagination(context, page_obj, hide_if_one_page=True):
|
def render_pagination(context, page_obj, hide_if_one_page=True):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@
|
||||||
# Django starts so that shared_task will use this app.
|
# Django starts so that shared_task will use this app.
|
||||||
from .celery import app as celery_app
|
from .celery import app as celery_app
|
||||||
|
|
||||||
__all__ = ('celery_app',)
|
__all__ = ("celery_app",)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
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()
|
application = get_asgi_application()
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,17 @@ import os
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
|
||||||
# Set the default Django settings module for the 'celery' program.
|
# 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
|
# Using a string here means the worker doesn't have to serialize
|
||||||
# the configuration object to child processes.
|
# the configuration object to child processes.
|
||||||
# - namespace='CELERY' means all celery-related configuration keys
|
# - namespace='CELERY' means all celery-related configuration keys
|
||||||
# should have a `CELERY_` prefix.
|
# 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.
|
# Load task modules from all registered Django apps.
|
||||||
app.autodiscover_tasks()
|
app.autodiscover_tasks()
|
||||||
|
|
@ -19,4 +21,4 @@ app.autodiscover_tasks()
|
||||||
|
|
||||||
@app.task(bind=True, ignore_result=True)
|
@app.task(bind=True, ignore_result=True)
|
||||||
def debug_task(self):
|
def debug_task(self):
|
||||||
print(f'Request: {self.request!r}')
|
print(f"Request: {self.request!r}")
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,90 @@
|
||||||
import socket
|
|
||||||
from pathlib import Path
|
|
||||||
import environ
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import environ
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from pkmntrade_club._version import __version__, get_version_info
|
from pkmntrade_club._version import __version__, get_version_info
|
||||||
|
|
||||||
# set default values to local dev values
|
# set default values to local dev values
|
||||||
env = environ.Env(
|
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_SIGNUPS=(bool, True),
|
||||||
DISABLE_CACHE=(bool, True),
|
DISABLE_CACHE=(bool, True),
|
||||||
DJANGO_DATABASE_URL=(str, 'postgresql://postgres@localhost:5432/postgres?sslmode=disable'),
|
DJANGO_DATABASE_URL=(
|
||||||
DJANGO_EMAIL_HOST=(str, ''),
|
str,
|
||||||
|
"postgresql://postgres@localhost:5432/postgres?sslmode=disable",
|
||||||
|
),
|
||||||
|
DJANGO_EMAIL_HOST=(str, ""),
|
||||||
DJANGO_EMAIL_PORT=(int, 587),
|
DJANGO_EMAIL_PORT=(int, 587),
|
||||||
DJANGO_EMAIL_USER=(str, ''),
|
DJANGO_EMAIL_USER=(str, ""),
|
||||||
DJANGO_EMAIL_PASSWORD=(str, ''),
|
DJANGO_EMAIL_PASSWORD=(str, ""),
|
||||||
DJANGO_EMAIL_USE_TLS=(bool, True),
|
DJANGO_EMAIL_USE_TLS=(bool, True),
|
||||||
DJANGO_DEFAULT_FROM_EMAIL=(str, ''),
|
DJANGO_DEFAULT_FROM_EMAIL=(str, ""),
|
||||||
SECRET_KEY=(str, '0000000000000000000000000000000000000000000000000000000000000000'),
|
DJANGO_EMAIL_SUBJECT_PREFIX=(str, ""),
|
||||||
ALLOWED_HOSTS=(str, 'localhost,127.0.0.1'),
|
SECRET_KEY=(
|
||||||
PUBLIC_HOST=(str, 'localhost'),
|
str,
|
||||||
ACCOUNT_EMAIL_VERIFICATION=(str, 'none'),
|
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
SCHEME=(str, 'http'),
|
),
|
||||||
REDIS_URL=(str, 'redis://localhost:6379'),
|
ALLOWED_HOSTS=(str, "localhost,127.0.0.1"),
|
||||||
CACHE_TIMEOUT=(int, 604800),
|
PUBLIC_HOST=(str, "localhost"),
|
||||||
TIME_ZONE=(str, 'America/Los_Angeles'),
|
ACCOUNT_EMAIL_VERIFICATION=(str, "none"),
|
||||||
|
SCHEME=(str, "http"),
|
||||||
|
REDIS_URL=(str, "redis://localhost:6379"),
|
||||||
|
CACHE_DEFAULT_TIMEOUT=(int, 60 * 5), # 5 minutes
|
||||||
|
CACHE_SHORT_TIMEOUT=(int, 60 * 5), # 5 minutes
|
||||||
|
CACHE_MEDIUM_TIMEOUT=(int, 60 * 60 * 1), # 1 hour
|
||||||
|
CACHE_LONG_TIMEOUT=(int, 60 * 60 * 24), # 24 hours
|
||||||
|
CACHE_JITTER=(int, 30), # 30 seconds
|
||||||
|
TIME_ZONE=(str, "America/Los_Angeles"),
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
"version": 1,
|
||||||
'disable_existing_loggers': False,
|
"disable_existing_loggers": False,
|
||||||
'formatters': {
|
"formatters": {
|
||||||
'verbose': {
|
"verbose": {
|
||||||
'format': '%(asctime)s %(name)-12s %(levelname)-8s %(message)s',
|
"format": "%(asctime)s %(name)-12s %(levelname)-8s %(message)s",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'handlers': {
|
"handlers": {
|
||||||
'console': {
|
"console": {
|
||||||
'level': 'INFO',
|
"level": "INFO",
|
||||||
'class': 'logging.StreamHandler',
|
"class": "logging.StreamHandler",
|
||||||
'stream': sys.stdout,
|
"stream": sys.stdout,
|
||||||
'formatter': 'verbose',
|
"formatter": "verbose",
|
||||||
'filters': [],
|
"filters": [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'loggers': {
|
"loggers": {
|
||||||
'django': {
|
"django": {
|
||||||
'handlers': ['console'],
|
"handlers": ["console"],
|
||||||
'level': 'INFO',
|
"level": "INFO",
|
||||||
},
|
},
|
||||||
'django.server': {
|
"django.server": {
|
||||||
'handlers': ['console'],
|
"handlers": ["console"],
|
||||||
'level': 'INFO',
|
"level": "INFO",
|
||||||
'propagate': False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
'granian.access': {
|
"granian.access": {
|
||||||
'handlers': ['console'],
|
"handlers": ["console"],
|
||||||
'level': 'INFO',
|
"level": "INFO",
|
||||||
'propagate': False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
'_granian': {
|
"_granian": {
|
||||||
'handlers': ['console'],
|
"handlers": ["console"],
|
||||||
'level': 'INFO',
|
"level": "INFO",
|
||||||
'propagate': False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
'': {
|
"": {
|
||||||
'handlers': ['console'],
|
"handlers": ["console"],
|
||||||
'level': 'INFO',
|
"level": "INFO",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -77,14 +93,18 @@ LOGGING = {
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# Take environment variables from .env file
|
# 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')
|
SCHEME = env("SCHEME")
|
||||||
PUBLIC_HOST = env('PUBLIC_HOST')
|
PUBLIC_HOST = env("PUBLIC_HOST")
|
||||||
REDIS_URL = env('REDIS_URL')
|
REDIS_URL = env("REDIS_URL")
|
||||||
CACHE_TIMEOUT = env('CACHE_TIMEOUT')
|
CACHE_DEFAULT_TIMEOUT = env("CACHE_DEFAULT_TIMEOUT")
|
||||||
DISABLE_SIGNUPS = env('DISABLE_SIGNUPS')
|
CACHE_SHORT_TIMEOUT = env("CACHE_SHORT_TIMEOUT")
|
||||||
DISABLE_CACHE = env('DISABLE_CACHE')
|
CACHE_MEDIUM_TIMEOUT = env("CACHE_MEDIUM_TIMEOUT")
|
||||||
|
CACHE_LONG_TIMEOUT = env("CACHE_LONG_TIMEOUT")
|
||||||
|
CACHE_JITTER = env("CACHE_JITTER")
|
||||||
|
DISABLE_SIGNUPS = env("DISABLE_SIGNUPS")
|
||||||
|
DISABLE_CACHE = env("DISABLE_CACHE")
|
||||||
|
|
||||||
VERSION = __version__
|
VERSION = __version__
|
||||||
VERSION_INFO = get_version_info()
|
VERSION_INFO = get_version_info()
|
||||||
|
|
@ -94,31 +114,38 @@ VERSION_INFO = get_version_info()
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
|
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# 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
|
# https://docs.djangoproject.com/en/dev/ref/settings/#debug
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# 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
|
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||||
ALLOWED_HOSTS = env('ALLOWED_HOSTS').split(',')
|
ALLOWED_HOSTS = env("ALLOWED_HOSTS").split(",")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_web_worker_hostname = socket.gethostname()
|
current_web_worker_hostname = socket.gethostname()
|
||||||
ALLOWED_HOSTS.append(current_web_worker_hostname)
|
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:
|
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}"]
|
CSRF_TRUSTED_ORIGINS = [f"{SCHEME}://{PUBLIC_HOST}"]
|
||||||
|
|
||||||
|
SHORTHAND_DATETIME_FORMAT = "Y-m-d P"
|
||||||
|
SHORTHAND_DATE_FORMAT = "Y-m-d"
|
||||||
|
|
||||||
FIRST_PARTY_APPS = [
|
FIRST_PARTY_APPS = [
|
||||||
'pkmntrade_club.accounts',
|
"pkmntrade_club.accounts",
|
||||||
'pkmntrade_club.cards',
|
"pkmntrade_club.cards",
|
||||||
'pkmntrade_club.common',
|
"pkmntrade_club.common",
|
||||||
'pkmntrade_club.home',
|
"pkmntrade_club.home",
|
||||||
'pkmntrade_club.theme',
|
"pkmntrade_club.theme",
|
||||||
'pkmntrade_club.trades',
|
"pkmntrade_club.trades",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
@ -136,21 +163,22 @@ INSTALLED_APPS = [
|
||||||
"django_celery_beat",
|
"django_celery_beat",
|
||||||
"allauth",
|
"allauth",
|
||||||
"allauth.account",
|
"allauth.account",
|
||||||
'allauth.socialaccount.providers.google',
|
"allauth.socialaccount.providers.google",
|
||||||
"crispy_forms",
|
"crispy_forms",
|
||||||
"crispy_tailwind",
|
"crispy_tailwind",
|
||||||
"tailwind",
|
"tailwind",
|
||||||
"django_linear_migrations",
|
"django_linear_migrations",
|
||||||
'health_check',
|
"health_check",
|
||||||
'health_check.db',
|
"health_check.db",
|
||||||
'health_check.cache',
|
"health_check.cache",
|
||||||
'health_check.storage',
|
"health_check.storage",
|
||||||
'health_check.contrib.migrations',
|
"health_check.contrib.migrations",
|
||||||
'health_check.contrib.celery',
|
"health_check.contrib.celery",
|
||||||
'health_check.contrib.celery_ping',
|
"health_check.contrib.celery_ping",
|
||||||
'health_check.contrib.psutil',
|
"health_check.contrib.psutil",
|
||||||
'health_check.contrib.redis',
|
"health_check.contrib.redis",
|
||||||
"meta",
|
"meta",
|
||||||
|
"parler",
|
||||||
] + FIRST_PARTY_APPS
|
] + FIRST_PARTY_APPS
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
|
|
@ -160,12 +188,12 @@ if DEBUG:
|
||||||
"debug_toolbar",
|
"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_SITE_PROTOCOL = SCHEME
|
||||||
META_USE_SITES = True
|
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
|
# https://docs.djangoproject.com/en/dev/ref/settings/#middleware
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|
@ -188,22 +216,22 @@ if DEBUG:
|
||||||
]
|
]
|
||||||
|
|
||||||
HEALTH_CHECK = {
|
HEALTH_CHECK = {
|
||||||
'DISK_USAGE_MAX': 90, # percent
|
"DISK_USAGE_MAX": 90, # percent
|
||||||
'MEMORY_MIN': 100, # in MB
|
"MEMORY_MIN": 100, # in MB
|
||||||
}
|
}
|
||||||
|
|
||||||
DAISY_SETTINGS = {
|
DAISY_SETTINGS = {
|
||||||
'SITE_TITLE': 'PKMN Trade Club Admin',
|
"SITE_TITLE": "PKMN Trade Club Admin",
|
||||||
'DONT_SUPPORT_ME': True,
|
"DONT_SUPPORT_ME": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
|
# 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
|
# 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
|
# https://docs.djangoproject.com/en/dev/ref/settings/#templates
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
|
|
@ -226,7 +254,7 @@ TEMPLATES = [
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
|
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': env.db(var="DJANGO_DATABASE_URL"),
|
"default": env.db(var="DJANGO_DATABASE_URL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
|
|
@ -251,8 +279,10 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#language-code
|
# https://docs.djangoproject.com/en/dev/ref/settings/#language-code
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
|
LANGUAGES = (("en", _("English")),)
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#time-zone
|
# 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
|
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-USE_I18N
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
@ -261,7 +291,7 @@ USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths
|
# 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)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
||||||
|
|
@ -296,23 +326,24 @@ STORAGES = {
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/stable/ref/settings/#default-auto-field
|
# 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
|
# django-crispy-forms
|
||||||
# https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs
|
# 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"
|
CRISPY_TEMPLATE_PACK = "tailwind"
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
|
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||||
EMAIL_HOST = env('DJANGO_EMAIL_HOST')
|
EMAIL_HOST = env("DJANGO_EMAIL_HOST")
|
||||||
EMAIL_PORT = env('DJANGO_EMAIL_PORT')
|
EMAIL_PORT = env("DJANGO_EMAIL_PORT")
|
||||||
EMAIL_HOST_USER = env('DJANGO_EMAIL_USER')
|
EMAIL_HOST_USER = env("DJANGO_EMAIL_USER")
|
||||||
EMAIL_HOST_PASSWORD = env('DJANGO_EMAIL_PASSWORD')
|
EMAIL_HOST_PASSWORD = env("DJANGO_EMAIL_PASSWORD")
|
||||||
EMAIL_USE_TLS = env('DJANGO_EMAIL_USE_TLS')
|
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
|
# 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
|
# django-debug-toolbar
|
||||||
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
|
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
|
||||||
|
|
@ -334,6 +365,15 @@ AUTH_USER_MODEL = "accounts.CustomUser"
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#site-id
|
# https://docs.djangoproject.com/en/dev/ref/settings/#site-id
|
||||||
SITE_ID = 1
|
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
|
# https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url
|
||||||
LOGIN_REDIRECT_URL = "home"
|
LOGIN_REDIRECT_URL = "home"
|
||||||
|
|
||||||
|
|
@ -347,13 +387,13 @@ AUTHENTICATION_BACKENDS = (
|
||||||
)
|
)
|
||||||
# https://django-allauth.readthedocs.io/en/latest/configuration.html
|
# https://django-allauth.readthedocs.io/en/latest/configuration.html
|
||||||
if DISABLE_SIGNUPS:
|
if DISABLE_SIGNUPS:
|
||||||
ACCOUNT_ADAPTER = 'pkmntrade_club.accounts.adapter.NoSignupAccountAdapter'
|
ACCOUNT_ADAPTER = "pkmntrade_club.accounts.adapter.NoSignupAccountAdapter"
|
||||||
SOCIALACCOUNT_ADAPTER = 'pkmntrade_club.accounts.adapter.NoSignupSocialAccountAdapter' # always disable social account signups
|
SOCIALACCOUNT_ADAPTER = "pkmntrade_club.accounts.adapter.NoSignupSocialAccountAdapter" # always disable social account signups
|
||||||
ACCOUNT_SESSION_REMEMBER = True
|
ACCOUNT_SESSION_REMEMBER = True
|
||||||
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = True
|
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = True
|
||||||
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
|
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
|
||||||
ACCOUNT_EMAIL_REQUIRED = True
|
ACCOUNT_EMAIL_REQUIRED = True
|
||||||
ACCOUNT_EMAIL_VERIFICATION = env('ACCOUNT_EMAIL_VERIFICATION')
|
ACCOUNT_EMAIL_VERIFICATION = env("ACCOUNT_EMAIL_VERIFICATION")
|
||||||
ACCOUNT_EMAIL_NOTIFICATIONS = True
|
ACCOUNT_EMAIL_NOTIFICATIONS = True
|
||||||
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
|
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
|
||||||
ACCOUNT_DEFAULT_HTTP_PROTOCOL = SCHEME
|
ACCOUNT_DEFAULT_HTTP_PROTOCOL = SCHEME
|
||||||
|
|
@ -374,6 +414,7 @@ SOCIALACCOUNT_ONLY = False
|
||||||
|
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
|
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
|
||||||
SESSION_COOKIE_HTTPONLY = True
|
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
|
# auto-detection doesn't work properly sometimes, so we'll just use the DEBUG setting
|
||||||
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG}
|
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ from debug_toolbar.toolbar import debug_toolbar_urls
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
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("accounts/", include("allauth.urls")),
|
||||||
path("", include("pkmntrade_club.home.urls")),
|
path("", include("pkmntrade_club.home.urls")),
|
||||||
path("cards/", include("pkmntrade_club.cards.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("trades/", include("pkmntrade_club.trades.urls")),
|
||||||
path("__reload__/", include("django_browser_reload.urls")),
|
path("__reload__/", include("django_browser_reload.urls")),
|
||||||
] + debug_toolbar_urls()
|
] + debug_toolbar_urls()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import os
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
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()
|
app = get_wsgi_application()
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,20 @@ from django.test import TestCase, Client, RequestFactory
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from pkmntrade_club.cards.models import Card, Deck
|
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.accounts.models import FriendCode
|
||||||
from pkmntrade_club.home.views import HomePageView
|
from pkmntrade_club.home.views import HomePageView
|
||||||
import json
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
|
||||||
import importlib
|
|
||||||
from tests.utils.rarity import RARITY_MAPPING
|
from tests.utils.rarity import RARITY_MAPPING
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class HomePageViewTests(TestCase):
|
class HomePageViewTests(TestCase):
|
||||||
"""Test suite for the HomePageView."""
|
"""Test suite for the HomePageView."""
|
||||||
|
|
||||||
|
|
@ -22,99 +24,83 @@ class HomePageViewTests(TestCase):
|
||||||
"""Set up data for all test methods."""
|
"""Set up data for all test methods."""
|
||||||
# Create a user
|
# Create a user
|
||||||
cls.user = User.objects.create_user(
|
cls.user = User.objects.create_user(
|
||||||
username='testuser',
|
username="testuser", email="testuser@example.com", password="testpass123"
|
||||||
email='testuser@example.com',
|
|
||||||
password='testpass123'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a friend code for the user
|
# Create a friend code for the user
|
||||||
cls.friend_code = FriendCode.objects.create(
|
cls.friend_code = FriendCode.objects.create(
|
||||||
user=cls.user,
|
user=cls.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer"
|
||||||
friend_code='SW-1234-5678-9012',
|
|
||||||
in_game_name='TestTrainer'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create decks
|
# Create decks
|
||||||
cls.deck1 = Deck.objects.create(
|
cls.deck1 = Deck.objects.create(
|
||||||
name='Test Deck 1',
|
name="Test Deck 1", hex_color="#FF0000", cardset="TEST01"
|
||||||
hex_color='#FF0000',
|
|
||||||
cardset='TEST01'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create cards with different rarities
|
# Create cards with different rarities
|
||||||
cls.common_card = Card.objects.create(
|
cls.common_card = Card.objects.create(
|
||||||
name='Common Test Card',
|
name="Common Test Card",
|
||||||
cardset='TEST01',
|
cardset="TEST01",
|
||||||
cardnum=1,
|
cardnum=1,
|
||||||
style='normal',
|
style="normal",
|
||||||
rarity_icon='★',
|
rarity_icon="★",
|
||||||
rarity_level=1
|
rarity_level=1,
|
||||||
)
|
)
|
||||||
cls.common_card.decks.add(cls.deck1)
|
cls.common_card.decks.add(cls.deck1)
|
||||||
|
|
||||||
cls.rare_card = Card.objects.create(
|
cls.rare_card = Card.objects.create(
|
||||||
name='Rare Test Card',
|
name="Rare Test Card",
|
||||||
cardset='TEST01',
|
cardset="TEST01",
|
||||||
cardnum=2,
|
cardnum=2,
|
||||||
style='normal',
|
style="normal",
|
||||||
rarity_icon='★★★',
|
rarity_icon="★★★",
|
||||||
rarity_level=3
|
rarity_level=3,
|
||||||
)
|
)
|
||||||
cls.rare_card.decks.add(cls.deck1)
|
cls.rare_card.decks.add(cls.deck1)
|
||||||
|
|
||||||
cls.ultra_rare_card = Card.objects.create(
|
cls.ultra_rare_card = Card.objects.create(
|
||||||
name='Ultra Rare Test Card',
|
name="Ultra Rare Test Card",
|
||||||
cardset='TEST01',
|
cardset="TEST01",
|
||||||
cardnum=3,
|
cardnum=3,
|
||||||
style='normal',
|
style="normal",
|
||||||
rarity_icon='★★★★',
|
rarity_icon="★★★★",
|
||||||
rarity_level=4
|
rarity_level=4,
|
||||||
)
|
)
|
||||||
cls.ultra_rare_card.decks.add(cls.deck1)
|
cls.ultra_rare_card.decks.add(cls.deck1)
|
||||||
|
|
||||||
# Create trade offers with consistent rarities
|
# Create trade offers with consistent rarities
|
||||||
cls.common_trade = TradeOffer.objects.create(
|
cls.common_trade = TradeOffer.objects.create(
|
||||||
initiated_by=cls.friend_code,
|
initiated_by=cls.friend_code, rarity_icon=RARITY_MAPPING[1], rarity_level=1
|
||||||
rarity_icon=RARITY_MAPPING[1],
|
|
||||||
rarity_level=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.rare_trade = TradeOffer.objects.create(
|
cls.rare_trade = TradeOffer.objects.create(
|
||||||
initiated_by=cls.friend_code,
|
initiated_by=cls.friend_code, rarity_icon=RARITY_MAPPING[3], rarity_level=3
|
||||||
rarity_icon=RARITY_MAPPING[3],
|
|
||||||
rarity_level=3
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add have and want cards with the SAME rarity for each trade
|
# Add have and want cards with the SAME rarity for each trade
|
||||||
TradeOfferHaveCard.objects.create(
|
TradeOfferHaveCard.objects.create(
|
||||||
trade_offer=cls.common_trade,
|
trade_offer=cls.common_trade, card=cls.common_card, quantity=2
|
||||||
card=cls.common_card,
|
|
||||||
quantity=2
|
|
||||||
)
|
)
|
||||||
|
|
||||||
TradeOfferHaveCard.objects.create(
|
TradeOfferHaveCard.objects.create(
|
||||||
trade_offer=cls.rare_trade,
|
trade_offer=cls.rare_trade, card=cls.rare_card, quantity=1
|
||||||
card=cls.rare_card,
|
|
||||||
quantity=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add want cards with the SAME rarity as the have cards for each trade
|
# Add want cards with the SAME rarity as the have cards for each trade
|
||||||
TradeOfferWantCard.objects.create(
|
TradeOfferWantCard.objects.create(
|
||||||
trade_offer=cls.common_trade,
|
trade_offer=cls.common_trade, card=cls.common_card, quantity=1
|
||||||
card=cls.common_card,
|
|
||||||
quantity=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
TradeOfferWantCard.objects.create(
|
TradeOfferWantCard.objects.create(
|
||||||
trade_offer=cls.rare_trade,
|
trade_offer=cls.rare_trade,
|
||||||
card=cls.rare_card, # Changed from ultra_rare_card to match the rarity
|
card=cls.rare_card, # Changed from ultra_rare_card to match the rarity
|
||||||
quantity=1
|
quantity=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up before each test method."""
|
"""Set up before each test method."""
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.url = reverse('home')
|
self.url = reverse("home")
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
def test_home_page_status_code(self):
|
def test_home_page_status_code(self):
|
||||||
|
|
@ -125,27 +111,27 @@ class HomePageViewTests(TestCase):
|
||||||
def test_home_page_template(self):
|
def test_home_page_template(self):
|
||||||
"""Test that the home page uses the correct template."""
|
"""Test that the home page uses the correct template."""
|
||||||
response = self.client.get(self.url)
|
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):
|
def test_home_page_context_cards(self):
|
||||||
"""Test that the home page contains all cards in the context."""
|
"""Test that the home page contains all cards in the context."""
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertIn('cards', response.context)
|
self.assertIn("cards", response.context)
|
||||||
self.assertEqual(response.context['cards'].count(), 3)
|
self.assertEqual(response.context["cards"].count(), 3)
|
||||||
|
|
||||||
def test_home_page_context_recent_offers(self):
|
def test_home_page_context_recent_offers(self):
|
||||||
"""Test that the home page contains recent offers in the context."""
|
"""Test that the home page contains recent offers in the context."""
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertIn('recent_offers', response.context)
|
self.assertIn("recent_offers", response.context)
|
||||||
self.assertEqual(len(response.context['recent_offers']), 2)
|
self.assertEqual(len(response.context["recent_offers"]), 2)
|
||||||
# Recent offers should be ordered by most recent first
|
# 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):
|
def test_home_page_context_most_offered_cards(self):
|
||||||
"""Test that the home page contains most offered cards in the context."""
|
"""Test that the home page contains most offered cards in the context."""
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertIn('most_offered_cards', response.context)
|
self.assertIn("most_offered_cards", response.context)
|
||||||
most_offered = list(response.context['most_offered_cards'])
|
most_offered = list(response.context["most_offered_cards"])
|
||||||
self.assertEqual(len(most_offered), 2)
|
self.assertEqual(len(most_offered), 2)
|
||||||
# Common card should be most offered (quantity of 2)
|
# Common card should be most offered (quantity of 2)
|
||||||
self.assertEqual(most_offered[0], self.common_card)
|
self.assertEqual(most_offered[0], self.common_card)
|
||||||
|
|
@ -153,35 +139,35 @@ class HomePageViewTests(TestCase):
|
||||||
def test_home_page_context_most_wanted_cards(self):
|
def test_home_page_context_most_wanted_cards(self):
|
||||||
"""Test that the home page contains most wanted cards in the context."""
|
"""Test that the home page contains most wanted cards in the context."""
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertIn('most_wanted_cards', response.context)
|
self.assertIn("most_wanted_cards", response.context)
|
||||||
most_wanted = list(response.context['most_wanted_cards'])
|
most_wanted = list(response.context["most_wanted_cards"])
|
||||||
self.assertEqual(len(most_wanted), 2)
|
self.assertEqual(len(most_wanted), 2)
|
||||||
|
|
||||||
def test_home_page_context_least_offered_cards(self):
|
def test_home_page_context_least_offered_cards(self):
|
||||||
"""Test that the home page contains least offered cards in the context."""
|
"""Test that the home page contains least offered cards in the context."""
|
||||||
response = self.client.get(self.url)
|
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):
|
def test_home_page_context_featured_offers(self):
|
||||||
"""Test that the home page contains featured offers in the context."""
|
"""Test that the home page contains featured offers in the context."""
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertIn('featured_offers', response.context)
|
self.assertIn("featured_offers", response.context)
|
||||||
featured = response.context['featured_offers']
|
featured = response.context["featured_offers"]
|
||||||
# Should be an OrderedDict
|
# Should be an OrderedDict
|
||||||
self.assertIsInstance(featured, OrderedDict)
|
self.assertIsInstance(featured, OrderedDict)
|
||||||
# Should contain "All" category
|
# Should contain "All" category
|
||||||
self.assertIn("All", featured)
|
self.assertIn("All", featured)
|
||||||
# Should contain both rarity icons
|
# Should contain both rarity icons
|
||||||
self.assertIn('★★★', featured)
|
self.assertIn("★★★", featured)
|
||||||
self.assertIn('★', featured)
|
self.assertIn("★", featured)
|
||||||
# Higher rarity should come before lower rarity
|
# Higher rarity should come before lower rarity
|
||||||
keys = list(featured.keys())
|
keys = list(featured.keys())
|
||||||
# First key should be "All"
|
# First key should be "All"
|
||||||
self.assertEqual(keys[0], "All")
|
self.assertEqual(keys[0], "All")
|
||||||
# Higher rarity (★★★) should come before lower rarity (★)
|
# Higher rarity (★★★) should come before lower rarity (★)
|
||||||
self.assertIn('★★★', keys)
|
self.assertIn("★★★", keys)
|
||||||
self.assertIn('★', keys)
|
self.assertIn("★", keys)
|
||||||
self.assertTrue(keys.index('★★★') < keys.index('★'))
|
self.assertTrue(keys.index("★★★") < keys.index("★"))
|
||||||
|
|
||||||
def test_closed_offers_not_shown(self):
|
def test_closed_offers_not_shown(self):
|
||||||
"""Test that closed offers are not shown on the home page."""
|
"""Test that closed offers are not shown on the home page."""
|
||||||
|
|
@ -190,7 +176,7 @@ class HomePageViewTests(TestCase):
|
||||||
self.common_trade.save()
|
self.common_trade.save()
|
||||||
|
|
||||||
response = self.client.get(self.url)
|
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
|
# Should only show the rare trade now
|
||||||
self.assertEqual(len(recent_offers), 1)
|
self.assertEqual(len(recent_offers), 1)
|
||||||
self.assertEqual(recent_offers[0], self.rare_trade)
|
self.assertEqual(recent_offers[0], self.rare_trade)
|
||||||
|
|
@ -203,11 +189,11 @@ class HomePageViewTests(TestCase):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
# Should have empty lists for offers
|
# 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):
|
def test_home_page_with_authenticated_user(self):
|
||||||
"""Test that the home page works for authenticated users."""
|
"""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)
|
response = self.client.get(self.url)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
@ -215,26 +201,20 @@ class HomePageViewTests(TestCase):
|
||||||
"""Test that offers are sorted by rarity level in descending order."""
|
"""Test that offers are sorted by rarity level in descending order."""
|
||||||
# Create a new ultra rare trade with consistent rarity
|
# Create a new ultra rare trade with consistent rarity
|
||||||
ultra_trade = TradeOffer.objects.create(
|
ultra_trade = TradeOffer.objects.create(
|
||||||
initiated_by=self.friend_code,
|
initiated_by=self.friend_code, rarity_icon="★★★★", rarity_level=4
|
||||||
rarity_icon='★★★★',
|
|
||||||
rarity_level=4
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add have and want cards with the same rarity
|
# Add have and want cards with the same rarity
|
||||||
TradeOfferHaveCard.objects.create(
|
TradeOfferHaveCard.objects.create(
|
||||||
trade_offer=ultra_trade,
|
trade_offer=ultra_trade, card=self.ultra_rare_card, quantity=1
|
||||||
card=self.ultra_rare_card,
|
|
||||||
quantity=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
TradeOfferWantCard.objects.create(
|
TradeOfferWantCard.objects.create(
|
||||||
trade_offer=ultra_trade,
|
trade_offer=ultra_trade, card=self.ultra_rare_card, quantity=1
|
||||||
card=self.ultra_rare_card,
|
|
||||||
quantity=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
featured = response.context['featured_offers']
|
featured = response.context["featured_offers"]
|
||||||
keys = list(featured.keys())
|
keys = list(featured.keys())
|
||||||
|
|
||||||
# Order should be: "All", "★★★★" (level 4), "★★★" (level 3), "★" (level 1)
|
# Order should be: "All", "★★★★" (level 4), "★★★" (level 3), "★" (level 1)
|
||||||
|
|
@ -251,12 +231,12 @@ class HomePageViewMockTests(TestCase):
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
self.view = HomePageView()
|
self.view = HomePageView()
|
||||||
|
|
||||||
@patch('trades.models.TradeOffer.objects')
|
@patch("trades.models.TradeOffer.objects")
|
||||||
@patch('cards.models.Card.objects')
|
@patch("cards.models.Card.objects")
|
||||||
def test_get_context_data_with_mocks(self, mock_card_objects, mock_offer_objects):
|
def test_get_context_data_with_mocks(self, mock_card_objects, mock_offer_objects):
|
||||||
"""Test get_context_data using mocks."""
|
"""Test get_context_data using mocks."""
|
||||||
# Set up request
|
# Set up request
|
||||||
request = self.factory.get(reverse('home'))
|
request = self.factory.get(reverse("home"))
|
||||||
self.view.request = request
|
self.view.request = request
|
||||||
|
|
||||||
# Mock the queryset responses
|
# Mock the queryset responses
|
||||||
|
|
@ -277,18 +257,18 @@ class HomePageViewMockTests(TestCase):
|
||||||
context = self.view.get_context_data()
|
context = self.view.get_context_data()
|
||||||
|
|
||||||
# Verify the expected context keys exist
|
# Verify the expected context keys exist
|
||||||
self.assertIn('cards', context)
|
self.assertIn("cards", context)
|
||||||
self.assertIn('recent_offers', context)
|
self.assertIn("recent_offers", context)
|
||||||
self.assertIn('most_offered_cards', context)
|
self.assertIn("most_offered_cards", context)
|
||||||
self.assertIn('most_wanted_cards', context)
|
self.assertIn("most_wanted_cards", context)
|
||||||
self.assertIn('least_offered_cards', context)
|
self.assertIn("least_offered_cards", context)
|
||||||
self.assertIn('featured_offers', context)
|
self.assertIn("featured_offers", context)
|
||||||
|
|
||||||
@patch('trades.models.TradeOffer.objects')
|
@patch("trades.models.TradeOffer.objects")
|
||||||
def test_empty_featured_offers(self, mock_offer_objects):
|
def test_empty_featured_offers(self, mock_offer_objects):
|
||||||
"""Test handling of empty featured offers."""
|
"""Test handling of empty featured offers."""
|
||||||
# Set up request
|
# Set up request
|
||||||
request = self.factory.get(reverse('home'))
|
request = self.factory.get(reverse("home"))
|
||||||
self.view.request = request
|
self.view.request = request
|
||||||
|
|
||||||
# Configure mock to return empty queryset
|
# Configure mock to return empty queryset
|
||||||
|
|
@ -301,101 +281,90 @@ class HomePageViewMockTests(TestCase):
|
||||||
context = self.view.get_context_data()
|
context = self.view.get_context_data()
|
||||||
|
|
||||||
# Verify the featured_offers is an OrderedDict but with just the "All" key
|
# Verify the featured_offers is an OrderedDict but with just the "All" key
|
||||||
self.assertIsInstance(context['featured_offers'], OrderedDict)
|
self.assertIsInstance(context["featured_offers"], OrderedDict)
|
||||||
self.assertIn("All", context['featured_offers'])
|
self.assertIn("All", context["featured_offers"])
|
||||||
self.assertEqual(len(context['featured_offers']), 1)
|
self.assertEqual(len(context["featured_offers"]), 1)
|
||||||
|
|
||||||
@patch('trades.models.TradeOffer.objects.filter')
|
@patch("trades.models.TradeOffer.objects.filter")
|
||||||
def test_exception_handling(self, mock_filter):
|
def test_exception_handling(self, mock_filter):
|
||||||
"""Test that exceptions are handled gracefully."""
|
"""Test that exceptions are handled gracefully."""
|
||||||
# Set up request
|
# Set up request
|
||||||
request = self.factory.get(reverse('home'))
|
request = self.factory.get(reverse("home"))
|
||||||
self.view.request = request
|
self.view.request = request
|
||||||
|
|
||||||
# Configure mock to raise an exception
|
# Configure mock to raise an exception
|
||||||
mock_filter.side_effect = Exception("Database error")
|
mock_filter.side_effect = Exception("Database error")
|
||||||
|
|
||||||
# Call the method - should not raise an exception
|
# 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()
|
context = self.view.get_context_data()
|
||||||
|
|
||||||
# Check if error was logged
|
# 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
|
# Verify fallback values were set
|
||||||
self.assertEqual(len(context['cards']), 0)
|
self.assertEqual(len(context["cards"]), 0)
|
||||||
self.assertEqual(len(context['recent_offers']), 0)
|
self.assertEqual(len(context["recent_offers"]), 0)
|
||||||
self.assertEqual(len(context['most_offered_cards']), 0)
|
self.assertEqual(len(context["most_offered_cards"]), 0)
|
||||||
self.assertEqual(len(context['most_wanted_cards']), 0)
|
self.assertEqual(len(context["most_wanted_cards"]), 0)
|
||||||
self.assertEqual(len(context['least_offered_cards']), 0)
|
self.assertEqual(len(context["least_offered_cards"]), 0)
|
||||||
self.assertIsInstance(context['featured_offers'], OrderedDict)
|
self.assertIsInstance(context["featured_offers"], OrderedDict)
|
||||||
self.assertEqual(len(context['featured_offers']), 1)
|
self.assertEqual(len(context["featured_offers"]), 1)
|
||||||
self.assertIn("All", context['featured_offers'])
|
self.assertIn("All", context["featured_offers"])
|
||||||
|
|
||||||
|
|
||||||
class HomePageEdgeCaseTests(TestCase):
|
class HomePageEdgeCaseTests(TestCase):
|
||||||
"""Test edge cases for the home page."""
|
"""Test edge cases for the home page."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
self.url = reverse('home')
|
self.url = reverse("home")
|
||||||
|
|
||||||
# Create a user
|
# Create a user
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username='testuser',
|
username="testuser", email="testuser@example.com", password="testpass123"
|
||||||
email='testuser@example.com',
|
|
||||||
password='testpass123'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a friend code for the user
|
# Create a friend code for the user
|
||||||
self.friend_code = FriendCode.objects.create(
|
self.friend_code = FriendCode.objects.create(
|
||||||
user=self.user,
|
user=self.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer"
|
||||||
friend_code='SW-1234-5678-9012',
|
|
||||||
in_game_name='TestTrainer'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_home_page_with_no_cards(self):
|
def test_home_page_with_no_cards(self):
|
||||||
"""Test home page with no cards in the database."""
|
"""Test home page with no cards in the database."""
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertEqual(response.status_code, 200)
|
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):
|
def test_home_page_with_many_offers(self):
|
||||||
"""Test home page with many offers to verify pagination or limiting works."""
|
"""Test home page with many offers to verify pagination or limiting works."""
|
||||||
# Create a card
|
# Create a card
|
||||||
card = Card.objects.create(
|
card = Card.objects.create(
|
||||||
name='Test Card',
|
name="Test Card",
|
||||||
cardset='TEST01',
|
cardset="TEST01",
|
||||||
cardnum=1,
|
cardnum=1,
|
||||||
style='normal',
|
style="normal",
|
||||||
rarity_icon='★',
|
rarity_icon="★",
|
||||||
rarity_level=1
|
rarity_level=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create 20 trade offers
|
# Create 20 trade offers
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
trade = TradeOffer.objects.create(
|
trade = TradeOffer.objects.create(
|
||||||
initiated_by=self.friend_code,
|
initiated_by=self.friend_code, rarity_icon="★", rarity_level=1
|
||||||
rarity_icon='★',
|
|
||||||
rarity_level=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add have and want cards
|
# Add have and want cards
|
||||||
TradeOfferHaveCard.objects.create(
|
TradeOfferHaveCard.objects.create(trade_offer=trade, card=card, quantity=1)
|
||||||
trade_offer=trade,
|
|
||||||
card=card,
|
|
||||||
quantity=1
|
|
||||||
)
|
|
||||||
|
|
||||||
TradeOfferWantCard.objects.create(
|
TradeOfferWantCard.objects.create(trade_offer=trade, card=card, quantity=1)
|
||||||
trade_offer=trade,
|
|
||||||
card=card,
|
|
||||||
quantity=1
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
# Check that recent_offers is limited to 6 as per the view
|
# 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):
|
def test_home_page_with_invalid_parameters(self):
|
||||||
"""Test home page with invalid GET parameters."""
|
"""Test home page with invalid GET parameters."""
|
||||||
|
|
@ -407,49 +376,46 @@ class HomePageEdgeCaseTests(TestCase):
|
||||||
"""Test performance with a larger dataset (basic check)."""
|
"""Test performance with a larger dataset (basic check)."""
|
||||||
# Create a card
|
# Create a card
|
||||||
card = Card.objects.create(
|
card = Card.objects.create(
|
||||||
name='Performance Test Card',
|
name="Performance Test Card",
|
||||||
cardset='PERF01',
|
cardset="PERF01",
|
||||||
cardnum=1,
|
cardnum=1,
|
||||||
style='normal',
|
style="normal",
|
||||||
rarity_icon='★',
|
rarity_icon="★",
|
||||||
rarity_level=1
|
rarity_level=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create 50 trade offers with different rarities
|
# Create 50 trade offers with different rarities
|
||||||
for i in range(50):
|
for i in range(50):
|
||||||
rarity_level = (i % 5) + 1 # 1-5
|
rarity_level = (i % 5) + 1 # 1-5
|
||||||
rarity_icon = '★' * rarity_level
|
rarity_icon = "★" * rarity_level
|
||||||
|
|
||||||
trade = TradeOffer.objects.create(
|
trade = TradeOffer.objects.create(
|
||||||
initiated_by=self.friend_code,
|
initiated_by=self.friend_code,
|
||||||
rarity_icon=rarity_icon,
|
rarity_icon=rarity_icon,
|
||||||
rarity_level=rarity_level
|
rarity_level=rarity_level,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add have and want cards with the same rarity
|
# Add have and want cards with the same rarity
|
||||||
rarity_card = Card.objects.create(
|
rarity_card = Card.objects.create(
|
||||||
name=f'Performance Test Card {i}',
|
name=f"Performance Test Card {i}",
|
||||||
cardset='PERF01',
|
cardset="PERF01",
|
||||||
cardnum=i + 10,
|
cardnum=i + 10,
|
||||||
style='normal',
|
style="normal",
|
||||||
rarity_icon=rarity_icon,
|
rarity_icon=rarity_icon,
|
||||||
rarity_level=rarity_level
|
rarity_level=rarity_level,
|
||||||
)
|
)
|
||||||
|
|
||||||
TradeOfferHaveCard.objects.create(
|
TradeOfferHaveCard.objects.create(
|
||||||
trade_offer=trade,
|
trade_offer=trade, card=rarity_card, quantity=1
|
||||||
card=rarity_card,
|
|
||||||
quantity=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
TradeOfferWantCard.objects.create(
|
TradeOfferWantCard.objects.create(
|
||||||
trade_offer=trade,
|
trade_offer=trade, card=rarity_card, quantity=1
|
||||||
card=rarity_card,
|
|
||||||
quantity=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Basic performance test - just checking it completes without timeout
|
# Basic performance test - just checking it completes without timeout
|
||||||
import time
|
import time
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
end = time.time()
|
end = time.time()
|
||||||
|
|
@ -468,46 +434,36 @@ class TemplateRenderingTests(TestCase):
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
# Create a user
|
# Create a user
|
||||||
cls.user = User.objects.create_user(
|
cls.user = User.objects.create_user(
|
||||||
username='testuser',
|
username="testuser", email="testuser@example.com", password="testpass123"
|
||||||
email='testuser@example.com',
|
|
||||||
password='testpass123'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a friend code for the user
|
# Create a friend code for the user
|
||||||
cls.friend_code = FriendCode.objects.create(
|
cls.friend_code = FriendCode.objects.create(
|
||||||
user=cls.user,
|
user=cls.user, friend_code="SW-1234-5678-9012", in_game_name="TestTrainer"
|
||||||
friend_code='SW-1234-5678-9012',
|
|
||||||
in_game_name='TestTrainer'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a card
|
# Create a card
|
||||||
cls.card = Card.objects.create(
|
cls.card = Card.objects.create(
|
||||||
name='Test Card',
|
name="Test Card",
|
||||||
cardset='TEST01',
|
cardset="TEST01",
|
||||||
cardnum=1,
|
cardnum=1,
|
||||||
style='normal',
|
style="normal",
|
||||||
rarity_icon='★',
|
rarity_icon="★",
|
||||||
rarity_level=1
|
rarity_level=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a trade offer
|
# Create a trade offer
|
||||||
cls.trade = TradeOffer.objects.create(
|
cls.trade = TradeOffer.objects.create(
|
||||||
initiated_by=cls.friend_code,
|
initiated_by=cls.friend_code, rarity_icon="★", rarity_level=1
|
||||||
rarity_icon='★',
|
|
||||||
rarity_level=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add have and want cards
|
# Add have and want cards
|
||||||
TradeOfferHaveCard.objects.create(
|
TradeOfferHaveCard.objects.create(
|
||||||
trade_offer=cls.trade,
|
trade_offer=cls.trade, card=cls.card, quantity=1
|
||||||
card=cls.card,
|
|
||||||
quantity=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
TradeOfferWantCard.objects.create(
|
TradeOfferWantCard.objects.create(
|
||||||
trade_offer=cls.trade,
|
trade_offer=cls.trade, card=cls.card, quantity=1
|
||||||
card=cls.card,
|
|
||||||
quantity=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
@ -516,21 +472,21 @@ class TemplateRenderingTests(TestCase):
|
||||||
|
|
||||||
def test_template_used(self):
|
def test_template_used(self):
|
||||||
"""Test that the correct template is used."""
|
"""Test that the correct template is used."""
|
||||||
response = self.client.get(reverse('home'))
|
response = self.client.get(reverse("home"))
|
||||||
self.assertTemplateUsed(response, 'home/home.html')
|
self.assertTemplateUsed(response, "home/home.html")
|
||||||
|
|
||||||
def test_context_variables_exist(self):
|
def test_context_variables_exist(self):
|
||||||
"""Test that all expected context variables exist."""
|
"""Test that all expected context variables exist."""
|
||||||
response = self.client.get(reverse('home'))
|
response = self.client.get(reverse("home"))
|
||||||
|
|
||||||
# Check all required context variables
|
# Check all required context variables
|
||||||
expected_keys = [
|
expected_keys = [
|
||||||
'cards',
|
"cards",
|
||||||
'recent_offers',
|
"recent_offers",
|
||||||
'most_offered_cards',
|
"most_offered_cards",
|
||||||
'most_wanted_cards',
|
"most_wanted_cards",
|
||||||
'least_offered_cards',
|
"least_offered_cards",
|
||||||
'featured_offers',
|
"featured_offers",
|
||||||
]
|
]
|
||||||
|
|
||||||
for key in expected_keys:
|
for key in expected_keys:
|
||||||
|
|
@ -541,22 +497,16 @@ class TemplateRenderingTests(TestCase):
|
||||||
# Create additional trade offers if pagination is implemented
|
# Create additional trade offers if pagination is implemented
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
trade = TradeOffer.objects.create(
|
trade = TradeOffer.objects.create(
|
||||||
initiated_by=self.friend_code,
|
initiated_by=self.friend_code, rarity_icon="★", rarity_level=1
|
||||||
rarity_icon='★',
|
|
||||||
rarity_level=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add have and want cards
|
# Add have and want cards
|
||||||
TradeOfferHaveCard.objects.create(
|
TradeOfferHaveCard.objects.create(
|
||||||
trade_offer=trade,
|
trade_offer=trade, card=self.card, quantity=1
|
||||||
card=self.card,
|
|
||||||
quantity=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
TradeOfferWantCard.objects.create(
|
TradeOfferWantCard.objects.create(
|
||||||
trade_offer=trade,
|
trade_offer=trade, card=self.card, quantity=1
|
||||||
card=self.card,
|
|
||||||
quantity=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test with page parameter
|
# Test with page parameter
|
||||||
|
|
@ -565,27 +515,30 @@ class TemplateRenderingTests(TestCase):
|
||||||
|
|
||||||
# Test with invalid page parameter
|
# Test with invalid page parameter
|
||||||
response = self.client.get(f"{reverse('home')}?page=999")
|
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
|
# Test with non-numeric page parameter
|
||||||
response = self.client.get(f"{reverse('home')}?page=abc")
|
response = self.client.get(f"{reverse('home')}?page=abc")
|
||||||
self.assertEqual(response.status_code, 200) # Should handle gracefully
|
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):
|
def test_view_renders_with_missing_context(self, mock_get_context):
|
||||||
"""Test that view renders even with incomplete context data."""
|
"""Test that view renders even with incomplete context data."""
|
||||||
# Return incomplete context
|
# 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
|
# 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)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_compatibility_with_multiple_django_versions(self):
|
def test_compatibility_with_multiple_django_versions(self):
|
||||||
"""Ensure compatibility with different Django versions."""
|
"""Ensure compatibility with different Django versions."""
|
||||||
import django
|
import django
|
||||||
|
|
||||||
# Simply log the Django version - the test itself verifies the page renders
|
# Simply log the Django version - the test itself verifies the page renders
|
||||||
# with the current version
|
# with the current version
|
||||||
django_version = django.get_version()
|
django_version = django.get_version()
|
||||||
response = self.client.get(reverse('home'))
|
response = self.client.get(reverse("home"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
from collections import defaultdict, OrderedDict
|
|
||||||
from django.views.generic import TemplateView
|
|
||||||
from django.urls import reverse_lazy
|
|
||||||
from django.db.models import Count, Q, Prefetch, Sum, F, IntegerField, Value, BooleanField, Case, When
|
|
||||||
from django.db.models.functions import Coalesce
|
|
||||||
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
|
||||||
from pkmntrade_club.trades.models import TradeOffer, TradeAcceptance, TradeOfferHaveCard, TradeOfferWantCard
|
|
||||||
from pkmntrade_club.cards.models import Card
|
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.template.response import TemplateResponse
|
|
||||||
from django.http import HttpResponseRedirect
|
|
||||||
import logging
|
import logging
|
||||||
from django.views import View
|
from collections import OrderedDict
|
||||||
from django.http import HttpResponse
|
|
||||||
import contextlib
|
from django.db.models import (
|
||||||
|
Max,
|
||||||
|
Sum,
|
||||||
|
)
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
from pkmntrade_club.cards.models import Card
|
||||||
|
from pkmntrade_club.trades.models import (
|
||||||
|
TradeOffer,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class HomePageView(TemplateView):
|
class HomePageView(TemplateView):
|
||||||
template_name = "home/home.html"
|
template_name = "home/home.html"
|
||||||
|
|
||||||
|
|
@ -25,7 +25,11 @@ class HomePageView(TemplateView):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get all cards ordered by name, exclude cards with rarity level > 5
|
# Get all cards ordered by name, exclude cards with rarity level > 5
|
||||||
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("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
|
# Reuse base trade offer queryset for market stats
|
||||||
base_offer_qs = TradeOffer.objects.filter(is_closed=False)
|
base_offer_qs = TradeOffer.objects.filter(is_closed=False)
|
||||||
|
|
@ -34,7 +38,15 @@ class HomePageView(TemplateView):
|
||||||
try:
|
try:
|
||||||
recent_offers_qs = base_offer_qs.order_by("-created_at")[:6]
|
recent_offers_qs = base_offer_qs.order_by("-created_at")[:6]
|
||||||
context["recent_offers"] = recent_offers_qs
|
context["recent_offers"] = recent_offers_qs
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching recent offers: {str(e)}")
|
logger.error(f"Error fetching recent offers: {str(e)}")
|
||||||
context["recent_offers"] = []
|
context["recent_offers"] = []
|
||||||
|
|
@ -43,12 +55,22 @@ class HomePageView(TemplateView):
|
||||||
# Most Offered Cards
|
# Most Offered Cards
|
||||||
try:
|
try:
|
||||||
most_offered_cards_qs = (
|
most_offered_cards_qs = (
|
||||||
Card.objects.filter(tradeofferhavecard__isnull=False).filter(rarity_level__lte=5)
|
Card.objects.with_details()
|
||||||
|
.filter(tradeofferhavecard__isnull=False)
|
||||||
|
.filter(rarity__level__lte=5)
|
||||||
.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
|
.annotate(offer_count=Sum("tradeofferhavecard__quantity"))
|
||||||
.order_by("-offer_count")[:6]
|
.order_by("-offer_count")[:6]
|
||||||
)
|
)
|
||||||
context["most_offered_cards"] = most_offered_cards_qs
|
context["most_offered_cards"] = most_offered_cards_qs
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching most offered cards: {str(e)}")
|
logger.error(f"Error fetching most offered cards: {str(e)}")
|
||||||
context["most_offered_cards"] = []
|
context["most_offered_cards"] = []
|
||||||
|
|
@ -56,12 +78,22 @@ class HomePageView(TemplateView):
|
||||||
# Most Wanted Cards
|
# Most Wanted Cards
|
||||||
try:
|
try:
|
||||||
most_wanted_cards_qs = (
|
most_wanted_cards_qs = (
|
||||||
Card.objects.filter(tradeofferwantcard__isnull=False).filter(rarity_level__lte=5)
|
Card.objects.with_details()
|
||||||
|
.filter(tradeofferwantcard__isnull=False)
|
||||||
|
.filter(rarity__level__lte=5)
|
||||||
.annotate(offer_count=Sum("tradeofferwantcard__quantity"))
|
.annotate(offer_count=Sum("tradeofferwantcard__quantity"))
|
||||||
.order_by("-offer_count")[:6]
|
.order_by("-offer_count")[:6]
|
||||||
)
|
)
|
||||||
context["most_wanted_cards"] = most_wanted_cards_qs
|
context["most_wanted_cards"] = most_wanted_cards_qs
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching most wanted cards: {str(e)}")
|
logger.error(f"Error fetching most wanted cards: {str(e)}")
|
||||||
context["most_wanted_cards"] = []
|
context["most_wanted_cards"] = []
|
||||||
|
|
@ -69,13 +101,23 @@ class HomePageView(TemplateView):
|
||||||
# Least Offered Cards
|
# Least Offered Cards
|
||||||
try:
|
try:
|
||||||
least_offered_cards_qs = (
|
least_offered_cards_qs = (
|
||||||
Card.objects.filter(rarity_level__lte=5).annotate(
|
Card.objects.with_details()
|
||||||
|
.filter(rarity__level__lte=5)
|
||||||
|
.annotate(
|
||||||
offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0)
|
offer_count=Coalesce(Sum("tradeofferhavecard__quantity"), 0)
|
||||||
)
|
)
|
||||||
.order_by("offer_count")[:6]
|
.order_by("offer_count")[:6]
|
||||||
)
|
)
|
||||||
context["least_offered_cards"] = least_offered_cards_qs
|
context["least_offered_cards"] = least_offered_cards_qs
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching least offered cards: {str(e)}")
|
logger.error(f"Error fetching least offered cards: {str(e)}")
|
||||||
context["least_offered_cards"] = []
|
context["least_offered_cards"] = []
|
||||||
|
|
@ -84,10 +126,21 @@ class HomePageView(TemplateView):
|
||||||
featured = OrderedDict()
|
featured = OrderedDict()
|
||||||
# Featured "All" offers remains fixed at the top
|
# Featured "All" offers remains fixed at the top
|
||||||
try:
|
try:
|
||||||
featured["All"] = base_offer_qs.order_by("created_at")[:6]
|
all_featured = base_offer_qs.order_by("created_at")[:6]
|
||||||
|
featured["All"] = all_featured
|
||||||
|
latest_update = all_featured.aggregate(latest=Max("updated_at"))[
|
||||||
|
"latest"
|
||||||
|
]
|
||||||
|
if latest_update:
|
||||||
|
context["cache_key_featured_offers"] = (
|
||||||
|
f"featured_all_{latest_update.timestamp()}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
context["cache_key_featured_offers"] = "featured_all_empty"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching 'All' featured offers: {str(e)}")
|
logger.error(f"Error fetching 'All' featured offers: {str(e)}")
|
||||||
featured["All"] = []
|
featured["All"] = []
|
||||||
|
context["cache_key_featured_offers"] = "featured_all_error"
|
||||||
|
|
||||||
# *** we only show All Featured Offers for now,
|
# *** we only show All Featured Offers for now,
|
||||||
# *** we will add rarity-tabbed featured offers later
|
# *** we will add rarity-tabbed featured offers later
|
||||||
|
|
@ -111,19 +164,6 @@ class HomePageView(TemplateView):
|
||||||
# logger.error(f"Error processing rarity-based featured offers: {str(e)}")
|
# logger.error(f"Error processing rarity-based featured offers: {str(e)}")
|
||||||
|
|
||||||
context["featured_offers"] = featured
|
context["featured_offers"] = featured
|
||||||
# Generate a cache key based on the pks and updated_at timestamps of all featured offers
|
|
||||||
# *** we will separate cache keys for each featured section later
|
|
||||||
all_offer_identifiers = []
|
|
||||||
for section_name,section_offers in featured.items():
|
|
||||||
# featured_section is a QuerySet. Fetch (pk, updated_at) tuples.
|
|
||||||
identifiers = section_offers.values_list('pk', 'updated_at')
|
|
||||||
# Format each tuple as "pk_timestamp" and add to the list
|
|
||||||
section_strings = [f"{section_name}_{pk}_{ts.timestamp()}" for pk, ts in identifiers]
|
|
||||||
all_offer_identifiers.extend(section_strings)
|
|
||||||
|
|
||||||
# Join all identifiers into a single string, sorted for consistency regardless of order
|
|
||||||
combined_identifiers = "|".join(sorted(all_offer_identifiers))
|
|
||||||
context["cache_key_featured_offers"] = f"featured_offers_{combined_identifiers}"
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}")
|
logger.error(f"Unhandled error in HomePageView.get_context_data: {str(e)}")
|
||||||
# Provide fallback empty data
|
# Provide fallback empty data
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,5 @@ RARITY_MAPPING = {
|
||||||
5: "⭐️",
|
5: "⭐️",
|
||||||
6: "⭐️⭐️",
|
6: "⭐️⭐️",
|
||||||
7: "⭐️⭐️⭐️",
|
7: "⭐️⭐️⭐️",
|
||||||
8: "👑"
|
8: "👑",
|
||||||
}
|
}
|
||||||
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class ThemeConfig(AppConfig):
|
class ThemeConfig(AppConfig):
|
||||||
name = 'pkmntrade_club.theme'
|
name = "pkmntrade_club.theme"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block extrastyle %}{{ block.super }}
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static "admin/css/dashboard.css" %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block coltype %}colM{% endblock %}
|
||||||
|
|
||||||
|
{% block bodyclass %}{{ block.super }} dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label='cards' %}">Cards</a>
|
||||||
|
› {% translate 'Full Card Importer' %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% translate 'Full Card Set Importer' %}</h1>
|
||||||
|
<p>{% translate 'Click the button below to import all card data from the configured JSON file directory.' %}</p>
|
||||||
|
<p>{% 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.).' %}</p>
|
||||||
|
|
||||||
|
<form method="POST" action="">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="button btn-success">{% translate 'Start Full Import' %}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<h2>{% translate 'Import Status:' %}</h2>
|
||||||
|
<ul class="messagelist">
|
||||||
|
{% for message in messages %}
|
||||||
|
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{% load static tailwind_tags gravatar %}
|
{% load static tailwind_tags gravatar %}
|
||||||
{% url 'home' as home_url %}
|
{% url 'home' as home_url %}
|
||||||
{% url 'trade_offer_list' as trade_offer_list_url %}
|
{% url 'trade_offer_list' as trade_offer_list_url %}
|
||||||
{% url 'cards:card_list' as cards_list_url %}
|
{% url 'cards:list' as cards_list_url %}
|
||||||
{% url 'dashboard' as dashboard_url %}
|
{% url 'dashboard' as dashboard_url %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
<div class="navbar-center hidden sm:flex">
|
<div class="navbar-center hidden sm:flex">
|
||||||
<ul class="menu menu-horizontal px-1">
|
<ul class="menu menu-horizontal px-1">
|
||||||
<li><a href="{% url 'home' %}">Home</a></li>
|
<li><a href="{% url 'home' %}">Home</a></li>
|
||||||
<li><a href="{% url 'cards:card_list' %}">Cards</a></li>
|
<li><a href=" {% url 'cards:list' %} ">Cards</a></li>
|
||||||
<li><a href="{% url 'trade_offer_list' %}">Trades</a></li>
|
<li><a href="{% url 'trade_offer_list' %}">Trades</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -138,6 +138,8 @@
|
||||||
<!-- Goatcounter: 100% privacy-first, no tracking analytics -->
|
<!-- Goatcounter: 100% privacy-first, no tracking analytics -->
|
||||||
<script data-goatcounter="https://stats.pkmntrade.club/count" async src="{% static 'js/count-v4.js' %}"></script>
|
<script data-goatcounter="https://stats.pkmntrade.club/count" async src="{% static 'js/count-v4.js' %}"></script>
|
||||||
|
|
||||||
|
<!-- bugdrop.app -->
|
||||||
|
<script src="https://cdn.bugdrop.app/widget.js" data-site-id="9652eaccff27e8db8b8f86595514f63e"></script>
|
||||||
{% block javascript %}{% endblock %}
|
{% block javascript %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<div class="flex items-center mb-6">
|
<div class="flex items-center mb-6">
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<h1 class="text-3xl font-bold">{{card.name}}</h1>
|
<h1 class="text-3xl font-bold">{{card.name}}</h1>
|
||||||
<h2 class="text-lg text-gray-500">{{ card.cardset }} #{{ card.cardnum }} • {{ card.rarity_icon }}</h2>
|
<h2 class="text-lg text-gray-500">{{ card.cardset }} #{{ card.cardnum }} • {{ card.rarity.icon }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,48 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static card_badge %}
|
{% load static card_badge %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto"
|
<div class="container mx-auto p-4">
|
||||||
x-data="{
|
<h1 class="text-3xl font-bold mb-4">Card List</h1>
|
||||||
order: '{{ order }}',
|
|
||||||
groupBy: '{{ group_by|default:'none' }}',
|
<div class="mb-4">
|
||||||
page: 1,
|
<!-- Sorting and Grouping controls can go here -->
|
||||||
loadCards() {
|
|
||||||
// Construct URL using current pathname and query parameters.
|
|
||||||
let groupParam = this.groupBy === 'none' ? '' : this.groupBy;
|
|
||||||
let url = window.location.pathname + '?order=' + this.order + '&group_by=' + groupParam + '&page=' + this.page;
|
|
||||||
fetch(url, { headers: { 'x-requested-with': 'XMLHttpRequest' } })
|
|
||||||
.then(response => response.text())
|
|
||||||
.then(html => {
|
|
||||||
this.$refs.cardList.innerHTML = html;
|
|
||||||
window.processMarqueeElements && window.processMarqueeElements();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
x-on:change-page.window="page = $event.detail.page; loadCards()">
|
|
||||||
<div class="flex flex-wrap items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold">Cards</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<!-- Sort Dropdown -->
|
{% if group_by == 'rarity' %}
|
||||||
<div class="dropdown dropdown-end m-1">
|
{% regroup cards by rarity as grouped_list %}
|
||||||
<div tabindex="0" class="btn">
|
{% for group in grouped_list %}
|
||||||
Sort by: <span x-text="order === 'absolute' ? 'Cardset' : (order === 'alphabetical' ? 'Alphabetical' : 'Rarity')"></span>
|
<div class="mb-6">
|
||||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<h2 class="text-2xl font-semibold mb-2">{{ group.grouper.icon }} {{ group.grouper.name }}</h2>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
</svg>
|
{% for card in group.list %}
|
||||||
</div>
|
{% card_badge card expanded=True %}
|
||||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-w-52">
|
{% endfor %}
|
||||||
<li><a href="#" @click.prevent="order = 'absolute'; page = 1; loadCards()">Cardset</a></li>
|
|
||||||
<li><a href="#" @click.prevent="order = 'alphabetical'; page = 1; loadCards()">Alphabetical</a></li>
|
|
||||||
<li><a href="#" @click.prevent="order = 'rarity'; page = 1; loadCards()">Rarity</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<!-- Grouping Dropdown -->
|
|
||||||
<div class="dropdown dropdown-end m-1">
|
|
||||||
<div tabindex="0" class="btn">
|
|
||||||
Group by: <span x-text="groupBy === 'none' ? 'None' : (groupBy.charAt(0).toUpperCase() + groupBy.slice(1))"></span>
|
|
||||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-w-52">
|
|
||||||
<li><a href="#" @click.prevent="groupBy = 'none'; page = 1; loadCards()">None</a></li>
|
|
||||||
<li><a href="#" @click.prevent="groupBy = 'deck'; page = 1; loadCards()">Deck</a></li>
|
|
||||||
<li><a href="#" @click.prevent="groupBy = 'cardset'; page = 1; loadCards()">Cardset</a></li>
|
|
||||||
<li><a href="#" @click.prevent="groupBy = 'rarity'; page = 1; loadCards()">Rarity</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% elif group_by == 'cardset' %}
|
||||||
|
{% regroup cards by cardset as grouped_list %}
|
||||||
|
{% for group in grouped_list %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">{{ group.grouper.name }}</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{% for card in group.list %}
|
||||||
|
{% card_badge card expanded=True %}
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<!-- Container for the partial card list -->
|
</div>
|
||||||
<div x-ref="cardList">
|
{% endfor %}
|
||||||
{% include "cards/_card_list.html" with cards=cards page_obj=page_obj %}
|
{% else %}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{% for card in cards %}
|
||||||
|
{% card_badge card expanded=True %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
{% include "templatetags/pagination_controls.html" with page_obj=page_obj %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -45,7 +45,7 @@ Welcome
|
||||||
<h2 id="stats-heading" class="text-2xl font-semibold mb-4">Card Stats</h2>
|
<h2 id="stats-heading" class="text-2xl font-semibold mb-4">Card Stats</h2>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<!-- Most Offered Cards -->
|
<!-- Most Offered Cards -->
|
||||||
{% cache CACHE_TIMEOUT most_offered_cards cache_key_most_offered_cards %}
|
{% cache CACHE_MEDIUM_TIMEOUT most_offered_cards cache_key_most_offered_cards %}
|
||||||
<div>
|
<div>
|
||||||
<div class="card card-border bg-base-100 shadow-lg">
|
<div class="card card-border bg-base-100 shadow-lg">
|
||||||
<div class="card-header text-base-content p-4">
|
<div class="card-header text-base-content p-4">
|
||||||
|
|
@ -58,7 +58,7 @@ Welcome
|
||||||
</div>
|
</div>
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
<!-- Most Wanted Cards -->
|
<!-- Most Wanted Cards -->
|
||||||
{% cache CACHE_TIMEOUT most_wanted_cards cache_key_most_wanted_cards %}
|
{% cache CACHE_MEDIUM_TIMEOUT most_wanted_cards cache_key_most_wanted_cards %}
|
||||||
<div>
|
<div>
|
||||||
<div class="card card-border bg-base-100 shadow-lg">
|
<div class="card card-border bg-base-100 shadow-lg">
|
||||||
<div class="card-header text-base-content p-4">
|
<div class="card-header text-base-content p-4">
|
||||||
|
|
@ -71,7 +71,7 @@ Welcome
|
||||||
</div>
|
</div>
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
<!-- Least Offered Cards (Last Group) -->
|
<!-- Least Offered Cards (Last Group) -->
|
||||||
{% cache CACHE_TIMEOUT least_offered_cards cache_key_least_offered_cards %}
|
{% cache CACHE_MEDIUM_TIMEOUT least_offered_cards cache_key_least_offered_cards %}
|
||||||
<div class="col-span-2 md:col-span-1">
|
<div class="col-span-2 md:col-span-1">
|
||||||
<div class="card card-border bg-base-100 shadow-lg">
|
<div class="card card-border bg-base-100 shadow-lg">
|
||||||
<div class="card-header text-base-content p-4">
|
<div class="card-header text-base-content p-4">
|
||||||
|
|
@ -90,7 +90,7 @@ Welcome
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<!-- Featured Offers -->
|
<!-- Featured Offers -->
|
||||||
{% cache CACHE_TIMEOUT featured_offers cache_key_featured_offers %}
|
{% cache CACHE_MEDIUM_TIMEOUT featured_offers cache_key_featured_offers %}
|
||||||
<div>
|
<div>
|
||||||
<div class="p-4 text-center ">
|
<div class="p-4 text-center ">
|
||||||
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Featured Offers</h5>
|
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Featured Offers</h5>
|
||||||
|
|
@ -109,7 +109,7 @@ Welcome
|
||||||
</div>
|
</div>
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
<!-- Recent Offers -->
|
<!-- Recent Offers -->
|
||||||
{% cache CACHE_TIMEOUT recent_offers cache_key_recent_offers %}
|
{% cache CACHE_MEDIUM_TIMEOUT recent_offers cache_key_recent_offers %}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-center p-4">
|
<div class="text-center p-4">
|
||||||
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Recent Offers</h5>
|
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Recent Offers</h5>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
{% load card_badge card_multiselect %}
|
|
||||||
<option value="" disabled>{{ placeholder }}</option>
|
|
||||||
{% for card in cards_to_render %}
|
|
||||||
{% with card_id_str=card.pk|stringformat:"s" %} {# Ensure card PK is string for lookup #}
|
|
||||||
{% if card_id_str in selected_cards %}
|
|
||||||
<option
|
|
||||||
value="{{ card.pk }}:{{ selected_cards|get_item:card_id_str }}"
|
|
||||||
data-card-id="{{ card.pk }}"
|
|
||||||
data-quantity="{{ selected_cards|get_item:card_id_str }}"
|
|
||||||
selected
|
|
||||||
data-html-content='<div class="m-2">{{ card|card_badge_inline:selected_cards|get_item:card_id_str }}</div>'
|
|
||||||
data-name="{{ card.name }}"
|
|
||||||
data-rarity="{{ card.rarity_icon }}"
|
|
||||||
data-cardset="{{ card.cardset }}">
|
|
||||||
{{ card.name }} {{ card.rarity_icon }} {{ card.cardset }}
|
|
||||||
</option>
|
|
||||||
{% else %}
|
|
||||||
<option
|
|
||||||
value="{{ card.pk }}:1"
|
|
||||||
data-card-id="{{ card.pk }}"
|
|
||||||
data-quantity="1"
|
|
||||||
data-html-content='<div class="m-2">{{ card|card_badge_inline:"" }}</div>'
|
|
||||||
data-name="{{ card.name }}"
|
|
||||||
data-rarity="{{ card.rarity_icon }}"
|
|
||||||
data-cardset="{{ card.cardset }}">
|
|
||||||
{{ card.name }} {{ card.rarity_icon }} {{ card.cardset }}
|
|
||||||
</option>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
{% load cache %}
|
{% load cache %}
|
||||||
{% cache CACHE_TIMEOUT card_badge cache_key %}
|
{% cache CACHE_LONG_TIMEOUT cache_key %}
|
||||||
|
<!-- clickable: {{ clickable }} -->
|
||||||
|
<!-- closeable: {{ closeable }} -->
|
||||||
|
{% if clickable %}
|
||||||
<a href="{{ url }}" @click.stop>
|
<a href="{{ url }}" @click.stop>
|
||||||
|
{% endif %}
|
||||||
<div class="relative block">
|
<div class="relative block">
|
||||||
{% if not expanded %}
|
{% if not expanded %}
|
||||||
<div class="flex flex-row items-center h-[32px] p-1.5 w-40 text-white shadow-lg" style="{{ style }}">
|
<div class="flex flex-row items-center h-[32px] p-1.5 w-40 text-white shadow-lg" style="{{ style }}">
|
||||||
|
|
@ -10,6 +14,10 @@
|
||||||
<div class="grow-0 shrink-0 relative w-fit ps-1">
|
<div class="grow-0 shrink-0 relative w-fit ps-1">
|
||||||
<div class="card-quantity-badge relative bg-gray-600 text-white text-sm font-semibold rounded-full text-center size-max px-1.5">{{ quantity }}</div>
|
<div class="card-quantity-badge relative bg-gray-600 text-white text-sm font-semibold rounded-full text-center size-max px-1.5">{{ quantity }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif closeable == True %}
|
||||||
|
<div class="grow-0 shrink-0 relative w-fit ps-1">
|
||||||
|
×
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -19,11 +27,17 @@
|
||||||
<div class="row-start-1 col-start-4 col-span-1 self-start ms-auto leading-tight relative w-fit ps-1">
|
<div class="row-start-1 col-start-4 col-span-1 self-start ms-auto leading-tight relative w-fit ps-1">
|
||||||
<div class="card-quantity-badge relative bg-gray-600 text-white text-sm font-semibold rounded-full text-center size-max px-1.5">{{ quantity }}</div>
|
<div class="card-quantity-badge relative bg-gray-600 text-white text-sm font-semibold rounded-full text-center size-max px-1.5">{{ quantity }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif closeable == True %}
|
||||||
|
<div class="row-start-1 col-start-4 col-span-1 self-start ms-auto leading-tight relative w-fit ps-1">
|
||||||
|
×
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="row-start-2 col-start-1 col-span-3 truncate self-end text-xs text-transparent">{{ rarity }}</div>
|
<div class="row-start-2 col-start-1 col-span-3 truncate self-end text-xs text-transparent">{{ rarity }}</div>
|
||||||
<div class="row-start-2 col-start-4 col-span-1 self-end text-right truncate font-semibold leading-tight text-sm">{{ cardset }}</div>
|
<div class="row-start-2 col-start-4 col-span-1 self-end text-right truncate font-semibold leading-tight text-sm">{{ cardset }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if clickable %}
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
|
|
@ -1,16 +1,267 @@
|
||||||
{% load cache card_badge %}
|
{% load i18n card_badge %}
|
||||||
{% load cache card_multiselect %}
|
|
||||||
|
|
||||||
<label for="{{ field_id }}" class="label">
|
<div
|
||||||
|
x-data="cardMultiSelect({{ initial_selected_cards_json }})"
|
||||||
|
x-init="init()"
|
||||||
|
@select-card.window="selectCard($event.detail)"
|
||||||
|
class="card-multiselect-component"
|
||||||
|
>
|
||||||
|
<label :for="fieldId + '_search'" class="label">
|
||||||
<span class="label-text">{{ label }}</span>
|
<span class="label-text">{{ label }}</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="{{ field_name }}" id="{{ field_id }}" class="select select-bordered w-full card-multiselect" data-placeholder="{{ placeholder }}" multiple x-cloak>
|
|
||||||
{% cache CACHE_TIMEOUT card_multiselect field_name label placeholder passed_cards_identifier selected_cards_key_part %}
|
{# Hidden select that holds the actual form values #}
|
||||||
{% if has_passed_cards %}
|
<select
|
||||||
{% include "templatetags/_card_multiselect_options.html" with cards_to_render=passed_cards selected_cards=selected_cards placeholder=placeholder %}
|
multiple
|
||||||
{% else %}
|
:name="fieldName"
|
||||||
{% fetch_all_cards as all_db_cards %}
|
:id="fieldId"
|
||||||
{% include "templatetags/_card_multiselect_options.html" with cards_to_render=all_db_cards selected_cards=selected_cards placeholder=placeholder %}
|
class="hidden"
|
||||||
{% endif %}
|
x-ref="selectElement"
|
||||||
{% endcache %}
|
>
|
||||||
|
<template x-for="card in selectedCards" :key="card.id">
|
||||||
|
<option :value="card.id" selected></option>
|
||||||
|
</template>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{# JS-powered search input and selected card display #}
|
||||||
|
<div x-cloak class="js-enabled-section">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-ref="searchInput"
|
||||||
|
:id="fieldId + '_search'"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
x-model="searchQuery"
|
||||||
|
@input.debounce.300ms="search()"
|
||||||
|
@keydown.down.prevent="highlightNext()"
|
||||||
|
@keydown.up.prevent="highlightPrev()"
|
||||||
|
@keydown.enter.prevent="selectHighlighted()"
|
||||||
|
@keydown.esc.prevent="closeAndClear()"
|
||||||
|
@focus="openPopover()"
|
||||||
|
popovertarget="search-results-popover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div id="search-results-popover" popover class="w-full bg-base-100 border border-base-300 rounded-box shadow-lg mt-1" :style="`width: ${popoverWidth}px`">
|
||||||
|
<ul>
|
||||||
|
<template x-for="(card, index) in searchResults" :key="card.id">
|
||||||
|
<li
|
||||||
|
class="p-2 cursor-pointer hover:bg-base-200"
|
||||||
|
:class="{ 'bg-base-300': highlightedIndex === index }"
|
||||||
|
@mouseenter="highlightedIndex = index"
|
||||||
|
@click="selectCard(card)"
|
||||||
|
>
|
||||||
|
<div x-html="card.html"></div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
<div x-show="isLoading" class="p-4 text-center">{% trans "Searching..." %}</div>
|
||||||
|
<div x-show="!isLoading && searchQuery.length > 1 && searchResults.length === 0" class="p-4 text-center">
|
||||||
|
{% trans "No results found." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="selectedCards.length > 0" class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<template x-for="card in selectedCards" :key="'selected-' + card.id">
|
||||||
|
<div class="relative">
|
||||||
|
<div x-html="card.html"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click.stop="removeCard(card.id)"
|
||||||
|
class="absolute -top-1 -right-1 btn btn-xs btn-circle btn-error text-white"
|
||||||
|
title="Remove Card"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Non-JS fallback search form #}
|
||||||
|
<noscript>
|
||||||
|
<div class="non-js-section">
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<div class="grow">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="{% trans 'Search for a card name...' %}"
|
||||||
|
value="{{ request.GET.q|default:'' }}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-primary">{% trans 'Search' %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if has_non_js_results %}
|
||||||
|
<select name="{{ field_name }}" id="{{ field_id }}" class="select select-bordered w-full mt-2" multiple>
|
||||||
|
{% for card in non_js_search_results %}
|
||||||
|
<option value="{{ card.id }}">
|
||||||
|
{{ card.name }} - {{ card.set.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="text-sm mt-1">{% trans "Select cards from the list above and continue filling out the form." %}</p>
|
||||||
|
{% elif request.GET.q %}
|
||||||
|
<p class="mt-2">{% trans "No cards found for your search." %}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if initial_selected_cards %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<h4 class="font-bold">{% trans "Currently Selected" %}</h4>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
{% for card in initial_selected_cards %}
|
||||||
|
<div class="badge badge-lg">{{ card.name }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('cardMultiSelect', (initialSelectedCards) => ({
|
||||||
|
searchQuery: '',
|
||||||
|
searchResults: [],
|
||||||
|
selectedCards: initialSelectedCards || [],
|
||||||
|
isLoading: false,
|
||||||
|
highlightedIndex: -1,
|
||||||
|
abortController: null,
|
||||||
|
fieldId: '',
|
||||||
|
fieldName: '',
|
||||||
|
placeholder: '',
|
||||||
|
popoverWidth: 0,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.fieldId = this.$el.querySelector('select').id;
|
||||||
|
this.fieldName = this.$el.querySelector('select').name;
|
||||||
|
this.placeholder = this.$el.querySelector('input[type=text]').placeholder;
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.repositionPopover();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also resize on window resize
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
this.repositionPopover();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
repositionPopover() {
|
||||||
|
if (!this.$refs.searchInput) return;
|
||||||
|
const inputRect = this.$refs.searchInput.getBoundingClientRect();
|
||||||
|
this.popoverWidth = inputRect.width;
|
||||||
|
|
||||||
|
const popover = document.getElementById('search-results-popover');
|
||||||
|
if (popover) {
|
||||||
|
popover.style.left = `${inputRect.left}px`;
|
||||||
|
popover.style.top = `${inputRect.bottom + window.scrollY}px`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openPopover() {
|
||||||
|
const popover = document.getElementById('search-results-popover');
|
||||||
|
if (popover && typeof popover.showPopover === 'function') {
|
||||||
|
popover.showPopover();
|
||||||
|
this.repositionPopover(); // Reposition after showing
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closePopover() {
|
||||||
|
const popover = document.getElementById('search-results-popover');
|
||||||
|
if (popover && typeof popover.hidePopover === 'function') {
|
||||||
|
popover.hidePopover();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAndClear() {
|
||||||
|
this.closePopover();
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.searchResults = [];
|
||||||
|
this.highlightedIndex = -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
search() {
|
||||||
|
if (this.searchQuery.length < 2) {
|
||||||
|
this.searchResults = [];
|
||||||
|
this.isLoading = false;
|
||||||
|
if(this.abortController) this.abortController.abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
this.highlightedIndex = -1;
|
||||||
|
|
||||||
|
if (this.abortController) {
|
||||||
|
this.abortController.abort();
|
||||||
|
}
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
|
||||||
|
const selectedIds = new URLSearchParams();
|
||||||
|
this.selectedCards.forEach(card => selectedIds.append('selected_ids[]', card.id));
|
||||||
|
|
||||||
|
fetch(`/cards/api/search/?q=${encodeURIComponent(this.searchQuery)}&${selectedIds.toString()}`, {
|
||||||
|
signal: this.abortController.signal
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
this.searchResults = data;
|
||||||
|
this.isLoading = false;
|
||||||
|
if (this.searchResults.length > 0) {
|
||||||
|
this.openPopover();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (error.name !== 'AbortError') {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
selectCard(card) {
|
||||||
|
if (!this.selectedCards.some(c => c.id === card.id)) {
|
||||||
|
this.selectedCards.push(card);
|
||||||
|
}
|
||||||
|
this.closeAndClear();
|
||||||
|
},
|
||||||
|
|
||||||
|
removeCard(cardId) {
|
||||||
|
this.selectedCards = this.selectedCards.filter(c => c.id !== cardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightNext() {
|
||||||
|
if (this.highlightedIndex < this.searchResults.length - 1) {
|
||||||
|
this.highlightedIndex++;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightPrev() {
|
||||||
|
if (this.highlightedIndex > 0) {
|
||||||
|
this.highlightedIndex--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectHighlighted() {
|
||||||
|
if (this.highlightedIndex > -1 && this.searchResults[this.highlightedIndex]) {
|
||||||
|
this.selectCard(this.searchResults[this.highlightedIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
|
||||||
|
/* Basic popover styles */
|
||||||
|
#search-results-popover {
|
||||||
|
width: var(--popover-width, 100%); /* Default width */
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{% load gravatar card_badge cache %}
|
{% load gravatar card_badge cache i18n %}
|
||||||
|
|
||||||
{% cache CACHE_TIMEOUT trade_acceptance cache_key %}
|
{% cache CACHE_MEDIUM_TIMEOUT trade_acceptance cache_key %}
|
||||||
<div class="card card-border bg-base-100 shadow-lg max-w-90 mx-auto">
|
<div class="card card-border bg-base-100 shadow-lg max-w-90 mx-auto">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}">
|
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{% load gravatar card_badge cache %}
|
{% load gravatar card_badge cache %}
|
||||||
|
|
||||||
{% cache CACHE_TIMEOUT trade_offer cache_key %}
|
{% cache CACHE_MEDIUM_TIMEOUT "trade_offer" offer_pk %}
|
||||||
<div x-data="{ flipped: {{flipped|lower}}, offerExpanded: {{flipped|yesno:'false,true'}}, acceptanceExpanded: {{flipped|lower}} }" x-ref="tradeOffer" class="transition-all duration-500 trade-offer-card">
|
<div x-data="{ flipped: {{flipped|lower}}, offerExpanded: {{flipped|yesno:'false,true'}}, acceptanceExpanded: {{flipped|lower}} }" x-ref="tradeOffer" class="transition-all duration-500 trade-offer-card">
|
||||||
<div class="flip-container">
|
<div class="flip-container">
|
||||||
<div class="flip-inner grid grid-cols-1 grid-rows-1 card bg-base-100 card-border shadow-lg w-90 transform transition-transform duration-500 ease-in-out{%if flipped %} rotate-y-180{% endif %}"
|
<div class="flip-inner grid grid-cols-1 grid-rows-1 card bg-base-100 card-border shadow-lg w-90 transform transition-transform duration-500 ease-in-out{%if flipped %} rotate-y-180{% endif %}"
|
||||||
|
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
{% load gravatar card_badge cache %}
|
|
||||||
|
|
||||||
{% cache 60 trade_offer offer_pk %}
|
|
||||||
<div class="trade-offer-card m-2 h-full w-auto flex justify-center">
|
|
||||||
<div x-data="tradeOfferCard()" x-init="defaultExpanded = {{expanded|lower}}; badgeExpanded = defaultExpanded; acceptanceExpanded = defaultExpanded; flipped = {{flipped|lower}}" class="transition-all duration-500 trade-offer-card my-auto"
|
|
||||||
@toggle-all.window="setBadge($event.detail.expanded)">
|
|
||||||
|
|
||||||
<!-- Flip container providing perspective -->
|
|
||||||
<div class="flip-container" style="perspective: 1000px;">
|
|
||||||
<!--
|
|
||||||
The rotating element (.flip-inner) now uses CSS Grid to stack its children in a single cell.
|
|
||||||
Persistent border, shadow, and rounding are applied here and the card rotates entirely.
|
|
||||||
-->
|
|
||||||
<div class="flip-inner freeze-bg-color grid grid-cols-1 grid-rows-1 card bg-base-100 card-border shadow-lg w-84 transform transition-transform duration-700 ease-in-out"
|
|
||||||
:class="{'rotate-y-180': flipped}">
|
|
||||||
|
|
||||||
<!-- Front Face: Trade Offer -->
|
|
||||||
<!-- Using grid placement classes (col-start-1 row-start-1) ensures both faces overlap -->
|
|
||||||
<div class="flip-face front col-start-1 row-start-1 grid grid-cols-1 auto-rows-min gap-2 content-between">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flip-face-header self-start">
|
|
||||||
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline block">
|
|
||||||
<!-- Set this container as relative to position the avatar absolutely -->
|
|
||||||
<div class="relative mt-6 mb-4 mx-2 sm:mx-4">
|
|
||||||
<!-- Two-column grid for the labels -->
|
|
||||||
<div class="grid grid-cols-2 items-center">
|
|
||||||
<span class="text-sm font-semibold text-center">Has</span>
|
|
||||||
<span class="text-sm font-semibold text-center">Wants</span>
|
|
||||||
</div>
|
|
||||||
<!-- The avatar is placed absolutely and centered -->
|
|
||||||
<div class="absolute inset-x-0 top-1/2 transform -translate-y-1/2 flex justify-center">
|
|
||||||
<div class="avatar">
|
|
||||||
<div class="w-10 rounded-full">
|
|
||||||
{{ initiated_by_email|gravatar:40 }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<!-- Main Trade Offer Row -->
|
|
||||||
<div class="flip-face-body self-start">
|
|
||||||
{% if not flipped %}
|
|
||||||
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline block">
|
|
||||||
<div class="px-2 main-badges pb-0">
|
|
||||||
<!-- Normal mode: just use an outer grid with 2 columns -->
|
|
||||||
<div class="flex flex-row gap-2 justify-around">
|
|
||||||
<!-- Has Side -->
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{% for card in have_cards_available|slice:"0:1" %}
|
|
||||||
{% card_badge card.card card.quantity %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<!-- Wants Side -->
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{% for card in want_cards_available|slice:"0:1" %}
|
|
||||||
{% card_badge card.card card.quantity %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<div class="flex justify-center mt-8">
|
|
||||||
<div class="text-sm">
|
|
||||||
All cards have been accepted.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<!-- Extra Card Badges (Collapsible) -->
|
|
||||||
<div x-show="badgeExpanded" x-collapse.duration.500ms x-cloak class="px-2 extra-badges">
|
|
||||||
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline block">
|
|
||||||
<div class="flex flex-row gap-2 justify-around">
|
|
||||||
<!-- Has Side Extra Badges -->
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{% for card in have_cards_available|slice:"1:" %}
|
|
||||||
{% card_badge card.card card.quantity %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<!-- Wants Side Extra Badges -->
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{% for card in want_cards_available|slice:"1:" %}
|
|
||||||
{% card_badge card.card card.quantity %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if have_cards_available|length > 1 or want_cards_available|length > 1 %}
|
|
||||||
<div @click="badgeExpanded = !badgeExpanded" class="flex justify-center h-5 cursor-pointer">
|
|
||||||
<svg x-bind:class="{ 'rotate-180': badgeExpanded }"
|
|
||||||
class="transition-transform duration-500 h-5 w-5 cursor-pointer"
|
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="h-5"></div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="flip-face-footer self-end">
|
|
||||||
<div class="flex justify-between px-2 pb-2">
|
|
||||||
<div class="text-gray-500 text-sm tooltip tooltip-right" data-tip="ID: {{ offer_hash }}">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
|
||||||
stroke="currentColor" class="size-5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<!-- Front-to-back flip button -->
|
|
||||||
<div class="cursor-pointer text-gray-500" @click="flipped = true; acceptanceExpanded = defaultExpanded">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8.689c0-.864.933-1.406 1.683-.977l7.108 4.061a1.125 1.125 0 0 1 0 1.954l-7.108 4.061A1.125 1.125 0 0 1 3 16.811V8.69ZM12.75 8.689c0-.864.933-1.406 1.683-.977l7.108 4.061a1.125 1.125 0 0 1 0 1.954l-7.108 4.061a1.125 1.125 0 0 1-1.683-.977V8.69Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Back Face: Acceptances View -->
|
|
||||||
<!-- Placed in the same grid cell as the front face -->
|
|
||||||
<div class="flip-face back col-start-1 row-start-1 grid grid-cols-1 auto-rows-min gap-2 content-between" style="transform: rotateY(180deg);">
|
|
||||||
<div class="self-start">
|
|
||||||
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline">
|
|
||||||
<div class="py-4 mx-2 sm:mx-4">
|
|
||||||
<div class="grid grid-cols-3 items-center">
|
|
||||||
<div class="flex justify-center items-center">
|
|
||||||
<span class="text-sm font-semibold">Has</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center items-center">
|
|
||||||
<div class="avatar">
|
|
||||||
<div class="w-10 rounded-full">
|
|
||||||
{{ initiated_by_email|gravatar:40 }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center items-center">
|
|
||||||
<span class="text-sm font-semibold">Wants</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="self-start">
|
|
||||||
<div class="px-2 pb-0">
|
|
||||||
<div class="overflow-hidden">
|
|
||||||
{% if acceptances.0 %}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{% with acceptance=acceptances.0 %}
|
|
||||||
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline"
|
|
||||||
data-tooltip-html='<div class="flex items-center space-x-2">
|
|
||||||
<div class="avatar">
|
|
||||||
<div class="w-10 rounded-full">
|
|
||||||
{{ acceptance.accepted_by.user.email|gravatar:"40" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="text-sm">Accepted by: {{ acceptance.accepted_by.user.username }}</span>
|
|
||||||
<span class="text-sm">State: {{ acceptance.state }}</span>
|
|
||||||
<span class="text-sm">Acceptance ID: {{ acceptance.hash }}</span>
|
|
||||||
</div>
|
|
||||||
</div>'>
|
|
||||||
<div class="grid grid-cols-2 gap-4 items-center">
|
|
||||||
<div>
|
|
||||||
{% card_badge acceptance.requested_card %}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{% card_badge acceptance.offered_card %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div x-show="acceptanceExpanded" x-collapse.duration.500ms class="space-y-3">
|
|
||||||
{% for acceptance in acceptances|slice:"1:" %}
|
|
||||||
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline"
|
|
||||||
data-tooltip-html='<div class="flex items-center space-x-2">
|
|
||||||
<div class="avatar">
|
|
||||||
<div class="w-10 rounded-full">
|
|
||||||
{{ acceptance.accepted_by.user.email|gravatar:"40" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="text-sm">Accepted by: {{ acceptance.accepted_by.user.username }}</span>
|
|
||||||
<span class="text-sm">State: {{ acceptance.state }}</span>
|
|
||||||
<span class="text-sm">Acceptance ID: {{ acceptance.hash }}</span>
|
|
||||||
</div>
|
|
||||||
</div>'>
|
|
||||||
<div class="grid grid-cols-2 gap-4 items-center">
|
|
||||||
<div>
|
|
||||||
{% card_badge acceptance.requested_card %}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{% card_badge acceptance.offered_card %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center h-5">
|
|
||||||
{% if acceptances|length > 1 %}
|
|
||||||
<svg @click="acceptanceExpanded = !acceptanceExpanded"
|
|
||||||
x-bind:class="{ 'rotate-180': acceptanceExpanded }"
|
|
||||||
class="transition-transform duration-500 h-5 w-5 cursor-pointer"
|
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between px-2 pb-2 self-end">
|
|
||||||
<!-- Back-to-front flip button -->
|
|
||||||
<div class="text-gray-500 cursor-pointer" @click="flipped = false; badgeExpanded = defaultExpanded">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 16.811c0 .864-.933 1.406-1.683.977l-7.108-4.061a1.125 1.125 0 0 1 0-1.954l7.108-4.061A1.125 1.125 0 0 1 21 8.689v8.122ZM11.25 16.811c0 .864-.933 1.406-1.683.977l-7.108-4.061a1.125 1.125 0 0 1 0-1.954l7.108-4.061a1.125 1.125 0 0 1 1.683.977v8.122Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="px-1 text-center">
|
|
||||||
<span class="text-sm font-semibold">
|
|
||||||
Acceptances ({{ acceptances|length }})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-gray-500 text-sm tooltip tooltip-left" data-tip="ID: {{ offer_hash }}">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
|
||||||
stroke="currentColor" class="size-5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<style>
|
|
||||||
/* Ensure proper 3D transformations on the rotating element */
|
|
||||||
.flip-inner {
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
}
|
|
||||||
/* Hide the back face of each card side */
|
|
||||||
.flip-face {
|
|
||||||
backface-visibility: hidden;
|
|
||||||
-webkit-backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
/* The front face is unrotated by default */
|
|
||||||
.flip-face.front {
|
|
||||||
transform: rotateY(0);
|
|
||||||
}
|
|
||||||
/* The .rotate-y-180 class rotates the entire element by 180deg */
|
|
||||||
.rotate-y-180 {
|
|
||||||
transform: rotateY(180deg);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endcache %}
|
|
||||||
|
|
@ -54,7 +54,12 @@
|
||||||
<div class="flex flex-row justify-around">
|
<div class="flex flex-row justify-around">
|
||||||
<!-- Has Side (inner grid of 2 columns) -->
|
<!-- Has Side (inner grid of 2 columns) -->
|
||||||
<div class="grid grid-cols-2 content-start gap-1">
|
<div class="grid grid-cols-2 content-start gap-1">
|
||||||
{% for card in have_cards_available %} {% card_badge card.card card.quantity %} {% empty %}
|
{% for card in have_cards_available %}
|
||||||
|
<div class="flex flex-col content-start gap-1">
|
||||||
|
<div class="font-bold">{{ card.card.rarity.icon }}</div>
|
||||||
|
{% card_badge card.card card.quantity %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
<div class="text-xs text-center mb-2 ms-3 col-span-2">
|
<div class="text-xs text-center mb-2 ms-3 col-span-2">
|
||||||
None left.
|
None left.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -66,8 +71,15 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- Wants Side (inner grid of 2 columns) -->
|
<!-- Wants Side (inner grid of 2 columns) -->
|
||||||
<div class="grid grid-cols-2 content-start gap-1">
|
<div class="grid grid-cols-2 content-start gap-1">
|
||||||
{% for card in want_cards_available %} {% card_badge card.card card.quantity %} {% empty %}
|
{% for card in want_cards_available %}
|
||||||
<div class="text-xs text-center mb-2 ms-3 col-span-2">
|
<div class="flex flex-col content-start gap-1">
|
||||||
|
<div class="font-bold">{{ card.card.rarity.icon }}</div>
|
||||||
|
{% card_badge card.card card.quantity %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div
|
||||||
|
class="text-xs text-center mb-2{% if expanded %} ms-8{% else %} me-4{% endif %}"
|
||||||
|
>
|
||||||
None left.
|
None left.
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -78,7 +90,12 @@
|
||||||
<div class="flex flex-row gap-1 justify-around">
|
<div class="flex flex-row gap-1 justify-around">
|
||||||
<!-- Has Side -->
|
<!-- Has Side -->
|
||||||
<div class="flex flex-col content-start gap-1">
|
<div class="flex flex-col content-start gap-1">
|
||||||
{% for card in have_cards_available %} {% card_badge card.card card.quantity %} {% empty %}
|
{% for card in have_cards_available %}
|
||||||
|
<div class="flex flex-col content-start gap-1">
|
||||||
|
<div class="font-bold">{{ card.card.rarity.icon }}</div>
|
||||||
|
{% card_badge card.card card.quantity %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
<div class="text-xs text-center mb-2 ms-3">
|
<div class="text-xs text-center mb-2 ms-3">
|
||||||
None left.
|
None left.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,7 +103,12 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- Wants Side -->
|
<!-- Wants Side -->
|
||||||
<div class="flex flex-col content-start gap-1">
|
<div class="flex flex-col content-start gap-1">
|
||||||
{% for card in want_cards_available %} {% card_badge card.card card.quantity %} {% empty %}
|
{% for card in want_cards_available %}
|
||||||
|
<div class="flex flex-col content-start gap-1">
|
||||||
|
<div class="font-bold">{{ card.card.rarity.icon }}</div>
|
||||||
|
{% card_badge card.card card.quantity %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
<div
|
<div
|
||||||
class="text-xs text-center mb-2{% if expanded %} ms-8{% else %} me-4{% endif %}"
|
class="text-xs text-center mb-2{% if expanded %} ms-8{% else %} me-4{% endif %}"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,107 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import TradeOffer, TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance
|
|
||||||
|
|
||||||
admin.site.register(TradeOffer)
|
from .models import TradeAcceptance, TradeOffer, TradeOfferHaveCard, TradeOfferWantCard
|
||||||
admin.site.register(TradeOfferHaveCard)
|
|
||||||
admin.site.register(TradeOfferWantCard)
|
|
||||||
admin.site.register(TradeAcceptance)
|
@admin.register(TradeOffer)
|
||||||
|
class TradeOfferAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"hash",
|
||||||
|
"initiated_by",
|
||||||
|
"rarity_icon",
|
||||||
|
"rarity_level",
|
||||||
|
"is_closed",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
list_filter = ("is_closed", "rarity_level")
|
||||||
|
search_fields = ("hash", "initiated_by__friend_code", "initiated_by__in_game_name")
|
||||||
|
readonly_fields = ("hash", "created_at", "updated_at", "image")
|
||||||
|
list_select_related = ("initiated_by__user",)
|
||||||
|
autocomplete_fields = ("initiated_by",)
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
# The default manager already includes significant prefetching.
|
||||||
|
return super().get_queryset(request)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TradeOfferHaveCard)
|
||||||
|
class TradeOfferHaveCardAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "trade_offer", "card", "quantity", "qty_accepted")
|
||||||
|
search_fields = ("trade_offer__hash", "card__translations__name")
|
||||||
|
autocomplete_fields = ("trade_offer", "card")
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return (
|
||||||
|
super()
|
||||||
|
.get_queryset(request)
|
||||||
|
.select_related(
|
||||||
|
"trade_offer",
|
||||||
|
"card__cardset",
|
||||||
|
"card__rarity",
|
||||||
|
)
|
||||||
|
.prefetch_related("card__translations")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TradeOfferWantCard)
|
||||||
|
class TradeOfferWantCardAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "trade_offer", "card", "quantity", "qty_accepted")
|
||||||
|
search_fields = ("trade_offer__hash", "card__translations__name")
|
||||||
|
autocomplete_fields = ("trade_offer", "card")
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return (
|
||||||
|
super()
|
||||||
|
.get_queryset(request)
|
||||||
|
.select_related(
|
||||||
|
"trade_offer",
|
||||||
|
"card__cardset",
|
||||||
|
"card__rarity",
|
||||||
|
)
|
||||||
|
.prefetch_related("card__translations")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TradeAcceptance)
|
||||||
|
class TradeAcceptanceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"hash",
|
||||||
|
"trade_offer",
|
||||||
|
"accepted_by",
|
||||||
|
"state",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
list_filter = ("state",)
|
||||||
|
search_fields = (
|
||||||
|
"hash",
|
||||||
|
"trade_offer__hash",
|
||||||
|
"accepted_by__friend_code",
|
||||||
|
"accepted_by__in_game_name",
|
||||||
|
)
|
||||||
|
readonly_fields = ("hash", "created_at", "updated_at")
|
||||||
|
autocomplete_fields = (
|
||||||
|
"trade_offer",
|
||||||
|
"accepted_by",
|
||||||
|
"requested_card",
|
||||||
|
"offered_card",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return (
|
||||||
|
super()
|
||||||
|
.get_queryset(request)
|
||||||
|
.select_related(
|
||||||
|
"trade_offer",
|
||||||
|
"accepted_by__user",
|
||||||
|
"requested_card",
|
||||||
|
"offered_card",
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
"requested_card__translations",
|
||||||
|
"offered_card__translations",
|
||||||
|
"trade_offer__want_cards__translations",
|
||||||
|
"trade_offer__have_cards__translations",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ class TradesConfig(AppConfig):
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
# Implicitly connect signal handlers decorated with @receiver.
|
# Implicitly connect signal handlers decorated with @receiver.
|
||||||
import pkmntrade_club.trades.signals
|
pass
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.forms import ModelForm
|
||||||
from .models import TradeOffer, TradeAcceptance
|
|
||||||
from pkmntrade_club.accounts.models import FriendCode
|
from pkmntrade_club.accounts.models import FriendCode
|
||||||
from pkmntrade_club.cards.models import Card
|
from pkmntrade_club.cards.models import Card
|
||||||
from django.forms import ModelForm
|
|
||||||
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard
|
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard
|
||||||
|
|
||||||
|
from .models import TradeAcceptance, TradeOffer
|
||||||
|
|
||||||
|
|
||||||
class NoValidationMultipleChoiceField(forms.MultipleChoiceField):
|
class NoValidationMultipleChoiceField(forms.MultipleChoiceField):
|
||||||
def validate(self, value):
|
def validate(self, value):
|
||||||
# Override the validation to skip checking against defined choices
|
# Override the validation to skip checking against defined choices
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferAcceptForm(forms.Form):
|
class TradeOfferAcceptForm(forms.Form):
|
||||||
friend_code = forms.ModelChoiceField(
|
friend_code = forms.ModelChoiceField(
|
||||||
queryset=FriendCode.objects.none(),
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
@ -23,6 +26,7 @@ class TradeOfferAcceptForm(forms.Form):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["friend_code"].queryset = friend_codes
|
self.fields["friend_code"].queryset = friend_codes
|
||||||
|
|
||||||
|
|
||||||
class TradeAcceptanceCreateForm(forms.ModelForm):
|
class TradeAcceptanceCreateForm(forms.ModelForm):
|
||||||
"""
|
"""
|
||||||
Form for creating a TradeAcceptance.
|
Form for creating a TradeAcceptance.
|
||||||
|
|
@ -32,11 +36,19 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
|
||||||
- default_friend_code (optional): the user's default FriendCode.
|
- default_friend_code (optional): the user's default FriendCode.
|
||||||
It filters available requested and offered cards based on what's still available.
|
It filters available requested and offered cards based on what's still available.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TradeAcceptance
|
model = TradeAcceptance
|
||||||
fields = ["accepted_by", "requested_card", "offered_card"]
|
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:
|
if trade_offer is None:
|
||||||
raise ValueError("trade_offer must be provided to filter choices.")
|
raise ValueError("trade_offer must be provided to filter choices.")
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
@ -52,16 +64,23 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
|
||||||
self.initial["accepted_by"] = friend_codes.first().pk
|
self.initial["accepted_by"] = friend_codes.first().pk
|
||||||
self.fields["accepted_by"].widget = forms.HiddenInput()
|
self.fields["accepted_by"].widget = forms.HiddenInput()
|
||||||
# Otherwise, if a default friend code is provided and it is in the queryset, preselect it.
|
# 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
|
self.initial["accepted_by"] = default_friend_code.pk
|
||||||
|
|
||||||
available_have_items = trade_offer.have_cards_available
|
available_have_items = trade_offer.have_cards_available
|
||||||
requested_card_pks = [item.card.pk for item in available_have_items]
|
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
|
available_want_items = trade_offer.want_cards_available
|
||||||
offered_card_pks = [item.card.pk for item in available_want_items]
|
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):
|
def clean(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -71,9 +90,11 @@ class TradeAcceptanceCreateForm(forms.ModelForm):
|
||||||
self.instance.trade_offer = self.trade_offer
|
self.instance.trade_offer = self.trade_offer
|
||||||
return super().clean()
|
return super().clean()
|
||||||
|
|
||||||
|
|
||||||
class ButtonRadioSelect(forms.RadioSelect):
|
class ButtonRadioSelect(forms.RadioSelect):
|
||||||
template_name = "widgets/button_radio_select.html"
|
template_name = "widgets/button_radio_select.html"
|
||||||
|
|
||||||
|
|
||||||
class TradeAcceptanceTransitionForm(forms.Form):
|
class TradeAcceptanceTransitionForm(forms.Form):
|
||||||
state = forms.ChoiceField(widget=forms.HiddenInput())
|
state = forms.ChoiceField(widget=forms.HiddenInput())
|
||||||
|
|
||||||
|
|
@ -90,10 +111,15 @@ class TradeAcceptanceTransitionForm(forms.Form):
|
||||||
|
|
||||||
self.fields["state"].choices = instance.get_allowed_state_transitions(user)
|
self.fields["state"].choices = instance.get_allowed_state_transitions(user)
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferCreateForm(ModelForm):
|
class TradeOfferCreateForm(ModelForm):
|
||||||
# Override the default fields to capture quantity info in the format 'card_id:quantity'
|
# Override the default fields to capture quantity info in the format 'card_id:quantity'
|
||||||
have_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True)
|
have_cards = NoValidationMultipleChoiceField(
|
||||||
want_cards = NoValidationMultipleChoiceField(widget=forms.SelectMultiple, required=True)
|
widget=forms.SelectMultiple, required=True
|
||||||
|
)
|
||||||
|
want_cards = NoValidationMultipleChoiceField(
|
||||||
|
widget=forms.SelectMultiple, required=True
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TradeOffer
|
model = TradeOffer
|
||||||
|
|
@ -102,7 +128,7 @@ class TradeOfferCreateForm(ModelForm):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Populate choices from Card model using the new field 'rarity_level' instead of the removed relation.
|
# Populate choices from Card model using the new field 'rarity_level' instead of the removed relation.
|
||||||
cards = Card.objects.order_by("name", "rarity_level")
|
cards = Card.objects.order_by("name", "rarity__level")
|
||||||
choices = [(str(card.pk), card.name) for card in cards]
|
choices = [(str(card.pk), card.name) for card in cards]
|
||||||
self.fields["have_cards"].choices = choices
|
self.fields["have_cards"].choices = choices
|
||||||
self.fields["want_cards"].choices = choices
|
self.fields["want_cards"].choices = choices
|
||||||
|
|
@ -111,10 +137,10 @@ class TradeOfferCreateForm(ModelForm):
|
||||||
data = self.data.getlist("have_cards")
|
data = self.data.getlist("have_cards")
|
||||||
parsed = {}
|
parsed = {}
|
||||||
for item in data:
|
for item in data:
|
||||||
if ':' not in item:
|
if ":" not in item:
|
||||||
# Ignore any input without a colon.
|
# Ignore any input without a colon.
|
||||||
continue
|
continue
|
||||||
parts = item.split(':')
|
parts = item.split(":")
|
||||||
card_id = parts[0]
|
card_id = parts[0]
|
||||||
try:
|
try:
|
||||||
# Only parse quantity when a colon is present.
|
# Only parse quantity when a colon is present.
|
||||||
|
|
@ -131,16 +157,18 @@ class TradeOfferCreateForm(ModelForm):
|
||||||
)
|
)
|
||||||
# Ensure no more than 20 unique have cards are selected.
|
# Ensure no more than 20 unique have cards are selected.
|
||||||
if len(parsed) > 20:
|
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
|
return parsed
|
||||||
|
|
||||||
def clean_want_cards(self):
|
def clean_want_cards(self):
|
||||||
data = self.data.getlist("want_cards")
|
data = self.data.getlist("want_cards")
|
||||||
parsed = {}
|
parsed = {}
|
||||||
for item in data:
|
for item in data:
|
||||||
if ':' not in item:
|
if ":" not in item:
|
||||||
continue
|
continue
|
||||||
parts = item.split(':')
|
parts = item.split(":")
|
||||||
card_id = parts[0]
|
card_id = parts[0]
|
||||||
try:
|
try:
|
||||||
quantity = int(parts[1])
|
quantity = int(parts[1])
|
||||||
|
|
@ -157,7 +185,9 @@ class TradeOfferCreateForm(ModelForm):
|
||||||
)
|
)
|
||||||
# Ensure no more than 20 unique want cards are selected.
|
# Ensure no more than 20 unique want cards are selected.
|
||||||
if len(parsed) > 20:
|
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
|
return parsed
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
|
|
@ -171,11 +201,15 @@ class TradeOfferCreateForm(ModelForm):
|
||||||
# Create through entries for have_cards
|
# Create through entries for have_cards
|
||||||
for card_id, quantity in self.cleaned_data["have_cards"].items():
|
for card_id, quantity in self.cleaned_data["have_cards"].items():
|
||||||
card = Card.objects.get(pk=card_id)
|
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
|
# Create through entries for want_cards
|
||||||
for card_id, quantity in self.cleaned_data["want_cards"].items():
|
for card_id, quantity in self.cleaned_data["want_cards"].items():
|
||||||
card = Card.objects.get(pk=card_id)
|
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
|
return instance
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated by Django 5.1 on 2025-05-10 01:22
|
# Generated by Django 5.1 on 2025-06-15 03:44
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
@ -9,75 +9,191 @@ class Migration(migrations.Migration):
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('accounts', '0001_initial'),
|
("accounts", "0001_initial"),
|
||||||
('cards', '0001_initial'),
|
("cards", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TradeOffer',
|
name="TradeOffer",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
('is_closed', models.BooleanField(db_index=True, default=False)),
|
("is_closed", models.BooleanField(db_index=True, default=False)),
|
||||||
('hash', models.CharField(editable=False, max_length=9)),
|
("hash", models.CharField(editable=False, max_length=9)),
|
||||||
('rarity_icon', models.CharField(max_length=8, null=True)),
|
("rarity_icon", models.CharField(max_length=8, null=True)),
|
||||||
('rarity_level', models.IntegerField(null=True)),
|
("rarity_level", models.IntegerField(null=True)),
|
||||||
('image', models.ImageField(blank=True, null=True, upload_to='trade_offers/')),
|
(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
"image",
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
models.ImageField(blank=True, null=True, upload_to="trade_offers/"),
|
||||||
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='initiated_trade_offers', to='accounts.friendcode')),
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"initiated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="initiated_trade_offers",
|
||||||
|
to="accounts.friendcode",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TradeAcceptance',
|
name="TradeAcceptance",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('state', models.CharField(choices=[('ACCEPTED', 'Accepted'), ('SENT', 'Sent'), ('RECEIVED', 'Received'), ('THANKED_BY_INITIATOR', 'Thanked by Initiator'), ('THANKED_BY_ACCEPTOR', 'Thanked by Acceptor'), ('THANKED_BY_BOTH', 'Thanked by Both'), ('REJECTED_BY_INITIATOR', 'Rejected by Initiator'), ('REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor')], default='ACCEPTED', max_length=25)),
|
"id",
|
||||||
('hash', models.CharField(blank=True, editable=False, max_length=9)),
|
models.BigAutoField(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
auto_created=True,
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
primary_key=True,
|
||||||
('accepted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='trade_acceptances', to='accounts.friendcode')),
|
serialize=False,
|
||||||
('offered_card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accepted_offered', to='cards.card')),
|
verbose_name="ID",
|
||||||
('requested_card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accepted_requested', to='cards.card')),
|
),
|
||||||
('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='acceptances', to='trades.tradeoffer')),
|
),
|
||||||
|
(
|
||||||
|
"state",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("ACCEPTED", "Accepted"),
|
||||||
|
("SENT", "Sent"),
|
||||||
|
("RECEIVED", "Received"),
|
||||||
|
("THANKED_BY_INITIATOR", "Thanked by Initiator"),
|
||||||
|
("THANKED_BY_ACCEPTOR", "Thanked by Acceptor"),
|
||||||
|
("THANKED_BY_BOTH", "Thanked by Both"),
|
||||||
|
("REJECTED_BY_INITIATOR", "Rejected by Initiator"),
|
||||||
|
("REJECTED_BY_ACCEPTOR", "Rejected by Acceptor"),
|
||||||
|
],
|
||||||
|
default="ACCEPTED",
|
||||||
|
max_length=25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("hash", models.CharField(blank=True, editable=False, max_length=9)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"accepted_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="trade_acceptances",
|
||||||
|
to="accounts.friendcode",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"offered_card",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="accepted_offered",
|
||||||
|
to="cards.card",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"requested_card",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="accepted_requested",
|
||||||
|
to="cards.card",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"trade_offer",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="acceptances",
|
||||||
|
to="trades.tradeoffer",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TradeOfferHaveCard',
|
name="TradeOfferHaveCard",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('quantity', models.PositiveIntegerField(default=1)),
|
"id",
|
||||||
('qty_accepted', models.PositiveIntegerField(default=0, editable=False)),
|
models.BigAutoField(
|
||||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cards.card')),
|
auto_created=True,
|
||||||
('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trade_offer_have_cards', to='trades.tradeoffer')),
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("quantity", models.PositiveIntegerField(default=1)),
|
||||||
|
(
|
||||||
|
"qty_accepted",
|
||||||
|
models.PositiveIntegerField(default=0, editable=False),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"card",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT, to="cards.card"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"trade_offer",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="trade_offer_have_cards",
|
||||||
|
to="trades.tradeoffer",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['card__name'],
|
"ordering": ["card__translations__name"],
|
||||||
'unique_together': {('trade_offer', 'card')},
|
"unique_together": {("trade_offer", "card")},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='tradeoffer',
|
model_name="tradeoffer",
|
||||||
name='have_cards',
|
name="have_cards",
|
||||||
field=models.ManyToManyField(related_name='trade_offers_have', through='trades.TradeOfferHaveCard', to='cards.card'),
|
field=models.ManyToManyField(
|
||||||
|
related_name="trade_offers_have",
|
||||||
|
through="trades.TradeOfferHaveCard",
|
||||||
|
to="cards.card",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TradeOfferWantCard',
|
name="TradeOfferWantCard",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('quantity', models.PositiveIntegerField(default=1)),
|
"id",
|
||||||
('qty_accepted', models.PositiveIntegerField(default=0, editable=False)),
|
models.BigAutoField(
|
||||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cards.card')),
|
auto_created=True,
|
||||||
('trade_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trade_offer_want_cards', to='trades.tradeoffer')),
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("quantity", models.PositiveIntegerField(default=1)),
|
||||||
|
(
|
||||||
|
"qty_accepted",
|
||||||
|
models.PositiveIntegerField(default=0, editable=False),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"card",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT, to="cards.card"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"trade_offer",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="trade_offer_want_cards",
|
||||||
|
to="trades.tradeoffer",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['card__name'],
|
"ordering": ["card__translations__name"],
|
||||||
'unique_together': {('trade_offer', 'card')},
|
"unique_together": {("trade_offer", "card")},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='tradeoffer',
|
model_name="tradeoffer",
|
||||||
name='want_cards',
|
name="want_cards",
|
||||||
field=models.ManyToManyField(related_name='trade_offers_want', through='trades.TradeOfferWantCard', to='cards.card'),
|
field=models.ManyToManyField(
|
||||||
|
related_name="trade_offers_want",
|
||||||
|
through="trades.TradeOfferWantCard",
|
||||||
|
to="cards.card",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
from pkmntrade_club.cards.models import Card
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferContextMixin:
|
class TradeOfferContextMixin:
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
# Start with any context passed in.
|
# Start with any context passed in.
|
||||||
context = kwargs.copy()
|
context = kwargs.copy()
|
||||||
# Include available cards requirements for multiselect fields.
|
# Include available cards requirements for multiselect fields.
|
||||||
context.setdefault("cards", Card.objects.all().order_by("name", "rarity_level"))
|
context.setdefault(
|
||||||
|
"cards", Card.objects.all().order_by("name", "rarity__level")
|
||||||
|
)
|
||||||
|
|
||||||
# Provide friend_codes and selected_friend_code as in TradeOfferCreateView
|
# Provide friend_codes and selected_friend_code as in TradeOfferCreateView
|
||||||
friend_codes = self.request.user.friend_codes.all()
|
friend_codes = self.request.user.friend_codes.all()
|
||||||
|
|
@ -14,24 +18,35 @@ class TradeOfferContextMixin:
|
||||||
|
|
||||||
if "initiated_by" in self.request.GET:
|
if "initiated_by" in self.request.GET:
|
||||||
try:
|
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:
|
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:
|
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
|
context["selected_friend_code"] = selected_friend_code
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class FriendCodeRequiredMixin:
|
class FriendCodeRequiredMixin:
|
||||||
"""
|
"""
|
||||||
Mixin to ensure the authenticated user has at least one friend code.
|
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.
|
This mixin must be placed after LoginRequiredMixin in the view's inheritance order.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
# Since LoginRequiredMixin guarantees that request.user is authenticated,
|
# Since LoginRequiredMixin guarantees that request.user is authenticated,
|
||||||
# we assume request.user has the attribute `friend_codes`. If no friend code exists,
|
# we assume request.user has the attribute `friend_codes`. If no friend code exists,
|
||||||
# raise a PermissionDenied error.
|
# 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.")
|
raise PermissionDenied("No friend codes available for your account.")
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
from django.db import models
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.db.models import Q, Count, Prefetch, F, Sum, Max
|
|
||||||
import hashlib
|
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
|
import uuid
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import F, Prefetch
|
||||||
|
|
||||||
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
|
|
||||||
def generate_tradeoffer_hash():
|
def generate_tradeoffer_hash():
|
||||||
"""
|
"""
|
||||||
Generates a unique 9-character hash for a TradeOffer.
|
Generates a unique 9-character hash for a TradeOffer.
|
||||||
|
|
@ -15,6 +15,7 @@ def generate_tradeoffer_hash():
|
||||||
"""
|
"""
|
||||||
return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "z"
|
return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "z"
|
||||||
|
|
||||||
|
|
||||||
def generate_tradeacceptance_hash():
|
def generate_tradeacceptance_hash():
|
||||||
"""
|
"""
|
||||||
Generates a unique 9-character hash for a TradeAcceptance.
|
Generates a unique 9-character hash for a TradeAcceptance.
|
||||||
|
|
@ -22,34 +23,57 @@ def generate_tradeacceptance_hash():
|
||||||
"""
|
"""
|
||||||
return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "y"
|
return hashlib.md5(uuid.uuid4().hex.encode("utf-8")).hexdigest()[:8] + "y"
|
||||||
|
|
||||||
class TradeOfferManager(models.Manager):
|
|
||||||
|
|
||||||
|
class TradeOfferManager(models.Manager):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
|
|
||||||
|
card_prefetch_selects = [
|
||||||
|
"card__rarity",
|
||||||
|
"card__cardset",
|
||||||
|
"card__card_type",
|
||||||
|
"card__pkmn_type",
|
||||||
|
"card__weakness_type",
|
||||||
|
]
|
||||||
|
card_prefetch_related = [
|
||||||
|
"card__translations",
|
||||||
|
"card__rarity__translations",
|
||||||
|
"card__cardset__translations",
|
||||||
|
"card__card_type__translations",
|
||||||
|
"card__pkmn_type__translations",
|
||||||
|
"card__weakness_type__translations",
|
||||||
|
"card__attacks__translations",
|
||||||
|
"card__abilities__translations",
|
||||||
|
]
|
||||||
|
|
||||||
# Prefetch for have_cards (through model: TradeOfferHaveCard)
|
# Prefetch for have_cards (through model: TradeOfferHaveCard)
|
||||||
# Ensures 'card' is select_related and 'Meta.ordering' is respected/applied.
|
|
||||||
prefetch_have_cards = Prefetch(
|
prefetch_have_cards = Prefetch(
|
||||||
'trade_offer_have_cards',
|
"trade_offer_have_cards",
|
||||||
queryset=TradeOfferHaveCard.objects.select_related('card').order_by('card__name')
|
queryset=TradeOfferHaveCard.objects.select_related(
|
||||||
|
*card_prefetch_selects
|
||||||
|
).prefetch_related(*card_prefetch_related),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prefetch for want_cards (through model: TradeOfferWantCard)
|
# Prefetch for want_cards (through model: TradeOfferWantCard)
|
||||||
# Ensures 'card' is select_related and 'Meta.ordering' is respected/applied.
|
|
||||||
prefetch_want_cards = Prefetch(
|
prefetch_want_cards = Prefetch(
|
||||||
'trade_offer_want_cards',
|
"trade_offer_want_cards",
|
||||||
queryset=TradeOfferWantCard.objects.select_related('card').order_by('card__name')
|
queryset=TradeOfferWantCard.objects.select_related(
|
||||||
|
*card_prefetch_selects
|
||||||
|
).prefetch_related(*card_prefetch_related),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prefetch for acceptances
|
# Prefetch for acceptances
|
||||||
# Ensures related 'accepted_by__user', 'requested_card', 'offered_card' are fetched.
|
|
||||||
prefetch_acceptances = Prefetch(
|
prefetch_acceptances = Prefetch(
|
||||||
'acceptances',
|
"acceptances",
|
||||||
queryset=TradeAcceptance.objects.select_related(
|
queryset=TradeAcceptance.objects.select_related(
|
||||||
'accepted_by__user',
|
"accepted_by__user",
|
||||||
'requested_card',
|
"requested_card__rarity",
|
||||||
'offered_card'
|
"requested_card__cardset",
|
||||||
).order_by('-created_at') # Sensible default ordering for acceptances
|
"offered_card__rarity",
|
||||||
|
"offered_card__cardset",
|
||||||
|
).prefetch_related(
|
||||||
|
"requested_card__translations", "offered_card__translations"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
qs = qs.select_related(
|
qs = qs.select_related(
|
||||||
|
|
@ -58,14 +82,20 @@ class TradeOfferManager(models.Manager):
|
||||||
prefetch_have_cards,
|
prefetch_have_cards,
|
||||||
prefetch_want_cards,
|
prefetch_want_cards,
|
||||||
prefetch_acceptances,
|
prefetch_acceptances,
|
||||||
# If direct access like offer.have_cards.all() (the M2M to Card, not through model)
|
# If direct access like offer.have_cards.all() is used, prefetch cards with their rarity
|
||||||
# is heavily used AND causes N+1s (e.g. via __str__), uncomment these:
|
Prefetch(
|
||||||
Prefetch('have_cards'),
|
"have_cards",
|
||||||
Prefetch('want_cards'),
|
queryset=Card.objects.with_details(),
|
||||||
|
),
|
||||||
|
Prefetch(
|
||||||
|
"want_cards",
|
||||||
|
queryset=Card.objects.with_details(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return qs.order_by("-updated_at") # Default ordering for TradeOffer querysets
|
return qs.order_by("-updated_at") # Default ordering for TradeOffer querysets
|
||||||
|
|
||||||
|
|
||||||
class TradeOffer(models.Model):
|
class TradeOffer(models.Model):
|
||||||
objects = TradeOfferManager()
|
objects = TradeOfferManager()
|
||||||
|
|
||||||
|
|
@ -75,20 +105,16 @@ class TradeOffer(models.Model):
|
||||||
initiated_by = models.ForeignKey(
|
initiated_by = models.ForeignKey(
|
||||||
"accounts.FriendCode",
|
"accounts.FriendCode",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='initiated_trade_offers'
|
related_name="initiated_trade_offers",
|
||||||
)
|
)
|
||||||
rarity_icon = models.CharField(max_length=8, null=True)
|
rarity_icon = models.CharField(max_length=8, null=True)
|
||||||
rarity_level = models.IntegerField(null=True)
|
rarity_level = models.IntegerField(null=True)
|
||||||
image = models.ImageField(upload_to='trade_offers/', null=True, blank=True)
|
image = models.ImageField(upload_to="trade_offers/", null=True, blank=True)
|
||||||
want_cards = models.ManyToManyField(
|
want_cards = models.ManyToManyField(
|
||||||
"cards.Card",
|
"cards.Card", related_name="trade_offers_want", through="TradeOfferWantCard"
|
||||||
related_name='trade_offers_want',
|
|
||||||
through="TradeOfferWantCard"
|
|
||||||
)
|
)
|
||||||
have_cards = models.ManyToManyField(
|
have_cards = models.ManyToManyField(
|
||||||
"cards.Card",
|
"cards.Card", related_name="trade_offers_have", through="TradeOfferHaveCard"
|
||||||
related_name='trade_offers_have',
|
|
||||||
through="TradeOfferHaveCard"
|
|
||||||
)
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
@ -108,46 +134,59 @@ class TradeOffer(models.Model):
|
||||||
Recalculates and updates the rarity_level and rarity_icon fields based on
|
Recalculates and updates the rarity_level and rarity_icon fields based on
|
||||||
the associated have_cards and want_cards.
|
the associated have_cards and want_cards.
|
||||||
|
|
||||||
Enforces that all cards in the trade offer share the same rarity.
|
Enforces that all cards in a trade offer share the same rarity.
|
||||||
Uses the first card's rarity details to update both fields.
|
Uses the first card's rarity details to update both fields.
|
||||||
"""
|
"""
|
||||||
# Gather all cards from both sides.
|
# Gather all cards from both sides, using select_related to prevent N+1 queries on rarity.
|
||||||
cards = list(self.have_cards.all()) + list(self.want_cards.all())
|
cards = list(self.have_cards.select_related("rarity").all()) + list(
|
||||||
|
self.want_cards.select_related("rarity").all()
|
||||||
|
)
|
||||||
if not cards:
|
if not cards:
|
||||||
|
self.rarity_level = None
|
||||||
|
self.rarity_icon = None
|
||||||
|
super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"])
|
||||||
return
|
return
|
||||||
|
|
||||||
# Enforce same rarity across all cards.
|
# Enforce same rarity across all cards.
|
||||||
rarity_levels = {card.rarity_level for card in cards}
|
rarity_levels = {card.rarity.level for card in cards}
|
||||||
if len(rarity_levels) > 1:
|
if len(rarity_levels) > 1:
|
||||||
raise ValidationError("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]
|
first_card = cards[0]
|
||||||
if first_card.rarity_level > 5:
|
if first_card.rarity.level > 5:
|
||||||
raise ValidationError("Cannot trade cards above one-star rarity.")
|
raise ValidationError("Cannot trade cards above one-star rarity.")
|
||||||
if self.rarity_level != first_card.rarity_level or self.rarity_icon != first_card.rarity_icon:
|
if (
|
||||||
self.rarity_level = first_card.rarity_level
|
self.rarity_level != first_card.rarity.level
|
||||||
self.rarity_icon = first_card.rarity_icon
|
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.
|
# Use super().save() here to avoid recursion.
|
||||||
super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"])
|
super(TradeOffer, self).save(update_fields=["rarity_level", "rarity_icon"])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def have_cards_available(self):
|
def have_cards_available(self):
|
||||||
# Returns the list of have_cards (through objects) that still have available quantity.
|
# Returns a queryset of have_cards that still have available quantity.
|
||||||
return [item for item in self.trade_offer_have_cards.all() if item.quantity > item.qty_accepted]
|
return self.trade_offer_have_cards.filter(quantity__gt=F("qty_accepted"))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def want_cards_available(self):
|
def want_cards_available(self):
|
||||||
# Returns the list of want_cards (through objects) that still have available quantity.
|
# Returns a queryset of want_cards that still have available quantity.
|
||||||
return [item for item in self.trade_offer_want_cards.all() if item.quantity > item.qty_accepted]
|
return self.trade_offer_want_cards.filter(quantity__gt=F("qty_accepted"))
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferHaveCard(models.Model):
|
class TradeOfferHaveCard(models.Model):
|
||||||
"""
|
"""
|
||||||
Through model for TradeOffer.have_cards.
|
Through model for TradeOffer.have_cards.
|
||||||
Represents the card the initiator is offering along with the quantity available.
|
Represents the card the initiator is offering along with the quantity available.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
trade_offer = models.ForeignKey(
|
trade_offer = models.ForeignKey(
|
||||||
TradeOffer,
|
TradeOffer,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='trade_offer_have_cards',
|
related_name="trade_offer_have_cards",
|
||||||
db_index=True
|
db_index=True,
|
||||||
)
|
)
|
||||||
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True)
|
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT, db_index=True)
|
||||||
quantity = models.PositiveIntegerField(default=1)
|
quantity = models.PositiveIntegerField(default=1)
|
||||||
|
|
@ -158,7 +197,7 @@ class TradeOfferHaveCard(models.Model):
|
||||||
return self.quantity - self.qty_accepted
|
return self.quantity - self.qty_accepted
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity_icon} {self.card.name}"
|
return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity.icon} {self.card.name}"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
self.trade_offer.update_rarity_fields()
|
self.trade_offer.update_rarity_fields()
|
||||||
|
|
@ -171,17 +210,17 @@ class TradeOfferHaveCard(models.Model):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("trade_offer", "card")
|
unique_together = ("trade_offer", "card")
|
||||||
ordering = ['card__name']
|
ordering = ["card__translations__name"]
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferWantCard(models.Model):
|
class TradeOfferWantCard(models.Model):
|
||||||
"""
|
"""
|
||||||
Through model for TradeOffer.want_cards.
|
Through model for TradeOffer.want_cards.
|
||||||
Represents the card the initiator is requesting along with the quantity requested.
|
Represents the card the initiator is requesting along with the quantity requested.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
trade_offer = models.ForeignKey(
|
trade_offer = models.ForeignKey(
|
||||||
TradeOffer,
|
TradeOffer, on_delete=models.CASCADE, related_name="trade_offer_want_cards"
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='trade_offer_want_cards'
|
|
||||||
)
|
)
|
||||||
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT)
|
card = models.ForeignKey("cards.Card", on_delete=models.PROTECT)
|
||||||
quantity = models.PositiveIntegerField(default=1)
|
quantity = models.PositiveIntegerField(default=1)
|
||||||
|
|
@ -192,7 +231,7 @@ class TradeOfferWantCard(models.Model):
|
||||||
return self.quantity - self.qty_accepted
|
return self.quantity - self.qty_accepted
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity_icon} {self.card.name}"
|
return f"#{self.card.cardnum} {self.card.cardset} {self.card.rarity.icon} {self.card.name}"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
@ -205,18 +244,19 @@ class TradeOfferWantCard(models.Model):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("trade_offer", "card")
|
unique_together = ("trade_offer", "card")
|
||||||
ordering = ['card__name']
|
ordering = ["card__translations__name"]
|
||||||
|
|
||||||
|
|
||||||
class TradeAcceptance(models.Model):
|
class TradeAcceptance(models.Model):
|
||||||
class AcceptanceState(models.TextChoices):
|
class AcceptanceState(models.TextChoices):
|
||||||
ACCEPTED = 'ACCEPTED', 'Accepted'
|
ACCEPTED = "ACCEPTED", "Accepted"
|
||||||
SENT = 'SENT', 'Sent'
|
SENT = "SENT", "Sent"
|
||||||
RECEIVED = 'RECEIVED', 'Received'
|
RECEIVED = "RECEIVED", "Received"
|
||||||
THANKED_BY_INITIATOR = 'THANKED_BY_INITIATOR', 'Thanked by Initiator'
|
THANKED_BY_INITIATOR = "THANKED_BY_INITIATOR", "Thanked by Initiator"
|
||||||
THANKED_BY_ACCEPTOR = 'THANKED_BY_ACCEPTOR', 'Thanked by Acceptor'
|
THANKED_BY_ACCEPTOR = "THANKED_BY_ACCEPTOR", "Thanked by Acceptor"
|
||||||
THANKED_BY_BOTH = 'THANKED_BY_BOTH', 'Thanked by Both'
|
THANKED_BY_BOTH = "THANKED_BY_BOTH", "Thanked by Both"
|
||||||
REJECTED_BY_INITIATOR = 'REJECTED_BY_INITIATOR', 'Rejected by Initiator'
|
REJECTED_BY_INITIATOR = "REJECTED_BY_INITIATOR", "Rejected by Initiator"
|
||||||
REJECTED_BY_ACCEPTOR = 'REJECTED_BY_ACCEPTOR', 'Rejected by Acceptor'
|
REJECTED_BY_ACCEPTOR = "REJECTED_BY_ACCEPTOR", "Rejected by Acceptor"
|
||||||
|
|
||||||
# DRY improvement: define active states once as a class-level constant.
|
# DRY improvement: define active states once as a class-level constant.
|
||||||
POSITIVE_STATES = [
|
POSITIVE_STATES = [
|
||||||
|
|
@ -229,30 +269,21 @@ class TradeAcceptance(models.Model):
|
||||||
]
|
]
|
||||||
|
|
||||||
trade_offer = models.ForeignKey(
|
trade_offer = models.ForeignKey(
|
||||||
TradeOffer,
|
TradeOffer, on_delete=models.CASCADE, related_name="acceptances", db_index=True
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='acceptances',
|
|
||||||
db_index=True
|
|
||||||
)
|
)
|
||||||
accepted_by = models.ForeignKey(
|
accepted_by = models.ForeignKey(
|
||||||
"accounts.FriendCode",
|
"accounts.FriendCode",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='trade_acceptances'
|
related_name="trade_acceptances",
|
||||||
)
|
)
|
||||||
requested_card = models.ForeignKey(
|
requested_card = models.ForeignKey(
|
||||||
"cards.Card",
|
"cards.Card", on_delete=models.PROTECT, related_name="accepted_requested"
|
||||||
on_delete=models.PROTECT,
|
|
||||||
related_name='accepted_requested'
|
|
||||||
)
|
)
|
||||||
offered_card = models.ForeignKey(
|
offered_card = models.ForeignKey(
|
||||||
"cards.Card",
|
"cards.Card", on_delete=models.PROTECT, related_name="accepted_offered"
|
||||||
on_delete=models.PROTECT,
|
|
||||||
related_name='accepted_offered'
|
|
||||||
)
|
)
|
||||||
state = models.CharField(
|
state = models.CharField(
|
||||||
max_length=25,
|
max_length=25, choices=AcceptanceState.choices, default=AcceptanceState.ACCEPTED
|
||||||
choices=AcceptanceState.choices,
|
|
||||||
default=AcceptanceState.ACCEPTED
|
|
||||||
)
|
)
|
||||||
hash = models.CharField(max_length=9, editable=False, blank=True)
|
hash = models.CharField(max_length=9, editable=False, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
@ -307,12 +338,15 @@ class TradeAcceptance(models.Model):
|
||||||
return self.get_action_label_for_state(self.AcceptanceState.SENT)
|
return self.get_action_label_for_state(self.AcceptanceState.SENT)
|
||||||
elif self.state == self.AcceptanceState.SENT:
|
elif self.state == self.AcceptanceState.SENT:
|
||||||
return self.get_action_label_for_state(self.AcceptanceState.RECEIVED)
|
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)
|
return self.get_action_label_for_state(self.AcceptanceState.THANKED_BY_BOTH)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_action_label_for_state_2(cls, state_value):
|
def get_action_label_for_state_2(cls, state_value):
|
||||||
"""
|
"""
|
||||||
|
|
@ -331,12 +365,20 @@ class TradeAcceptance(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_initiator_state(self):
|
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
|
@property
|
||||||
def is_acceptor_state(self):
|
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
|
@property
|
||||||
def is_completed(self):
|
def is_completed(self):
|
||||||
|
|
@ -368,19 +410,30 @@ class TradeAcceptance(models.Model):
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
try:
|
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:
|
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:
|
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:
|
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).
|
# Only perform these validations on creation (when self.pk is None).
|
||||||
if self.pk is None:
|
if self.pk is None:
|
||||||
if self.trade_offer.is_closed:
|
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.
|
# Use direct comparison with qty_accepted and quantity.
|
||||||
if have_card.qty_accepted >= have_card.quantity:
|
if have_card.qty_accepted >= have_card.quantity:
|
||||||
raise ValidationError("The requested card has no available quantity.")
|
raise ValidationError("The requested card has no available quantity.")
|
||||||
|
|
@ -403,26 +456,42 @@ class TradeAcceptance(models.Model):
|
||||||
]:
|
]:
|
||||||
return 0
|
return 0
|
||||||
else:
|
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):
|
def update_state(self, new_state, user):
|
||||||
if new_state not in [choice[0] for choice in self.AcceptanceState.choices]:
|
if new_state not in [choice[0] for choice in self.AcceptanceState.choices]:
|
||||||
raise ValueError(f"'{new_state}' is not a valid state.")
|
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 \
|
if (
|
||||||
(new_state == self.AcceptanceState.THANKED_BY_INITIATOR and self.state == self.AcceptanceState.THANKED_BY_ACCEPTOR):
|
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
|
new_state = self.AcceptanceState.THANKED_BY_BOTH
|
||||||
|
|
||||||
if self.state in [
|
if self.state in [
|
||||||
self.AcceptanceState.THANKED_BY_BOTH,
|
self.AcceptanceState.THANKED_BY_BOTH,
|
||||||
self.AcceptanceState.REJECTED_BY_INITIATOR,
|
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)]
|
allowed = [x for x, y in self.get_allowed_state_transitions(user)]
|
||||||
if new_state not in allowed:
|
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._actioning_user = user
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
|
|
@ -434,10 +503,12 @@ class TradeAcceptance(models.Model):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return (f"TradeAcceptance(offer_hash={self.trade_offer.hash}, "
|
return (
|
||||||
|
f"TradeAcceptance(offer_hash={self.trade_offer.hash}, "
|
||||||
f"accepted_by={self.accepted_by}, "
|
f"accepted_by={self.accepted_by}, "
|
||||||
f"requested_card={self.requested_card}, "
|
f"requested_card={self.requested_card}, "
|
||||||
f"offered_card={self.offered_card}, state={self.state})")
|
f"offered_card={self.offered_card}, state={self.state})"
|
||||||
|
)
|
||||||
|
|
||||||
def get_allowed_state_transitions(self, user):
|
def get_allowed_state_transitions(self, user):
|
||||||
if self.trade_offer.initiated_by in user.friend_codes.all():
|
if self.trade_offer.initiated_by in user.friend_codes.all():
|
||||||
|
|
@ -470,7 +541,7 @@ class TradeAcceptance(models.Model):
|
||||||
},
|
},
|
||||||
self.AcceptanceState.RECEIVED: {
|
self.AcceptanceState.RECEIVED: {
|
||||||
self.AcceptanceState.THANKED_BY_ACCEPTOR, # allow early thanks (uses THANKED_BY_ACCEPTOR state)
|
self.AcceptanceState.THANKED_BY_ACCEPTOR, # allow early thanks (uses THANKED_BY_ACCEPTOR state)
|
||||||
self.AcceptanceState.REJECTED_BY_ACCEPTOR
|
self.AcceptanceState.REJECTED_BY_ACCEPTOR,
|
||||||
},
|
},
|
||||||
self.AcceptanceState.THANKED_BY_ACCEPTOR: {},
|
self.AcceptanceState.THANKED_BY_ACCEPTOR: {},
|
||||||
self.AcceptanceState.THANKED_BY_INITIATOR: {
|
self.AcceptanceState.THANKED_BY_INITIATOR: {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
from django.db.models.signals import post_save, post_delete, pre_save
|
|
||||||
from django.dispatch import receiver
|
|
||||||
from django.db.models import F
|
|
||||||
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard, TradeAcceptance, TradeOffer
|
|
||||||
from django.db import transaction
|
|
||||||
from pkmntrade_club.accounts.models import CustomUser
|
|
||||||
from datetime import timedelta
|
|
||||||
from django.utils import timezone
|
|
||||||
import uuid
|
|
||||||
import hashlib
|
|
||||||
from django.core.mail import send_mail
|
|
||||||
from django.conf import settings
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
import logging
|
from django.core.mail import send_mail
|
||||||
|
from django.db.models import F
|
||||||
|
from django.db.models.signals import post_delete, post_save, pre_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
from pkmntrade_club.accounts.models import CustomUser
|
||||||
|
from pkmntrade_club.trades.models import (
|
||||||
|
TradeAcceptance,
|
||||||
|
TradeOffer,
|
||||||
|
TradeOfferHaveCard,
|
||||||
|
TradeOfferWantCard,
|
||||||
|
)
|
||||||
|
|
||||||
POSITIVE_STATES = [
|
POSITIVE_STATES = [
|
||||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
TradeAcceptance.AcceptanceState.ACCEPTED,
|
||||||
|
|
@ -24,20 +23,20 @@ POSITIVE_STATES = [
|
||||||
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
TradeAcceptance.AcceptanceState.THANKED_BY_BOTH,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def adjust_qty_for_trade_offer(trade_offer, card, side, delta):
|
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.
|
Increment (or decrement) qty_accepted by delta for the given card on the specified side.
|
||||||
"""
|
"""
|
||||||
if side == 'have':
|
if side == "have":
|
||||||
TradeOfferHaveCard.objects.filter(
|
TradeOfferHaveCard.objects.filter(trade_offer=trade_offer, card=card).update(
|
||||||
trade_offer=trade_offer,
|
qty_accepted=F("qty_accepted") + delta
|
||||||
card=card
|
)
|
||||||
).update(qty_accepted=F('qty_accepted') + delta)
|
elif side == "want":
|
||||||
elif side == 'want':
|
TradeOfferWantCard.objects.filter(trade_offer=trade_offer, card=card).update(
|
||||||
TradeOfferWantCard.objects.filter(
|
qty_accepted=F("qty_accepted") + delta
|
||||||
trade_offer=trade_offer,
|
)
|
||||||
card=card
|
|
||||||
).update(qty_accepted=F('qty_accepted') + delta)
|
|
||||||
|
|
||||||
def update_trade_offer_closed_status(trade_offer):
|
def update_trade_offer_closed_status(trade_offer):
|
||||||
"""
|
"""
|
||||||
|
|
@ -46,18 +45,17 @@ def update_trade_offer_closed_status(trade_offer):
|
||||||
greater than or equal to quantity; otherwise, mark it as open.
|
greater than or equal to quantity; otherwise, mark it as open.
|
||||||
"""
|
"""
|
||||||
have_complete = not TradeOfferHaveCard.objects.filter(
|
have_complete = not TradeOfferHaveCard.objects.filter(
|
||||||
trade_offer=trade_offer,
|
trade_offer=trade_offer, qty_accepted__lt=F("quantity")
|
||||||
qty_accepted__lt=F('quantity')
|
|
||||||
).exists()
|
).exists()
|
||||||
want_complete = not TradeOfferWantCard.objects.filter(
|
want_complete = not TradeOfferWantCard.objects.filter(
|
||||||
trade_offer=trade_offer,
|
trade_offer=trade_offer, qty_accepted__lt=F("quantity")
|
||||||
qty_accepted__lt=F('quantity')
|
|
||||||
).exists()
|
).exists()
|
||||||
closed = have_complete or want_complete
|
closed = have_complete or want_complete
|
||||||
if trade_offer.is_closed != closed:
|
if trade_offer.is_closed != closed:
|
||||||
trade_offer.is_closed = closed
|
trade_offer.is_closed = closed
|
||||||
trade_offer.save(update_fields=["is_closed"])
|
trade_offer.save(update_fields=["is_closed"])
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_save, sender=TradeAcceptance)
|
@receiver(pre_save, sender=TradeAcceptance)
|
||||||
def trade_acceptance_pre_save(sender, instance, **kwargs):
|
def trade_acceptance_pre_save(sender, instance, **kwargs):
|
||||||
# Skip signal processing during raw fixture load or when saving a new instance
|
# Skip signal processing during raw fixture load or when saving a new instance
|
||||||
|
|
@ -68,6 +66,7 @@ def trade_acceptance_pre_save(sender, instance, **kwargs):
|
||||||
old_instance = TradeAcceptance.objects.get(pk=instance.pk)
|
old_instance = TradeAcceptance.objects.get(pk=instance.pk)
|
||||||
instance._old_state = old_instance.state
|
instance._old_state = old_instance.state
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=TradeAcceptance)
|
@receiver(post_save, sender=TradeAcceptance)
|
||||||
def trade_acceptance_post_save(sender, instance, created, **kwargs):
|
def trade_acceptance_post_save(sender, instance, created, **kwargs):
|
||||||
delta = 0
|
delta = 0
|
||||||
|
|
@ -75,7 +74,7 @@ def trade_acceptance_post_save(sender, instance, created, **kwargs):
|
||||||
if instance.state in POSITIVE_STATES:
|
if instance.state in POSITIVE_STATES:
|
||||||
delta = 1
|
delta = 1
|
||||||
else:
|
else:
|
||||||
old_state = getattr(instance, '_old_state', None)
|
old_state = getattr(instance, "_old_state", None)
|
||||||
if old_state is not None:
|
if old_state is not None:
|
||||||
if old_state in POSITIVE_STATES and instance.state not in POSITIVE_STATES:
|
if old_state in POSITIVE_STATES and instance.state not in POSITIVE_STATES:
|
||||||
delta = -1
|
delta = -1
|
||||||
|
|
@ -84,29 +83,49 @@ def trade_acceptance_post_save(sender, instance, created, **kwargs):
|
||||||
|
|
||||||
if delta != 0:
|
if delta != 0:
|
||||||
trade_offer = instance.trade_offer
|
trade_offer = instance.trade_offer
|
||||||
adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta)
|
adjust_qty_for_trade_offer(
|
||||||
adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta)
|
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)
|
update_trade_offer_closed_status(trade_offer)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=TradeAcceptance)
|
@receiver(post_delete, sender=TradeAcceptance)
|
||||||
def trade_acceptance_post_delete(sender, instance, **kwargs):
|
def trade_acceptance_post_delete(sender, instance, **kwargs):
|
||||||
if instance.state in POSITIVE_STATES:
|
if instance.state in POSITIVE_STATES:
|
||||||
delta = -1
|
delta = -1
|
||||||
trade_offer = instance.trade_offer
|
trade_offer = instance.trade_offer
|
||||||
adjust_qty_for_trade_offer(trade_offer, instance.requested_card, side='have', delta=delta)
|
adjust_qty_for_trade_offer(
|
||||||
adjust_qty_for_trade_offer(trade_offer, instance.offered_card, side='want', delta=delta)
|
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)
|
update_trade_offer_closed_status(trade_offer)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=TradeAcceptance)
|
@receiver(post_save, sender=TradeAcceptance)
|
||||||
def trade_acceptance_email_notification(sender, instance, created, **kwargs):
|
def trade_acceptance_email_notification(sender, instance, created, **kwargs):
|
||||||
# Only proceed if the update was triggered by an acting user.
|
# Only proceed if the update was triggered by an acting user.
|
||||||
if not hasattr(instance, "_actioning_user"):
|
if not hasattr(instance, "_actioning_user"):
|
||||||
return
|
return
|
||||||
|
|
||||||
# check if were in debug mode
|
# Re-fetch instance with related data to avoid N+1 queries.
|
||||||
# if settings.DEBUG:
|
instance = (
|
||||||
# print("DEBUG: skipping email notification in debug mode")
|
TradeAcceptance.objects.select_related(
|
||||||
# return
|
"trade_offer__initiated_by__user",
|
||||||
|
"accepted_by__user",
|
||||||
|
"requested_card",
|
||||||
|
"offered_card",
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
"requested_card__translations",
|
||||||
|
"offered_card__translations",
|
||||||
|
)
|
||||||
|
.get(pk=instance.pk)
|
||||||
|
)
|
||||||
|
|
||||||
acting_user = instance._actioning_user
|
acting_user = instance._actioning_user
|
||||||
del instance._actioning_user
|
del instance._actioning_user
|
||||||
|
|
@ -132,7 +151,6 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
# Determine the non-acting party:
|
# Determine the non-acting party:
|
||||||
if instance.trade_offer.initiated_by.user.pk == acting_user.pk:
|
if instance.trade_offer.initiated_by.user.pk == acting_user.pk:
|
||||||
# The initiator made the change; notify the acceptor.
|
# The initiator made the change; notify the acceptor.
|
||||||
|
|
@ -153,17 +171,31 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
|
||||||
"want_card": instance.offered_card,
|
"want_card": instance.offered_card,
|
||||||
"hash": instance.hash,
|
"hash": instance.hash,
|
||||||
"acting_user": acting_user.username,
|
"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": recipient_user.username,
|
||||||
"recipient_user_ign": instance.accepted_by.in_game_name if is_initiator else instance.trade_offer.initiated_by.in_game_name,
|
"recipient_user_ign": (
|
||||||
"acting_user_friend_code": instance.trade_offer.initiated_by.friend_code if is_initiator else instance.accepted_by.friend_code,
|
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,
|
"is_initiator": is_initiator,
|
||||||
"domain": "https://" + Site.objects.get_current().domain,
|
"domain": "https://" + Site.objects.get_current().domain,
|
||||||
"pk": instance.pk,
|
"pk": instance.pk,
|
||||||
}
|
}
|
||||||
email_template = "email/trades/trade_update_" + state + ".txt"
|
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/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)
|
email_body = render_to_string(email_template, email_context)
|
||||||
|
|
||||||
send_mail(
|
send_mail(
|
||||||
|
|
@ -173,6 +205,7 @@ def trade_acceptance_email_notification(sender, instance, created, **kwargs):
|
||||||
[recipient_user.email],
|
[recipient_user.email],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=TradeAcceptance)
|
@receiver(post_save, sender=TradeAcceptance)
|
||||||
def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
|
def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
@ -189,30 +222,53 @@ def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
|
||||||
if created:
|
if created:
|
||||||
return # No action on creation as terminal states are not expected.
|
return # No action on creation as terminal states are not expected.
|
||||||
|
|
||||||
|
# Re-fetch instance with related data to avoid N+1 queries.
|
||||||
|
instance = TradeAcceptance.objects.select_related(
|
||||||
|
"trade_offer__initiated_by__user", "accepted_by__user"
|
||||||
|
).get(pk=instance.pk)
|
||||||
|
|
||||||
thanks_delta = 0
|
thanks_delta = 0
|
||||||
rejection_delta_initiator = 0 # Delta for the initiator's reputation
|
rejection_delta_initiator = 0 # Delta for the initiator's reputation
|
||||||
rejection_delta_acceptor = 0 # Delta for the acceptor's reputation
|
rejection_delta_acceptor = 0 # Delta for the acceptor's reputation
|
||||||
|
|
||||||
old_state = getattr(instance, '_old_state', None)
|
old_state = getattr(instance, "_old_state", None)
|
||||||
if old_state is None:
|
if old_state is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Handle THANKED_BY_BOTH transitions
|
# 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
|
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
|
thanks_delta = -1
|
||||||
|
|
||||||
# Handle REJECTED_BY_INITIATOR transitions (affects the acceptor)
|
# 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
|
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
|
rejection_delta_acceptor = 1
|
||||||
|
|
||||||
# Handle REJECTED_BY_ACCEPTOR transitions (affects the initiator)
|
# 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
|
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
|
rejection_delta_initiator = 1
|
||||||
|
|
||||||
# Apply reputation updates:
|
# Apply reputation updates:
|
||||||
|
|
@ -237,6 +293,7 @@ def trade_acceptance_reputation_update(sender, instance, created, **kwargs):
|
||||||
reputation_score=F("reputation_score") + rejection_delta_initiator
|
reputation_score=F("reputation_score") + rejection_delta_initiator
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=TradeAcceptance)
|
@receiver(post_delete, sender=TradeAcceptance)
|
||||||
def trade_acceptance_reputation_delete(sender, instance, **kwargs):
|
def trade_acceptance_reputation_delete(sender, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
@ -263,20 +320,31 @@ def trade_acceptance_reputation_delete(sender, instance, **kwargs):
|
||||||
reputation_score=F("reputation_score") + 1
|
reputation_score=F("reputation_score") + 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=TradeOffer)
|
||||||
|
@receiver(post_delete, sender=TradeOffer)
|
||||||
|
def on_trade_offer_change(sender, instance, **kwargs):
|
||||||
|
"""Invalidate cache for a trade offer when it's changed or deleted."""
|
||||||
|
cache.delete(f"trade_offer_{instance.pk}")
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=TradeOfferHaveCard)
|
@receiver(post_save, sender=TradeOfferHaveCard)
|
||||||
@receiver(post_delete, sender=TradeOfferHaveCard)
|
@receiver(post_delete, sender=TradeOfferHaveCard)
|
||||||
@receiver(post_save, sender=TradeOfferWantCard)
|
@receiver(post_save, sender=TradeOfferWantCard)
|
||||||
@receiver(post_delete, sender=TradeOfferWantCard)
|
@receiver(post_delete, sender=TradeOfferWantCard)
|
||||||
|
def on_trade_offer_card_change(sender, instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Invalidate cache for a trade offer when one of its card relationships changes.
|
||||||
|
"""
|
||||||
|
if instance.trade_offer:
|
||||||
|
cache.delete(f"trade_offer_{instance.trade_offer.pk}")
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=TradeAcceptance)
|
@receiver(post_save, sender=TradeAcceptance)
|
||||||
@receiver(post_delete, sender=TradeAcceptance)
|
@receiver(post_delete, sender=TradeAcceptance)
|
||||||
def bubble_up_trade_offer_updates(sender, instance, **kwargs):
|
def on_trade_acceptance_change(sender, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
Bubble up updated_at to the TradeOffer model when related instances change.
|
Invalidate cache for a trade offer when one of its acceptances changes.
|
||||||
Also invalidates any cached image by deleting the file.
|
|
||||||
"""
|
"""
|
||||||
trade_offer = getattr(instance, 'trade_offer', None)
|
if instance.trade_offer:
|
||||||
|
cache.delete(f"trade_offer_{instance.trade_offer.pk}")
|
||||||
if trade_offer and trade_offer.image:
|
|
||||||
trade_offer.image.delete(save=True) # deleting the image will trigger a save, which updates the updated_at field
|
|
||||||
elif trade_offer:
|
|
||||||
trade_offer.save(update_fields=['updated_at'])
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from django import template
|
from django import template
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from pkmntrade_club.trades.models import TradeAcceptance
|
|
||||||
register = template.Library()
|
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):
|
def render_trade_offer(context, offer):
|
||||||
"""
|
"""
|
||||||
Renders a trade offer including detailed trade acceptance information.
|
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())
|
trade_offer_want_cards = list(offer.trade_offer_want_cards.all())
|
||||||
acceptances = list(offer.acceptances.all())
|
acceptances = list(offer.acceptances.all())
|
||||||
|
|
||||||
|
|
||||||
have_cards_available = [
|
have_cards_available = [
|
||||||
card for card in trade_offer_have_cards
|
card for card in trade_offer_have_cards if card.quantity > card.qty_accepted
|
||||||
if card.quantity > card.qty_accepted
|
|
||||||
]
|
]
|
||||||
want_cards_available = [
|
want_cards_available = [
|
||||||
card for card in trade_offer_want_cards
|
card for card in trade_offer_want_cards if card.quantity > card.qty_accepted
|
||||||
if card.quantity > card.qty_accepted
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if not have_cards_available or not want_cards_available:
|
if not have_cards_available or not want_cards_available:
|
||||||
|
|
@ -31,37 +29,41 @@ def render_trade_offer(context, offer):
|
||||||
flipped = False
|
flipped = False
|
||||||
|
|
||||||
tag_context = {
|
tag_context = {
|
||||||
'offer_pk': offer.pk,
|
"offer_pk": offer.pk,
|
||||||
'flipped': flipped,
|
"flipped": flipped,
|
||||||
'offer_hash': offer.hash,
|
"offer_hash": offer.hash,
|
||||||
'rarity_icon': offer.rarity_icon,
|
"rarity_icon": offer.rarity_icon,
|
||||||
'initiated_by_email': offer.initiated_by.user.email,
|
"initiated_by_email": offer.initiated_by.user.email,
|
||||||
'initiated_by_username': offer.initiated_by.user.username,
|
"initiated_by_username": offer.initiated_by.user.username,
|
||||||
'initiated_reputation': offer.initiated_by.user.reputation_score,
|
"initiated_reputation": offer.initiated_by.user.reputation_score,
|
||||||
'acceptances': acceptances,
|
"acceptances": acceptances,
|
||||||
'have_cards_available': have_cards_available,
|
"have_cards_available": have_cards_available,
|
||||||
'want_cards_available': want_cards_available,
|
"want_cards_available": want_cards_available,
|
||||||
'num_cards_available': len(have_cards_available) + len(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)+"/"),
|
"on_detail_page": context.get("request").path.endswith(
|
||||||
'cache_key': f'trade_offer_{offer.pk}_{offer.updated_at.timestamp()}_{flipped}',
|
"trades/" + str(offer.pk) + "/"
|
||||||
|
),
|
||||||
|
"cache_key": f"trade_offer_{offer.pk}_{offer.updated_at.timestamp()}_{flipped}",
|
||||||
}
|
}
|
||||||
|
|
||||||
context.update(tag_context)
|
context.update(tag_context)
|
||||||
return 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):
|
def render_trade_acceptance(context, acceptance):
|
||||||
"""
|
"""
|
||||||
Renders a simple trade acceptance view with a single row and simplified header/footer.
|
Renders a simple trade acceptance view with a single row and simplified header/footer.
|
||||||
"""
|
"""
|
||||||
tag_context = {
|
tag_context = {
|
||||||
"acceptance": acceptance,
|
"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)
|
context.update(tag_context)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def get_action_label(acceptance, state_value):
|
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)
|
return acceptance.get_action_label_for_state(state_value)
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def action_button_class(state_value):
|
def action_button_class(state_value):
|
||||||
"""
|
"""
|
||||||
Returns daisyUI button classes based on the provided state value.
|
Returns daisyUI button classes based on the provided state value.
|
||||||
"""
|
"""
|
||||||
mapping = {
|
mapping = {
|
||||||
'ACCEPTED': 'btn btn-primary',
|
"ACCEPTED": "btn btn-primary",
|
||||||
'SENT': 'btn btn-info',
|
"SENT": "btn btn-info",
|
||||||
'RECEIVED': 'btn btn-info',
|
"RECEIVED": "btn btn-info",
|
||||||
'THANKED_BY_INITIATOR': 'btn btn-success',
|
"THANKED_BY_INITIATOR": "btn btn-success",
|
||||||
'THANKED_BY_ACCEPTOR': 'btn btn-success',
|
"THANKED_BY_ACCEPTOR": "btn btn-success",
|
||||||
'THANKED_BY_BOTH': 'btn btn-success',
|
"THANKED_BY_BOTH": "btn btn-success",
|
||||||
'REJECTED_BY_INITIATOR': 'btn btn-error',
|
"REJECTED_BY_INITIATOR": "btn btn-error",
|
||||||
'REJECTED_BY_ACCEPTOR': 'btn btn-error',
|
"REJECTED_BY_ACCEPTOR": "btn btn-error",
|
||||||
}
|
}
|
||||||
# Return a default style if the state isn't in the mapping.
|
# 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):
|
def render_trade_offer_png(context, offer, show_friend_code=False):
|
||||||
CARD_HEIGHT = 32
|
CARD_HEIGHT = 32
|
||||||
CARD_WIDTH = 160
|
CARD_WIDTH = 160
|
||||||
|
|
@ -104,7 +108,12 @@ def render_trade_offer_png(context, offer, show_friend_code=False):
|
||||||
expanded = (len(have_cards_available) + len(want_cards_available)) > 4
|
expanded = (len(have_cards_available) + len(want_cards_available)) > 4
|
||||||
if expanded:
|
if expanded:
|
||||||
num_cards = ceil(num_cards / 2) # 2 cards per row 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
|
image_height = (
|
||||||
|
(num_cards * CARD_HEIGHT)
|
||||||
|
+ ((num_cards - 1) * CARD_COL_GAP)
|
||||||
|
+ HEADER_HEIGHT
|
||||||
|
+ FOOTER_HEIGHT
|
||||||
|
)
|
||||||
|
|
||||||
if expanded:
|
if expanded:
|
||||||
image_width = (4 * CARD_WIDTH) + EXPANDED_CARD_WIDTH_PADDING
|
image_width = (4 * CARD_WIDTH) + EXPANDED_CARD_WIDTH_PADDING
|
||||||
|
|
@ -121,22 +130,22 @@ def render_trade_offer_png(context, offer, show_friend_code=False):
|
||||||
base_url = "https://{0}".format(request.get_host())
|
base_url = "https://{0}".format(request.get_host())
|
||||||
|
|
||||||
tag_context = {
|
tag_context = {
|
||||||
'offer_pk': offer.pk,
|
"offer_pk": offer.pk,
|
||||||
'offer_hash': offer.hash,
|
"offer_hash": offer.hash,
|
||||||
'rarity_icon': offer.rarity_icon,
|
"rarity_icon": offer.rarity_icon,
|
||||||
'initiated_by_email': offer.initiated_by.user.email,
|
"initiated_by_email": offer.initiated_by.user.email,
|
||||||
'initiated_by_username': offer.initiated_by.user.username,
|
"initiated_by_username": offer.initiated_by.user.username,
|
||||||
'have_cards_available': have_cards_available,
|
"have_cards_available": have_cards_available,
|
||||||
'want_cards_available': want_cards_available,
|
"want_cards_available": want_cards_available,
|
||||||
'in_game_name': offer.initiated_by.in_game_name,
|
"in_game_name": offer.initiated_by.in_game_name,
|
||||||
'friend_code': offer.initiated_by.friend_code,
|
"friend_code": offer.initiated_by.friend_code,
|
||||||
'show_friend_code': show_friend_code,
|
"show_friend_code": show_friend_code,
|
||||||
'num_cards_available': len(have_cards_available) + len(want_cards_available),
|
"num_cards_available": len(have_cards_available) + len(want_cards_available),
|
||||||
'expanded': expanded,
|
"expanded": expanded,
|
||||||
'image_width': image_width,
|
"image_width": image_width,
|
||||||
'image_height': image_height,
|
"image_height": image_height,
|
||||||
'base_url': base_url,
|
"base_url": base_url,
|
||||||
'cache_key': f'trade_offer_png_{offer.pk}_{offer.updated_at.timestamp()}_{expanded}',
|
"cache_key": f"trade_offer_png_{offer.pk}_{offer.updated_at.timestamp()}_{expanded}",
|
||||||
}
|
}
|
||||||
|
|
||||||
context.update(tag_context)
|
context.update(tag_context)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from pkmntrade_club.trades.forms import (
|
||||||
)
|
)
|
||||||
from tests.utils.rarity import RARITY_MAPPING
|
from tests.utils.rarity import RARITY_MAPPING
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
# Model Tests
|
# Model Tests
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
|
|
@ -35,17 +36,29 @@ class TradeOfferModelTest(TestCase):
|
||||||
|
|
||||||
# Create cards with the same rarity (valid scenario)
|
# Create cards with the same rarity (valid scenario)
|
||||||
self.card1 = Card.objects.create(
|
self.card1 = Card.objects.create(
|
||||||
name="Card1", cardset="set1", cardnum=1, style="default",
|
name="Card1",
|
||||||
rarity_icon=RARITY_MAPPING[1], rarity_level=1
|
cardset="set1",
|
||||||
|
cardnum=1,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[1],
|
||||||
|
rarity_level=1,
|
||||||
)
|
)
|
||||||
self.card2 = Card.objects.create(
|
self.card2 = Card.objects.create(
|
||||||
name="Card2", cardset="set1", cardnum=2, style="default",
|
name="Card2",
|
||||||
rarity_icon=RARITY_MAPPING[1], rarity_level=1
|
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)
|
# Create a card with a different rarity (to test invalid trade offers)
|
||||||
self.card3 = Card.objects.create(
|
self.card3 = Card.objects.create(
|
||||||
name="Card3", cardset="set1", cardnum=3, style="default",
|
name="Card3",
|
||||||
rarity_icon=RARITY_MAPPING[8], rarity_level=8
|
cardset="set1",
|
||||||
|
cardnum=3,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[8],
|
||||||
|
rarity_level=8,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a valid trade offer with consistent rarity details
|
# Create a valid trade offer with consistent rarity details
|
||||||
|
|
@ -92,17 +105,27 @@ class TradeAcceptanceModelTest(TestCase):
|
||||||
username="initiator", email="init@example.com", password="password"
|
username="initiator", email="init@example.com", password="password"
|
||||||
)
|
)
|
||||||
self.initiator_friend_code = FriendCode.objects.create(
|
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)
|
# Create two cards (with the same rarity)
|
||||||
self.card1 = Card.objects.create(
|
self.card1 = Card.objects.create(
|
||||||
name="CardA", cardset="setA", cardnum=1, style="default",
|
name="CardA",
|
||||||
rarity_icon=RARITY_MAPPING[2], rarity_level=2
|
cardset="setA",
|
||||||
|
cardnum=1,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[2],
|
||||||
|
rarity_level=2,
|
||||||
)
|
)
|
||||||
self.card2 = Card.objects.create(
|
self.card2 = Card.objects.create(
|
||||||
name="CardB", cardset="setA", cardnum=2, style="default",
|
name="CardB",
|
||||||
rarity_icon=RARITY_MAPPING[2], rarity_level=2
|
cardset="setA",
|
||||||
|
cardnum=2,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[2],
|
||||||
|
rarity_level=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a trade offer by the initiator.
|
# Create a trade offer by the initiator.
|
||||||
|
|
@ -150,9 +173,7 @@ class TradeAcceptanceModelTest(TestCase):
|
||||||
self.acceptance.update_state(
|
self.acceptance.update_state(
|
||||||
TradeAcceptance.AcceptanceState.SENT, user=self.other_user
|
TradeAcceptance.AcceptanceState.SENT, user=self.other_user
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(self.acceptance.state, TradeAcceptance.AcceptanceState.SENT)
|
||||||
self.acceptance.state, TradeAcceptance.AcceptanceState.SENT
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_signal_adjusts_qty_accepted(self):
|
def test_signal_adjusts_qty_accepted(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -206,12 +227,20 @@ class TradeOfferFormTest(TestCase):
|
||||||
)
|
)
|
||||||
# Create two cards with the same rarity details.
|
# Create two cards with the same rarity details.
|
||||||
self.card1 = Card.objects.create(
|
self.card1 = Card.objects.create(
|
||||||
name="FormCard1", cardset="formset", cardnum=1, style="default",
|
name="FormCard1",
|
||||||
rarity_icon=RARITY_MAPPING[3], rarity_level=3
|
cardset="formset",
|
||||||
|
cardnum=1,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[3],
|
||||||
|
rarity_level=3,
|
||||||
)
|
)
|
||||||
self.card2 = Card.objects.create(
|
self.card2 = Card.objects.create(
|
||||||
name="FormCard2", cardset="formset", cardnum=2, style="default",
|
name="FormCard2",
|
||||||
rarity_icon=RARITY_MAPPING[3], rarity_level=3
|
cardset="formset",
|
||||||
|
cardnum=2,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[3],
|
||||||
|
rarity_level=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_trade_offer_create_form_valid(self):
|
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.
|
A valid POST using colon-separated quantity strings should succeed.
|
||||||
"""
|
"""
|
||||||
# Build a QueryDict with multiple values for each list field.
|
# 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("have_cards", [f"{self.card1.pk}:2"])
|
||||||
qd.setlist("want_cards", [f"{self.card2.pk}:3"])
|
qd.setlist("want_cards", [f"{self.card2.pk}:3"])
|
||||||
# 'initiated_by' is a normal field so we can update it directly.
|
# '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.
|
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).
|
# Provide an invalid quantity ("two" instead of an integer).
|
||||||
qd.setlist("have_cards", [f"{self.card1.pk}:two"])
|
qd.setlist("have_cards", [f"{self.card1.pk}:two"])
|
||||||
qd.setlist("want_cards", [f"{self.card2.pk}:3"])
|
qd.setlist("want_cards", [f"{self.card2.pk}:3"])
|
||||||
|
|
@ -244,7 +273,7 @@ class TradeOfferFormTest(TestCase):
|
||||||
"""
|
"""
|
||||||
An entry missing a colon should be ignored.
|
An entry missing a colon should be ignored.
|
||||||
"""
|
"""
|
||||||
qd = QueryDict('', mutable=True)
|
qd = QueryDict("", mutable=True)
|
||||||
# No colon present in the selections.
|
# No colon present in the selections.
|
||||||
qd.setlist("have_cards", [f"{self.card1.pk}"])
|
qd.setlist("have_cards", [f"{self.card1.pk}"])
|
||||||
qd.setlist("want_cards", [f"{self.card2.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."""
|
"""Test that TradeOfferAcceptForm correctly sets the friend_code queryset."""
|
||||||
friend_codes = FriendCode.objects.filter(pk=self.friend_code.pk)
|
friend_codes = FriendCode.objects.filter(pk=self.friend_code.pk)
|
||||||
form = TradeOfferAcceptForm(friend_codes=friend_codes)
|
form = TradeOfferAcceptForm(friend_codes=friend_codes)
|
||||||
self.assertEqual(
|
self.assertEqual(list(form.fields["friend_code"].queryset), list(friend_codes))
|
||||||
list(form.fields["friend_code"].queryset), list(friend_codes)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_trade_acceptance_transition_form(self):
|
def test_trade_acceptance_transition_form(self):
|
||||||
"""Test that the transition form provides only allowed transitions."""
|
"""Test that the transition form provides only allowed transitions."""
|
||||||
|
|
@ -312,7 +339,10 @@ class TradeOfferFormTest(TestCase):
|
||||||
)
|
)
|
||||||
form = TradeAcceptanceTransitionForm(instance=acceptance, user=other_user)
|
form = TradeAcceptanceTransitionForm(instance=acceptance, user=other_user)
|
||||||
# Compare the form's state choices with the allowed transitions.
|
# 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]
|
form_choices = [choice[0] for choice in form.fields["state"].choices]
|
||||||
for choice in allowed:
|
for choice in allowed:
|
||||||
self.assertIn(choice, form_choices)
|
self.assertIn(choice, form_choices)
|
||||||
|
|
@ -337,12 +367,20 @@ class TradeViewsTest(TestCase):
|
||||||
|
|
||||||
# Create sample cards.
|
# Create sample cards.
|
||||||
self.card1 = Card.objects.create(
|
self.card1 = Card.objects.create(
|
||||||
name="ViewCard1", cardset="setV", cardnum=1, style="default",
|
name="ViewCard1",
|
||||||
rarity_icon=RARITY_MAPPING[7], rarity_level=7
|
cardset="setV",
|
||||||
|
cardnum=1,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[7],
|
||||||
|
rarity_level=7,
|
||||||
)
|
)
|
||||||
self.card2 = Card.objects.create(
|
self.card2 = Card.objects.create(
|
||||||
name="ViewCard2", cardset="setV", cardnum=2, style="default",
|
name="ViewCard2",
|
||||||
rarity_icon=RARITY_MAPPING[7], rarity_level=7
|
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.
|
# Create a trade offer initiated by the logged-in user's friend code.
|
||||||
self.trade_offer = TradeOffer.objects.create(initiated_by=self.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.
|
Instead, if no active acceptances remain it should mark the offer as closed.
|
||||||
"""
|
"""
|
||||||
# Create a trade offer with an active acceptance.
|
# 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
|
# Use quantity=2 so the trade offer isn't automatically closed when one acceptance is created
|
||||||
TradeOfferHaveCard.objects.create(
|
TradeOfferHaveCard.objects.create(
|
||||||
trade_offer=trade_offer_with_acceptance, card=self.card1, quantity=2
|
trade_offer=trade_offer_with_acceptance, card=self.card1, quantity=2
|
||||||
|
|
@ -403,10 +443,13 @@ class TradeViewsTest(TestCase):
|
||||||
offered_card=self.card2,
|
offered_card=self.card2,
|
||||||
state=TradeAcceptance.AcceptanceState.ACCEPTED,
|
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 ---
|
# --- Patch the view's get_object() method to return our trade offer ---
|
||||||
from pkmntrade_club.trades.views import TradeOfferDeleteView
|
from pkmntrade_club.trades.views import TradeOfferDeleteView
|
||||||
|
|
||||||
orig_get_object = TradeOfferDeleteView.get_object
|
orig_get_object = TradeOfferDeleteView.get_object
|
||||||
TradeOfferDeleteView.get_object = lambda self: trade_offer_with_acceptance
|
TradeOfferDeleteView.get_object = lambda self: trade_offer_with_acceptance
|
||||||
|
|
||||||
|
|
@ -453,13 +496,19 @@ class TradeViewsTest(TestCase):
|
||||||
form = response.context.get("form")
|
form = response.context.get("form")
|
||||||
self.assertIsNotNone(form, "Form should be present in the response context.")
|
self.assertIsNotNone(form, "Form should be present in the response context.")
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"state", form.errors,
|
"state",
|
||||||
"Expected an error on the 'state' field when an invalid state is submitted."
|
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.
|
# 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:
|
if allowed_states:
|
||||||
valid_state = allowed_states[0]
|
valid_state = allowed_states[0]
|
||||||
response = self.client.post(update_url, {"state": valid_state})
|
response = self.client.post(update_url, {"state": valid_state})
|
||||||
|
|
@ -493,12 +542,20 @@ class TradeOfferSecurityTests(TestCase):
|
||||||
|
|
||||||
# Create test cards with proper rarity levels
|
# Create test cards with proper rarity levels
|
||||||
self.card1 = Card.objects.create(
|
self.card1 = Card.objects.create(
|
||||||
name="SecCard1", cardset="secset", cardnum=1, style="default",
|
name="SecCard1",
|
||||||
rarity_icon=RARITY_MAPPING[3], rarity_level=3
|
cardset="secset",
|
||||||
|
cardnum=1,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[3],
|
||||||
|
rarity_level=3,
|
||||||
)
|
)
|
||||||
self.card2 = Card.objects.create(
|
self.card2 = Card.objects.create(
|
||||||
name="SecCard2", cardset="secset", cardnum=2, style="default",
|
name="SecCard2",
|
||||||
rarity_icon=RARITY_MAPPING[3], rarity_level=3
|
cardset="secset",
|
||||||
|
cardnum=2,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[3],
|
||||||
|
rarity_level=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a trade offer by user1
|
# Create a trade offer by user1
|
||||||
|
|
@ -536,7 +593,7 @@ class TradeOfferSecurityTests(TestCase):
|
||||||
self.client.login(username="user3", password="password3")
|
self.client.login(username="user3", password="password3")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
|
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
|
||||||
{"state": TradeAcceptance.AcceptanceState.SENT}
|
{"state": TradeAcceptance.AcceptanceState.SENT},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
@ -551,12 +608,10 @@ class TradeOfferSecurityTests(TestCase):
|
||||||
"initiated_by": self.fc1.pk, # User1's friend code
|
"initiated_by": self.fc1.pk, # User1's friend code
|
||||||
"have_cards": [f"{self.card1.pk}:1"],
|
"have_cards": [f"{self.card1.pk}:1"],
|
||||||
"want_cards": [f"{self.card2.pk}:1"],
|
"want_cards": [f"{self.card2.pk}:1"],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200) # Form should fail validation
|
self.assertEqual(response.status_code, 200) # Form should fail validation
|
||||||
self.assertFalse(
|
self.assertFalse(TradeOffer.objects.filter(initiated_by=self.fc1).count() > 1)
|
||||||
TradeOffer.objects.filter(initiated_by=self.fc1).count() > 1
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_authenticated_only_views(self):
|
def test_authenticated_only_views(self):
|
||||||
"""Test that authenticated-only views are properly protected."""
|
"""Test that authenticated-only views are properly protected."""
|
||||||
|
|
@ -564,7 +619,9 @@ class TradeOfferSecurityTests(TestCase):
|
||||||
urls_to_test = [
|
urls_to_test = [
|
||||||
reverse("trade_offer_create"),
|
reverse("trade_offer_create"),
|
||||||
reverse("trade_offer_dashboard"),
|
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
|
# First ensure we're logged out
|
||||||
|
|
@ -575,7 +632,7 @@ class TradeOfferSecurityTests(TestCase):
|
||||||
self.assertRedirects(
|
self.assertRedirects(
|
||||||
response,
|
response,
|
||||||
f"/accounts/login/?next={url}",
|
f"/accounts/login/?next={url}",
|
||||||
msg_prefix=f"URL {url} should require authentication"
|
msg_prefix=f"URL {url} should require authentication",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -591,16 +648,28 @@ class TradeOfferEdgeCasesTest(TestCase):
|
||||||
|
|
||||||
# Create test cards with different rarities using proper levels and icons
|
# Create test cards with different rarities using proper levels and icons
|
||||||
self.common_card = Card.objects.create(
|
self.common_card = Card.objects.create(
|
||||||
name="CommonCard", cardset="edgeset", cardnum=1, style="default",
|
name="CommonCard",
|
||||||
rarity_icon=RARITY_MAPPING[1], rarity_level=1
|
cardset="edgeset",
|
||||||
|
cardnum=1,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[1],
|
||||||
|
rarity_level=1,
|
||||||
)
|
)
|
||||||
self.rare_card = Card.objects.create(
|
self.rare_card = Card.objects.create(
|
||||||
name="RareCard", cardset="edgeset", cardnum=2, style="default",
|
name="RareCard",
|
||||||
rarity_icon=RARITY_MAPPING[5], rarity_level=5
|
cardset="edgeset",
|
||||||
|
cardnum=2,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[5],
|
||||||
|
rarity_level=5,
|
||||||
)
|
)
|
||||||
self.crown_card = Card.objects.create(
|
self.crown_card = Card.objects.create(
|
||||||
name="CrownCard", cardset="edgeset", cardnum=3, style="default",
|
name="CrownCard",
|
||||||
rarity_icon=RARITY_MAPPING[8], rarity_level=8
|
cardset="edgeset",
|
||||||
|
cardnum=3,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[8],
|
||||||
|
rarity_level=8,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
|
|
@ -614,7 +683,7 @@ class TradeOfferEdgeCasesTest(TestCase):
|
||||||
"initiated_by": self.friend_code.pk,
|
"initiated_by": self.friend_code.pk,
|
||||||
"have_cards": [f"{self.common_card.pk}:0"],
|
"have_cards": [f"{self.common_card.pk}:0"],
|
||||||
"want_cards": [f"{self.common_card.pk}:1"],
|
"want_cards": [f"{self.common_card.pk}:1"],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
|
|
@ -629,7 +698,7 @@ class TradeOfferEdgeCasesTest(TestCase):
|
||||||
"initiated_by": self.friend_code.pk,
|
"initiated_by": self.friend_code.pk,
|
||||||
"have_cards": [f"{self.common_card.pk}:-1"],
|
"have_cards": [f"{self.common_card.pk}:-1"],
|
||||||
"want_cards": [f"{self.common_card.pk}:1"],
|
"want_cards": [f"{self.common_card.pk}:1"],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
|
|
@ -644,7 +713,7 @@ class TradeOfferEdgeCasesTest(TestCase):
|
||||||
"initiated_by": self.friend_code.pk,
|
"initiated_by": self.friend_code.pk,
|
||||||
"have_cards": [f"{self.common_card.pk}:1"],
|
"have_cards": [f"{self.common_card.pk}:1"],
|
||||||
"want_cards": [f"{self.crown_card.pk}:1"],
|
"want_cards": [f"{self.crown_card.pk}:1"],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
|
|
@ -657,12 +726,9 @@ class TradeOfferEdgeCasesTest(TestCase):
|
||||||
reverse("trade_offer_create"),
|
reverse("trade_offer_create"),
|
||||||
{
|
{
|
||||||
"initiated_by": self.friend_code.pk,
|
"initiated_by": self.friend_code.pk,
|
||||||
"have_cards": [
|
"have_cards": [f"{self.common_card.pk}:1", f"{self.common_card.pk}:1"],
|
||||||
f"{self.common_card.pk}:1",
|
|
||||||
f"{self.common_card.pk}:1"
|
|
||||||
],
|
|
||||||
"want_cards": [f"{self.common_card.pk}:1"],
|
"want_cards": [f"{self.common_card.pk}:1"],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
|
|
@ -682,16 +748,28 @@ class TradeSearchTests(TestCase):
|
||||||
|
|
||||||
# Create test cards with proper rarity levels
|
# Create test cards with proper rarity levels
|
||||||
self.card1 = Card.objects.create(
|
self.card1 = Card.objects.create(
|
||||||
name="SearchCard1", cardset="sc1", cardnum=1, style="default",
|
name="SearchCard1",
|
||||||
rarity_icon=RARITY_MAPPING[4], rarity_level=4
|
cardset="sc1",
|
||||||
|
cardnum=1,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[4],
|
||||||
|
rarity_level=4,
|
||||||
)
|
)
|
||||||
self.card2 = Card.objects.create(
|
self.card2 = Card.objects.create(
|
||||||
name="SearchCard2", cardset="sc1", cardnum=2, style="default",
|
name="SearchCard2",
|
||||||
rarity_icon=RARITY_MAPPING[4], rarity_level=4
|
cardset="sc1",
|
||||||
|
cardnum=2,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[4],
|
||||||
|
rarity_level=4,
|
||||||
)
|
)
|
||||||
self.card3 = Card.objects.create(
|
self.card3 = Card.objects.create(
|
||||||
name="SearchCard3", cardset="sc1", cardnum=3, style="default",
|
name="SearchCard3",
|
||||||
rarity_icon=RARITY_MAPPING[4], rarity_level=4
|
cardset="sc1",
|
||||||
|
cardnum=3,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[4],
|
||||||
|
rarity_level=4,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create some trade offers
|
# Create some trade offers
|
||||||
|
|
@ -719,7 +797,7 @@ class TradeSearchTests(TestCase):
|
||||||
reverse("trade_offer_search"),
|
reverse("trade_offer_search"),
|
||||||
{
|
{
|
||||||
"have_cards": [f"{self.card2.pk}:1"],
|
"have_cards": [f"{self.card2.pk}:1"],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
|
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
|
||||||
|
|
@ -731,7 +809,7 @@ class TradeSearchTests(TestCase):
|
||||||
reverse("trade_offer_search"),
|
reverse("trade_offer_search"),
|
||||||
{
|
{
|
||||||
"want_cards": [f"{self.card1.pk}:1"],
|
"want_cards": [f"{self.card1.pk}:1"],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
|
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
|
||||||
|
|
@ -743,7 +821,7 @@ class TradeSearchTests(TestCase):
|
||||||
reverse("trade_offer_search"),
|
reverse("trade_offer_search"),
|
||||||
{
|
{
|
||||||
"have_cards": ["999999:1"], # Non-existent card ID
|
"have_cards": ["999999:1"], # Non-existent card ID
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
|
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
|
||||||
|
|
@ -758,7 +836,7 @@ class TradeSearchTests(TestCase):
|
||||||
reverse("trade_offer_search"),
|
reverse("trade_offer_search"),
|
||||||
{
|
{
|
||||||
"have_cards": [f"{self.card2.pk}:1"],
|
"have_cards": [f"{self.card2.pk}:1"],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
|
self.assertNotContains(response, self.trade_offer1.initiated_by.in_game_name)
|
||||||
|
|
@ -775,28 +853,48 @@ class TradeAcceptanceComplexTests(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.initiator_fc = FriendCode.objects.create(
|
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(
|
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
|
# Create test cards with proper rarity levels
|
||||||
self.card1 = Card.objects.create(
|
self.card1 = Card.objects.create(
|
||||||
name="ComplexCard1", cardset="cx1", cardnum=1, style="default",
|
name="ComplexCard1",
|
||||||
rarity_icon=RARITY_MAPPING[6], rarity_level=6
|
cardset="cx1",
|
||||||
|
cardnum=1,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[6],
|
||||||
|
rarity_level=6,
|
||||||
)
|
)
|
||||||
self.card2 = Card.objects.create(
|
self.card2 = Card.objects.create(
|
||||||
name="ComplexCard2", cardset="cx1", cardnum=2, style="default",
|
name="ComplexCard2",
|
||||||
rarity_icon=RARITY_MAPPING[6], rarity_level=6
|
cardset="cx1",
|
||||||
|
cardnum=2,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[6],
|
||||||
|
rarity_level=6,
|
||||||
)
|
)
|
||||||
self.card3 = Card.objects.create(
|
self.card3 = Card.objects.create(
|
||||||
name="ComplexCard3", cardset="cx1", cardnum=3, style="default",
|
name="ComplexCard3",
|
||||||
rarity_icon=RARITY_MAPPING[6], rarity_level=6
|
cardset="cx1",
|
||||||
|
cardnum=3,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[6],
|
||||||
|
rarity_level=6,
|
||||||
)
|
)
|
||||||
self.card4 = Card.objects.create(
|
self.card4 = Card.objects.create(
|
||||||
name="ComplexCard4", cardset="cx1", cardnum=4, style="default",
|
name="ComplexCard4",
|
||||||
rarity_icon=RARITY_MAPPING[6], rarity_level=6
|
cardset="cx1",
|
||||||
|
cardnum=4,
|
||||||
|
style="default",
|
||||||
|
rarity_icon=RARITY_MAPPING[6],
|
||||||
|
rarity_level=6,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a trade offer with multiple quantities
|
# Create a trade offer with multiple quantities
|
||||||
|
|
@ -822,49 +920,58 @@ class TradeAcceptanceComplexTests(TestCase):
|
||||||
|
|
||||||
# Create first acceptance
|
# Create first acceptance
|
||||||
response1 = self.client.post(
|
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,
|
"accepted_by": self.acceptor_fc.pk,
|
||||||
"requested_card": self.card1.pk,
|
"requested_card": self.card1.pk,
|
||||||
"offered_card": self.card2.pk,
|
"offered_card": self.card2.pk,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response1.status_code, 302) # Successful creation
|
self.assertEqual(response1.status_code, 302) # Successful creation
|
||||||
|
|
||||||
# Create second acceptance
|
# Create second acceptance
|
||||||
response2 = self.client.post(
|
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,
|
"accepted_by": self.acceptor_fc.pk,
|
||||||
"requested_card": self.card1.pk,
|
"requested_card": self.card1.pk,
|
||||||
"offered_card": self.card2.pk,
|
"offered_card": self.card2.pk,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response2.status_code, 302) # Successful creation
|
self.assertEqual(response2.status_code, 302) # Successful creation
|
||||||
|
|
||||||
# Try to create a fourth acceptance (should fail as only 3 are allowed)
|
# Try to create a fourth acceptance (should fail as only 3 are allowed)
|
||||||
response3 = self.client.post(
|
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,
|
"accepted_by": self.acceptor_fc.pk,
|
||||||
"requested_card": self.card1.pk,
|
"requested_card": self.card1.pk,
|
||||||
"offered_card": self.card2.pk,
|
"offered_card": self.card2.pk,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response3.status_code, 302) # Successful creation
|
self.assertEqual(response3.status_code, 302) # Successful creation
|
||||||
|
|
||||||
response4 = self.client.post(
|
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,
|
"accepted_by": self.acceptor_fc.pk,
|
||||||
"requested_card": self.card1.pk,
|
"requested_card": self.card1.pk,
|
||||||
"offered_card": self.card2.pk,
|
"offered_card": self.card2.pk,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response4.status_code, 200) # Should fail
|
self.assertEqual(response4.status_code, 200) # Should fail
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.trade_offer.acceptances.count(), 3,
|
self.trade_offer.acceptances.count(),
|
||||||
"Should not allow more acceptances than the quantity limit"
|
3,
|
||||||
|
"Should not allow more acceptances than the quantity limit",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_complex_state_transitions(self):
|
def test_complex_state_transitions(self):
|
||||||
|
|
@ -890,14 +997,14 @@ class TradeAcceptanceComplexTests(TestCase):
|
||||||
for invalid_state in invalid_transitions:
|
for invalid_state in invalid_transitions:
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
|
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
|
||||||
{"state": invalid_state}
|
{"state": invalid_state},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200) # Should stay on form
|
self.assertEqual(response.status_code, 200) # Should stay on form
|
||||||
acceptance.refresh_from_db()
|
acceptance.refresh_from_db()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
acceptance.state,
|
acceptance.state,
|
||||||
TradeAcceptance.AcceptanceState.ACCEPTED,
|
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
|
# Test valid state transition sequence
|
||||||
|
|
@ -912,14 +1019,12 @@ class TradeAcceptanceComplexTests(TestCase):
|
||||||
self.client.login(username=user.username, password="password")
|
self.client.login(username=user.username, password="password")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
|
reverse("trade_acceptance_update", kwargs={"pk": acceptance.pk}),
|
||||||
{"state": state}
|
{"state": state},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302) # Should redirect on success
|
self.assertEqual(response.status_code, 302) # Should redirect on success
|
||||||
acceptance.refresh_from_db()
|
acceptance.refresh_from_db()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
acceptance.state,
|
acceptance.state,
|
||||||
state,
|
state,
|
||||||
f"Valid transition to {state} should be allowed"
|
f"Valid transition to {state} should be allowed",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,24 @@ from .views import (
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("create/", TradeOfferCreateView.as_view(), name="trade_offer_create"),
|
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("", TradeOfferAllListView.as_view(), name="trade_offer_list"),
|
||||||
path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"),
|
path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"),
|
||||||
path("<int:pk>/", TradeOfferDetailView.as_view(), name="trade_offer_detail"),
|
path("<int:pk>/", TradeOfferDetailView.as_view(), name="trade_offer_detail"),
|
||||||
path("<int:pk>.png", TradeOfferPNGView.as_view(), name="trade_offer_png"),
|
path("<int:pk>.png", TradeOfferPNGView.as_view(), name="trade_offer_png"),
|
||||||
path("delete/<int:pk>/", TradeOfferDeleteView.as_view(), name="trade_offer_delete"),
|
path("delete/<int:pk>/", TradeOfferDeleteView.as_view(), name="trade_offer_delete"),
|
||||||
path("accept/<int:offer_pk>", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"),
|
path(
|
||||||
path("update/<int:pk>/", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"),
|
"accept/<int:offer_pk>",
|
||||||
|
TradeAcceptanceCreateView.as_view(),
|
||||||
|
name="trade_acceptance_create",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"update/<int:pk>/",
|
||||||
|
TradeAcceptanceUpdateView.as_view(),
|
||||||
|
name="trade_acceptance_update",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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 django.contrib import messages
|
||||||
from meta.views import Meta
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from .models import TradeOffer, TradeAcceptance
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from .forms import (TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm)
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.shortcuts import render
|
||||||
from django.template.loader import render_to_string
|
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 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.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):
|
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
|
model = TradeOffer
|
||||||
form_class = TradeOfferCreateForm
|
form_class = TradeOfferCreateForm
|
||||||
template_name = "trades/trade_offer_create.html"
|
template_name = "trades/trade_offer_create.html"
|
||||||
|
|
@ -42,20 +52,33 @@ class TradeOfferCreateView(LoginRequiredMixin, CreateView):
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
from pkmntrade_club.cards.models import Card
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
# Ensure available_cards is a proper QuerySet
|
# Ensure available_cards is a proper QuerySet
|
||||||
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level")
|
context["cards"] = (
|
||||||
|
Card.objects.filter(rarity__level__lte=5)
|
||||||
|
.select_related("rarity")
|
||||||
|
.prefetch_related("translations")
|
||||||
|
.order_by("translations__name", "rarity__level")
|
||||||
|
)
|
||||||
friend_codes = self.request.user.friend_codes.all()
|
friend_codes = self.request.user.friend_codes.all()
|
||||||
if "initiated_by" in self.request.GET:
|
if "initiated_by" in self.request.GET:
|
||||||
try:
|
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:
|
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:
|
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["friend_codes"] = friend_codes
|
||||||
context["selected_friend_code"] = selected_friend_code
|
context["selected_friend_code"] = selected_friend_code
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferAllListView(ReusablePaginationMixin, ListView):
|
class TradeOfferAllListView(ReusablePaginationMixin, ListView):
|
||||||
model = TradeOffer
|
model = TradeOffer
|
||||||
template_name = "trades/trade_offer_all_list.html"
|
template_name = "trades/trade_offer_all_list.html"
|
||||||
|
|
@ -93,14 +116,21 @@ class TradeOfferAllListView(ReusablePaginationMixin, ListView):
|
||||||
|
|
||||||
page_number = self.get_page_number()
|
page_number = self.get_page_number()
|
||||||
self.per_page = 10
|
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(
|
return render(
|
||||||
self.request,
|
self.request,
|
||||||
"trades/_trade_offer_list.html",
|
"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)
|
return super().render_to_response(context, **response_kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteView):
|
class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteView):
|
||||||
model = TradeOffer
|
model = TradeOffer
|
||||||
success_url = reverse_lazy("trade_offer_list")
|
success_url = reverse_lazy("trade_offer_list")
|
||||||
|
|
@ -108,8 +138,12 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
self.object = super().get_object()
|
self.object = super().get_object()
|
||||||
if self.object.initiated_by_id not in request.user.friend_codes.values_list("id", flat=True):
|
if self.object.initiated_by_id not in request.user.friend_codes.values_list(
|
||||||
raise PermissionDenied("You are not authorized to delete or close this trade offer.")
|
"id", flat=True
|
||||||
|
):
|
||||||
|
raise PermissionDenied(
|
||||||
|
"You are not authorized to delete or close this trade offer."
|
||||||
|
)
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
@ -144,7 +178,7 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi
|
||||||
if active_acceptances.exists():
|
if active_acceptances.exists():
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
request,
|
||||||
"Cannot close this trade offer while there are active acceptances. Please reject all acceptances before closing, or finish the trades."
|
"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()
|
context = self.get_context_data()
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
@ -158,6 +192,7 @@ class TradeOfferDeleteView(LoginRequiredMixin, FriendCodeRequiredMixin, DeleteVi
|
||||||
messages.success(request, "Trade offer has been deleted.")
|
messages.success(request, "Trade offer has been deleted.")
|
||||||
return super().delete(request, *args, **kwargs)
|
return super().delete(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferSearchView(ListView):
|
class TradeOfferSearchView(ListView):
|
||||||
"""
|
"""
|
||||||
Reworked trade offer search view using POST.
|
Reworked trade offer search view using POST.
|
||||||
|
|
@ -171,6 +206,7 @@ class TradeOfferSearchView(ListView):
|
||||||
(_search_results.html) is rendered. On GET (initial page load), the search results queryset
|
(_search_results.html) is rendered. On GET (initial page load), the search results queryset
|
||||||
is empty.
|
is empty.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = TradeOffer
|
model = TradeOffer
|
||||||
context_object_name = "search_results"
|
context_object_name = "search_results"
|
||||||
template_name = "trades/trade_offer_search.html"
|
template_name = "trades/trade_offer_search.html"
|
||||||
|
|
@ -246,8 +282,14 @@ class TradeOfferSearchView(ListView):
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
from pkmntrade_club.cards.models import Card
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
# Populate available_cards to re-populate the multiselects. Exclude cards with rarity level > 5.
|
# Populate available_cards to re-populate the multiselects. Exclude cards with rarity level > 5.
|
||||||
context["cards"] = Card.objects.filter(rarity_level__lte=5).order_by("name", "rarity_level")
|
context["cards"] = (
|
||||||
|
Card.objects.filter(rarity__level__lte=5)
|
||||||
|
.select_related("rarity")
|
||||||
|
.prefetch_related("translations")
|
||||||
|
.order_by("translations__name", "rarity__level")
|
||||||
|
)
|
||||||
if self.request.method == "POST":
|
if self.request.method == "POST":
|
||||||
context["have_cards"] = self.request.POST.getlist("have_cards")
|
context["have_cards"] = self.request.POST.getlist("have_cards")
|
||||||
context["want_cards"] = self.request.POST.getlist("want_cards")
|
context["want_cards"] = self.request.POST.getlist("want_cards")
|
||||||
|
|
@ -263,16 +305,19 @@ class TradeOfferSearchView(ListView):
|
||||||
"""
|
"""
|
||||||
if self.request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
if self.request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
||||||
return render(self.request, "trades/_search_results.html", context)
|
return render(self.request, "trades/_search_results.html", context)
|
||||||
else:
|
else:
|
||||||
return super().render_to_response(context, **response_kwargs)
|
return super().render_to_response(context, **response_kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferDetailView(DetailView):
|
class TradeOfferDetailView(DetailView):
|
||||||
"""
|
"""
|
||||||
Displays the details of a TradeOffer along with its active acceptances.
|
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,
|
If the offer is still open and the current user is not its initiator,
|
||||||
an acceptance form is provided to create a new acceptance.
|
an acceptance form is provided to create a new acceptance.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = TradeOffer
|
model = TradeOffer
|
||||||
template_name = "trades/trade_offer_detail.html"
|
template_name = "trades/trade_offer_detail.html"
|
||||||
|
|
||||||
|
|
@ -282,7 +327,9 @@ class TradeOfferDetailView(DetailView):
|
||||||
trade_offer = self.get_object()
|
trade_offer = self.get_object()
|
||||||
screenshot_mode = self.request.GET.get("screenshot_mode")
|
screenshot_mode = self.request.GET.get("screenshot_mode")
|
||||||
if 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
|
context["screenshot_mode"] = screenshot_mode
|
||||||
|
|
||||||
# Calculate the number of cards in each category.
|
# Calculate the number of cards in each category.
|
||||||
|
|
@ -317,12 +364,12 @@ class TradeOfferDetailView(DetailView):
|
||||||
image_height = int(round(image_width / aspect_ratio))
|
image_height = int(round(image_width / aspect_ratio))
|
||||||
|
|
||||||
# Build the meta tags with the computed dimensions.
|
# 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(
|
context["meta"] = Meta(
|
||||||
title=title,
|
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={
|
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",
|
"type": "image/png",
|
||||||
"width": image_width,
|
"width": image_width,
|
||||||
"height": image_height,
|
"height": image_height,
|
||||||
|
|
@ -347,7 +394,9 @@ class TradeOfferDetailView(DetailView):
|
||||||
context["acceptances"] = trade_offer.acceptances.all()
|
context["acceptances"] = trade_offer.acceptances.all()
|
||||||
|
|
||||||
# Option 1: Filter active acceptances using the queryset lookup.
|
# 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:
|
if self.request.user.is_authenticated:
|
||||||
user_friend_codes = self.request.user.friend_codes.all()
|
user_friend_codes = self.request.user.friend_codes.all()
|
||||||
|
|
@ -355,19 +404,26 @@ class TradeOfferDetailView(DetailView):
|
||||||
# Add context flag and deletion URL if the current user is the initiator
|
# Add context flag and deletion URL if the current user is the initiator
|
||||||
if trade_offer.initiated_by in user_friend_codes:
|
if trade_offer.initiated_by in user_friend_codes:
|
||||||
context["is_initiator"] = True
|
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:
|
else:
|
||||||
context["is_initiator"] = False
|
context["is_initiator"] = False
|
||||||
|
|
||||||
# Determine the user's default friend code (or fallback as needed).
|
# 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 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(
|
context["acceptance_form"] = TradeAcceptanceCreateForm(
|
||||||
trade_offer=trade_offer,
|
trade_offer=trade_offer,
|
||||||
friend_codes=user_friend_codes,
|
friend_codes=user_friend_codes,
|
||||||
default_friend_code=default_friend_code
|
default_friend_code=default_friend_code,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
context["is_initiator"] = False
|
context["is_initiator"] = False
|
||||||
|
|
@ -376,11 +432,15 @@ class TradeOfferDetailView(DetailView):
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, CreateView):
|
|
||||||
|
class TradeAcceptanceCreateView(
|
||||||
|
LoginRequiredMixin, FriendCodeRequiredMixin, CreateView
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
View to create a new TradeAcceptance.
|
View to create a new TradeAcceptance.
|
||||||
The URL should provide 'offer_pk' so that the proper TradeOffer can be identified.
|
The URL should provide 'offer_pk' so that the proper TradeOffer can be identified.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = TradeAcceptance
|
model = TradeAcceptance
|
||||||
form_class = TradeAcceptanceCreateForm
|
form_class = TradeAcceptanceCreateForm
|
||||||
template_name = "trades/trade_acceptance_create.html"
|
template_name = "trades/trade_acceptance_create.html"
|
||||||
|
|
@ -390,16 +450,18 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_trade_offer(self):
|
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):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
if (self.trade_offer.initiated_by_id in
|
if (
|
||||||
self.request.user.friend_codes.values_list("id", flat=True) or
|
self.trade_offer.initiated_by_id
|
||||||
self.trade_offer.is_closed):
|
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.")
|
raise PermissionDenied("You cannot accept this trade offer.")
|
||||||
kwargs['trade_offer'] = self.trade_offer
|
kwargs["trade_offer"] = self.trade_offer
|
||||||
kwargs['friend_codes'] = self.request.user.friend_codes.all()
|
kwargs["friend_codes"] = self.request.user.friend_codes.all()
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
@ -430,7 +492,13 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre
|
||||||
"acceptance_form": form,
|
"acceptance_form": form,
|
||||||
"friend_codes": friend_codes,
|
"friend_codes": friend_codes,
|
||||||
"is_initiator": is_initiator,
|
"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
|
# Render the detail page with the form errors
|
||||||
return render(self.request, "trades/trade_offer_detail.html", context)
|
return render(self.request, "trades/trade_offer_detail.html", context)
|
||||||
|
|
@ -439,20 +507,46 @@ class TradeAcceptanceCreateView(LoginRequiredMixin, FriendCodeRequiredMixin, Cre
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk})
|
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.
|
View to update the state of an existing TradeAcceptance.
|
||||||
The allowed state transitions are provided via the form.
|
The allowed state transitions are provided via the form.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = TradeAcceptance
|
model = TradeAcceptance
|
||||||
form_class = TradeAcceptanceTransitionForm
|
form_class = TradeAcceptanceTransitionForm
|
||||||
template_name = "trades/trade_acceptance_update.html"
|
template_name = "trades/trade_acceptance_update.html"
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
"""
|
||||||
|
Return the object the view is displaying.
|
||||||
|
Apply select_related for efficiency.
|
||||||
|
"""
|
||||||
|
if queryset is None:
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
|
||||||
|
pk = self.kwargs.get(self.pk_url_kwarg)
|
||||||
|
queryset = queryset.select_related(
|
||||||
|
"trade_offer__initiated_by__user",
|
||||||
|
"accepted_by__user",
|
||||||
|
"requested_card",
|
||||||
|
"offered_card",
|
||||||
|
).prefetch_related(
|
||||||
|
"requested_card__translations",
|
||||||
|
"offered_card__translations",
|
||||||
|
)
|
||||||
|
return queryset.get(pk=pk)
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
friend_codes = request.user.friend_codes.values_list("id", flat=True)
|
friend_codes = request.user.friend_codes.values_list("id", flat=True)
|
||||||
if (self.object.accepted_by_id not in friend_codes and
|
if (
|
||||||
self.object.trade_offer.initiated_by_id not in friend_codes):
|
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.")
|
raise PermissionDenied("You are not authorized to update this acceptance.")
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
@ -481,6 +575,7 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, FriendCodeRequiredMixin, Upd
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk})
|
return reverse_lazy("trade_acceptance_update", kwargs={"pk": self.object.pk})
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferPNGView(View):
|
class TradeOfferPNGView(View):
|
||||||
"""
|
"""
|
||||||
Generate a PNG screenshot of the rendered trade offer detail page using Playwright.
|
Generate a PNG screenshot of the rendered trade offer detail page using Playwright.
|
||||||
|
|
@ -488,15 +583,17 @@ class TradeOfferPNGView(View):
|
||||||
runs at a time for a given TradeOffer. The generated PNG is then cached in the
|
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).
|
TradeOffer model's `image` field (assumed to be an ImageField).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_lock_key(self, trade_offer_id):
|
def get_lock_key(self, trade_offer_id):
|
||||||
# Use the trade_offer_id as the lock key; adjust if needed.
|
# Use the trade_offer_id as the lock key; adjust if needed.
|
||||||
return trade_offer_id
|
return trade_offer_id
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
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
|
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 the image is already generated and stored, serve it directly.
|
||||||
if trade_offer.image and not request.GET.get("debug"):
|
if trade_offer.image and not request.GET.get("debug"):
|
||||||
|
|
@ -505,6 +602,7 @@ class TradeOfferPNGView(View):
|
||||||
|
|
||||||
# Acquire PostgreSQL advisory lock to prevent concurrent generation.
|
# Acquire PostgreSQL advisory lock to prevent concurrent generation.
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
|
||||||
lock_key = self.get_lock_key(trade_offer.pk)
|
lock_key = self.get_lock_key(trade_offer.pk)
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key])
|
cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key])
|
||||||
|
|
@ -516,16 +614,20 @@ class TradeOfferPNGView(View):
|
||||||
return HttpResponse(trade_offer.image.read(), content_type="image/png")
|
return HttpResponse(trade_offer.image.read(), content_type="image/png")
|
||||||
|
|
||||||
tag_context = render_trade_offer_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_width = tag_context.get("image_width")
|
||||||
image_height = tag_context.get('image_height')
|
image_height = tag_context.get("image_height")
|
||||||
if not image_width or not 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(
|
html = render_to_string(
|
||||||
"templatetags/trade_offer_png.html",
|
"templatetags/trade_offer_png.html",
|
||||||
context=tag_context,
|
context=tag_context,
|
||||||
request=request
|
request=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
# if query string has "debug", render the HTML instead of the PNG
|
# if query string has "debug", render the HTML instead of the PNG
|
||||||
|
|
@ -545,13 +647,20 @@ class TradeOfferPNGView(View):
|
||||||
"--disable-audio-output",
|
"--disable-audio-output",
|
||||||
"--disable-webgl",
|
"--disable-webgl",
|
||||||
"--no-first-run",
|
"--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 = context_browser.new_page()
|
||||||
page.on("console", lambda msg: print(f"Console {msg.type}: {msg.text}"))
|
page.on("console", lambda msg: print(f"Console {msg.type}: {msg.text}"))
|
||||||
page.on("pageerror", lambda err: print(f"Page error: {err}"))
|
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")
|
page.set_content(html, wait_until="networkidle")
|
||||||
element = page.wait_for_selector(".trade-offer-card-screenshot")
|
element = page.wait_for_selector(".trade-offer-card-screenshot")
|
||||||
screenshot_bytes = element.screenshot(type="png", omit_background=True)
|
screenshot_bytes = element.screenshot(type="png", omit_background=True)
|
||||||
|
|
@ -567,11 +676,13 @@ class TradeOfferPNGView(View):
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
|
cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
|
||||||
|
|
||||||
|
|
||||||
class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
||||||
"""
|
"""
|
||||||
Processes a two-step create for TradeOffer; on confirmation,
|
Processes a two-step create for TradeOffer; on confirmation,
|
||||||
commits the offer and shows form errors if any occur.
|
commits the offer and shows form errors if any occur.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
if "confirm" in request.POST:
|
if "confirm" in request.POST:
|
||||||
return self._commit_offer(request)
|
return self._commit_offer(request)
|
||||||
|
|
@ -605,17 +716,26 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
||||||
}
|
}
|
||||||
# Supply additional context required by trade_offer_create.html.
|
# Supply additional context required by trade_offer_create.html.
|
||||||
from pkmntrade_club.cards.models import Card
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"form": form,
|
"form": form,
|
||||||
"friend_codes": request.user.friend_codes.all(),
|
"friend_codes": request.user.friend_codes.all(),
|
||||||
"selected_friend_code": (
|
"selected_friend_code": (
|
||||||
request.user.default_friend_code or request.user.friend_codes.first()
|
request.user.default_friend_code
|
||||||
|
or request.user.friend_codes.first()
|
||||||
|
),
|
||||||
|
"cards": (
|
||||||
|
Card.objects.filter(rarity__level__lte=5)
|
||||||
|
.select_related("rarity")
|
||||||
|
.prefetch_related("translations")
|
||||||
|
.order_by("translations__name", "rarity__level")
|
||||||
),
|
),
|
||||||
"cards": Card.objects.all().order_by("name", "rarity_level"),
|
|
||||||
}
|
}
|
||||||
return render(request, "trades/trade_offer_create.html", context)
|
return render(request, "trades/trade_offer_create.html", context)
|
||||||
messages.success(request, "Trade offer created successfully!")
|
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:
|
else:
|
||||||
# When the form is not valid, update its initial data as well:
|
# When the form is not valid, update its initial data as well:
|
||||||
form.initial = {
|
form.initial = {
|
||||||
|
|
@ -624,13 +744,20 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
||||||
"initiated_by": request.POST.get("initiated_by"),
|
"initiated_by": request.POST.get("initiated_by"),
|
||||||
}
|
}
|
||||||
from pkmntrade_club.cards.models import Card
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"form": form,
|
"form": form,
|
||||||
"friend_codes": request.user.friend_codes.all(),
|
"friend_codes": request.user.friend_codes.all(),
|
||||||
"selected_friend_code": (
|
"selected_friend_code": (
|
||||||
request.user.default_friend_code or request.user.friend_codes.first()
|
request.user.default_friend_code
|
||||||
|
or request.user.friend_codes.first()
|
||||||
|
),
|
||||||
|
"cards": (
|
||||||
|
Card.objects.filter(rarity__level__lte=5)
|
||||||
|
.select_related("rarity")
|
||||||
|
.prefetch_related("translations")
|
||||||
|
.order_by("translations__name", "rarity__level")
|
||||||
),
|
),
|
||||||
"cards": Card.objects.all().order_by("name", "rarity_level"),
|
|
||||||
}
|
}
|
||||||
return render(request, "trades/trade_offer_create.html", context)
|
return render(request, "trades/trade_offer_create.html", context)
|
||||||
|
|
||||||
|
|
@ -641,6 +768,7 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
||||||
query_params.pop("confirm", None)
|
query_params.pop("confirm", None)
|
||||||
query_params.pop("preview", None)
|
query_params.pop("preview", None)
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
base_url = reverse("trade_offer_create")
|
base_url = reverse("trade_offer_create")
|
||||||
url_with_params = f"{base_url}?{query_params.urlencode()}"
|
url_with_params = f"{base_url}?{query_params.urlencode()}"
|
||||||
return HttpResponseRedirect(url_with_params)
|
return HttpResponseRedirect(url_with_params)
|
||||||
|
|
@ -656,15 +784,21 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
||||||
"initiated_by": request.POST.get("initiated_by"),
|
"initiated_by": request.POST.get("initiated_by"),
|
||||||
}
|
}
|
||||||
from pkmntrade_club.cards.models import Card
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"form": form,
|
"form": form,
|
||||||
"friend_codes": request.user.friend_codes.all(),
|
"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
|
||||||
"cards": Card.objects.all().order_by("name", "rarity_level"),
|
or request.user.friend_codes.first(),
|
||||||
|
"cards": (
|
||||||
|
Card.objects.filter(rarity__level__lte=5)
|
||||||
|
.select_related("rarity")
|
||||||
|
.prefetch_related("translations")
|
||||||
|
.order_by("translations__name", "rarity__level")
|
||||||
|
),
|
||||||
}
|
}
|
||||||
return render(request, "trades/trade_offer_create.html", context)
|
return render(request, "trades/trade_offer_create.html", context)
|
||||||
|
|
||||||
|
|
||||||
# Parse the card selections for "have" and "want" cards.
|
# Parse the card selections for "have" and "want" cards.
|
||||||
have_selections = self._parse_card_selections("have_cards")
|
have_selections = self._parse_card_selections("have_cards")
|
||||||
want_selections = self._parse_card_selections("want_cards")
|
want_selections = self._parse_card_selections("want_cards")
|
||||||
|
|
@ -672,7 +806,9 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
||||||
from pkmntrade_club.cards.models import Card
|
from pkmntrade_club.cards.models import Card
|
||||||
|
|
||||||
have_cards_ids = [card_id for card_id, _ in have_selections]
|
have_cards_ids = [card_id for card_id, _ in have_selections]
|
||||||
cards_have_qs = Card.objects.filter(pk__in=have_cards_ids)
|
cards_have_qs = Card.objects.filter(pk__in=have_cards_ids).select_related(
|
||||||
|
"rarity", "cardset"
|
||||||
|
)
|
||||||
cards_have_dict = {card.pk: card for card in cards_have_qs}
|
cards_have_dict = {card.pk: card for card in cards_have_qs}
|
||||||
|
|
||||||
# Define a dummy wrapper for a trade offer card entry.
|
# Define a dummy wrapper for a trade offer card entry.
|
||||||
|
|
@ -689,7 +825,9 @@ class TradeOfferCreateConfirmView(LoginRequiredMixin, View):
|
||||||
have_offer_cards.append(DummyOfferCard(card, quantity))
|
have_offer_cards.append(DummyOfferCard(card, quantity))
|
||||||
|
|
||||||
want_cards_ids = [card_id for card_id, _ in want_selections]
|
want_cards_ids = [card_id for card_id, _ in want_selections]
|
||||||
cards_want_qs = Card.objects.filter(pk__in=want_cards_ids)
|
cards_want_qs = Card.objects.filter(pk__in=want_cards_ids).select_related(
|
||||||
|
"rarity", "cardset"
|
||||||
|
)
|
||||||
cards_want_dict = {card.pk: card for card in cards_want_qs}
|
cards_want_dict = {card.pk: card for card in cards_want_qs}
|
||||||
want_offer_cards = []
|
want_offer_cards = []
|
||||||
for card_id, quantity in want_selections:
|
for card_id, quantity in want_selections:
|
||||||
|
|
|
||||||
86
uv.lock
generated
86
uv.lock
generated
|
|
@ -34,7 +34,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "celery"
|
name = "celery"
|
||||||
version = "5.5.2"
|
version = "5.5.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "billiard" },
|
{ name = "billiard" },
|
||||||
|
|
@ -46,9 +46,9 @@ dependencies = [
|
||||||
{ name = "python-dateutil" },
|
{ name = "python-dateutil" },
|
||||||
{ name = "vine" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -315,14 +315,14 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-health-check"
|
name = "django-health-check"
|
||||||
version = "3.18.3"
|
version = "3.19.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -468,36 +468,35 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.2.2"
|
version = "3.2.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/07/f4/b2a26a309a04fb844c7406a4501331b9400e1dd7dd64d3450472fd47d2e1/greenlet-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b24c7844c0a0afc3ccbeb0b807adeefb7eff2b5599229ecedddcfeb0ef333bec", size = 296239 },
|
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/90/2e/59d6491834b6e289051b252cf4776d16da51c7c6ca6a87ff97e3a50aa0cd/greenlet-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421", size = 296023 },
|
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/df/b7d17d66c8d0f578d2885a3d8f565e9e4725eacc9d3fdc946d0031c055c4/greenlet-3.2.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:9ea5231428af34226c05f927e16fc7f6fa5e39e3ad3cd24ffa48ba53a47f4240", size = 269899 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -523,16 +522,17 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kombu"
|
name = "kombu"
|
||||||
version = "5.5.3"
|
version = "5.5.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "amqp" },
|
{ name = "amqp" },
|
||||||
|
{ name = "packaging" },
|
||||||
{ name = "tzdata" },
|
{ name = "tzdata" },
|
||||||
{ name = "vine" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -848,11 +848,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "6.1.0"
|
version = "6.2.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -885,11 +885,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "setuptools"
|
name = "setuptools"
|
||||||
version = "80.8.0"
|
version = "80.9.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue