diff --git a/.dockerignore b/.dockerignore index 70ea998..db0ca2a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -70,6 +70,7 @@ docs/ # CI/CD .github/ .gitlab-ci.yml +.gitlab/ # OS .DS_Store diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..43328ce --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,237 @@ +stages: + - test + - build + - deploy + +variables: + DOCKER_IMAGE: yourusername/discord-bot-v2 + DOCKER_DRIVER: overlay2 + # Semantic versioning - update these for releases + VERSION_MAJOR: "2" + VERSION_MINOR: "1" + +# Test on all branches +test: + stage: test + image: python:3.11-slim + before_script: + - cd discord-app-v2 + - pip install --cache-dir .cache/pip -r requirements.txt + script: + - python -m pytest --tb=short -q --cov=. --cov-report=term-missing + cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - .cache/pip + only: + - branches + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: discord-app-v2/coverage.xml + +# Build with versioned tags +build: + stage: build + image: docker:24-dind + services: + - docker:24-dind + before_script: + - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD + script: + - cd discord-app-v2 + + # Calculate version tags + - export VERSION_PATCH=${CI_PIPELINE_IID} + - export FULL_VERSION="v${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}" + - export SHORT_SHA=${CI_COMMIT_SHORT_SHA} + - export BRANCH_TAG="${CI_COMMIT_REF_SLUG}-${SHORT_SHA}" + + # Build once, tag multiple times + - | + docker build \ + --build-arg VERSION=${FULL_VERSION} \ + --build-arg GIT_COMMIT=${CI_COMMIT_SHA} \ + --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -t ${DOCKER_IMAGE}:${FULL_VERSION} \ + -t ${DOCKER_IMAGE}:${SHORT_SHA} \ + -t ${DOCKER_IMAGE}:${BRANCH_TAG} \ + . + + # Tag as latest only for main branch + - | + if [ "$CI_COMMIT_BRANCH" == "main" ]; then + docker tag ${DOCKER_IMAGE}:${FULL_VERSION} ${DOCKER_IMAGE}:latest + fi + + # Tag as staging for develop branch + - | + if [ "$CI_COMMIT_BRANCH" == "develop" ]; then + docker tag ${DOCKER_IMAGE}:${FULL_VERSION} ${DOCKER_IMAGE}:staging + fi + + # Push all tags + - docker push ${DOCKER_IMAGE}:${FULL_VERSION} + - docker push ${DOCKER_IMAGE}:${SHORT_SHA} + - docker push ${DOCKER_IMAGE}:${BRANCH_TAG} + - | + if [ "$CI_COMMIT_BRANCH" == "main" ]; then + docker push ${DOCKER_IMAGE}:latest + fi + - | + if [ "$CI_COMMIT_BRANCH" == "develop" ]; then + docker push ${DOCKER_IMAGE}:staging + fi + + # Save version info for deployment + - echo "FULL_VERSION=${FULL_VERSION}" > version.env + - echo "SHORT_SHA=${SHORT_SHA}" >> version.env + - echo "BRANCH_TAG=${BRANCH_TAG}" >> version.env + + artifacts: + reports: + dotenv: discord-app-v2/version.env + + only: + - main + - develop + - tags + +# Deploy to staging (automatic for develop branch) +deploy:staging: + stage: deploy + image: alpine:latest + needs: + - build + before_script: + - apk add --no-cache openssh-client + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H $VPS_HOST >> ~/.ssh/known_hosts + script: + - echo "Deploying version ${FULL_VERSION} to staging..." + - | + ssh $VPS_USER@$VPS_HOST << EOF + cd /path/to/discord-bot-staging + + # Backup current version + docker inspect discord-bot-staging --format='{{.Image}}' > .last_version || true + + # Update docker-compose with specific version + sed -i 's|image: ${DOCKER_IMAGE}:.*|image: ${DOCKER_IMAGE}:staging|' docker-compose.yml + + # Pull and deploy + docker-compose pull + docker-compose up -d + + # Wait for health check + sleep 10 + if docker-compose ps | grep -q "Up (healthy)"; then + echo "✅ Deployment successful!" + docker image prune -f + else + echo "❌ Health check failed!" + exit 1 + fi + EOF + environment: + name: staging + url: https://staging-bot.yourdomain.com + only: + - develop + +# Deploy to production (manual approval required) +deploy:production: + stage: deploy + image: alpine:latest + needs: + - build + before_script: + - apk add --no-cache openssh-client + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H $VPS_HOST >> ~/.ssh/known_hosts + script: + - echo "Deploying version ${FULL_VERSION} to production..." + - | + ssh $VPS_USER@$VPS_HOST << EOF + cd /path/to/discord-bot + + # Backup current version for rollback + docker inspect discord-bot --format='{{.Image}}' > .last_version || true + echo "${FULL_VERSION}" > .deployed_version + + # Create deployment record + echo "$(date -Iseconds) | ${FULL_VERSION} | ${CI_COMMIT_SHORT_SHA} | ${CI_COMMIT_MESSAGE}" >> deployments.log + + # Update docker-compose with specific version tag + sed -i 's|image: ${DOCKER_IMAGE}:.*|image: ${DOCKER_IMAGE}:${FULL_VERSION}|' docker-compose.yml + + # Pull and deploy + docker-compose pull + docker-compose up -d + + # Wait for health check + sleep 10 + if docker-compose ps | grep -q "Up (healthy)"; then + echo "✅ Deployment successful!" + echo "Deployed: ${FULL_VERSION}" + docker image prune -f + else + echo "❌ Health check failed! Rolling back..." + LAST_VERSION=\$(cat .last_version) + sed -i "s|image: ${DOCKER_IMAGE}:.*|image: \${LAST_VERSION}|" docker-compose.yml + docker-compose up -d + exit 1 + fi + EOF + environment: + name: production + url: https://bot.yourdomain.com + when: manual # Require manual approval + only: + - main + - tags + +# Rollback job (manual trigger) +rollback:production: + stage: deploy + image: alpine:latest + before_script: + - apk add --no-cache openssh-client + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H $VPS_HOST >> ~/.ssh/known_hosts + script: + - | + ssh $VPS_USER@$VPS_HOST << 'EOF' + cd /path/to/discord-bot + + # Show recent deployments + echo "Recent deployments:" + tail -n 10 deployments.log + + # Get last successful version + LAST_VERSION=$(cat .last_version) + echo "" + echo "Rolling back to: ${LAST_VERSION}" + + # Rollback + sed -i "s|image: ${DOCKER_IMAGE}:.*|image: ${LAST_VERSION}|" docker-compose.yml + docker-compose up -d + + # Record rollback + echo "$(date -Iseconds) | ROLLBACK | ${LAST_VERSION}" >> deployments.log + + echo "✅ Rollback complete!" + EOF + environment: + name: production + action: rollback + when: manual + only: + - main diff --git a/.gitlab/DEPLOYMENT_SETUP.md b/.gitlab/DEPLOYMENT_SETUP.md new file mode 100644 index 0000000..2493d99 --- /dev/null +++ b/.gitlab/DEPLOYMENT_SETUP.md @@ -0,0 +1,536 @@ +# GitLab CI/CD Deployment Setup Guide + +This guide will help you set up the complete CI/CD pipeline for Discord Bot v2.0. + +--- + +## 📋 Prerequisites + +- GitLab account (free tier) +- Docker Hub account +- SSH access to your Ubuntu VPS +- Git repository with Discord Bot v2.0 code + +--- + +## 🚀 Step 1: GitLab Setup (5 minutes) + +### 1.1 Create GitLab Project + +```bash +# Option A: Mirror from existing GitHub repo +git remote add gitlab git@gitlab.com:yourusername/discord-bot.git +git push gitlab main + +# Option B: Create new GitLab repo and push +# 1. Go to gitlab.com +# 2. Click "New Project" +# 3. Name it "discord-bot" +# 4. Set visibility to "Private" +# 5. Create project +# 6. Follow instructions to push existing repository +``` + +### 1.2 Add CI/CD Variables + +Go to: **Settings > CI/CD > Variables** + +Add the following variables (all marked as "Protected" and "Masked"): + +| Variable | Value | Description | +|----------|-------|-------------| +| `DOCKER_USERNAME` | your-docker-hub-username | Docker Hub login | +| `DOCKER_PASSWORD` | your-docker-hub-token | Docker Hub access token (NOT password) | +| `SSH_PRIVATE_KEY` | your-ssh-private-key | SSH key for VPS access (see below) | +| `VPS_HOST` | your.vps.ip.address | VPS IP or hostname | +| `VPS_USER` | your-vps-username | SSH username (usually `ubuntu` or `root`) | + +**Important Notes:** +- For `DOCKER_PASSWORD`: Use a Docker Hub access token, not your password + - Go to hub.docker.com > Account Settings > Security > New Access Token +- For `SSH_PRIVATE_KEY`: Copy your entire private key including headers + - `cat ~/.ssh/id_rsa` (or whatever key you use) + - Include `-----BEGIN OPENSSH PRIVATE KEY-----` and `-----END OPENSSH PRIVATE KEY-----` + +--- + +## 🔑 Step 2: SSH Key Setup for VPS + +### 2.1 Generate SSH Key (if you don't have one) + +```bash +# On your local machine +ssh-keygen -t ed25519 -C "gitlab-ci@discord-bot" -f ~/.ssh/gitlab_ci_bot + +# Copy public key to VPS +ssh-copy-id -i ~/.ssh/gitlab_ci_bot.pub your-user@your-vps-host +``` + +### 2.2 Add Private Key to GitLab + +```bash +# Copy private key +cat ~/.ssh/gitlab_ci_bot + +# Paste entire output (including headers) into GitLab CI/CD variable SSH_PRIVATE_KEY +``` + +### 2.3 Test SSH Access + +```bash +ssh -i ~/.ssh/gitlab_ci_bot your-user@your-vps-host "echo 'Connection successful!'" +``` + +--- + +## 🐳 Step 3: Docker Hub Setup + +### 3.1 Create Access Token + +1. Go to https://hub.docker.com/settings/security +2. Click "New Access Token" +3. Name: "GitLab CI/CD" +4. Permissions: "Read, Write, Delete" +5. Copy token immediately (you won't see it again!) + +### 3.2 Create Repository + +1. Go to https://hub.docker.com/repositories +2. Click "Create Repository" +3. Name: "discord-bot-v2" +4. Visibility: Private or Public (your choice) +5. Create + +--- + +## 🖥️ Step 4: VPS Setup + +### 4.1 Create Directory Structure + +```bash +# SSH into your VPS +ssh your-user@your-vps-host + +# Create production directory +sudo mkdir -p /opt/discord-bot +sudo chown $USER:$USER /opt/discord-bot +cd /opt/discord-bot + +# Create staging directory (optional) +sudo mkdir -p /opt/discord-bot-staging +sudo chown $USER:$USER /opt/discord-bot-staging +``` + +### 4.2 Create docker-compose.yml (Production) + +```bash +cd /opt/discord-bot +nano docker-compose.yml +``` + +Paste: +```yaml +version: '3.8' + +services: + bot: + image: yourusername/discord-bot-v2:latest + container_name: discord-bot + restart: unless-stopped + env_file: + - .env.production + volumes: + - ./logs:/app/logs + - ./storage:/app/storage + networks: + - bot-network + healthcheck: + test: ["CMD", "python", "-c", "import discord; print('ok')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + redis: + image: redis:7-alpine + container_name: discord-redis + restart: unless-stopped + volumes: + - redis-data:/data + networks: + - bot-network + +volumes: + redis-data: + +networks: + bot-network: +``` + +### 4.3 Create Environment File + +```bash +nano .env.production +``` + +Paste: +```bash +BOT_TOKEN=your_discord_bot_token +API_TOKEN=your_database_api_token +DB_URL=http://your-api-url:8000 +GUILD_ID=your_discord_server_id +LOG_LEVEL=INFO +REDIS_URL=redis://redis:6379 +REDIS_CACHE_TTL=300 +``` + +### 4.4 Create Rollback Script + +```bash +nano rollback.sh +chmod +x rollback.sh +``` + +Paste: +```bash +#!/bin/bash +set -e + +COMPOSE_FILE="docker-compose.yml" +LOG_FILE="deployments.log" + +echo "=== Discord Bot Rollback ===" +echo "" + +# Show recent deployments +echo "Recent deployments:" +tail -n 10 $LOG_FILE | column -t -s '|' +echo "" + +# Show current version +CURRENT=$(grep "image:" $COMPOSE_FILE | awk '{print $2}') +echo "Current version: $CURRENT" +echo "" + +# Show last version +if [ -f .last_version ]; then + LAST=$(cat .last_version) + echo "Last version: $LAST" + echo "" + + read -p "Rollback to this version? (y/N): " confirm + if [ "$confirm" != "y" ]; then + echo "Rollback cancelled." + exit 0 + fi + + # Perform rollback + echo "Rolling back..." + sed -i "s|image:.*|image: $LAST|" $COMPOSE_FILE + docker-compose up -d + + # Record rollback + echo "$(date -Iseconds) | ROLLBACK | $LAST" >> $LOG_FILE + + echo "✅ Rollback complete!" +else + echo "❌ No previous version found!" + exit 1 +fi +``` + +### 4.5 Initialize Deployment Log + +```bash +touch deployments.log +echo "$(date -Iseconds) | INIT | Manual Setup" >> deployments.log +``` + +--- + +## 📁 Step 5: Update Project Files + +### 5.1 Copy GitLab CI Configuration + +```bash +# On your local machine, in project root +cp discord-app-v2/.gitlab-ci.yml .gitlab-ci.yml + +# Update DOCKER_IMAGE variable with your Docker Hub username +sed -i 's/yourusername/YOUR_ACTUAL_USERNAME/' .gitlab-ci.yml +``` + +### 5.2 Update Dockerfile + +```bash +# Replace existing Dockerfile with versioned one +cd discord-app-v2 +mv Dockerfile Dockerfile.old +cp Dockerfile.versioned Dockerfile +``` + +### 5.3 Add Version Command to Bot + +Edit `discord-app-v2/bot.py` and add: + +```python +import os + +BOT_VERSION = os.getenv('BOT_VERSION', 'dev') +GIT_COMMIT = os.getenv('BOT_GIT_COMMIT', 'unknown') +BUILD_DATE = os.getenv('BOT_BUILD_DATE', 'unknown') + +@bot.tree.command(name="version", description="Display bot version info") +async def version_command(interaction: discord.Interaction): + embed = discord.Embed( + title="🤖 Bot Version Information", + color=0x00ff00 + ) + embed.add_field(name="Version", value=BOT_VERSION, inline=False) + embed.add_field(name="Git Commit", value=GIT_COMMIT[:8], inline=True) + embed.add_field(name="Build Date", value=BUILD_DATE, inline=True) + + await interaction.response.send_message(embed=embed, ephemeral=True) +``` + +--- + +## 🧪 Step 6: Test the Pipeline + +### 6.1 Initial Commit + +```bash +git add . +git commit -m "Setup GitLab CI/CD pipeline" +git push gitlab main +``` + +### 6.2 Watch Pipeline Execute + +1. Go to GitLab project page +2. Click "CI/CD > Pipelines" +3. Watch your pipeline run: + - ✅ Test stage should run + - ✅ Build stage should run + - ⏸️ Deploy stage waits for manual trigger + +### 6.3 Manual Production Deploy + +1. In GitLab pipeline view, find "deploy:production" job +2. Click the "Play" button ▶️ +3. Watch deployment execute +4. Verify on VPS: + ```bash + ssh your-user@your-vps-host + cd /opt/discord-bot + docker-compose ps + tail -f logs/discord_bot_v2.log + ``` + +--- + +## ✅ Step 7: Verify Everything Works + +### 7.1 Check Bot Status + +```bash +# On VPS +docker-compose ps + +# Should show: +# NAME STATUS +# discord-bot Up (healthy) +# discord-redis Up +``` + +### 7.2 Check Version in Discord + +In your Discord server: +``` +/version +``` + +Should show something like: +``` +Version: v2.1.1 +Git Commit: a1b2c3d4 +Build Date: 2025-01-19T10:30:00Z +``` + +### 7.3 Check Deployment Log + +```bash +# On VPS +cat /opt/discord-bot/deployments.log +``` + +--- + +## 🔄 Step 8: Create Development Workflow + +### 8.1 Create Develop Branch + +```bash +git checkout -b develop +git push gitlab develop +``` + +### 8.2 Set Up Branch Protection (Optional) + +In GitLab: +1. Settings > Repository > Protected Branches +2. Protect `main`: Require merge requests, maintainers can push +3. Protect `develop`: Developers can push + +--- + +## 🎯 Usage Workflows + +### Regular Feature Development + +```bash +# Create feature branch +git checkout -b feature/new-feature develop + +# Make changes, commit +git add . +git commit -m "Add new feature" +git push gitlab feature/new-feature + +# Merge to develop (auto-deploys to staging if configured) +git checkout develop +git merge feature/new-feature +git push gitlab develop + +# After testing, merge to main +git checkout main +git merge develop +git push gitlab main + +# In GitLab UI, manually trigger production deploy +``` + +### Hotfix + +```bash +# Create from main +git checkout -b hotfix/critical-bug main + +# Fix and commit +git add . +git commit -m "Fix critical bug" +git push gitlab hotfix/critical-bug + +# Merge to main +git checkout main +git merge hotfix/critical-bug +git push gitlab main + +# Manually deploy in GitLab +``` + +### Rollback + +**Option 1 - GitLab UI:** +1. CI/CD > Pipelines +2. Find pipeline with working version +3. Click "Rollback" on deploy:production job + +**Option 2 - VPS Script:** +```bash +ssh your-user@your-vps-host +cd /opt/discord-bot +./rollback.sh +``` + +**Option 3 - Manual Job:** +1. CI/CD > Pipelines > Latest +2. Click "Play" on rollback:production job + +--- + +## 🐛 Troubleshooting + +### Pipeline Fails at Build Stage + +**Error**: "Cannot connect to Docker daemon" +**Fix**: GitLab runners need Docker-in-Docker enabled (already configured in `.gitlab-ci.yml`) + +**Error**: "Permission denied for Docker Hub" +**Fix**: Check `DOCKER_USERNAME` and `DOCKER_PASSWORD` variables are correct + +### Pipeline Fails at Deploy Stage + +**Error**: "Permission denied (publickey)" +**Fix**: +1. Check `SSH_PRIVATE_KEY` variable includes headers +2. Verify public key is in VPS `~/.ssh/authorized_keys` +3. Test: `ssh -i ~/.ssh/gitlab_ci_bot your-user@your-vps-host` + +**Error**: "docker-compose: command not found" +**Fix**: Install docker-compose on VPS: +```bash +sudo apt-get update +sudo apt-get install docker-compose-plugin +``` + +### Bot Doesn't Start on VPS + +**Check logs:** +```bash +cd /opt/discord-bot +docker-compose logs -f bot +``` + +**Common issues:** +- Missing/wrong `.env.production` values +- Bot token expired +- Database API unreachable + +--- + +## 📊 Version Bumping + +Update version in `.gitlab-ci.yml`: + +```yaml +variables: + VERSION_MAJOR: "2" + VERSION_MINOR: "1" # ← Change this for new features +``` + +**Rules:** +- **Patch**: Auto-increments each pipeline +- **Minor**: Manual bump for new features +- **Major**: Manual bump for breaking changes + +--- + +## 🎓 What You Get + +✅ **Automated Testing**: Every push runs tests +✅ **Automated Builds**: Docker images built on CI +✅ **Semantic Versioning**: v2.1.X format +✅ **Manual Production Deploys**: Approval required +✅ **Automatic Rollback**: On health check failure +✅ **Quick Manual Rollback**: 3 methods available +✅ **Deployment History**: Full audit trail +✅ **Version Visibility**: `/version` command + +--- + +## 📞 Support + +If you get stuck: +1. Check GitLab pipeline logs +2. Check VPS docker logs: `docker-compose logs` +3. Check deployment log: `cat deployments.log` +4. Verify all CI/CD variables are set correctly + +--- + +**Setup Time**: ~30 minutes +**Deployment Time After Setup**: ~2-3 minutes +**Rollback Time**: ~1-2 minutes + +**You're all set! 🚀** diff --git a/.gitlab/QUICK_REFERENCE.md b/.gitlab/QUICK_REFERENCE.md new file mode 100644 index 0000000..7e69975 --- /dev/null +++ b/.gitlab/QUICK_REFERENCE.md @@ -0,0 +1,315 @@ +# GitLab CI/CD Quick Reference + +Quick commands and reminders for daily development. + +--- + +## 🔄 Common Workflows + +### Deploy Feature to Production + +```bash +# 1. Develop feature +git checkout -b feature/my-feature develop +# ... make changes ... +git commit -m "Add my feature" +git push gitlab feature/my-feature + +# 2. Merge to develop for staging test (optional) +git checkout develop +git merge feature/my-feature +git push gitlab develop +# → Auto-deploys to staging + +# 3. Merge to main +git checkout main +git merge develop +git push gitlab main + +# 4. In GitLab UI: CI/CD > Pipelines > Click ▶️ on deploy:production +``` + +### Emergency Rollback + +```bash +# Option 1: VPS Script (fastest) +ssh user@vps "cd /opt/discord-bot && ./rollback.sh" + +# Option 2: GitLab UI +# CI/CD > Pipelines > Click ▶️ on rollback:production + +# Option 3: Manual +ssh user@vps +cd /opt/discord-bot +# Edit docker-compose.yml to previous version +docker-compose up -d +``` + +### Check Deployment Status + +```bash +# Check running version on VPS +ssh user@vps "cd /opt/discord-bot && docker inspect discord-bot --format '{{.Config.Labels}}' | grep version" + +# Check recent deployments +ssh user@vps "cd /opt/discord-bot && tail -10 deployments.log" + +# Check bot health +ssh user@vps "cd /opt/discord-bot && docker-compose ps" +``` + +--- + +## 🏷️ Version Management + +### Current Version Strategy + +| Format | Example | Auto/Manual | When | +|--------|---------|-------------|------| +| Major | `v2.x.x` | Manual | Breaking changes | +| Minor | `v2.1.x` | Manual | New features | +| Patch | `v2.1.123` | Auto | Every build | + +### Bump Version + +Edit `.gitlab-ci.yml`: +```yaml +variables: + VERSION_MAJOR: "2" + VERSION_MINOR: "2" # ← Change this +``` + +Then: +```bash +git add .gitlab-ci.yml +git commit -m "Bump version to v2.2.x" +git push gitlab main +``` + +--- + +## 🐳 Docker Tags Generated + +Every build creates: +- `v2.1.123` - Full semantic version +- `a1b2c3d` - Git commit SHA +- `main-a1b2c3d` - Branch + SHA +- `latest` - Latest main branch (production) +- `staging` - Latest develop branch (staging) + +--- + +## 🔍 Useful Commands + +### Check Pipeline Status +```bash +# From CLI (requires gitlab-ci-lint or gitlab CLI) +gitlab-ci-lint .gitlab-ci.yml + +# Or visit: +# https://gitlab.com/yourusername/discord-bot/-/pipelines +``` + +### View Logs +```bash +# Bot logs +ssh user@vps "cd /opt/discord-bot && docker-compose logs -f bot" + +# Redis logs +ssh user@vps "cd /opt/discord-bot && docker-compose logs -f redis" + +# Deployment history +ssh user@vps "cd /opt/discord-bot && cat deployments.log | column -t -s '|'" +``` + +### Test Locally Before Push +```bash +cd discord-app-v2 +python -m pytest --tb=short -q +``` + +### Build Docker Image Locally +```bash +cd discord-app-v2 +docker build \ + --build-arg VERSION="dev" \ + --build-arg GIT_COMMIT=$(git rev-parse --short HEAD) \ + --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -t discord-bot-v2:local . +``` + +--- + +## 🎯 GitLab CI/CD Variables + +**Required Variables** (Settings > CI/CD > Variables): + +| Variable | Type | Example | +|----------|------|---------| +| `DOCKER_USERNAME` | Masked | `youruser` | +| `DOCKER_PASSWORD` | Masked | `dckr_pat_abc123...` | +| `SSH_PRIVATE_KEY` | Masked | `-----BEGIN OPENSSH...` | +| `VPS_HOST` | Plain | `123.456.789.0` | +| `VPS_USER` | Plain | `ubuntu` | + +--- + +## 🚨 Emergency Procedures + +### Build Failing + +1. Check GitLab pipeline logs +2. Run tests locally: `pytest` +3. Check Docker build: `docker build ...` +4. Fix issues +5. Push again + +### Deploy Failing + +1. Check SSH access: `ssh user@vps` +2. Check docker-compose.yml exists +3. Check .env.production has all vars +4. Check VPS disk space: `df -h` +5. Check Docker is running: `docker ps` + +### Bot Not Starting After Deploy + +```bash +# SSH to VPS +ssh user@vps +cd /opt/discord-bot + +# Check logs +docker-compose logs bot | tail -50 + +# Check health +docker-compose ps + +# Restart +docker-compose restart bot + +# Nuclear option: full restart +docker-compose down +docker-compose up -d +``` + +### Rollback Needed Immediately + +```bash +# Fastest: VPS script +ssh user@vps "cd /opt/discord-bot && ./rollback.sh" + +# Confirm version +ssh user@vps "cd /opt/discord-bot && docker-compose ps" +``` + +--- + +## 📊 Health Checks + +### Bot Health +```bash +# Check if bot is healthy +ssh user@vps "docker inspect discord-bot --format '{{.State.Health.Status}}'" +# Should show: healthy + +# Check Discord connection (in Discord) +/version +``` + +### Redis Health +```bash +ssh user@vps "docker exec discord-redis redis-cli ping" +# Should show: PONG +``` + +### Full System Check +```bash +ssh user@vps << 'EOF' +cd /opt/discord-bot +echo "=== Container Status ===" +docker-compose ps +echo "" +echo "=== Recent Logs ===" +docker-compose logs --tail=10 bot +echo "" +echo "=== Deployment History ===" +tail -5 deployments.log +EOF +``` + +--- + +## 🔐 Security Reminders + +- ✅ Never commit `.env` files +- ✅ Use GitLab CI/CD variables for secrets +- ✅ Mark all secrets as "Masked" in GitLab +- ✅ Rotate SSH keys periodically +- ✅ Use Docker Hub access tokens, not passwords +- ✅ Keep VPS firewall enabled + +--- + +## 📈 Monitoring + +### Check Metrics +```bash +# If Prometheus is set up +curl http://vps-ip:8000/metrics + +# Check bot uptime +ssh user@vps "docker inspect discord-bot --format '{{.State.StartedAt}}'" +``` + +### Watch Live Logs +```bash +ssh user@vps "cd /opt/discord-bot && docker-compose logs -f --tail=100" +``` + +--- + +## 🎓 Tips & Tricks + +### Skip CI for Minor Changes +```bash +git commit -m "Update README [skip ci]" +``` + +### Test in Staging First +```bash +# Push to develop → auto-deploys to staging +git push gitlab develop + +# Test thoroughly, then merge to main +``` + +### View All Available Versions +```bash +# On Docker Hub +docker search yourusername/discord-bot-v2 + +# On VPS +ssh user@vps "docker images yourusername/discord-bot-v2" +``` + +### Clean Up Old Images +```bash +# On VPS (run monthly) +ssh user@vps "docker image prune -a -f" +``` + +--- + +## 📞 Getting Help + +1. **Check Logs**: Always start with logs +2. **GitLab Pipeline**: Look at failed job output +3. **Docker Logs**: `docker-compose logs` +4. **Deployment Log**: `cat deployments.log` + +--- + +**Last Updated**: January 2025 +**Bot Version**: v2.1.x +**CI/CD Platform**: GitLab CI/CD diff --git a/.gitlab/VPS_SCRIPTS.md b/.gitlab/VPS_SCRIPTS.md new file mode 100644 index 0000000..9eafff4 --- /dev/null +++ b/.gitlab/VPS_SCRIPTS.md @@ -0,0 +1,517 @@ +# VPS Helper Scripts + +Collection of useful scripts for managing the Discord bot on your VPS. + +--- + +## 📍 Script Locations + +All scripts should be placed in `/opt/discord-bot/` on your VPS. + +```bash +/opt/discord-bot/ +├── docker-compose.yml +├── .env.production +├── rollback.sh # Rollback to previous version +├── deploy-manual.sh # Manual deployment script +├── health-check.sh # Check bot health +├── logs-view.sh # View logs easily +├── cleanup.sh # Clean up old Docker images +└── deployments.log # Auto-generated deployment history +``` + +--- + +## 🔄 rollback.sh + +Already created during setup. For reference: + +```bash +#!/bin/bash +set -e + +COMPOSE_FILE="docker-compose.yml" +LOG_FILE="deployments.log" + +echo "=== Discord Bot Rollback ===" +echo "" + +# Show recent deployments +echo "Recent deployments:" +tail -n 10 $LOG_FILE | column -t -s '|' +echo "" + +# Show current version +CURRENT=$(grep "image:" $COMPOSE_FILE | awk '{print $2}') +echo "Current version: $CURRENT" +echo "" + +# Show last version +if [ -f .last_version ]; then + LAST=$(cat .last_version) + echo "Last version: $LAST" + echo "" + + read -p "Rollback to this version? (y/N): " confirm + if [ "$confirm" != "y" ]; then + echo "Rollback cancelled." + exit 0 + fi + + # Perform rollback + echo "Rolling back..." + sed -i "s|image:.*|image: $LAST|" $COMPOSE_FILE + docker-compose up -d + + # Record rollback + echo "$(date -Iseconds) | ROLLBACK | $LAST" >> $LOG_FILE + + echo "✅ Rollback complete!" +else + echo "❌ No previous version found!" + exit 1 +fi +``` + +--- + +## 🚀 deploy-manual.sh + +For manual deployments (bypassing GitLab): + +```bash +#!/bin/bash +set -e + +COMPOSE_FILE="docker-compose.yml" +LOG_FILE="deployments.log" +IMAGE="yourusername/discord-bot-v2" + +echo "=== Manual Discord Bot Deployment ===" +echo "" + +# Show available versions +echo "Available versions on Docker Hub:" +echo "(Showing last 10 tags)" +curl -s "https://hub.docker.com/v2/repositories/${IMAGE}/tags?page_size=10" | \ + grep -o '"name":"[^"]*' | \ + grep -o '[^"]*$' +echo "" + +# Prompt for version +read -p "Enter version to deploy (or 'latest'): " VERSION + +if [ -z "$VERSION" ]; then + echo "❌ No version specified!" + exit 1 +fi + +# Backup current version +docker inspect discord-bot --format='{{.Image}}' > .last_version || true + +# Update docker-compose +sed -i "s|image: ${IMAGE}:.*|image: ${IMAGE}:${VERSION}|" $COMPOSE_FILE + +# Pull and deploy +echo "Pulling ${IMAGE}:${VERSION}..." +docker-compose pull + +echo "Deploying..." +docker-compose up -d + +# Wait for health check +echo "Waiting for health check..." +sleep 10 + +if docker-compose ps | grep -q "Up (healthy)"; then + echo "✅ Deployment successful!" + echo "$(date -Iseconds) | MANUAL | ${VERSION} | Manual deployment" >> $LOG_FILE + docker image prune -f +else + echo "❌ Health check failed! Rolling back..." + LAST_VERSION=$(cat .last_version) + sed -i "s|image: ${IMAGE}:.*|image: ${LAST_VERSION}|" $COMPOSE_FILE + docker-compose up -d + exit 1 +fi +``` + +**Usage:** +```bash +cd /opt/discord-bot +./deploy-manual.sh +``` + +--- + +## 🏥 health-check.sh + +Comprehensive health check: + +```bash +#!/bin/bash + +echo "=== Discord Bot Health Check ===" +echo "" + +# Container status +echo "📦 Container Status:" +docker-compose ps +echo "" + +# Bot health +BOT_HEALTH=$(docker inspect discord-bot --format '{{.State.Health.Status}}' 2>/dev/null || echo "unknown") +echo "🤖 Bot Health: $BOT_HEALTH" + +# Redis health +REDIS_HEALTH=$(docker exec discord-redis redis-cli ping 2>/dev/null || echo "unreachable") +echo "💾 Redis Health: $REDIS_HEALTH" +echo "" + +# Uptime +BOT_STARTED=$(docker inspect discord-bot --format '{{.State.StartedAt}}' 2>/dev/null || echo "unknown") +echo "⏱️ Bot Started: $BOT_STARTED" +echo "" + +# Resource usage +echo "💻 Resource Usage:" +docker stats --no-stream discord-bot discord-redis +echo "" + +# Recent errors +echo "⚠️ Recent Errors (last 10):" +docker-compose logs --tail=100 bot 2>&1 | grep -i error | tail -10 || echo "No recent errors" +echo "" + +# Deployment history +echo "📜 Recent Deployments:" +tail -5 deployments.log | column -t -s '|' +echo "" + +# Summary +echo "=== Summary ===" +if [ "$BOT_HEALTH" = "healthy" ] && [ "$REDIS_HEALTH" = "PONG" ]; then + echo "✅ All systems operational" + exit 0 +else + echo "❌ Issues detected" + exit 1 +fi +``` + +**Usage:** +```bash +cd /opt/discord-bot +./health-check.sh +``` + +**Cron for daily checks:** +```bash +# Run health check daily at 6 AM +0 6 * * * /opt/discord-bot/health-check.sh | mail -s "Bot Health Report" you@email.com +``` + +--- + +## 📋 logs-view.sh + +Easy log viewing: + +```bash +#!/bin/bash + +echo "Discord Bot Logs Viewer" +echo "" +echo "Select option:" +echo "1) Live bot logs (follow)" +echo "2) Last 100 bot logs" +echo "3) Last 50 error logs" +echo "4) All logs (bot + redis)" +echo "5) Deployment history" +echo "6) Search logs" +echo "" +read -p "Choice [1-6]: " choice + +case $choice in + 1) + echo "Following live logs (Ctrl+C to exit)..." + docker-compose logs -f --tail=50 bot + ;; + 2) + docker-compose logs --tail=100 bot + ;; + 3) + docker-compose logs --tail=500 bot | grep -i error | tail -50 + ;; + 4) + docker-compose logs --tail=100 + ;; + 5) + cat deployments.log | column -t -s '|' + ;; + 6) + read -p "Search term: " term + docker-compose logs bot | grep -i "$term" | tail -50 + ;; + *) + echo "Invalid option" + exit 1 + ;; +esac +``` + +**Usage:** +```bash +cd /opt/discord-bot +./logs-view.sh +``` + +--- + +## 🧹 cleanup.sh + +Clean up old Docker images and data: + +```bash +#!/bin/bash +set -e + +echo "=== Discord Bot Cleanup ===" +echo "" + +# Show current disk usage +echo "💾 Current Disk Usage:" +df -h /var/lib/docker +echo "" + +# Show Docker disk usage +echo "🐳 Docker Disk Usage:" +docker system df +echo "" + +read -p "Proceed with cleanup? (y/N): " confirm +if [ "$confirm" != "y" ]; then + echo "Cleanup cancelled." + exit 0 +fi + +# Stop containers temporarily +echo "Stopping containers..." +docker-compose down + +# Prune images (keep recent ones) +echo "Pruning old images..." +docker image prune -a -f --filter "until=720h" # Keep images from last 30 days + +# Prune volumes (be careful!) +# Uncomment if you want to clean volumes +# echo "Pruning unused volumes..." +# docker volume prune -f + +# Prune build cache +echo "Pruning build cache..." +docker builder prune -f + +# Restart containers +echo "Restarting containers..." +docker-compose up -d + +# Show new disk usage +echo "" +echo "✅ Cleanup complete!" +echo "" +echo "💾 New Disk Usage:" +df -h /var/lib/docker +echo "" +docker system df +``` + +**Usage:** +```bash +cd /opt/discord-bot +./cleanup.sh +``` + +**Cron for monthly cleanup:** +```bash +# Run cleanup first Sunday of month at 3 AM +0 3 1-7 * 0 /opt/discord-bot/cleanup.sh +``` + +--- + +## 🔍 version-info.sh + +Show detailed version information: + +```bash +#!/bin/bash + +echo "=== Version Information ===" +echo "" + +# Docker image version +echo "🐳 Docker Image:" +docker inspect discord-bot --format '{{.Config.Image}}' +echo "" + +# Image labels +echo "🏷️ Build Metadata:" +docker inspect discord-bot --format '{{json .Config.Labels}}' | jq '.' +echo "" + +# Environment variables (version info only) +echo "🔧 Environment:" +docker inspect discord-bot --format '{{range .Config.Env}}{{println .}}{{end}}' | grep BOT_ +echo "" + +# Currently deployed +echo "📦 Currently Deployed:" +cat .deployed_version 2>/dev/null || echo "Unknown" +echo "" + +# Last deployment +echo "📅 Last Deployment:" +tail -1 deployments.log | column -t -s '|' +echo "" + +# Available for rollback +echo "⏮️ Available for Rollback:" +cat .last_version 2>/dev/null || echo "None" +``` + +**Usage:** +```bash +cd /opt/discord-bot +./version-info.sh +``` + +--- + +## 📊 status-dashboard.sh + +Combined status dashboard: + +```bash +#!/bin/bash + +clear +echo "╔════════════════════════════════════════════╗" +echo "║ Discord Bot Status Dashboard ║" +echo "╚════════════════════════════════════════════╝" +echo "" + +# Version +echo "📦 Version: $(cat .deployed_version 2>/dev/null || echo 'Unknown')" +echo "" + +# Health +BOT_HEALTH=$(docker inspect discord-bot --format '{{.State.Health.Status}}' 2>/dev/null || echo "down") +REDIS_HEALTH=$(docker exec discord-redis redis-cli ping 2>/dev/null || echo "DOWN") + +if [ "$BOT_HEALTH" = "healthy" ]; then + echo "✅ Bot: $BOT_HEALTH" +else + echo "❌ Bot: $BOT_HEALTH" +fi + +if [ "$REDIS_HEALTH" = "PONG" ]; then + echo "✅ Redis: UP" +else + echo "❌ Redis: $REDIS_HEALTH" +fi +echo "" + +# Uptime +STARTED=$(docker inspect discord-bot --format '{{.State.StartedAt}}' 2>/dev/null || echo "unknown") +echo "⏱️ Uptime: $STARTED" +echo "" + +# Resource usage +echo "💻 Resources:" +docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" discord-bot discord-redis +echo "" + +# Recent deployments +echo "📜 Recent Deployments:" +tail -3 deployments.log | column -t -s '|' +echo "" + +# Errors +ERROR_COUNT=$(docker-compose logs --tail=1000 bot 2>&1 | grep -ic error || echo 0) +echo "⚠️ Errors (last 1000 lines): $ERROR_COUNT" +echo "" + +echo "╚════════════════════════════════════════════╝" +echo "Press Ctrl+C to exit, or run with 'watch' for live updates" +``` + +**Usage:** +```bash +# One-time view +cd /opt/discord-bot +./status-dashboard.sh + +# Live updating (every 2 seconds) +watch -n 2 /opt/discord-bot/status-dashboard.sh +``` + +--- + +## 🚀 Quick Setup + +Install all scripts at once: + +```bash +ssh user@vps << 'EOF' +cd /opt/discord-bot + +# Make scripts executable +chmod +x rollback.sh +chmod +x deploy-manual.sh +chmod +x health-check.sh +chmod +x logs-view.sh +chmod +x cleanup.sh +chmod +x version-info.sh +chmod +x status-dashboard.sh + +echo "✅ All scripts are ready!" +ls -lah *.sh +EOF +``` + +--- + +## 🎯 Useful Aliases + +Add to `~/.bashrc` on VPS: + +```bash +# Discord Bot aliases +alias bot-status='cd /opt/discord-bot && ./status-dashboard.sh' +alias bot-logs='cd /opt/discord-bot && ./logs-view.sh' +alias bot-health='cd /opt/discord-bot && ./health-check.sh' +alias bot-rollback='cd /opt/discord-bot && ./rollback.sh' +alias bot-deploy='cd /opt/discord-bot && ./deploy-manual.sh' +alias bot-restart='cd /opt/discord-bot && docker-compose restart bot' +alias bot-down='cd /opt/discord-bot && docker-compose down' +alias bot-up='cd /opt/discord-bot && docker-compose up -d' + +# Quick status +alias bs='bot-status' +alias bl='bot-logs' +``` + +Then: +```bash +source ~/.bashrc + +# Now you can use: +bs # Status dashboard +bl # View logs +bot-health # Health check +``` + +--- + +**Tip**: Create a `README.txt` in `/opt/discord-bot/` listing all available scripts and their purposes! diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..3246815 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "readme-context": { + "command": "node", + "args": [ + "/home/cal/.claude/mcp-servers/readme-context/dist/index.js" + ], + "env": { + "PROJECT_ROOT": "/mnt/NV2/Development/major-domo/discord-app-v2" + } + } + } +} diff --git a/Dockerfile.versioned b/Dockerfile.versioned new file mode 100644 index 0000000..87dc043 --- /dev/null +++ b/Dockerfile.versioned @@ -0,0 +1,49 @@ +# Enhanced Dockerfile with Version Metadata +# Rename to Dockerfile when ready to use + +# Build stage +FROM python:3.11-slim as builder + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --user --no-cache-dir -r requirements.txt + +# Runtime stage +FROM python:3.11-slim + +WORKDIR /app + +# Copy dependencies from builder +COPY --from=builder /root/.local /root/.local +ENV PATH=/root/.local/bin:$PATH + +# Add version metadata as build args +ARG VERSION="dev" +ARG GIT_COMMIT="unknown" +ARG BUILD_DATE="unknown" + +# Store as labels (visible via `docker inspect`) +LABEL org.opencontainers.image.version="${VERSION}" +LABEL org.opencontainers.image.revision="${GIT_COMMIT}" +LABEL org.opencontainers.image.created="${BUILD_DATE}" +LABEL org.opencontainers.image.title="Discord Bot v2.0" +LABEL org.opencontainers.image.description="SBA Discord Bot - Modernized" + +# Store as environment variables (accessible in bot) +ENV BOT_VERSION="${VERSION}" +ENV BOT_GIT_COMMIT="${GIT_COMMIT}" +ENV BOT_BUILD_DATE="${BUILD_DATE}" + +# Copy application +COPY . . + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD python -c "import discord; print('ok')" || exit 1 + +CMD ["python", "bot.py"] diff --git a/api/client.py b/api/client.py index df41640..553ac04 100644 --- a/api/client.py +++ b/api/client.py @@ -7,7 +7,7 @@ Provides connection pooling, proper error handling, and session management. import aiohttp import logging from typing import Optional, List, Dict, Any, Union -from urllib.parse import urljoin +from urllib.parse import urljoin, quote from contextlib import asynccontextmanager from config import get_config @@ -60,26 +60,28 @@ class APIClient: 'User-Agent': 'SBA-Discord-Bot-v2/1.0' } - def _build_url(self, endpoint: str, api_version: int = 3, object_id: Optional[int] = None) -> str: + def _build_url(self, endpoint: str, api_version: int = 3, object_id: Optional[Union[int, str]] = None) -> str: """ Build complete API URL from components. - + Args: endpoint: API endpoint path api_version: API version number (default: 3) - object_id: Optional object ID to append - + object_id: Optional object ID to append (int for numeric IDs, str for moveids) + Returns: Complete URL for API request """ # Handle already complete URLs if endpoint.startswith(('http://', 'https://')) or '/api/' in endpoint: return endpoint - + path = f"v{api_version}/{endpoint}" if object_id is not None: - path += f"/{object_id}" - + # URL-encode the object_id to handle special characters (e.g., colons in moveids) + encoded_id = quote(str(object_id), safe='') + path += f"/{encoded_id}" + return urljoin(self.base_url.rstrip('/') + '/', path) def _add_params(self, url: str, params: Optional[List[tuple]] = None) -> str: @@ -121,9 +123,9 @@ class APIClient: logger.debug("Created new aiohttp session with connection pooling") async def get( - self, - endpoint: str, - object_id: Optional[int] = None, + self, + endpoint: str, + object_id: Optional[Union[int, str]] = None, params: Optional[List[tuple]] = None, api_version: int = 3, timeout: Optional[int] = None @@ -251,10 +253,10 @@ class APIClient: raise APIException(f"POST failed: {e}") async def put( - self, - endpoint: str, + self, + endpoint: str, data: Dict[str, Any], - object_id: Optional[int] = None, + object_id: Optional[Union[int, str]] = None, api_version: int = 3, timeout: Optional[int] = None ) -> Optional[Dict[str, Any]]: @@ -313,7 +315,7 @@ class APIClient: self, endpoint: str, data: Optional[Dict[str, Any]] = None, - object_id: Optional[int] = None, + object_id: Optional[Union[int, str]] = None, api_version: int = 3, timeout: Optional[int] = None, use_query_params: bool = False @@ -349,6 +351,7 @@ class APIClient: try: logger.debug(f"PATCH: {endpoint} id: {object_id} data: {data} use_query_params: {use_query_params}") + logger.debug(f"PATCH URL: {url}") request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None @@ -356,6 +359,7 @@ class APIClient: kwargs = {} if data is not None and not use_query_params: kwargs['json'] = data + logger.debug(f"PATCH JSON body: {data}") async with self._session.patch(url, timeout=request_timeout, **kwargs) as response: if response.status == 401: @@ -384,9 +388,9 @@ class APIClient: raise APIException(f"PATCH failed: {e}") async def delete( - self, - endpoint: str, - object_id: Optional[int] = None, + self, + endpoint: str, + object_id: Optional[Union[int, str]] = None, api_version: int = 3, timeout: Optional[int] = None ) -> bool: diff --git a/bot.py b/bot.py index ce43a0b..82af6ae 100644 --- a/bot.py +++ b/bot.py @@ -173,6 +173,11 @@ class SBABot(commands.Bot): from tasks.custom_command_cleanup import setup_cleanup_task self.custom_command_cleanup = setup_cleanup_task(self) + # Initialize transaction freeze/thaw task + from tasks.transaction_freeze import setup_freeze_task + self.transaction_freeze = setup_freeze_task(self) + self.logger.info("✅ Transaction freeze/thaw task started") + # Initialize voice channel cleanup service from commands.voice.cleanup_service import VoiceChannelCleanupService self.voice_cleanup_service = VoiceChannelCleanupService() @@ -313,7 +318,7 @@ class SBABot(commands.Bot): async def close(self): """Clean shutdown of the bot.""" self.logger.info("Bot shutting down...") - + # Stop background tasks if hasattr(self, 'custom_command_cleanup'): try: @@ -322,13 +327,20 @@ class SBABot(commands.Bot): except Exception as e: self.logger.error(f"Error stopping cleanup task: {e}") + if hasattr(self, 'transaction_freeze'): + try: + self.transaction_freeze.weekly_loop.cancel() + self.logger.info("Transaction freeze/thaw task stopped") + except Exception as e: + self.logger.error(f"Error stopping transaction freeze task: {e}") + if hasattr(self, 'voice_cleanup_service'): try: self.voice_cleanup_service.stop_monitoring() self.logger.info("Voice channel cleanup service stopped") except Exception as e: self.logger.error(f"Error stopping voice cleanup service: {e}") - + # Call parent close method await super().close() self.logger.info("Bot shutdown complete") diff --git a/commands/admin/__init__.py b/commands/admin/__init__.py index eaffeb5..4ab3e0c 100644 --- a/commands/admin/__init__.py +++ b/commands/admin/__init__.py @@ -11,6 +11,7 @@ from discord.ext import commands from .management import AdminCommands from .users import UserManagementCommands +from .league_management import LeagueManagementCommands logger = logging.getLogger(f'{__name__}.setup_admin') @@ -25,6 +26,7 @@ async def setup_admin(bot: commands.Bot) -> Tuple[int, int, List[str]]: admin_cogs: List[Tuple[str, Type[commands.Cog]]] = [ ("AdminCommands", AdminCommands), ("UserManagementCommands", UserManagementCommands), + ("LeagueManagementCommands", LeagueManagementCommands), ] successful = 0 diff --git a/commands/admin/league_management.py b/commands/admin/league_management.py new file mode 100644 index 0000000..248b6d6 --- /dev/null +++ b/commands/admin/league_management.py @@ -0,0 +1,560 @@ +""" +Admin League Management Commands + +Administrative commands for manual control of league state and transaction processing. +Provides manual override capabilities for the automated freeze/thaw system. +""" +from typing import Optional + +import discord +from discord.ext import commands +from discord import app_commands + +from config import get_config +from utils.logging import get_contextual_logger +from utils.decorators import logged_command +from views.embeds import EmbedColors, EmbedTemplate +from services.league_service import league_service +from services.transaction_service import transaction_service +from tasks.transaction_freeze import resolve_contested_transactions + + +class LeagueManagementCommands(commands.Cog): + """Administrative commands for league state and transaction management.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.LeagueManagementCommands') + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + """Check if user has admin permissions.""" + if not interaction.user.guild_permissions.administrator: + await interaction.response.send_message( + "❌ You need administrator permissions to use admin commands.", + ephemeral=True + ) + return False + return True + + @app_commands.command( + name="admin-freeze-begin", + description="[ADMIN] Manually trigger freeze begin (increment week, set freeze)" + ) + @logged_command("/admin-freeze-begin") + async def admin_freeze_begin(self, interaction: discord.Interaction): + """Manually trigger the freeze begin process.""" + await interaction.response.defer() + + # Get current state + current = await league_service.get_current_state() + if not current: + await interaction.followup.send( + "❌ Could not retrieve current league state.", + ephemeral=True + ) + return + + # Check if already frozen + if current.freeze: + embed = EmbedTemplate.warning( + title="Already Frozen", + description=f"League is already in freeze period for week {current.week}." + ) + embed.add_field( + name="Current State", + value=f"**Week:** {current.week}\n**Freeze:** {current.freeze}\n**Season:** {current.season}", + inline=False + ) + await interaction.followup.send(embed=embed) + return + + # Increment week and set freeze + new_week = current.week + 1 + updated = await league_service.update_current_state( + week=new_week, + freeze=True + ) + + if not updated: + await interaction.followup.send( + "❌ Failed to update league state.", + ephemeral=True + ) + return + + # Create success embed + embed = EmbedTemplate.success( + title="Freeze Period Begun", + description=f"Manually triggered freeze begin for week {new_week}." + ) + + embed.add_field( + name="Previous State", + value=f"**Week:** {current.week}\n**Freeze:** {current.freeze}", + inline=True + ) + + embed.add_field( + name="New State", + value=f"**Week:** {new_week}\n**Freeze:** True", + inline=True + ) + + embed.add_field( + name="Actions Performed", + value="✅ Week incremented\n✅ Freeze flag set to True", + inline=False + ) + + embed.add_field( + name="⚠️ Manual Steps Required", + value="• Post freeze announcement to #transaction-log\n" + "• Post weekly info to #weekly-info (if weeks 1-18)\n" + "• Run regular transactions if needed", + inline=False + ) + + embed.set_footer(text=f"Triggered by {interaction.user.display_name}") + await interaction.followup.send(embed=embed) + + @app_commands.command( + name="admin-freeze-end", + description="[ADMIN] Manually trigger freeze end (process transactions, unfreeze)" + ) + @logged_command("/admin-freeze-end") + async def admin_freeze_end(self, interaction: discord.Interaction): + """Manually trigger the freeze end process.""" + await interaction.response.defer() + + # Get current state + current = await league_service.get_current_state() + if not current: + await interaction.followup.send( + "❌ Could not retrieve current league state.", + ephemeral=True + ) + return + + # Check if currently frozen + if not current.freeze: + embed = EmbedTemplate.warning( + title="Not Frozen", + description=f"League is not currently in freeze period (week {current.week})." + ) + embed.add_field( + name="Current State", + value=f"**Week:** {current.week}\n**Freeze:** {current.freeze}\n**Season:** {current.season}", + inline=False + ) + await interaction.followup.send(embed=embed) + return + + # Process frozen transactions + processing_msg = await interaction.followup.send( + "⏳ Processing frozen transactions...", + wait=True + ) + + # Get frozen transactions + transactions = await transaction_service.get_frozen_transactions_by_week( + season=current.season, + week_start=current.week, + week_end=current.week + 1 + ) + + winning_count = 0 + losing_count = 0 + + if transactions: + # Resolve contested transactions + winning_move_ids, losing_move_ids = await resolve_contested_transactions( + transactions, + current.season + ) + + # Cancel losing transactions (one API call per moveid, updates all transactions in group) + for losing_move_id in losing_move_ids: + await transaction_service.cancel_transaction(losing_move_id) + losing_count += 1 + + # Unfreeze winning transactions (one API call per moveid, updates all transactions in group) + for winning_move_id in winning_move_ids: + await transaction_service.unfreeze_transaction(winning_move_id) + winning_count += 1 + + # Update processing message + await processing_msg.edit(content="⏳ Updating league state...") + + # Set freeze to False + updated = await league_service.update_current_state(freeze=False) + + if not updated: + await interaction.followup.send( + "❌ Failed to update league state after processing transactions.", + ephemeral=True + ) + return + + # Create success embed + embed = EmbedTemplate.success( + title="Freeze Period Ended", + description=f"Manually triggered freeze end for week {current.week}." + ) + + embed.add_field( + name="Transaction Processing", + value=f"**Total Transactions:** {len(transactions) if transactions else 0}\n" + f"**Successful:** {winning_count}\n" + f"**Cancelled:** {losing_count}", + inline=True + ) + + embed.add_field( + name="League State", + value=f"**Week:** {current.week}\n" + f"**Freeze:** False\n" + f"**Season:** {current.season}", + inline=True + ) + + embed.add_field( + name="Actions Performed", + value=f"✅ Processed {len(transactions) if transactions else 0} frozen transactions\n" + f"✅ Resolved contested players\n" + f"✅ Freeze flag set to False", + inline=False + ) + + if transactions: + embed.add_field( + name="⚠️ Manual Steps Required", + value="• Post thaw announcement to #transaction-log\n" + "• Notify GMs of cancelled transactions\n" + "• Post successful transactions to #transaction-log", + inline=False + ) + + embed.set_footer(text=f"Triggered by {interaction.user.display_name}") + + # Edit the processing message to show final results instead of deleting and sending new + await processing_msg.edit(content=None, embed=embed) + + @app_commands.command( + name="admin-set-week", + description="[ADMIN] Manually set the current league week" + ) + @app_commands.describe( + week="Week number to set (1-24)" + ) + @logged_command("/admin-set-week") + async def admin_set_week(self, interaction: discord.Interaction, week: int): + """Manually set the current league week.""" + await interaction.response.defer() + + # Validate week number + if week < 1 or week > 24: + await interaction.followup.send( + "❌ Week number must be between 1 and 24.", + ephemeral=True + ) + return + + # Get current state + current = await league_service.get_current_state() + if not current: + await interaction.followup.send( + "❌ Could not retrieve current league state.", + ephemeral=True + ) + return + + # Update week + updated = await league_service.update_current_state(week=week) + + if not updated: + await interaction.followup.send( + "❌ Failed to update league week.", + ephemeral=True + ) + return + + # Create success embed + embed = EmbedTemplate.success( + title="League Week Updated", + description=f"Manually set league week to {week}." + ) + + embed.add_field( + name="Previous Week", + value=f"**Week:** {current.week}", + inline=True + ) + + embed.add_field( + name="New Week", + value=f"**Week:** {week}", + inline=True + ) + + embed.add_field( + name="Current State", + value=f"**Season:** {current.season}\n" + f"**Freeze:** {current.freeze}\n" + f"**Trade Deadline:** Week {current.trade_deadline}\n" + f"**Playoffs Begin:** Week {current.playoffs_begin}", + inline=False + ) + + embed.add_field( + name="⚠️ Warning", + value="Manual week changes bypass automated freeze/thaw processes.\n" + "Ensure you run appropriate admin commands for transaction management.", + inline=False + ) + + embed.set_footer(text=f"Changed by {interaction.user.display_name}") + await interaction.followup.send(embed=embed) + + @app_commands.command( + name="admin-set-freeze", + description="[ADMIN] Manually toggle freeze status" + ) + @app_commands.describe( + freeze="True to freeze transactions, False to unfreeze" + ) + @app_commands.choices(freeze=[ + app_commands.Choice(name="Freeze (True)", value=1), + app_commands.Choice(name="Unfreeze (False)", value=0) + ]) + @logged_command("/admin-set-freeze") + async def admin_set_freeze(self, interaction: discord.Interaction, freeze: int): + """Manually toggle the freeze status.""" + await interaction.response.defer() + + freeze_bool = bool(freeze) + + # Get current state + current = await league_service.get_current_state() + if not current: + await interaction.followup.send( + "❌ Could not retrieve current league state.", + ephemeral=True + ) + return + + # Check if already in desired state + if current.freeze == freeze_bool: + status = "frozen" if freeze_bool else "unfrozen" + embed = EmbedTemplate.warning( + title="No Change Needed", + description=f"League is already {status}." + ) + embed.add_field( + name="Current State", + value=f"**Week:** {current.week}\n**Freeze:** {current.freeze}\n**Season:** {current.season}", + inline=False + ) + await interaction.followup.send(embed=embed) + return + + # Update freeze status + updated = await league_service.update_current_state(freeze=freeze_bool) + + if not updated: + await interaction.followup.send( + "❌ Failed to update freeze status.", + ephemeral=True + ) + return + + # Create success embed + action = "Frozen" if freeze_bool else "Unfrozen" + embed = EmbedTemplate.success( + title=f"Transactions {action}", + description=f"Manually set freeze status to {freeze_bool}." + ) + + embed.add_field( + name="Previous Status", + value=f"**Freeze:** {current.freeze}", + inline=True + ) + + embed.add_field( + name="New Status", + value=f"**Freeze:** {freeze_bool}", + inline=True + ) + + embed.add_field( + name="Current State", + value=f"**Week:** {current.week}\n**Season:** {current.season}", + inline=False + ) + + if freeze_bool: + embed.add_field( + name="⚠️ Note", + value="Transactions are now frozen. Use `/admin-freeze-end` to process frozen transactions.", + inline=False + ) + else: + embed.add_field( + name="⚠️ Warning", + value="Manual freeze toggle bypasses transaction processing.\n" + "Ensure frozen transactions were processed before unfreezing.", + inline=False + ) + + embed.set_footer(text=f"Changed by {interaction.user.display_name}") + await interaction.followup.send(embed=embed) + + @app_commands.command( + name="admin-process-transactions", + description="[ADMIN] Manually process frozen transactions without changing freeze status" + ) + @app_commands.describe( + week="Week to process transactions for (defaults to current week)", + dry_run="Preview results without making changes (default: False)" + ) + @logged_command("/admin-process-transactions") + async def admin_process_transactions( + self, + interaction: discord.Interaction, + week: Optional[int] = None, + dry_run: bool = False + ): + """Manually process frozen transactions for a specific week.""" + await interaction.response.defer() + + # Get current state + current = await league_service.get_current_state() + if not current: + await interaction.followup.send( + "❌ Could not retrieve current league state.", + ephemeral=True + ) + return + + # Use provided week or current week + target_week = week if week is not None else current.week + + # Validate week + if target_week < 1 or target_week > 24: + await interaction.followup.send( + "❌ Week number must be between 1 and 24.", + ephemeral=True + ) + return + + # Send processing message + mode_text = " (DRY RUN - No changes will be made)" if dry_run else "" + processing_msg = await interaction.followup.send( + f"⏳ Processing frozen transactions for week {target_week}{mode_text}...", + wait=True + ) + + # Get frozen transactions for the week + transactions = await transaction_service.get_frozen_transactions_by_week( + season=current.season, + week_start=target_week, + week_end=target_week + 1 + ) + + if not transactions: + await processing_msg.edit( + content=f"ℹ️ No frozen transactions found for week {target_week}." + ) + return + + # Resolve contested transactions + winning_move_ids, losing_move_ids = await resolve_contested_transactions( + transactions, + current.season + ) + + # Process transactions (unless dry run) + if not dry_run: + # Cancel losing transactions (one API call per moveid, updates all transactions in group) + for losing_move_id in losing_move_ids: + await transaction_service.cancel_transaction(losing_move_id) + + # Unfreeze winning transactions (one API call per moveid, updates all transactions in group) + for winning_move_id in winning_move_ids: + await transaction_service.unfreeze_transaction(winning_move_id) + + # Create detailed results embed + if dry_run: + embed = EmbedTemplate.info( + title="Transaction Processing Preview", + description=f"Dry run results for week {target_week} (no changes made)." + ) + else: + embed = EmbedTemplate.success( + title="Transactions Processed", + description=f"Successfully processed frozen transactions for week {target_week}." + ) + + embed.add_field( + name="Transaction Summary", + value=f"**Total Frozen:** {len(transactions)}\n" + f"**Successful:** {len(winning_move_ids)}\n" + f"**Cancelled:** {len(losing_move_ids)}", + inline=True + ) + + embed.add_field( + name="Processing Details", + value=f"**Week:** {target_week}\n" + f"**Season:** {current.season}\n" + f"**Mode:** {'Dry Run' if dry_run else 'Live'}", + inline=True + ) + + # Show contested transactions + if losing_move_ids: + contested_info = [] + for losing_move_id in losing_move_ids: + losing_moves = [t for t in transactions if t.moveid == losing_move_id] + if losing_moves: + player_name = losing_moves[0].player.name + team_abbrev = losing_moves[0].newteam.abbrev + contested_info.append(f"• {player_name} ({team_abbrev} - cancelled)") + + if contested_info: + # Limit to first 10 contested transactions + display_info = contested_info[:10] + if len(contested_info) > 10: + display_info.append(f"... and {len(contested_info) - 10} more") + + embed.add_field( + name="Contested Transactions", + value="\n".join(display_info), + inline=False + ) + + # Add warnings + if dry_run: + embed.add_field( + name="ℹ️ Dry Run Mode", + value="No transactions were modified. Run without `dry_run` parameter to apply changes.", + inline=False + ) + else: + embed.add_field( + name="⚠️ Manual Steps Required", + value="• Notify GMs of cancelled transactions\n" + "• Post successful transactions to #transaction-log\n" + "• Verify all transactions processed correctly", + inline=False + ) + + embed.set_footer(text=f"Processed by {interaction.user.display_name}") + + # Edit the processing message to show final results instead of deleting and sending new + await processing_msg.edit(content=None, embed=embed) + + +async def setup(bot: commands.Bot): + """Load the league management commands cog.""" + await bot.add_cog(LeagueManagementCommands(bot)) diff --git a/config.py b/config.py index dbe0b61..81cfea7 100644 --- a/config.py +++ b/config.py @@ -33,6 +33,7 @@ class BotConfig(BaseSettings): weeks_per_season: int = 18 games_per_week: int = 4 modern_stats_start_season: int = 8 + offseason_flag: bool = False # When True, relaxes roster limits and disables weekly freeze/thaw # Current Season Constants sba_current_season: int = 12 diff --git a/services/league_service.py b/services/league_service.py index 35545d2..d39444c 100644 --- a/services/league_service.py +++ b/services/league_service.py @@ -32,26 +32,76 @@ class LeagueService(BaseService[Current]): async def get_current_state(self) -> Optional[Current]: """ Get the current league state including week, season, and settings. - + Returns: Current league state or None if not available """ try: client = await self.get_client() data = await client.get('current') - + if data: current = Current.from_api_data(data) logger.debug(f"Retrieved current state: Week {current.week}, Season {current.season}") return current - + logger.debug("No current state data found") return None - + except Exception as e: logger.error(f"Failed to get current league state: {e}") return None - + + async def update_current_state( + self, + week: Optional[int] = None, + freeze: Optional[bool] = None + ) -> Optional[Current]: + """ + Update current league state (week and/or freeze status). + + This is typically used by automated tasks to increment the week + and toggle freeze status during weekly operations. + + Args: + week: New week number (None to leave unchanged) + freeze: New freeze status (None to leave unchanged) + + Returns: + Updated Current object or None if update failed + + Raises: + APIException: If the update operation fails + """ + try: + # Build update data + update_data = {} + if week is not None: + update_data['week'] = week + if freeze is not None: + update_data['freeze'] = freeze + + if not update_data: + logger.warning("update_current_state called with no updates") + return await self.get_current_state() + + # Current state always has ID of 1 (single record) + current_id = 1 + + # Use BaseService patch method + updated_current = await self.patch(current_id, update_data) + + if updated_current: + logger.info(f"Updated current state: {update_data}") + return updated_current + else: + logger.error("Failed to update current state - patch returned None") + return None + + except Exception as e: + logger.error(f"Error updating current state: {e}") + raise APIException(f"Failed to update current state: {e}") + async def get_standings(self, season: Optional[int] = None) -> Optional[List[Dict[str, Any]]]: """ Get league standings for a season. diff --git a/services/transaction_service.py b/services/transaction_service.py index 6104812..04fa13d 100644 --- a/services/transaction_service.py +++ b/services/transaction_service.py @@ -191,40 +191,126 @@ class TransactionService(BaseService[Transaction]): async def cancel_transaction(self, transaction_id: str) -> bool: """ Cancel a pending transaction. - + + Note: When using moveid, this updates ALL transactions with that moveid (bulk update). + The API returns a message string like "Updated 4 transactions" instead of the transaction object. + Args: - transaction_id: ID of transaction to cancel - + transaction_id: Move ID of transaction to cancel (e.g., "Season-012-Week-17-08-18:57:21") + Returns: True if cancelled successfully """ try: - transaction = await self.get_by_id(transaction_id) - if not transaction: - return False - - if not transaction.is_pending: - logger.warning(f"Cannot cancel transaction {transaction_id}: not pending (cancelled={transaction.cancelled}, frozen={transaction.frozen})") - return False - - # Update transaction status + # Update transaction status using direct API call to handle bulk updates update_data = { 'cancelled': True, 'cancelled_at': datetime.now(UTC).isoformat() } - - updated_transaction = await self.update(transaction_id, update_data) - - if updated_transaction: + + # Call API directly since bulk update returns a message string, not a Transaction object + client = await self.get_client() + response = await client.patch( + self.endpoint, + update_data, + object_id=transaction_id, + use_query_params=True + ) + + # Check if response indicates success + # Response will be a string like "Updated 4 transactions" for bulk updates + if response and (isinstance(response, str) and 'Updated' in response): + logger.info(f"Cancelled transaction(s) {transaction_id}: {response}") + return True + elif response: + # If we got a dict response, it's a single transaction update logger.info(f"Cancelled transaction {transaction_id}") return True else: + logger.warning(f"Failed to cancel transaction {transaction_id}") return False - + except Exception as e: logger.error(f"Error cancelling transaction {transaction_id}: {e}") return False - + + async def unfreeze_transaction(self, transaction_id: str) -> bool: + """ + Unfreeze a frozen transaction, allowing it to be processed. + + Note: When using moveid, this updates ALL transactions with that moveid (bulk update). + The API returns a message string like "Updated 4 transactions" instead of the transaction object. + + Args: + transaction_id: Move ID of transaction to unfreeze (e.g., "Season-012-Week-17-08-18:57:21") + + Returns: + True if unfrozen successfully + """ + try: + # Call API directly since bulk update returns a message string, not a Transaction object + client = await self.get_client() + response = await client.patch( + self.endpoint, + {'frozen': False}, + object_id=transaction_id, + use_query_params=True + ) + + # Check if response indicates success + # Response will be a string like "Updated 4 transactions" for bulk updates + if response and (isinstance(response, str) and 'Updated' in response): + logger.info(f"Unfroze transaction(s) {transaction_id}: {response}") + return True + elif response: + # If we got a dict response, it's a single transaction update + logger.info(f"Unfroze transaction {transaction_id}") + return True + else: + logger.warning(f"Failed to unfreeze transaction {transaction_id}") + return False + + except Exception as e: + logger.error(f"Error unfreezing transaction {transaction_id}: {e}") + return False + + async def get_frozen_transactions_by_week( + self, + season: int, + week_start: int, + week_end: int + ) -> List[Transaction]: + """ + Get all frozen transactions for a week range (all teams). + + This is used during freeze processing to get all contested transactions + across the entire league. + + Args: + season: Season number + week_start: Starting week number + week_end: Ending week number + + Returns: + List of frozen transactions for the week range + """ + try: + params = [ + ('season', str(season)), + ('week_start', str(week_start)), + ('week_end', str(week_end)), + ('frozen', 'true') + ] + + transactions = await self.get_all_items(params=params) + + logger.debug(f"Retrieved {len(transactions)} frozen transactions for weeks {week_start}-{week_end}") + return transactions + + except Exception as e: + logger.error(f"Error getting frozen transactions for weeks {week_start}-{week_end}: {e}") + return [] + async def get_contested_transactions(self, season: int, week: int) -> List[Transaction]: """ Get transactions that may be contested (multiple teams want same player). diff --git a/tasks/transaction_freeze.py b/tasks/transaction_freeze.py new file mode 100644 index 0000000..d344274 --- /dev/null +++ b/tasks/transaction_freeze.py @@ -0,0 +1,685 @@ +""" +Transaction Freeze/Thaw Task for Discord Bot v2.0 + +Automated weekly system for freezing and processing transactions. +Runs on a schedule to increment weeks and process contested transactions. +""" +import random +from datetime import datetime, UTC +from typing import Dict, List, Tuple, Set +from dataclasses import dataclass + +import discord +from discord.ext import commands, tasks + +from services.league_service import league_service +from services.transaction_service import transaction_service +from services.standings_service import standings_service +from models.current import Current +from models.transaction import Transaction +from utils.logging import get_contextual_logger +from views.embeds import EmbedTemplate, EmbedColors +from config import get_config + + +@dataclass +class TransactionPriority: + """ + Data class for transaction priority calculation. + Used to resolve contested transactions (multiple teams wanting same player). + """ + transaction: Transaction + team_win_percentage: float + tiebreaker: float # win% + small random number for randomized tiebreak + + def __lt__(self, other): + """Allow sorting by tiebreaker value.""" + return self.tiebreaker < other.tiebreaker + + +async def resolve_contested_transactions( + transactions: List[Transaction], + season: int +) -> Tuple[List[str], List[str]]: + """ + Resolve contested transactions where multiple teams want the same player. + + This is extracted as a pure function for testability. + + Args: + transactions: List of all frozen transactions for the week + season: Current season number + + Returns: + Tuple of (winning_move_ids, losing_move_ids) + """ + logger = get_contextual_logger(f'{__name__}.resolve_contested_transactions') + + # Group transactions by player name + player_transactions: Dict[str, List[Transaction]] = {} + + for transaction in transactions: + player_name = transaction.player.name.lower() + + # Only consider transactions where a team is acquiring a player (not FA drops) + if transaction.newteam.abbrev.upper() != 'FA': + if player_name not in player_transactions: + player_transactions[player_name] = [] + player_transactions[player_name].append(transaction) + + # Identify contested players (multiple teams want same player) + contested_players: Dict[str, List[Transaction]] = {} + non_contested_moves: Set[str] = set() + + for player_name, player_transactions_list in player_transactions.items(): + if len(player_transactions_list) > 1: + contested_players[player_name] = player_transactions_list + logger.info(f"Contested player: {player_name} ({len(player_transactions_list)} teams)") + else: + # Non-contested, automatically wins + non_contested_moves.add(player_transactions_list[0].moveid) + + # Resolve contests using team priority (win% + random tiebreaker) + winning_move_ids: Set[str] = set() + losing_move_ids: Set[str] = set() + + for player_name, contested_transactions in contested_players.items(): + priorities: List[TransactionPriority] = [] + + for transaction in contested_transactions: + # Get team for priority calculation + # If adding to MiL team, use the parent ML team for standings + if transaction.newteam.abbrev.endswith('MiL'): + team_abbrev = transaction.newteam.abbrev[:-3] # Remove 'MiL' suffix + else: + team_abbrev = transaction.newteam.abbrev + + try: + # Get team standings to calculate win percentage + standings = await standings_service.get_team_standings(team_abbrev, season) + + if standings and standings.wins is not None and standings.losses is not None: + total_games = standings.wins + standings.losses + win_pct = standings.wins / total_games if total_games > 0 else 0.0 + else: + win_pct = 0.0 + logger.warning(f"Could not get standings for {team_abbrev}, using 0.0 win%") + + # Add small random component for tiebreaking (5 decimal precision) + random_component = random.randint(10000, 99999) * 0.00000001 + tiebreaker = win_pct + random_component + + priorities.append(TransactionPriority( + transaction=transaction, + team_win_percentage=win_pct, + tiebreaker=tiebreaker + )) + + except Exception as e: + logger.error(f"Error calculating priority for {team_abbrev}: {e}") + # Give them 0.0 priority on error + priorities.append(TransactionPriority( + transaction=transaction, + team_win_percentage=0.0, + tiebreaker=random.randint(10000, 99999) * 0.00000001 + )) + + # Sort by tiebreaker (lowest win% wins - worst teams get priority) + priorities.sort() + + # First team wins, rest lose + if priorities: + winner = priorities[0] + winning_move_ids.add(winner.transaction.moveid) + + logger.info( + f"Contest resolved for {player_name}: {winner.transaction.newteam.abbrev} wins " + f"(win%: {winner.team_win_percentage:.3f}, tiebreaker: {winner.tiebreaker:.8f})" + ) + + for loser in priorities[1:]: + losing_move_ids.add(loser.transaction.moveid) + logger.info( + f"Contest lost for {player_name}: {loser.transaction.newteam.abbrev} " + f"(win%: {loser.team_win_percentage:.3f}, tiebreaker: {loser.tiebreaker:.8f})" + ) + + # Add non-contested moves to winners + winning_move_ids.update(non_contested_moves) + + return list(winning_move_ids), list(losing_move_ids) + + +class TransactionFreezeTask: + """Automated weekly freeze/thaw system for transactions.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.TransactionFreezeTask') + self.weekly_warning_sent = False # Prevent duplicate error notifications + self.logger.info("Transaction freeze/thaw task initialized") + + # Start the weekly loop + self.weekly_loop.start() + + def cog_unload(self): + """Stop the task when cog is unloaded.""" + self.weekly_loop.cancel() + + @tasks.loop(minutes=1) + async def weekly_loop(self): + """ + Main loop that checks time and triggers freeze/thaw operations. + + Runs every minute and checks: + - Monday 00:00 -> Begin freeze (increment week, set freeze flag) + - Saturday 00:00 -> End freeze (process frozen transactions) + """ + try: + self.logger.info("Weekly loop check starting") + config = get_config() + + # Skip if offseason mode is enabled + if config.offseason_flag: + self.logger.info("Skipping freeze/thaw operations - offseason mode enabled") + return + + # Get current league state + current = await league_service.get_current_state() + if not current: + self.logger.warning("Could not get current league state") + return + + now = datetime.now() + self.logger.info( + f"Weekly loop check", + datetime=now.isoformat(), + weekday=now.weekday(), + hour=now.hour, + current_week=current.week, + freeze_status=current.freeze + ) + + # BEGIN FREEZE: Monday at 00:00, not already frozen + if now.weekday() == 0 and now.hour == 0 and not current.freeze: + self.logger.info("Triggering freeze begin") + await self._begin_freeze(current) + self.weekly_warning_sent = False # Reset error flag + + # END FREEZE: Saturday at 00:00, currently frozen + elif now.weekday() == 5 and now.hour == 0 and current.freeze: + self.logger.info("Triggering freeze end") + await self._end_freeze(current) + self.weekly_warning_sent = False # Reset error flag + + else: + self.logger.debug("No freeze/thaw action needed at this time") + + except Exception as e: + self.logger.error(f"Unhandled exception in weekly_loop: {e}", exc_info=True) + error_message = ( + f"⚠️ **Weekly Freeze Task Failed**\n" + f"```\n" + f"Error: {str(e)}\n" + f"Time: {datetime.now(UTC).isoformat()}\n" + f"Task: weekly_loop in transaction_freeze.py\n" + f"```" + ) + + try: + if not self.weekly_warning_sent: + await self._send_owner_notification(error_message) + self.weekly_warning_sent = True + except Exception as notify_error: + self.logger.error(f"Failed to send error notification: {notify_error}") + + @weekly_loop.before_loop + async def before_weekly_loop(self): + """Wait for bot to be ready before starting.""" + await self.bot.wait_until_ready() + self.logger.info("Bot is ready, transaction freeze/thaw task starting") + + async def _begin_freeze(self, current: Current): + """ + Begin weekly freeze period. + + Actions: + 1. Increment current week + 2. Set freeze flag to True + 3. Run regular transactions for current week + 4. Send freeze announcement + 5. Post weekly info (weeks 1-18 only) + """ + try: + self.logger.info(f"Beginning freeze for week {current.week}") + + # Increment week and set freeze via service + new_week = current.week + 1 + updated_current = await league_service.update_current_state( + week=new_week, + freeze=True + ) + + if not updated_current: + raise Exception("Failed to update current state during freeze begin") + + self.logger.info(f"Week incremented to {new_week}, freeze set to True") + + # Update local current object with returned data + current.week = updated_current.week + current.freeze = updated_current.freeze + + # Run regular transactions for the new week + await self._run_transactions(current) + + # Send freeze announcement + await self._send_freeze_announcement(current.week, is_beginning=True) + + # Post weekly info for weeks 1-18 + if 1 <= current.week <= 18: + await self._post_weekly_info(current) + + self.logger.info(f"Freeze begin completed for week {current.week}") + + except Exception as e: + self.logger.error(f"Error in _begin_freeze: {e}", exc_info=True) + raise + + async def _end_freeze(self, current: Current): + """ + End weekly freeze period. + + Actions: + 1. Process frozen transactions with priority resolution + 2. Set freeze flag to False + 3. Send thaw announcement + """ + try: + self.logger.info(f"Ending freeze for week {current.week}") + + # Process frozen transactions BEFORE unfreezing + await self._process_frozen_transactions(current) + + # Set freeze to False via service + updated_current = await league_service.update_current_state(freeze=False) + + if not updated_current: + raise Exception("Failed to update current state during freeze end") + + self.logger.info(f"Freeze set to False for week {current.week}") + + # Update local current object + current.freeze = updated_current.freeze + + # Send thaw announcement + await self._send_freeze_announcement(current.week, is_beginning=False) + + self.logger.info(f"Freeze end completed for week {current.week}") + + except Exception as e: + self.logger.error(f"Error in _end_freeze: {e}", exc_info=True) + raise + + async def _run_transactions(self, current: Current): + """ + Process regular (non-frozen) transactions for the current week. + + These are transactions that take effect immediately. + """ + try: + # Get all non-frozen transactions for current week + client = await transaction_service.get_client() + params = [ + ('season', str(current.season)), + ('week_start', str(current.week)), + ('week_end', str(current.week)) + ] + + response = await client.get('transactions', params=params) + + if not response or response.get('count', 0) == 0: + self.logger.info(f"No regular transactions to process for week {current.week}") + return + + transactions = response.get('transactions', []) + self.logger.info(f"Processing {len(transactions)} regular transactions for week {current.week}") + + # Note: The actual player updates would happen via the API here + # For now, we just log them - the API handles the actual roster updates + + except Exception as e: + self.logger.error(f"Error running transactions: {e}", exc_info=True) + + async def _process_frozen_transactions(self, current: Current): + """ + Process frozen transactions with priority resolution. + + Uses the NEW transaction logic (no backup implementation). + + Steps: + 1. Get all frozen transactions for current week + 2. Resolve contested transactions (multiple teams want same player) + 3. Cancel losing transactions + 4. Unfreeze and post winning transactions + """ + try: + # Get all frozen transactions for current week via service + transactions = await transaction_service.get_frozen_transactions_by_week( + season=current.season, + week_start=current.week, + week_end=current.week + 1 + ) + + if not transactions: + self.logger.warning(f"No frozen transactions to process for week {current.week}") + return + + self.logger.info(f"Processing {len(transactions)} frozen transactions for week {current.week}") + + # Resolve contested transactions + winning_move_ids, losing_move_ids = await resolve_contested_transactions( + transactions, + current.season + ) + + # Cancel losing transactions via service + for losing_move_id in losing_move_ids: + try: + # Get all moves with this moveid (could be multiple players in one transaction) + losing_moves = [t for t in transactions if t.moveid == losing_move_id] + + if losing_moves: + # Cancel the entire transaction (all moves with same moveid) + for move in losing_moves: + success = await transaction_service.cancel_transaction(move.moveid) + if not success: + self.logger.warning(f"Failed to cancel transaction {move.moveid}") + + # Notify the GM(s) about cancellation + first_move = losing_moves[0] + + # Determine which team to notify (the team that was trying to acquire) + team_for_notification = (first_move.newteam + if first_move.newteam.abbrev.upper() != 'FA' + else first_move.oldteam) + + await self._notify_gm_of_cancellation(first_move, team_for_notification) + + contested_players = [move.player.name for move in losing_moves] + self.logger.info( + f"Cancelled transaction {losing_move_id} due to contested players: " + f"{contested_players}" + ) + + except Exception as e: + self.logger.error(f"Error cancelling transaction {losing_move_id}: {e}") + + # Unfreeze winning transactions and post to log via service + for winning_move_id in winning_move_ids: + try: + # Get all moves with this moveid + winning_moves = [t for t in transactions if t.moveid == winning_move_id] + + for move in winning_moves: + # Unfreeze the transaction via service + success = await transaction_service.unfreeze_transaction(move.moveid) + if not success: + self.logger.warning(f"Failed to unfreeze transaction {move.moveid}") + + # Post to transaction log + await self._post_transaction_to_log(winning_move_id, transactions) + + self.logger.info(f"Processed successful transaction {winning_move_id}") + + except Exception as e: + self.logger.error(f"Error processing winning transaction {winning_move_id}: {e}") + + self.logger.info( + f"Freeze processing complete: {len(winning_move_ids)} successful transactions, " + f"{len(losing_move_ids)} cancelled transactions" + ) + + except Exception as e: + self.logger.error(f"Error during freeze processing: {e}", exc_info=True) + raise + + async def _send_freeze_announcement(self, week: int, is_beginning: bool): + """ + Send freeze/thaw announcement to transaction log channel. + + Args: + week: Current week number + is_beginning: True for freeze begin, False for freeze end + """ + try: + config = get_config() + guild = self.bot.get_guild(config.guild_id) + if not guild: + self.logger.warning("Could not find guild for freeze announcement") + return + + channel = discord.utils.get(guild.text_channels, name='transaction-log') + if not channel: + self.logger.warning("Could not find transaction-log channel") + return + + # Create announcement message (formatted like legacy bot) + week_num = f'Week {week}' + stars = '*' * 32 + + if is_beginning: + message = ( + f'```\n' + f'{stars}\n' + f'{week_num:>9} Freeze Period Begins\n' + f'{stars}\n' + f'```' + ) + else: + message = ( + f'```\n' + f'{"*" * 30}\n' + f'{week_num:>9} Freeze Period Ends\n' + f'{"*" * 30}\n' + f'```' + ) + + await channel.send(message) + self.logger.info(f"Freeze announcement sent for week {week} ({'begin' if is_beginning else 'end'})") + + except Exception as e: + self.logger.error(f"Error sending freeze announcement: {e}") + + async def _post_weekly_info(self, current: Current): + """ + Post weekly schedule information to #weekly-info channel. + + Args: + current: Current league state + """ + try: + config = get_config() + guild = self.bot.get_guild(config.guild_id) + if not guild: + return + + info_channel = discord.utils.get(guild.text_channels, name='weekly-info') + if not info_channel: + self.logger.warning("Could not find weekly-info channel") + return + + # Clear recent messages (last 25) + async for message in info_channel.history(limit=25): + try: + await message.delete() + except: + pass # Ignore deletion errors + + # Determine season emoji + if current.week <= 5: + season_str = "🌼 **Spring**" + elif current.week > 14: + season_str = "🍂 **Fall**" + else: + season_str = "🏖️ **Summer**" + + # Determine day/night schedule + night_str = "🌙 Night" + day_str = "🌞 Day" + is_div_week = current.week in [1, 3, 6, 14, 16, 18] + + weekly_str = ( + f'**Season**: {season_str}\n' + f'**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / ' + f'{night_str} / {day_str}' + ) + + # Send info messages + await info_channel.send( + content=( + f'Each team has manage permissions in their home ballpark. ' + f'They may pin messages and rename the channel.\n\n' + f'**Make sure your ballpark starts with your team abbreviation.**' + ) + ) + await info_channel.send(weekly_str) + + self.logger.info(f"Weekly info posted for week {current.week}") + + except Exception as e: + self.logger.error(f"Error posting weekly info: {e}") + + async def _post_transaction_to_log( + self, + move_id: str, + all_transactions: List[Transaction] + ): + """ + Post a transaction to the transaction log channel. + + Args: + move_id: Transaction move ID + all_transactions: List of all transactions to find moves with this ID + """ + try: + config = get_config() + guild = self.bot.get_guild(config.guild_id) + if not guild: + return + + channel = discord.utils.get(guild.text_channels, name='transaction-log') + if not channel: + return + + # Get all moves with this moveid + moves = [t for t in all_transactions if t.moveid == move_id] + if not moves: + return + + # Determine the team for the embed (team making the moves) + first_move = moves[0] + if first_move.newteam.abbrev.upper() != 'FA' and 'IL' not in first_move.newteam.abbrev: + this_team = first_move.newteam + elif first_move.oldteam.abbrev.upper() != 'FA' and 'IL' not in first_move.oldteam.abbrev: + this_team = first_move.oldteam + else: + # Default to newteam if both are FA/IL + this_team = first_move.newteam + + # Build move string + move_string = "" + week_num = first_move.week + + for move in moves: + move_string += ( + f'**{move.player.name}** ({move.player.wara:.1f}) ' + f'from {move.oldteam.abbrev} to {move.newteam.abbrev}\n' + ) + + # Create embed + embed = EmbedTemplate.create_base_embed( + title=f'Week {week_num} Transaction', + description=this_team.sname if hasattr(this_team, 'sname') else this_team.lname, + color=EmbedColors.INFO + ) + + # Set team color if available + if hasattr(this_team, 'color') and this_team.color: + try: + embed.color = discord.Color(int(this_team.color.replace('#', ''), 16)) + except: + pass # Use default color on error + + embed.add_field(name='Player Moves', value=move_string, inline=False) + + await channel.send(embed=embed) + self.logger.info(f"Transaction posted to log: {move_id}") + + except Exception as e: + self.logger.error(f"Error posting transaction to log: {e}") + + async def _notify_gm_of_cancellation( + self, + transaction: Transaction, + team + ): + """ + Send DM to GM(s) about cancelled transaction. + + Args: + transaction: The cancelled transaction + team: Team whose GMs should be notified + """ + try: + config = get_config() + guild = self.bot.get_guild(config.guild_id) + if not guild: + return + + cancel_text = ( + f'Your transaction for **{transaction.player.name}** has been cancelled ' + f'because another team successfully claimed them during the freeze period.' + ) + + # Notify GM1 + if hasattr(team, 'gmid') and team.gmid: + try: + gm_one = guild.get_member(team.gmid) + if gm_one: + await gm_one.send(cancel_text) + self.logger.info(f"Cancellation notification sent to GM1 of {team.abbrev}") + except Exception as e: + self.logger.error(f"Could not notify GM1 of {team.abbrev}: {e}") + + # Notify GM2 if exists + if hasattr(team, 'gmid2') and team.gmid2: + try: + gm_two = guild.get_member(team.gmid2) + if gm_two: + await gm_two.send(cancel_text) + self.logger.info(f"Cancellation notification sent to GM2 of {team.abbrev}") + except Exception as e: + self.logger.error(f"Could not notify GM2 of {team.abbrev}: {e}") + + except Exception as e: + self.logger.error(f"Error notifying GM of cancellation: {e}") + + async def _send_owner_notification(self, message: str): + """ + Send error notification to bot owner. + + Args: + message: Error message to send + """ + try: + app_info = await self.bot.application_info() + if app_info.owner: + await app_info.owner.send(message) + self.logger.info("Owner notification sent") + except Exception as e: + self.logger.error(f"Could not send owner notification: {e}") + + +def setup_freeze_task(bot: commands.Bot) -> TransactionFreezeTask: + """Set up the transaction freeze/thaw task.""" + return TransactionFreezeTask(bot) diff --git a/tests/test_tasks_transaction_freeze.py b/tests/test_tasks_transaction_freeze.py new file mode 100644 index 0000000..d0226d9 --- /dev/null +++ b/tests/test_tasks_transaction_freeze.py @@ -0,0 +1,1172 @@ +""" +Tests for Transaction Freeze/Thaw Tasks in Discord Bot v2.0 + +Validates the automated weekly freeze system for transactions, including: +- Freeze/thaw scheduling logic +- Contested transaction resolution +- Priority calculation using team standings +- GM notifications +- Transaction processing +""" +import pytest +from datetime import datetime, timezone, UTC +from unittest.mock import AsyncMock, MagicMock, Mock, patch, call +from typing import List + +from tasks.transaction_freeze import ( + TransactionFreezeTask, + resolve_contested_transactions, + TransactionPriority +) +from models.transaction import Transaction +from models.current import Current +from models.team import Team +from models.player import Player +from models.standings import TeamStandings +from tests.factories import ( + PlayerFactory, + TeamFactory, + CurrentFactory +) + + +@pytest.fixture +def mock_bot(): + """Fixture providing a mock Discord bot.""" + bot = AsyncMock() + bot.wait_until_ready = AsyncMock() + + # Mock guild + mock_guild = MagicMock() + mock_guild.id = 12345 + mock_guild.text_channels = [] + bot.get_guild.return_value = mock_guild + + # Mock application info for owner notifications + app_info = MagicMock() + app_info.owner = AsyncMock() + bot.application_info = AsyncMock(return_value=app_info) + + return bot + + +@pytest.fixture +def current_state() -> Current: + """Fixture providing current league state.""" + return CurrentFactory.create( + week=10, + season=12, + freeze=False, + trade_deadline=14, + playoffs_begin=19 + ) + + +@pytest.fixture +def frozen_state() -> Current: + """Fixture providing frozen league state.""" + return CurrentFactory.create( + week=10, + season=12, + freeze=True, + trade_deadline=14, + playoffs_begin=19 + ) + + +@pytest.fixture +def sample_team_wv() -> Team: + """Fixture providing West Virginia team.""" + return TeamFactory.west_virginia( + id=499, + gmid=111111, + gmid2=222222 + ) + + +@pytest.fixture +def sample_team_ny() -> Team: + """Fixture providing New York team.""" + return TeamFactory.new_york( + id=500, + gmid=333333, + gmid2=None + ) + + +@pytest.fixture +def sample_player() -> Player: + """Fixture providing a test player.""" + return PlayerFactory.mike_trout( + id=12472, + team_id=None, # Free agent + wara=2.5 + ) + + +@pytest.fixture +def sample_transaction(sample_player, sample_team_wv) -> Transaction: + """Fixture providing a sample transaction.""" + fa_team = TeamFactory.create( + id=999, + abbrev="FA", + sname="Free Agents", + lname="Free Agents", + season=12 + ) + + return Transaction( + id=27787, + week=10, + season=12, + moveid='Season-012-Week-10-19-13:04:41', + player=sample_player, + oldteam=fa_team, + newteam=sample_team_wv, + cancelled=False, + frozen=True + ) + + +@pytest.fixture +def contested_transactions(sample_player, sample_team_wv, sample_team_ny) -> List[Transaction]: + """Fixture providing contested transactions (two teams want same player).""" + fa_team = TeamFactory.create( + id=999, + abbrev="FA", + sname="Free Agents", + lname="Free Agents", + season=12 + ) + + # Transaction 1: WV wants the player + tx1 = Transaction( + id=27787, + week=10, + season=12, + moveid='Season-012-Week-10-WV-13:04:41', + player=sample_player, + oldteam=fa_team, + newteam=sample_team_wv, + cancelled=False, + frozen=True + ) + + # Transaction 2: NY wants the same player + tx2 = Transaction( + id=27788, + week=10, + season=12, + moveid='Season-012-Week-10-NY-13:05:00', + player=sample_player, + oldteam=fa_team, + newteam=sample_team_ny, + cancelled=False, + frozen=True + ) + + return [tx1, tx2] + + +@pytest.fixture +def mil_transaction(sample_player, sample_team_wv) -> Transaction: + """Fixture providing a MiL team transaction.""" + fa_team = TeamFactory.create( + id=999, + abbrev="FA", + sname="Free Agents", + lname="Free Agents", + season=12 + ) + + mil_team = TeamFactory.create( + id=501, + abbrev="WVMiL", + sname="Black Bears MiL", + lname="West Virginia Black Bears MiL", + season=12 + ) + + return Transaction( + id=27789, + week=10, + season=12, + moveid='Season-012-Week-10-WVMiL-14:00:00', + player=sample_player, + oldteam=fa_team, + newteam=mil_team, + cancelled=False, + frozen=True + ) + + +@pytest.fixture +def sample_standings_wv() -> TeamStandings: + """Fixture providing standings for WV (bad team - higher priority).""" + # Create a minimal team for standings + team_wv = TeamFactory.west_virginia(id=499) + + return TeamStandings( + id=1, + team=team_wv, + wins=30, + losses=70, + run_diff=-200, + div_gb=None, + div_e_num=None, + wc_gb=None, + wc_e_num=None, + home_wins=15, + home_losses=35, + away_wins=15, + away_losses=35, + last8_wins=2, + last8_losses=6, + streak_wl="l", + streak_num=3, + one_run_wins=8, + one_run_losses=12, + pythag_wins=28, + pythag_losses=72, + div1_wins=8, + div1_losses=12, + div2_wins=7, + div2_losses=13, + div3_wins=8, + div3_losses=12, + div4_wins=7, + div4_losses=13 + ) + + +@pytest.fixture +def sample_standings_ny() -> TeamStandings: + """Fixture providing standings for NY (good team - lower priority).""" + # Create a minimal team for standings + team_ny = TeamFactory.new_york(id=500) + + return TeamStandings( + id=2, + team=team_ny, + wins=70, + losses=30, + run_diff=200, + div_gb=None, + div_e_num=None, + wc_gb=None, + wc_e_num=None, + home_wins=35, + home_losses=15, + away_wins=35, + away_losses=15, + last8_wins=6, + last8_losses=2, + streak_wl="w", + streak_num=4, + one_run_wins=12, + one_run_losses=8, + pythag_wins=72, + pythag_losses=28, + div1_wins=12, + div1_losses=8, + div2_wins=13, + div2_losses=7, + div3_wins=12, + div3_losses=8, + div4_wins=13, + div4_losses=7 + ) + + +class TestTransactionPriority: + """Test TransactionPriority data class.""" + + def test_priority_initialization(self, sample_transaction): + """Test TransactionPriority initialization.""" + priority = TransactionPriority( + transaction=sample_transaction, + team_win_percentage=0.500, + tiebreaker=0.50012345 + ) + + assert priority.transaction == sample_transaction + assert priority.team_win_percentage == 0.500 + assert priority.tiebreaker == 0.50012345 + + def test_priority_sorting_by_tiebreaker(self, sample_transaction): + """Test that priorities sort correctly by tiebreaker (lowest first).""" + priority1 = TransactionPriority( + transaction=sample_transaction, + team_win_percentage=0.300, + tiebreaker=0.30012345 + ) + + priority2 = TransactionPriority( + transaction=sample_transaction, + team_win_percentage=0.700, + tiebreaker=0.70012345 + ) + + priorities = [priority2, priority1] + priorities.sort() + + # Lower tiebreaker should come first (worst teams get priority) + assert priorities[0].tiebreaker == 0.30012345 + assert priorities[1].tiebreaker == 0.70012345 + + def test_priority_comparison(self, sample_transaction): + """Test priority comparison operators.""" + priority_low = TransactionPriority( + transaction=sample_transaction, + team_win_percentage=0.300, + tiebreaker=0.300 + ) + + priority_high = TransactionPriority( + transaction=sample_transaction, + team_win_percentage=0.700, + tiebreaker=0.700 + ) + + assert priority_low < priority_high + assert not priority_high < priority_low + + +class TestResolveContestedTransactions: + """Test resolve_contested_transactions function.""" + + @pytest.mark.asyncio + async def test_no_contested_transactions(self, sample_transaction): + """Test with no contested transactions (single team wants player).""" + transactions = [sample_transaction] + + with patch('tasks.transaction_freeze.standings_service') as mock_standings: + mock_standings.get_team_standings = AsyncMock(return_value=None) + + winning_ids, losing_ids = await resolve_contested_transactions(transactions, 12) + + # Single transaction should win automatically + assert sample_transaction.moveid in winning_ids + assert len(losing_ids) == 0 + + @pytest.mark.asyncio + async def test_contested_transaction_resolution( + self, + contested_transactions, + sample_standings_wv, + sample_standings_ny + ): + """Test contested transaction resolution with priority.""" + with patch('tasks.transaction_freeze.standings_service') as mock_standings: + async def get_standings(team_abbrev, season): + if team_abbrev == "WV": + return sample_standings_wv # 0.300 win% + elif team_abbrev == "NY": + return sample_standings_ny # 0.700 win% + return None + + mock_standings.get_team_standings = AsyncMock(side_effect=get_standings) + + # Mock random for deterministic testing + with patch('tasks.transaction_freeze.random.randint', return_value=50000): + winning_ids, losing_ids = await resolve_contested_transactions( + contested_transactions, 12 + ) + + # WV should win (lower win% = higher priority) + assert len(winning_ids) == 1 + assert len(losing_ids) == 1 + + # Find which transaction won + wv_tx = next(tx for tx in contested_transactions if tx.newteam.abbrev == "WV") + ny_tx = next(tx for tx in contested_transactions if tx.newteam.abbrev == "NY") + + assert wv_tx.moveid in winning_ids + assert ny_tx.moveid in losing_ids + + @pytest.mark.asyncio + async def test_mil_team_uses_parent_standings(self, sample_player, sample_standings_wv): + """Test that MiL team transactions use parent ML team standings.""" + # Create MiL team transaction that WILL be contested + fa_team = TeamFactory.create( + id=999, + abbrev="FA", + sname="Free Agents", + lname="Free Agents", + season=12 + ) + + mil_team = TeamFactory.create( + id=501, + abbrev="WVMiL", + sname="Black Bears MiL", + lname="West Virginia Black Bears MiL", + season=12 + ) + + # Create TWO transactions for the same player to trigger contest resolution + mil_transaction = Transaction( + id=27789, + week=10, + season=12, + moveid='Season-012-Week-10-WVMiL-14:00:00', + player=sample_player, + oldteam=fa_team, + newteam=mil_team, + cancelled=False, + frozen=True + ) + + # Second transaction to create a contest + ny_team = TeamFactory.new_york(id=500) + ny_transaction = Transaction( + id=27790, + week=10, + season=12, + moveid='Season-012-Week-10-NY-14:01:00', + player=sample_player, + oldteam=fa_team, + newteam=ny_team, + cancelled=False, + frozen=True + ) + + transactions = [mil_transaction, ny_transaction] + + with patch('tasks.transaction_freeze.standings_service') as mock_standings: + # Should request standings for "WV" (parent), not "WVMiL" + mock_standings.get_team_standings = AsyncMock(return_value=sample_standings_wv) + + # Mock random for deterministic testing + with patch('tasks.transaction_freeze.random.randint', return_value=50000): + winning_ids, losing_ids = await resolve_contested_transactions(transactions, 12) + + # Should have called with "WV" (stripped "MiL" suffix) + # Will be called twice (once for WVMiL, once for NY) + calls = mock_standings.get_team_standings.call_args_list + assert any(call[0] == ("WV", 12) for call in calls), \ + f"Expected call with ('WV', 12), got {calls}" + + # Should have resolved (one winner, one loser) + assert len(winning_ids) == 1 + assert len(losing_ids) == 1 + + @pytest.mark.asyncio + async def test_fa_drops_not_contested(self, sample_player, sample_team_wv): + """Test that FA drops are not considered for contests.""" + fa_team = TeamFactory.create( + id=999, + abbrev="FA", + sname="Free Agents", + lname="Free Agents", + season=12 + ) + + # Drop to FA (not an acquisition) + drop_tx = Transaction( + id=27790, + week=10, + season=12, + moveid='Season-012-Week-10-DROP-15:00:00', + player=sample_player, + oldteam=sample_team_wv, + newteam=fa_team, # Dropping to FA + cancelled=False, + frozen=True + ) + + transactions = [drop_tx] + + winning_ids, losing_ids = await resolve_contested_transactions(transactions, 12) + + # FA drops are not winners or losers (they're not acquisitions) + assert len(winning_ids) == 0 + assert len(losing_ids) == 0 + + @pytest.mark.asyncio + async def test_standings_error_fallback(self, contested_transactions): + """Test that standings errors result in 0.0 priority.""" + with patch('tasks.transaction_freeze.standings_service') as mock_standings: + # Simulate standings service error + mock_standings.get_team_standings = AsyncMock(side_effect=Exception("API Error")) + + # Mock random for deterministic testing + with patch('tasks.transaction_freeze.random.randint', return_value=50000): + winning_ids, losing_ids = await resolve_contested_transactions( + contested_transactions, 12 + ) + + # Should still resolve (one wins, one loses) + assert len(winning_ids) == 1 + assert len(losing_ids) == 1 + + @pytest.mark.asyncio + async def test_three_way_contest(self, sample_player): + """Test contest with three teams wanting same player.""" + fa_team = TeamFactory.create( + id=999, + abbrev="FA", + sname="Free Agents", + lname="Free Agents", + season=12 + ) + + team1 = TeamFactory.create(id=1, abbrev="T1", sname="Team 1", lname="Team 1", season=12) + team2 = TeamFactory.create(id=2, abbrev="T2", sname="Team 2", lname="Team 2", season=12) + team3 = TeamFactory.create(id=3, abbrev="T3", sname="Team 3", lname="Team 3", season=12) + + tx1 = Transaction( + id=1, week=10, season=12, moveid='move-1', player=sample_player, + oldteam=fa_team, newteam=team1, cancelled=False, frozen=True + ) + tx2 = Transaction( + id=2, week=10, season=12, moveid='move-2', player=sample_player, + oldteam=fa_team, newteam=team2, cancelled=False, frozen=True + ) + tx3 = Transaction( + id=3, week=10, season=12, moveid='move-3', player=sample_player, + oldteam=fa_team, newteam=team3, cancelled=False, frozen=True + ) + + transactions = [tx1, tx2, tx3] + + with patch('tasks.transaction_freeze.standings_service') as mock_standings: + async def get_standings(team_abbrev, season): + # Create minimal team objects for standings + standings_map = { + "T1": TeamStandings( + id=1, team=team1, wins=20, losses=80, run_diff=0, + home_wins=10, home_losses=40, away_wins=10, away_losses=40, + last8_wins=1, last8_losses=7, streak_wl="l", streak_num=5, + one_run_wins=5, one_run_losses=10, pythag_wins=22, pythag_losses=78, + div1_wins=5, div1_losses=15, div2_wins=5, div2_losses=15, + div3_wins=5, div3_losses=15, div4_wins=5, div4_losses=15 + ), + "T2": TeamStandings( + id=2, team=team2, wins=50, losses=50, run_diff=0, + home_wins=25, home_losses=25, away_wins=25, away_losses=25, + last8_wins=4, last8_losses=4, streak_wl="w", streak_num=2, + one_run_wins=10, one_run_losses=10, pythag_wins=50, pythag_losses=50, + div1_wins=12, div1_losses=13, div2_wins=13, div2_losses=12, + div3_wins=12, div3_losses=13, div4_wins=13, div4_losses=12 + ), + "T3": TeamStandings( + id=3, team=team3, wins=80, losses=20, run_diff=0, + home_wins=40, home_losses=10, away_wins=40, away_losses=10, + last8_wins=7, last8_losses=1, streak_wl="w", streak_num=8, + one_run_wins=15, one_run_losses=5, pythag_wins=78, pythag_losses=22, + div1_wins=20, div1_losses=5, div2_wins=20, div2_losses=5, + div3_wins=20, div3_losses=5, div4_wins=20, div4_losses=5 + ), + } + return standings_map.get(team_abbrev) + + mock_standings.get_team_standings = AsyncMock(side_effect=get_standings) + + with patch('tasks.transaction_freeze.random.randint', return_value=50000): + winning_ids, losing_ids = await resolve_contested_transactions(transactions, 12) + + # Only one winner + assert len(winning_ids) == 1 + # Two losers + assert len(losing_ids) == 2 + + # T1 should win (worst record = 0.200) + assert tx1.moveid in winning_ids + assert tx2.moveid in losing_ids + assert tx3.moveid in losing_ids + + +class TestTransactionFreezeTaskInitialization: + """Test TransactionFreezeTask initialization and setup.""" + + def test_task_initialization(self, mock_bot): + """Test task initialization.""" + with patch.object(TransactionFreezeTask, 'weekly_loop') as mock_loop: + task = TransactionFreezeTask(mock_bot) + + assert task.bot == mock_bot + assert task.logger is not None + assert task.weekly_warning_sent is False + mock_loop.start.assert_called_once() + + def test_cog_unload(self, mock_bot): + """Test that cog_unload cancels the task.""" + with patch.object(TransactionFreezeTask, 'weekly_loop') as mock_loop: + task = TransactionFreezeTask(mock_bot) + + task.cog_unload() + + mock_loop.cancel.assert_called_once() + + +class TestFreezeBeginLogic: + """Test freeze begin logic.""" + + @pytest.mark.asyncio + async def test_begin_freeze_increments_week(self, mock_bot, current_state): + """Test that freeze begin increments week and sets freeze flag.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.league_service') as mock_league: + # Mock the update call + updated_state = CurrentFactory.create( + week=11, # Incremented + season=12, + freeze=True # Set to True + ) + mock_league.update_current_state = AsyncMock(return_value=updated_state) + + # Mock other methods + task._run_transactions = AsyncMock() + task._send_freeze_announcement = AsyncMock() + task._post_weekly_info = AsyncMock() + + await task._begin_freeze(current_state) + + # Verify week was incremented and freeze set + mock_league.update_current_state.assert_called_once_with( + week=11, + freeze=True + ) + + # Verify freeze announcement was sent + task._send_freeze_announcement.assert_called_once_with(11, is_beginning=True) + + @pytest.mark.asyncio + async def test_begin_freeze_runs_transactions(self, mock_bot, current_state): + """Test that freeze begin runs regular transactions.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.league_service') as mock_league: + updated_state = CurrentFactory.create(week=11, season=12, freeze=True) + mock_league.update_current_state = AsyncMock(return_value=updated_state) + + task._run_transactions = AsyncMock() + task._send_freeze_announcement = AsyncMock() + task._post_weekly_info = AsyncMock() + + await task._begin_freeze(current_state) + + # Verify transactions were run + task._run_transactions.assert_called_once() + + @pytest.mark.asyncio + async def test_begin_freeze_posts_weekly_info_weeks_1_18(self, mock_bot, current_state): + """Test that weekly info is posted for weeks 1-18.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.league_service') as mock_league: + # Week 5 (within 1-18 range) + updated_state = CurrentFactory.create(week=5, season=12, freeze=True) + mock_league.update_current_state = AsyncMock(return_value=updated_state) + + task._run_transactions = AsyncMock() + task._send_freeze_announcement = AsyncMock() + task._post_weekly_info = AsyncMock() + + current_state.week = 4 # Starting at week 4 + await task._begin_freeze(current_state) + + # Verify weekly info was posted + task._post_weekly_info.assert_called_once() + + @pytest.mark.asyncio + async def test_begin_freeze_skips_weekly_info_after_week_18(self, mock_bot, current_state): + """Test that weekly info is NOT posted after week 18.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.league_service') as mock_league: + # Week 19 (playoffs) + updated_state = CurrentFactory.create(week=19, season=12, freeze=True) + mock_league.update_current_state = AsyncMock(return_value=updated_state) + + task._run_transactions = AsyncMock() + task._send_freeze_announcement = AsyncMock() + task._post_weekly_info = AsyncMock() + + current_state.week = 18 # Starting at week 18 + await task._begin_freeze(current_state) + + # Verify weekly info was NOT posted + task._post_weekly_info.assert_not_called() + + @pytest.mark.asyncio + async def test_begin_freeze_error_handling(self, mock_bot, current_state): + """Test that errors in freeze begin are raised.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.league_service') as mock_league: + mock_league.update_current_state = AsyncMock( + side_effect=Exception("Database error") + ) + + # Patch logger to avoid exc_info conflict + with patch.object(task.logger, 'error'): + with pytest.raises(Exception, match="Database error"): + await task._begin_freeze(current_state) + + +class TestFreezeEndLogic: + """Test freeze end logic.""" + + @pytest.mark.asyncio + async def test_end_freeze_processes_transactions(self, mock_bot, frozen_state): + """Test that freeze end processes frozen transactions.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.league_service') as mock_league: + updated_state = CurrentFactory.create(week=10, season=12, freeze=False) + mock_league.update_current_state = AsyncMock(return_value=updated_state) + + task._process_frozen_transactions = AsyncMock() + task._send_freeze_announcement = AsyncMock() + + await task._end_freeze(frozen_state) + + # Verify transactions were processed + task._process_frozen_transactions.assert_called_once_with(frozen_state) + + @pytest.mark.asyncio + async def test_end_freeze_sets_freeze_false(self, mock_bot, frozen_state): + """Test that freeze end sets freeze flag to False.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.league_service') as mock_league: + updated_state = CurrentFactory.create(week=10, season=12, freeze=False) + mock_league.update_current_state = AsyncMock(return_value=updated_state) + + task._process_frozen_transactions = AsyncMock() + task._send_freeze_announcement = AsyncMock() + + await task._end_freeze(frozen_state) + + # Verify freeze was set to False + mock_league.update_current_state.assert_called_once_with(freeze=False) + + @pytest.mark.asyncio + async def test_end_freeze_sends_announcement(self, mock_bot, frozen_state): + """Test that freeze end sends thaw announcement.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.league_service') as mock_league: + updated_state = CurrentFactory.create(week=10, season=12, freeze=False) + mock_league.update_current_state = AsyncMock(return_value=updated_state) + + task._process_frozen_transactions = AsyncMock() + task._send_freeze_announcement = AsyncMock() + + await task._end_freeze(frozen_state) + + # Verify thaw announcement was sent + task._send_freeze_announcement.assert_called_once_with(10, is_beginning=False) + + @pytest.mark.asyncio + async def test_end_freeze_error_handling(self, mock_bot, frozen_state): + """Test that errors in freeze end are raised.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.league_service') as mock_league: + mock_league.update_current_state = AsyncMock( + side_effect=Exception("Database error") + ) + + task._process_frozen_transactions = AsyncMock() + + # Patch logger to avoid exc_info conflict + with patch.object(task.logger, 'error'): + with pytest.raises(Exception, match="Database error"): + await task._end_freeze(frozen_state) + + +class TestProcessFrozenTransactions: + """Test frozen transaction processing.""" + + @pytest.mark.asyncio + async def test_process_frozen_transactions_basic( + self, + mock_bot, + frozen_state, + sample_transaction + ): + """Test basic frozen transaction processing.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.transaction_service') as mock_tx_service: + mock_tx_service.get_frozen_transactions_by_week = AsyncMock( + return_value=[sample_transaction] + ) + mock_tx_service.unfreeze_transaction = AsyncMock(return_value=True) + + with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve: + mock_resolve.return_value = ([sample_transaction.moveid], []) + + task._post_transaction_to_log = AsyncMock() + + await task._process_frozen_transactions(frozen_state) + + # Verify transaction was unfrozen + mock_tx_service.unfreeze_transaction.assert_called_once_with( + sample_transaction.id + ) + + # Verify transaction was posted to log + task._post_transaction_to_log.assert_called_once() + + @pytest.mark.asyncio + async def test_process_frozen_transactions_with_cancellations( + self, + mock_bot, + frozen_state, + contested_transactions + ): + """Test processing with contested transactions and cancellations.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + tx1, tx2 = contested_transactions + + with patch('tasks.transaction_freeze.transaction_service') as mock_tx_service: + mock_tx_service.get_frozen_transactions_by_week = AsyncMock( + return_value=contested_transactions + ) + mock_tx_service.cancel_transaction = AsyncMock(return_value=True) + mock_tx_service.unfreeze_transaction = AsyncMock(return_value=True) + + with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve: + # tx1 wins, tx2 loses + mock_resolve.return_value = ([tx1.moveid], [tx2.moveid]) + + task._post_transaction_to_log = AsyncMock() + task._notify_gm_of_cancellation = AsyncMock() + + await task._process_frozen_transactions(frozen_state) + + # Verify losing transaction was cancelled + mock_tx_service.cancel_transaction.assert_called_once_with(str(tx2.id)) + + # Verify GM was notified + task._notify_gm_of_cancellation.assert_called_once() + + # Verify winning transaction was unfrozen + mock_tx_service.unfreeze_transaction.assert_called_once_with(tx1.id) + + @pytest.mark.asyncio + async def test_process_frozen_no_transactions(self, mock_bot, frozen_state): + """Test processing when no frozen transactions exist.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.transaction_service') as mock_tx_service: + mock_tx_service.get_frozen_transactions_by_week = AsyncMock(return_value=None) + + # Should not raise error + await task._process_frozen_transactions(frozen_state) + + @pytest.mark.asyncio + async def test_process_frozen_transaction_error_recovery( + self, + mock_bot, + frozen_state, + sample_transaction + ): + """Test that processing continues despite individual transaction errors.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.transaction_service') as mock_tx_service: + mock_tx_service.get_frozen_transactions_by_week = AsyncMock( + return_value=[sample_transaction] + ) + # Simulate unfreeze failure + mock_tx_service.unfreeze_transaction = AsyncMock(return_value=False) + + with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve: + mock_resolve.return_value = ([sample_transaction.moveid], []) + + task._post_transaction_to_log = AsyncMock() + + # Should not raise error + await task._process_frozen_transactions(frozen_state) + + # Post should still be attempted + task._post_transaction_to_log.assert_called_once() + + +class TestNotificationsAndEmbeds: + """Test notification and embed creation.""" + + @pytest.mark.asyncio + async def test_send_freeze_announcement_begin(self, mock_bot, current_state): + """Test freeze begin announcement.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + # Mock guild and channel + mock_guild = MagicMock() + mock_channel = AsyncMock() + mock_guild.text_channels = [mock_channel] + mock_channel.name = 'transaction-log' + + with patch('tasks.transaction_freeze.get_config') as mock_config: + config = MagicMock() + config.guild_id = 12345 + mock_config.return_value = config + + with patch('tasks.transaction_freeze.discord.utils.get', return_value=mock_channel): + task.bot.get_guild.return_value = mock_guild + + await task._send_freeze_announcement(10, is_beginning=True) + + # Verify message was sent + mock_channel.send.assert_called_once() + + # Verify message content + call_args = mock_channel.send.call_args + message = call_args[0][0] if call_args[0] else call_args[1]['content'] + assert 'Week 10' in message + assert 'Freeze Period Begins' in message + + @pytest.mark.asyncio + async def test_send_freeze_announcement_end(self, mock_bot, current_state): + """Test freeze end (thaw) announcement.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + mock_guild = MagicMock() + mock_channel = AsyncMock() + mock_guild.text_channels = [mock_channel] + mock_channel.name = 'transaction-log' + + with patch('tasks.transaction_freeze.get_config') as mock_config: + config = MagicMock() + config.guild_id = 12345 + mock_config.return_value = config + + with patch('tasks.transaction_freeze.discord.utils.get', return_value=mock_channel): + task.bot.get_guild.return_value = mock_guild + + await task._send_freeze_announcement(10, is_beginning=False) + + # Verify message was sent + mock_channel.send.assert_called_once() + + # Verify message content + call_args = mock_channel.send.call_args + message = call_args[0][0] if call_args[0] else call_args[1]['content'] + assert 'Week 10' in message + assert 'Freeze Period Ends' in message + + @pytest.mark.asyncio + async def test_notify_gm_of_cancellation( + self, + mock_bot, + sample_transaction, + sample_team_wv + ): + """Test GM notification of cancelled transaction.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + # Mock guild members + mock_guild = MagicMock() + mock_gm1 = AsyncMock() + mock_gm2 = AsyncMock() + + mock_guild.get_member.side_effect = lambda id: { + 111111: mock_gm1, + 222222: mock_gm2 + }.get(id) + + with patch('tasks.transaction_freeze.get_config') as mock_config: + config = MagicMock() + config.guild_id = 12345 + mock_config.return_value = config + + task.bot.get_guild.return_value = mock_guild + + await task._notify_gm_of_cancellation(sample_transaction, sample_team_wv) + + # Verify both GMs were sent messages + mock_gm1.send.assert_called_once() + mock_gm2.send.assert_called_once() + + # Verify message content + message = mock_gm1.send.call_args[0][0] + assert sample_transaction.player.name in message + assert 'cancelled' in message.lower() + + +class TestOffseasonMode: + """Test offseason mode behavior.""" + + @pytest.mark.asyncio + async def test_weekly_loop_skips_during_offseason(self, mock_bot, current_state): + """Test that weekly loop skips operations when offseason_flag is True.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.get_config') as mock_config: + config = MagicMock() + config.offseason_flag = True # Offseason enabled + mock_config.return_value = config + + with patch('tasks.transaction_freeze.league_service') as mock_league: + mock_league.get_current_state = AsyncMock(return_value=current_state) + + task._begin_freeze = AsyncMock() + task._end_freeze = AsyncMock() + + # Manually call the loop logic + await task.weekly_loop() + + # Verify no freeze/thaw operations occurred + task._begin_freeze.assert_not_called() + task._end_freeze.assert_not_called() + + +class TestErrorHandlingAndRecovery: + """Test error handling and recovery.""" + + @pytest.mark.asyncio + async def test_weekly_loop_error_sends_owner_notification(self, mock_bot): + """Test that weekly loop errors send owner notifications.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + with patch('tasks.transaction_freeze.get_config') as mock_config: + config = MagicMock() + config.offseason_flag = False + mock_config.return_value = config + + with patch('tasks.transaction_freeze.league_service') as mock_league: + # Simulate error getting current state + mock_league.get_current_state = AsyncMock( + side_effect=Exception("Database connection failed") + ) + + task._send_owner_notification = AsyncMock() + + # Manually call the loop logic + await task.weekly_loop() + + # Verify owner was notified + task._send_owner_notification.assert_called_once() + + # Verify warning flag was set + assert task.weekly_warning_sent is True + + @pytest.mark.asyncio + async def test_owner_notification_prevents_duplicates(self, mock_bot): + """Test that duplicate owner notifications are prevented.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + task.weekly_warning_sent = True # Already sent + + with patch('tasks.transaction_freeze.get_config') as mock_config: + config = MagicMock() + config.offseason_flag = False + mock_config.return_value = config + + with patch('tasks.transaction_freeze.league_service') as mock_league: + mock_league.get_current_state = AsyncMock( + side_effect=Exception("Another error") + ) + + task._send_owner_notification = AsyncMock() + + await task.weekly_loop() + + # Verify owner was NOT notified again + task._send_owner_notification.assert_not_called() + + @pytest.mark.asyncio + async def test_send_owner_notification(self, mock_bot): + """Test sending owner notification.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + await task._send_owner_notification("Test error message") + + # Verify application_info was called + mock_bot.application_info.assert_called_once() + + # Verify owner was sent message + app_info = await mock_bot.application_info() + app_info.owner.send.assert_called_once_with("Test error message") + + +class TestWeeklyScheduleTiming: + """Test weekly schedule timing logic.""" + + @pytest.mark.asyncio + async def test_freeze_triggers_monday_midnight(self, mock_bot, current_state): + """Test that freeze triggers on Monday at 00:00.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + # Mock datetime to be Monday (weekday=0) at 00:00 + mock_now = MagicMock() + mock_now.weekday.return_value = 0 # Monday + mock_now.hour = 0 + + with patch('tasks.transaction_freeze.datetime') as mock_datetime: + mock_datetime.now.return_value = mock_now + + with patch('tasks.transaction_freeze.get_config') as mock_config: + config = MagicMock() + config.offseason_flag = False + mock_config.return_value = config + + with patch('tasks.transaction_freeze.league_service') as mock_league: + mock_league.get_current_state = AsyncMock(return_value=current_state) + + task._begin_freeze = AsyncMock() + task._end_freeze = AsyncMock() + + await task.weekly_loop() + + # Verify freeze began + task._begin_freeze.assert_called_once_with(current_state) + task._end_freeze.assert_not_called() + + @pytest.mark.asyncio + async def test_thaw_triggers_saturday_midnight(self, mock_bot, frozen_state): + """Test that thaw triggers on Saturday at 00:00.""" + with patch.object(TransactionFreezeTask, 'weekly_loop'): + task = TransactionFreezeTask(mock_bot) + + # Mock datetime to be Saturday (weekday=5) at 00:00 + mock_now = MagicMock() + mock_now.weekday.return_value = 5 # Saturday + mock_now.hour = 0 + + with patch('tasks.transaction_freeze.datetime') as mock_datetime: + mock_datetime.now.return_value = mock_now + + with patch('tasks.transaction_freeze.get_config') as mock_config: + config = MagicMock() + config.offseason_flag = False + mock_config.return_value = config + + with patch('tasks.transaction_freeze.league_service') as mock_league: + mock_league.get_current_state = AsyncMock(return_value=frozen_state) + + task._begin_freeze = AsyncMock() + task._end_freeze = AsyncMock() + + await task.weekly_loop() + + # Verify freeze ended + task._end_freeze.assert_called_once_with(frozen_state) + task._begin_freeze.assert_not_called()