Compare commits

...
Sign in to create a new pull request.

5 commits
main ... dev

Author SHA1 Message Date
ecb060af6d
feat(cards): Refactor card styling and implement JS multiselect
Refactors card styling by moving color data to the `CardSet` model, removing the `Card.style` and `Pack.hex_color` fields.

- Adds `hex_color` to `CardSet` and a `CardSetColorMapping` model to populate it during import.
- Card importer now uses correct filename regexes to identify sets and apply color mappings.
- Card badge styling is now derived from the `CardSet`'s color, simplifying the data model.

Replaces the old clunky card multiselect with a dynamic Alpine.js component for an improved user experience.

- Introduces an API endpoint (`cards/api/search/`) for asynchronous searching.
- Provides a modern search-as-you-type interface with a `<noscript>` fallback.
2025-06-20 00:30:03 -07:00
af2f48a491
refactor(db): update cursor rules and enhance deployment rollback script
- Standardized string formatting in cursor rules for consistency.
- Added a new rollback deployment script to facilitate blue-green deployment strategy.
- Removed outdated seed data files and introduced new rarity mappings for better data management.
- Improved model relationships and query optimizations in various views and admin configurations.
- Enhanced caching strategies across templates to improve performance and reduce load times, including jitter in cache settings for better performance.
- Refactored card-related views and templates to utilize new model fields and relationships.
2025-06-19 15:42:36 -07:00
39a002e394
style: standardize string formatting and improve readability across multiple files
- Refactored string formatting in various files to use consistent double quotes.
- Improved readability by adding newlines in function definitions and method calls.
- Cleaned up unnecessary imports and ensured proper spacing for better code clarity.
- Updated management commands and context processors for consistent formatting.
- Enhanced the overall maintainability of the codebase by adhering to PEP 8 guidelines.
- Applied Ruff linting and formatting
2025-06-12 20:53:38 -07:00
4b9e4f651e
refactor(db): initial, incomplete work to update model and re-normalize fields 2025-06-12 17:06:21 -07:00
30ce126a07
feat(deploy): implement blue-green deployment strategy
This commit replaces the previous deployment mechanism with a blue-green strategy to lay the groundwork for zero-downtime deployments.
Key changes:
Introduces a deploy-blue-green.sh script to manage "blue" and "green" container sets, creating versioned releases.
Updates the Anubis gatekeeper template to dynamically route traffic based on the active deployment color, allowing for seamless traffic switching.
Modifies Docker Compose files to include color-specific labels and environment variables.
Adapts the GitHub Actions workflow to execute the new blue-green deployment process.
Removes the old, now-obsolete deployment and health check scripts.
Note: Automated rollback on health check failure is not yet implemented. Downgrades can be performed manually by switching the active color.
2025-06-12 16:58:55 -07:00
86 changed files with 6186 additions and 15295 deletions

View file

@ -10,9 +10,9 @@ Key Principles
Django/Python Django/Python
- Use Djangos 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 Djangos 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 Djangos 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 Djangos 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 Djangos caching framework to optimize performance for frequently accessed data. - Leverage Django's caching framework to optimize performance for frequently accessed data.
- Use Djangos 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 Djangos 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 Djangos 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

View file

@ -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"

View file

@ -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
View file

@ -0,0 +1 @@
../server/scripts/common-lib.sh

207
scripts/deploy-blue-green.sh Executable file
View 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}"

View file

@ -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
View file

View 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

View file

@ -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
View 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

View file

@ -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

View file

@ -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
View 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"

View file

@ -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"
}
}
]

View 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}}]

View 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
}
}
]

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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"]

View file

@ -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"]

View file

@ -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

View file

@ -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"

View file

@ -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
View 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
View 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

View file

@ -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"]

View file

@ -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,
} }

View file

@ -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

View file

@ -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",)

View file

@ -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"

View file

@ -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"]

View file

@ -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")

View file

@ -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",
),
), ),
] ]

View file

@ -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)

View file

@ -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("&", "&amp;") params = params.replace("&", "&amp;")
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):
""" """

View file

@ -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)

View file

@ -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"),
] ]

View file

@ -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)

View file

@ -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

View file

@ -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),
), ),
] ]

View file

@ -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):

View file

@ -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}'"

View file

@ -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"])

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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",
),
] ]

View file

@ -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)

View file

@ -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,
} }

View file

@ -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

View file

@ -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):
""" """

View file

@ -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",)

View file

@ -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()

View file

@ -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}")

View file

@ -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
@ -325,7 +356,7 @@ INTERNAL_IPS = [
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
for ip in ips: for ip in ips:
INTERNAL_IPS.append(ip) INTERNAL_IPS.append(ip)
INTERNAL_IPS.append(".".join(ip.rsplit(".")[:-1])+ ".1") INTERNAL_IPS.append(".".join(ip.rsplit(".")[:-1]) + ".1")
# https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model # https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model
AUTH_USER_MODEL = "accounts.CustomUser" AUTH_USER_MODEL = "accounts.CustomUser"
@ -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}

View file

@ -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()

View file

@ -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()

View file

@ -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)

View file

@ -1,31 +1,35 @@
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"
#@silk_profile(name='Home Page') # @silk_profile(name='Home Page')
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
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

View file

@ -6,5 +6,5 @@ RARITY_MAPPING = {
5: "⭐️", 5: "⭐️",
6: "⭐️⭐️", 6: "⭐️⭐️",
7: "⭐️⭐️⭐️", 7: "⭐️⭐️⭐️",
8: "👑" 8: "👑",
} }

View file

@ -2,4 +2,4 @@ from django.apps import AppConfig
class ThemeConfig(AppConfig): class ThemeConfig(AppConfig):
name = 'pkmntrade_club.theme' name = "pkmntrade_club.theme"

View file

@ -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>
&rsaquo; <a href="{% url 'admin:app_list' app_label='cards' %}">Cards</a>
&rsaquo; {% 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 %}

View file

@ -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>

View file

@ -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 }} &bull; {{ card.rarity_icon }}</h2> <h2 class="text-lg text-gray-500">{{ card.cardset }} #{{ card.cardnum }} &bull; {{ card.rarity.icon }}</h2>
</div> </div>
</div> </div>

View file

@ -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 %}

View file

@ -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>

View file

@ -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 %}

View file

@ -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">
&times;
</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">
&times;
</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 %}

View file

@ -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"
x-ref="selectElement"
>
<template x-for="card in selectedCards" :key="card.id">
<option :value="card.id" selected></option>
</template>
</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"
>
&times;
</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 %} {% endif %}
{% endcache %}
</select> {% 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>

View file

@ -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 %}">

View file

@ -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 %}"

View file

@ -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 %}

View file

@ -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 %}"
> >

View file

@ -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",
)
)

View file

@ -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

View file

@ -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

View file

@ -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",
),
), ),
] ]

View file

@ -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)

View file

@ -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():
@ -453,7 +524,7 @@ class TradeAcceptance(models.Model):
self.AcceptanceState.THANKED_BY_INITIATOR, self.AcceptanceState.THANKED_BY_INITIATOR,
self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_INITIATOR,
}, },
self.AcceptanceState.THANKED_BY_INITIATOR: { }, self.AcceptanceState.THANKED_BY_INITIATOR: {},
self.AcceptanceState.THANKED_BY_ACCEPTOR: { self.AcceptanceState.THANKED_BY_ACCEPTOR: {
self.AcceptanceState.REJECTED_BY_INITIATOR, self.AcceptanceState.REJECTED_BY_INITIATOR,
self.AcceptanceState.THANKED_BY_BOTH, self.AcceptanceState.THANKED_BY_BOTH,
@ -469,10 +540,10 @@ class TradeAcceptance(models.Model):
self.AcceptanceState.REJECTED_BY_ACCEPTOR, self.AcceptanceState.REJECTED_BY_ACCEPTOR,
}, },
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: {
self.AcceptanceState.THANKED_BY_BOTH, self.AcceptanceState.THANKED_BY_BOTH,
}, },

View file

@ -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'])

View file

@ -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)

View file

@ -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",
) )

View file

@ -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",
),
] ]

View file

@ -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"
@ -198,7 +234,7 @@ class TradeOfferSearchView(ListView):
results.append((card_id, qty)) results.append((card_id, qty))
return results return results
#@silk_profile(name="Trade Offer Search- Get Queryset") # @silk_profile(name="Trade Offer Search- Get Queryset")
def get_queryset(self): def get_queryset(self):
# For a GET request (initial load), return an empty queryset. # For a GET request (initial load), return an empty queryset.
if self.request.method == "GET": if self.request.method == "GET":
@ -237,17 +273,23 @@ class TradeOfferSearchView(ListView):
return qs.distinct() return qs.distinct()
#@silk_profile(name="Trade Offer Search- Post") # @silk_profile(name="Trade Offer Search- Post")
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
# For POST, simply process the search through get(). # For POST, simply process the search through get().
return self.get(request, *args, **kwargs) return self.get(request, *args, **kwargs)
#@silk_profile(name="Trade Offer Search- Get Context Data") # @silk_profile(name="Trade Offer Search- Get Context Data")
def get_context_data(self, **kwargs): 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")
@ -256,33 +298,38 @@ class TradeOfferSearchView(ListView):
context["want_cards"] = [] context["want_cards"] = []
return context return context
#@silk_profile(name="Trade Offer Search- Render to Response") # @silk_profile(name="Trade Offer Search- Render to Response")
def render_to_response(self, context, **response_kwargs): def render_to_response(self, context, **response_kwargs):
""" """
Render the AJAX fragment if the request is AJAX; otherwise, render the complete page. Render the AJAX fragment if the request is AJAX; otherwise, render the complete page.
""" """
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"
#@silk_profile(name="Trade Offer Detail- Get Context Data") # @silk_profile(name="Trade Offer Detail- Get Context Data")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
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
View file

@ -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]]