Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd253dab09 | |||
|
|
0ea1c1d633 | ||
| c8cb80c5f3 | |||
|
|
6016afb999 | ||
| f95c857363 | |||
|
|
174ce4474d | ||
|
|
2091302b8a | ||
| 27a272b813 | |||
|
|
95010bfd5d | ||
| deb40476a4 | |||
|
|
65d3099a7c | ||
| 8e02889fd4 | |||
|
|
b872a05397 | ||
| 6889499fff | |||
|
|
3c453c89ce | ||
| be4213aab6 | |||
|
|
4e75656225 | ||
| c30e0ad321 | |||
|
|
b57f91833b | ||
| 04efc46382 | |||
|
|
7e7aa46a73 | ||
| 91b367af93 | |||
|
|
3c24e03a0c | ||
| fd24a41422 | |||
| daa3366b60 | |||
| ee2387a385 | |||
|
|
6f3339a42e | ||
| 498fcdfe51 | |||
|
|
a3e63f730f | ||
| f0934937cb | |||
| 4775c175c5 | |||
|
|
ce2c47ca0c | ||
| 0c041bce99 | |||
|
|
70c4555a74 | ||
|
|
8878ce85f7 | ||
|
|
008d6be86c | ||
| 18ab1393c0 | |||
| 8862850c59 | |||
| 8d97e1dd17 | |||
| 52fa56cb69 | |||
|
|
d4e7246166 | ||
|
|
0992acf718 | ||
|
|
b480120731 | ||
| 6d3c7305ce | |||
|
|
9df8d77fa0 | ||
|
|
df9e9bedbe | ||
|
|
c8ed4dee38 | ||
|
|
03dd449551 |
@ -1,22 +1,18 @@
|
|||||||
# Gitea Actions: Docker Build, Push, and Notify
|
# Gitea Actions: Docker Build, Push, and Notify
|
||||||
#
|
#
|
||||||
# CI/CD pipeline for Major Domo Discord Bot:
|
# CI/CD pipeline for Major Domo Discord Bot:
|
||||||
# - Builds Docker images on every push/PR
|
# - Triggered by pushing a CalVer tag (e.g., 2026.3.11)
|
||||||
# - Auto-generates CalVer version (YYYY.MM.BUILD) on main branch merges
|
# - Builds Docker image and pushes to Docker Hub with version + production tags
|
||||||
# - Supports multi-channel releases: stable (main), rc (next-release), dev (PRs)
|
|
||||||
# - Pushes to Docker Hub and creates git tag on main
|
|
||||||
# - Sends Discord notifications on success/failure
|
# - Sends Discord notifications on success/failure
|
||||||
|
#
|
||||||
|
# To release: git tag 2026.3.11 && git push --tags
|
||||||
|
|
||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
- main
|
- '20*' # matches CalVer tags like 2026.3.11
|
||||||
- next-release
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@ -26,7 +22,16 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Full history for tag counting
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
|
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "sha_short=$SHA_SHORT" >> $GITHUB_OUTPUT
|
||||||
|
echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: https://github.com/docker/setup-buildx-action@v3
|
uses: https://github.com/docker/setup-buildx-action@v3
|
||||||
@ -37,67 +42,47 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Generate CalVer version
|
|
||||||
id: calver
|
|
||||||
uses: cal/gitea-actions/calver@main
|
|
||||||
|
|
||||||
- name: Resolve Docker tags
|
|
||||||
id: tags
|
|
||||||
uses: cal/gitea-actions/docker-tags@main
|
|
||||||
with:
|
|
||||||
image: manticorum67/major-domo-discordapp
|
|
||||||
version: ${{ steps.calver.outputs.version }}
|
|
||||||
sha_short: ${{ steps.calver.outputs.sha_short }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: https://github.com/docker/build-push-action@v5
|
uses: https://github.com/docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.tags.outputs.tags }}
|
tags: |
|
||||||
|
manticorum67/major-domo-discordapp:${{ steps.version.outputs.version }}
|
||||||
|
manticorum67/major-domo-discordapp:production
|
||||||
cache-from: type=registry,ref=manticorum67/major-domo-discordapp:buildcache
|
cache-from: type=registry,ref=manticorum67/major-domo-discordapp:buildcache
|
||||||
cache-to: type=registry,ref=manticorum67/major-domo-discordapp:buildcache,mode=max
|
cache-to: type=registry,ref=manticorum67/major-domo-discordapp:buildcache,mode=max
|
||||||
|
|
||||||
- name: Tag release
|
|
||||||
if: success() && github.ref == 'refs/heads/main'
|
|
||||||
uses: cal/gitea-actions/gitea-tag@main
|
|
||||||
with:
|
|
||||||
version: ${{ steps.calver.outputs.version }}
|
|
||||||
token: ${{ github.token }}
|
|
||||||
|
|
||||||
- name: Build Summary
|
- name: Build Summary
|
||||||
run: |
|
run: |
|
||||||
echo "## Docker Build Successful" >> $GITHUB_STEP_SUMMARY
|
echo "## Docker Build Successful" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Channel:** \`${{ steps.tags.outputs.channel }}\`" >> $GITHUB_STEP_SUMMARY
|
echo "**Version:** \`${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY
|
echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY
|
||||||
IFS=',' read -ra TAG_ARRAY <<< "${{ steps.tags.outputs.tags }}"
|
echo "- \`manticorum67/major-domo-discordapp:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
for tag in "${TAG_ARRAY[@]}"; do
|
echo "- \`manticorum67/major-domo-discordapp:production\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`${tag}\`" >> $GITHUB_STEP_SUMMARY
|
|
||||||
done
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY
|
echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Branch: \`${{ steps.calver.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY
|
echo "- Commit: \`${{ steps.version.outputs.sha_short }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
echo "- Timestamp: \`${{ steps.version.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Timestamp: \`${{ steps.calver.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Pull with: \`docker pull manticorum67/major-domo-discordapp:${{ steps.tags.outputs.primary_tag }}\`" >> $GITHUB_STEP_SUMMARY
|
echo "Pull with: \`docker pull manticorum67/major-domo-discordapp:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
- name: Discord Notification - Success
|
- name: Discord Notification - Success
|
||||||
if: success() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next-release')
|
if: success()
|
||||||
uses: cal/gitea-actions/discord-notify@main
|
uses: cal/gitea-actions/discord-notify@main
|
||||||
with:
|
with:
|
||||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
title: "Major Domo Bot"
|
title: "Major Domo Bot"
|
||||||
status: success
|
status: success
|
||||||
version: ${{ steps.calver.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
image_tag: ${{ steps.tags.outputs.primary_tag }}
|
image_tag: ${{ steps.version.outputs.version }}
|
||||||
commit_sha: ${{ steps.calver.outputs.sha_short }}
|
commit_sha: ${{ steps.version.outputs.sha_short }}
|
||||||
timestamp: ${{ steps.calver.outputs.timestamp }}
|
timestamp: ${{ steps.version.outputs.timestamp }}
|
||||||
|
|
||||||
- name: Discord Notification - Failure
|
- name: Discord Notification - Failure
|
||||||
if: failure() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next-release')
|
if: failure()
|
||||||
uses: cal/gitea-actions/discord-notify@main
|
uses: cal/gitea-actions/discord-notify@main
|
||||||
with:
|
with:
|
||||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -218,5 +218,6 @@ __marimo__/
|
|||||||
|
|
||||||
# Project-specific
|
# Project-specific
|
||||||
data/
|
data/
|
||||||
|
storage/
|
||||||
production_logs/
|
production_logs/
|
||||||
*.json
|
*.json
|
||||||
|
|||||||
@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SSH_CMD="ssh -i ~/.ssh/cloud_servers_rsa root@akamai"
|
SSH_CMD="ssh akamai"
|
||||||
REMOTE_DIR="/root/container-data/major-domo"
|
REMOTE_DIR="/root/container-data/major-domo"
|
||||||
SERVICE="discord-app"
|
SERVICE="discord-app"
|
||||||
CONTAINER="major-domo-discord-app-1"
|
CONTAINER="major-domo-discord-app-1"
|
||||||
IMAGE="manticorum67/major-domo-discordapp:latest"
|
IMAGE="manticorum67/major-domo-discordapp:production"
|
||||||
|
|
||||||
SKIP_CONFIRM=false
|
SKIP_CONFIRM=false
|
||||||
[[ "${1:-}" == "-y" ]] && SKIP_CONFIRM=true
|
[[ "${1:-}" == "-y" ]] && SKIP_CONFIRM=true
|
||||||
@ -19,9 +19,9 @@ SKIP_CONFIRM=false
|
|||||||
# --- Pre-deploy checks ---
|
# --- Pre-deploy checks ---
|
||||||
|
|
||||||
if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then
|
if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then
|
||||||
echo "WARNING: You have uncommitted changes."
|
echo "WARNING: You have uncommitted changes."
|
||||||
git status --short
|
git status --short
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
||||||
@ -32,9 +32,12 @@ echo "Target: akamai (${IMAGE})"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [[ "$SKIP_CONFIRM" != true ]]; then
|
if [[ "$SKIP_CONFIRM" != true ]]; then
|
||||||
read -rp "Deploy to production? [y/N] " answer
|
read -rp "Deploy to production? [y/N] " answer
|
||||||
[[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
[[ "$answer" =~ ^[Yy]$ ]] || {
|
||||||
echo ""
|
echo "Aborted."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Save previous image for rollback ---
|
# --- Save previous image for rollback ---
|
||||||
@ -64,16 +67,16 @@ echo ""
|
|||||||
echo "==> Image digest: ${NEW_DIGEST}"
|
echo "==> Image digest: ${NEW_DIGEST}"
|
||||||
|
|
||||||
if [[ "$PREV_DIGEST" == "$NEW_DIGEST" ]]; then
|
if [[ "$PREV_DIGEST" == "$NEW_DIGEST" ]]; then
|
||||||
echo " (unchanged from previous deploy)"
|
echo " (unchanged from previous deploy)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Rollback command ---
|
# --- Rollback command ---
|
||||||
|
|
||||||
if [[ "$PREV_DIGEST" != "unknown" && "$PREV_DIGEST" != "$NEW_DIGEST" ]]; then
|
if [[ "$PREV_DIGEST" != "unknown" && "$PREV_DIGEST" != "$NEW_DIGEST" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "==> To rollback:"
|
echo "==> To rollback:"
|
||||||
echo " ssh -i ~/.ssh/cloud_servers_rsa root@akamai \\"
|
echo " ssh akamai \\"
|
||||||
echo " \"cd ${REMOTE_DIR} && docker pull ${PREV_DIGEST} && docker tag ${PREV_DIGEST} ${IMAGE} && docker compose up -d ${SERVICE}\""
|
echo " \"cd ${REMOTE_DIR} && docker pull ${PREV_DIGEST} && docker tag ${PREV_DIGEST} ${IMAGE} && docker compose up -d ${SERVICE}\""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
99
.scripts/release.sh
Executable file
99
.scripts/release.sh
Executable file
@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create a CalVer release tag and push to trigger CI.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# .scripts/release.sh # auto-generates next version (YYYY.M.BUILD)
|
||||||
|
# .scripts/release.sh 2026.3.11 # explicit version
|
||||||
|
# .scripts/release.sh -y # auto-generate + skip confirmation
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SKIP_CONFIRM=false
|
||||||
|
VERSION=""
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-y) SKIP_CONFIRM=true ;;
|
||||||
|
*) VERSION="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Ensure we're on main and up to date ---
|
||||||
|
|
||||||
|
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
if [[ "$BRANCH" != "main" ]]; then
|
||||||
|
echo "ERROR: Must be on main branch (currently on ${BRANCH})"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Fetching latest..."
|
||||||
|
git fetch origin main --tags --quiet
|
||||||
|
LOCAL=$(git rev-parse HEAD)
|
||||||
|
REMOTE=$(git rev-parse origin/main)
|
||||||
|
if [[ "$LOCAL" != "$REMOTE" ]]; then
|
||||||
|
echo "ERROR: Local main is not up to date with origin. Run: git pull"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Determine version ---
|
||||||
|
|
||||||
|
YEAR=$(date +%Y)
|
||||||
|
MONTH=$(date +%-m) # no leading zero
|
||||||
|
|
||||||
|
if [[ -z "$VERSION" ]]; then
|
||||||
|
# Find the highest build number for this year.month
|
||||||
|
LAST_BUILD=$(git tag --list "${YEAR}.${MONTH}.*" --sort=-v:refname | head -1 | awk -F. '{print $3}')
|
||||||
|
NEXT_BUILD=$((${LAST_BUILD:-0} + 1))
|
||||||
|
VERSION="${YEAR}.${MONTH}.${NEXT_BUILD}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate format
|
||||||
|
if [[ ! "$VERSION" =~ ^20[0-9]{2}\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "ERROR: Invalid version format '${VERSION}'. Expected YYYY.M.BUILD (e.g., 2026.3.11)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check tag doesn't already exist
|
||||||
|
if git rev-parse "refs/tags/${VERSION}" &>/dev/null; then
|
||||||
|
echo "ERROR: Tag ${VERSION} already exists"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Show what's being released ---
|
||||||
|
|
||||||
|
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||||
|
echo ""
|
||||||
|
echo "Version: ${VERSION}"
|
||||||
|
echo "Previous: ${LAST_TAG:-none}"
|
||||||
|
echo "Commit: $(git log -1 --format='%h %s')"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ -n "$LAST_TAG" ]]; then
|
||||||
|
COMMIT_COUNT=$(git rev-list "${LAST_TAG}..HEAD" --count)
|
||||||
|
echo "Changes since ${LAST_TAG} (${COMMIT_COUNT} commits):"
|
||||||
|
git log "${LAST_TAG}..HEAD" --oneline --no-merges
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Confirm ---
|
||||||
|
|
||||||
|
if [[ "$SKIP_CONFIRM" != true ]]; then
|
||||||
|
read -rp "Create tag ${VERSION} and trigger release? [y/N] " answer
|
||||||
|
[[ "$answer" =~ ^[Yy]$ ]] || {
|
||||||
|
echo "Aborted."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Tag and push ---
|
||||||
|
|
||||||
|
git tag "$VERSION"
|
||||||
|
git push origin tag "$VERSION"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==> Tag ${VERSION} pushed. CI will build:"
|
||||||
|
echo " - manticorum67/major-domo-discordapp:${VERSION}"
|
||||||
|
echo " - manticorum67/major-domo-discordapp:production"
|
||||||
|
echo ""
|
||||||
|
echo "Deploy with: .scripts/deploy.sh"
|
||||||
18
CLAUDE.md
18
CLAUDE.md
@ -16,15 +16,13 @@ manticorum67/major-domo-discordapp
|
|||||||
There is NO DASH between "discord" and "app". Not `discord-app`, not `discordapp-v2`.
|
There is NO DASH between "discord" and "app". Not `discord-app`, not `discordapp-v2`.
|
||||||
|
|
||||||
### Git Workflow
|
### Git Workflow
|
||||||
NEVER commit directly to `main` or `next-release`. Always use feature branches.
|
NEVER commit directly to `main`. Always use feature branches.
|
||||||
|
|
||||||
**Branch from `next-release`** for normal work targeting the next release:
|
|
||||||
```bash
|
```bash
|
||||||
git checkout -b feature/name origin/next-release # or fix/name, refactor/name
|
git checkout -b feature/name origin/main # or fix/name, refactor/name
|
||||||
```
|
```
|
||||||
**Branch from `main`** only for urgent hotfixes that bypass the release cycle.
|
|
||||||
|
|
||||||
PRs go to `next-release` (staging), then `next-release → main` when releasing.
|
PRs go to `main`. CI builds the Docker image and creates a CalVer tag on merge.
|
||||||
|
|
||||||
### Double Emoji in Embeds
|
### Double Emoji in Embeds
|
||||||
`EmbedTemplate.success/error/warning/info/loading()` auto-add emoji prefixes.
|
`EmbedTemplate.success/error/warning/info/loading()` auto-add emoji prefixes.
|
||||||
@ -63,13 +61,13 @@ class MyCog(commands.Cog):
|
|||||||
- **Container**: `major-domo-discord-app-1`
|
- **Container**: `major-domo-discord-app-1`
|
||||||
- **Image**: `manticorum67/major-domo-discordapp` (no dash between discord and app)
|
- **Image**: `manticorum67/major-domo-discordapp` (no dash between discord and app)
|
||||||
- **Health**: Process liveness only (no HTTP endpoint)
|
- **Health**: Process liveness only (no HTTP endpoint)
|
||||||
- **CI/CD**: Gitea Actions on PR to `main` — builds Docker image, auto-generates CalVer version (`YYYY.MM.BUILD`) on merge
|
- **CI/CD**: Gitea Actions — tag-triggered Docker builds (push a CalVer tag to release)
|
||||||
|
|
||||||
### Release Workflow
|
### Release Workflow
|
||||||
1. Create feature/fix branches off `next-release` (e.g., `fix/scorebug-bugs`)
|
1. Create feature/fix branches off `main` (e.g., `fix/scorebug-bugs`)
|
||||||
2. When done, merge the branch into `next-release` — this is the staging branch where changes accumulate
|
2. Open a PR to `main` when ready — merging does NOT trigger a build
|
||||||
3. When ready to release, open a PR from `next-release` → `main`
|
3. When ready to release: `git tag YYYY.M.BUILD && git push --tags`
|
||||||
4. CI builds Docker image on PR; CalVer tag is created on merge
|
4. CI builds Docker image, tags it with the version + `production`, notifies Discord
|
||||||
5. Deploy the new image to production (see `/deploy` skill)
|
5. Deploy the new image to production (see `/deploy` skill)
|
||||||
- **Other services on same host**: `sba_db_api`, `sba_postgres`, `sba_redis`, `sba-website-sba-web-1`, `pd_api`
|
- **Other services on same host**: `sba_db_api`, `sba_postgres`, `sba_redis`, `sba-website-sba-web-1`, `pd_api`
|
||||||
|
|
||||||
|
|||||||
25
bot.py
25
bot.py
@ -42,7 +42,9 @@ def setup_logging():
|
|||||||
|
|
||||||
# JSON file handler - structured logging for monitoring and analysis
|
# JSON file handler - structured logging for monitoring and analysis
|
||||||
json_handler = RotatingFileHandler(
|
json_handler = RotatingFileHandler(
|
||||||
"logs/discord_bot_v2.json", maxBytes=5 * 1024 * 1024, backupCount=5 # 5MB
|
"logs/discord_bot_v2.json",
|
||||||
|
maxBytes=5 * 1024 * 1024,
|
||||||
|
backupCount=5, # 5MB
|
||||||
)
|
)
|
||||||
json_handler.setFormatter(JSONFormatter())
|
json_handler.setFormatter(JSONFormatter())
|
||||||
logger.addHandler(json_handler)
|
logger.addHandler(json_handler)
|
||||||
@ -120,28 +122,11 @@ class SBABot(commands.Bot):
|
|||||||
|
|
||||||
self.maintenance_mode: bool = False
|
self.maintenance_mode: bool = False
|
||||||
self.logger = logging.getLogger("discord_bot_v2")
|
self.logger = logging.getLogger("discord_bot_v2")
|
||||||
self.maintenance_mode: bool = False
|
|
||||||
|
|
||||||
async def setup_hook(self):
|
async def setup_hook(self):
|
||||||
"""Called when the bot is starting up."""
|
"""Called when the bot is starting up."""
|
||||||
self.logger.info("Setting up bot...")
|
self.logger.info("Setting up bot...")
|
||||||
|
|
||||||
@self.tree.interaction_check
|
|
||||||
async def maintenance_check(interaction: discord.Interaction) -> bool:
|
|
||||||
"""Block non-admin users when maintenance mode is enabled."""
|
|
||||||
if not self.maintenance_mode:
|
|
||||||
return True
|
|
||||||
if (
|
|
||||||
isinstance(interaction.user, discord.Member)
|
|
||||||
and interaction.user.guild_permissions.administrator
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
await interaction.response.send_message(
|
|
||||||
"🔧 The bot is currently in maintenance mode. Please try again later.",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Load command packages
|
# Load command packages
|
||||||
await self._load_command_packages()
|
await self._load_command_packages()
|
||||||
|
|
||||||
@ -443,7 +428,9 @@ async def health_command(interaction: discord.Interaction):
|
|||||||
embed.add_field(name="Bot Status", value="✅ Online", inline=True)
|
embed.add_field(name="Bot Status", value="✅ Online", inline=True)
|
||||||
embed.add_field(name="API Status", value=api_status, inline=True)
|
embed.add_field(name="API Status", value=api_status, inline=True)
|
||||||
embed.add_field(name="Guilds", value=str(guild_count), inline=True)
|
embed.add_field(name="Guilds", value=str(guild_count), inline=True)
|
||||||
embed.add_field(name="Latency", value=f"{bot.latency*1000:.1f}ms", inline=True)
|
embed.add_field(
|
||||||
|
name="Latency", value=f"{bot.latency * 1000:.1f}ms", inline=True
|
||||||
|
)
|
||||||
|
|
||||||
if bot.user:
|
if bot.user:
|
||||||
embed.set_footer(
|
embed.set_footer(
|
||||||
|
|||||||
@ -568,14 +568,9 @@ class AdminCommands(commands.Cog):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Clear all messages from the channel
|
# Clear all messages from the channel using bulk delete
|
||||||
deleted_count = 0
|
deleted_messages = await live_scores_channel.purge(limit=100)
|
||||||
async for message in live_scores_channel.history(limit=100):
|
deleted_count = len(deleted_messages)
|
||||||
try:
|
|
||||||
await message.delete()
|
|
||||||
deleted_count += 1
|
|
||||||
except discord.NotFound:
|
|
||||||
pass # Message already deleted
|
|
||||||
|
|
||||||
self.logger.info(f"Cleared {deleted_count} messages from #live-sba-scores")
|
self.logger.info(f"Cleared {deleted_count} messages from #live-sba-scores")
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ Scorebug Commands
|
|||||||
Implements commands for publishing and displaying live game scorebugs from Google Sheets scorecards.
|
Implements commands for publishing and displaying live game scorebugs from Google Sheets scorecards.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
@ -73,12 +74,18 @@ class ScorebugCommands(commands.Cog):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Get team data for display
|
# Get team data for display
|
||||||
away_team = None
|
away_team, home_team = await asyncio.gather(
|
||||||
home_team = None
|
(
|
||||||
if scorebug_data.away_team_id:
|
team_service.get_team(scorebug_data.away_team_id)
|
||||||
away_team = await team_service.get_team(scorebug_data.away_team_id)
|
if scorebug_data.away_team_id
|
||||||
if scorebug_data.home_team_id:
|
else asyncio.sleep(0)
|
||||||
home_team = await team_service.get_team(scorebug_data.home_team_id)
|
),
|
||||||
|
(
|
||||||
|
team_service.get_team(scorebug_data.home_team_id)
|
||||||
|
if scorebug_data.home_team_id
|
||||||
|
else asyncio.sleep(0)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Format scorecard link
|
# Format scorecard link
|
||||||
away_abbrev = away_team.abbrev if away_team else "AWAY"
|
away_abbrev = away_team.abbrev if away_team else "AWAY"
|
||||||
@ -86,7 +93,7 @@ class ScorebugCommands(commands.Cog):
|
|||||||
scorecard_link = f"[{away_abbrev} @ {home_abbrev}]({url})"
|
scorecard_link = f"[{away_abbrev} @ {home_abbrev}]({url})"
|
||||||
|
|
||||||
# Store the scorecard in the tracker
|
# Store the scorecard in the tracker
|
||||||
self.scorecard_tracker.publish_scorecard(
|
await self.scorecard_tracker.publish_scorecard(
|
||||||
text_channel_id=interaction.channel_id, # type: ignore
|
text_channel_id=interaction.channel_id, # type: ignore
|
||||||
sheet_url=url,
|
sheet_url=url,
|
||||||
publisher_id=interaction.user.id,
|
publisher_id=interaction.user.id,
|
||||||
@ -157,7 +164,7 @@ class ScorebugCommands(commands.Cog):
|
|||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
# Check if a scorecard is published in this channel
|
# Check if a scorecard is published in this channel
|
||||||
sheet_url = self.scorecard_tracker.get_scorecard(interaction.channel_id) # type: ignore
|
sheet_url = await self.scorecard_tracker.get_scorecard(interaction.channel_id) # type: ignore
|
||||||
|
|
||||||
if not sheet_url:
|
if not sheet_url:
|
||||||
embed = EmbedTemplate.error(
|
embed = EmbedTemplate.error(
|
||||||
@ -179,12 +186,18 @@ class ScorebugCommands(commands.Cog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get team data
|
# Get team data
|
||||||
away_team = None
|
away_team, home_team = await asyncio.gather(
|
||||||
home_team = None
|
(
|
||||||
if scorebug_data.away_team_id:
|
team_service.get_team(scorebug_data.away_team_id)
|
||||||
away_team = await team_service.get_team(scorebug_data.away_team_id)
|
if scorebug_data.away_team_id
|
||||||
if scorebug_data.home_team_id:
|
else asyncio.sleep(0)
|
||||||
home_team = await team_service.get_team(scorebug_data.home_team_id)
|
),
|
||||||
|
(
|
||||||
|
team_service.get_team(scorebug_data.home_team_id)
|
||||||
|
if scorebug_data.home_team_id
|
||||||
|
else asyncio.sleep(0)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Create scorebug embed using shared utility
|
# Create scorebug embed using shared utility
|
||||||
embed = create_scorebug_embed(
|
embed = create_scorebug_embed(
|
||||||
@ -194,7 +207,7 @@ class ScorebugCommands(commands.Cog):
|
|||||||
await interaction.edit_original_response(content=None, embed=embed)
|
await interaction.edit_original_response(content=None, embed=embed)
|
||||||
|
|
||||||
# Update timestamp in tracker
|
# Update timestamp in tracker
|
||||||
self.scorecard_tracker.update_timestamp(interaction.channel_id) # type: ignore
|
await self.scorecard_tracker.update_timestamp(interaction.channel_id) # type: ignore
|
||||||
|
|
||||||
except SheetsException as e:
|
except SheetsException as e:
|
||||||
embed = EmbedTemplate.error(
|
embed = EmbedTemplate.error(
|
||||||
|
|||||||
@ -24,7 +24,7 @@ class ScorecardTracker:
|
|||||||
- Timestamp tracking for monitoring
|
- Timestamp tracking for monitoring
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, data_file: str = "data/scorecards.json"):
|
def __init__(self, data_file: str = "storage/scorecards.json"):
|
||||||
"""
|
"""
|
||||||
Initialize the scorecard tracker.
|
Initialize the scorecard tracker.
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ class ScorecardTracker:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save scorecard data: {e}")
|
logger.error(f"Failed to save scorecard data: {e}")
|
||||||
|
|
||||||
def publish_scorecard(
|
async def publish_scorecard(
|
||||||
self, text_channel_id: int, sheet_url: str, publisher_id: int
|
self, text_channel_id: int, sheet_url: str, publisher_id: int
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@ -82,7 +82,7 @@ class ScorecardTracker:
|
|||||||
self.save_data()
|
self.save_data()
|
||||||
logger.info(f"Published scorecard to channel {text_channel_id}: {sheet_url}")
|
logger.info(f"Published scorecard to channel {text_channel_id}: {sheet_url}")
|
||||||
|
|
||||||
def unpublish_scorecard(self, text_channel_id: int) -> bool:
|
async def unpublish_scorecard(self, text_channel_id: int) -> bool:
|
||||||
"""
|
"""
|
||||||
Remove scorecard from a text channel.
|
Remove scorecard from a text channel.
|
||||||
|
|
||||||
@ -103,7 +103,7 @@ class ScorecardTracker:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_scorecard(self, text_channel_id: int) -> Optional[str]:
|
async def get_scorecard(self, text_channel_id: int) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Get scorecard URL for a text channel.
|
Get scorecard URL for a text channel.
|
||||||
|
|
||||||
@ -118,7 +118,7 @@ class ScorecardTracker:
|
|||||||
scorecard_data = scorecards.get(str(text_channel_id))
|
scorecard_data = scorecards.get(str(text_channel_id))
|
||||||
return scorecard_data["sheet_url"] if scorecard_data else None
|
return scorecard_data["sheet_url"] if scorecard_data else None
|
||||||
|
|
||||||
def get_all_scorecards(self) -> List[Tuple[int, str]]:
|
async def get_all_scorecards(self) -> List[Tuple[int, str]]:
|
||||||
"""
|
"""
|
||||||
Get all published scorecards.
|
Get all published scorecards.
|
||||||
|
|
||||||
@ -132,7 +132,7 @@ class ScorecardTracker:
|
|||||||
for channel_id, data in scorecards.items()
|
for channel_id, data in scorecards.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
def update_timestamp(self, text_channel_id: int) -> None:
|
async def update_timestamp(self, text_channel_id: int) -> None:
|
||||||
"""
|
"""
|
||||||
Update the last_updated timestamp for a scorecard.
|
Update the last_updated timestamp for a scorecard.
|
||||||
|
|
||||||
@ -146,7 +146,7 @@ class ScorecardTracker:
|
|||||||
scorecards[channel_key]["last_updated"] = datetime.now(UTC).isoformat()
|
scorecards[channel_key]["last_updated"] = datetime.now(UTC).isoformat()
|
||||||
self.save_data()
|
self.save_data()
|
||||||
|
|
||||||
def cleanup_stale_entries(self, valid_channel_ids: List[int]) -> int:
|
async def cleanup_stale_entries(self, valid_channel_ids: List[int]) -> int:
|
||||||
"""
|
"""
|
||||||
Remove tracking entries for text channels that no longer exist.
|
Remove tracking entries for text channels that no longer exist.
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ The injury rating format (#p##) encodes both games played and rating:
|
|||||||
- Remaining: Injury rating (p70, p65, p60, p50, p40, p30, p20)
|
- Remaining: Injury rating (p70, p65, p60, p50, p40, p30, p20)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
import discord
|
import discord
|
||||||
@ -114,16 +115,14 @@ class InjuryGroup(app_commands.Group):
|
|||||||
"""Roll for injury using 3d6 dice and injury tables."""
|
"""Roll for injury using 3d6 dice and injury tables."""
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
|
||||||
# Get current season
|
# Get current season and search for player in parallel
|
||||||
current = await league_service.get_current_state()
|
current, players = await asyncio.gather(
|
||||||
|
league_service.get_current_state(),
|
||||||
|
player_service.search_players(player_name, limit=10),
|
||||||
|
)
|
||||||
if not current:
|
if not current:
|
||||||
raise BotException("Failed to get current season information")
|
raise BotException("Failed to get current season information")
|
||||||
|
|
||||||
# Search for player using the search endpoint (more reliable than name param)
|
|
||||||
players = await player_service.search_players(
|
|
||||||
player_name, limit=10, season=current.season
|
|
||||||
)
|
|
||||||
|
|
||||||
if not players:
|
if not players:
|
||||||
embed = EmbedTemplate.error(
|
embed = EmbedTemplate.error(
|
||||||
title="Player Not Found",
|
title="Player Not Found",
|
||||||
@ -530,16 +529,14 @@ class InjuryGroup(app_commands.Group):
|
|||||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get current season
|
# Get current season and search for player in parallel
|
||||||
current = await league_service.get_current_state()
|
current, players = await asyncio.gather(
|
||||||
|
league_service.get_current_state(),
|
||||||
|
player_service.search_players(player_name, limit=10),
|
||||||
|
)
|
||||||
if not current:
|
if not current:
|
||||||
raise BotException("Failed to get current season information")
|
raise BotException("Failed to get current season information")
|
||||||
|
|
||||||
# Search for player using the search endpoint (more reliable than name param)
|
|
||||||
players = await player_service.search_players(
|
|
||||||
player_name, limit=10, season=current.season
|
|
||||||
)
|
|
||||||
|
|
||||||
if not players:
|
if not players:
|
||||||
embed = EmbedTemplate.error(
|
embed = EmbedTemplate.error(
|
||||||
title="Player Not Found",
|
title="Player Not Found",
|
||||||
@ -717,16 +714,14 @@ class InjuryGroup(app_commands.Group):
|
|||||||
|
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
|
||||||
# Get current season
|
# Get current season and search for player in parallel
|
||||||
current = await league_service.get_current_state()
|
current, players = await asyncio.gather(
|
||||||
|
league_service.get_current_state(),
|
||||||
|
player_service.search_players(player_name, limit=10),
|
||||||
|
)
|
||||||
if not current:
|
if not current:
|
||||||
raise BotException("Failed to get current season information")
|
raise BotException("Failed to get current season information")
|
||||||
|
|
||||||
# Search for player using the search endpoint (more reliable than name param)
|
|
||||||
players = await player_service.search_players(
|
|
||||||
player_name, limit=10, season=current.season
|
|
||||||
)
|
|
||||||
|
|
||||||
if not players:
|
if not players:
|
||||||
embed = EmbedTemplate.error(
|
embed = EmbedTemplate.error(
|
||||||
title="Player Not Found",
|
title="Player Not Found",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ League Schedule Commands
|
|||||||
|
|
||||||
Implements slash commands for displaying game schedules and results.
|
Implements slash commands for displaying game schedules and results.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
@ -22,16 +23,13 @@ class ScheduleCommands(commands.Cog):
|
|||||||
|
|
||||||
def __init__(self, bot: commands.Bot):
|
def __init__(self, bot: commands.Bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.logger = get_contextual_logger(f'{__name__}.ScheduleCommands')
|
self.logger = get_contextual_logger(f"{__name__}.ScheduleCommands")
|
||||||
|
|
||||||
@discord.app_commands.command(
|
@discord.app_commands.command(name="schedule", description="Display game schedule")
|
||||||
name="schedule",
|
|
||||||
description="Display game schedule"
|
|
||||||
)
|
|
||||||
@discord.app_commands.describe(
|
@discord.app_commands.describe(
|
||||||
season="Season to show schedule for (defaults to current season)",
|
season="Season to show schedule for (defaults to current season)",
|
||||||
week="Week number to show (optional)",
|
week="Week number to show (optional)",
|
||||||
team="Team abbreviation to filter by (optional)"
|
team="Team abbreviation to filter by (optional)",
|
||||||
)
|
)
|
||||||
@requires_team()
|
@requires_team()
|
||||||
@logged_command("/schedule")
|
@logged_command("/schedule")
|
||||||
@ -40,7 +38,7 @@ class ScheduleCommands(commands.Cog):
|
|||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
season: Optional[int] = None,
|
season: Optional[int] = None,
|
||||||
week: Optional[int] = None,
|
week: Optional[int] = None,
|
||||||
team: Optional[str] = None
|
team: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Display game schedule for a week or team."""
|
"""Display game schedule for a week or team."""
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
@ -105,7 +103,9 @@ class ScheduleCommands(commands.Cog):
|
|||||||
# embed = await self._create_recent_results_embed(recent_games, search_season)
|
# embed = await self._create_recent_results_embed(recent_games, search_season)
|
||||||
# await interaction.followup.send(embed=embed)
|
# await interaction.followup.send(embed=embed)
|
||||||
|
|
||||||
async def _show_week_schedule(self, interaction: discord.Interaction, season: int, week: int):
|
async def _show_week_schedule(
|
||||||
|
self, interaction: discord.Interaction, season: int, week: int
|
||||||
|
):
|
||||||
"""Show schedule for a specific week."""
|
"""Show schedule for a specific week."""
|
||||||
self.logger.debug("Fetching week schedule", season=season, week=week)
|
self.logger.debug("Fetching week schedule", season=season, week=week)
|
||||||
|
|
||||||
@ -113,15 +113,20 @@ class ScheduleCommands(commands.Cog):
|
|||||||
|
|
||||||
if not games:
|
if not games:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
f"❌ No games found for season {season}, week {week}.",
|
f"❌ No games found for season {season}, week {week}.", ephemeral=True
|
||||||
ephemeral=True
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
embed = await self._create_week_schedule_embed(games, season, week)
|
embed = await self._create_week_schedule_embed(games, season, week)
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.followup.send(embed=embed)
|
||||||
|
|
||||||
async def _show_team_schedule(self, interaction: discord.Interaction, season: int, team: str, week: Optional[int]):
|
async def _show_team_schedule(
|
||||||
|
self,
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
season: int,
|
||||||
|
team: str,
|
||||||
|
week: Optional[int],
|
||||||
|
):
|
||||||
"""Show schedule for a specific team."""
|
"""Show schedule for a specific team."""
|
||||||
self.logger.debug("Fetching team schedule", season=season, team=team, week=week)
|
self.logger.debug("Fetching team schedule", season=season, team=team, week=week)
|
||||||
|
|
||||||
@ -129,8 +134,10 @@ class ScheduleCommands(commands.Cog):
|
|||||||
# Show team games for specific week
|
# Show team games for specific week
|
||||||
week_games = await schedule_service.get_week_schedule(season, week)
|
week_games = await schedule_service.get_week_schedule(season, week)
|
||||||
team_games = [
|
team_games = [
|
||||||
game for game in week_games
|
game
|
||||||
if game.away_team.abbrev.upper() == team.upper() or game.home_team.abbrev.upper() == team.upper()
|
for game in week_games
|
||||||
|
if game.away_team.abbrev.upper() == team.upper()
|
||||||
|
or game.home_team.abbrev.upper() == team.upper()
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# Show team's recent/upcoming games (limited weeks)
|
# Show team's recent/upcoming games (limited weeks)
|
||||||
@ -140,38 +147,44 @@ class ScheduleCommands(commands.Cog):
|
|||||||
week_text = f" for week {week}" if week else ""
|
week_text = f" for week {week}" if week else ""
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
f"❌ No games found for team '{team}'{week_text} in season {season}.",
|
f"❌ No games found for team '{team}'{week_text} in season {season}.",
|
||||||
ephemeral=True
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
embed = await self._create_team_schedule_embed(team_games, season, team, week)
|
embed = await self._create_team_schedule_embed(team_games, season, team, week)
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.followup.send(embed=embed)
|
||||||
|
|
||||||
async def _show_current_schedule(self, interaction: discord.Interaction, season: int):
|
async def _show_current_schedule(
|
||||||
|
self, interaction: discord.Interaction, season: int
|
||||||
|
):
|
||||||
"""Show current schedule overview with recent and upcoming games."""
|
"""Show current schedule overview with recent and upcoming games."""
|
||||||
self.logger.debug("Fetching current schedule overview", season=season)
|
self.logger.debug("Fetching current schedule overview", season=season)
|
||||||
|
|
||||||
# Get both recent and upcoming games
|
# Get both recent and upcoming games
|
||||||
recent_games, upcoming_games = await asyncio.gather(
|
recent_games, upcoming_games = await asyncio.gather(
|
||||||
schedule_service.get_recent_games(season, weeks_back=1),
|
schedule_service.get_recent_games(season, weeks_back=1),
|
||||||
schedule_service.get_upcoming_games(season, weeks_ahead=1)
|
schedule_service.get_upcoming_games(season),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not recent_games and not upcoming_games:
|
if not recent_games and not upcoming_games:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
f"❌ No recent or upcoming games found for season {season}.",
|
f"❌ No recent or upcoming games found for season {season}.",
|
||||||
ephemeral=True
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
embed = await self._create_current_schedule_embed(recent_games, upcoming_games, season)
|
embed = await self._create_current_schedule_embed(
|
||||||
|
recent_games, upcoming_games, season
|
||||||
|
)
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.followup.send(embed=embed)
|
||||||
|
|
||||||
async def _create_week_schedule_embed(self, games, season: int, week: int) -> discord.Embed:
|
async def _create_week_schedule_embed(
|
||||||
|
self, games, season: int, week: int
|
||||||
|
) -> discord.Embed:
|
||||||
"""Create an embed for a week's schedule."""
|
"""Create an embed for a week's schedule."""
|
||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"📅 Week {week} Schedule - Season {season}",
|
title=f"📅 Week {week} Schedule - Season {season}",
|
||||||
color=EmbedColors.PRIMARY
|
color=EmbedColors.PRIMARY,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Group games by series
|
# Group games by series
|
||||||
@ -184,9 +197,7 @@ class ScheduleCommands(commands.Cog):
|
|||||||
|
|
||||||
if schedule_lines:
|
if schedule_lines:
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Games",
|
name="Games", value="\n\n".join(schedule_lines), inline=False
|
||||||
value="\n\n".join(schedule_lines),
|
|
||||||
inline=False
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add week summary
|
# Add week summary
|
||||||
@ -195,18 +206,20 @@ class ScheduleCommands(commands.Cog):
|
|||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Week Progress",
|
name="Week Progress",
|
||||||
value=f"{completed}/{total} games completed",
|
value=f"{completed}/{total} games completed",
|
||||||
inline=True
|
inline=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
embed.set_footer(text=f"Season {season} • Week {week}")
|
embed.set_footer(text=f"Season {season} • Week {week}")
|
||||||
return embed
|
return embed
|
||||||
|
|
||||||
async def _create_team_schedule_embed(self, games, season: int, team: str, week: Optional[int]) -> discord.Embed:
|
async def _create_team_schedule_embed(
|
||||||
|
self, games, season: int, team: str, week: Optional[int]
|
||||||
|
) -> discord.Embed:
|
||||||
"""Create an embed for a team's schedule."""
|
"""Create an embed for a team's schedule."""
|
||||||
week_text = f" - Week {week}" if week else ""
|
week_text = f" - Week {week}" if week else ""
|
||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"📅 {team.upper()} Schedule{week_text} - Season {season}",
|
title=f"📅 {team.upper()} Schedule{week_text} - Season {season}",
|
||||||
color=EmbedColors.PRIMARY
|
color=EmbedColors.PRIMARY,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Separate completed and upcoming games
|
# Separate completed and upcoming games
|
||||||
@ -216,18 +229,26 @@ class ScheduleCommands(commands.Cog):
|
|||||||
if completed_games:
|
if completed_games:
|
||||||
recent_lines = []
|
recent_lines = []
|
||||||
for game in completed_games[-5:]: # Last 5 games
|
for game in completed_games[-5:]: # Last 5 games
|
||||||
result = "W" if game.winner and game.winner.abbrev.upper() == team.upper() else "L"
|
result = (
|
||||||
|
"W"
|
||||||
|
if game.winner and game.winner.abbrev.upper() == team.upper()
|
||||||
|
else "L"
|
||||||
|
)
|
||||||
if game.home_team.abbrev.upper() == team.upper():
|
if game.home_team.abbrev.upper() == team.upper():
|
||||||
# Team was home
|
# Team was home
|
||||||
recent_lines.append(f"Week {game.week}: {result} vs {game.away_team.abbrev} ({game.score_display})")
|
recent_lines.append(
|
||||||
|
f"Week {game.week}: {result} vs {game.away_team.abbrev} ({game.score_display})"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Team was away
|
# Team was away
|
||||||
recent_lines.append(f"Week {game.week}: {result} @ {game.home_team.abbrev} ({game.score_display})")
|
recent_lines.append(
|
||||||
|
f"Week {game.week}: {result} @ {game.home_team.abbrev} ({game.score_display})"
|
||||||
|
)
|
||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Recent Results",
|
name="Recent Results",
|
||||||
value="\n".join(recent_lines) if recent_lines else "No recent games",
|
value="\n".join(recent_lines) if recent_lines else "No recent games",
|
||||||
inline=False
|
inline=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if upcoming_games:
|
if upcoming_games:
|
||||||
@ -235,25 +256,32 @@ class ScheduleCommands(commands.Cog):
|
|||||||
for game in upcoming_games[:5]: # Next 5 games
|
for game in upcoming_games[:5]: # Next 5 games
|
||||||
if game.home_team.abbrev.upper() == team.upper():
|
if game.home_team.abbrev.upper() == team.upper():
|
||||||
# Team is home
|
# Team is home
|
||||||
upcoming_lines.append(f"Week {game.week}: vs {game.away_team.abbrev}")
|
upcoming_lines.append(
|
||||||
|
f"Week {game.week}: vs {game.away_team.abbrev}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Team is away
|
# Team is away
|
||||||
upcoming_lines.append(f"Week {game.week}: @ {game.home_team.abbrev}")
|
upcoming_lines.append(
|
||||||
|
f"Week {game.week}: @ {game.home_team.abbrev}"
|
||||||
|
)
|
||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Upcoming Games",
|
name="Upcoming Games",
|
||||||
value="\n".join(upcoming_lines) if upcoming_lines else "No upcoming games",
|
value=(
|
||||||
inline=False
|
"\n".join(upcoming_lines) if upcoming_lines else "No upcoming games"
|
||||||
|
),
|
||||||
|
inline=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
embed.set_footer(text=f"Season {season} • {team.upper()}")
|
embed.set_footer(text=f"Season {season} • {team.upper()}")
|
||||||
return embed
|
return embed
|
||||||
|
|
||||||
async def _create_week_results_embed(self, games, season: int, week: int) -> discord.Embed:
|
async def _create_week_results_embed(
|
||||||
|
self, games, season: int, week: int
|
||||||
|
) -> discord.Embed:
|
||||||
"""Create an embed for week results."""
|
"""Create an embed for week results."""
|
||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"🏆 Week {week} Results - Season {season}",
|
title=f"🏆 Week {week} Results - Season {season}", color=EmbedColors.SUCCESS
|
||||||
color=EmbedColors.SUCCESS
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Group by series and show results
|
# Group by series and show results
|
||||||
@ -262,8 +290,12 @@ class ScheduleCommands(commands.Cog):
|
|||||||
results_lines = []
|
results_lines = []
|
||||||
for (team1, team2), series in series_games.items():
|
for (team1, team2), series in series_games.items():
|
||||||
# Count wins for each team
|
# Count wins for each team
|
||||||
team1_wins = len([g for g in series if g.winner and g.winner.abbrev == team1])
|
team1_wins = len(
|
||||||
team2_wins = len([g for g in series if g.winner and g.winner.abbrev == team2])
|
[g for g in series if g.winner and g.winner.abbrev == team1]
|
||||||
|
)
|
||||||
|
team2_wins = len(
|
||||||
|
[g for g in series if g.winner and g.winner.abbrev == team2]
|
||||||
|
)
|
||||||
|
|
||||||
# Series result
|
# Series result
|
||||||
series_result = f"**{team1} {team1_wins}-{team2_wins} {team2}**"
|
series_result = f"**{team1} {team1_wins}-{team2_wins} {team2}**"
|
||||||
@ -272,25 +304,26 @@ class ScheduleCommands(commands.Cog):
|
|||||||
game_details = []
|
game_details = []
|
||||||
for game in series:
|
for game in series:
|
||||||
if game.series_game_display:
|
if game.series_game_display:
|
||||||
game_details.append(f"{game.series_game_display}: {game.matchup_display}")
|
game_details.append(
|
||||||
|
f"{game.series_game_display}: {game.matchup_display}"
|
||||||
|
)
|
||||||
|
|
||||||
results_lines.append(f"{series_result}\n" + "\n".join(game_details))
|
results_lines.append(f"{series_result}\n" + "\n".join(game_details))
|
||||||
|
|
||||||
if results_lines:
|
if results_lines:
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Series Results",
|
name="Series Results", value="\n\n".join(results_lines), inline=False
|
||||||
value="\n\n".join(results_lines),
|
|
||||||
inline=False
|
|
||||||
)
|
)
|
||||||
|
|
||||||
embed.set_footer(text=f"Season {season} • Week {week} • {len(games)} games completed")
|
embed.set_footer(
|
||||||
|
text=f"Season {season} • Week {week} • {len(games)} games completed"
|
||||||
|
)
|
||||||
return embed
|
return embed
|
||||||
|
|
||||||
async def _create_recent_results_embed(self, games, season: int) -> discord.Embed:
|
async def _create_recent_results_embed(self, games, season: int) -> discord.Embed:
|
||||||
"""Create an embed for recent results."""
|
"""Create an embed for recent results."""
|
||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"🏆 Recent Results - Season {season}",
|
title=f"🏆 Recent Results - Season {season}", color=EmbedColors.SUCCESS
|
||||||
color=EmbedColors.SUCCESS
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Show most recent games
|
# Show most recent games
|
||||||
@ -300,19 +333,18 @@ class ScheduleCommands(commands.Cog):
|
|||||||
|
|
||||||
if recent_lines:
|
if recent_lines:
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Latest Games",
|
name="Latest Games", value="\n".join(recent_lines), inline=False
|
||||||
value="\n".join(recent_lines),
|
|
||||||
inline=False
|
|
||||||
)
|
)
|
||||||
|
|
||||||
embed.set_footer(text=f"Season {season} • Last {len(games)} completed games")
|
embed.set_footer(text=f"Season {season} • Last {len(games)} completed games")
|
||||||
return embed
|
return embed
|
||||||
|
|
||||||
async def _create_current_schedule_embed(self, recent_games, upcoming_games, season: int) -> discord.Embed:
|
async def _create_current_schedule_embed(
|
||||||
|
self, recent_games, upcoming_games, season: int
|
||||||
|
) -> discord.Embed:
|
||||||
"""Create an embed for current schedule overview."""
|
"""Create an embed for current schedule overview."""
|
||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"📅 Current Schedule - Season {season}",
|
title=f"📅 Current Schedule - Season {season}", color=EmbedColors.INFO
|
||||||
color=EmbedColors.INFO
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if recent_games:
|
if recent_games:
|
||||||
@ -321,9 +353,7 @@ class ScheduleCommands(commands.Cog):
|
|||||||
recent_lines.append(f"Week {game.week}: {game.matchup_display}")
|
recent_lines.append(f"Week {game.week}: {game.matchup_display}")
|
||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Recent Results",
|
name="Recent Results", value="\n".join(recent_lines), inline=False
|
||||||
value="\n".join(recent_lines),
|
|
||||||
inline=False
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if upcoming_games:
|
if upcoming_games:
|
||||||
@ -332,9 +362,7 @@ class ScheduleCommands(commands.Cog):
|
|||||||
upcoming_lines.append(f"Week {game.week}: {game.matchup_display}")
|
upcoming_lines.append(f"Week {game.week}: {game.matchup_display}")
|
||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Upcoming Games",
|
name="Upcoming Games", value="\n".join(upcoming_lines), inline=False
|
||||||
value="\n".join(upcoming_lines),
|
|
||||||
inline=False
|
|
||||||
)
|
)
|
||||||
|
|
||||||
embed.set_footer(text=f"Season {season}")
|
embed.set_footer(text=f"Season {season}")
|
||||||
@ -344,7 +372,11 @@ class ScheduleCommands(commands.Cog):
|
|||||||
"""Format a series summary."""
|
"""Format a series summary."""
|
||||||
lines = []
|
lines = []
|
||||||
for game in series:
|
for game in series:
|
||||||
game_display = f"{game.series_game_display}: {game.matchup_display}" if game.series_game_display else game.matchup_display
|
game_display = (
|
||||||
|
f"{game.series_game_display}: {game.matchup_display}"
|
||||||
|
if game.series_game_display
|
||||||
|
else game.matchup_display
|
||||||
|
)
|
||||||
lines.append(game_display)
|
lines.append(game_display)
|
||||||
|
|
||||||
return "\n".join(lines) if lines else "No games"
|
return "\n".join(lines) if lines else "No games"
|
||||||
|
|||||||
@ -5,6 +5,7 @@ Implements the /submit-scorecard command for submitting Google Sheets
|
|||||||
scorecards with play-by-play data, pitching decisions, and game results.
|
scorecards with play-by-play data, pitching decisions, and game results.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
@ -107,11 +108,13 @@ class SubmitScorecardCommands(commands.Cog):
|
|||||||
content="🔍 Looking up teams and managers..."
|
content="🔍 Looking up teams and managers..."
|
||||||
)
|
)
|
||||||
|
|
||||||
away_team = await team_service.get_team_by_abbrev(
|
away_team, home_team = await asyncio.gather(
|
||||||
setup_data["away_team_abbrev"], current.season
|
team_service.get_team_by_abbrev(
|
||||||
)
|
setup_data["away_team_abbrev"], current.season
|
||||||
home_team = await team_service.get_team_by_abbrev(
|
),
|
||||||
setup_data["home_team_abbrev"], current.season
|
team_service.get_team_by_abbrev(
|
||||||
|
setup_data["home_team_abbrev"], current.season
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not away_team or not home_team:
|
if not away_team or not home_team:
|
||||||
@ -235,9 +238,13 @@ class SubmitScorecardCommands(commands.Cog):
|
|||||||
decision["game_num"] = setup_data["game_num"]
|
decision["game_num"] = setup_data["game_num"]
|
||||||
|
|
||||||
# Validate WP and LP exist and fetch Player objects
|
# Validate WP and LP exist and fetch Player objects
|
||||||
wp, lp, sv, holders, _blown_saves = (
|
(
|
||||||
await decision_service.find_winning_losing_pitchers(decisions_data)
|
wp,
|
||||||
)
|
lp,
|
||||||
|
sv,
|
||||||
|
holders,
|
||||||
|
_blown_saves,
|
||||||
|
) = await decision_service.find_winning_losing_pitchers(decisions_data)
|
||||||
|
|
||||||
if wp is None or lp is None:
|
if wp is None or lp is None:
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
|
|||||||
@ -3,13 +3,14 @@ Soak Tracker
|
|||||||
|
|
||||||
Provides persistent tracking of "soak" mentions using JSON file storage.
|
Provides persistent tracking of "soak" mentions using JSON file storage.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, UTC
|
from datetime import datetime, timedelta, UTC
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
logger = logging.getLogger(f'{__name__}.SoakTracker')
|
logger = logging.getLogger(f"{__name__}.SoakTracker")
|
||||||
|
|
||||||
|
|
||||||
class SoakTracker:
|
class SoakTracker:
|
||||||
@ -22,7 +23,7 @@ class SoakTracker:
|
|||||||
- Time-based calculations for disappointment tiers
|
- Time-based calculations for disappointment tiers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, data_file: str = "data/soak_data.json"):
|
def __init__(self, data_file: str = "storage/soak_data.json"):
|
||||||
"""
|
"""
|
||||||
Initialize the soak tracker.
|
Initialize the soak tracker.
|
||||||
|
|
||||||
@ -38,28 +39,22 @@ class SoakTracker:
|
|||||||
"""Load soak data from JSON file."""
|
"""Load soak data from JSON file."""
|
||||||
try:
|
try:
|
||||||
if self.data_file.exists():
|
if self.data_file.exists():
|
||||||
with open(self.data_file, 'r') as f:
|
with open(self.data_file, "r") as f:
|
||||||
self._data = json.load(f)
|
self._data = json.load(f)
|
||||||
logger.debug(f"Loaded soak data: {self._data.get('total_count', 0)} total soaks")
|
logger.debug(
|
||||||
|
f"Loaded soak data: {self._data.get('total_count', 0)} total soaks"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self._data = {
|
self._data = {"last_soak": None, "total_count": 0, "history": []}
|
||||||
"last_soak": None,
|
|
||||||
"total_count": 0,
|
|
||||||
"history": []
|
|
||||||
}
|
|
||||||
logger.info("No existing soak data found, starting fresh")
|
logger.info("No existing soak data found, starting fresh")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load soak data: {e}")
|
logger.error(f"Failed to load soak data: {e}")
|
||||||
self._data = {
|
self._data = {"last_soak": None, "total_count": 0, "history": []}
|
||||||
"last_soak": None,
|
|
||||||
"total_count": 0,
|
|
||||||
"history": []
|
|
||||||
}
|
|
||||||
|
|
||||||
def save_data(self) -> None:
|
def save_data(self) -> None:
|
||||||
"""Save soak data to JSON file."""
|
"""Save soak data to JSON file."""
|
||||||
try:
|
try:
|
||||||
with open(self.data_file, 'w') as f:
|
with open(self.data_file, "w") as f:
|
||||||
json.dump(self._data, f, indent=2, default=str)
|
json.dump(self._data, f, indent=2, default=str)
|
||||||
logger.debug("Soak data saved successfully")
|
logger.debug("Soak data saved successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -71,7 +66,7 @@ class SoakTracker:
|
|||||||
username: str,
|
username: str,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
channel_id: int,
|
channel_id: int,
|
||||||
message_id: int
|
message_id: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Record a new soak mention.
|
Record a new soak mention.
|
||||||
@ -89,7 +84,7 @@ class SoakTracker:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"display_name": display_name,
|
"display_name": display_name,
|
||||||
"channel_id": str(channel_id),
|
"channel_id": str(channel_id),
|
||||||
"message_id": str(message_id)
|
"message_id": str(message_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Update last_soak
|
# Update last_soak
|
||||||
@ -110,7 +105,9 @@ class SoakTracker:
|
|||||||
|
|
||||||
self.save_data()
|
self.save_data()
|
||||||
|
|
||||||
logger.info(f"Recorded soak by {username} (ID: {user_id}) in channel {channel_id}")
|
logger.info(
|
||||||
|
f"Recorded soak by {username} (ID: {user_id}) in channel {channel_id}"
|
||||||
|
)
|
||||||
|
|
||||||
def get_last_soak(self) -> Optional[Dict[str, Any]]:
|
def get_last_soak(self) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@ -135,10 +132,12 @@ class SoakTracker:
|
|||||||
try:
|
try:
|
||||||
# Parse ISO format timestamp
|
# Parse ISO format timestamp
|
||||||
last_timestamp_str = last_soak["timestamp"]
|
last_timestamp_str = last_soak["timestamp"]
|
||||||
if last_timestamp_str.endswith('Z'):
|
if last_timestamp_str.endswith("Z"):
|
||||||
last_timestamp_str = last_timestamp_str[:-1] + '+00:00'
|
last_timestamp_str = last_timestamp_str[:-1] + "+00:00"
|
||||||
|
|
||||||
last_timestamp = datetime.fromisoformat(last_timestamp_str.replace('Z', '+00:00'))
|
last_timestamp = datetime.fromisoformat(
|
||||||
|
last_timestamp_str.replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
|
||||||
# Ensure both times are timezone-aware
|
# Ensure both times are timezone-aware
|
||||||
if last_timestamp.tzinfo is None:
|
if last_timestamp.tzinfo is None:
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Transaction Management Commands
|
|||||||
|
|
||||||
Core transaction commands for roster management and transaction tracking.
|
Core transaction commands for roster management and transaction tracking.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ from views.base import PaginationView
|
|||||||
from services.transaction_service import transaction_service
|
from services.transaction_service import transaction_service
|
||||||
from services.roster_service import roster_service
|
from services.roster_service import roster_service
|
||||||
from services.team_service import team_service
|
from services.team_service import team_service
|
||||||
|
|
||||||
# No longer need TransactionStatus enum
|
# No longer need TransactionStatus enum
|
||||||
|
|
||||||
|
|
||||||
@ -34,25 +36,28 @@ class TransactionPaginationView(PaginationView):
|
|||||||
all_transactions: list,
|
all_transactions: list,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
timeout: float = 300.0,
|
timeout: float = 300.0,
|
||||||
show_page_numbers: bool = True
|
show_page_numbers: bool = True,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
pages=pages,
|
pages=pages,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
show_page_numbers=show_page_numbers
|
show_page_numbers=show_page_numbers,
|
||||||
)
|
)
|
||||||
self.all_transactions = all_transactions
|
self.all_transactions = all_transactions
|
||||||
|
|
||||||
@discord.ui.button(label="Show Move IDs", style=discord.ButtonStyle.secondary, emoji="🔍", row=1)
|
@discord.ui.button(
|
||||||
async def show_move_ids(self, interaction: discord.Interaction, button: discord.ui.Button):
|
label="Show Move IDs", style=discord.ButtonStyle.secondary, emoji="🔍", row=1
|
||||||
|
)
|
||||||
|
async def show_move_ids(
|
||||||
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
|
):
|
||||||
"""Show all move IDs in an ephemeral message."""
|
"""Show all move IDs in an ephemeral message."""
|
||||||
self.increment_interaction_count()
|
self.increment_interaction_count()
|
||||||
|
|
||||||
if not self.all_transactions:
|
if not self.all_transactions:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
"No transactions to show.",
|
"No transactions to show.", ephemeral=True
|
||||||
ephemeral=True
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -85,8 +90,7 @@ class TransactionPaginationView(PaginationView):
|
|||||||
# Send the messages
|
# Send the messages
|
||||||
if not messages:
|
if not messages:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
"No transactions to display.",
|
"No transactions to display.", ephemeral=True
|
||||||
ephemeral=True
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -104,11 +108,10 @@ class TransactionCommands(commands.Cog):
|
|||||||
|
|
||||||
def __init__(self, bot: commands.Bot):
|
def __init__(self, bot: commands.Bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.logger = get_contextual_logger(f'{__name__}.TransactionCommands')
|
self.logger = get_contextual_logger(f"{__name__}.TransactionCommands")
|
||||||
|
|
||||||
@app_commands.command(
|
@app_commands.command(
|
||||||
name="mymoves",
|
name="mymoves", description="View your pending and scheduled transactions"
|
||||||
description="View your pending and scheduled transactions"
|
|
||||||
)
|
)
|
||||||
@app_commands.describe(
|
@app_commands.describe(
|
||||||
show_cancelled="Include cancelled transactions in the display (default: False)"
|
show_cancelled="Include cancelled transactions in the display (default: False)"
|
||||||
@ -116,39 +119,45 @@ class TransactionCommands(commands.Cog):
|
|||||||
@requires_team()
|
@requires_team()
|
||||||
@logged_command("/mymoves")
|
@logged_command("/mymoves")
|
||||||
async def my_moves(
|
async def my_moves(
|
||||||
self,
|
self, interaction: discord.Interaction, show_cancelled: bool = False
|
||||||
interaction: discord.Interaction,
|
|
||||||
show_cancelled: bool = False
|
|
||||||
):
|
):
|
||||||
"""Display user's transaction status and history."""
|
"""Display user's transaction status and history."""
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
|
||||||
# Get user's team
|
# Get user's team
|
||||||
team = await get_user_major_league_team(interaction.user.id, get_config().sba_season)
|
team = await get_user_major_league_team(
|
||||||
|
interaction.user.id, get_config().sba_season
|
||||||
|
)
|
||||||
|
|
||||||
if not team:
|
if not team:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
"❌ You don't appear to own a team in the current season.",
|
"❌ You don't appear to own a team in the current season.",
|
||||||
ephemeral=True
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get transactions in parallel
|
# Get transactions in parallel
|
||||||
pending_task = transaction_service.get_pending_transactions(team.abbrev, get_config().sba_season)
|
(
|
||||||
frozen_task = transaction_service.get_frozen_transactions(team.abbrev, get_config().sba_season)
|
pending_transactions,
|
||||||
processed_task = transaction_service.get_processed_transactions(team.abbrev, get_config().sba_season)
|
frozen_transactions,
|
||||||
|
processed_transactions,
|
||||||
pending_transactions = await pending_task
|
) = await asyncio.gather(
|
||||||
frozen_transactions = await frozen_task
|
transaction_service.get_pending_transactions(
|
||||||
processed_transactions = await processed_task
|
team.abbrev, get_config().sba_season
|
||||||
|
),
|
||||||
|
transaction_service.get_frozen_transactions(
|
||||||
|
team.abbrev, get_config().sba_season
|
||||||
|
),
|
||||||
|
transaction_service.get_processed_transactions(
|
||||||
|
team.abbrev, get_config().sba_season
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Get cancelled if requested
|
# Get cancelled if requested
|
||||||
cancelled_transactions = []
|
cancelled_transactions = []
|
||||||
if show_cancelled:
|
if show_cancelled:
|
||||||
cancelled_transactions = await transaction_service.get_team_transactions(
|
cancelled_transactions = await transaction_service.get_team_transactions(
|
||||||
team.abbrev,
|
team.abbrev, get_config().sba_season, cancelled=True
|
||||||
get_config().sba_season,
|
|
||||||
cancelled=True
|
|
||||||
)
|
)
|
||||||
|
|
||||||
pages = self._create_my_moves_pages(
|
pages = self._create_my_moves_pages(
|
||||||
@ -156,15 +165,15 @@ class TransactionCommands(commands.Cog):
|
|||||||
pending_transactions,
|
pending_transactions,
|
||||||
frozen_transactions,
|
frozen_transactions,
|
||||||
processed_transactions,
|
processed_transactions,
|
||||||
cancelled_transactions
|
cancelled_transactions,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Collect all transactions for the "Show Move IDs" button
|
# Collect all transactions for the "Show Move IDs" button
|
||||||
all_transactions = (
|
all_transactions = (
|
||||||
pending_transactions +
|
pending_transactions
|
||||||
frozen_transactions +
|
+ frozen_transactions
|
||||||
processed_transactions +
|
+ processed_transactions
|
||||||
cancelled_transactions
|
+ cancelled_transactions
|
||||||
)
|
)
|
||||||
|
|
||||||
# If only one page and no transactions, send without any buttons
|
# If only one page and no transactions, send without any buttons
|
||||||
@ -177,43 +186,40 @@ class TransactionCommands(commands.Cog):
|
|||||||
all_transactions=all_transactions,
|
all_transactions=all_transactions,
|
||||||
user_id=interaction.user.id,
|
user_id=interaction.user.id,
|
||||||
timeout=300.0,
|
timeout=300.0,
|
||||||
show_page_numbers=True
|
show_page_numbers=True,
|
||||||
)
|
)
|
||||||
await interaction.followup.send(embed=view.get_current_embed(), view=view)
|
await interaction.followup.send(embed=view.get_current_embed(), view=view)
|
||||||
|
|
||||||
@app_commands.command(
|
@app_commands.command(
|
||||||
name="legal",
|
name="legal", description="Check roster legality for current and next week"
|
||||||
description="Check roster legality for current and next week"
|
|
||||||
)
|
|
||||||
@app_commands.describe(
|
|
||||||
team="Team abbreviation to check (defaults to your team)"
|
|
||||||
)
|
)
|
||||||
|
@app_commands.describe(team="Team abbreviation to check (defaults to your team)")
|
||||||
@requires_team()
|
@requires_team()
|
||||||
@logged_command("/legal")
|
@logged_command("/legal")
|
||||||
async def legal(
|
async def legal(self, interaction: discord.Interaction, team: Optional[str] = None):
|
||||||
self,
|
|
||||||
interaction: discord.Interaction,
|
|
||||||
team: Optional[str] = None
|
|
||||||
):
|
|
||||||
"""Check roster legality and display detailed validation results."""
|
"""Check roster legality and display detailed validation results."""
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
|
||||||
# Get target team
|
# Get target team
|
||||||
if team:
|
if team:
|
||||||
target_team = await team_service.get_team_by_abbrev(team.upper(), get_config().sba_season)
|
target_team = await team_service.get_team_by_abbrev(
|
||||||
|
team.upper(), get_config().sba_season
|
||||||
|
)
|
||||||
if not target_team:
|
if not target_team:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
f"❌ Could not find team '{team}' in season {get_config().sba_season}.",
|
f"❌ Could not find team '{team}' in season {get_config().sba_season}.",
|
||||||
ephemeral=True
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
# Get user's team
|
# Get user's team
|
||||||
user_teams = await team_service.get_teams_by_owner(interaction.user.id, get_config().sba_season)
|
user_teams = await team_service.get_teams_by_owner(
|
||||||
|
interaction.user.id, get_config().sba_season
|
||||||
|
)
|
||||||
if not user_teams:
|
if not user_teams:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
"❌ You don't appear to own a team. Please specify a team abbreviation.",
|
"❌ You don't appear to own a team. Please specify a team abbreviation.",
|
||||||
ephemeral=True
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
target_team = user_teams[0]
|
target_team = user_teams[0]
|
||||||
@ -221,13 +227,13 @@ class TransactionCommands(commands.Cog):
|
|||||||
# Get rosters in parallel
|
# Get rosters in parallel
|
||||||
current_roster, next_roster = await asyncio.gather(
|
current_roster, next_roster = await asyncio.gather(
|
||||||
roster_service.get_current_roster(target_team.id),
|
roster_service.get_current_roster(target_team.id),
|
||||||
roster_service.get_next_roster(target_team.id)
|
roster_service.get_next_roster(target_team.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not current_roster and not next_roster:
|
if not current_roster and not next_roster:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
f"❌ Could not retrieve roster data for {target_team.abbrev}.",
|
f"❌ Could not retrieve roster data for {target_team.abbrev}.",
|
||||||
ephemeral=True
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -252,7 +258,7 @@ class TransactionCommands(commands.Cog):
|
|||||||
current_roster,
|
current_roster,
|
||||||
next_roster,
|
next_roster,
|
||||||
current_validation,
|
current_validation,
|
||||||
next_validation
|
next_validation,
|
||||||
)
|
)
|
||||||
|
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.followup.send(embed=embed)
|
||||||
@ -263,7 +269,7 @@ class TransactionCommands(commands.Cog):
|
|||||||
pending_transactions,
|
pending_transactions,
|
||||||
frozen_transactions,
|
frozen_transactions,
|
||||||
processed_transactions,
|
processed_transactions,
|
||||||
cancelled_transactions
|
cancelled_transactions,
|
||||||
) -> list[discord.Embed]:
|
) -> list[discord.Embed]:
|
||||||
"""Create paginated embeds showing user's transaction status."""
|
"""Create paginated embeds showing user's transaction status."""
|
||||||
|
|
||||||
@ -277,7 +283,9 @@ class TransactionCommands(commands.Cog):
|
|||||||
# Page 1: Summary + Pending Transactions
|
# Page 1: Summary + Pending Transactions
|
||||||
if pending_transactions:
|
if pending_transactions:
|
||||||
total_pending = len(pending_transactions)
|
total_pending = len(pending_transactions)
|
||||||
total_pages = (total_pending + transactions_per_page - 1) // transactions_per_page
|
total_pages = (
|
||||||
|
total_pending + transactions_per_page - 1
|
||||||
|
) // transactions_per_page
|
||||||
|
|
||||||
for page_num in range(total_pages):
|
for page_num in range(total_pages):
|
||||||
start_idx = page_num * transactions_per_page
|
start_idx = page_num * transactions_per_page
|
||||||
@ -287,11 +295,11 @@ class TransactionCommands(commands.Cog):
|
|||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"📋 Transaction Status - {team.abbrev}",
|
title=f"📋 Transaction Status - {team.abbrev}",
|
||||||
description=f"{team.lname} • Season {get_config().sba_season}",
|
description=f"{team.lname} • Season {get_config().sba_season}",
|
||||||
color=EmbedColors.INFO
|
color=EmbedColors.INFO,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add team thumbnail if available
|
# Add team thumbnail if available
|
||||||
if hasattr(team, 'thumbnail') and team.thumbnail:
|
if hasattr(team, "thumbnail") and team.thumbnail:
|
||||||
embed.set_thumbnail(url=team.thumbnail)
|
embed.set_thumbnail(url=team.thumbnail)
|
||||||
|
|
||||||
# Pending transactions for this page
|
# Pending transactions for this page
|
||||||
@ -300,7 +308,7 @@ class TransactionCommands(commands.Cog):
|
|||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=f"⏳ Pending Transactions ({total_pending} total)",
|
name=f"⏳ Pending Transactions ({total_pending} total)",
|
||||||
value="\n".join(pending_lines),
|
value="\n".join(pending_lines),
|
||||||
inline=False
|
inline=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add summary only on first page
|
# Add summary only on first page
|
||||||
@ -314,8 +322,12 @@ class TransactionCommands(commands.Cog):
|
|||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Summary",
|
name="Summary",
|
||||||
value=", ".join(status_text) if status_text else "No active transactions",
|
value=(
|
||||||
inline=True
|
", ".join(status_text)
|
||||||
|
if status_text
|
||||||
|
else "No active transactions"
|
||||||
|
),
|
||||||
|
inline=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
pages.append(embed)
|
pages.append(embed)
|
||||||
@ -324,16 +336,16 @@ class TransactionCommands(commands.Cog):
|
|||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"📋 Transaction Status - {team.abbrev}",
|
title=f"📋 Transaction Status - {team.abbrev}",
|
||||||
description=f"{team.lname} • Season {get_config().sba_season}",
|
description=f"{team.lname} • Season {get_config().sba_season}",
|
||||||
color=EmbedColors.INFO
|
color=EmbedColors.INFO,
|
||||||
)
|
)
|
||||||
|
|
||||||
if hasattr(team, 'thumbnail') and team.thumbnail:
|
if hasattr(team, "thumbnail") and team.thumbnail:
|
||||||
embed.set_thumbnail(url=team.thumbnail)
|
embed.set_thumbnail(url=team.thumbnail)
|
||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="⏳ Pending Transactions",
|
name="⏳ Pending Transactions",
|
||||||
value="No pending transactions",
|
value="No pending transactions",
|
||||||
inline=False
|
inline=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
total_frozen = len(frozen_transactions)
|
total_frozen = len(frozen_transactions)
|
||||||
@ -343,8 +355,10 @@ class TransactionCommands(commands.Cog):
|
|||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Summary",
|
name="Summary",
|
||||||
value=", ".join(status_text) if status_text else "No active transactions",
|
value=(
|
||||||
inline=True
|
", ".join(status_text) if status_text else "No active transactions"
|
||||||
|
),
|
||||||
|
inline=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
pages.append(embed)
|
pages.append(embed)
|
||||||
@ -354,10 +368,10 @@ class TransactionCommands(commands.Cog):
|
|||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"📋 Transaction Status - {team.abbrev}",
|
title=f"📋 Transaction Status - {team.abbrev}",
|
||||||
description=f"{team.lname} • Season {get_config().sba_season}",
|
description=f"{team.lname} • Season {get_config().sba_season}",
|
||||||
color=EmbedColors.INFO
|
color=EmbedColors.INFO,
|
||||||
)
|
)
|
||||||
|
|
||||||
if hasattr(team, 'thumbnail') and team.thumbnail:
|
if hasattr(team, "thumbnail") and team.thumbnail:
|
||||||
embed.set_thumbnail(url=team.thumbnail)
|
embed.set_thumbnail(url=team.thumbnail)
|
||||||
|
|
||||||
frozen_lines = [format_transaction(tx) for tx in frozen_transactions]
|
frozen_lines = [format_transaction(tx) for tx in frozen_transactions]
|
||||||
@ -365,7 +379,7 @@ class TransactionCommands(commands.Cog):
|
|||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=f"❄️ Scheduled for Processing ({len(frozen_transactions)} total)",
|
name=f"❄️ Scheduled for Processing ({len(frozen_transactions)} total)",
|
||||||
value="\n".join(frozen_lines),
|
value="\n".join(frozen_lines),
|
||||||
inline=False
|
inline=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
pages.append(embed)
|
pages.append(embed)
|
||||||
@ -375,18 +389,20 @@ class TransactionCommands(commands.Cog):
|
|||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"📋 Transaction Status - {team.abbrev}",
|
title=f"📋 Transaction Status - {team.abbrev}",
|
||||||
description=f"{team.lname} • Season {get_config().sba_season}",
|
description=f"{team.lname} • Season {get_config().sba_season}",
|
||||||
color=EmbedColors.INFO
|
color=EmbedColors.INFO,
|
||||||
)
|
)
|
||||||
|
|
||||||
if hasattr(team, 'thumbnail') and team.thumbnail:
|
if hasattr(team, "thumbnail") and team.thumbnail:
|
||||||
embed.set_thumbnail(url=team.thumbnail)
|
embed.set_thumbnail(url=team.thumbnail)
|
||||||
|
|
||||||
processed_lines = [format_transaction(tx) for tx in processed_transactions[-20:]] # Last 20
|
processed_lines = [
|
||||||
|
format_transaction(tx) for tx in processed_transactions[-20:]
|
||||||
|
] # Last 20
|
||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=f"✅ Recently Processed ({len(processed_transactions[-20:])} shown)",
|
name=f"✅ Recently Processed ({len(processed_transactions[-20:])} shown)",
|
||||||
value="\n".join(processed_lines),
|
value="\n".join(processed_lines),
|
||||||
inline=False
|
inline=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
pages.append(embed)
|
pages.append(embed)
|
||||||
@ -396,18 +412,20 @@ class TransactionCommands(commands.Cog):
|
|||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"📋 Transaction Status - {team.abbrev}",
|
title=f"📋 Transaction Status - {team.abbrev}",
|
||||||
description=f"{team.lname} • Season {get_config().sba_season}",
|
description=f"{team.lname} • Season {get_config().sba_season}",
|
||||||
color=EmbedColors.INFO
|
color=EmbedColors.INFO,
|
||||||
)
|
)
|
||||||
|
|
||||||
if hasattr(team, 'thumbnail') and team.thumbnail:
|
if hasattr(team, "thumbnail") and team.thumbnail:
|
||||||
embed.set_thumbnail(url=team.thumbnail)
|
embed.set_thumbnail(url=team.thumbnail)
|
||||||
|
|
||||||
cancelled_lines = [format_transaction(tx) for tx in cancelled_transactions[-20:]] # Last 20
|
cancelled_lines = [
|
||||||
|
format_transaction(tx) for tx in cancelled_transactions[-20:]
|
||||||
|
] # Last 20
|
||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=f"❌ Cancelled Transactions ({len(cancelled_transactions[-20:])} shown)",
|
name=f"❌ Cancelled Transactions ({len(cancelled_transactions[-20:])} shown)",
|
||||||
value="\n".join(cancelled_lines),
|
value="\n".join(cancelled_lines),
|
||||||
inline=False
|
inline=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
pages.append(embed)
|
pages.append(embed)
|
||||||
@ -419,12 +437,7 @@ class TransactionCommands(commands.Cog):
|
|||||||
return pages
|
return pages
|
||||||
|
|
||||||
async def _create_legal_embed(
|
async def _create_legal_embed(
|
||||||
self,
|
self, team, current_roster, next_roster, current_validation, next_validation
|
||||||
team,
|
|
||||||
current_roster,
|
|
||||||
next_roster,
|
|
||||||
current_validation,
|
|
||||||
next_validation
|
|
||||||
) -> discord.Embed:
|
) -> discord.Embed:
|
||||||
"""Create embed showing roster legality check results."""
|
"""Create embed showing roster legality check results."""
|
||||||
|
|
||||||
@ -441,17 +454,19 @@ class TransactionCommands(commands.Cog):
|
|||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"{status_emoji} Roster Check - {team.abbrev}",
|
title=f"{status_emoji} Roster Check - {team.abbrev}",
|
||||||
description=f"{team.lname} • Season {get_config().sba_season}",
|
description=f"{team.lname} • Season {get_config().sba_season}",
|
||||||
color=embed_color
|
color=embed_color,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add team thumbnail if available
|
# Add team thumbnail if available
|
||||||
if hasattr(team, 'thumbnail') and team.thumbnail:
|
if hasattr(team, "thumbnail") and team.thumbnail:
|
||||||
embed.set_thumbnail(url=team.thumbnail)
|
embed.set_thumbnail(url=team.thumbnail)
|
||||||
|
|
||||||
# Current week roster
|
# Current week roster
|
||||||
if current_roster and current_validation:
|
if current_roster and current_validation:
|
||||||
current_lines = []
|
current_lines = []
|
||||||
current_lines.append(f"**Players:** {current_validation.active_players} active, {current_validation.il_players} IL")
|
current_lines.append(
|
||||||
|
f"**Players:** {current_validation.active_players} active, {current_validation.il_players} IL"
|
||||||
|
)
|
||||||
current_lines.append(f"**sWAR:** {current_validation.total_sWAR:.2f}")
|
current_lines.append(f"**sWAR:** {current_validation.total_sWAR:.2f}")
|
||||||
|
|
||||||
if current_validation.errors:
|
if current_validation.errors:
|
||||||
@ -460,26 +475,28 @@ class TransactionCommands(commands.Cog):
|
|||||||
current_lines.append(f"• {error}")
|
current_lines.append(f"• {error}")
|
||||||
|
|
||||||
if current_validation.warnings:
|
if current_validation.warnings:
|
||||||
current_lines.append(f"**⚠️ Warnings:** {len(current_validation.warnings)}")
|
current_lines.append(
|
||||||
|
f"**⚠️ Warnings:** {len(current_validation.warnings)}"
|
||||||
|
)
|
||||||
for warning in current_validation.warnings[:2]: # Show first 2 warnings
|
for warning in current_validation.warnings[:2]: # Show first 2 warnings
|
||||||
current_lines.append(f"• {warning}")
|
current_lines.append(f"• {warning}")
|
||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=f"{current_validation.status_emoji} Current Week",
|
name=f"{current_validation.status_emoji} Current Week",
|
||||||
value="\n".join(current_lines),
|
value="\n".join(current_lines),
|
||||||
inline=True
|
inline=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="❓ Current Week",
|
name="❓ Current Week", value="Roster data not available", inline=True
|
||||||
value="Roster data not available",
|
|
||||||
inline=True
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Next week roster
|
# Next week roster
|
||||||
if next_roster and next_validation:
|
if next_roster and next_validation:
|
||||||
next_lines = []
|
next_lines = []
|
||||||
next_lines.append(f"**Players:** {next_validation.active_players} active, {next_validation.il_players} IL")
|
next_lines.append(
|
||||||
|
f"**Players:** {next_validation.active_players} active, {next_validation.il_players} IL"
|
||||||
|
)
|
||||||
next_lines.append(f"**sWAR:** {next_validation.total_sWAR:.2f}")
|
next_lines.append(f"**sWAR:** {next_validation.total_sWAR:.2f}")
|
||||||
|
|
||||||
if next_validation.errors:
|
if next_validation.errors:
|
||||||
@ -495,27 +512,23 @@ class TransactionCommands(commands.Cog):
|
|||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=f"{next_validation.status_emoji} Next Week",
|
name=f"{next_validation.status_emoji} Next Week",
|
||||||
value="\n".join(next_lines),
|
value="\n".join(next_lines),
|
||||||
inline=True
|
inline=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="❓ Next Week",
|
name="❓ Next Week", value="Roster data not available", inline=True
|
||||||
value="Roster data not available",
|
|
||||||
inline=True
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Overall status
|
# Overall status
|
||||||
if overall_legal:
|
if overall_legal:
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Overall Status",
|
name="Overall Status", value="✅ All rosters are legal", inline=False
|
||||||
value="✅ All rosters are legal",
|
|
||||||
inline=False
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Overall Status",
|
name="Overall Status",
|
||||||
value="❌ Roster violations found - please review and correct",
|
value="❌ Roster violations found - please review and correct",
|
||||||
inline=False
|
inline=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
embed.set_footer(text="Roster validation based on current league rules")
|
embed.set_footer(text="Roster validation based on current league rules")
|
||||||
|
|||||||
@ -26,6 +26,7 @@ from services.trade_builder import (
|
|||||||
clear_trade_builder,
|
clear_trade_builder,
|
||||||
clear_trade_builder_by_team,
|
clear_trade_builder_by_team,
|
||||||
)
|
)
|
||||||
|
from services.league_service import league_service
|
||||||
from services.player_service import player_service
|
from services.player_service import player_service
|
||||||
from services.team_service import team_service
|
from services.team_service import team_service
|
||||||
from models.team import RosterType
|
from models.team import RosterType
|
||||||
@ -130,6 +131,22 @@ class TradeCommands(commands.Cog):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check trade deadline
|
||||||
|
current = await league_service.get_current_state()
|
||||||
|
if not current:
|
||||||
|
await interaction.followup.send(
|
||||||
|
"❌ Could not retrieve league state. Please try again later.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if current.is_past_trade_deadline:
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"❌ **The trade deadline has passed.** The deadline was Week {current.trade_deadline} "
|
||||||
|
f"and we are currently in Week {current.week}. No new trades can be initiated.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Clear any existing trade and create new one
|
# Clear any existing trade and create new one
|
||||||
clear_trade_builder(interaction.user.id)
|
clear_trade_builder(interaction.user.id)
|
||||||
trade_builder = get_trade_builder(interaction.user.id, user_team)
|
trade_builder = get_trade_builder(interaction.user.id, user_team)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Trade Channel Tracker
|
|||||||
|
|
||||||
Provides persistent tracking of bot-created trade discussion channels using JSON file storage.
|
Provides persistent tracking of bot-created trade discussion channels using JSON file storage.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, UTC
|
from datetime import datetime, UTC
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -12,7 +13,7 @@ import discord
|
|||||||
|
|
||||||
from utils.logging import get_contextual_logger
|
from utils.logging import get_contextual_logger
|
||||||
|
|
||||||
logger = get_contextual_logger(f'{__name__}.TradeChannelTracker')
|
logger = get_contextual_logger(f"{__name__}.TradeChannelTracker")
|
||||||
|
|
||||||
|
|
||||||
class TradeChannelTracker:
|
class TradeChannelTracker:
|
||||||
@ -26,7 +27,7 @@ class TradeChannelTracker:
|
|||||||
- Automatic stale entry removal
|
- Automatic stale entry removal
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, data_file: str = "data/trade_channels.json"):
|
def __init__(self, data_file: str = "storage/trade_channels.json"):
|
||||||
"""
|
"""
|
||||||
Initialize the trade channel tracker.
|
Initialize the trade channel tracker.
|
||||||
|
|
||||||
@ -42,9 +43,11 @@ class TradeChannelTracker:
|
|||||||
"""Load channel data from JSON file."""
|
"""Load channel data from JSON file."""
|
||||||
try:
|
try:
|
||||||
if self.data_file.exists():
|
if self.data_file.exists():
|
||||||
with open(self.data_file, 'r') as f:
|
with open(self.data_file, "r") as f:
|
||||||
self._data = json.load(f)
|
self._data = json.load(f)
|
||||||
logger.debug(f"Loaded {len(self._data.get('trade_channels', {}))} tracked trade channels")
|
logger.debug(
|
||||||
|
f"Loaded {len(self._data.get('trade_channels', {}))} tracked trade channels"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self._data = {"trade_channels": {}}
|
self._data = {"trade_channels": {}}
|
||||||
logger.info("No existing trade channel data found, starting fresh")
|
logger.info("No existing trade channel data found, starting fresh")
|
||||||
@ -55,7 +58,7 @@ class TradeChannelTracker:
|
|||||||
def save_data(self) -> None:
|
def save_data(self) -> None:
|
||||||
"""Save channel data to JSON file."""
|
"""Save channel data to JSON file."""
|
||||||
try:
|
try:
|
||||||
with open(self.data_file, 'w') as f:
|
with open(self.data_file, "w") as f:
|
||||||
json.dump(self._data, f, indent=2, default=str)
|
json.dump(self._data, f, indent=2, default=str)
|
||||||
logger.debug("Trade channel data saved successfully")
|
logger.debug("Trade channel data saved successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -67,7 +70,7 @@ class TradeChannelTracker:
|
|||||||
trade_id: str,
|
trade_id: str,
|
||||||
team1_abbrev: str,
|
team1_abbrev: str,
|
||||||
team2_abbrev: str,
|
team2_abbrev: str,
|
||||||
creator_id: int
|
creator_id: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Add a new trade channel to tracking.
|
Add a new trade channel to tracking.
|
||||||
@ -87,10 +90,12 @@ class TradeChannelTracker:
|
|||||||
"team1_abbrev": team1_abbrev,
|
"team1_abbrev": team1_abbrev,
|
||||||
"team2_abbrev": team2_abbrev,
|
"team2_abbrev": team2_abbrev,
|
||||||
"created_at": datetime.now(UTC).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"creator_id": str(creator_id)
|
"creator_id": str(creator_id),
|
||||||
}
|
}
|
||||||
self.save_data()
|
self.save_data()
|
||||||
logger.info(f"Added trade channel to tracking: {channel.name} (ID: {channel.id}, Trade: {trade_id})")
|
logger.info(
|
||||||
|
f"Added trade channel to tracking: {channel.name} (ID: {channel.id}, Trade: {trade_id})"
|
||||||
|
)
|
||||||
|
|
||||||
def remove_channel(self, channel_id: int) -> None:
|
def remove_channel(self, channel_id: int) -> None:
|
||||||
"""
|
"""
|
||||||
@ -108,7 +113,9 @@ class TradeChannelTracker:
|
|||||||
channel_name = channel_data["name"]
|
channel_name = channel_data["name"]
|
||||||
del channels[channel_key]
|
del channels[channel_key]
|
||||||
self.save_data()
|
self.save_data()
|
||||||
logger.info(f"Removed trade channel from tracking: {channel_name} (ID: {channel_id}, Trade: {trade_id})")
|
logger.info(
|
||||||
|
f"Removed trade channel from tracking: {channel_name} (ID: {channel_id}, Trade: {trade_id})"
|
||||||
|
)
|
||||||
|
|
||||||
def get_channel_by_trade_id(self, trade_id: str) -> Optional[Dict[str, Any]]:
|
def get_channel_by_trade_id(self, trade_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@ -175,7 +182,9 @@ class TradeChannelTracker:
|
|||||||
channel_name = channels[channel_id_str].get("name", "unknown")
|
channel_name = channels[channel_id_str].get("name", "unknown")
|
||||||
trade_id = channels[channel_id_str].get("trade_id", "unknown")
|
trade_id = channels[channel_id_str].get("trade_id", "unknown")
|
||||||
del channels[channel_id_str]
|
del channels[channel_id_str]
|
||||||
logger.info(f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str}, Trade: {trade_id})")
|
logger.info(
|
||||||
|
f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str}, Trade: {trade_id})"
|
||||||
|
)
|
||||||
|
|
||||||
if stale_entries:
|
if stale_entries:
|
||||||
self.save_data()
|
self.save_data()
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Voice Channel Cleanup Service
|
|||||||
|
|
||||||
Provides automatic cleanup of empty voice channels with restart resilience.
|
Provides automatic cleanup of empty voice channels with restart resilience.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
@ -12,7 +13,7 @@ from .tracker import VoiceChannelTracker
|
|||||||
from commands.gameplay.scorecard_tracker import ScorecardTracker
|
from commands.gameplay.scorecard_tracker import ScorecardTracker
|
||||||
from utils.logging import get_contextual_logger
|
from utils.logging import get_contextual_logger
|
||||||
|
|
||||||
logger = logging.getLogger(f'{__name__}.VoiceChannelCleanupService')
|
logger = logging.getLogger(f"{__name__}.VoiceChannelCleanupService")
|
||||||
|
|
||||||
|
|
||||||
class VoiceChannelCleanupService:
|
class VoiceChannelCleanupService:
|
||||||
@ -27,7 +28,9 @@ class VoiceChannelCleanupService:
|
|||||||
- Automatic scorecard unpublishing when voice channel is cleaned up
|
- Automatic scorecard unpublishing when voice channel is cleaned up
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, bot: commands.Bot, data_file: str = "data/voice_channels.json"):
|
def __init__(
|
||||||
|
self, bot: commands.Bot, data_file: str = "storage/voice_channels.json"
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the cleanup service.
|
Initialize the cleanup service.
|
||||||
|
|
||||||
@ -36,10 +39,10 @@ class VoiceChannelCleanupService:
|
|||||||
data_file: Path to the JSON data file for persistence
|
data_file: Path to the JSON data file for persistence
|
||||||
"""
|
"""
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.logger = get_contextual_logger(f'{__name__}.VoiceChannelCleanupService')
|
self.logger = get_contextual_logger(f"{__name__}.VoiceChannelCleanupService")
|
||||||
self.tracker = VoiceChannelTracker(data_file)
|
self.tracker = VoiceChannelTracker(data_file)
|
||||||
self.scorecard_tracker = ScorecardTracker()
|
self.scorecard_tracker = ScorecardTracker()
|
||||||
self.empty_threshold = 5 # Delete after 5 minutes empty
|
self.empty_threshold = 5 # Delete after 5 minutes empty
|
||||||
|
|
||||||
# Start the cleanup task - @before_loop will wait for bot readiness
|
# Start the cleanup task - @before_loop will wait for bot readiness
|
||||||
self.cleanup_loop.start()
|
self.cleanup_loop.start()
|
||||||
@ -90,13 +93,17 @@ class VoiceChannelCleanupService:
|
|||||||
|
|
||||||
guild = bot.get_guild(guild_id)
|
guild = bot.get_guild(guild_id)
|
||||||
if not guild:
|
if not guild:
|
||||||
self.logger.warning(f"Guild {guild_id} not found, removing channel {channel_data['name']}")
|
self.logger.warning(
|
||||||
|
f"Guild {guild_id} not found, removing channel {channel_data['name']}"
|
||||||
|
)
|
||||||
channels_to_remove.append(channel_id)
|
channels_to_remove.append(channel_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
channel = guild.get_channel(channel_id)
|
channel = guild.get_channel(channel_id)
|
||||||
if not channel:
|
if not channel:
|
||||||
self.logger.warning(f"Channel {channel_data['name']} (ID: {channel_id}) no longer exists")
|
self.logger.warning(
|
||||||
|
f"Channel {channel_data['name']} (ID: {channel_id}) no longer exists"
|
||||||
|
)
|
||||||
channels_to_remove.append(channel_id)
|
channels_to_remove.append(channel_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -121,18 +128,26 @@ class VoiceChannelCleanupService:
|
|||||||
if channel_data and channel_data.get("text_channel_id"):
|
if channel_data and channel_data.get("text_channel_id"):
|
||||||
try:
|
try:
|
||||||
text_channel_id_int = int(channel_data["text_channel_id"])
|
text_channel_id_int = int(channel_data["text_channel_id"])
|
||||||
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
|
was_unpublished = await self.scorecard_tracker.unpublish_scorecard(
|
||||||
|
text_channel_id_int
|
||||||
|
)
|
||||||
if was_unpublished:
|
if was_unpublished:
|
||||||
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel)")
|
self.logger.info(
|
||||||
|
f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel)"
|
||||||
|
)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
self.logger.warning(f"Invalid text_channel_id in stale voice channel data: {e}")
|
self.logger.warning(
|
||||||
|
f"Invalid text_channel_id in stale voice channel data: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Also clean up any additional stale entries
|
# Also clean up any additional stale entries
|
||||||
stale_removed = self.tracker.cleanup_stale_entries(valid_channel_ids)
|
stale_removed = self.tracker.cleanup_stale_entries(valid_channel_ids)
|
||||||
total_removed = len(channels_to_remove) + stale_removed
|
total_removed = len(channels_to_remove) + stale_removed
|
||||||
|
|
||||||
if total_removed > 0:
|
if total_removed > 0:
|
||||||
self.logger.info(f"Cleaned up {total_removed} stale channel tracking entries")
|
self.logger.info(
|
||||||
|
f"Cleaned up {total_removed} stale channel tracking entries"
|
||||||
|
)
|
||||||
|
|
||||||
self.logger.info(f"Verified {len(valid_channel_ids)} valid tracked channels")
|
self.logger.info(f"Verified {len(valid_channel_ids)} valid tracked channels")
|
||||||
|
|
||||||
@ -149,10 +164,14 @@ class VoiceChannelCleanupService:
|
|||||||
await self.update_all_channel_statuses(bot)
|
await self.update_all_channel_statuses(bot)
|
||||||
|
|
||||||
# Get channels ready for cleanup
|
# Get channels ready for cleanup
|
||||||
channels_for_cleanup = self.tracker.get_channels_for_cleanup(self.empty_threshold)
|
channels_for_cleanup = self.tracker.get_channels_for_cleanup(
|
||||||
|
self.empty_threshold
|
||||||
|
)
|
||||||
|
|
||||||
if channels_for_cleanup:
|
if channels_for_cleanup:
|
||||||
self.logger.info(f"Found {len(channels_for_cleanup)} channels ready for cleanup")
|
self.logger.info(
|
||||||
|
f"Found {len(channels_for_cleanup)} channels ready for cleanup"
|
||||||
|
)
|
||||||
|
|
||||||
# Delete empty channels
|
# Delete empty channels
|
||||||
for channel_data in channels_for_cleanup:
|
for channel_data in channels_for_cleanup:
|
||||||
@ -182,12 +201,16 @@ class VoiceChannelCleanupService:
|
|||||||
|
|
||||||
guild = bot.get_guild(guild_id)
|
guild = bot.get_guild(guild_id)
|
||||||
if not guild:
|
if not guild:
|
||||||
self.logger.debug(f"Guild {guild_id} not found for channel {channel_data['name']}")
|
self.logger.debug(
|
||||||
|
f"Guild {guild_id} not found for channel {channel_data['name']}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
channel = guild.get_channel(channel_id)
|
channel = guild.get_channel(channel_id)
|
||||||
if not channel:
|
if not channel:
|
||||||
self.logger.debug(f"Channel {channel_data['name']} no longer exists, removing from tracking")
|
self.logger.debug(
|
||||||
|
f"Channel {channel_data['name']} no longer exists, removing from tracking"
|
||||||
|
)
|
||||||
self.tracker.remove_channel(channel_id)
|
self.tracker.remove_channel(channel_id)
|
||||||
|
|
||||||
# Unpublish associated scorecard if it exists
|
# Unpublish associated scorecard if it exists
|
||||||
@ -195,17 +218,27 @@ class VoiceChannelCleanupService:
|
|||||||
if text_channel_id:
|
if text_channel_id:
|
||||||
try:
|
try:
|
||||||
text_channel_id_int = int(text_channel_id)
|
text_channel_id_int = int(text_channel_id)
|
||||||
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
|
was_unpublished = (
|
||||||
|
await self.scorecard_tracker.unpublish_scorecard(
|
||||||
|
text_channel_id_int
|
||||||
|
)
|
||||||
|
)
|
||||||
if was_unpublished:
|
if was_unpublished:
|
||||||
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (manually deleted voice channel)")
|
self.logger.info(
|
||||||
|
f"📋 Unpublished scorecard from text channel {text_channel_id_int} (manually deleted voice channel)"
|
||||||
|
)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
self.logger.warning(f"Invalid text_channel_id in manually deleted voice channel data: {e}")
|
self.logger.warning(
|
||||||
|
f"Invalid text_channel_id in manually deleted voice channel data: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Ensure it's a voice channel before checking members
|
# Ensure it's a voice channel before checking members
|
||||||
if not isinstance(channel, discord.VoiceChannel):
|
if not isinstance(channel, discord.VoiceChannel):
|
||||||
self.logger.warning(f"Channel {channel_data['name']} is not a voice channel, removing from tracking")
|
self.logger.warning(
|
||||||
|
f"Channel {channel_data['name']} is not a voice channel, removing from tracking"
|
||||||
|
)
|
||||||
self.tracker.remove_channel(channel_id)
|
self.tracker.remove_channel(channel_id)
|
||||||
|
|
||||||
# Unpublish associated scorecard if it exists
|
# Unpublish associated scorecard if it exists
|
||||||
@ -213,11 +246,19 @@ class VoiceChannelCleanupService:
|
|||||||
if text_channel_id:
|
if text_channel_id:
|
||||||
try:
|
try:
|
||||||
text_channel_id_int = int(text_channel_id)
|
text_channel_id_int = int(text_channel_id)
|
||||||
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
|
was_unpublished = (
|
||||||
|
await self.scorecard_tracker.unpublish_scorecard(
|
||||||
|
text_channel_id_int
|
||||||
|
)
|
||||||
|
)
|
||||||
if was_unpublished:
|
if was_unpublished:
|
||||||
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (wrong channel type)")
|
self.logger.info(
|
||||||
|
f"📋 Unpublished scorecard from text channel {text_channel_id_int} (wrong channel type)"
|
||||||
|
)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
self.logger.warning(f"Invalid text_channel_id in wrong channel type data: {e}")
|
self.logger.warning(
|
||||||
|
f"Invalid text_channel_id in wrong channel type data: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -225,11 +266,15 @@ class VoiceChannelCleanupService:
|
|||||||
is_empty = len(channel.members) == 0
|
is_empty = len(channel.members) == 0
|
||||||
self.tracker.update_channel_status(channel_id, is_empty)
|
self.tracker.update_channel_status(channel_id, is_empty)
|
||||||
|
|
||||||
self.logger.debug(f"Channel {channel_data['name']}: {'empty' if is_empty else 'occupied'} "
|
self.logger.debug(
|
||||||
f"({len(channel.members)} members)")
|
f"Channel {channel_data['name']}: {'empty' if is_empty else 'occupied'} "
|
||||||
|
f"({len(channel.members)} members)"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error checking channel status for {channel_data.get('name', 'unknown')}: {e}")
|
self.logger.error(
|
||||||
|
f"Error checking channel status for {channel_data.get('name', 'unknown')}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
async def cleanup_channel(self, bot: commands.Bot, channel_data: dict) -> None:
|
async def cleanup_channel(self, bot: commands.Bot, channel_data: dict) -> None:
|
||||||
"""
|
"""
|
||||||
@ -246,25 +291,33 @@ class VoiceChannelCleanupService:
|
|||||||
|
|
||||||
guild = bot.get_guild(guild_id)
|
guild = bot.get_guild(guild_id)
|
||||||
if not guild:
|
if not guild:
|
||||||
self.logger.info(f"Guild {guild_id} not found, removing tracking for {channel_name}")
|
self.logger.info(
|
||||||
|
f"Guild {guild_id} not found, removing tracking for {channel_name}"
|
||||||
|
)
|
||||||
self.tracker.remove_channel(channel_id)
|
self.tracker.remove_channel(channel_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
channel = guild.get_channel(channel_id)
|
channel = guild.get_channel(channel_id)
|
||||||
if not channel:
|
if not channel:
|
||||||
self.logger.info(f"Channel {channel_name} already deleted, removing from tracking")
|
self.logger.info(
|
||||||
|
f"Channel {channel_name} already deleted, removing from tracking"
|
||||||
|
)
|
||||||
self.tracker.remove_channel(channel_id)
|
self.tracker.remove_channel(channel_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Ensure it's a voice channel before checking members
|
# Ensure it's a voice channel before checking members
|
||||||
if not isinstance(channel, discord.VoiceChannel):
|
if not isinstance(channel, discord.VoiceChannel):
|
||||||
self.logger.warning(f"Channel {channel_name} is not a voice channel, removing from tracking")
|
self.logger.warning(
|
||||||
|
f"Channel {channel_name} is not a voice channel, removing from tracking"
|
||||||
|
)
|
||||||
self.tracker.remove_channel(channel_id)
|
self.tracker.remove_channel(channel_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Final check: make sure channel is still empty before deleting
|
# Final check: make sure channel is still empty before deleting
|
||||||
if len(channel.members) > 0:
|
if len(channel.members) > 0:
|
||||||
self.logger.info(f"Channel {channel_name} is no longer empty, skipping cleanup")
|
self.logger.info(
|
||||||
|
f"Channel {channel_name} is no longer empty, skipping cleanup"
|
||||||
|
)
|
||||||
self.tracker.update_channel_status(channel_id, False)
|
self.tracker.update_channel_status(channel_id, False)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -272,24 +325,36 @@ class VoiceChannelCleanupService:
|
|||||||
await channel.delete(reason="Automatic cleanup - empty for 5+ minutes")
|
await channel.delete(reason="Automatic cleanup - empty for 5+ minutes")
|
||||||
self.tracker.remove_channel(channel_id)
|
self.tracker.remove_channel(channel_id)
|
||||||
|
|
||||||
self.logger.info(f"✅ Cleaned up empty voice channel: {channel_name} (ID: {channel_id})")
|
self.logger.info(
|
||||||
|
f"✅ Cleaned up empty voice channel: {channel_name} (ID: {channel_id})"
|
||||||
|
)
|
||||||
|
|
||||||
# Unpublish associated scorecard if it exists
|
# Unpublish associated scorecard if it exists
|
||||||
text_channel_id = channel_data.get("text_channel_id")
|
text_channel_id = channel_data.get("text_channel_id")
|
||||||
if text_channel_id:
|
if text_channel_id:
|
||||||
try:
|
try:
|
||||||
text_channel_id_int = int(text_channel_id)
|
text_channel_id_int = int(text_channel_id)
|
||||||
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
|
was_unpublished = await self.scorecard_tracker.unpublish_scorecard(
|
||||||
|
text_channel_id_int
|
||||||
|
)
|
||||||
if was_unpublished:
|
if was_unpublished:
|
||||||
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (voice channel cleanup)")
|
self.logger.info(
|
||||||
|
f"📋 Unpublished scorecard from text channel {text_channel_id_int} (voice channel cleanup)"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.logger.debug(f"No scorecard found for text channel {text_channel_id_int}")
|
self.logger.debug(
|
||||||
|
f"No scorecard found for text channel {text_channel_id_int}"
|
||||||
|
)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
self.logger.warning(f"Invalid text_channel_id in voice channel data: {e}")
|
self.logger.warning(
|
||||||
|
f"Invalid text_channel_id in voice channel data: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
except discord.NotFound:
|
except discord.NotFound:
|
||||||
# Channel was already deleted
|
# Channel was already deleted
|
||||||
self.logger.info(f"Channel {channel_data.get('name', 'unknown')} was already deleted")
|
self.logger.info(
|
||||||
|
f"Channel {channel_data.get('name', 'unknown')} was already deleted"
|
||||||
|
)
|
||||||
self.tracker.remove_channel(int(channel_data["channel_id"]))
|
self.tracker.remove_channel(int(channel_data["channel_id"]))
|
||||||
|
|
||||||
# Still try to unpublish associated scorecard
|
# Still try to unpublish associated scorecard
|
||||||
@ -297,15 +362,25 @@ class VoiceChannelCleanupService:
|
|||||||
if text_channel_id:
|
if text_channel_id:
|
||||||
try:
|
try:
|
||||||
text_channel_id_int = int(text_channel_id)
|
text_channel_id_int = int(text_channel_id)
|
||||||
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
|
was_unpublished = await self.scorecard_tracker.unpublish_scorecard(
|
||||||
|
text_channel_id_int
|
||||||
|
)
|
||||||
if was_unpublished:
|
if was_unpublished:
|
||||||
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel cleanup)")
|
self.logger.info(
|
||||||
|
f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel cleanup)"
|
||||||
|
)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
self.logger.warning(f"Invalid text_channel_id in voice channel data: {e}")
|
self.logger.warning(
|
||||||
|
f"Invalid text_channel_id in voice channel data: {e}"
|
||||||
|
)
|
||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
self.logger.error(f"Missing permissions to delete channel {channel_data.get('name', 'unknown')}")
|
self.logger.error(
|
||||||
|
f"Missing permissions to delete channel {channel_data.get('name', 'unknown')}"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error cleaning up channel {channel_data.get('name', 'unknown')}: {e}")
|
self.logger.error(
|
||||||
|
f"Error cleaning up channel {channel_data.get('name', 'unknown')}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
def get_tracker(self) -> VoiceChannelTracker:
|
def get_tracker(self) -> VoiceChannelTracker:
|
||||||
"""
|
"""
|
||||||
@ -330,7 +405,7 @@ class VoiceChannelCleanupService:
|
|||||||
"running": self.cleanup_loop.is_running(),
|
"running": self.cleanup_loop.is_running(),
|
||||||
"total_tracked": len(all_channels),
|
"total_tracked": len(all_channels),
|
||||||
"empty_channels": len(empty_channels),
|
"empty_channels": len(empty_channels),
|
||||||
"empty_threshold": self.empty_threshold
|
"empty_threshold": self.empty_threshold,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Voice Channel Tracker
|
|||||||
|
|
||||||
Provides persistent tracking of bot-created voice channels using JSON file storage.
|
Provides persistent tracking of bot-created voice channels using JSON file storage.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, UTC
|
from datetime import datetime, timedelta, UTC
|
||||||
@ -11,7 +12,7 @@ from typing import Dict, List, Optional, Any
|
|||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
logger = logging.getLogger(f'{__name__}.VoiceChannelTracker')
|
logger = logging.getLogger(f"{__name__}.VoiceChannelTracker")
|
||||||
|
|
||||||
|
|
||||||
class VoiceChannelTracker:
|
class VoiceChannelTracker:
|
||||||
@ -25,7 +26,7 @@ class VoiceChannelTracker:
|
|||||||
- Automatic stale entry removal
|
- Automatic stale entry removal
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, data_file: str = "data/voice_channels.json"):
|
def __init__(self, data_file: str = "storage/voice_channels.json"):
|
||||||
"""
|
"""
|
||||||
Initialize the voice channel tracker.
|
Initialize the voice channel tracker.
|
||||||
|
|
||||||
@ -41,9 +42,11 @@ class VoiceChannelTracker:
|
|||||||
"""Load channel data from JSON file."""
|
"""Load channel data from JSON file."""
|
||||||
try:
|
try:
|
||||||
if self.data_file.exists():
|
if self.data_file.exists():
|
||||||
with open(self.data_file, 'r') as f:
|
with open(self.data_file, "r") as f:
|
||||||
self._data = json.load(f)
|
self._data = json.load(f)
|
||||||
logger.debug(f"Loaded {len(self._data.get('voice_channels', {}))} tracked channels")
|
logger.debug(
|
||||||
|
f"Loaded {len(self._data.get('voice_channels', {}))} tracked channels"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self._data = {"voice_channels": {}}
|
self._data = {"voice_channels": {}}
|
||||||
logger.info("No existing voice channel data found, starting fresh")
|
logger.info("No existing voice channel data found, starting fresh")
|
||||||
@ -54,7 +57,7 @@ class VoiceChannelTracker:
|
|||||||
def save_data(self) -> None:
|
def save_data(self) -> None:
|
||||||
"""Save channel data to JSON file."""
|
"""Save channel data to JSON file."""
|
||||||
try:
|
try:
|
||||||
with open(self.data_file, 'w') as f:
|
with open(self.data_file, "w") as f:
|
||||||
json.dump(self._data, f, indent=2, default=str)
|
json.dump(self._data, f, indent=2, default=str)
|
||||||
logger.debug("Voice channel data saved successfully")
|
logger.debug("Voice channel data saved successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -65,7 +68,7 @@ class VoiceChannelTracker:
|
|||||||
channel: discord.VoiceChannel,
|
channel: discord.VoiceChannel,
|
||||||
channel_type: str,
|
channel_type: str,
|
||||||
creator_id: int,
|
creator_id: int,
|
||||||
text_channel_id: Optional[int] = None
|
text_channel_id: Optional[int] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Add a new channel to tracking.
|
Add a new channel to tracking.
|
||||||
@ -85,7 +88,7 @@ class VoiceChannelTracker:
|
|||||||
"last_checked": datetime.now(UTC).isoformat(),
|
"last_checked": datetime.now(UTC).isoformat(),
|
||||||
"empty_since": None,
|
"empty_since": None,
|
||||||
"creator_id": str(creator_id),
|
"creator_id": str(creator_id),
|
||||||
"text_channel_id": str(text_channel_id) if text_channel_id else None
|
"text_channel_id": str(text_channel_id) if text_channel_id else None,
|
||||||
}
|
}
|
||||||
self.save_data()
|
self.save_data()
|
||||||
logger.info(f"Added channel to tracking: {channel.name} (ID: {channel.id})")
|
logger.info(f"Added channel to tracking: {channel.name} (ID: {channel.id})")
|
||||||
@ -130,9 +133,13 @@ class VoiceChannelTracker:
|
|||||||
channel_name = channels[channel_key]["name"]
|
channel_name = channels[channel_key]["name"]
|
||||||
del channels[channel_key]
|
del channels[channel_key]
|
||||||
self.save_data()
|
self.save_data()
|
||||||
logger.info(f"Removed channel from tracking: {channel_name} (ID: {channel_id})")
|
logger.info(
|
||||||
|
f"Removed channel from tracking: {channel_name} (ID: {channel_id})"
|
||||||
|
)
|
||||||
|
|
||||||
def get_channels_for_cleanup(self, empty_threshold_minutes: int = 15) -> List[Dict[str, Any]]:
|
def get_channels_for_cleanup(
|
||||||
|
self, empty_threshold_minutes: int = 15
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get channels that should be deleted based on empty duration.
|
Get channels that should be deleted based on empty duration.
|
||||||
|
|
||||||
@ -153,10 +160,12 @@ class VoiceChannelTracker:
|
|||||||
# Parse empty_since timestamp
|
# Parse empty_since timestamp
|
||||||
empty_since_str = channel_data["empty_since"]
|
empty_since_str = channel_data["empty_since"]
|
||||||
# Handle both with and without timezone info
|
# Handle both with and without timezone info
|
||||||
if empty_since_str.endswith('Z'):
|
if empty_since_str.endswith("Z"):
|
||||||
empty_since_str = empty_since_str[:-1] + '+00:00'
|
empty_since_str = empty_since_str[:-1] + "+00:00"
|
||||||
|
|
||||||
empty_since = datetime.fromisoformat(empty_since_str.replace('Z', '+00:00'))
|
empty_since = datetime.fromisoformat(
|
||||||
|
empty_since_str.replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
|
||||||
# Remove timezone info for comparison (both times are UTC)
|
# Remove timezone info for comparison (both times are UTC)
|
||||||
if empty_since.tzinfo:
|
if empty_since.tzinfo:
|
||||||
@ -164,10 +173,14 @@ class VoiceChannelTracker:
|
|||||||
|
|
||||||
if empty_since <= cutoff_time:
|
if empty_since <= cutoff_time:
|
||||||
cleanup_candidates.append(channel_data)
|
cleanup_candidates.append(channel_data)
|
||||||
logger.debug(f"Channel {channel_data['name']} ready for cleanup (empty since {empty_since})")
|
logger.debug(
|
||||||
|
f"Channel {channel_data['name']} ready for cleanup (empty since {empty_since})"
|
||||||
|
)
|
||||||
|
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
logger.warning(f"Invalid timestamp for channel {channel_data.get('name', 'unknown')}: {e}")
|
logger.warning(
|
||||||
|
f"Invalid timestamp for channel {channel_data.get('name', 'unknown')}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
return cleanup_candidates
|
return cleanup_candidates
|
||||||
|
|
||||||
@ -242,7 +255,9 @@ class VoiceChannelTracker:
|
|||||||
for channel_id_str in stale_entries:
|
for channel_id_str in stale_entries:
|
||||||
channel_name = channels[channel_id_str].get("name", "unknown")
|
channel_name = channels[channel_id_str].get("name", "unknown")
|
||||||
del channels[channel_id_str]
|
del channels[channel_id_str]
|
||||||
logger.info(f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str})")
|
logger.info(
|
||||||
|
f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str})"
|
||||||
|
)
|
||||||
|
|
||||||
if stale_entries:
|
if stale_entries:
|
||||||
self.save_data()
|
self.save_data()
|
||||||
|
|||||||
@ -36,8 +36,11 @@ services:
|
|||||||
|
|
||||||
# Volume mounts
|
# Volume mounts
|
||||||
volumes:
|
volumes:
|
||||||
# Google Sheets credentials (required)
|
# Google Sheets credentials (read-only, file mount)
|
||||||
- ${SHEETS_CREDENTIALS_HOST_PATH:-./data}:/app/data:ro
|
- ${SHEETS_CREDENTIALS_HOST_PATH:-./data/major-domo-service-creds.json}:/app/data/major-domo-service-creds.json:ro
|
||||||
|
|
||||||
|
# Runtime state files (writable) - scorecards, voice channels, trade channels, soak data
|
||||||
|
- ${STATE_HOST_PATH:-./storage}:/app/storage:rw
|
||||||
|
|
||||||
# Logs directory (persistent) - mounted to /app/logs where the application expects it
|
# Logs directory (persistent) - mounted to /app/logs where the application expects it
|
||||||
- ${LOGS_HOST_PATH:-./logs}:/app/logs:rw
|
- ${LOGS_HOST_PATH:-./logs}:/app/logs:rw
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Current league state model
|
|||||||
|
|
||||||
Represents the current state of the league including week, season, and settings.
|
Represents the current state of the league including week, season, and settings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
from models.base import SBABaseModel
|
from models.base import SBABaseModel
|
||||||
@ -14,7 +15,7 @@ class Current(SBABaseModel):
|
|||||||
week: int = Field(69, description="Current week number")
|
week: int = Field(69, description="Current week number")
|
||||||
season: int = Field(69, description="Current season number")
|
season: int = Field(69, description="Current season number")
|
||||||
freeze: bool = Field(True, description="Whether league is frozen")
|
freeze: bool = Field(True, description="Whether league is frozen")
|
||||||
bet_week: str = Field('sheets', description="Betting week identifier")
|
bet_week: str = Field("sheets", description="Betting week identifier")
|
||||||
trade_deadline: int = Field(1, description="Trade deadline week")
|
trade_deadline: int = Field(1, description="Trade deadline week")
|
||||||
pick_trade_start: int = Field(69, description="Draft pick trading start week")
|
pick_trade_start: int = Field(69, description="Draft pick trading start week")
|
||||||
pick_trade_end: int = Field(420, description="Draft pick trading end week")
|
pick_trade_end: int = Field(420, description="Draft pick trading end week")
|
||||||
@ -24,7 +25,7 @@ class Current(SBABaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def cast_bet_week_to_string(cls, v):
|
def cast_bet_week_to_string(cls, v):
|
||||||
"""Ensure bet_week is always a string."""
|
"""Ensure bet_week is always a string."""
|
||||||
return str(v) if v is not None else 'sheets'
|
return str(v) if v is not None else "sheets"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_offseason(self) -> bool:
|
def is_offseason(self) -> bool:
|
||||||
@ -45,3 +46,10 @@ class Current(SBABaseModel):
|
|||||||
def ever_trade_picks(self) -> bool:
|
def ever_trade_picks(self) -> bool:
|
||||||
"""Check if draft pick trading is allowed this season at all"""
|
"""Check if draft pick trading is allowed this season at all"""
|
||||||
return self.pick_trade_start <= self.playoffs_begin + 4
|
return self.pick_trade_start <= self.playoffs_begin + 4
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_past_trade_deadline(self) -> bool:
|
||||||
|
"""Check if the trade deadline has passed."""
|
||||||
|
if self.is_offseason:
|
||||||
|
return False
|
||||||
|
return self.week > self.trade_deadline
|
||||||
|
|||||||
@ -4,6 +4,7 @@ Chart Service for managing gameplay charts and infographics.
|
|||||||
This service handles loading, saving, and managing chart definitions
|
This service handles loading, saving, and managing chart definitions
|
||||||
from the JSON configuration file.
|
from the JSON configuration file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -18,6 +19,7 @@ logger = logging.getLogger(__name__)
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Chart:
|
class Chart:
|
||||||
"""Represents a gameplay chart or infographic."""
|
"""Represents a gameplay chart or infographic."""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
name: str
|
name: str
|
||||||
category: str
|
category: str
|
||||||
@ -27,17 +29,17 @@ class Chart:
|
|||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert chart to dictionary (excluding key)."""
|
"""Convert chart to dictionary (excluding key)."""
|
||||||
return {
|
return {
|
||||||
'name': self.name,
|
"name": self.name,
|
||||||
'category': self.category,
|
"category": self.category,
|
||||||
'description': self.description,
|
"description": self.description,
|
||||||
'urls': self.urls
|
"urls": self.urls,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ChartService:
|
class ChartService:
|
||||||
"""Service for managing gameplay charts and infographics."""
|
"""Service for managing gameplay charts and infographics."""
|
||||||
|
|
||||||
CHARTS_FILE = Path(__file__).parent.parent / 'data' / 'charts.json'
|
CHARTS_FILE = Path(__file__).parent.parent / "storage" / "charts.json"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the chart service."""
|
"""Initialize the chart service."""
|
||||||
@ -54,21 +56,21 @@ class ChartService:
|
|||||||
self._categories = {}
|
self._categories = {}
|
||||||
return
|
return
|
||||||
|
|
||||||
with open(self.CHARTS_FILE, 'r') as f:
|
with open(self.CHARTS_FILE, "r") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
# Load categories
|
# Load categories
|
||||||
self._categories = data.get('categories', {})
|
self._categories = data.get("categories", {})
|
||||||
|
|
||||||
# Load charts
|
# Load charts
|
||||||
charts_data = data.get('charts', {})
|
charts_data = data.get("charts", {})
|
||||||
for key, chart_data in charts_data.items():
|
for key, chart_data in charts_data.items():
|
||||||
self._charts[key] = Chart(
|
self._charts[key] = Chart(
|
||||||
key=key,
|
key=key,
|
||||||
name=chart_data['name'],
|
name=chart_data["name"],
|
||||||
category=chart_data['category'],
|
category=chart_data["category"],
|
||||||
description=chart_data.get('description', ''),
|
description=chart_data.get("description", ""),
|
||||||
urls=chart_data.get('urls', [])
|
urls=chart_data.get("urls", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Loaded {len(self._charts)} charts from {self.CHARTS_FILE}")
|
logger.info(f"Loaded {len(self._charts)} charts from {self.CHARTS_FILE}")
|
||||||
@ -81,20 +83,17 @@ class ChartService:
|
|||||||
def _save_charts(self) -> None:
|
def _save_charts(self) -> None:
|
||||||
"""Save charts to JSON file."""
|
"""Save charts to JSON file."""
|
||||||
try:
|
try:
|
||||||
# Ensure data directory exists
|
# Ensure storage directory exists
|
||||||
self.CHARTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
self.CHARTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Build data structure
|
# Build data structure
|
||||||
data = {
|
data = {
|
||||||
'charts': {
|
"charts": {key: chart.to_dict() for key, chart in self._charts.items()},
|
||||||
key: chart.to_dict()
|
"categories": self._categories,
|
||||||
for key, chart in self._charts.items()
|
|
||||||
},
|
|
||||||
'categories': self._categories
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Write to file
|
# Write to file
|
||||||
with open(self.CHARTS_FILE, 'w') as f:
|
with open(self.CHARTS_FILE, "w") as f:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
logger.info(f"Saved {len(self._charts)} charts to {self.CHARTS_FILE}")
|
logger.info(f"Saved {len(self._charts)} charts to {self.CHARTS_FILE}")
|
||||||
@ -134,10 +133,7 @@ class ChartService:
|
|||||||
Returns:
|
Returns:
|
||||||
List of charts in the specified category
|
List of charts in the specified category
|
||||||
"""
|
"""
|
||||||
return [
|
return [chart for chart in self._charts.values() if chart.category == category]
|
||||||
chart for chart in self._charts.values()
|
|
||||||
if chart.category == category
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_chart_keys(self) -> List[str]:
|
def get_chart_keys(self) -> List[str]:
|
||||||
"""
|
"""
|
||||||
@ -157,8 +153,9 @@ class ChartService:
|
|||||||
"""
|
"""
|
||||||
return self._categories.copy()
|
return self._categories.copy()
|
||||||
|
|
||||||
def add_chart(self, key: str, name: str, category: str,
|
def add_chart(
|
||||||
urls: List[str], description: str = "") -> None:
|
self, key: str, name: str, category: str, urls: List[str], description: str = ""
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Add a new chart.
|
Add a new chart.
|
||||||
|
|
||||||
@ -176,18 +173,19 @@ class ChartService:
|
|||||||
raise BotException(f"Chart '{key}' already exists")
|
raise BotException(f"Chart '{key}' already exists")
|
||||||
|
|
||||||
self._charts[key] = Chart(
|
self._charts[key] = Chart(
|
||||||
key=key,
|
key=key, name=name, category=category, description=description, urls=urls
|
||||||
name=name,
|
|
||||||
category=category,
|
|
||||||
description=description,
|
|
||||||
urls=urls
|
|
||||||
)
|
)
|
||||||
self._save_charts()
|
self._save_charts()
|
||||||
logger.info(f"Added chart: {key}")
|
logger.info(f"Added chart: {key}")
|
||||||
|
|
||||||
def update_chart(self, key: str, name: Optional[str] = None,
|
def update_chart(
|
||||||
category: Optional[str] = None, urls: Optional[List[str]] = None,
|
self,
|
||||||
description: Optional[str] = None) -> None:
|
key: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
urls: Optional[List[str]] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Update an existing chart.
|
Update an existing chart.
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ Custom Commands Service for Discord Bot v2.0
|
|||||||
Modern async service layer for managing custom commands with full type safety.
|
Modern async service layer for managing custom commands with full type safety.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import math
|
import math
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Optional, List, Any, Tuple
|
from typing import Optional, List, Any, Tuple
|
||||||
@ -119,8 +120,8 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
|||||||
content_length=len(content),
|
content_length=len(content),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return full command with creator info
|
# Return command with creator info (use POST response directly)
|
||||||
return await self.get_command_by_name(name)
|
return result.model_copy(update={"creator": creator})
|
||||||
|
|
||||||
async def get_command_by_name(self, name: str) -> CustomCommand:
|
async def get_command_by_name(self, name: str) -> CustomCommand:
|
||||||
"""
|
"""
|
||||||
@ -217,7 +218,8 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
|||||||
new_content_length=len(new_content),
|
new_content_length=len(new_content),
|
||||||
)
|
)
|
||||||
|
|
||||||
return await self.get_command_by_name(name)
|
# Return updated command with creator info (use PUT response directly)
|
||||||
|
return result.model_copy(update={"creator": command.creator})
|
||||||
|
|
||||||
async def delete_command(
|
async def delete_command(
|
||||||
self, name: str, deleter_discord_id: int, force: bool = False
|
self, name: str, deleter_discord_id: int, force: bool = False
|
||||||
@ -466,21 +468,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
|||||||
|
|
||||||
commands_data = await self.get_items_with_params(params)
|
commands_data = await self.get_items_with_params(params)
|
||||||
|
|
||||||
|
creators = await asyncio.gather(
|
||||||
|
*[
|
||||||
|
self.get_creator_by_id(cmd_data.creator_id)
|
||||||
|
for cmd_data in commands_data
|
||||||
|
],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
commands = []
|
commands = []
|
||||||
for cmd_data in commands_data:
|
for cmd_data, creator in zip(commands_data, creators):
|
||||||
try:
|
if isinstance(creator, BotException):
|
||||||
creator = await self.get_creator_by_id(cmd_data.creator_id)
|
|
||||||
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
|
||||||
except BotException as e:
|
|
||||||
# Handle missing creator gracefully
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Skipping popular command with missing creator",
|
"Skipping popular command with missing creator",
|
||||||
command_id=cmd_data.id,
|
command_id=cmd_data.id,
|
||||||
command_name=cmd_data.name,
|
command_name=cmd_data.name,
|
||||||
creator_id=cmd_data.creator_id,
|
creator_id=cmd_data.creator_id,
|
||||||
error=str(e),
|
error=str(creator),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
if isinstance(creator, BaseException):
|
||||||
|
raise creator
|
||||||
|
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
||||||
|
|
||||||
return commands
|
return commands
|
||||||
|
|
||||||
@ -536,7 +545,9 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
|||||||
# Update username if it changed
|
# Update username if it changed
|
||||||
if creator.username != username or creator.display_name != display_name:
|
if creator.username != username or creator.display_name != display_name:
|
||||||
await self._update_creator_info(creator.id, username, display_name)
|
await self._update_creator_info(creator.id, username, display_name)
|
||||||
creator = await self.get_creator_by_discord_id(discord_id)
|
creator = creator.model_copy(
|
||||||
|
update={"username": username, "display_name": display_name}
|
||||||
|
)
|
||||||
return creator
|
return creator
|
||||||
except BotException:
|
except BotException:
|
||||||
# Creator doesn't exist, create new one
|
# Creator doesn't exist, create new one
|
||||||
@ -557,7 +568,8 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
|||||||
if not result:
|
if not result:
|
||||||
raise BotException("Failed to create command creator")
|
raise BotException("Failed to create command creator")
|
||||||
|
|
||||||
return await self.get_creator_by_discord_id(discord_id)
|
# Return created creator directly from POST response
|
||||||
|
return CustomCommandCreator(**result)
|
||||||
|
|
||||||
async def get_creator_by_discord_id(self, discord_id: int) -> CustomCommandCreator:
|
async def get_creator_by_discord_id(self, discord_id: int) -> CustomCommandCreator:
|
||||||
"""Get creator by Discord ID.
|
"""Get creator by Discord ID.
|
||||||
@ -610,31 +622,34 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
|||||||
|
|
||||||
async def get_statistics(self) -> CustomCommandStats:
|
async def get_statistics(self) -> CustomCommandStats:
|
||||||
"""Get comprehensive statistics about custom commands."""
|
"""Get comprehensive statistics about custom commands."""
|
||||||
# Get basic counts
|
|
||||||
total_commands = await self._get_search_count([])
|
|
||||||
active_commands = await self._get_search_count([("is_active", True)])
|
|
||||||
total_creators = await self._get_creator_count()
|
|
||||||
|
|
||||||
# Get total uses
|
|
||||||
all_commands = await self.get_items_with_params([("is_active", True)])
|
|
||||||
total_uses = sum(cmd.use_count for cmd in all_commands)
|
|
||||||
|
|
||||||
# Get most popular command
|
|
||||||
popular_commands = await self.get_popular_commands(limit=1)
|
|
||||||
most_popular = popular_commands[0] if popular_commands else None
|
|
||||||
|
|
||||||
# Get most active creator
|
|
||||||
most_active_creator = await self._get_most_active_creator()
|
|
||||||
|
|
||||||
# Get recent commands count
|
|
||||||
week_ago = datetime.now(UTC) - timedelta(days=7)
|
week_ago = datetime.now(UTC) - timedelta(days=7)
|
||||||
recent_count = await self._get_search_count(
|
|
||||||
[("created_at__gte", week_ago.isoformat()), ("is_active", True)]
|
(
|
||||||
|
total_commands,
|
||||||
|
active_commands,
|
||||||
|
total_creators,
|
||||||
|
all_commands,
|
||||||
|
popular_commands,
|
||||||
|
most_active_creator,
|
||||||
|
recent_count,
|
||||||
|
warning_count,
|
||||||
|
deletion_count,
|
||||||
|
) = await asyncio.gather(
|
||||||
|
self._get_search_count([]),
|
||||||
|
self._get_search_count([("is_active", True)]),
|
||||||
|
self._get_creator_count(),
|
||||||
|
self.get_items_with_params([("is_active", True)]),
|
||||||
|
self.get_popular_commands(limit=1),
|
||||||
|
self._get_most_active_creator(),
|
||||||
|
self._get_search_count(
|
||||||
|
[("created_at__gte", week_ago.isoformat()), ("is_active", True)]
|
||||||
|
),
|
||||||
|
self._get_commands_needing_warning_count(),
|
||||||
|
self._get_commands_eligible_for_deletion_count(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get cleanup statistics
|
total_uses = sum(cmd.use_count for cmd in all_commands)
|
||||||
warning_count = await self._get_commands_needing_warning_count()
|
most_popular = popular_commands[0] if popular_commands else None
|
||||||
deletion_count = await self._get_commands_eligible_for_deletion_count()
|
|
||||||
|
|
||||||
return CustomCommandStats(
|
return CustomCommandStats(
|
||||||
total_commands=total_commands,
|
total_commands=total_commands,
|
||||||
@ -662,21 +677,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
|||||||
|
|
||||||
commands_data = await self.get_items_with_params(params)
|
commands_data = await self.get_items_with_params(params)
|
||||||
|
|
||||||
|
creators = await asyncio.gather(
|
||||||
|
*[
|
||||||
|
self.get_creator_by_id(cmd_data.creator_id)
|
||||||
|
for cmd_data in commands_data
|
||||||
|
],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
commands = []
|
commands = []
|
||||||
for cmd_data in commands_data:
|
for cmd_data, creator in zip(commands_data, creators):
|
||||||
try:
|
if isinstance(creator, BotException):
|
||||||
creator = await self.get_creator_by_id(cmd_data.creator_id)
|
|
||||||
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
|
||||||
except BotException as e:
|
|
||||||
# Handle missing creator gracefully
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Skipping command with missing creator",
|
"Skipping command with missing creator",
|
||||||
command_id=cmd_data.id,
|
command_id=cmd_data.id,
|
||||||
command_name=cmd_data.name,
|
command_name=cmd_data.name,
|
||||||
creator_id=cmd_data.creator_id,
|
creator_id=cmd_data.creator_id,
|
||||||
error=str(e),
|
error=str(creator),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
if isinstance(creator, BaseException):
|
||||||
|
raise creator
|
||||||
|
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
||||||
|
|
||||||
return commands
|
return commands
|
||||||
|
|
||||||
@ -688,21 +710,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
|||||||
|
|
||||||
commands_data = await self.get_items_with_params(params)
|
commands_data = await self.get_items_with_params(params)
|
||||||
|
|
||||||
|
creators = await asyncio.gather(
|
||||||
|
*[
|
||||||
|
self.get_creator_by_id(cmd_data.creator_id)
|
||||||
|
for cmd_data in commands_data
|
||||||
|
],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
commands = []
|
commands = []
|
||||||
for cmd_data in commands_data:
|
for cmd_data, creator in zip(commands_data, creators):
|
||||||
try:
|
if isinstance(creator, BotException):
|
||||||
creator = await self.get_creator_by_id(cmd_data.creator_id)
|
|
||||||
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
|
||||||
except BotException as e:
|
|
||||||
# Handle missing creator gracefully
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Skipping command with missing creator",
|
"Skipping command with missing creator",
|
||||||
command_id=cmd_data.id,
|
command_id=cmd_data.id,
|
||||||
command_name=cmd_data.name,
|
command_name=cmd_data.name,
|
||||||
creator_id=cmd_data.creator_id,
|
creator_id=cmd_data.creator_id,
|
||||||
error=str(e),
|
error=str(creator),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
if isinstance(creator, BaseException):
|
||||||
|
raise creator
|
||||||
|
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
||||||
|
|
||||||
return commands
|
return commands
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ Decision Service
|
|||||||
Manages pitching decision operations for game submission.
|
Manages pitching decision operations for game submission.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
|
|
||||||
from utils.logging import get_contextual_logger
|
from utils.logging import get_contextual_logger
|
||||||
@ -124,22 +125,19 @@ class DecisionService:
|
|||||||
if int(decision.get("b_save", 0)) == 1:
|
if int(decision.get("b_save", 0)) == 1:
|
||||||
bsv_ids.append(pitcher_id)
|
bsv_ids.append(pitcher_id)
|
||||||
|
|
||||||
# Second pass: Fetch Player objects
|
# Second pass: Fetch all Player objects in parallel
|
||||||
wp = await player_service.get_player(wp_id) if wp_id else None
|
# Order: [wp_id, lp_id, sv_id, *hold_ids, *bsv_ids]; None IDs resolve immediately
|
||||||
lp = await player_service.get_player(lp_id) if lp_id else None
|
ordered_ids = [wp_id, lp_id, sv_id] + hold_ids + bsv_ids
|
||||||
sv = await player_service.get_player(sv_id) if sv_id else None
|
results = await asyncio.gather(
|
||||||
|
*[
|
||||||
|
player_service.get_player(pid) if pid else asyncio.sleep(0, result=None)
|
||||||
|
for pid in ordered_ids
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
holders = []
|
wp, lp, sv = results[0], results[1], results[2]
|
||||||
for hold_id in hold_ids:
|
holders = [p for p in results[3 : 3 + len(hold_ids)] if p]
|
||||||
holder = await player_service.get_player(hold_id)
|
blown_saves = [p for p in results[3 + len(hold_ids) :] if p]
|
||||||
if holder:
|
|
||||||
holders.append(holder)
|
|
||||||
|
|
||||||
blown_saves = []
|
|
||||||
for bsv_id in bsv_ids:
|
|
||||||
bsv = await player_service.get_player(bsv_id)
|
|
||||||
if bsv:
|
|
||||||
blown_saves.append(bsv)
|
|
||||||
|
|
||||||
return wp, lp, sv, holders, blown_saves
|
return wp, lp, sv, holders, blown_saves
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ Modern async service layer for managing help commands with full type safety.
|
|||||||
Allows admins and help editors to create custom help topics for league documentation,
|
Allows admins and help editors to create custom help topics for league documentation,
|
||||||
resources, FAQs, links, and guides.
|
resources, FAQs, links, and guides.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from utils.logging import get_contextual_logger
|
from utils.logging import get_contextual_logger
|
||||||
|
|
||||||
@ -12,7 +13,7 @@ from models.help_command import (
|
|||||||
HelpCommand,
|
HelpCommand,
|
||||||
HelpCommandSearchFilters,
|
HelpCommandSearchFilters,
|
||||||
HelpCommandSearchResult,
|
HelpCommandSearchResult,
|
||||||
HelpCommandStats
|
HelpCommandStats,
|
||||||
)
|
)
|
||||||
from services.base_service import BaseService
|
from services.base_service import BaseService
|
||||||
from exceptions import BotException
|
from exceptions import BotException
|
||||||
@ -20,16 +21,19 @@ from exceptions import BotException
|
|||||||
|
|
||||||
class HelpCommandNotFoundError(BotException):
|
class HelpCommandNotFoundError(BotException):
|
||||||
"""Raised when a help command is not found."""
|
"""Raised when a help command is not found."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class HelpCommandExistsError(BotException):
|
class HelpCommandExistsError(BotException):
|
||||||
"""Raised when trying to create a help command that already exists."""
|
"""Raised when trying to create a help command that already exists."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class HelpCommandPermissionError(BotException):
|
class HelpCommandPermissionError(BotException):
|
||||||
"""Raised when user lacks permission for help command operation."""
|
"""Raised when user lacks permission for help command operation."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -37,8 +41,8 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
"""Service for managing help commands."""
|
"""Service for managing help commands."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(HelpCommand, 'help_commands')
|
super().__init__(HelpCommand, "help_commands")
|
||||||
self.logger = get_contextual_logger(f'{__name__}.HelpCommandsService')
|
self.logger = get_contextual_logger(f"{__name__}.HelpCommandsService")
|
||||||
self.logger.info("HelpCommandsService initialized")
|
self.logger.info("HelpCommandsService initialized")
|
||||||
|
|
||||||
# === Command CRUD Operations ===
|
# === Command CRUD Operations ===
|
||||||
@ -50,7 +54,7 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
content: str,
|
content: str,
|
||||||
creator_discord_id: str,
|
creator_discord_id: str,
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
display_order: int = 0
|
display_order: int = 0,
|
||||||
) -> HelpCommand:
|
) -> HelpCommand:
|
||||||
"""
|
"""
|
||||||
Create a new help command.
|
Create a new help command.
|
||||||
@ -80,14 +84,16 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
|
|
||||||
# Create help command data
|
# Create help command data
|
||||||
help_data = {
|
help_data = {
|
||||||
'name': name.lower().strip(),
|
"name": name.lower().strip(),
|
||||||
'title': title.strip(),
|
"title": title.strip(),
|
||||||
'content': content.strip(),
|
"content": content.strip(),
|
||||||
'category': category.lower().strip() if category else None,
|
"category": category.lower().strip() if category else None,
|
||||||
'created_by_discord_id': str(creator_discord_id), # Convert to string for safe storage
|
"created_by_discord_id": str(
|
||||||
'display_order': display_order,
|
creator_discord_id
|
||||||
'is_active': True,
|
), # Convert to string for safe storage
|
||||||
'view_count': 0
|
"display_order": display_order,
|
||||||
|
"is_active": True,
|
||||||
|
"view_count": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create via API
|
# Create via API
|
||||||
@ -95,18 +101,18 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
if not result:
|
if not result:
|
||||||
raise BotException("Failed to create help command")
|
raise BotException("Failed to create help command")
|
||||||
|
|
||||||
self.logger.info("Help command created",
|
self.logger.info(
|
||||||
help_name=name,
|
"Help command created",
|
||||||
creator_id=creator_discord_id,
|
help_name=name,
|
||||||
category=category)
|
creator_id=creator_discord_id,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
|
||||||
# Return full help command
|
# Return help command directly from POST response
|
||||||
return await self.get_help_by_name(name)
|
return result
|
||||||
|
|
||||||
async def get_help_by_name(
|
async def get_help_by_name(
|
||||||
self,
|
self, name: str, include_inactive: bool = False
|
||||||
name: str,
|
|
||||||
include_inactive: bool = False
|
|
||||||
) -> HelpCommand:
|
) -> HelpCommand:
|
||||||
"""
|
"""
|
||||||
Get a help command by name.
|
Get a help command by name.
|
||||||
@ -126,8 +132,12 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
try:
|
try:
|
||||||
# Use the dedicated by_name endpoint for exact lookup
|
# Use the dedicated by_name endpoint for exact lookup
|
||||||
client = await self.get_client()
|
client = await self.get_client()
|
||||||
params = [('include_inactive', include_inactive)] if include_inactive else []
|
params = (
|
||||||
data = await client.get(f'help_commands/by_name/{normalized_name}', params=params)
|
[("include_inactive", include_inactive)] if include_inactive else []
|
||||||
|
)
|
||||||
|
data = await client.get(
|
||||||
|
f"help_commands/by_name/{normalized_name}", params=params
|
||||||
|
)
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
|
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
|
||||||
@ -139,9 +149,9 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
if "404" in str(e) or "not found" in str(e).lower():
|
if "404" in str(e) or "not found" in str(e).lower():
|
||||||
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
|
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
|
||||||
else:
|
else:
|
||||||
self.logger.error("Failed to get help command by name",
|
self.logger.error(
|
||||||
help_name=name,
|
"Failed to get help command by name", help_name=name, error=e
|
||||||
error=e)
|
)
|
||||||
raise BotException(f"Failed to retrieve help topic '{name}': {e}")
|
raise BotException(f"Failed to retrieve help topic '{name}': {e}")
|
||||||
|
|
||||||
async def update_help(
|
async def update_help(
|
||||||
@ -151,7 +161,7 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
new_content: Optional[str] = None,
|
new_content: Optional[str] = None,
|
||||||
updater_discord_id: Optional[str] = None,
|
updater_discord_id: Optional[str] = None,
|
||||||
new_category: Optional[str] = None,
|
new_category: Optional[str] = None,
|
||||||
new_display_order: Optional[int] = None
|
new_display_order: Optional[int] = None,
|
||||||
) -> HelpCommand:
|
) -> HelpCommand:
|
||||||
"""
|
"""
|
||||||
Update an existing help command.
|
Update an existing help command.
|
||||||
@ -176,35 +186,42 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
update_data = {}
|
update_data = {}
|
||||||
|
|
||||||
if new_title is not None:
|
if new_title is not None:
|
||||||
update_data['title'] = new_title.strip()
|
update_data["title"] = new_title.strip()
|
||||||
|
|
||||||
if new_content is not None:
|
if new_content is not None:
|
||||||
update_data['content'] = new_content.strip()
|
update_data["content"] = new_content.strip()
|
||||||
|
|
||||||
if new_category is not None:
|
if new_category is not None:
|
||||||
update_data['category'] = new_category.lower().strip() if new_category else None
|
update_data["category"] = (
|
||||||
|
new_category.lower().strip() if new_category else None
|
||||||
|
)
|
||||||
|
|
||||||
if new_display_order is not None:
|
if new_display_order is not None:
|
||||||
update_data['display_order'] = new_display_order
|
update_data["display_order"] = new_display_order
|
||||||
|
|
||||||
if updater_discord_id is not None:
|
if updater_discord_id is not None:
|
||||||
update_data['last_modified_by'] = str(updater_discord_id) # Convert to string for safe storage
|
update_data["last_modified_by"] = str(
|
||||||
|
updater_discord_id
|
||||||
|
) # Convert to string for safe storage
|
||||||
|
|
||||||
if not update_data:
|
if not update_data:
|
||||||
raise BotException("No fields to update")
|
raise BotException("No fields to update")
|
||||||
|
|
||||||
# Update via API
|
# Update via API
|
||||||
client = await self.get_client()
|
client = await self.get_client()
|
||||||
result = await client.put(f'help_commands/{help_cmd.id}', update_data)
|
result = await client.put(f"help_commands/{help_cmd.id}", update_data)
|
||||||
if not result:
|
if not result:
|
||||||
raise BotException("Failed to update help command")
|
raise BotException("Failed to update help command")
|
||||||
|
|
||||||
self.logger.info("Help command updated",
|
self.logger.info(
|
||||||
help_name=name,
|
"Help command updated",
|
||||||
updater_id=updater_discord_id,
|
help_name=name,
|
||||||
fields_updated=list(update_data.keys()))
|
updater_id=updater_discord_id,
|
||||||
|
fields_updated=list(update_data.keys()),
|
||||||
|
)
|
||||||
|
|
||||||
return await self.get_help_by_name(name)
|
# Return updated help command directly from PUT response
|
||||||
|
return self.model_class.from_api_data(result)
|
||||||
|
|
||||||
async def delete_help(self, name: str) -> bool:
|
async def delete_help(self, name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@ -223,11 +240,11 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
|
|
||||||
# Soft delete via API
|
# Soft delete via API
|
||||||
client = await self.get_client()
|
client = await self.get_client()
|
||||||
await client.delete(f'help_commands/{help_cmd.id}')
|
await client.delete(f"help_commands/{help_cmd.id}")
|
||||||
|
|
||||||
self.logger.info("Help command soft deleted",
|
self.logger.info(
|
||||||
help_name=name,
|
"Help command soft deleted", help_name=name, help_id=help_cmd.id
|
||||||
help_id=help_cmd.id)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -252,13 +269,11 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
|
|
||||||
# Restore via API
|
# Restore via API
|
||||||
client = await self.get_client()
|
client = await self.get_client()
|
||||||
result = await client.patch(f'help_commands/{help_cmd.id}/restore')
|
result = await client.patch(f"help_commands/{help_cmd.id}/restore")
|
||||||
if not result:
|
if not result:
|
||||||
raise BotException("Failed to restore help command")
|
raise BotException("Failed to restore help command")
|
||||||
|
|
||||||
self.logger.info("Help command restored",
|
self.logger.info("Help command restored", help_name=name, help_id=help_cmd.id)
|
||||||
help_name=name,
|
|
||||||
help_id=help_cmd.id)
|
|
||||||
|
|
||||||
return self.model_class.from_api_data(result)
|
return self.model_class.from_api_data(result)
|
||||||
|
|
||||||
@ -279,10 +294,9 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
client = await self.get_client()
|
client = await self.get_client()
|
||||||
await client.patch(f'help_commands/by_name/{normalized_name}/view')
|
await client.patch(f"help_commands/by_name/{normalized_name}/view")
|
||||||
|
|
||||||
self.logger.debug("Help command view count incremented",
|
self.logger.debug("Help command view count incremented", help_name=name)
|
||||||
help_name=name)
|
|
||||||
|
|
||||||
# Return updated command
|
# Return updated command
|
||||||
return await self.get_help_by_name(name)
|
return await self.get_help_by_name(name)
|
||||||
@ -291,16 +305,15 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
if "404" in str(e) or "not found" in str(e).lower():
|
if "404" in str(e) or "not found" in str(e).lower():
|
||||||
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
|
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
|
||||||
else:
|
else:
|
||||||
self.logger.error("Failed to increment view count",
|
self.logger.error(
|
||||||
help_name=name,
|
"Failed to increment view count", help_name=name, error=e
|
||||||
error=e)
|
)
|
||||||
raise BotException(f"Failed to increment view count for '{name}': {e}")
|
raise BotException(f"Failed to increment view count for '{name}': {e}")
|
||||||
|
|
||||||
# === Search and Listing ===
|
# === Search and Listing ===
|
||||||
|
|
||||||
async def search_help_commands(
|
async def search_help_commands(
|
||||||
self,
|
self, filters: HelpCommandSearchFilters
|
||||||
filters: HelpCommandSearchFilters
|
|
||||||
) -> HelpCommandSearchResult:
|
) -> HelpCommandSearchResult:
|
||||||
"""
|
"""
|
||||||
Search for help commands with filtering and pagination.
|
Search for help commands with filtering and pagination.
|
||||||
@ -316,23 +329,23 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if filters.name_contains:
|
if filters.name_contains:
|
||||||
params.append(('name', filters.name_contains)) # API will do ILIKE search
|
params.append(("name", filters.name_contains)) # API will do ILIKE search
|
||||||
|
|
||||||
if filters.category:
|
if filters.category:
|
||||||
params.append(('category', filters.category))
|
params.append(("category", filters.category))
|
||||||
|
|
||||||
params.append(('is_active', filters.is_active))
|
params.append(("is_active", filters.is_active))
|
||||||
|
|
||||||
# Add sorting
|
# Add sorting
|
||||||
params.append(('sort', filters.sort_by))
|
params.append(("sort", filters.sort_by))
|
||||||
|
|
||||||
# Add pagination
|
# Add pagination
|
||||||
params.append(('page', filters.page))
|
params.append(("page", filters.page))
|
||||||
params.append(('page_size', filters.page_size))
|
params.append(("page_size", filters.page_size))
|
||||||
|
|
||||||
# Execute search via API
|
# Execute search via API
|
||||||
client = await self.get_client()
|
client = await self.get_client()
|
||||||
data = await client.get('help_commands', params=params)
|
data = await client.get("help_commands", params=params)
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return HelpCommandSearchResult(
|
return HelpCommandSearchResult(
|
||||||
@ -341,14 +354,14 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
page=filters.page,
|
page=filters.page,
|
||||||
page_size=filters.page_size,
|
page_size=filters.page_size,
|
||||||
total_pages=0,
|
total_pages=0,
|
||||||
has_more=False
|
has_more=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract response data
|
# Extract response data
|
||||||
help_commands_data = data.get('help_commands', [])
|
help_commands_data = data.get("help_commands", [])
|
||||||
total_count = data.get('total_count', 0)
|
total_count = data.get("total_count", 0)
|
||||||
total_pages = data.get('total_pages', 0)
|
total_pages = data.get("total_pages", 0)
|
||||||
has_more = data.get('has_more', False)
|
has_more = data.get("has_more", False)
|
||||||
|
|
||||||
# Convert to HelpCommand objects
|
# Convert to HelpCommand objects
|
||||||
help_commands = []
|
help_commands = []
|
||||||
@ -356,15 +369,21 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
try:
|
try:
|
||||||
help_commands.append(self.model_class.from_api_data(cmd_data))
|
help_commands.append(self.model_class.from_api_data(cmd_data))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("Failed to create HelpCommand from API data",
|
self.logger.warning(
|
||||||
help_id=cmd_data.get('id'),
|
"Failed to create HelpCommand from API data",
|
||||||
error=e)
|
help_id=cmd_data.get("id"),
|
||||||
|
error=e,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.logger.debug("Help commands search completed",
|
self.logger.debug(
|
||||||
total_results=total_count,
|
"Help commands search completed",
|
||||||
page=filters.page,
|
total_results=total_count,
|
||||||
filters_applied=len([p for p in params if p[0] not in ['sort', 'page', 'page_size']]))
|
page=filters.page,
|
||||||
|
filters_applied=len(
|
||||||
|
[p for p in params if p[0] not in ["sort", "page", "page_size"]]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return HelpCommandSearchResult(
|
return HelpCommandSearchResult(
|
||||||
help_commands=help_commands,
|
help_commands=help_commands,
|
||||||
@ -372,13 +391,11 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
page=filters.page,
|
page=filters.page,
|
||||||
page_size=filters.page_size,
|
page_size=filters.page_size,
|
||||||
total_pages=total_pages,
|
total_pages=total_pages,
|
||||||
has_more=has_more
|
has_more=has_more,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_all_help_topics(
|
async def get_all_help_topics(
|
||||||
self,
|
self, category: Optional[str] = None, include_inactive: bool = False
|
||||||
category: Optional[str] = None,
|
|
||||||
include_inactive: bool = False
|
|
||||||
) -> List[HelpCommand]:
|
) -> List[HelpCommand]:
|
||||||
"""
|
"""
|
||||||
Get all help topics, optionally filtered by category.
|
Get all help topics, optionally filtered by category.
|
||||||
@ -393,37 +410,36 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
params = []
|
params = []
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
params.append(('category', category))
|
params.append(("category", category))
|
||||||
|
|
||||||
params.append(('is_active', not include_inactive))
|
params.append(("is_active", not include_inactive))
|
||||||
params.append(('sort', 'display_order'))
|
params.append(("sort", "display_order"))
|
||||||
params.append(('page_size', 100)) # Get all
|
params.append(("page_size", 100)) # Get all
|
||||||
|
|
||||||
client = await self.get_client()
|
client = await self.get_client()
|
||||||
data = await client.get('help_commands', params=params)
|
data = await client.get("help_commands", params=params)
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
help_commands_data = data.get('help_commands', [])
|
help_commands_data = data.get("help_commands", [])
|
||||||
|
|
||||||
help_commands = []
|
help_commands = []
|
||||||
for cmd_data in help_commands_data:
|
for cmd_data in help_commands_data:
|
||||||
try:
|
try:
|
||||||
help_commands.append(self.model_class.from_api_data(cmd_data))
|
help_commands.append(self.model_class.from_api_data(cmd_data))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("Failed to create HelpCommand from API data",
|
self.logger.warning(
|
||||||
help_id=cmd_data.get('id'),
|
"Failed to create HelpCommand from API data",
|
||||||
error=e)
|
help_id=cmd_data.get("id"),
|
||||||
|
error=e,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return help_commands
|
return help_commands
|
||||||
|
|
||||||
async def get_help_names_for_autocomplete(
|
async def get_help_names_for_autocomplete(
|
||||||
self,
|
self, partial_name: str = "", limit: int = 25, include_inactive: bool = False
|
||||||
partial_name: str = "",
|
|
||||||
limit: int = 25,
|
|
||||||
include_inactive: bool = False
|
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get help command names for Discord autocomplete.
|
Get help command names for Discord autocomplete.
|
||||||
@ -439,25 +455,28 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
try:
|
try:
|
||||||
# Use the dedicated autocomplete endpoint
|
# Use the dedicated autocomplete endpoint
|
||||||
client = await self.get_client()
|
client = await self.get_client()
|
||||||
params = [('limit', limit)]
|
params = [("limit", limit)]
|
||||||
|
|
||||||
if partial_name:
|
if partial_name:
|
||||||
params.append(('q', partial_name.lower()))
|
params.append(("q", partial_name.lower()))
|
||||||
|
|
||||||
result = await client.get('help_commands/autocomplete', params=params)
|
result = await client.get("help_commands/autocomplete", params=params)
|
||||||
|
|
||||||
# The autocomplete endpoint returns results with name, title, category
|
# The autocomplete endpoint returns results with name, title, category
|
||||||
if isinstance(result, dict) and 'results' in result:
|
if isinstance(result, dict) and "results" in result:
|
||||||
return [item['name'] for item in result['results']]
|
return [item["name"] for item in result["results"]]
|
||||||
else:
|
else:
|
||||||
self.logger.warning("Unexpected autocomplete response format",
|
self.logger.warning(
|
||||||
response=result)
|
"Unexpected autocomplete response format", response=result
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error("Failed to get help names for autocomplete",
|
self.logger.error(
|
||||||
partial_name=partial_name,
|
"Failed to get help names for autocomplete",
|
||||||
error=e)
|
partial_name=partial_name,
|
||||||
|
error=e,
|
||||||
|
)
|
||||||
# Return empty list on error to not break Discord autocomplete
|
# Return empty list on error to not break Discord autocomplete
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -467,7 +486,7 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
"""Get comprehensive statistics about help commands."""
|
"""Get comprehensive statistics about help commands."""
|
||||||
try:
|
try:
|
||||||
client = await self.get_client()
|
client = await self.get_client()
|
||||||
data = await client.get('help_commands/stats')
|
data = await client.get("help_commands/stats")
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return HelpCommandStats(
|
return HelpCommandStats(
|
||||||
@ -475,23 +494,25 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
active_commands=0,
|
active_commands=0,
|
||||||
total_views=0,
|
total_views=0,
|
||||||
most_viewed_command=None,
|
most_viewed_command=None,
|
||||||
recent_commands_count=0
|
recent_commands_count=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert most_viewed_command if present
|
# Convert most_viewed_command if present
|
||||||
most_viewed = None
|
most_viewed = None
|
||||||
if data.get('most_viewed_command'):
|
if data.get("most_viewed_command"):
|
||||||
try:
|
try:
|
||||||
most_viewed = self.model_class.from_api_data(data['most_viewed_command'])
|
most_viewed = self.model_class.from_api_data(
|
||||||
|
data["most_viewed_command"]
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("Failed to parse most viewed command", error=e)
|
self.logger.warning("Failed to parse most viewed command", error=e)
|
||||||
|
|
||||||
return HelpCommandStats(
|
return HelpCommandStats(
|
||||||
total_commands=data.get('total_commands', 0),
|
total_commands=data.get("total_commands", 0),
|
||||||
active_commands=data.get('active_commands', 0),
|
active_commands=data.get("active_commands", 0),
|
||||||
total_views=data.get('total_views', 0),
|
total_views=data.get("total_views", 0),
|
||||||
most_viewed_command=most_viewed,
|
most_viewed_command=most_viewed,
|
||||||
recent_commands_count=data.get('recent_commands_count', 0)
|
recent_commands_count=data.get("recent_commands_count", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -502,7 +523,7 @@ class HelpCommandsService(BaseService[HelpCommand]):
|
|||||||
active_commands=0,
|
active_commands=0,
|
||||||
total_views=0,
|
total_views=0,
|
||||||
most_viewed_command=None,
|
most_viewed_command=None,
|
||||||
recent_commands_count=0
|
recent_commands_count=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ Schedule service for Discord Bot v2.0
|
|||||||
Handles game schedule and results retrieval and processing.
|
Handles game schedule and results retrieval and processing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Dict, Tuple
|
from typing import Optional, List, Dict, Tuple
|
||||||
|
|
||||||
@ -102,10 +103,10 @@ class ScheduleService:
|
|||||||
# If weeks not specified, try a reasonable range (18 weeks typical)
|
# If weeks not specified, try a reasonable range (18 weeks typical)
|
||||||
week_range = range(1, (weeks + 1) if weeks else 19)
|
week_range = range(1, (weeks + 1) if weeks else 19)
|
||||||
|
|
||||||
for week in week_range:
|
all_week_games = await asyncio.gather(
|
||||||
week_games = await self.get_week_schedule(season, week)
|
*[self.get_week_schedule(season, week) for week in week_range]
|
||||||
|
)
|
||||||
# Filter games involving this team
|
for week_games in all_week_games:
|
||||||
for game in week_games:
|
for game in week_games:
|
||||||
if (
|
if (
|
||||||
game.away_team.abbrev.upper() == team_abbrev_upper
|
game.away_team.abbrev.upper() == team_abbrev_upper
|
||||||
@ -135,15 +136,13 @@ class ScheduleService:
|
|||||||
recent_games = []
|
recent_games = []
|
||||||
|
|
||||||
# Get games from recent weeks
|
# Get games from recent weeks
|
||||||
for week_offset in range(weeks_back):
|
weeks_to_fetch = [
|
||||||
# This is simplified - in production you'd want to determine current week
|
(10 - offset) for offset in range(weeks_back) if (10 - offset) > 0
|
||||||
week = 10 - week_offset # Assuming we're around week 10
|
]
|
||||||
if week <= 0:
|
all_week_games = await asyncio.gather(
|
||||||
break
|
*[self.get_week_schedule(season, week) for week in weeks_to_fetch]
|
||||||
|
)
|
||||||
week_games = await self.get_week_schedule(season, week)
|
for week_games in all_week_games:
|
||||||
|
|
||||||
# Only include completed games
|
|
||||||
completed_games = [game for game in week_games if game.is_completed]
|
completed_games = [game for game in week_games if game.is_completed]
|
||||||
recent_games.extend(completed_games)
|
recent_games.extend(completed_games)
|
||||||
|
|
||||||
@ -157,13 +156,12 @@ class ScheduleService:
|
|||||||
logger.error(f"Error getting recent games: {e}")
|
logger.error(f"Error getting recent games: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_upcoming_games(self, season: int, weeks_ahead: int = 6) -> List[Game]:
|
async def get_upcoming_games(self, season: int) -> List[Game]:
|
||||||
"""
|
"""
|
||||||
Get upcoming scheduled games by scanning multiple weeks.
|
Get upcoming scheduled games by scanning all weeks.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
season: Season number
|
season: Season number
|
||||||
weeks_ahead: Number of weeks to scan ahead (default 6)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of upcoming Game instances
|
List of upcoming Game instances
|
||||||
@ -171,20 +169,16 @@ class ScheduleService:
|
|||||||
try:
|
try:
|
||||||
upcoming_games = []
|
upcoming_games = []
|
||||||
|
|
||||||
# Scan through weeks to find games without scores
|
# Fetch all weeks in parallel and filter for incomplete games
|
||||||
for week in range(1, 19): # Standard season length
|
all_week_games = await asyncio.gather(
|
||||||
week_games = await self.get_week_schedule(season, week)
|
*[self.get_week_schedule(season, week) for week in range(1, 19)]
|
||||||
|
)
|
||||||
# Find games without scores (not yet played)
|
for week_games in all_week_games:
|
||||||
upcoming_games_week = [
|
upcoming_games_week = [
|
||||||
game for game in week_games if not game.is_completed
|
game for game in week_games if not game.is_completed
|
||||||
]
|
]
|
||||||
upcoming_games.extend(upcoming_games_week)
|
upcoming_games.extend(upcoming_games_week)
|
||||||
|
|
||||||
# If we found upcoming games, we can limit how many more weeks to check
|
|
||||||
if upcoming_games and len(upcoming_games) >= 20: # Reasonable limit
|
|
||||||
break
|
|
||||||
|
|
||||||
# Sort by week, then game number
|
# Sort by week, then game number
|
||||||
upcoming_games.sort(key=lambda x: (x.week, x.game_num or 0))
|
upcoming_games.sort(key=lambda x: (x.week, x.game_num or 0))
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ Statistics service for Discord Bot v2.0
|
|||||||
Handles batting and pitching statistics retrieval and processing.
|
Handles batting and pitching statistics retrieval and processing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -144,11 +145,10 @@ class StatsService:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get both types of stats concurrently
|
# Get both types of stats concurrently
|
||||||
batting_task = self.get_batting_stats(player_id, season)
|
batting_stats, pitching_stats = await asyncio.gather(
|
||||||
pitching_task = self.get_pitching_stats(player_id, season)
|
self.get_batting_stats(player_id, season),
|
||||||
|
self.get_pitching_stats(player_id, season),
|
||||||
batting_stats = await batting_task
|
)
|
||||||
pitching_stats = await pitching_task
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Retrieved stats for player {player_id}: "
|
f"Retrieved stats for player {player_id}: "
|
||||||
|
|||||||
@ -4,6 +4,7 @@ Trade Builder Service
|
|||||||
Extends the TransactionBuilder to support multi-team trades and player exchanges.
|
Extends the TransactionBuilder to support multi-team trades and player exchanges.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Optional, Set
|
from typing import Dict, List, Optional, Set
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@ -524,14 +525,22 @@ class TradeBuilder:
|
|||||||
|
|
||||||
# Validate each team's roster after the trade
|
# Validate each team's roster after the trade
|
||||||
for participant in self.trade.participants:
|
for participant in self.trade.participants:
|
||||||
team_id = participant.team.id
|
result.team_abbrevs[participant.team.id] = participant.team.abbrev
|
||||||
result.team_abbrevs[team_id] = participant.team.abbrev
|
|
||||||
if team_id in self._team_builders:
|
|
||||||
builder = self._team_builders[team_id]
|
|
||||||
roster_validation = await builder.validate_transaction(next_week)
|
|
||||||
|
|
||||||
|
team_ids_to_validate = [
|
||||||
|
participant.team.id
|
||||||
|
for participant in self.trade.participants
|
||||||
|
if participant.team.id in self._team_builders
|
||||||
|
]
|
||||||
|
if team_ids_to_validate:
|
||||||
|
validations = await asyncio.gather(
|
||||||
|
*[
|
||||||
|
self._team_builders[tid].validate_transaction(next_week)
|
||||||
|
for tid in team_ids_to_validate
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for team_id, roster_validation in zip(team_ids_to_validate, validations):
|
||||||
result.participant_validations[team_id] = roster_validation
|
result.participant_validations[team_id] = roster_validation
|
||||||
|
|
||||||
if not roster_validation.is_legal:
|
if not roster_validation.is_legal:
|
||||||
result.is_legal = False
|
result.is_legal = False
|
||||||
|
|
||||||
|
|||||||
@ -277,6 +277,35 @@ class TransactionBuilder:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (success: bool, error_message: str). If success is True, error_message is empty.
|
Tuple of (success: bool, error_message: str). If success is True, error_message is empty.
|
||||||
"""
|
"""
|
||||||
|
# Fetch current state once if needed by FA lock or pending-transaction check
|
||||||
|
is_fa_pickup = (
|
||||||
|
move.from_roster == RosterType.FREE_AGENCY
|
||||||
|
and move.to_roster != RosterType.FREE_AGENCY
|
||||||
|
)
|
||||||
|
needs_current_state = is_fa_pickup or (
|
||||||
|
check_pending_transactions and next_week is None
|
||||||
|
)
|
||||||
|
|
||||||
|
current_week: Optional[int] = None
|
||||||
|
if needs_current_state:
|
||||||
|
try:
|
||||||
|
current_state = await league_service.get_current_state()
|
||||||
|
current_week = current_state.week if current_state else 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not get current week: {e}")
|
||||||
|
current_week = 1
|
||||||
|
|
||||||
|
# Block adding players FROM Free Agency after the FA lock deadline
|
||||||
|
if is_fa_pickup and current_week is not None:
|
||||||
|
config = get_config()
|
||||||
|
if current_week >= config.fa_lock_week:
|
||||||
|
error_msg = (
|
||||||
|
f"Free agency is closed (week {current_week}, deadline was week {config.fa_lock_week}). "
|
||||||
|
f"Cannot add {move.player.name} from FA."
|
||||||
|
)
|
||||||
|
logger.warning(error_msg)
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
# Check if player is already in a move in this transaction builder
|
# Check if player is already in a move in this transaction builder
|
||||||
existing_move = self.get_move_for_player(move.player.id)
|
existing_move = self.get_move_for_player(move.player.id)
|
||||||
if existing_move:
|
if existing_move:
|
||||||
@ -299,23 +328,15 @@ class TransactionBuilder:
|
|||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
# Check if player is already in another team's pending transaction for next week
|
# Check if player is already in another team's pending transaction for next week
|
||||||
# This prevents duplicate claims that would need to be resolved at freeze time
|
|
||||||
# Only applies to /dropadd (scheduled moves), not /ilmove (immediate moves)
|
|
||||||
if check_pending_transactions:
|
if check_pending_transactions:
|
||||||
if next_week is None:
|
if next_week is None:
|
||||||
try:
|
next_week = (current_week + 1) if current_week else 1
|
||||||
current_state = await league_service.get_current_state()
|
|
||||||
next_week = (current_state.week + 1) if current_state else 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
f"Could not get current week for pending transaction check: {e}"
|
|
||||||
)
|
|
||||||
next_week = 1
|
|
||||||
|
|
||||||
is_pending, claiming_team = (
|
(
|
||||||
await transaction_service.is_player_in_pending_transaction(
|
is_pending,
|
||||||
player_id=move.player.id, week=next_week, season=self.season
|
claiming_team,
|
||||||
)
|
) = await transaction_service.is_player_in_pending_transaction(
|
||||||
|
player_id=move.player.id, week=next_week, season=self.season
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_pending:
|
if is_pending:
|
||||||
|
|||||||
@ -95,7 +95,7 @@ class LiveScorebugTracker:
|
|||||||
# Don't return - still update voice channels
|
# Don't return - still update voice channels
|
||||||
else:
|
else:
|
||||||
# Get all published scorecards
|
# Get all published scorecards
|
||||||
all_scorecards = self.scorecard_tracker.get_all_scorecards()
|
all_scorecards = await self.scorecard_tracker.get_all_scorecards()
|
||||||
|
|
||||||
if not all_scorecards:
|
if not all_scorecards:
|
||||||
# No active scorebugs - clear the channel and hide it
|
# No active scorebugs - clear the channel and hide it
|
||||||
@ -112,17 +112,16 @@ class LiveScorebugTracker:
|
|||||||
for text_channel_id, sheet_url in all_scorecards:
|
for text_channel_id, sheet_url in all_scorecards:
|
||||||
try:
|
try:
|
||||||
scorebug_data = await self.scorebug_service.read_scorebug_data(
|
scorebug_data = await self.scorebug_service.read_scorebug_data(
|
||||||
sheet_url, full_length=False # Compact view for live channel
|
sheet_url,
|
||||||
|
full_length=False, # Compact view for live channel
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only include active (non-final) games
|
# Only include active (non-final) games
|
||||||
if scorebug_data.is_active:
|
if scorebug_data.is_active:
|
||||||
# Get team data
|
# Get team data
|
||||||
away_team = await team_service.get_team(
|
away_team, home_team = await asyncio.gather(
|
||||||
scorebug_data.away_team_id
|
team_service.get_team(scorebug_data.away_team_id),
|
||||||
)
|
team_service.get_team(scorebug_data.home_team_id),
|
||||||
home_team = await team_service.get_team(
|
|
||||||
scorebug_data.home_team_id
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if away_team is None or home_team is None:
|
if away_team is None or home_team is None:
|
||||||
@ -188,9 +187,8 @@ class LiveScorebugTracker:
|
|||||||
embeds: List of scorebug embeds
|
embeds: List of scorebug embeds
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Clear old messages
|
# Clear old messages using bulk delete
|
||||||
async for message in channel.history(limit=25):
|
await channel.purge(limit=25)
|
||||||
await message.delete()
|
|
||||||
|
|
||||||
# Post new scorebugs (Discord allows up to 10 embeds per message)
|
# Post new scorebugs (Discord allows up to 10 embeds per message)
|
||||||
if len(embeds) <= 10:
|
if len(embeds) <= 10:
|
||||||
@ -216,9 +214,8 @@ class LiveScorebugTracker:
|
|||||||
channel: Discord text channel
|
channel: Discord text channel
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Clear all messages
|
# Clear all messages using bulk delete
|
||||||
async for message in channel.history(limit=25):
|
await channel.purge(limit=25)
|
||||||
await message.delete()
|
|
||||||
|
|
||||||
self.logger.info("Cleared live-sba-scores channel (no active games)")
|
self.logger.info("Cleared live-sba-scores channel (no active games)")
|
||||||
|
|
||||||
|
|||||||
143
tests/test_commands_trade_deadline.py
Normal file
143
tests/test_commands_trade_deadline.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Tests for trade deadline enforcement in /trade commands.
|
||||||
|
|
||||||
|
Validates that trades are blocked after the trade deadline and allowed during/before it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from tests.factories import CurrentFactory, TeamFactory
|
||||||
|
|
||||||
|
|
||||||
|
class TestTradeInitiateDeadlineGuard:
|
||||||
|
"""Test trade deadline enforcement in /trade initiate command."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_interaction(self):
|
||||||
|
"""Create mock Discord interaction with deferred response."""
|
||||||
|
interaction = AsyncMock()
|
||||||
|
interaction.user = MagicMock()
|
||||||
|
interaction.user.id = 258104532423147520
|
||||||
|
interaction.response = AsyncMock()
|
||||||
|
interaction.followup = AsyncMock()
|
||||||
|
interaction.guild = MagicMock()
|
||||||
|
interaction.guild.id = 669356687294988350
|
||||||
|
return interaction
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trade_initiate_blocked_past_deadline(self, mock_interaction):
|
||||||
|
"""After the trade deadline, /trade initiate should return a friendly error."""
|
||||||
|
user_team = TeamFactory.west_virginia()
|
||||||
|
other_team = TeamFactory.new_york()
|
||||||
|
past_deadline = CurrentFactory.create(week=15, trade_deadline=14)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"commands.transactions.trade.validate_user_has_team",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=user_team,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"commands.transactions.trade.get_team_by_abbrev_with_validation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=other_team,
|
||||||
|
),
|
||||||
|
patch("commands.transactions.trade.league_service") as mock_league,
|
||||||
|
):
|
||||||
|
mock_league.get_current_state = AsyncMock(return_value=past_deadline)
|
||||||
|
|
||||||
|
from commands.transactions.trade import TradeCommands
|
||||||
|
|
||||||
|
bot = MagicMock()
|
||||||
|
cog = TradeCommands(bot)
|
||||||
|
await cog.trade_initiate.callback(cog, mock_interaction, "NY")
|
||||||
|
|
||||||
|
mock_interaction.followup.send.assert_called_once()
|
||||||
|
call_kwargs = mock_interaction.followup.send.call_args
|
||||||
|
msg = (
|
||||||
|
call_kwargs[0][0]
|
||||||
|
if call_kwargs[0]
|
||||||
|
else call_kwargs[1].get("content", "")
|
||||||
|
)
|
||||||
|
assert "trade deadline has passed" in msg.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trade_initiate_allowed_at_deadline_week(self, mock_interaction):
|
||||||
|
"""During the deadline week itself, /trade initiate should proceed."""
|
||||||
|
user_team = TeamFactory.west_virginia()
|
||||||
|
other_team = TeamFactory.new_york()
|
||||||
|
at_deadline = CurrentFactory.create(week=14, trade_deadline=14)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"commands.transactions.trade.validate_user_has_team",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=user_team,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"commands.transactions.trade.get_team_by_abbrev_with_validation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=other_team,
|
||||||
|
),
|
||||||
|
patch("commands.transactions.trade.league_service") as mock_league,
|
||||||
|
patch("commands.transactions.trade.clear_trade_builder") as mock_clear,
|
||||||
|
patch("commands.transactions.trade.get_trade_builder") as mock_get_builder,
|
||||||
|
patch(
|
||||||
|
"commands.transactions.trade.create_trade_embed",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=MagicMock(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_league.get_current_state = AsyncMock(return_value=at_deadline)
|
||||||
|
mock_builder = MagicMock()
|
||||||
|
mock_builder.add_team = AsyncMock(return_value=(True, None))
|
||||||
|
mock_builder.trade_id = "test-123"
|
||||||
|
mock_get_builder.return_value = mock_builder
|
||||||
|
|
||||||
|
from commands.transactions.trade import TradeCommands
|
||||||
|
|
||||||
|
bot = MagicMock()
|
||||||
|
cog = TradeCommands(bot)
|
||||||
|
cog.channel_manager = MagicMock()
|
||||||
|
cog.channel_manager.create_trade_channel = AsyncMock(return_value=None)
|
||||||
|
await cog.trade_initiate.callback(cog, mock_interaction, "NY")
|
||||||
|
|
||||||
|
# Should have proceeded past deadline check to clear/create trade
|
||||||
|
mock_clear.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trade_initiate_blocked_when_current_none(self, mock_interaction):
|
||||||
|
"""When league state can't be fetched, /trade initiate should fail closed."""
|
||||||
|
user_team = TeamFactory.west_virginia()
|
||||||
|
other_team = TeamFactory.new_york()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"commands.transactions.trade.validate_user_has_team",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=user_team,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"commands.transactions.trade.get_team_by_abbrev_with_validation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=other_team,
|
||||||
|
),
|
||||||
|
patch("commands.transactions.trade.league_service") as mock_league,
|
||||||
|
):
|
||||||
|
mock_league.get_current_state = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
from commands.transactions.trade import TradeCommands
|
||||||
|
|
||||||
|
bot = MagicMock()
|
||||||
|
cog = TradeCommands(bot)
|
||||||
|
await cog.trade_initiate.callback(cog, mock_interaction, "NY")
|
||||||
|
|
||||||
|
mock_interaction.followup.send.assert_called_once()
|
||||||
|
call_kwargs = mock_interaction.followup.send.call_args
|
||||||
|
msg = (
|
||||||
|
call_kwargs[0][0]
|
||||||
|
if call_kwargs[0]
|
||||||
|
else call_kwargs[1].get("content", "")
|
||||||
|
)
|
||||||
|
assert "could not retrieve league state" in msg.lower()
|
||||||
@ -3,6 +3,7 @@ Tests for SBA data models
|
|||||||
|
|
||||||
Validates model creation, validation, and business logic.
|
Validates model creation, validation, and business logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from models import Team, Player, Current, DraftPick, DraftData, DraftList
|
from models import Team, Player, Current, DraftPick, DraftData, DraftList
|
||||||
@ -14,33 +15,37 @@ class TestSBABaseModel:
|
|||||||
def test_model_creation_with_api_data(self):
|
def test_model_creation_with_api_data(self):
|
||||||
"""Test creating models from API data."""
|
"""Test creating models from API data."""
|
||||||
team_data = {
|
team_data = {
|
||||||
'id': 1,
|
"id": 1,
|
||||||
'abbrev': 'NYY',
|
"abbrev": "NYY",
|
||||||
'sname': 'Yankees',
|
"sname": "Yankees",
|
||||||
'lname': 'New York Yankees',
|
"lname": "New York Yankees",
|
||||||
'season': 12
|
"season": 12,
|
||||||
}
|
}
|
||||||
|
|
||||||
team = Team.from_api_data(team_data)
|
team = Team.from_api_data(team_data)
|
||||||
assert team.id == 1
|
assert team.id == 1
|
||||||
assert team.abbrev == 'NYY'
|
assert team.abbrev == "NYY"
|
||||||
assert team.lname == 'New York Yankees'
|
assert team.lname == "New York Yankees"
|
||||||
|
|
||||||
def test_to_dict_functionality(self):
|
def test_to_dict_functionality(self):
|
||||||
"""Test model to dictionary conversion."""
|
"""Test model to dictionary conversion."""
|
||||||
team = Team(id=1, abbrev='LAA', sname='Angels', lname='Los Angeles Angels', season=12)
|
team = Team(
|
||||||
|
id=1, abbrev="LAA", sname="Angels", lname="Los Angeles Angels", season=12
|
||||||
|
)
|
||||||
|
|
||||||
team_dict = team.to_dict()
|
team_dict = team.to_dict()
|
||||||
assert 'abbrev' in team_dict
|
assert "abbrev" in team_dict
|
||||||
assert team_dict['abbrev'] == 'LAA'
|
assert team_dict["abbrev"] == "LAA"
|
||||||
assert team_dict['lname'] == 'Los Angeles Angels'
|
assert team_dict["lname"] == "Los Angeles Angels"
|
||||||
|
|
||||||
def test_model_repr(self):
|
def test_model_repr(self):
|
||||||
"""Test model string representation."""
|
"""Test model string representation."""
|
||||||
team = Team(id=2, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12)
|
team = Team(
|
||||||
|
id=2, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12
|
||||||
|
)
|
||||||
repr_str = repr(team)
|
repr_str = repr(team)
|
||||||
assert 'Team(' in repr_str
|
assert "Team(" in repr_str
|
||||||
assert 'abbrev=BOS' in repr_str
|
assert "abbrev=BOS" in repr_str
|
||||||
|
|
||||||
|
|
||||||
class TestTeamModel:
|
class TestTeamModel:
|
||||||
@ -49,55 +54,59 @@ class TestTeamModel:
|
|||||||
def test_team_creation_minimal(self):
|
def test_team_creation_minimal(self):
|
||||||
"""Test team creation with minimal required fields."""
|
"""Test team creation with minimal required fields."""
|
||||||
team = Team(
|
team = Team(
|
||||||
id=4,
|
id=4, abbrev="HOU", sname="Astros", lname="Houston Astros", season=12
|
||||||
abbrev='HOU',
|
|
||||||
sname='Astros',
|
|
||||||
lname='Houston Astros',
|
|
||||||
season=12
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert team.abbrev == 'HOU'
|
assert team.abbrev == "HOU"
|
||||||
assert team.sname == 'Astros'
|
assert team.sname == "Astros"
|
||||||
assert team.lname == 'Houston Astros'
|
assert team.lname == "Houston Astros"
|
||||||
assert team.season == 12
|
assert team.season == 12
|
||||||
|
|
||||||
def test_team_creation_with_optional_fields(self):
|
def test_team_creation_with_optional_fields(self):
|
||||||
"""Test team creation with optional fields."""
|
"""Test team creation with optional fields."""
|
||||||
team = Team(
|
team = Team(
|
||||||
id=5,
|
id=5,
|
||||||
abbrev='SF',
|
abbrev="SF",
|
||||||
sname='Giants',
|
sname="Giants",
|
||||||
lname='San Francisco Giants',
|
lname="San Francisco Giants",
|
||||||
season=12,
|
season=12,
|
||||||
gmid=100,
|
gmid=100,
|
||||||
division_id=1,
|
division_id=1,
|
||||||
stadium='Oracle Park',
|
stadium="Oracle Park",
|
||||||
color='FF8C00'
|
color="FF8C00",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert team.gmid == 100
|
assert team.gmid == 100
|
||||||
assert team.division_id == 1
|
assert team.division_id == 1
|
||||||
assert team.stadium == 'Oracle Park'
|
assert team.stadium == "Oracle Park"
|
||||||
assert team.color == 'FF8C00'
|
assert team.color == "FF8C00"
|
||||||
|
|
||||||
def test_team_str_representation(self):
|
def test_team_str_representation(self):
|
||||||
"""Test team string representation."""
|
"""Test team string representation."""
|
||||||
team = Team(id=3, abbrev='SD', sname='Padres', lname='San Diego Padres', season=12)
|
team = Team(
|
||||||
assert str(team) == 'SD - San Diego Padres'
|
id=3, abbrev="SD", sname="Padres", lname="San Diego Padres", season=12
|
||||||
|
)
|
||||||
|
assert str(team) == "SD - San Diego Padres"
|
||||||
|
|
||||||
def test_team_roster_type_major_league(self):
|
def test_team_roster_type_major_league(self):
|
||||||
"""Test roster type detection for Major League teams."""
|
"""Test roster type detection for Major League teams."""
|
||||||
from models.team import RosterType
|
from models.team import RosterType
|
||||||
|
|
||||||
# 3 chars or less → Major League
|
# 3 chars or less → Major League
|
||||||
team = Team(id=1, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=12)
|
team = Team(
|
||||||
|
id=1, abbrev="NYY", sname="Yankees", lname="New York Yankees", season=12
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
||||||
|
|
||||||
team = Team(id=2, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12)
|
team = Team(
|
||||||
|
id=2, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
||||||
|
|
||||||
# Even "BHM" (ends in M) should be Major League
|
# Even "BHM" (ends in M) should be Major League
|
||||||
team = Team(id=3, abbrev='BHM', sname='Iron', lname='Birmingham Iron', season=12)
|
team = Team(
|
||||||
|
id=3, abbrev="BHM", sname="Iron", lname="Birmingham Iron", season=12
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
||||||
|
|
||||||
def test_team_roster_type_minor_league(self):
|
def test_team_roster_type_minor_league(self):
|
||||||
@ -105,14 +114,28 @@ class TestTeamModel:
|
|||||||
from models.team import RosterType
|
from models.team import RosterType
|
||||||
|
|
||||||
# Standard Minor League: [Team] + "MIL"
|
# Standard Minor League: [Team] + "MIL"
|
||||||
team = Team(id=4, abbrev='NYYMIL', sname='RailRiders', lname='Staten Island RailRiders', season=12)
|
team = Team(
|
||||||
|
id=4,
|
||||||
|
abbrev="NYYMIL",
|
||||||
|
sname="RailRiders",
|
||||||
|
lname="Staten Island RailRiders",
|
||||||
|
season=12,
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||||
|
|
||||||
team = Team(id=5, abbrev='PORMIL', sname='Portland MiL', lname='Portland Minor League', season=12)
|
team = Team(
|
||||||
|
id=5,
|
||||||
|
abbrev="PORMIL",
|
||||||
|
sname="Portland MiL",
|
||||||
|
lname="Portland Minor League",
|
||||||
|
season=12,
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||||
|
|
||||||
# Case insensitive
|
# Case insensitive
|
||||||
team = Team(id=6, abbrev='LAAmil', sname='Bees', lname='Salt Lake Bees', season=12)
|
team = Team(
|
||||||
|
id=6, abbrev="LAAmil", sname="Bees", lname="Salt Lake Bees", season=12
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||||
|
|
||||||
def test_team_roster_type_injured_list(self):
|
def test_team_roster_type_injured_list(self):
|
||||||
@ -120,14 +143,32 @@ class TestTeamModel:
|
|||||||
from models.team import RosterType
|
from models.team import RosterType
|
||||||
|
|
||||||
# Standard Injured List: [Team] + "IL"
|
# Standard Injured List: [Team] + "IL"
|
||||||
team = Team(id=7, abbrev='NYYIL', sname='Yankees IL', lname='New York Yankees IL', season=12)
|
team = Team(
|
||||||
|
id=7,
|
||||||
|
abbrev="NYYIL",
|
||||||
|
sname="Yankees IL",
|
||||||
|
lname="New York Yankees IL",
|
||||||
|
season=12,
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.INJURED_LIST
|
assert team.roster_type() == RosterType.INJURED_LIST
|
||||||
|
|
||||||
team = Team(id=8, abbrev='PORIL', sname='Loggers IL', lname='Portland Loggers IL', season=12)
|
team = Team(
|
||||||
|
id=8,
|
||||||
|
abbrev="PORIL",
|
||||||
|
sname="Loggers IL",
|
||||||
|
lname="Portland Loggers IL",
|
||||||
|
season=12,
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.INJURED_LIST
|
assert team.roster_type() == RosterType.INJURED_LIST
|
||||||
|
|
||||||
# Case insensitive
|
# Case insensitive
|
||||||
team = Team(id=9, abbrev='LAAil', sname='Angels IL', lname='Los Angeles Angels IL', season=12)
|
team = Team(
|
||||||
|
id=9,
|
||||||
|
abbrev="LAAil",
|
||||||
|
sname="Angels IL",
|
||||||
|
lname="Los Angeles Angels IL",
|
||||||
|
season=12,
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.INJURED_LIST
|
assert team.roster_type() == RosterType.INJURED_LIST
|
||||||
|
|
||||||
def test_team_roster_type_edge_case_bhmil(self):
|
def test_team_roster_type_edge_case_bhmil(self):
|
||||||
@ -143,16 +184,30 @@ class TestTeamModel:
|
|||||||
from models.team import RosterType
|
from models.team import RosterType
|
||||||
|
|
||||||
# "BHMIL" = "BHM" + "IL" → sname contains "IL" → INJURED_LIST
|
# "BHMIL" = "BHM" + "IL" → sname contains "IL" → INJURED_LIST
|
||||||
team = Team(id=10, abbrev='BHMIL', sname='Iron IL', lname='Birmingham Iron IL', season=12)
|
team = Team(
|
||||||
|
id=10,
|
||||||
|
abbrev="BHMIL",
|
||||||
|
sname="Iron IL",
|
||||||
|
lname="Birmingham Iron IL",
|
||||||
|
season=12,
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.INJURED_LIST
|
assert team.roster_type() == RosterType.INJURED_LIST
|
||||||
|
|
||||||
# Compare with a real Minor League team that has "Island" in name
|
# Compare with a real Minor League team that has "Island" in name
|
||||||
# "NYYMIL" = "NYY" + "MIL", even though sname has "Island" → MINOR_LEAGUE
|
# "NYYMIL" = "NYY" + "MIL", even though sname has "Island" → MINOR_LEAGUE
|
||||||
team = Team(id=11, abbrev='NYYMIL', sname='Staten Island RailRiders', lname='Staten Island RailRiders', season=12)
|
team = Team(
|
||||||
|
id=11,
|
||||||
|
abbrev="NYYMIL",
|
||||||
|
sname="Staten Island RailRiders",
|
||||||
|
lname="Staten Island RailRiders",
|
||||||
|
season=12,
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||||
|
|
||||||
# Another IL edge case with sname containing "IL" at word boundary
|
# Another IL edge case with sname containing "IL" at word boundary
|
||||||
team = Team(id=12, abbrev='WVMIL', sname='WV IL', lname='West Virginia IL', season=12)
|
team = Team(
|
||||||
|
id=12, abbrev="WVMIL", sname="WV IL", lname="West Virginia IL", season=12
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.INJURED_LIST
|
assert team.roster_type() == RosterType.INJURED_LIST
|
||||||
|
|
||||||
def test_team_roster_type_sname_disambiguation(self):
|
def test_team_roster_type_sname_disambiguation(self):
|
||||||
@ -160,15 +215,33 @@ class TestTeamModel:
|
|||||||
from models.team import RosterType
|
from models.team import RosterType
|
||||||
|
|
||||||
# MiL team - sname does NOT have "IL" as a word
|
# MiL team - sname does NOT have "IL" as a word
|
||||||
team = Team(id=13, abbrev='WVMIL', sname='Miners', lname='West Virginia Miners', season=12)
|
team = Team(
|
||||||
|
id=13,
|
||||||
|
abbrev="WVMIL",
|
||||||
|
sname="Miners",
|
||||||
|
lname="West Virginia Miners",
|
||||||
|
season=12,
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||||
|
|
||||||
# IL team - sname has "IL" at word boundary
|
# IL team - sname has "IL" at word boundary
|
||||||
team = Team(id=14, abbrev='WVMIL', sname='Miners IL', lname='West Virginia Miners IL', season=12)
|
team = Team(
|
||||||
|
id=14,
|
||||||
|
abbrev="WVMIL",
|
||||||
|
sname="Miners IL",
|
||||||
|
lname="West Virginia Miners IL",
|
||||||
|
season=12,
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.INJURED_LIST
|
assert team.roster_type() == RosterType.INJURED_LIST
|
||||||
|
|
||||||
# MiL team - sname has "IL" but only in "Island" (substring, not word boundary)
|
# MiL team - sname has "IL" but only in "Island" (substring, not word boundary)
|
||||||
team = Team(id=15, abbrev='CHIMIL', sname='Island Hoppers', lname='Chicago Island Hoppers', season=12)
|
team = Team(
|
||||||
|
id=15,
|
||||||
|
abbrev="CHIMIL",
|
||||||
|
sname="Island Hoppers",
|
||||||
|
lname="Chicago Island Hoppers",
|
||||||
|
season=12,
|
||||||
|
)
|
||||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||||
|
|
||||||
|
|
||||||
@ -179,74 +252,74 @@ class TestPlayerModel:
|
|||||||
"""Test player creation with required fields."""
|
"""Test player creation with required fields."""
|
||||||
player = Player(
|
player = Player(
|
||||||
id=101,
|
id=101,
|
||||||
name='Mike Trout',
|
name="Mike Trout",
|
||||||
wara=8.5,
|
wara=8.5,
|
||||||
season=12,
|
season=12,
|
||||||
team_id=1,
|
team_id=1,
|
||||||
image='trout.jpg',
|
image="trout.jpg",
|
||||||
pos_1='CF'
|
pos_1="CF",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert player.name == 'Mike Trout'
|
assert player.name == "Mike Trout"
|
||||||
assert player.wara == 8.5
|
assert player.wara == 8.5
|
||||||
assert player.team_id == 1
|
assert player.team_id == 1
|
||||||
assert player.pos_1 == 'CF'
|
assert player.pos_1 == "CF"
|
||||||
|
|
||||||
def test_player_positions_property(self):
|
def test_player_positions_property(self):
|
||||||
"""Test player positions property."""
|
"""Test player positions property."""
|
||||||
player = Player(
|
player = Player(
|
||||||
id=102,
|
id=102,
|
||||||
name='Shohei Ohtani',
|
name="Shohei Ohtani",
|
||||||
wara=9.0,
|
wara=9.0,
|
||||||
season=12,
|
season=12,
|
||||||
team_id=1,
|
team_id=1,
|
||||||
image='ohtani.jpg',
|
image="ohtani.jpg",
|
||||||
pos_1='SP',
|
pos_1="SP",
|
||||||
pos_2='DH',
|
pos_2="DH",
|
||||||
pos_3='RF'
|
pos_3="RF",
|
||||||
)
|
)
|
||||||
|
|
||||||
positions = player.positions
|
positions = player.positions
|
||||||
assert len(positions) == 3
|
assert len(positions) == 3
|
||||||
assert 'SP' in positions
|
assert "SP" in positions
|
||||||
assert 'DH' in positions
|
assert "DH" in positions
|
||||||
assert 'RF' in positions
|
assert "RF" in positions
|
||||||
|
|
||||||
def test_player_primary_position(self):
|
def test_player_primary_position(self):
|
||||||
"""Test primary position property."""
|
"""Test primary position property."""
|
||||||
player = Player(
|
player = Player(
|
||||||
id=103,
|
id=103,
|
||||||
name='Mookie Betts',
|
name="Mookie Betts",
|
||||||
wara=7.2,
|
wara=7.2,
|
||||||
season=12,
|
season=12,
|
||||||
team_id=1,
|
team_id=1,
|
||||||
image='betts.jpg',
|
image="betts.jpg",
|
||||||
pos_1='RF',
|
pos_1="RF",
|
||||||
pos_2='2B'
|
pos_2="2B",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert player.primary_position == 'RF'
|
assert player.primary_position == "RF"
|
||||||
|
|
||||||
def test_player_is_pitcher(self):
|
def test_player_is_pitcher(self):
|
||||||
"""Test is_pitcher property."""
|
"""Test is_pitcher property."""
|
||||||
pitcher = Player(
|
pitcher = Player(
|
||||||
id=104,
|
id=104,
|
||||||
name='Gerrit Cole',
|
name="Gerrit Cole",
|
||||||
wara=6.8,
|
wara=6.8,
|
||||||
season=12,
|
season=12,
|
||||||
team_id=1,
|
team_id=1,
|
||||||
image='cole.jpg',
|
image="cole.jpg",
|
||||||
pos_1='SP'
|
pos_1="SP",
|
||||||
)
|
)
|
||||||
|
|
||||||
position_player = Player(
|
position_player = Player(
|
||||||
id=105,
|
id=105,
|
||||||
name='Aaron Judge',
|
name="Aaron Judge",
|
||||||
wara=8.1,
|
wara=8.1,
|
||||||
season=12,
|
season=12,
|
||||||
team_id=1,
|
team_id=1,
|
||||||
image='judge.jpg',
|
image="judge.jpg",
|
||||||
pos_1='RF'
|
pos_1="RF",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert pitcher.is_pitcher is True
|
assert pitcher.is_pitcher is True
|
||||||
@ -256,15 +329,15 @@ class TestPlayerModel:
|
|||||||
"""Test player string representation."""
|
"""Test player string representation."""
|
||||||
player = Player(
|
player = Player(
|
||||||
id=106,
|
id=106,
|
||||||
name='Ronald Acuna Jr.',
|
name="Ronald Acuna Jr.",
|
||||||
wara=8.8,
|
wara=8.8,
|
||||||
season=12,
|
season=12,
|
||||||
team_id=1,
|
team_id=1,
|
||||||
image='acuna.jpg',
|
image="acuna.jpg",
|
||||||
pos_1='OF'
|
pos_1="OF",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert str(player) == 'Ronald Acuna Jr. (OF)'
|
assert str(player) == "Ronald Acuna Jr. (OF)"
|
||||||
|
|
||||||
|
|
||||||
class TestCurrentModel:
|
class TestCurrentModel:
|
||||||
@ -277,16 +350,12 @@ class TestCurrentModel:
|
|||||||
assert current.week == 69
|
assert current.week == 69
|
||||||
assert current.season == 69
|
assert current.season == 69
|
||||||
assert current.freeze is True
|
assert current.freeze is True
|
||||||
assert current.bet_week == 'sheets'
|
assert current.bet_week == "sheets"
|
||||||
|
|
||||||
def test_current_with_custom_values(self):
|
def test_current_with_custom_values(self):
|
||||||
"""Test current model with custom values."""
|
"""Test current model with custom values."""
|
||||||
current = Current(
|
current = Current(
|
||||||
week=15,
|
week=15, season=12, freeze=False, trade_deadline=14, playoffs_begin=19
|
||||||
season=12,
|
|
||||||
freeze=False,
|
|
||||||
trade_deadline=14,
|
|
||||||
playoffs_begin=19
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert current.week == 15
|
assert current.week == 15
|
||||||
@ -309,19 +378,32 @@ class TestCurrentModel:
|
|||||||
current = Current(week=15, pick_trade_start=10, pick_trade_end=20)
|
current = Current(week=15, pick_trade_start=10, pick_trade_end=20)
|
||||||
assert current.can_trade_picks is True
|
assert current.can_trade_picks is True
|
||||||
|
|
||||||
|
def test_is_past_trade_deadline(self):
|
||||||
|
"""Test trade deadline property — trades allowed during deadline week, blocked after."""
|
||||||
|
# Before deadline
|
||||||
|
current = Current(week=10, trade_deadline=14)
|
||||||
|
assert current.is_past_trade_deadline is False
|
||||||
|
|
||||||
|
# At deadline week (still allowed)
|
||||||
|
current = Current(week=14, trade_deadline=14)
|
||||||
|
assert current.is_past_trade_deadline is False
|
||||||
|
|
||||||
|
# One week past deadline
|
||||||
|
current = Current(week=15, trade_deadline=14)
|
||||||
|
assert current.is_past_trade_deadline is True
|
||||||
|
|
||||||
|
# Offseason bypasses deadline (week > 18)
|
||||||
|
current = Current(week=20, trade_deadline=14)
|
||||||
|
assert current.is_offseason is True
|
||||||
|
assert current.is_past_trade_deadline is False
|
||||||
|
|
||||||
|
|
||||||
class TestDraftPickModel:
|
class TestDraftPickModel:
|
||||||
"""Test DraftPick model functionality."""
|
"""Test DraftPick model functionality."""
|
||||||
|
|
||||||
def test_draft_pick_creation(self):
|
def test_draft_pick_creation(self):
|
||||||
"""Test draft pick creation."""
|
"""Test draft pick creation."""
|
||||||
pick = DraftPick(
|
pick = DraftPick(season=12, overall=1, round=1, origowner_id=1, owner_id=1)
|
||||||
season=12,
|
|
||||||
overall=1,
|
|
||||||
round=1,
|
|
||||||
origowner_id=1,
|
|
||||||
owner_id=1
|
|
||||||
)
|
|
||||||
|
|
||||||
assert pick.season == 12
|
assert pick.season == 12
|
||||||
assert pick.overall == 1
|
assert pick.overall == 1
|
||||||
@ -331,36 +413,21 @@ class TestDraftPickModel:
|
|||||||
def test_draft_pick_properties(self):
|
def test_draft_pick_properties(self):
|
||||||
"""Test draft pick properties."""
|
"""Test draft pick properties."""
|
||||||
# Not traded, not selected
|
# Not traded, not selected
|
||||||
pick = DraftPick(
|
pick = DraftPick(season=12, overall=5, round=1, origowner_id=1, owner_id=1)
|
||||||
season=12,
|
|
||||||
overall=5,
|
|
||||||
round=1,
|
|
||||||
origowner_id=1,
|
|
||||||
owner_id=1
|
|
||||||
)
|
|
||||||
|
|
||||||
assert pick.is_traded is False
|
assert pick.is_traded is False
|
||||||
assert pick.is_selected is False
|
assert pick.is_selected is False
|
||||||
|
|
||||||
# Traded pick
|
# Traded pick
|
||||||
traded_pick = DraftPick(
|
traded_pick = DraftPick(
|
||||||
season=12,
|
season=12, overall=10, round=1, origowner_id=1, owner_id=2
|
||||||
overall=10,
|
|
||||||
round=1,
|
|
||||||
origowner_id=1,
|
|
||||||
owner_id=2
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert traded_pick.is_traded is True
|
assert traded_pick.is_traded is True
|
||||||
|
|
||||||
# Selected pick
|
# Selected pick
|
||||||
selected_pick = DraftPick(
|
selected_pick = DraftPick(
|
||||||
season=12,
|
season=12, overall=15, round=1, origowner_id=1, owner_id=1, player_id=100
|
||||||
overall=15,
|
|
||||||
round=1,
|
|
||||||
origowner_id=1,
|
|
||||||
owner_id=1,
|
|
||||||
player_id=100
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert selected_pick.is_selected is True
|
assert selected_pick.is_selected is True
|
||||||
@ -372,9 +439,7 @@ class TestDraftDataModel:
|
|||||||
def test_draft_data_creation(self):
|
def test_draft_data_creation(self):
|
||||||
"""Test draft data creation."""
|
"""Test draft data creation."""
|
||||||
draft_data = DraftData(
|
draft_data = DraftData(
|
||||||
result_channel=123456789,
|
result_channel=123456789, ping_channel=987654321, pick_minutes=10
|
||||||
ping_channel=987654321,
|
|
||||||
pick_minutes=10
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert draft_data.result_channel == 123456789
|
assert draft_data.result_channel == 123456789
|
||||||
@ -384,20 +449,12 @@ class TestDraftDataModel:
|
|||||||
def test_draft_data_properties(self):
|
def test_draft_data_properties(self):
|
||||||
"""Test draft data properties."""
|
"""Test draft data properties."""
|
||||||
# Inactive draft
|
# Inactive draft
|
||||||
draft_data = DraftData(
|
draft_data = DraftData(result_channel=123, ping_channel=456, timer=False)
|
||||||
result_channel=123,
|
|
||||||
ping_channel=456,
|
|
||||||
timer=False
|
|
||||||
)
|
|
||||||
|
|
||||||
assert draft_data.is_draft_active is False
|
assert draft_data.is_draft_active is False
|
||||||
|
|
||||||
# Active draft
|
# Active draft
|
||||||
active_draft = DraftData(
|
active_draft = DraftData(result_channel=123, ping_channel=456, timer=True)
|
||||||
result_channel=123,
|
|
||||||
ping_channel=456,
|
|
||||||
timer=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert active_draft.is_draft_active is True
|
assert active_draft.is_draft_active is True
|
||||||
|
|
||||||
@ -409,17 +466,13 @@ class TestDraftListModel:
|
|||||||
not just IDs. The API returns these objects populated.
|
not just IDs. The API returns these objects populated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _create_mock_team(self, team_id: int = 1) -> 'Team':
|
def _create_mock_team(self, team_id: int = 1) -> "Team":
|
||||||
"""Create a mock team for testing."""
|
"""Create a mock team for testing."""
|
||||||
return Team(
|
return Team(
|
||||||
id=team_id,
|
id=team_id, abbrev="TST", sname="Test", lname="Test Team", season=12
|
||||||
abbrev="TST",
|
|
||||||
sname="Test",
|
|
||||||
lname="Test Team",
|
|
||||||
season=12
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create_mock_player(self, player_id: int = 100) -> 'Player':
|
def _create_mock_player(self, player_id: int = 100) -> "Player":
|
||||||
"""Create a mock player for testing."""
|
"""Create a mock player for testing."""
|
||||||
return Player(
|
return Player(
|
||||||
id=player_id,
|
id=player_id,
|
||||||
@ -430,7 +483,7 @@ class TestDraftListModel:
|
|||||||
team_id=1,
|
team_id=1,
|
||||||
season=12,
|
season=12,
|
||||||
wara=2.5,
|
wara=2.5,
|
||||||
image="https://example.com/test.jpg"
|
image="https://example.com/test.jpg",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_draft_list_creation(self):
|
def test_draft_list_creation(self):
|
||||||
@ -438,12 +491,7 @@ class TestDraftListModel:
|
|||||||
mock_team = self._create_mock_team(team_id=1)
|
mock_team = self._create_mock_team(team_id=1)
|
||||||
mock_player = self._create_mock_player(player_id=100)
|
mock_player = self._create_mock_player(player_id=100)
|
||||||
|
|
||||||
draft_entry = DraftList(
|
draft_entry = DraftList(season=12, team=mock_team, rank=1, player=mock_player)
|
||||||
season=12,
|
|
||||||
team=mock_team,
|
|
||||||
rank=1,
|
|
||||||
player=mock_player
|
|
||||||
)
|
|
||||||
|
|
||||||
assert draft_entry.season == 12
|
assert draft_entry.season == 12
|
||||||
assert draft_entry.team_id == 1
|
assert draft_entry.team_id == 1
|
||||||
@ -456,18 +504,10 @@ class TestDraftListModel:
|
|||||||
mock_player_top = self._create_mock_player(player_id=100)
|
mock_player_top = self._create_mock_player(player_id=100)
|
||||||
mock_player_lower = self._create_mock_player(player_id=200)
|
mock_player_lower = self._create_mock_player(player_id=200)
|
||||||
|
|
||||||
top_pick = DraftList(
|
top_pick = DraftList(season=12, team=mock_team, rank=1, player=mock_player_top)
|
||||||
season=12,
|
|
||||||
team=mock_team,
|
|
||||||
rank=1,
|
|
||||||
player=mock_player_top
|
|
||||||
)
|
|
||||||
|
|
||||||
lower_pick = DraftList(
|
lower_pick = DraftList(
|
||||||
season=12,
|
season=12, team=mock_team, rank=5, player=mock_player_lower
|
||||||
team=mock_team,
|
|
||||||
rank=5,
|
|
||||||
player=mock_player_lower
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert top_pick.is_top_ranked is True
|
assert top_pick.is_top_ranked is True
|
||||||
@ -486,32 +526,32 @@ class TestDraftListModel:
|
|||||||
"""
|
"""
|
||||||
# Simulate API response format - nested objects, NOT flat IDs
|
# Simulate API response format - nested objects, NOT flat IDs
|
||||||
api_response = {
|
api_response = {
|
||||||
'id': 303,
|
"id": 303,
|
||||||
'season': 13,
|
"season": 13,
|
||||||
'rank': 1,
|
"rank": 1,
|
||||||
'team': {
|
"team": {
|
||||||
'id': 548,
|
"id": 548,
|
||||||
'abbrev': 'WV',
|
"abbrev": "WV",
|
||||||
'sname': 'Black Bears',
|
"sname": "Black Bears",
|
||||||
'lname': 'West Virginia Black Bears',
|
"lname": "West Virginia Black Bears",
|
||||||
'season': 13
|
"season": 13,
|
||||||
},
|
},
|
||||||
'player': {
|
"player": {
|
||||||
'id': 12843,
|
"id": 12843,
|
||||||
'name': 'George Springer',
|
"name": "George Springer",
|
||||||
'wara': 0.31,
|
"wara": 0.31,
|
||||||
'image': 'https://example.com/springer.png',
|
"image": "https://example.com/springer.png",
|
||||||
'season': 13,
|
"season": 13,
|
||||||
'pos_1': 'CF',
|
"pos_1": "CF",
|
||||||
# Note: NO flat team_id here - it's nested in 'team' below
|
# Note: NO flat team_id here - it's nested in 'team' below
|
||||||
'team': {
|
"team": {
|
||||||
'id': 547, # Free Agent team
|
"id": 547, # Free Agent team
|
||||||
'abbrev': 'FA',
|
"abbrev": "FA",
|
||||||
'sname': 'Free Agents',
|
"sname": "Free Agents",
|
||||||
'lname': 'Free Agents',
|
"lname": "Free Agents",
|
||||||
'season': 13
|
"season": 13,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create DraftList using from_api_data (what BaseService calls)
|
# Create DraftList using from_api_data (what BaseService calls)
|
||||||
@ -522,17 +562,18 @@ class TestDraftListModel:
|
|||||||
assert draft_entry.player is not None
|
assert draft_entry.player is not None
|
||||||
|
|
||||||
# CRITICAL: player.team_id must be extracted from nested team object
|
# CRITICAL: player.team_id must be extracted from nested team object
|
||||||
assert draft_entry.player.team_id == 547, \
|
assert draft_entry.player.team_id == 547, (
|
||||||
f"player.team_id should be 547 (FA), got {draft_entry.player.team_id}"
|
f"player.team_id should be 547 (FA), got {draft_entry.player.team_id}"
|
||||||
|
)
|
||||||
|
|
||||||
# Verify the nested team object is also populated
|
# Verify the nested team object is also populated
|
||||||
assert draft_entry.player.team is not None
|
assert draft_entry.player.team is not None
|
||||||
assert draft_entry.player.team.id == 547
|
assert draft_entry.player.team.id == 547
|
||||||
assert draft_entry.player.team.abbrev == 'FA'
|
assert draft_entry.player.team.abbrev == "FA"
|
||||||
|
|
||||||
# Verify DraftList's own team data
|
# Verify DraftList's own team data
|
||||||
assert draft_entry.team.id == 548
|
assert draft_entry.team.id == 548
|
||||||
assert draft_entry.team.abbrev == 'WV'
|
assert draft_entry.team.abbrev == "WV"
|
||||||
assert draft_entry.team_id == 548 # Property from nested team
|
assert draft_entry.team_id == 548 # Property from nested team
|
||||||
|
|
||||||
|
|
||||||
@ -544,65 +585,71 @@ class TestModelCoverageExtras:
|
|||||||
from models.base import SBABaseModel
|
from models.base import SBABaseModel
|
||||||
|
|
||||||
# Test with empty data raises ValueError
|
# Test with empty data raises ValueError
|
||||||
with pytest.raises(ValueError, match="Cannot create SBABaseModel from empty data"):
|
with pytest.raises(
|
||||||
|
ValueError, match="Cannot create SBABaseModel from empty data"
|
||||||
|
):
|
||||||
SBABaseModel.from_api_data({})
|
SBABaseModel.from_api_data({})
|
||||||
|
|
||||||
# Test with None raises ValueError
|
# Test with None raises ValueError
|
||||||
with pytest.raises(ValueError, match="Cannot create SBABaseModel from empty data"):
|
with pytest.raises(
|
||||||
|
ValueError, match="Cannot create SBABaseModel from empty data"
|
||||||
|
):
|
||||||
SBABaseModel.from_api_data(None)
|
SBABaseModel.from_api_data(None)
|
||||||
|
|
||||||
def test_player_positions_comprehensive(self):
|
def test_player_positions_comprehensive(self):
|
||||||
"""Test player positions property with all position variations."""
|
"""Test player positions property with all position variations."""
|
||||||
player_data = {
|
player_data = {
|
||||||
'id': 201,
|
"id": 201,
|
||||||
'name': 'Multi-Position Player',
|
"name": "Multi-Position Player",
|
||||||
'wara': 3.0,
|
"wara": 3.0,
|
||||||
'season': 12,
|
"season": 12,
|
||||||
'team_id': 5,
|
"team_id": 5,
|
||||||
'image': 'https://example.com/player.jpg',
|
"image": "https://example.com/player.jpg",
|
||||||
'pos_1': 'C',
|
"pos_1": "C",
|
||||||
'pos_2': '1B',
|
"pos_2": "1B",
|
||||||
'pos_3': '3B',
|
"pos_3": "3B",
|
||||||
'pos_4': None, # Test None handling
|
"pos_4": None, # Test None handling
|
||||||
'pos_5': 'DH',
|
"pos_5": "DH",
|
||||||
'pos_6': 'OF',
|
"pos_6": "OF",
|
||||||
'pos_7': None, # Another None
|
"pos_7": None, # Another None
|
||||||
'pos_8': 'SS'
|
"pos_8": "SS",
|
||||||
}
|
}
|
||||||
player = Player.from_api_data(player_data)
|
player = Player.from_api_data(player_data)
|
||||||
|
|
||||||
positions = player.positions
|
positions = player.positions
|
||||||
assert 'C' in positions
|
assert "C" in positions
|
||||||
assert '1B' in positions
|
assert "1B" in positions
|
||||||
assert '3B' in positions
|
assert "3B" in positions
|
||||||
assert 'DH' in positions
|
assert "DH" in positions
|
||||||
assert 'OF' in positions
|
assert "OF" in positions
|
||||||
assert 'SS' in positions
|
assert "SS" in positions
|
||||||
assert len(positions) == 6 # Should exclude None values
|
assert len(positions) == 6 # Should exclude None values
|
||||||
assert None not in positions
|
assert None not in positions
|
||||||
|
|
||||||
def test_player_is_pitcher_variations(self):
|
def test_player_is_pitcher_variations(self):
|
||||||
"""Test is_pitcher property with different positions."""
|
"""Test is_pitcher property with different positions."""
|
||||||
test_cases = [
|
test_cases = [
|
||||||
('SP', True), # Starting pitcher
|
("SP", True), # Starting pitcher
|
||||||
('RP', True), # Relief pitcher
|
("RP", True), # Relief pitcher
|
||||||
('P', True), # Generic pitcher
|
("P", True), # Generic pitcher
|
||||||
('C', False), # Catcher
|
("C", False), # Catcher
|
||||||
('1B', False), # First base
|
("1B", False), # First base
|
||||||
('OF', False), # Outfield
|
("OF", False), # Outfield
|
||||||
('DH', False), # Designated hitter
|
("DH", False), # Designated hitter
|
||||||
]
|
]
|
||||||
|
|
||||||
for position, expected in test_cases:
|
for position, expected in test_cases:
|
||||||
player_data = {
|
player_data = {
|
||||||
'id': 300 + ord(position[0]), # Generate unique IDs based on position
|
"id": 300 + ord(position[0]), # Generate unique IDs based on position
|
||||||
'name': f'Test {position}',
|
"name": f"Test {position}",
|
||||||
'wara': 2.0,
|
"wara": 2.0,
|
||||||
'season': 12,
|
"season": 12,
|
||||||
'team_id': 5,
|
"team_id": 5,
|
||||||
'image': 'https://example.com/player.jpg',
|
"image": "https://example.com/player.jpg",
|
||||||
'pos_1': position,
|
"pos_1": position,
|
||||||
}
|
}
|
||||||
player = Player.from_api_data(player_data)
|
player = Player.from_api_data(player_data)
|
||||||
assert player.is_pitcher == expected, f"Position {position} should return {expected}"
|
assert player.is_pitcher == expected, (
|
||||||
|
f"Position {position} should return {expected}"
|
||||||
|
)
|
||||||
assert player.primary_position == position
|
assert player.primary_position == position
|
||||||
@ -24,7 +24,8 @@ from utils.scorebug_helpers import create_scorebug_embed, create_team_progress_b
|
|||||||
class TestScorecardTrackerFreshReads:
|
class TestScorecardTrackerFreshReads:
|
||||||
"""Tests that ScorecardTracker reads fresh data from disk (fix for #40)."""
|
"""Tests that ScorecardTracker reads fresh data from disk (fix for #40)."""
|
||||||
|
|
||||||
def test_get_all_scorecards_reads_fresh_data(self, tmp_path):
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_all_scorecards_reads_fresh_data(self, tmp_path):
|
||||||
"""get_all_scorecards() should pick up scorecards written by another process.
|
"""get_all_scorecards() should pick up scorecards written by another process.
|
||||||
|
|
||||||
Simulates the background task having a stale tracker instance while
|
Simulates the background task having a stale tracker instance while
|
||||||
@ -34,7 +35,7 @@ class TestScorecardTrackerFreshReads:
|
|||||||
data_file.write_text(json.dumps({"scorecards": {}}))
|
data_file.write_text(json.dumps({"scorecards": {}}))
|
||||||
|
|
||||||
tracker = ScorecardTracker(data_file=str(data_file))
|
tracker = ScorecardTracker(data_file=str(data_file))
|
||||||
assert tracker.get_all_scorecards() == []
|
assert await tracker.get_all_scorecards() == []
|
||||||
|
|
||||||
# Another process writes a scorecard to the same file
|
# Another process writes a scorecard to the same file
|
||||||
new_data = {
|
new_data = {
|
||||||
@ -51,17 +52,18 @@ class TestScorecardTrackerFreshReads:
|
|||||||
data_file.write_text(json.dumps(new_data))
|
data_file.write_text(json.dumps(new_data))
|
||||||
|
|
||||||
# Should see the new scorecard without restart
|
# Should see the new scorecard without restart
|
||||||
result = tracker.get_all_scorecards()
|
result = await tracker.get_all_scorecards()
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0] == (111, "https://docs.google.com/spreadsheets/d/abc123")
|
assert result[0] == (111, "https://docs.google.com/spreadsheets/d/abc123")
|
||||||
|
|
||||||
def test_get_scorecard_reads_fresh_data(self, tmp_path):
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_scorecard_reads_fresh_data(self, tmp_path):
|
||||||
"""get_scorecard() should pick up a scorecard written by another process."""
|
"""get_scorecard() should pick up a scorecard written by another process."""
|
||||||
data_file = tmp_path / "scorecards.json"
|
data_file = tmp_path / "scorecards.json"
|
||||||
data_file.write_text(json.dumps({"scorecards": {}}))
|
data_file.write_text(json.dumps({"scorecards": {}}))
|
||||||
|
|
||||||
tracker = ScorecardTracker(data_file=str(data_file))
|
tracker = ScorecardTracker(data_file=str(data_file))
|
||||||
assert tracker.get_scorecard(222) is None
|
assert await tracker.get_scorecard(222) is None
|
||||||
|
|
||||||
# Another process writes a scorecard
|
# Another process writes a scorecard
|
||||||
new_data = {
|
new_data = {
|
||||||
@ -79,7 +81,7 @@ class TestScorecardTrackerFreshReads:
|
|||||||
|
|
||||||
# Should see the new scorecard
|
# Should see the new scorecard
|
||||||
assert (
|
assert (
|
||||||
tracker.get_scorecard(222)
|
await tracker.get_scorecard(222)
|
||||||
== "https://docs.google.com/spreadsheets/d/xyz789"
|
== "https://docs.google.com/spreadsheets/d/xyz789"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Tests for Help Commands Service in Discord Bot v2.0
|
|||||||
|
|
||||||
Comprehensive tests for help commands CRUD operations and business logic.
|
Comprehensive tests for help commands CRUD operations and business logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
@ -10,13 +11,13 @@ from unittest.mock import AsyncMock
|
|||||||
from services.help_commands_service import (
|
from services.help_commands_service import (
|
||||||
HelpCommandsService,
|
HelpCommandsService,
|
||||||
HelpCommandNotFoundError,
|
HelpCommandNotFoundError,
|
||||||
HelpCommandExistsError
|
HelpCommandExistsError,
|
||||||
)
|
)
|
||||||
from models.help_command import (
|
from models.help_command import (
|
||||||
HelpCommand,
|
HelpCommand,
|
||||||
HelpCommandSearchFilters,
|
HelpCommandSearchFilters,
|
||||||
HelpCommandSearchResult,
|
HelpCommandSearchResult,
|
||||||
HelpCommandStats
|
HelpCommandStats,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -26,17 +27,17 @@ def sample_help_command() -> HelpCommand:
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
return HelpCommand(
|
return HelpCommand(
|
||||||
id=1,
|
id=1,
|
||||||
name='trading-rules',
|
name="trading-rules",
|
||||||
title='Trading Rules & Guidelines',
|
title="Trading Rules & Guidelines",
|
||||||
content='Complete trading rules for the league...',
|
content="Complete trading rules for the league...",
|
||||||
category='rules',
|
category="rules",
|
||||||
created_by_discord_id='123456789',
|
created_by_discord_id="123456789",
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=None,
|
updated_at=None,
|
||||||
last_modified_by=None,
|
last_modified_by=None,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
view_count=100,
|
view_count=100,
|
||||||
display_order=10
|
display_order=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -64,6 +65,7 @@ class TestHelpCommandsServiceInit:
|
|||||||
|
|
||||||
# Multiple imports should return the same instance
|
# Multiple imports should return the same instance
|
||||||
from services.help_commands_service import help_commands_service as service2
|
from services.help_commands_service import help_commands_service as service2
|
||||||
|
|
||||||
assert help_commands_service is service2
|
assert help_commands_service is service2
|
||||||
|
|
||||||
def test_service_has_required_methods(self):
|
def test_service_has_required_methods(self):
|
||||||
@ -71,22 +73,22 @@ class TestHelpCommandsServiceInit:
|
|||||||
from services.help_commands_service import help_commands_service
|
from services.help_commands_service import help_commands_service
|
||||||
|
|
||||||
# Core CRUD operations
|
# Core CRUD operations
|
||||||
assert hasattr(help_commands_service, 'create_help')
|
assert hasattr(help_commands_service, "create_help")
|
||||||
assert hasattr(help_commands_service, 'get_help_by_name')
|
assert hasattr(help_commands_service, "get_help_by_name")
|
||||||
assert hasattr(help_commands_service, 'update_help')
|
assert hasattr(help_commands_service, "update_help")
|
||||||
assert hasattr(help_commands_service, 'delete_help')
|
assert hasattr(help_commands_service, "delete_help")
|
||||||
assert hasattr(help_commands_service, 'restore_help')
|
assert hasattr(help_commands_service, "restore_help")
|
||||||
|
|
||||||
# Search and listing
|
# Search and listing
|
||||||
assert hasattr(help_commands_service, 'search_help_commands')
|
assert hasattr(help_commands_service, "search_help_commands")
|
||||||
assert hasattr(help_commands_service, 'get_all_help_topics')
|
assert hasattr(help_commands_service, "get_all_help_topics")
|
||||||
assert hasattr(help_commands_service, 'get_help_names_for_autocomplete')
|
assert hasattr(help_commands_service, "get_help_names_for_autocomplete")
|
||||||
|
|
||||||
# View tracking
|
# View tracking
|
||||||
assert hasattr(help_commands_service, 'increment_view_count')
|
assert hasattr(help_commands_service, "increment_view_count")
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
assert hasattr(help_commands_service, 'get_statistics')
|
assert hasattr(help_commands_service, "get_statistics")
|
||||||
|
|
||||||
|
|
||||||
class TestHelpCommandsServiceCRUD:
|
class TestHelpCommandsServiceCRUD:
|
||||||
@ -118,7 +120,7 @@ class TestHelpCommandsServiceCRUD:
|
|||||||
last_modified_by=None,
|
last_modified_by=None,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
view_count=0,
|
view_count=0,
|
||||||
display_order=data.get("display_order", 0)
|
display_order=data.get("display_order", 0),
|
||||||
)
|
)
|
||||||
return created_help
|
return created_help
|
||||||
|
|
||||||
@ -130,8 +132,8 @@ class TestHelpCommandsServiceCRUD:
|
|||||||
name="test-topic",
|
name="test-topic",
|
||||||
title="Test Topic",
|
title="Test Topic",
|
||||||
content="This is test content for the help topic.",
|
content="This is test content for the help topic.",
|
||||||
creator_discord_id='123456789',
|
creator_discord_id="123456789",
|
||||||
category="info"
|
category="info",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result, HelpCommand)
|
assert isinstance(result, HelpCommand)
|
||||||
@ -141,39 +143,48 @@ class TestHelpCommandsServiceCRUD:
|
|||||||
assert result.view_count == 0
|
assert result.view_count == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_help_already_exists(self, help_commands_service_instance, sample_help_command):
|
async def test_create_help_already_exists(
|
||||||
|
self, help_commands_service_instance, sample_help_command
|
||||||
|
):
|
||||||
"""Test help command creation when topic already exists."""
|
"""Test help command creation when topic already exists."""
|
||||||
|
|
||||||
# Mock topic already exists
|
# Mock topic already exists
|
||||||
async def mock_get_help_by_name(*args, **kwargs):
|
async def mock_get_help_by_name(*args, **kwargs):
|
||||||
return sample_help_command
|
return sample_help_command
|
||||||
|
|
||||||
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
||||||
|
|
||||||
with pytest.raises(HelpCommandExistsError, match="Help topic 'trading-rules' already exists"):
|
with pytest.raises(
|
||||||
|
HelpCommandExistsError, match="Help topic 'trading-rules' already exists"
|
||||||
|
):
|
||||||
await help_commands_service_instance.create_help(
|
await help_commands_service_instance.create_help(
|
||||||
name="trading-rules",
|
name="trading-rules",
|
||||||
title="Trading Rules",
|
title="Trading Rules",
|
||||||
content="Rules content",
|
content="Rules content",
|
||||||
creator_discord_id='123456789'
|
creator_discord_id="123456789",
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_help_by_name_success(self, help_commands_service_instance, sample_help_command):
|
async def test_get_help_by_name_success(
|
||||||
|
self, help_commands_service_instance, sample_help_command
|
||||||
|
):
|
||||||
"""Test successful help command retrieval."""
|
"""Test successful help command retrieval."""
|
||||||
# Mock the API client to return proper data structure
|
# Mock the API client to return proper data structure
|
||||||
help_data = {
|
help_data = {
|
||||||
'id': sample_help_command.id,
|
"id": sample_help_command.id,
|
||||||
'name': sample_help_command.name,
|
"name": sample_help_command.name,
|
||||||
'title': sample_help_command.title,
|
"title": sample_help_command.title,
|
||||||
'content': sample_help_command.content,
|
"content": sample_help_command.content,
|
||||||
'category': sample_help_command.category,
|
"category": sample_help_command.category,
|
||||||
'created_by_discord_id': sample_help_command.created_by_discord_id,
|
"created_by_discord_id": sample_help_command.created_by_discord_id,
|
||||||
'created_at': sample_help_command.created_at.isoformat(),
|
"created_at": sample_help_command.created_at.isoformat(),
|
||||||
'updated_at': sample_help_command.updated_at.isoformat() if sample_help_command.updated_at else None,
|
"updated_at": sample_help_command.updated_at.isoformat()
|
||||||
'last_modified_by': sample_help_command.last_modified_by,
|
if sample_help_command.updated_at
|
||||||
'is_active': sample_help_command.is_active,
|
else None,
|
||||||
'view_count': sample_help_command.view_count,
|
"last_modified_by": sample_help_command.last_modified_by,
|
||||||
'display_order': sample_help_command.display_order
|
"is_active": sample_help_command.is_active,
|
||||||
|
"view_count": sample_help_command.view_count,
|
||||||
|
"display_order": sample_help_command.display_order,
|
||||||
}
|
}
|
||||||
|
|
||||||
help_commands_service_instance._client.get.return_value = help_data
|
help_commands_service_instance._client.get.return_value = help_data
|
||||||
@ -191,66 +202,61 @@ class TestHelpCommandsServiceCRUD:
|
|||||||
# Mock the API client to return None (not found)
|
# Mock the API client to return None (not found)
|
||||||
help_commands_service_instance._client.get.return_value = None
|
help_commands_service_instance._client.get.return_value = None
|
||||||
|
|
||||||
with pytest.raises(HelpCommandNotFoundError, match="Help topic 'nonexistent' not found"):
|
with pytest.raises(
|
||||||
|
HelpCommandNotFoundError, match="Help topic 'nonexistent' not found"
|
||||||
|
):
|
||||||
await help_commands_service_instance.get_help_by_name("nonexistent")
|
await help_commands_service_instance.get_help_by_name("nonexistent")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_help_success(self, help_commands_service_instance, sample_help_command):
|
async def test_update_help_success(
|
||||||
|
self, help_commands_service_instance, sample_help_command
|
||||||
|
):
|
||||||
"""Test successful help command update."""
|
"""Test successful help command update."""
|
||||||
|
|
||||||
# Mock getting the existing help command
|
# Mock getting the existing help command
|
||||||
async def mock_get_help_by_name(name, include_inactive=False):
|
async def mock_get_help_by_name(name, include_inactive=False):
|
||||||
if name == "trading-rules":
|
if name == "trading-rules":
|
||||||
return sample_help_command
|
return sample_help_command
|
||||||
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
|
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
|
||||||
|
|
||||||
# Mock the API update call
|
# Mock the API update call returning the updated help command data directly
|
||||||
|
updated_data = {
|
||||||
|
"id": sample_help_command.id,
|
||||||
|
"name": sample_help_command.name,
|
||||||
|
"title": "Updated Trading Rules",
|
||||||
|
"content": "Updated content",
|
||||||
|
"category": sample_help_command.category,
|
||||||
|
"created_by_discord_id": sample_help_command.created_by_discord_id,
|
||||||
|
"created_at": sample_help_command.created_at.isoformat(),
|
||||||
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"last_modified_by": "987654321",
|
||||||
|
"is_active": sample_help_command.is_active,
|
||||||
|
"view_count": sample_help_command.view_count,
|
||||||
|
"display_order": sample_help_command.display_order,
|
||||||
|
}
|
||||||
|
|
||||||
async def mock_put(*args, **kwargs):
|
async def mock_put(*args, **kwargs):
|
||||||
return True
|
return updated_data
|
||||||
|
|
||||||
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
||||||
help_commands_service_instance._client.put = mock_put
|
help_commands_service_instance._client.put = mock_put
|
||||||
|
|
||||||
# Update should call get_help_by_name again at the end, so mock it to return updated version
|
|
||||||
updated_help = HelpCommand(
|
|
||||||
id=sample_help_command.id,
|
|
||||||
name=sample_help_command.name,
|
|
||||||
title="Updated Trading Rules",
|
|
||||||
content="Updated content",
|
|
||||||
category=sample_help_command.category,
|
|
||||||
created_by_discord_id=sample_help_command.created_by_discord_id,
|
|
||||||
created_at=sample_help_command.created_at,
|
|
||||||
updated_at=datetime.now(timezone.utc),
|
|
||||||
last_modified_by='987654321',
|
|
||||||
is_active=sample_help_command.is_active,
|
|
||||||
view_count=sample_help_command.view_count,
|
|
||||||
display_order=sample_help_command.display_order
|
|
||||||
)
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
async def mock_get_with_counter(name, include_inactive=False):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
if call_count == 1:
|
|
||||||
return sample_help_command
|
|
||||||
else:
|
|
||||||
return updated_help
|
|
||||||
|
|
||||||
help_commands_service_instance.get_help_by_name = mock_get_with_counter
|
|
||||||
|
|
||||||
result = await help_commands_service_instance.update_help(
|
result = await help_commands_service_instance.update_help(
|
||||||
name="trading-rules",
|
name="trading-rules",
|
||||||
new_title="Updated Trading Rules",
|
new_title="Updated Trading Rules",
|
||||||
new_content="Updated content",
|
new_content="Updated content",
|
||||||
updater_discord_id='987654321'
|
updater_discord_id="987654321",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result, HelpCommand)
|
assert isinstance(result, HelpCommand)
|
||||||
assert result.title == "Updated Trading Rules"
|
assert result.title == "Updated Trading Rules"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_help_success(self, help_commands_service_instance, sample_help_command):
|
async def test_delete_help_success(
|
||||||
|
self, help_commands_service_instance, sample_help_command
|
||||||
|
):
|
||||||
"""Test successful help command deletion (soft delete)."""
|
"""Test successful help command deletion (soft delete)."""
|
||||||
|
|
||||||
# Mock getting the help command
|
# Mock getting the help command
|
||||||
async def mock_get_help_by_name(name, include_inactive=False):
|
async def mock_get_help_by_name(name, include_inactive=False):
|
||||||
return sample_help_command
|
return sample_help_command
|
||||||
@ -272,12 +278,12 @@ class TestHelpCommandsServiceCRUD:
|
|||||||
# Mock getting a deleted help command
|
# Mock getting a deleted help command
|
||||||
deleted_help = HelpCommand(
|
deleted_help = HelpCommand(
|
||||||
id=1,
|
id=1,
|
||||||
name='deleted-topic',
|
name="deleted-topic",
|
||||||
title='Deleted Topic',
|
title="Deleted Topic",
|
||||||
content='Content',
|
content="Content",
|
||||||
created_by_discord_id='123456789',
|
created_by_discord_id="123456789",
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
is_active=False
|
is_active=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def mock_get_help_by_name(name, include_inactive=False):
|
async def mock_get_help_by_name(name, include_inactive=False):
|
||||||
@ -285,15 +291,15 @@ class TestHelpCommandsServiceCRUD:
|
|||||||
|
|
||||||
# Mock the API restore call
|
# Mock the API restore call
|
||||||
restored_data = {
|
restored_data = {
|
||||||
'id': deleted_help.id,
|
"id": deleted_help.id,
|
||||||
'name': deleted_help.name,
|
"name": deleted_help.name,
|
||||||
'title': deleted_help.title,
|
"title": deleted_help.title,
|
||||||
'content': deleted_help.content,
|
"content": deleted_help.content,
|
||||||
'created_by_discord_id': deleted_help.created_by_discord_id,
|
"created_by_discord_id": deleted_help.created_by_discord_id,
|
||||||
'created_at': deleted_help.created_at.isoformat(),
|
"created_at": deleted_help.created_at.isoformat(),
|
||||||
'is_active': True,
|
"is_active": True,
|
||||||
'view_count': 0,
|
"view_count": 0,
|
||||||
'display_order': 0
|
"display_order": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
||||||
@ -312,33 +318,30 @@ class TestHelpCommandsServiceSearch:
|
|||||||
async def test_search_help_commands(self, help_commands_service_instance):
|
async def test_search_help_commands(self, help_commands_service_instance):
|
||||||
"""Test searching for help commands with filters."""
|
"""Test searching for help commands with filters."""
|
||||||
filters = HelpCommandSearchFilters(
|
filters = HelpCommandSearchFilters(
|
||||||
name_contains='trading',
|
name_contains="trading", category="rules", page=1, page_size=10
|
||||||
category='rules',
|
|
||||||
page=1,
|
|
||||||
page_size=10
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock API response
|
# Mock API response
|
||||||
api_response = {
|
api_response = {
|
||||||
'help_commands': [
|
"help_commands": [
|
||||||
{
|
{
|
||||||
'id': 1,
|
"id": 1,
|
||||||
'name': 'trading-rules',
|
"name": "trading-rules",
|
||||||
'title': 'Trading Rules',
|
"title": "Trading Rules",
|
||||||
'content': 'Content',
|
"content": "Content",
|
||||||
'category': 'rules',
|
"category": "rules",
|
||||||
'created_by_discord_id': '123',
|
"created_by_discord_id": "123",
|
||||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
'is_active': True,
|
"is_active": True,
|
||||||
'view_count': 100,
|
"view_count": 100,
|
||||||
'display_order': 0
|
"display_order": 0,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
'total_count': 1,
|
"total_count": 1,
|
||||||
'page': 1,
|
"page": 1,
|
||||||
'page_size': 10,
|
"page_size": 10,
|
||||||
'total_pages': 1,
|
"total_pages": 1,
|
||||||
'has_more': False
|
"has_more": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
help_commands_service_instance._client.get.return_value = api_response
|
help_commands_service_instance._client.get.return_value = api_response
|
||||||
@ -348,33 +351,33 @@ class TestHelpCommandsServiceSearch:
|
|||||||
assert isinstance(result, HelpCommandSearchResult)
|
assert isinstance(result, HelpCommandSearchResult)
|
||||||
assert len(result.help_commands) == 1
|
assert len(result.help_commands) == 1
|
||||||
assert result.total_count == 1
|
assert result.total_count == 1
|
||||||
assert result.help_commands[0].name == 'trading-rules'
|
assert result.help_commands[0].name == "trading-rules"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_all_help_topics(self, help_commands_service_instance):
|
async def test_get_all_help_topics(self, help_commands_service_instance):
|
||||||
"""Test getting all help topics."""
|
"""Test getting all help topics."""
|
||||||
# Mock API response
|
# Mock API response
|
||||||
api_response = {
|
api_response = {
|
||||||
'help_commands': [
|
"help_commands": [
|
||||||
{
|
{
|
||||||
'id': i,
|
"id": i,
|
||||||
'name': f'topic-{i}',
|
"name": f"topic-{i}",
|
||||||
'title': f'Topic {i}',
|
"title": f"Topic {i}",
|
||||||
'content': f'Content {i}',
|
"content": f"Content {i}",
|
||||||
'category': 'rules' if i % 2 == 0 else 'guides',
|
"category": "rules" if i % 2 == 0 else "guides",
|
||||||
'created_by_discord_id': '123',
|
"created_by_discord_id": "123",
|
||||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
'is_active': True,
|
"is_active": True,
|
||||||
'view_count': i * 10,
|
"view_count": i * 10,
|
||||||
'display_order': i
|
"display_order": i,
|
||||||
}
|
}
|
||||||
for i in range(1, 6)
|
for i in range(1, 6)
|
||||||
],
|
],
|
||||||
'total_count': 5,
|
"total_count": 5,
|
||||||
'page': 1,
|
"page": 1,
|
||||||
'page_size': 100,
|
"page_size": 100,
|
||||||
'total_pages': 1,
|
"total_pages": 1,
|
||||||
'has_more': False
|
"has_more": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
help_commands_service_instance._client.get.return_value = api_response
|
help_commands_service_instance._client.get.return_value = api_response
|
||||||
@ -386,42 +389,45 @@ class TestHelpCommandsServiceSearch:
|
|||||||
assert all(isinstance(cmd, HelpCommand) for cmd in result)
|
assert all(isinstance(cmd, HelpCommand) for cmd in result)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_help_names_for_autocomplete(self, help_commands_service_instance):
|
async def test_get_help_names_for_autocomplete(
|
||||||
|
self, help_commands_service_instance
|
||||||
|
):
|
||||||
"""Test getting help names for autocomplete."""
|
"""Test getting help names for autocomplete."""
|
||||||
# Mock API response
|
# Mock API response
|
||||||
api_response = {
|
api_response = {
|
||||||
'results': [
|
"results": [
|
||||||
{
|
{
|
||||||
'name': 'trading-rules',
|
"name": "trading-rules",
|
||||||
'title': 'Trading Rules',
|
"title": "Trading Rules",
|
||||||
'category': 'rules'
|
"category": "rules",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'trading-deadline',
|
"name": "trading-deadline",
|
||||||
'title': 'Trading Deadline',
|
"title": "Trading Deadline",
|
||||||
'category': 'info'
|
"category": "info",
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
help_commands_service_instance._client.get.return_value = api_response
|
help_commands_service_instance._client.get.return_value = api_response
|
||||||
|
|
||||||
result = await help_commands_service_instance.get_help_names_for_autocomplete(
|
result = await help_commands_service_instance.get_help_names_for_autocomplete(
|
||||||
partial_name='trading',
|
partial_name="trading", limit=25
|
||||||
limit=25
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert 'trading-rules' in result
|
assert "trading-rules" in result
|
||||||
assert 'trading-deadline' in result
|
assert "trading-deadline" in result
|
||||||
|
|
||||||
|
|
||||||
class TestHelpCommandsServiceViewTracking:
|
class TestHelpCommandsServiceViewTracking:
|
||||||
"""Test view count tracking."""
|
"""Test view count tracking."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_increment_view_count(self, help_commands_service_instance, sample_help_command):
|
async def test_increment_view_count(
|
||||||
|
self, help_commands_service_instance, sample_help_command
|
||||||
|
):
|
||||||
"""Test incrementing view count."""
|
"""Test incrementing view count."""
|
||||||
# Mock the API patch call
|
# Mock the API patch call
|
||||||
help_commands_service_instance._client.patch = AsyncMock()
|
help_commands_service_instance._client.patch = AsyncMock()
|
||||||
@ -437,7 +443,7 @@ class TestHelpCommandsServiceViewTracking:
|
|||||||
created_at=sample_help_command.created_at,
|
created_at=sample_help_command.created_at,
|
||||||
is_active=sample_help_command.is_active,
|
is_active=sample_help_command.is_active,
|
||||||
view_count=sample_help_command.view_count + 1,
|
view_count=sample_help_command.view_count + 1,
|
||||||
display_order=sample_help_command.display_order
|
display_order=sample_help_command.display_order,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def mock_get_help_by_name(name, include_inactive=False):
|
async def mock_get_help_by_name(name, include_inactive=False):
|
||||||
@ -445,7 +451,9 @@ class TestHelpCommandsServiceViewTracking:
|
|||||||
|
|
||||||
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
||||||
|
|
||||||
result = await help_commands_service_instance.increment_view_count("trading-rules")
|
result = await help_commands_service_instance.increment_view_count(
|
||||||
|
"trading-rules"
|
||||||
|
)
|
||||||
|
|
||||||
assert isinstance(result, HelpCommand)
|
assert isinstance(result, HelpCommand)
|
||||||
assert result.view_count == 101
|
assert result.view_count == 101
|
||||||
@ -459,21 +467,21 @@ class TestHelpCommandsServiceStatistics:
|
|||||||
"""Test getting help command statistics."""
|
"""Test getting help command statistics."""
|
||||||
# Mock API response
|
# Mock API response
|
||||||
api_response = {
|
api_response = {
|
||||||
'total_commands': 50,
|
"total_commands": 50,
|
||||||
'active_commands': 45,
|
"active_commands": 45,
|
||||||
'total_views': 5000,
|
"total_views": 5000,
|
||||||
'most_viewed_command': {
|
"most_viewed_command": {
|
||||||
'id': 1,
|
"id": 1,
|
||||||
'name': 'popular-topic',
|
"name": "popular-topic",
|
||||||
'title': 'Popular Topic',
|
"title": "Popular Topic",
|
||||||
'content': 'Content',
|
"content": "Content",
|
||||||
'created_by_discord_id': '123',
|
"created_by_discord_id": "123",
|
||||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
'is_active': True,
|
"is_active": True,
|
||||||
'view_count': 500,
|
"view_count": 500,
|
||||||
'display_order': 0
|
"display_order": 0,
|
||||||
},
|
},
|
||||||
'recent_commands_count': 5
|
"recent_commands_count": 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
help_commands_service_instance._client.get.return_value = api_response
|
help_commands_service_instance._client.get.return_value = api_response
|
||||||
@ -485,7 +493,7 @@ class TestHelpCommandsServiceStatistics:
|
|||||||
assert result.active_commands == 45
|
assert result.active_commands == 45
|
||||||
assert result.total_views == 5000
|
assert result.total_views == 5000
|
||||||
assert result.most_viewed_command is not None
|
assert result.most_viewed_command is not None
|
||||||
assert result.most_viewed_command.name == 'popular-topic'
|
assert result.most_viewed_command.name == "popular-topic"
|
||||||
assert result.recent_commands_count == 5
|
assert result.recent_commands_count == 5
|
||||||
|
|
||||||
|
|
||||||
@ -498,7 +506,9 @@ class TestHelpCommandsServiceErrorHandling:
|
|||||||
from exceptions import APIException, BotException
|
from exceptions import APIException, BotException
|
||||||
|
|
||||||
# Mock the API client to raise an APIException
|
# Mock the API client to raise an APIException
|
||||||
help_commands_service_instance._client.get.side_effect = APIException("Connection error")
|
help_commands_service_instance._client.get.side_effect = APIException(
|
||||||
|
"Connection error"
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(BotException, match="Failed to retrieve help topic 'test'"):
|
with pytest.raises(BotException, match="Failed to retrieve help topic 'test'"):
|
||||||
await help_commands_service_instance.get_help_by_name("test")
|
await help_commands_service_instance.get_help_by_name("test")
|
||||||
|
|||||||
284
tests/test_services_schedule.py
Normal file
284
tests/test_services_schedule.py
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
"""
|
||||||
|
Tests for schedule service functionality.
|
||||||
|
|
||||||
|
Covers get_week_schedule, get_team_schedule, get_recent_games,
|
||||||
|
get_upcoming_games, and group_games_by_series — verifying the
|
||||||
|
asyncio.gather parallelization and post-fetch filtering logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from services.schedule_service import ScheduleService
|
||||||
|
from tests.factories import GameFactory, TeamFactory
|
||||||
|
|
||||||
|
|
||||||
|
def _game(game_id, week, away_abbrev, home_abbrev, **kwargs):
|
||||||
|
"""Create a Game with distinct team IDs per matchup."""
|
||||||
|
return GameFactory.create(
|
||||||
|
id=game_id,
|
||||||
|
week=week,
|
||||||
|
away_team=TeamFactory.create(id=game_id * 10, abbrev=away_abbrev),
|
||||||
|
home_team=TeamFactory.create(id=game_id * 10 + 1, abbrev=home_abbrev),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetWeekSchedule:
|
||||||
|
"""Tests for ScheduleService.get_week_schedule — the HTTP layer."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(self):
|
||||||
|
svc = ScheduleService()
|
||||||
|
svc.get_client = AsyncMock()
|
||||||
|
return svc
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_success(self, service):
|
||||||
|
"""get_week_schedule returns parsed Game objects on a normal response."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get.return_value = {
|
||||||
|
"games": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"season": 12,
|
||||||
|
"week": 5,
|
||||||
|
"game_num": 1,
|
||||||
|
"season_type": "regular",
|
||||||
|
"away_team": {
|
||||||
|
"id": 10,
|
||||||
|
"abbrev": "NYY",
|
||||||
|
"sname": "NYY",
|
||||||
|
"lname": "New York",
|
||||||
|
"season": 12,
|
||||||
|
},
|
||||||
|
"home_team": {
|
||||||
|
"id": 11,
|
||||||
|
"abbrev": "BOS",
|
||||||
|
"sname": "BOS",
|
||||||
|
"lname": "Boston",
|
||||||
|
"season": 12,
|
||||||
|
},
|
||||||
|
"away_score": 4,
|
||||||
|
"home_score": 2,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
service.get_client.return_value = mock_client
|
||||||
|
|
||||||
|
games = await service.get_week_schedule(12, 5)
|
||||||
|
|
||||||
|
assert len(games) == 1
|
||||||
|
assert games[0].away_team.abbrev == "NYY"
|
||||||
|
assert games[0].home_team.abbrev == "BOS"
|
||||||
|
assert games[0].is_completed
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_response(self, service):
|
||||||
|
"""get_week_schedule returns [] when the API has no games."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get.return_value = {"games": []}
|
||||||
|
service.get_client.return_value = mock_client
|
||||||
|
|
||||||
|
games = await service.get_week_schedule(12, 99)
|
||||||
|
assert games == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_error_returns_empty(self, service):
|
||||||
|
"""get_week_schedule returns [] on API error (no exception raised)."""
|
||||||
|
service.get_client.side_effect = Exception("connection refused")
|
||||||
|
|
||||||
|
games = await service.get_week_schedule(12, 1)
|
||||||
|
assert games == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_games_key(self, service):
|
||||||
|
"""get_week_schedule returns [] when response lacks 'games' key."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get.return_value = {"status": "ok"}
|
||||||
|
service.get_client.return_value = mock_client
|
||||||
|
|
||||||
|
games = await service.get_week_schedule(12, 1)
|
||||||
|
assert games == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetTeamSchedule:
|
||||||
|
"""Tests for get_team_schedule — gather + team-abbrev filter."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(self):
|
||||||
|
return ScheduleService()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filters_by_team_case_insensitive(self, service):
|
||||||
|
"""get_team_schedule returns only games involving the requested team,
|
||||||
|
regardless of abbreviation casing."""
|
||||||
|
week1 = [
|
||||||
|
_game(1, 1, "NYY", "BOS", away_score=3, home_score=1),
|
||||||
|
_game(2, 1, "LAD", "CHC", away_score=5, home_score=2),
|
||||||
|
]
|
||||||
|
week2 = [
|
||||||
|
_game(3, 2, "BOS", "NYY", away_score=2, home_score=4),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||||||
|
mock.side_effect = [week1, week2]
|
||||||
|
result = await service.get_team_schedule(12, "nyy", weeks=2)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert all(
|
||||||
|
g.away_team.abbrev == "NYY" or g.home_team.abbrev == "NYY" for g in result
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_season_fetches_18_weeks(self, service):
|
||||||
|
"""When weeks is None, all 18 weeks are fetched via gather."""
|
||||||
|
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = []
|
||||||
|
await service.get_team_schedule(12, "NYY")
|
||||||
|
|
||||||
|
assert mock.call_count == 18
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_limited_weeks(self, service):
|
||||||
|
"""When weeks=5, only 5 weeks are fetched."""
|
||||||
|
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = []
|
||||||
|
await service.get_team_schedule(12, "NYY", weeks=5)
|
||||||
|
|
||||||
|
assert mock.call_count == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRecentGames:
|
||||||
|
"""Tests for get_recent_games — gather + completed-only filter."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(self):
|
||||||
|
return ScheduleService()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_only_completed_games(self, service):
|
||||||
|
"""get_recent_games filters out games without scores."""
|
||||||
|
completed = GameFactory.completed(id=1, week=10)
|
||||||
|
incomplete = GameFactory.upcoming(id=2, week=10)
|
||||||
|
|
||||||
|
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = [completed, incomplete]
|
||||||
|
result = await service.get_recent_games(12, weeks_back=1)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].is_completed
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sorted_descending_by_week_and_game_num(self, service):
|
||||||
|
"""Recent games are sorted most-recent first."""
|
||||||
|
game_w10 = GameFactory.completed(id=1, week=10, game_num=2)
|
||||||
|
game_w9 = GameFactory.completed(id=2, week=9, game_num=1)
|
||||||
|
|
||||||
|
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||||||
|
mock.side_effect = [[game_w10], [game_w9]]
|
||||||
|
result = await service.get_recent_games(12, weeks_back=2)
|
||||||
|
|
||||||
|
assert result[0].week == 10
|
||||||
|
assert result[1].week == 9
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_negative_weeks(self, service):
|
||||||
|
"""Weeks that would be <= 0 are excluded from fetch."""
|
||||||
|
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = []
|
||||||
|
await service.get_recent_games(12, weeks_back=15)
|
||||||
|
|
||||||
|
# weeks_to_fetch = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] — only 10 valid weeks
|
||||||
|
assert mock.call_count == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetUpcomingGames:
|
||||||
|
"""Tests for get_upcoming_games — gather all 18 weeks + incomplete filter."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(self):
|
||||||
|
return ScheduleService()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_only_incomplete_games(self, service):
|
||||||
|
"""get_upcoming_games filters out completed games."""
|
||||||
|
completed = GameFactory.completed(id=1, week=5)
|
||||||
|
upcoming = GameFactory.upcoming(id=2, week=5)
|
||||||
|
|
||||||
|
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = [completed, upcoming]
|
||||||
|
result = await service.get_upcoming_games(12)
|
||||||
|
|
||||||
|
assert len(result) == 18 # 1 incomplete game per week × 18 weeks
|
||||||
|
assert all(not g.is_completed for g in result)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sorted_ascending_by_week_and_game_num(self, service):
|
||||||
|
"""Upcoming games are sorted earliest first."""
|
||||||
|
game_w3 = GameFactory.upcoming(id=1, week=3, game_num=1)
|
||||||
|
game_w1 = GameFactory.upcoming(id=2, week=1, game_num=2)
|
||||||
|
|
||||||
|
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||||||
|
|
||||||
|
def side_effect(season, week):
|
||||||
|
if week == 1:
|
||||||
|
return [game_w1]
|
||||||
|
if week == 3:
|
||||||
|
return [game_w3]
|
||||||
|
return []
|
||||||
|
|
||||||
|
mock.side_effect = side_effect
|
||||||
|
result = await service.get_upcoming_games(12)
|
||||||
|
|
||||||
|
assert result[0].week == 1
|
||||||
|
assert result[1].week == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetches_all_18_weeks(self, service):
|
||||||
|
"""All 18 weeks are fetched in parallel (no early exit)."""
|
||||||
|
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = []
|
||||||
|
await service.get_upcoming_games(12)
|
||||||
|
|
||||||
|
assert mock.call_count == 18
|
||||||
|
|
||||||
|
|
||||||
|
class TestGroupGamesBySeries:
|
||||||
|
"""Tests for group_games_by_series — synchronous grouping logic."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(self):
|
||||||
|
return ScheduleService()
|
||||||
|
|
||||||
|
def test_groups_by_alphabetical_pairing(self, service):
|
||||||
|
"""Games between the same two teams are grouped under one key,
|
||||||
|
with the alphabetically-first team first in the tuple."""
|
||||||
|
games = [
|
||||||
|
_game(1, 1, "NYY", "BOS", game_num=1),
|
||||||
|
_game(2, 1, "BOS", "NYY", game_num=2),
|
||||||
|
_game(3, 1, "LAD", "CHC", game_num=1),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = service.group_games_by_series(games)
|
||||||
|
|
||||||
|
assert ("BOS", "NYY") in result
|
||||||
|
assert len(result[("BOS", "NYY")]) == 2
|
||||||
|
assert ("CHC", "LAD") in result
|
||||||
|
assert len(result[("CHC", "LAD")]) == 1
|
||||||
|
|
||||||
|
def test_sorted_by_game_num_within_series(self, service):
|
||||||
|
"""Games within each series are sorted by game_num."""
|
||||||
|
games = [
|
||||||
|
_game(1, 1, "NYY", "BOS", game_num=3),
|
||||||
|
_game(2, 1, "NYY", "BOS", game_num=1),
|
||||||
|
_game(3, 1, "NYY", "BOS", game_num=2),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = service.group_games_by_series(games)
|
||||||
|
series = result[("BOS", "NYY")]
|
||||||
|
assert [g.game_num for g in series] == [1, 2, 3]
|
||||||
|
|
||||||
|
def test_empty_input(self, service):
|
||||||
|
"""Empty games list returns empty dict."""
|
||||||
|
assert service.group_games_by_series([]) == {}
|
||||||
111
tests/test_services_stats.py
Normal file
111
tests/test_services_stats.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
Tests for StatsService
|
||||||
|
|
||||||
|
Validates stats service functionality including concurrent stat retrieval
|
||||||
|
and error handling in get_player_stats().
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from services.stats_service import StatsService
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatsServiceGetPlayerStats:
|
||||||
|
"""Test StatsService.get_player_stats() concurrent retrieval."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(self):
|
||||||
|
"""Create a fresh StatsService instance for testing."""
|
||||||
|
return StatsService()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_batting_stats(self):
|
||||||
|
"""Create a mock BattingStats object."""
|
||||||
|
stats = MagicMock()
|
||||||
|
stats.avg = 0.300
|
||||||
|
return stats
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_pitching_stats(self):
|
||||||
|
"""Create a mock PitchingStats object."""
|
||||||
|
stats = MagicMock()
|
||||||
|
stats.era = 3.50
|
||||||
|
return stats
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_both_stats_returned(
|
||||||
|
self, service, mock_batting_stats, mock_pitching_stats
|
||||||
|
):
|
||||||
|
"""When both batting and pitching stats exist, both are returned.
|
||||||
|
|
||||||
|
Verifies that get_player_stats returns a tuple of (batting, pitching)
|
||||||
|
when both stat types are available for the player.
|
||||||
|
"""
|
||||||
|
service.get_batting_stats = AsyncMock(return_value=mock_batting_stats)
|
||||||
|
service.get_pitching_stats = AsyncMock(return_value=mock_pitching_stats)
|
||||||
|
|
||||||
|
batting, pitching = await service.get_player_stats(player_id=100, season=12)
|
||||||
|
|
||||||
|
assert batting is mock_batting_stats
|
||||||
|
assert pitching is mock_pitching_stats
|
||||||
|
service.get_batting_stats.assert_called_once_with(100, 12)
|
||||||
|
service.get_pitching_stats.assert_called_once_with(100, 12)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batting_only(self, service, mock_batting_stats):
|
||||||
|
"""When only batting stats exist, pitching is None.
|
||||||
|
|
||||||
|
Covers the case of a position player with no pitching record.
|
||||||
|
"""
|
||||||
|
service.get_batting_stats = AsyncMock(return_value=mock_batting_stats)
|
||||||
|
service.get_pitching_stats = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
batting, pitching = await service.get_player_stats(player_id=200, season=12)
|
||||||
|
|
||||||
|
assert batting is mock_batting_stats
|
||||||
|
assert pitching is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pitching_only(self, service, mock_pitching_stats):
|
||||||
|
"""When only pitching stats exist, batting is None.
|
||||||
|
|
||||||
|
Covers the case of a pitcher with no batting record.
|
||||||
|
"""
|
||||||
|
service.get_batting_stats = AsyncMock(return_value=None)
|
||||||
|
service.get_pitching_stats = AsyncMock(return_value=mock_pitching_stats)
|
||||||
|
|
||||||
|
batting, pitching = await service.get_player_stats(player_id=300, season=12)
|
||||||
|
|
||||||
|
assert batting is None
|
||||||
|
assert pitching is mock_pitching_stats
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_stats_found(self, service):
|
||||||
|
"""When no stats exist for the player, both are None.
|
||||||
|
|
||||||
|
Covers the case where a player has no stats for the given season
|
||||||
|
(e.g., didn't play).
|
||||||
|
"""
|
||||||
|
service.get_batting_stats = AsyncMock(return_value=None)
|
||||||
|
service.get_pitching_stats = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
batting, pitching = await service.get_player_stats(player_id=400, season=12)
|
||||||
|
|
||||||
|
assert batting is None
|
||||||
|
assert pitching is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exception_returns_none_tuple(self, service):
|
||||||
|
"""When an exception occurs, (None, None) is returned.
|
||||||
|
|
||||||
|
The get_player_stats method wraps both calls in a try/except and
|
||||||
|
returns (None, None) on any error, ensuring callers always get a tuple.
|
||||||
|
"""
|
||||||
|
service.get_batting_stats = AsyncMock(side_effect=RuntimeError("API down"))
|
||||||
|
service.get_pitching_stats = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
batting, pitching = await service.get_player_stats(player_id=500, season=12)
|
||||||
|
|
||||||
|
assert batting is None
|
||||||
|
assert pitching is None
|
||||||
@ -115,6 +115,13 @@ class TestTransactionBuilder:
|
|||||||
svc.get_current_roster.return_value = mock_roster
|
svc.get_current_roster.return_value = mock_roster
|
||||||
return svc
|
return svc
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_league_service(self):
|
||||||
|
"""Patch league_service for all tests so FA lock check uses week 10 (before deadline)."""
|
||||||
|
with patch("services.transaction_builder.league_service") as mock_ls:
|
||||||
|
mock_ls.get_current_state = AsyncMock(return_value=MagicMock(week=10))
|
||||||
|
yield mock_ls
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def builder(self, mock_team, mock_roster_service):
|
def builder(self, mock_team, mock_roster_service):
|
||||||
"""Create a TransactionBuilder for testing with injected roster service."""
|
"""Create a TransactionBuilder for testing with injected roster service."""
|
||||||
@ -152,6 +159,50 @@ class TestTransactionBuilder:
|
|||||||
assert builder.is_empty is False
|
assert builder.is_empty is False
|
||||||
assert move in builder.moves
|
assert move in builder.moves
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_move_from_fa_blocked_after_deadline(self, builder, mock_player):
|
||||||
|
"""Test that adding a player FROM Free Agency is blocked after fa_lock_week."""
|
||||||
|
move = TransactionMove(
|
||||||
|
player=mock_player,
|
||||||
|
from_roster=RosterType.FREE_AGENCY,
|
||||||
|
to_roster=RosterType.MAJOR_LEAGUE,
|
||||||
|
to_team=builder.team,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"services.transaction_builder.league_service"
|
||||||
|
) as mock_league_service:
|
||||||
|
mock_league_service.get_current_state = AsyncMock(
|
||||||
|
return_value=MagicMock(week=15)
|
||||||
|
)
|
||||||
|
|
||||||
|
success, error_message = await builder.add_move(
|
||||||
|
move, check_pending_transactions=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert "Free agency is closed" in error_message
|
||||||
|
assert builder.move_count == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drop_to_fa_allowed_after_deadline(self, builder, mock_player):
|
||||||
|
"""Test that dropping a player TO Free Agency is still allowed after fa_lock_week."""
|
||||||
|
move = TransactionMove(
|
||||||
|
player=mock_player,
|
||||||
|
from_roster=RosterType.MAJOR_LEAGUE,
|
||||||
|
to_roster=RosterType.FREE_AGENCY,
|
||||||
|
from_team=builder.team,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop to FA doesn't trigger the FA lock check (autouse fixture provides week 10)
|
||||||
|
success, error_message = await builder.add_move(
|
||||||
|
move, check_pending_transactions=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert error_message == ""
|
||||||
|
assert builder.move_count == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_add_duplicate_move_fails(self, builder, mock_player):
|
async def test_add_duplicate_move_fails(self, builder, mock_player):
|
||||||
"""Test that adding duplicate moves for same player fails."""
|
"""Test that adding duplicate moves for same player fails."""
|
||||||
@ -809,6 +860,13 @@ class TestPendingTransactionValidation:
|
|||||||
"""Create a mock player for testing."""
|
"""Create a mock player for testing."""
|
||||||
return Player(id=12472, name="Test Player", wara=2.5, season=12, pos_1="OF")
|
return Player(id=12472, name="Test Player", wara=2.5, season=12, pos_1="OF")
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_league_service(self):
|
||||||
|
"""Patch league_service so FA lock check and week resolution use week 10."""
|
||||||
|
with patch("services.transaction_builder.league_service") as mock_ls:
|
||||||
|
mock_ls.get_current_state = AsyncMock(return_value=MagicMock(week=10))
|
||||||
|
yield mock_ls
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def builder(self, mock_team):
|
def builder(self, mock_team):
|
||||||
"""Create a TransactionBuilder for testing."""
|
"""Create a TransactionBuilder for testing."""
|
||||||
|
|||||||
@ -3,10 +3,16 @@ Tests for shared autocomplete utility functions.
|
|||||||
|
|
||||||
Validates the shared autocomplete functions used across multiple command modules.
|
Validates the shared autocomplete functions used across multiple command modules.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
from utils.autocomplete import player_autocomplete, team_autocomplete, major_league_team_autocomplete
|
import utils.autocomplete
|
||||||
|
from utils.autocomplete import (
|
||||||
|
player_autocomplete,
|
||||||
|
team_autocomplete,
|
||||||
|
major_league_team_autocomplete,
|
||||||
|
)
|
||||||
from tests.factories import PlayerFactory, TeamFactory
|
from tests.factories import PlayerFactory, TeamFactory
|
||||||
from models.team import RosterType
|
from models.team import RosterType
|
||||||
|
|
||||||
@ -14,6 +20,13 @@ from models.team import RosterType
|
|||||||
class TestPlayerAutocomplete:
|
class TestPlayerAutocomplete:
|
||||||
"""Test player autocomplete functionality."""
|
"""Test player autocomplete functionality."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_user_team_cache(self):
|
||||||
|
"""Clear the module-level user team cache before each test to prevent interference."""
|
||||||
|
utils.autocomplete._user_team_cache.clear()
|
||||||
|
yield
|
||||||
|
utils.autocomplete._user_team_cache.clear()
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_interaction(self):
|
def mock_interaction(self):
|
||||||
"""Create a mock Discord interaction."""
|
"""Create a mock Discord interaction."""
|
||||||
@ -26,41 +39,43 @@ class TestPlayerAutocomplete:
|
|||||||
"""Test successful player autocomplete."""
|
"""Test successful player autocomplete."""
|
||||||
mock_players = [
|
mock_players = [
|
||||||
PlayerFactory.mike_trout(id=1),
|
PlayerFactory.mike_trout(id=1),
|
||||||
PlayerFactory.ronald_acuna(id=2)
|
PlayerFactory.ronald_acuna(id=2),
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch('utils.autocomplete.player_service') as mock_service:
|
with patch("utils.autocomplete.player_service") as mock_service:
|
||||||
mock_service.search_players = AsyncMock(return_value=mock_players)
|
mock_service.search_players = AsyncMock(return_value=mock_players)
|
||||||
|
|
||||||
choices = await player_autocomplete(mock_interaction, 'Trout')
|
choices = await player_autocomplete(mock_interaction, "Trout")
|
||||||
|
|
||||||
assert len(choices) == 2
|
assert len(choices) == 2
|
||||||
assert choices[0].name == 'Mike Trout (CF)'
|
assert choices[0].name == "Mike Trout (CF)"
|
||||||
assert choices[0].value == 'Mike Trout'
|
assert choices[0].value == "Mike Trout"
|
||||||
assert choices[1].name == 'Ronald Acuna Jr. (OF)'
|
assert choices[1].name == "Ronald Acuna Jr. (OF)"
|
||||||
assert choices[1].value == 'Ronald Acuna Jr.'
|
assert choices[1].value == "Ronald Acuna Jr."
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_player_autocomplete_with_team_info(self, mock_interaction):
|
async def test_player_autocomplete_with_team_info(self, mock_interaction):
|
||||||
"""Test player autocomplete with team information."""
|
"""Test player autocomplete with team information."""
|
||||||
mock_team = TeamFactory.create(id=499, abbrev='LAA', sname='Angels', lname='Los Angeles Angels')
|
mock_team = TeamFactory.create(
|
||||||
|
id=499, abbrev="LAA", sname="Angels", lname="Los Angeles Angels"
|
||||||
|
)
|
||||||
mock_player = PlayerFactory.mike_trout(id=1)
|
mock_player = PlayerFactory.mike_trout(id=1)
|
||||||
mock_player.team = mock_team
|
mock_player.team = mock_team
|
||||||
|
|
||||||
with patch('utils.autocomplete.player_service') as mock_service:
|
with patch("utils.autocomplete.player_service") as mock_service:
|
||||||
mock_service.search_players = AsyncMock(return_value=[mock_player])
|
mock_service.search_players = AsyncMock(return_value=[mock_player])
|
||||||
|
|
||||||
choices = await player_autocomplete(mock_interaction, 'Trout')
|
choices = await player_autocomplete(mock_interaction, "Trout")
|
||||||
|
|
||||||
assert len(choices) == 1
|
assert len(choices) == 1
|
||||||
assert choices[0].name == 'Mike Trout (CF - LAA)'
|
assert choices[0].name == "Mike Trout (CF - LAA)"
|
||||||
assert choices[0].value == 'Mike Trout'
|
assert choices[0].value == "Mike Trout"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_player_autocomplete_prioritizes_user_team(self, mock_interaction):
|
async def test_player_autocomplete_prioritizes_user_team(self, mock_interaction):
|
||||||
"""Test that user's team players are prioritized in autocomplete."""
|
"""Test that user's team players are prioritized in autocomplete."""
|
||||||
user_team = TeamFactory.create(id=1, abbrev='POR', sname='Loggers')
|
user_team = TeamFactory.create(id=1, abbrev="POR", sname="Loggers")
|
||||||
other_team = TeamFactory.create(id=2, abbrev='LAA', sname='Angels')
|
other_team = TeamFactory.create(id=2, abbrev="LAA", sname="Angels")
|
||||||
|
|
||||||
# Create players - one from user's team, one from other team
|
# Create players - one from user's team, one from other team
|
||||||
user_player = PlayerFactory.mike_trout(id=1)
|
user_player = PlayerFactory.mike_trout(id=1)
|
||||||
@ -71,32 +86,35 @@ class TestPlayerAutocomplete:
|
|||||||
other_player.team = other_team
|
other_player.team = other_team
|
||||||
other_player.team_id = other_team.id
|
other_player.team_id = other_team.id
|
||||||
|
|
||||||
with patch('utils.autocomplete.player_service') as mock_service, \
|
with (
|
||||||
patch('utils.autocomplete.get_user_major_league_team') as mock_get_team:
|
patch("utils.autocomplete.player_service") as mock_service,
|
||||||
|
patch("utils.autocomplete.get_user_major_league_team") as mock_get_team,
|
||||||
mock_service.search_players = AsyncMock(return_value=[other_player, user_player])
|
):
|
||||||
|
mock_service.search_players = AsyncMock(
|
||||||
|
return_value=[other_player, user_player]
|
||||||
|
)
|
||||||
mock_get_team.return_value = user_team
|
mock_get_team.return_value = user_team
|
||||||
|
|
||||||
choices = await player_autocomplete(mock_interaction, 'player')
|
choices = await player_autocomplete(mock_interaction, "player")
|
||||||
|
|
||||||
assert len(choices) == 2
|
assert len(choices) == 2
|
||||||
# User's team player should be first
|
# User's team player should be first
|
||||||
assert choices[0].name == 'Mike Trout (CF - POR)'
|
assert choices[0].name == "Mike Trout (CF - POR)"
|
||||||
assert choices[1].name == 'Ronald Acuna Jr. (OF - LAA)'
|
assert choices[1].name == "Ronald Acuna Jr. (OF - LAA)"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_player_autocomplete_short_input(self, mock_interaction):
|
async def test_player_autocomplete_short_input(self, mock_interaction):
|
||||||
"""Test player autocomplete with short input returns empty."""
|
"""Test player autocomplete with short input returns empty."""
|
||||||
choices = await player_autocomplete(mock_interaction, 'T')
|
choices = await player_autocomplete(mock_interaction, "T")
|
||||||
assert len(choices) == 0
|
assert len(choices) == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_player_autocomplete_error_handling(self, mock_interaction):
|
async def test_player_autocomplete_error_handling(self, mock_interaction):
|
||||||
"""Test player autocomplete error handling."""
|
"""Test player autocomplete error handling."""
|
||||||
with patch('utils.autocomplete.player_service') as mock_service:
|
with patch("utils.autocomplete.player_service") as mock_service:
|
||||||
mock_service.search_players.side_effect = Exception("API Error")
|
mock_service.search_players.side_effect = Exception("API Error")
|
||||||
|
|
||||||
choices = await player_autocomplete(mock_interaction, 'Trout')
|
choices = await player_autocomplete(mock_interaction, "Trout")
|
||||||
assert len(choices) == 0
|
assert len(choices) == 0
|
||||||
|
|
||||||
|
|
||||||
@ -114,35 +132,35 @@ class TestTeamAutocomplete:
|
|||||||
async def test_team_autocomplete_success(self, mock_interaction):
|
async def test_team_autocomplete_success(self, mock_interaction):
|
||||||
"""Test successful team autocomplete."""
|
"""Test successful team autocomplete."""
|
||||||
mock_teams = [
|
mock_teams = [
|
||||||
TeamFactory.create(id=1, abbrev='LAA', sname='Angels'),
|
TeamFactory.create(id=1, abbrev="LAA", sname="Angels"),
|
||||||
TeamFactory.create(id=2, abbrev='LAAMIL', sname='Salt Lake Bees'),
|
TeamFactory.create(id=2, abbrev="LAAMIL", sname="Salt Lake Bees"),
|
||||||
TeamFactory.create(id=3, abbrev='LAAAIL', sname='Angels IL'),
|
TeamFactory.create(id=3, abbrev="LAAAIL", sname="Angels IL"),
|
||||||
TeamFactory.create(id=4, abbrev='POR', sname='Loggers')
|
TeamFactory.create(id=4, abbrev="POR", sname="Loggers"),
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch('utils.autocomplete.team_service') as mock_service:
|
with patch("utils.autocomplete.team_service") as mock_service:
|
||||||
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
||||||
|
|
||||||
choices = await team_autocomplete(mock_interaction, 'la')
|
choices = await team_autocomplete(mock_interaction, "la")
|
||||||
|
|
||||||
assert len(choices) == 3 # All teams with 'la' in abbrev or sname
|
assert len(choices) == 3 # All teams with 'la' in abbrev or sname
|
||||||
assert any('LAA' in choice.name for choice in choices)
|
assert any("LAA" in choice.name for choice in choices)
|
||||||
assert any('LAAMIL' in choice.name for choice in choices)
|
assert any("LAAMIL" in choice.name for choice in choices)
|
||||||
assert any('LAAAIL' in choice.name for choice in choices)
|
assert any("LAAAIL" in choice.name for choice in choices)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_team_autocomplete_short_input(self, mock_interaction):
|
async def test_team_autocomplete_short_input(self, mock_interaction):
|
||||||
"""Test team autocomplete with very short input."""
|
"""Test team autocomplete with very short input."""
|
||||||
choices = await team_autocomplete(mock_interaction, '')
|
choices = await team_autocomplete(mock_interaction, "")
|
||||||
assert len(choices) == 0
|
assert len(choices) == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_team_autocomplete_error_handling(self, mock_interaction):
|
async def test_team_autocomplete_error_handling(self, mock_interaction):
|
||||||
"""Test team autocomplete error handling."""
|
"""Test team autocomplete error handling."""
|
||||||
with patch('utils.autocomplete.team_service') as mock_service:
|
with patch("utils.autocomplete.team_service") as mock_service:
|
||||||
mock_service.get_teams_by_season.side_effect = Exception("API Error")
|
mock_service.get_teams_by_season.side_effect = Exception("API Error")
|
||||||
|
|
||||||
choices = await team_autocomplete(mock_interaction, 'LAA')
|
choices = await team_autocomplete(mock_interaction, "LAA")
|
||||||
assert len(choices) == 0
|
assert len(choices) == 0
|
||||||
|
|
||||||
|
|
||||||
@ -157,101 +175,197 @@ class TestMajorLeagueTeamAutocomplete:
|
|||||||
return interaction
|
return interaction
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_major_league_team_autocomplete_filters_correctly(self, mock_interaction):
|
async def test_major_league_team_autocomplete_filters_correctly(
|
||||||
|
self, mock_interaction
|
||||||
|
):
|
||||||
"""Test that only major league teams are returned."""
|
"""Test that only major league teams are returned."""
|
||||||
# Create teams with different roster types
|
# Create teams with different roster types
|
||||||
mock_teams = [
|
mock_teams = [
|
||||||
TeamFactory.create(id=1, abbrev='LAA', sname='Angels'), # ML
|
TeamFactory.create(id=1, abbrev="LAA", sname="Angels"), # ML
|
||||||
TeamFactory.create(id=2, abbrev='LAAMIL', sname='Salt Lake Bees'), # MiL
|
TeamFactory.create(id=2, abbrev="LAAMIL", sname="Salt Lake Bees"), # MiL
|
||||||
TeamFactory.create(id=3, abbrev='LAAAIL', sname='Angels IL'), # IL
|
TeamFactory.create(id=3, abbrev="LAAAIL", sname="Angels IL"), # IL
|
||||||
TeamFactory.create(id=4, abbrev='FA', sname='Free Agents'), # FA
|
TeamFactory.create(id=4, abbrev="FA", sname="Free Agents"), # FA
|
||||||
TeamFactory.create(id=5, abbrev='POR', sname='Loggers'), # ML
|
TeamFactory.create(id=5, abbrev="POR", sname="Loggers"), # ML
|
||||||
TeamFactory.create(id=6, abbrev='PORMIL', sname='Portland MiL'), # MiL
|
TeamFactory.create(id=6, abbrev="PORMIL", sname="Portland MiL"), # MiL
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch('utils.autocomplete.team_service') as mock_service:
|
with patch("utils.autocomplete.team_service") as mock_service:
|
||||||
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
||||||
|
|
||||||
choices = await major_league_team_autocomplete(mock_interaction, 'l')
|
choices = await major_league_team_autocomplete(mock_interaction, "l")
|
||||||
|
|
||||||
# Should only return major league teams that match 'l' (LAA, POR)
|
# Should only return major league teams that match 'l' (LAA, POR)
|
||||||
choice_values = [choice.value for choice in choices]
|
choice_values = [choice.value for choice in choices]
|
||||||
assert 'LAA' in choice_values
|
assert "LAA" in choice_values
|
||||||
assert 'POR' in choice_values
|
assert "POR" in choice_values
|
||||||
assert len(choice_values) == 2
|
assert len(choice_values) == 2
|
||||||
# Should NOT include MiL, IL, or FA teams
|
# Should NOT include MiL, IL, or FA teams
|
||||||
assert 'LAAMIL' not in choice_values
|
assert "LAAMIL" not in choice_values
|
||||||
assert 'LAAAIL' not in choice_values
|
assert "LAAAIL" not in choice_values
|
||||||
assert 'FA' not in choice_values
|
assert "FA" not in choice_values
|
||||||
assert 'PORMIL' not in choice_values
|
assert "PORMIL" not in choice_values
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_major_league_team_autocomplete_matching(self, mock_interaction):
|
async def test_major_league_team_autocomplete_matching(self, mock_interaction):
|
||||||
"""Test search matching on abbreviation and short name."""
|
"""Test search matching on abbreviation and short name."""
|
||||||
mock_teams = [
|
mock_teams = [
|
||||||
TeamFactory.create(id=1, abbrev='LAA', sname='Angels'),
|
TeamFactory.create(id=1, abbrev="LAA", sname="Angels"),
|
||||||
TeamFactory.create(id=2, abbrev='LAD', sname='Dodgers'),
|
TeamFactory.create(id=2, abbrev="LAD", sname="Dodgers"),
|
||||||
TeamFactory.create(id=3, abbrev='POR', sname='Loggers'),
|
TeamFactory.create(id=3, abbrev="POR", sname="Loggers"),
|
||||||
TeamFactory.create(id=4, abbrev='BOS', sname='Red Sox'),
|
TeamFactory.create(id=4, abbrev="BOS", sname="Red Sox"),
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch('utils.autocomplete.team_service') as mock_service:
|
with patch("utils.autocomplete.team_service") as mock_service:
|
||||||
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
||||||
|
|
||||||
# Test abbreviation matching
|
# Test abbreviation matching
|
||||||
choices = await major_league_team_autocomplete(mock_interaction, 'la')
|
choices = await major_league_team_autocomplete(mock_interaction, "la")
|
||||||
assert len(choices) == 2 # LAA and LAD
|
assert len(choices) == 2 # LAA and LAD
|
||||||
choice_values = [choice.value for choice in choices]
|
choice_values = [choice.value for choice in choices]
|
||||||
assert 'LAA' in choice_values
|
assert "LAA" in choice_values
|
||||||
assert 'LAD' in choice_values
|
assert "LAD" in choice_values
|
||||||
|
|
||||||
# Test short name matching
|
# Test short name matching
|
||||||
choices = await major_league_team_autocomplete(mock_interaction, 'red')
|
choices = await major_league_team_autocomplete(mock_interaction, "red")
|
||||||
assert len(choices) == 1
|
assert len(choices) == 1
|
||||||
assert choices[0].value == 'BOS'
|
assert choices[0].value == "BOS"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_major_league_team_autocomplete_short_input(self, mock_interaction):
|
async def test_major_league_team_autocomplete_short_input(self, mock_interaction):
|
||||||
"""Test major league team autocomplete with very short input."""
|
"""Test major league team autocomplete with very short input."""
|
||||||
choices = await major_league_team_autocomplete(mock_interaction, '')
|
choices = await major_league_team_autocomplete(mock_interaction, "")
|
||||||
assert len(choices) == 0
|
assert len(choices) == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_major_league_team_autocomplete_error_handling(self, mock_interaction):
|
async def test_major_league_team_autocomplete_error_handling(
|
||||||
|
self, mock_interaction
|
||||||
|
):
|
||||||
"""Test major league team autocomplete error handling."""
|
"""Test major league team autocomplete error handling."""
|
||||||
with patch('utils.autocomplete.team_service') as mock_service:
|
with patch("utils.autocomplete.team_service") as mock_service:
|
||||||
mock_service.get_teams_by_season.side_effect = Exception("API Error")
|
mock_service.get_teams_by_season.side_effect = Exception("API Error")
|
||||||
|
|
||||||
choices = await major_league_team_autocomplete(mock_interaction, 'LAA')
|
choices = await major_league_team_autocomplete(mock_interaction, "LAA")
|
||||||
assert len(choices) == 0
|
assert len(choices) == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_major_league_team_autocomplete_roster_type_detection(self, mock_interaction):
|
async def test_major_league_team_autocomplete_roster_type_detection(
|
||||||
|
self, mock_interaction
|
||||||
|
):
|
||||||
"""Test that roster type detection works correctly for edge cases."""
|
"""Test that roster type detection works correctly for edge cases."""
|
||||||
# Test edge cases like teams whose abbreviation ends in 'M' + 'IL'
|
# Test edge cases like teams whose abbreviation ends in 'M' + 'IL'
|
||||||
mock_teams = [
|
mock_teams = [
|
||||||
TeamFactory.create(id=1, abbrev='BHM', sname='Iron'), # ML team ending in 'M'
|
TeamFactory.create(
|
||||||
TeamFactory.create(id=2, abbrev='BHMIL', sname='Iron IL'), # IL team (BHM + IL)
|
id=1, abbrev="BHM", sname="Iron"
|
||||||
TeamFactory.create(id=3, abbrev='NYYMIL', sname='Staten Island RailRiders'), # MiL team (NYY + MIL)
|
), # ML team ending in 'M'
|
||||||
TeamFactory.create(id=4, abbrev='NYY', sname='Yankees'), # ML team
|
TeamFactory.create(
|
||||||
|
id=2, abbrev="BHMIL", sname="Iron IL"
|
||||||
|
), # IL team (BHM + IL)
|
||||||
|
TeamFactory.create(
|
||||||
|
id=3, abbrev="NYYMIL", sname="Staten Island RailRiders"
|
||||||
|
), # MiL team (NYY + MIL)
|
||||||
|
TeamFactory.create(id=4, abbrev="NYY", sname="Yankees"), # ML team
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch('utils.autocomplete.team_service') as mock_service:
|
with patch("utils.autocomplete.team_service") as mock_service:
|
||||||
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
||||||
|
|
||||||
choices = await major_league_team_autocomplete(mock_interaction, 'b')
|
choices = await major_league_team_autocomplete(mock_interaction, "b")
|
||||||
|
|
||||||
# Should only return major league teams
|
# Should only return major league teams
|
||||||
choice_values = [choice.value for choice in choices]
|
choice_values = [choice.value for choice in choices]
|
||||||
assert 'BHM' in choice_values # Major league team
|
assert "BHM" in choice_values # Major league team
|
||||||
assert 'BHMIL' not in choice_values # Should be detected as IL, not MiL
|
assert "BHMIL" not in choice_values # Should be detected as IL, not MiL
|
||||||
assert 'NYYMIL' not in choice_values # Minor league team
|
assert "NYYMIL" not in choice_values # Minor league team
|
||||||
|
|
||||||
# Verify the roster type detection is working
|
# Verify the roster type detection is working
|
||||||
bhm_team = next(t for t in mock_teams if t.abbrev == 'BHM')
|
bhm_team = next(t for t in mock_teams if t.abbrev == "BHM")
|
||||||
bhmil_team = next(t for t in mock_teams if t.abbrev == 'BHMIL')
|
bhmil_team = next(t for t in mock_teams if t.abbrev == "BHMIL")
|
||||||
nyymil_team = next(t for t in mock_teams if t.abbrev == 'NYYMIL')
|
nyymil_team = next(t for t in mock_teams if t.abbrev == "NYYMIL")
|
||||||
|
|
||||||
assert bhm_team.roster_type() == RosterType.MAJOR_LEAGUE
|
assert bhm_team.roster_type() == RosterType.MAJOR_LEAGUE
|
||||||
assert bhmil_team.roster_type() == RosterType.INJURED_LIST
|
assert bhmil_team.roster_type() == RosterType.INJURED_LIST
|
||||||
assert nyymil_team.roster_type() == RosterType.MINOR_LEAGUE
|
assert nyymil_team.roster_type() == RosterType.MINOR_LEAGUE
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCachedUserTeam:
|
||||||
|
"""Test the _get_cached_user_team caching helper.
|
||||||
|
|
||||||
|
Verifies that the cache avoids redundant get_user_major_league_team calls
|
||||||
|
on repeated invocations within the TTL window, and that expired entries are
|
||||||
|
re-fetched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_cache(self):
|
||||||
|
"""Isolate each test from cache state left by other tests."""
|
||||||
|
utils.autocomplete._user_team_cache.clear()
|
||||||
|
yield
|
||||||
|
utils.autocomplete._user_team_cache.clear()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_interaction(self):
|
||||||
|
interaction = MagicMock()
|
||||||
|
interaction.user.id = 99999
|
||||||
|
return interaction
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_caches_result_on_first_call(self, mock_interaction):
|
||||||
|
"""First call populates the cache; API function called exactly once."""
|
||||||
|
user_team = TeamFactory.create(id=1, abbrev="POR", sname="Loggers")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"utils.autocomplete.get_user_major_league_team", new_callable=AsyncMock
|
||||||
|
) as mock_get_team:
|
||||||
|
mock_get_team.return_value = user_team
|
||||||
|
|
||||||
|
from utils.autocomplete import _get_cached_user_team
|
||||||
|
|
||||||
|
result1 = await _get_cached_user_team(mock_interaction)
|
||||||
|
result2 = await _get_cached_user_team(mock_interaction)
|
||||||
|
|
||||||
|
assert result1 is user_team
|
||||||
|
assert result2 is user_team
|
||||||
|
# API called only once despite two invocations
|
||||||
|
mock_get_team.assert_called_once_with(99999)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_re_fetches_after_ttl_expires(self, mock_interaction):
|
||||||
|
"""Expired cache entries cause a fresh API call."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
user_team = TeamFactory.create(id=1, abbrev="POR", sname="Loggers")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"utils.autocomplete.get_user_major_league_team", new_callable=AsyncMock
|
||||||
|
) as mock_get_team:
|
||||||
|
mock_get_team.return_value = user_team
|
||||||
|
|
||||||
|
from utils.autocomplete import _get_cached_user_team, _USER_TEAM_CACHE_TTL
|
||||||
|
|
||||||
|
# Seed the cache with a timestamp that is already expired
|
||||||
|
utils.autocomplete._user_team_cache[99999] = (
|
||||||
|
user_team,
|
||||||
|
time.time() - _USER_TEAM_CACHE_TTL - 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
await _get_cached_user_team(mock_interaction)
|
||||||
|
|
||||||
|
# Should have called the API to refresh the stale entry
|
||||||
|
mock_get_team.assert_called_once_with(99999)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_caches_none_result(self, mock_interaction):
|
||||||
|
"""None (user has no team) is cached to avoid repeated API calls."""
|
||||||
|
with patch(
|
||||||
|
"utils.autocomplete.get_user_major_league_team", new_callable=AsyncMock
|
||||||
|
) as mock_get_team:
|
||||||
|
mock_get_team.return_value = None
|
||||||
|
|
||||||
|
from utils.autocomplete import _get_cached_user_team
|
||||||
|
|
||||||
|
result1 = await _get_cached_user_team(mock_interaction)
|
||||||
|
result2 = await _get_cached_user_team(mock_interaction)
|
||||||
|
|
||||||
|
assert result1 is None
|
||||||
|
assert result2 is None
|
||||||
|
mock_get_team.assert_called_once()
|
||||||
|
|||||||
@ -4,16 +4,33 @@ Autocomplete Utilities
|
|||||||
Shared autocomplete functions for Discord slash commands.
|
Shared autocomplete functions for Discord slash commands.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List
|
import time
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
|
|
||||||
from config import get_config
|
from config import get_config
|
||||||
from models.team import RosterType
|
from models.team import RosterType, Team
|
||||||
from services.player_service import player_service
|
from services.player_service import player_service
|
||||||
from services.team_service import team_service
|
from services.team_service import team_service
|
||||||
from utils.team_utils import get_user_major_league_team
|
from utils.team_utils import get_user_major_league_team
|
||||||
|
|
||||||
|
# Cache for user team lookups: user_id -> (team, cached_at)
|
||||||
|
_user_team_cache: Dict[int, Tuple[Optional[Team], float]] = {}
|
||||||
|
_USER_TEAM_CACHE_TTL = 60 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_cached_user_team(interaction: discord.Interaction) -> Optional[Team]:
|
||||||
|
"""Return the user's major league team, cached for 60 seconds per user."""
|
||||||
|
user_id = interaction.user.id
|
||||||
|
if user_id in _user_team_cache:
|
||||||
|
team, cached_at = _user_team_cache[user_id]
|
||||||
|
if time.time() - cached_at < _USER_TEAM_CACHE_TTL:
|
||||||
|
return team
|
||||||
|
team = await get_user_major_league_team(user_id)
|
||||||
|
_user_team_cache[user_id] = (team, time.time())
|
||||||
|
return team
|
||||||
|
|
||||||
|
|
||||||
async def player_autocomplete(
|
async def player_autocomplete(
|
||||||
interaction: discord.Interaction, current: str
|
interaction: discord.Interaction, current: str
|
||||||
@ -34,12 +51,12 @@ async def player_autocomplete(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get user's team for prioritization
|
# Get user's team for prioritization (cached per user, 60s TTL)
|
||||||
user_team = await get_user_major_league_team(interaction.user.id)
|
user_team = await _get_cached_user_team(interaction)
|
||||||
|
|
||||||
# Search for players using the search endpoint
|
# Search for players using the search endpoint
|
||||||
players = await player_service.search_players(
|
players = await player_service.search_players(
|
||||||
current, limit=50, season=get_config().sba_season
|
current, limit=25, season=get_config().sba_season
|
||||||
)
|
)
|
||||||
|
|
||||||
# Separate players by team (user's team vs others)
|
# Separate players by team (user's team vs others)
|
||||||
|
|||||||
@ -188,9 +188,11 @@ class CacheManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
pattern = f"{prefix}:*"
|
pattern = f"{prefix}:*"
|
||||||
keys = await client.keys(pattern)
|
keys_to_delete = []
|
||||||
if keys:
|
async for key in client.scan_iter(match=pattern):
|
||||||
deleted = await client.delete(*keys)
|
keys_to_delete.append(key)
|
||||||
|
if keys_to_delete:
|
||||||
|
deleted = await client.delete(*keys_to_delete)
|
||||||
logger.info(f"Cleared {deleted} cache keys with prefix '{prefix}'")
|
logger.info(f"Cleared {deleted} cache keys with prefix '{prefix}'")
|
||||||
return deleted
|
return deleted
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -11,14 +11,14 @@ from functools import wraps
|
|||||||
from typing import List, Optional, Callable, Any
|
from typing import List, Optional, Callable, Any
|
||||||
from utils.logging import set_discord_context, get_contextual_logger
|
from utils.logging import set_discord_context, get_contextual_logger
|
||||||
|
|
||||||
cache_logger = logging.getLogger(f'{__name__}.CacheDecorators')
|
cache_logger = logging.getLogger(f"{__name__}.CacheDecorators")
|
||||||
period_check_logger = logging.getLogger(f'{__name__}.PeriodCheckDecorators')
|
period_check_logger = logging.getLogger(f"{__name__}.PeriodCheckDecorators")
|
||||||
|
|
||||||
|
|
||||||
def logged_command(
|
def logged_command(
|
||||||
command_name: Optional[str] = None,
|
command_name: Optional[str] = None,
|
||||||
log_params: bool = True,
|
log_params: bool = True,
|
||||||
exclude_params: Optional[List[str]] = None
|
exclude_params: Optional[List[str]] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Decorator for Discord commands that adds comprehensive logging.
|
Decorator for Discord commands that adds comprehensive logging.
|
||||||
@ -54,7 +54,12 @@ def logged_command(
|
|||||||
- Function must be an async method with (self, interaction, ...) signature
|
- Function must be an async method with (self, interaction, ...) signature
|
||||||
- Preserves Discord.py command registration compatibility
|
- Preserves Discord.py command registration compatibility
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
param_names = list(sig.parameters.keys())[2:] # Skip self, interaction
|
||||||
|
exclude_set = set(exclude_params or [])
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(self, interaction, *args, **kwargs):
|
async def wrapper(self, interaction, *args, **kwargs):
|
||||||
# Auto-detect command name if not provided
|
# Auto-detect command name if not provided
|
||||||
@ -63,10 +68,6 @@ def logged_command(
|
|||||||
# Build context with safe parameter logging
|
# Build context with safe parameter logging
|
||||||
context = {"command": cmd_name}
|
context = {"command": cmd_name}
|
||||||
if log_params:
|
if log_params:
|
||||||
sig = inspect.signature(func)
|
|
||||||
param_names = list(sig.parameters.keys())[2:] # Skip self, interaction
|
|
||||||
exclude_set = set(exclude_params or [])
|
|
||||||
|
|
||||||
for i, (name, value) in enumerate(zip(param_names, args)):
|
for i, (name, value) in enumerate(zip(param_names, args)):
|
||||||
if name not in exclude_set:
|
if name not in exclude_set:
|
||||||
context[f"param_{name}"] = value
|
context[f"param_{name}"] = value
|
||||||
@ -74,7 +75,13 @@ def logged_command(
|
|||||||
set_discord_context(interaction=interaction, **context)
|
set_discord_context(interaction=interaction, **context)
|
||||||
|
|
||||||
# Get logger from the class instance or create one
|
# Get logger from the class instance or create one
|
||||||
logger = getattr(self, 'logger', get_contextual_logger(f'{self.__class__.__module__}.{self.__class__.__name__}'))
|
logger = getattr(
|
||||||
|
self,
|
||||||
|
"logger",
|
||||||
|
get_contextual_logger(
|
||||||
|
f"{self.__class__.__module__}.{self.__class__.__name__}"
|
||||||
|
),
|
||||||
|
)
|
||||||
trace_id = logger.start_operation(f"{func.__name__}_command")
|
trace_id = logger.start_operation(f"{func.__name__}_command")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -91,8 +98,9 @@ def logged_command(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
# Preserve signature for Discord.py command registration
|
# Preserve signature for Discord.py command registration
|
||||||
wrapper.__signature__ = inspect.signature(func) # type: ignore
|
wrapper.__signature__ = sig # type: ignore
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
@ -122,6 +130,7 @@ def requires_draft_period(func):
|
|||||||
- Should be placed before @logged_command decorator
|
- Should be placed before @logged_command decorator
|
||||||
- league_service must be available via import
|
- league_service must be available via import
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(self, interaction, *args, **kwargs):
|
async def wrapper(self, interaction, *args, **kwargs):
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
@ -133,10 +142,12 @@ def requires_draft_period(func):
|
|||||||
current = await league_service.get_current_state()
|
current = await league_service.get_current_state()
|
||||||
|
|
||||||
if not current:
|
if not current:
|
||||||
period_check_logger.error("Could not retrieve league state for draft period check")
|
period_check_logger.error(
|
||||||
|
"Could not retrieve league state for draft period check"
|
||||||
|
)
|
||||||
embed = EmbedTemplate.error(
|
embed = EmbedTemplate.error(
|
||||||
"System Error",
|
"System Error",
|
||||||
"Could not verify draft period status. Please try again later."
|
"Could not verify draft period status. Please try again later.",
|
||||||
)
|
)
|
||||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
return
|
return
|
||||||
@ -148,12 +159,12 @@ def requires_draft_period(func):
|
|||||||
extra={
|
extra={
|
||||||
"user_id": interaction.user.id,
|
"user_id": interaction.user.id,
|
||||||
"command": func.__name__,
|
"command": func.__name__,
|
||||||
"current_week": current.week
|
"current_week": current.week,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
embed = EmbedTemplate.error(
|
embed = EmbedTemplate.error(
|
||||||
"Not Available",
|
"Not Available",
|
||||||
"Draft commands are only available in the offseason."
|
"Draft commands are only available in the offseason.",
|
||||||
)
|
)
|
||||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
return
|
return
|
||||||
@ -161,7 +172,7 @@ def requires_draft_period(func):
|
|||||||
# Week <= 0, allow command to proceed
|
# Week <= 0, allow command to proceed
|
||||||
period_check_logger.debug(
|
period_check_logger.debug(
|
||||||
f"Draft period check passed - week {current.week}",
|
f"Draft period check passed - week {current.week}",
|
||||||
extra={"user_id": interaction.user.id, "command": func.__name__}
|
extra={"user_id": interaction.user.id, "command": func.__name__},
|
||||||
)
|
)
|
||||||
return await func(self, interaction, *args, **kwargs)
|
return await func(self, interaction, *args, **kwargs)
|
||||||
|
|
||||||
@ -169,7 +180,7 @@ def requires_draft_period(func):
|
|||||||
period_check_logger.error(
|
period_check_logger.error(
|
||||||
f"Error in draft period check: {e}",
|
f"Error in draft period check: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
extra={"user_id": interaction.user.id, "command": func.__name__}
|
extra={"user_id": interaction.user.id, "command": func.__name__},
|
||||||
)
|
)
|
||||||
# Re-raise to let error handling in logged_command handle it
|
# Re-raise to let error handling in logged_command handle it
|
||||||
raise
|
raise
|
||||||
@ -204,11 +215,14 @@ def cached_api_call(ttl: Optional[int] = None, cache_key_suffix: str = ""):
|
|||||||
- Class must have self.cache (CacheManager instance)
|
- Class must have self.cache (CacheManager instance)
|
||||||
- Class must have self._generate_cache_key, self._get_cached_items, self._cache_items methods
|
- Class must have self._generate_cache_key, self._get_cached_items, self._cache_items methods
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func: Callable) -> Callable:
|
def decorator(func: Callable) -> Callable:
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(self, *args, **kwargs) -> List[Any]:
|
async def wrapper(self, *args, **kwargs) -> List[Any]:
|
||||||
# Check if caching is available (service has cache manager)
|
# Check if caching is available (service has cache manager)
|
||||||
if not hasattr(self, 'cache') or not hasattr(self, '_generate_cache_key'):
|
if not hasattr(self, "cache") or not hasattr(self, "_generate_cache_key"):
|
||||||
# No caching available, execute original method
|
# No caching available, execute original method
|
||||||
return await func(self, *args, **kwargs)
|
return await func(self, *args, **kwargs)
|
||||||
|
|
||||||
@ -216,20 +230,19 @@ def cached_api_call(ttl: Optional[int] = None, cache_key_suffix: str = ""):
|
|||||||
method_name = f"{func.__name__}{cache_key_suffix}"
|
method_name = f"{func.__name__}{cache_key_suffix}"
|
||||||
|
|
||||||
# Convert args and kwargs to params list for consistent cache key
|
# Convert args and kwargs to params list for consistent cache key
|
||||||
sig = inspect.signature(func)
|
|
||||||
bound_args = sig.bind(self, *args, **kwargs)
|
bound_args = sig.bind(self, *args, **kwargs)
|
||||||
bound_args.apply_defaults()
|
bound_args.apply_defaults()
|
||||||
|
|
||||||
# Skip 'self' and convert to params format
|
# Skip 'self' and convert to params format
|
||||||
params = []
|
params = []
|
||||||
for param_name, param_value in bound_args.arguments.items():
|
for param_name, param_value in bound_args.arguments.items():
|
||||||
if param_name != 'self' and param_value is not None:
|
if param_name != "self" and param_value is not None:
|
||||||
params.append((param_name, param_value))
|
params.append((param_name, param_value))
|
||||||
|
|
||||||
cache_key = self._generate_cache_key(method_name, params)
|
cache_key = self._generate_cache_key(method_name, params)
|
||||||
|
|
||||||
# Try to get from cache
|
# Try to get from cache
|
||||||
if hasattr(self, '_get_cached_items'):
|
if hasattr(self, "_get_cached_items"):
|
||||||
cached_result = await self._get_cached_items(cache_key)
|
cached_result = await self._get_cached_items(cache_key)
|
||||||
if cached_result is not None:
|
if cached_result is not None:
|
||||||
cache_logger.debug(f"Cache hit: {method_name}")
|
cache_logger.debug(f"Cache hit: {method_name}")
|
||||||
@ -240,13 +253,14 @@ def cached_api_call(ttl: Optional[int] = None, cache_key_suffix: str = ""):
|
|||||||
result = await func(self, *args, **kwargs)
|
result = await func(self, *args, **kwargs)
|
||||||
|
|
||||||
# Cache the result if we have items and caching methods
|
# Cache the result if we have items and caching methods
|
||||||
if result and hasattr(self, '_cache_items'):
|
if result and hasattr(self, "_cache_items"):
|
||||||
await self._cache_items(cache_key, result, ttl)
|
await self._cache_items(cache_key, result, ttl)
|
||||||
cache_logger.debug(f"Cached {len(result)} items for {method_name}")
|
cache_logger.debug(f"Cached {len(result)} items for {method_name}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
@ -265,23 +279,25 @@ def cached_single_item(ttl: Optional[int] = None, cache_key_suffix: str = ""):
|
|||||||
async def get_player(self, player_id: int) -> Optional[Player]:
|
async def get_player(self, player_id: int) -> Optional[Player]:
|
||||||
# Original method implementation
|
# Original method implementation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func: Callable) -> Callable:
|
def decorator(func: Callable) -> Callable:
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(self, *args, **kwargs) -> Optional[Any]:
|
async def wrapper(self, *args, **kwargs) -> Optional[Any]:
|
||||||
# Check if caching is available
|
# Check if caching is available
|
||||||
if not hasattr(self, 'cache') or not hasattr(self, '_generate_cache_key'):
|
if not hasattr(self, "cache") or not hasattr(self, "_generate_cache_key"):
|
||||||
return await func(self, *args, **kwargs)
|
return await func(self, *args, **kwargs)
|
||||||
|
|
||||||
# Generate cache key
|
# Generate cache key
|
||||||
method_name = f"{func.__name__}{cache_key_suffix}"
|
method_name = f"{func.__name__}{cache_key_suffix}"
|
||||||
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
bound_args = sig.bind(self, *args, **kwargs)
|
bound_args = sig.bind(self, *args, **kwargs)
|
||||||
bound_args.apply_defaults()
|
bound_args.apply_defaults()
|
||||||
|
|
||||||
params = []
|
params = []
|
||||||
for param_name, param_value in bound_args.arguments.items():
|
for param_name, param_value in bound_args.arguments.items():
|
||||||
if param_name != 'self' and param_value is not None:
|
if param_name != "self" and param_value is not None:
|
||||||
params.append((param_name, param_value))
|
params.append((param_name, param_value))
|
||||||
|
|
||||||
cache_key = self._generate_cache_key(method_name, params)
|
cache_key = self._generate_cache_key(method_name, params)
|
||||||
@ -293,7 +309,9 @@ def cached_single_item(ttl: Optional[int] = None, cache_key_suffix: str = ""):
|
|||||||
cache_logger.debug(f"Cache hit: {method_name}")
|
cache_logger.debug(f"Cache hit: {method_name}")
|
||||||
return self.model_class.from_api_data(cached_data)
|
return self.model_class.from_api_data(cached_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
cache_logger.warning(f"Error reading single item cache for {cache_key}: {e}")
|
cache_logger.warning(
|
||||||
|
f"Error reading single item cache for {cache_key}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Cache miss - execute original method
|
# Cache miss - execute original method
|
||||||
cache_logger.debug(f"Cache miss: {method_name}")
|
cache_logger.debug(f"Cache miss: {method_name}")
|
||||||
@ -306,11 +324,14 @@ def cached_single_item(ttl: Optional[int] = None, cache_key_suffix: str = ""):
|
|||||||
await self.cache.set(cache_key, cache_data, ttl)
|
await self.cache.set(cache_key, cache_data, ttl)
|
||||||
cache_logger.debug(f"Cached single item for {method_name}")
|
cache_logger.debug(f"Cached single item for {method_name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
cache_logger.warning(f"Error caching single item for {cache_key}: {e}")
|
cache_logger.warning(
|
||||||
|
f"Error caching single item for {cache_key}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
@ -326,6 +347,7 @@ def cache_invalidate(*cache_patterns: str):
|
|||||||
async def update_player(self, player_id: int, updates: dict) -> Optional[Player]:
|
async def update_player(self, player_id: int, updates: dict) -> Optional[Player]:
|
||||||
# Original method implementation
|
# Original method implementation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func: Callable) -> Callable:
|
def decorator(func: Callable) -> Callable:
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(self, *args, **kwargs):
|
async def wrapper(self, *args, **kwargs):
|
||||||
@ -333,16 +355,23 @@ def cache_invalidate(*cache_patterns: str):
|
|||||||
result = await func(self, *args, **kwargs)
|
result = await func(self, *args, **kwargs)
|
||||||
|
|
||||||
# Invalidate specified cache patterns
|
# Invalidate specified cache patterns
|
||||||
if hasattr(self, 'cache'):
|
if hasattr(self, "cache"):
|
||||||
for pattern in cache_patterns:
|
for pattern in cache_patterns:
|
||||||
try:
|
try:
|
||||||
cleared = await self.cache.clear_prefix(f"sba:{self.endpoint}_{pattern}")
|
cleared = await self.cache.clear_prefix(
|
||||||
|
f"sba:{self.endpoint}_{pattern}"
|
||||||
|
)
|
||||||
if cleared > 0:
|
if cleared > 0:
|
||||||
cache_logger.info(f"Invalidated {cleared} cache entries for pattern: {pattern}")
|
cache_logger.info(
|
||||||
|
f"Invalidated {cleared} cache entries for pattern: {pattern}"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
cache_logger.warning(f"Error invalidating cache pattern {pattern}: {e}")
|
cache_logger.warning(
|
||||||
|
f"Error invalidating cache pattern {pattern}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
@ -24,6 +24,8 @@ JSONValue = Union[
|
|||||||
str, int, float, bool, None, dict[str, Any], list[Any] # nested object # arrays
|
str, int, float, bool, None, dict[str, Any], list[Any] # nested object # arrays
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_SERIALIZABLE_TYPES = (str, int, float, bool, type(None))
|
||||||
|
|
||||||
|
|
||||||
class JSONFormatter(logging.Formatter):
|
class JSONFormatter(logging.Formatter):
|
||||||
"""Custom JSON formatter for structured file logging."""
|
"""Custom JSON formatter for structured file logging."""
|
||||||
@ -93,11 +95,11 @@ class JSONFormatter(logging.Formatter):
|
|||||||
extra_data = {}
|
extra_data = {}
|
||||||
for key, value in record.__dict__.items():
|
for key, value in record.__dict__.items():
|
||||||
if key not in excluded_keys:
|
if key not in excluded_keys:
|
||||||
# Ensure JSON serializable
|
if isinstance(value, _SERIALIZABLE_TYPES) or isinstance(
|
||||||
try:
|
value, (list, dict)
|
||||||
json.dumps(value)
|
):
|
||||||
extra_data[key] = value
|
extra_data[key] = value
|
||||||
except (TypeError, ValueError):
|
else:
|
||||||
extra_data[key] = str(value)
|
extra_data[key] = str(value)
|
||||||
|
|
||||||
if extra_data:
|
if extra_data:
|
||||||
|
|||||||
@ -124,6 +124,22 @@ class TradeEmbedView(discord.ui.View):
|
|||||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
):
|
):
|
||||||
"""Handle submit trade button click."""
|
"""Handle submit trade button click."""
|
||||||
|
# Check trade deadline
|
||||||
|
current = await league_service.get_current_state()
|
||||||
|
if not current:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"❌ Could not retrieve league state. Please try again later.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if current.is_past_trade_deadline:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"❌ **The trade deadline has passed** (Week {current.trade_deadline}). "
|
||||||
|
f"This trade can no longer be submitted.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if self.builder.is_empty:
|
if self.builder.is_empty:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
"Cannot submit empty trade. Add some moves first!", ephemeral=True
|
"Cannot submit empty trade. Add some moves first!", ephemeral=True
|
||||||
@ -328,6 +344,7 @@ class TradeAcceptanceView(discord.ui.View):
|
|||||||
def __init__(self, builder: TradeBuilder):
|
def __init__(self, builder: TradeBuilder):
|
||||||
super().__init__(timeout=3600.0) # 1 hour timeout
|
super().__init__(timeout=3600.0) # 1 hour timeout
|
||||||
self.builder = builder
|
self.builder = builder
|
||||||
|
self._checked_teams: dict[int, Team] = {}
|
||||||
|
|
||||||
async def _get_user_team(self, interaction: discord.Interaction) -> Optional[Team]:
|
async def _get_user_team(self, interaction: discord.Interaction) -> Optional[Team]:
|
||||||
"""Get the team owned by the interacting user."""
|
"""Get the team owned by the interacting user."""
|
||||||
@ -353,6 +370,7 @@ class TradeAcceptanceView(discord.ui.View):
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
self._checked_teams[interaction.user.id] = user_team
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def on_timeout(self) -> None:
|
async def on_timeout(self) -> None:
|
||||||
@ -366,7 +384,7 @@ class TradeAcceptanceView(discord.ui.View):
|
|||||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
):
|
):
|
||||||
"""Handle accept button click."""
|
"""Handle accept button click."""
|
||||||
user_team = await self._get_user_team(interaction)
|
user_team = self._checked_teams.get(interaction.user.id)
|
||||||
if not user_team:
|
if not user_team:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -401,7 +419,7 @@ class TradeAcceptanceView(discord.ui.View):
|
|||||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
):
|
):
|
||||||
"""Handle reject button click - moves trade back to DRAFT."""
|
"""Handle reject button click - moves trade back to DRAFT."""
|
||||||
user_team = await self._get_user_team(interaction)
|
user_team = self._checked_teams.get(interaction.user.id)
|
||||||
if not user_team:
|
if not user_team:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -433,7 +451,16 @@ class TradeAcceptanceView(discord.ui.View):
|
|||||||
config = get_config()
|
config = get_config()
|
||||||
|
|
||||||
current = await league_service.get_current_state()
|
current = await league_service.get_current_state()
|
||||||
next_week = current.week + 1 if current else 1
|
if not current or current.is_past_trade_deadline:
|
||||||
|
deadline_msg = (
|
||||||
|
f"❌ **The trade deadline has passed** (Week {current.trade_deadline}). "
|
||||||
|
f"This trade cannot be finalized."
|
||||||
|
if current
|
||||||
|
else "❌ Could not retrieve league state. Please try again later."
|
||||||
|
)
|
||||||
|
await interaction.followup.send(deadline_msg, ephemeral=True)
|
||||||
|
return
|
||||||
|
next_week = current.week + 1
|
||||||
|
|
||||||
fa_team = Team(
|
fa_team = Team(
|
||||||
id=config.free_agent_team_id,
|
id=config.free_agent_team_id,
|
||||||
@ -708,10 +735,10 @@ async def create_trade_embed(builder: TradeBuilder) -> discord.Embed:
|
|||||||
Returns:
|
Returns:
|
||||||
Discord embed with current trade state
|
Discord embed with current trade state
|
||||||
"""
|
"""
|
||||||
|
validation = await builder.validate_trade()
|
||||||
if builder.is_empty:
|
if builder.is_empty:
|
||||||
color = EmbedColors.SECONDARY
|
color = EmbedColors.SECONDARY
|
||||||
else:
|
else:
|
||||||
validation = await builder.validate_trade()
|
|
||||||
color = EmbedColors.SUCCESS if validation.is_legal else EmbedColors.WARNING
|
color = EmbedColors.SUCCESS if validation.is_legal else EmbedColors.WARNING
|
||||||
|
|
||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
@ -766,7 +793,6 @@ async def create_trade_embed(builder: TradeBuilder) -> discord.Embed:
|
|||||||
inline=False,
|
inline=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
validation = await builder.validate_trade()
|
|
||||||
if validation.is_legal:
|
if validation.is_legal:
|
||||||
status_text = "Trade appears legal"
|
status_text = "Trade appears legal"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -6,6 +6,8 @@ Handles the Discord embed and button interfaces for the transaction builder.
|
|||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
|
from utils.logging import get_contextual_logger
|
||||||
|
|
||||||
from services.transaction_builder import (
|
from services.transaction_builder import (
|
||||||
TransactionBuilder,
|
TransactionBuilder,
|
||||||
clear_transaction_builder,
|
clear_transaction_builder,
|
||||||
@ -235,6 +237,7 @@ class SubmitConfirmationModal(discord.ui.Modal):
|
|||||||
super().__init__(title="Confirm Transaction Submission")
|
super().__init__(title="Confirm Transaction Submission")
|
||||||
self.builder = builder
|
self.builder = builder
|
||||||
self.submission_handler = submission_handler
|
self.submission_handler = submission_handler
|
||||||
|
self.logger = get_contextual_logger(f"{__name__}.SubmitConfirmationModal")
|
||||||
|
|
||||||
self.confirmation = discord.ui.TextInput(
|
self.confirmation = discord.ui.TextInput(
|
||||||
label="Type 'CONFIRM' to submit",
|
label="Type 'CONFIRM' to submit",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user