Compare commits

..

15 Commits

Author SHA1 Message Date
Cal Corum
95010bfd5d perf: eliminate redundant GET after create/update and parallelize stats (#95)
- custom_commands_service: return POST response directly from create_command()
  instead of a follow-up GET by_name
- custom_commands_service: return PUT response directly from update_command()
  instead of a follow-up GET by_name
- custom_commands_service: avoid GET after PUT in get_or_create_creator() by
  constructing updated creator from model_copy()
- custom_commands_service: return POST response directly from get_or_create_creator()
  creator creation instead of a follow-up GET
- custom_commands_service: parallelize all 9 sequential API calls in
  get_statistics() with asyncio.gather()
- help_commands_service: return POST response directly from create_help()
  instead of a follow-up GET by_name
- help_commands_service: return PUT response directly from update_help()
  instead of a follow-up GET by_name
- tests: update test_update_help_success to mock PUT returning dict data

Closes #95

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:45:44 +00:00
cal
deb40476a4 Merge pull request 'perf: parallelize N+1 player/creator lookups with asyncio.gather (#89)' (#118) from ai/major-domo-v2-89 into main
Reviewed-on: #118
2026-03-31 19:45:01 +00:00
Cal Corum
65d3099a7c perf: parallelize N+1 player/creator lookups with asyncio.gather (#89)
Closes #89

Replace sequential per-item await loops with asyncio.gather() to fetch
all results in parallel:

- decision_service.find_winning_losing_pitchers: gather wp, lp, sv,
  hold_ids, and bsv_ids (5-10 calls) in a single parallel batch
- custom_commands_service: parallelize get_creator_by_id() in
  get_popular_commands, get_commands_needing_warning, and
  get_commands_eligible_for_deletion using return_exceptions=True to
  preserve the existing BotException-skip / re-raise-other behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:42:53 +00:00
cal
8e02889fd4 Merge pull request 'feat: enforce trade deadline in /trade commands' (#121) from feature/trade-deadline-enforcement into main
All checks were successful
Build Docker Image / build (push) Successful in 1m30s
2026-03-30 21:46:18 +00:00
Cal Corum
b872a05397 feat: enforce trade deadline in /trade commands
Add is_past_trade_deadline property to Current model and guard /trade initiate,
submit, and finalize flows. All checks fail-closed (block if API unreachable).
981 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:39:04 -05:00
cal
6889499fff Merge pull request 'fix: update chart_service path from data/ to storage/' (#119) from fix/chart-service-storage-path into main
All checks were successful
Build Docker Image / build (push) Successful in 1m46s
Reviewed-on: #119
2026-03-21 02:01:55 +00:00
Cal Corum
3c453c89ce fix: update chart_service path from data/ to storage/
PR #86 moved state files to storage/ but missed chart_service.py,
which still pointed to data/charts.json. The file exists at
/app/storage/charts.json in the container but the code looked
in /app/data/charts.json, causing empty autocomplete results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:30:41 -05:00
cal
be4213aab6 Merge pull request 'hotfix: make ScorecardTracker methods async to match await callers' (#117) from hotfix/scorecard-tracker-async into main
All checks were successful
Build Docker Image / build (push) Successful in 1m21s
Reviewed-on: #117
2026-03-20 18:41:44 +00:00
Cal Corum
4e75656225 hotfix: make ScorecardTracker methods async to match await callers
PR #106 added await to scorecard_tracker calls but the tracker
methods were still sync, causing TypeError in production:
- /scorebug: "object NoneType can't be used in 'await' expression"
- live_scorebug_tracker: "object list can't be used in 'await' expression"

Also fixes 5 missing awaits in cleanup_service.py and updates tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:37:47 -05:00
cal
c30e0ad321 Merge pull request 'ci: add release script for tag-triggered deployments' (#115) from ci/add-release-script into main
All checks were successful
Build Docker Image / build (push) Successful in 1m20s
Reviewed-on: #115
2026-03-20 18:27:05 +00:00
Cal Corum
b57f91833b ci: add release script for tag-triggered deployments
Auto-generates next CalVer tag (YYYY.M.BUILD) or accepts explicit
version. Shows commits since last tag, confirms, then pushes tag
to trigger CI build.

Usage:
  .scripts/release.sh           # auto-generate next version
  .scripts/release.sh 2026.3.11 # explicit version
  .scripts/release.sh -y        # skip confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:26:25 -05:00
cal
04efc46382 Merge pull request 'fix: update deploy script for tag-triggered releases' (#114) from fix/deploy-script-update into main
Reviewed-on: #114
2026-03-20 18:25:00 +00:00
Cal Corum
7e7aa46a73 fix: update deploy script for tag-triggered releases
- Use SSH alias (ssh akamai) instead of manual ssh -i command
- Change image tag from :latest to :production
- Fix rollback command to use SSH alias

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:24:29 -05:00
cal
91b367af93 Merge pull request 'ci: switch to tag-triggered releases with production Docker tag' (#113) from ci/tag-triggered-releases into main
All checks were successful
Build Docker Image / build (push) Successful in 1m21s
Reviewed-on: #113
2026-03-20 18:20:38 +00:00
Cal Corum
3c24e03a0c ci: switch to tag-triggered releases with production Docker tag
Replace branch-push trigger with tag-push trigger (20* pattern).
Version is extracted from the git tag itself instead of auto-generated.
Docker images are tagged with the CalVer version + floating "production" tag.

To release: git tag YYYY.M.BUILD && git push --tags

Also updates CLAUDE.md to document the new workflow and removes all
next-release branch references (branch retired).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:18:49 -05:00
15 changed files with 766 additions and 415 deletions

View File

@ -1,19 +1,18 @@
# Gitea Actions: Docker Build, Push, and Notify
#
# CI/CD pipeline for Major Domo Discord Bot:
# - Builds Docker images on merge to main/next-release
# - Auto-generates CalVer version (YYYY.MM.BUILD) on main branch merges
# - Supports multi-channel releases: stable (main), rc (next-release)
# - Pushes to Docker Hub and creates git tag on main
# - Triggered by pushing a CalVer tag (e.g., 2026.3.11)
# - Builds Docker image and pushes to Docker Hub with version + production tags
# - Sends Discord notifications on success/failure
#
# To release: git tag 2026.3.11 && git push --tags
name: Build Docker Image
on:
push:
branches:
- main
- next-release
tags:
- '20*' # matches CalVer tags like 2026.3.11
jobs:
build:
@ -23,7 +22,16 @@ jobs:
- name: Checkout code
uses: https://github.com/actions/checkout@v4
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
uses: https://github.com/docker/setup-buildx-action@v3
@ -34,67 +42,47 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
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
uses: https://github.com/docker/build-push-action@v5
with:
context: .
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-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
run: |
echo "## Docker Build Successful" >> $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 "**Image Tags:**" >> $GITHUB_STEP_SUMMARY
IFS=',' read -ra TAG_ARRAY <<< "${{ steps.tags.outputs.tags }}"
for tag in "${TAG_ARRAY[@]}"; do
echo "- \`${tag}\`" >> $GITHUB_STEP_SUMMARY
done
echo "- \`manticorum67/major-domo-discordapp:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
echo "- \`manticorum67/major-domo-discordapp:production\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY
echo "- Branch: \`${{ steps.calver.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Timestamp: \`${{ steps.calver.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Commit: \`${{ steps.version.outputs.sha_short }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Timestamp: \`${{ steps.version.outputs.timestamp }}\`" >> $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
if: success() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next-release')
if: success()
uses: cal/gitea-actions/discord-notify@main
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
title: "Major Domo Bot"
status: success
version: ${{ steps.calver.outputs.version }}
image_tag: ${{ steps.tags.outputs.primary_tag }}
commit_sha: ${{ steps.calver.outputs.sha_short }}
timestamp: ${{ steps.calver.outputs.timestamp }}
version: ${{ steps.version.outputs.version }}
image_tag: ${{ steps.version.outputs.version }}
commit_sha: ${{ steps.version.outputs.sha_short }}
timestamp: ${{ steps.version.outputs.timestamp }}
- 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
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}

View File

@ -7,11 +7,11 @@
set -euo pipefail
SSH_CMD="ssh -i ~/.ssh/cloud_servers_rsa root@akamai"
SSH_CMD="ssh akamai"
REMOTE_DIR="/root/container-data/major-domo"
SERVICE="discord-app"
CONTAINER="major-domo-discord-app-1"
IMAGE="manticorum67/major-domo-discordapp:latest"
IMAGE="manticorum67/major-domo-discordapp:production"
SKIP_CONFIRM=false
[[ "${1:-}" == "-y" ]] && SKIP_CONFIRM=true
@ -19,9 +19,9 @@ SKIP_CONFIRM=false
# --- Pre-deploy checks ---
if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then
echo "WARNING: You have uncommitted changes."
git status --short
echo ""
echo "WARNING: You have uncommitted changes."
git status --short
echo ""
fi
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
@ -32,9 +32,12 @@ echo "Target: akamai (${IMAGE})"
echo ""
if [[ "$SKIP_CONFIRM" != true ]]; then
read -rp "Deploy to production? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
echo ""
read -rp "Deploy to production? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || {
echo "Aborted."
exit 0
}
echo ""
fi
# --- Save previous image for rollback ---
@ -64,16 +67,16 @@ echo ""
echo "==> Image digest: ${NEW_DIGEST}"
if [[ "$PREV_DIGEST" == "$NEW_DIGEST" ]]; then
echo " (unchanged from previous deploy)"
echo " (unchanged from previous deploy)"
fi
# --- Rollback command ---
if [[ "$PREV_DIGEST" != "unknown" && "$PREV_DIGEST" != "$NEW_DIGEST" ]]; then
echo ""
echo "==> To rollback:"
echo " ssh -i ~/.ssh/cloud_servers_rsa root@akamai \\"
echo " \"cd ${REMOTE_DIR} && docker pull ${PREV_DIGEST} && docker tag ${PREV_DIGEST} ${IMAGE} && docker compose up -d ${SERVICE}\""
echo ""
echo "==> To rollback:"
echo " ssh akamai \\"
echo " \"cd ${REMOTE_DIR} && docker pull ${PREV_DIGEST} && docker tag ${PREV_DIGEST} ${IMAGE} && docker compose up -d ${SERVICE}\""
fi
echo ""

99
.scripts/release.sh Executable file
View 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"

View File

@ -16,15 +16,13 @@ manticorum67/major-domo-discordapp
There is NO DASH between "discord" and "app". Not `discord-app`, not `discordapp-v2`.
### 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
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
`EmbedTemplate.success/error/warning/info/loading()` auto-add emoji prefixes.
@ -63,13 +61,13 @@ class MyCog(commands.Cog):
- **Container**: `major-domo-discord-app-1`
- **Image**: `manticorum67/major-domo-discordapp` (no dash between discord and app)
- **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
1. Create feature/fix branches off `next-release` (e.g., `fix/scorebug-bugs`)
2. When done, merge the branch into `next-release` — this is the staging branch where changes accumulate
3. When ready to release, open a PR from `next-release``main`
4. CI builds Docker image on PR; CalVer tag is created on merge
1. Create feature/fix branches off `main` (e.g., `fix/scorebug-bugs`)
2. Open a PR to `main` when ready — merging does NOT trigger a build
3. When ready to release: `git tag YYYY.M.BUILD && git push --tags`
4. CI builds Docker image, tags it with the version + `production`, notifies Discord
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`

View File

@ -61,7 +61,7 @@ class ScorecardTracker:
except Exception as 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
) -> None:
"""
@ -82,7 +82,7 @@ class ScorecardTracker:
self.save_data()
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.
@ -103,7 +103,7 @@ class ScorecardTracker:
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.
@ -118,7 +118,7 @@ class ScorecardTracker:
scorecard_data = scorecards.get(str(text_channel_id))
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.
@ -132,7 +132,7 @@ class ScorecardTracker:
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.
@ -146,7 +146,7 @@ class ScorecardTracker:
scorecards[channel_key]["last_updated"] = datetime.now(UTC).isoformat()
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.

View File

@ -26,6 +26,7 @@ from services.trade_builder import (
clear_trade_builder,
clear_trade_builder_by_team,
)
from services.league_service import league_service
from services.player_service import player_service
from services.team_service import team_service
from models.team import RosterType
@ -130,6 +131,22 @@ class TradeCommands(commands.Cog):
)
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_trade_builder(interaction.user.id)
trade_builder = get_trade_builder(interaction.user.id, user_team)

View File

@ -128,7 +128,7 @@ class VoiceChannelCleanupService:
if channel_data and channel_data.get("text_channel_id"):
try:
text_channel_id_int = int(channel_data["text_channel_id"])
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
was_unpublished = await self.scorecard_tracker.unpublish_scorecard(
text_channel_id_int
)
if was_unpublished:
@ -218,8 +218,10 @@ class VoiceChannelCleanupService:
if text_channel_id:
try:
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:
self.logger.info(
@ -244,8 +246,10 @@ class VoiceChannelCleanupService:
if text_channel_id:
try:
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:
self.logger.info(
@ -330,7 +334,7 @@ class VoiceChannelCleanupService:
if text_channel_id:
try:
text_channel_id_int = int(text_channel_id)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
was_unpublished = await self.scorecard_tracker.unpublish_scorecard(
text_channel_id_int
)
if was_unpublished:
@ -358,7 +362,7 @@ class VoiceChannelCleanupService:
if text_channel_id:
try:
text_channel_id_int = int(text_channel_id)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
was_unpublished = await self.scorecard_tracker.unpublish_scorecard(
text_channel_id_int
)
if was_unpublished:

View File

@ -3,6 +3,7 @@ Current league state model
Represents the current state of the league including week, season, and settings.
"""
from pydantic import Field, field_validator
from models.base import SBABaseModel
@ -10,38 +11,45 @@ from models.base import SBABaseModel
class Current(SBABaseModel):
"""Model representing current league state and settings."""
week: int = Field(69, description="Current week number")
season: int = Field(69, description="Current season number")
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")
pick_trade_start: int = Field(69, description="Draft pick trading start week")
pick_trade_end: int = Field(420, description="Draft pick trading end week")
playoffs_begin: int = Field(420, description="Week when playoffs begin")
@field_validator("bet_week", mode="before")
@classmethod
def cast_bet_week_to_string(cls, v):
"""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
def is_offseason(self) -> bool:
"""Check if league is currently in offseason."""
return self.week > 18
@property
def is_playoffs(self) -> bool:
"""Check if league is currently in playoffs."""
return self.week >= self.playoffs_begin
@property
def can_trade_picks(self) -> bool:
"""Check if draft pick trading is currently allowed."""
return self.pick_trade_start <= self.week <= self.pick_trade_end
@property
def ever_trade_picks(self) -> bool:
"""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

View File

@ -4,6 +4,7 @@ Chart Service for managing gameplay charts and infographics.
This service handles loading, saving, and managing chart definitions
from the JSON configuration file.
"""
import json
import logging
from pathlib import Path
@ -18,6 +19,7 @@ logger = logging.getLogger(__name__)
@dataclass
class Chart:
"""Represents a gameplay chart or infographic."""
key: str
name: str
category: str
@ -27,17 +29,17 @@ class Chart:
def to_dict(self) -> Dict[str, Any]:
"""Convert chart to dictionary (excluding key)."""
return {
'name': self.name,
'category': self.category,
'description': self.description,
'urls': self.urls
"name": self.name,
"category": self.category,
"description": self.description,
"urls": self.urls,
}
class ChartService:
"""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):
"""Initialize the chart service."""
@ -54,21 +56,21 @@ class ChartService:
self._categories = {}
return
with open(self.CHARTS_FILE, 'r') as f:
with open(self.CHARTS_FILE, "r") as f:
data = json.load(f)
# Load categories
self._categories = data.get('categories', {})
self._categories = data.get("categories", {})
# Load charts
charts_data = data.get('charts', {})
charts_data = data.get("charts", {})
for key, chart_data in charts_data.items():
self._charts[key] = Chart(
key=key,
name=chart_data['name'],
category=chart_data['category'],
description=chart_data.get('description', ''),
urls=chart_data.get('urls', [])
name=chart_data["name"],
category=chart_data["category"],
description=chart_data.get("description", ""),
urls=chart_data.get("urls", []),
)
logger.info(f"Loaded {len(self._charts)} charts from {self.CHARTS_FILE}")
@ -81,20 +83,17 @@ class ChartService:
def _save_charts(self) -> None:
"""Save charts to JSON file."""
try:
# Ensure data directory exists
# Ensure storage directory exists
self.CHARTS_FILE.parent.mkdir(parents=True, exist_ok=True)
# Build data structure
data = {
'charts': {
key: chart.to_dict()
for key, chart in self._charts.items()
},
'categories': self._categories
"charts": {key: chart.to_dict() for key, chart in self._charts.items()},
"categories": self._categories,
}
# 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)
logger.info(f"Saved {len(self._charts)} charts to {self.CHARTS_FILE}")
@ -134,10 +133,7 @@ class ChartService:
Returns:
List of charts in the specified category
"""
return [
chart for chart in self._charts.values()
if chart.category == category
]
return [chart for chart in self._charts.values() if chart.category == category]
def get_chart_keys(self) -> List[str]:
"""
@ -157,8 +153,9 @@ class ChartService:
"""
return self._categories.copy()
def add_chart(self, key: str, name: str, category: str,
urls: List[str], description: str = "") -> None:
def add_chart(
self, key: str, name: str, category: str, urls: List[str], description: str = ""
) -> None:
"""
Add a new chart.
@ -176,18 +173,19 @@ class ChartService:
raise BotException(f"Chart '{key}' already exists")
self._charts[key] = Chart(
key=key,
name=name,
category=category,
description=description,
urls=urls
key=key, name=name, category=category, description=description, urls=urls
)
self._save_charts()
logger.info(f"Added chart: {key}")
def update_chart(self, key: str, name: Optional[str] = None,
category: Optional[str] = None, urls: Optional[List[str]] = None,
description: Optional[str] = None) -> None:
def update_chart(
self,
key: str,
name: Optional[str] = None,
category: Optional[str] = None,
urls: Optional[List[str]] = None,
description: Optional[str] = None,
) -> None:
"""
Update an existing chart.

View File

@ -468,21 +468,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
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 = []
for cmd_data in commands_data:
try:
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
for cmd_data, creator in zip(commands_data, creators):
if isinstance(creator, BotException):
self.logger.warning(
"Skipping popular command with missing creator",
command_id=cmd_data.id,
command_name=cmd_data.name,
creator_id=cmd_data.creator_id,
error=str(e),
error=str(creator),
)
continue
if isinstance(creator, BaseException):
raise creator
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
return commands
@ -670,21 +677,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
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 = []
for cmd_data in commands_data:
try:
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
for cmd_data, creator in zip(commands_data, creators):
if isinstance(creator, BotException):
self.logger.warning(
"Skipping command with missing creator",
command_id=cmd_data.id,
command_name=cmd_data.name,
creator_id=cmd_data.creator_id,
error=str(e),
error=str(creator),
)
continue
if isinstance(creator, BaseException):
raise creator
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
return commands
@ -696,21 +710,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
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 = []
for cmd_data in commands_data:
try:
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
for cmd_data, creator in zip(commands_data, creators):
if isinstance(creator, BotException):
self.logger.warning(
"Skipping command with missing creator",
command_id=cmd_data.id,
command_name=cmd_data.name,
creator_id=cmd_data.creator_id,
error=str(e),
error=str(creator),
)
continue
if isinstance(creator, BaseException):
raise creator
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
return commands

View File

@ -4,6 +4,7 @@ Decision Service
Manages pitching decision operations for game submission.
"""
import asyncio
from typing import List, Dict, Any, Optional, Tuple
from utils.logging import get_contextual_logger
@ -124,22 +125,19 @@ class DecisionService:
if int(decision.get("b_save", 0)) == 1:
bsv_ids.append(pitcher_id)
# Second pass: Fetch Player objects
wp = await player_service.get_player(wp_id) if wp_id else None
lp = await player_service.get_player(lp_id) if lp_id else None
sv = await player_service.get_player(sv_id) if sv_id else None
# Second pass: Fetch all Player objects in parallel
# Order: [wp_id, lp_id, sv_id, *hold_ids, *bsv_ids]; None IDs resolve immediately
ordered_ids = [wp_id, lp_id, sv_id] + hold_ids + bsv_ids
results = await asyncio.gather(
*[
player_service.get_player(pid) if pid else asyncio.sleep(0, result=None)
for pid in ordered_ids
]
)
holders = []
for hold_id in hold_ids:
holder = await player_service.get_player(hold_id)
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)
wp, lp, sv = results[0], results[1], results[2]
holders = [p for p in results[3 : 3 + len(hold_ids)] if p]
blown_saves = [p for p in results[3 + len(hold_ids) :] if p]
return wp, lp, sv, holders, blown_saves

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

View File

@ -3,6 +3,7 @@ Tests for SBA data models
Validates model creation, validation, and business logic.
"""
import pytest
from models import Team, Player, Current, DraftPick, DraftData, DraftList
@ -10,94 +11,102 @@ from models import Team, Player, Current, DraftPick, DraftData, DraftList
class TestSBABaseModel:
"""Test base model functionality."""
def test_model_creation_with_api_data(self):
"""Test creating models from API data."""
team_data = {
'id': 1,
'abbrev': 'NYY',
'sname': 'Yankees',
'lname': 'New York Yankees',
'season': 12
"id": 1,
"abbrev": "NYY",
"sname": "Yankees",
"lname": "New York Yankees",
"season": 12,
}
team = Team.from_api_data(team_data)
assert team.id == 1
assert team.abbrev == 'NYY'
assert team.lname == 'New York Yankees'
assert team.abbrev == "NYY"
assert team.lname == "New York Yankees"
def test_to_dict_functionality(self):
"""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()
assert 'abbrev' in team_dict
assert team_dict['abbrev'] == 'LAA'
assert team_dict['lname'] == 'Los Angeles Angels'
assert "abbrev" in team_dict
assert team_dict["abbrev"] == "LAA"
assert team_dict["lname"] == "Los Angeles Angels"
def test_model_repr(self):
"""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)
assert 'Team(' in repr_str
assert 'abbrev=BOS' in repr_str
assert "Team(" in repr_str
assert "abbrev=BOS" in repr_str
class TestTeamModel:
"""Test Team model functionality."""
def test_team_creation_minimal(self):
"""Test team creation with minimal required fields."""
team = Team(
id=4,
abbrev='HOU',
sname='Astros',
lname='Houston Astros',
season=12
id=4, abbrev="HOU", sname="Astros", lname="Houston Astros", season=12
)
assert team.abbrev == 'HOU'
assert team.sname == 'Astros'
assert team.lname == 'Houston Astros'
assert team.abbrev == "HOU"
assert team.sname == "Astros"
assert team.lname == "Houston Astros"
assert team.season == 12
def test_team_creation_with_optional_fields(self):
"""Test team creation with optional fields."""
team = Team(
id=5,
abbrev='SF',
sname='Giants',
lname='San Francisco Giants',
abbrev="SF",
sname="Giants",
lname="San Francisco Giants",
season=12,
gmid=100,
division_id=1,
stadium='Oracle Park',
color='FF8C00'
stadium="Oracle Park",
color="FF8C00",
)
assert team.gmid == 100
assert team.division_id == 1
assert team.stadium == 'Oracle Park'
assert team.color == 'FF8C00'
assert team.stadium == "Oracle Park"
assert team.color == "FF8C00"
def test_team_str_representation(self):
"""Test team string representation."""
team = Team(id=3, abbrev='SD', sname='Padres', lname='San Diego Padres', season=12)
assert str(team) == 'SD - San Diego Padres'
team = Team(
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):
"""Test roster type detection for Major League teams."""
from models.team import RosterType
# 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
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
# 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
def test_team_roster_type_minor_league(self):
@ -105,14 +114,28 @@ class TestTeamModel:
from models.team import RosterType
# 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
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
# 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
def test_team_roster_type_injured_list(self):
@ -120,14 +143,32 @@ class TestTeamModel:
from models.team import RosterType
# 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
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
# 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
def test_team_roster_type_edge_case_bhmil(self):
@ -143,16 +184,30 @@ class TestTeamModel:
from models.team import RosterType
# "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
# Compare with a real Minor League team that has "Island" in name
# "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
# 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
def test_team_roster_type_sname_disambiguation(self):
@ -160,221 +215,231 @@ class TestTeamModel:
from models.team import RosterType
# 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
# 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
# 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
class TestPlayerModel:
"""Test Player model functionality."""
def test_player_creation(self):
"""Test player creation with required fields."""
player = Player(
id=101,
name='Mike Trout',
name="Mike Trout",
wara=8.5,
season=12,
team_id=1,
image='trout.jpg',
pos_1='CF'
image="trout.jpg",
pos_1="CF",
)
assert player.name == 'Mike Trout'
assert player.name == "Mike Trout"
assert player.wara == 8.5
assert player.team_id == 1
assert player.pos_1 == 'CF'
assert player.pos_1 == "CF"
def test_player_positions_property(self):
"""Test player positions property."""
player = Player(
id=102,
name='Shohei Ohtani',
name="Shohei Ohtani",
wara=9.0,
season=12,
team_id=1,
image='ohtani.jpg',
pos_1='SP',
pos_2='DH',
pos_3='RF'
image="ohtani.jpg",
pos_1="SP",
pos_2="DH",
pos_3="RF",
)
positions = player.positions
assert len(positions) == 3
assert 'SP' in positions
assert 'DH' in positions
assert 'RF' in positions
assert "SP" in positions
assert "DH" in positions
assert "RF" in positions
def test_player_primary_position(self):
"""Test primary position property."""
player = Player(
id=103,
name='Mookie Betts',
name="Mookie Betts",
wara=7.2,
season=12,
team_id=1,
image='betts.jpg',
pos_1='RF',
pos_2='2B'
image="betts.jpg",
pos_1="RF",
pos_2="2B",
)
assert player.primary_position == 'RF'
assert player.primary_position == "RF"
def test_player_is_pitcher(self):
"""Test is_pitcher property."""
pitcher = Player(
id=104,
name='Gerrit Cole',
name="Gerrit Cole",
wara=6.8,
season=12,
team_id=1,
image='cole.jpg',
pos_1='SP'
image="cole.jpg",
pos_1="SP",
)
position_player = Player(
id=105,
name='Aaron Judge',
name="Aaron Judge",
wara=8.1,
season=12,
team_id=1,
image='judge.jpg',
pos_1='RF'
image="judge.jpg",
pos_1="RF",
)
assert pitcher.is_pitcher is True
assert position_player.is_pitcher is False
def test_player_str_representation(self):
"""Test player string representation."""
player = Player(
id=106,
name='Ronald Acuna Jr.',
name="Ronald Acuna Jr.",
wara=8.8,
season=12,
team_id=1,
image='acuna.jpg',
pos_1='OF'
image="acuna.jpg",
pos_1="OF",
)
assert str(player) == 'Ronald Acuna Jr. (OF)'
assert str(player) == "Ronald Acuna Jr. (OF)"
class TestCurrentModel:
"""Test Current league state model."""
def test_current_default_values(self):
"""Test current model with default values."""
current = Current()
assert current.week == 69
assert current.season == 69
assert current.freeze is True
assert current.bet_week == 'sheets'
assert current.bet_week == "sheets"
def test_current_with_custom_values(self):
"""Test current model with custom values."""
current = Current(
week=15,
season=12,
freeze=False,
trade_deadline=14,
playoffs_begin=19
week=15, season=12, freeze=False, trade_deadline=14, playoffs_begin=19
)
assert current.week == 15
assert current.season == 12
assert current.freeze is False
def test_current_properties(self):
"""Test current model properties."""
# Regular season
current = Current(week=10, playoffs_begin=19)
assert current.is_offseason is False
assert current.is_playoffs is False
# Playoffs
current = Current(week=20, playoffs_begin=19)
assert current.is_offseason is True
assert current.is_playoffs is True
# Pick trading
current = Current(week=15, pick_trade_start=10, pick_trade_end=20)
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:
"""Test DraftPick model functionality."""
def test_draft_pick_creation(self):
"""Test draft pick creation."""
pick = DraftPick(
season=12,
overall=1,
round=1,
origowner_id=1,
owner_id=1
)
pick = DraftPick(season=12, overall=1, round=1, origowner_id=1, owner_id=1)
assert pick.season == 12
assert pick.overall == 1
assert pick.origowner_id == 1
assert pick.owner_id == 1
def test_draft_pick_properties(self):
"""Test draft pick properties."""
# Not traded, not selected
pick = DraftPick(
season=12,
overall=5,
round=1,
origowner_id=1,
owner_id=1
)
pick = DraftPick(season=12, overall=5, round=1, origowner_id=1, owner_id=1)
assert pick.is_traded is False
assert pick.is_selected is False
# Traded pick
traded_pick = DraftPick(
season=12,
overall=10,
round=1,
origowner_id=1,
owner_id=2
season=12, overall=10, round=1, origowner_id=1, owner_id=2
)
assert traded_pick.is_traded is True
# Selected pick
selected_pick = DraftPick(
season=12,
overall=15,
round=1,
origowner_id=1,
owner_id=1,
player_id=100
season=12, overall=15, round=1, origowner_id=1, owner_id=1, player_id=100
)
assert selected_pick.is_selected is True
class TestDraftDataModel:
"""Test DraftData model functionality."""
def test_draft_data_creation(self):
"""Test draft data creation."""
draft_data = DraftData(
result_channel=123456789,
ping_channel=987654321,
pick_minutes=10
result_channel=123456789, ping_channel=987654321, pick_minutes=10
)
assert draft_data.result_channel == 123456789
@ -384,20 +449,12 @@ class TestDraftDataModel:
def test_draft_data_properties(self):
"""Test draft data properties."""
# Inactive draft
draft_data = DraftData(
result_channel=123,
ping_channel=456,
timer=False
)
draft_data = DraftData(result_channel=123, ping_channel=456, timer=False)
assert draft_data.is_draft_active is False
# Active draft
active_draft = DraftData(
result_channel=123,
ping_channel=456,
timer=True
)
active_draft = DraftData(result_channel=123, ping_channel=456, timer=True)
assert active_draft.is_draft_active is True
@ -409,17 +466,13 @@ class TestDraftListModel:
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."""
return Team(
id=team_id,
abbrev="TST",
sname="Test",
lname="Test Team",
season=12
id=team_id, 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."""
return Player(
id=player_id,
@ -430,7 +483,7 @@ class TestDraftListModel:
team_id=1,
season=12,
wara=2.5,
image="https://example.com/test.jpg"
image="https://example.com/test.jpg",
)
def test_draft_list_creation(self):
@ -438,12 +491,7 @@ class TestDraftListModel:
mock_team = self._create_mock_team(team_id=1)
mock_player = self._create_mock_player(player_id=100)
draft_entry = DraftList(
season=12,
team=mock_team,
rank=1,
player=mock_player
)
draft_entry = DraftList(season=12, team=mock_team, rank=1, player=mock_player)
assert draft_entry.season == 12
assert draft_entry.team_id == 1
@ -456,18 +504,10 @@ class TestDraftListModel:
mock_player_top = self._create_mock_player(player_id=100)
mock_player_lower = self._create_mock_player(player_id=200)
top_pick = DraftList(
season=12,
team=mock_team,
rank=1,
player=mock_player_top
)
top_pick = DraftList(season=12, team=mock_team, rank=1, player=mock_player_top)
lower_pick = DraftList(
season=12,
team=mock_team,
rank=5,
player=mock_player_lower
season=12, team=mock_team, rank=5, player=mock_player_lower
)
assert top_pick.is_top_ranked is True
@ -486,32 +526,32 @@ class TestDraftListModel:
"""
# Simulate API response format - nested objects, NOT flat IDs
api_response = {
'id': 303,
'season': 13,
'rank': 1,
'team': {
'id': 548,
'abbrev': 'WV',
'sname': 'Black Bears',
'lname': 'West Virginia Black Bears',
'season': 13
"id": 303,
"season": 13,
"rank": 1,
"team": {
"id": 548,
"abbrev": "WV",
"sname": "Black Bears",
"lname": "West Virginia Black Bears",
"season": 13,
},
'player': {
'id': 12843,
'name': 'George Springer',
'wara': 0.31,
'image': 'https://example.com/springer.png',
'season': 13,
'pos_1': 'CF',
"player": {
"id": 12843,
"name": "George Springer",
"wara": 0.31,
"image": "https://example.com/springer.png",
"season": 13,
"pos_1": "CF",
# Note: NO flat team_id here - it's nested in 'team' below
'team': {
'id': 547, # Free Agent team
'abbrev': 'FA',
'sname': 'Free Agents',
'lname': 'Free Agents',
'season': 13
}
}
"team": {
"id": 547, # Free Agent team
"abbrev": "FA",
"sname": "Free Agents",
"lname": "Free Agents",
"season": 13,
},
},
}
# Create DraftList using from_api_data (what BaseService calls)
@ -522,87 +562,94 @@ class TestDraftListModel:
assert draft_entry.player is not None
# 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}"
)
# Verify the nested team object is also populated
assert draft_entry.player.team is not None
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
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
class TestModelCoverageExtras:
"""Additional model coverage tests."""
def test_base_model_from_api_data_validation(self):
"""Test from_api_data with various edge cases."""
from models.base import SBABaseModel
# 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({})
# 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)
def test_player_positions_comprehensive(self):
"""Test player positions property with all position variations."""
player_data = {
'id': 201,
'name': 'Multi-Position Player',
'wara': 3.0,
'season': 12,
'team_id': 5,
'image': 'https://example.com/player.jpg',
'pos_1': 'C',
'pos_2': '1B',
'pos_3': '3B',
'pos_4': None, # Test None handling
'pos_5': 'DH',
'pos_6': 'OF',
'pos_7': None, # Another None
'pos_8': 'SS'
"id": 201,
"name": "Multi-Position Player",
"wara": 3.0,
"season": 12,
"team_id": 5,
"image": "https://example.com/player.jpg",
"pos_1": "C",
"pos_2": "1B",
"pos_3": "3B",
"pos_4": None, # Test None handling
"pos_5": "DH",
"pos_6": "OF",
"pos_7": None, # Another None
"pos_8": "SS",
}
player = Player.from_api_data(player_data)
positions = player.positions
assert 'C' in positions
assert '1B' in positions
assert '3B' in positions
assert 'DH' in positions
assert 'OF' in positions
assert 'SS' in positions
assert "C" in positions
assert "1B" in positions
assert "3B" in positions
assert "DH" in positions
assert "OF" in positions
assert "SS" in positions
assert len(positions) == 6 # Should exclude None values
assert None not in positions
def test_player_is_pitcher_variations(self):
"""Test is_pitcher property with different positions."""
test_cases = [
('SP', True), # Starting pitcher
('RP', True), # Relief pitcher
('P', True), # Generic pitcher
('C', False), # Catcher
('1B', False), # First base
('OF', False), # Outfield
('DH', False), # Designated hitter
("SP", True), # Starting pitcher
("RP", True), # Relief pitcher
("P", True), # Generic pitcher
("C", False), # Catcher
("1B", False), # First base
("OF", False), # Outfield
("DH", False), # Designated hitter
]
for position, expected in test_cases:
player_data = {
'id': 300 + ord(position[0]), # Generate unique IDs based on position
'name': f'Test {position}',
'wara': 2.0,
'season': 12,
'team_id': 5,
'image': 'https://example.com/player.jpg',
'pos_1': position,
"id": 300 + ord(position[0]), # Generate unique IDs based on position
"name": f"Test {position}",
"wara": 2.0,
"season": 12,
"team_id": 5,
"image": "https://example.com/player.jpg",
"pos_1": position,
}
player = Player.from_api_data(player_data)
assert player.is_pitcher == expected, f"Position {position} should return {expected}"
assert player.primary_position == position
assert player.is_pitcher == expected, (
f"Position {position} should return {expected}"
)
assert player.primary_position == position

View File

@ -24,7 +24,8 @@ from utils.scorebug_helpers import create_scorebug_embed, create_team_progress_b
class TestScorecardTrackerFreshReads:
"""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.
Simulates the background task having a stale tracker instance while
@ -34,7 +35,7 @@ class TestScorecardTrackerFreshReads:
data_file.write_text(json.dumps({"scorecards": {}}))
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
new_data = {
@ -51,17 +52,18 @@ class TestScorecardTrackerFreshReads:
data_file.write_text(json.dumps(new_data))
# Should see the new scorecard without restart
result = tracker.get_all_scorecards()
result = await tracker.get_all_scorecards()
assert len(result) == 1
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."""
data_file = tmp_path / "scorecards.json"
data_file.write_text(json.dumps({"scorecards": {}}))
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
new_data = {
@ -79,7 +81,7 @@ class TestScorecardTrackerFreshReads:
# Should see the new scorecard
assert (
tracker.get_scorecard(222)
await tracker.get_scorecard(222)
== "https://docs.google.com/spreadsheets/d/xyz789"
)

View File

@ -124,6 +124,22 @@ class TradeEmbedView(discord.ui.View):
self, interaction: discord.Interaction, button: discord.ui.Button
):
"""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:
await interaction.response.send_message(
"Cannot submit empty trade. Add some moves first!", ephemeral=True
@ -433,7 +449,16 @@ class TradeAcceptanceView(discord.ui.View):
config = get_config()
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(
id=config.free_agent_team_id,