feat: implement user data schema and API endpoints

- Add Alembic migration setup with async configuration
- Create 16 user data models (users, decks, cards, replays, etc.)
- Implement comprehensive API endpoints with JWT auth
- Add replay, card collection, group, network, preferences, and activity log routers
- Include API documentation and migration test plan
- Update Dockerfile to run migrations on startup
This commit is contained in:
2026-07-23 03:23:54 +00:00
parent 2c74a107bc
commit c42d7ca0e1
13 changed files with 4068 additions and 3 deletions
+852
View File
@@ -0,0 +1,852 @@
# User Data API Endpoints - Complete Documentation
## Overview
The User Data API provides comprehensive CRUD operations for:
- **Session Management** - User authentication sessions
- **Deck Versions** - Deck version history and rollback
- **Game Replays** - Game recording and playback
- **Game Outcomes** - Win/loss tracking with ratings
- **User Statistics** - Denormalized stats (games, wins, streaks)
- **Card Collection** - User-owned cards with condition/language
- **Wishlist** - Cards users want to acquire
- **Groups** - User groups with roles and chat
- **Networks** - Extended social connections
- **Preferences** - User settings and preferences
- **Activity Log** - Audit trail with JSONB metadata
## Base URL
```
/api/v1/user-data
```
## Authentication
All endpoints require a valid JWT token in the `Authorization` header:
```
Authorization: Bearer <your-jwt-token>
```
---
## 1. Session Management
### Get Active Sessions
```http
GET /sessions/me
```
**Response:**
```json
[
{
"cleaned_count": 2,
"message": "Found 2 active sessions"
}
]
```
### Cleanup Expired Sessions
```http
DELETE /sessions/cleanup
```
**Response:**
```json
{
"message": "Cleaned 5 expired sessions"
}
```
### Logout Current Session
```http
POST /sessions/logout
```
**Response:**
```json
{
"message": "Logged out successfully"
}
```
---
## 2. Deck Versions
### Create Deck Version
```http
POST /decks/{deck_id}/versions
Content-Type: application/json
{
"content": "4x Thoughtseize, 4x Lightning Bolt, ...",
"status": "DRAFT",
"comment": "Updated for meta change"
}
```
**Response:**
```json
{
"id": 1,
"deck_id": 42,
"version_number": 3,
"content": "4x Thoughtseize, ...",
"status": "DRAFT",
"comment": "Updated for meta change",
"created_at": "2026-01-01T12:00:00Z"
}
```
### Get Deck Versions
```http
GET /decks/{deck_id}/versions?page=1&page_size=50
```
**Response:**
```json
{
"versions": [...],
"total": 10,
"page": 1,
"page_size": 50,
"total_pages": 1
}
```
### Update Deck Version
```http
PATCH /decks/{deck_id}/versions/{version_id}
Content-Type: application/json
{
"status": "FINAL",
"comment": "Ready for tournament"
}
```
### Delete Deck Version
```http
DELETE /decks/{deck_id}/versions/{version_id}
```
---
## 3. Game Replays
### Create Game Replay
```http
POST /replays
Content-Type: application/json
{
"game_uuid": "550e8400-e29b-41d4-a716-446655440000",
"room_id": 1,
"game_type": "Draft",
"format": "Standard",
"duration_seconds": 1800,
"start_time": "2026-01-01T12:00:00Z",
"end_time": "2026-01-01T12:30:00Z",
"status": "COMPLETED",
"replay_data": {
"turns": [...],
"deck": {...}
}
}
```
**Response:**
```json
{
"id": 1,
"game_uuid": "550e8400-...",
"room_id": 1,
"game_type": "Draft",
"format": "Standard",
"duration_seconds": 1800,
"start_time": "2026-01-01T12:00:00Z",
"end_time": "2026-01-01T12:30:00Z",
"status": "COMPLETED",
"replay_data": {...},
"created_at": "2026-01-01T12:30:00Z",
"updated_at": "2026-01-01T12:30:00Z",
"players": [...]
}
```
### Get Game Replays
```http
GET /replays?page=1&page_size=50&user_id=42&status_filter=COMPLETED
```
**Response:**
```json
{
"replays": [...],
"total": 100,
"page": 1,
"page_size": 50,
"total_pages": 2
}
```
### Get Replay Players
```http
GET /replays/{replay_id}/players
```
**Response:**
```json
[
{
"id": 1,
"user_id": 42,
"deck_id": 10,
"position": 1,
"won": true,
"lost": false,
"concession": false,
"turn_one": false
}
]
```
---
## 4. Game Outcomes
### Create Game Outcome
```http
POST /outcomes
Content-Type: application/json
{
"game_uuid": "550e8400-e29b-41d4-a716-446655440000",
"outcome": "WIN",
"opponent_id": 99,
"format": "Standard",
"rating_before": 1500,
"rating_after": 1525,
"rating_change": 25
}
```
**Response:**
```json
{
"id": 1,
"user_id": 42,
"game_uuid": "550e8400-...",
"outcome": "WIN",
"opponent_id": 99,
"format": "Standard",
"rating_before": 1500,
"rating_after": 1525,
"rating_change": 25,
"created_at": "2026-01-01T12:30:00Z"
}
```
### Get Game Outcomes
```http
GET /outcomes?page=1&page_size=50&user_id=42
```
---
## 5. User Statistics
### Get User Statistics
```http
GET /statistics/{user_id}
```
**Response:**
```json
{
"user_id": 42,
"total_games": 150,
"total_wins": 90,
"total_losses": 55,
"total_concessions": 5,
"win_rate": 60.0,
"current_streak": 3,
"best_streak": 8,
"average_rating": 1450.5,
"last_game_date": "2026-01-01T12:00:00Z",
"updated_at": "2026-01-01T12:30:00Z"
}
```
### Update User Statistics
```http
POST /statistics/update
Content-Type: application/json
{
"user_id": 42,
"outcome": "WIN"
}
```
**Response:**
```json
{
"user_id": 42,
"total_games": 151,
"total_wins": 91,
"total_losses": 55,
"win_rate": 60.26,
"current_streak": 4,
"updated_at": "2026-01-01T12:35:00Z"
}
```
---
## 6. Card Collection
### Add Card to Collection
```http
POST /collection
Content-Type: application/json
{
"card_id": 12345,
"quantity": 4,
"condition": "NEAR_MINT",
"language": "EN",
"is_foil": true,
"is_alt_art": false,
"acquired_date": "2026-01-01T00:00:00Z",
"acquisition_method": "Bought",
"notes": "From card shop"
}
```
**Response:**
```json
{
"id": 1,
"user_id": 42,
"card_id": 12345,
"quantity": 4,
"condition": "NEAR_MINT",
"language": "EN",
"is_foil": true,
"is_alt_art": false,
"acquired_date": "2026-01-01T00:00:00Z",
"acquisition_method": "Bought",
"notes": "From card shop",
"created_at": "2026-01-01T12:00:00Z",
"updated_at": "2026-01-01T12:00:00Z"
}
```
### Get Card Collection
```http
GET /collection?page=1&page_size=50&is_foil=true&is_alt_art=false
```
**Response:**
```json
{
"cards": [...],
"total": 500,
"page": 1,
"page_size": 50,
"total_pages": 10
}
```
### Update Card in Collection
```http
PATCH /collection/{card_id}
Content-Type: application/json
{
"quantity": 3,
"condition": "EX",
"notes": "Slightly worn"
}
```
### Remove Card from Collection
```http
DELETE /collection/{card_id}
```
---
## 7. Wishlist
### Add to Wishlist
```http
POST /wishlist
Content-Type: application/json
{
"card_id": 12345,
"max_price": 50.00,
"notes": "Looking for foil version"
}
```
**Response:**
```json
{
"id": 1,
"user_id": 42,
"card_id": 12345,
"max_price": 50.00,
"notes": "Looking for foil version",
"created_at": "2026-01-01T12:00:00Z"
}
```
### Get Wishlist
```http
GET /wishlist?page=1&page_size=50
```
### Update Wishlist Item
```http
PATCH /wishlist/{item_id}
Content-Type: application/json
{
"max_price": 75.00,
"notes": "Willing to pay more"
}
```
### Remove from Wishlist
```http
DELETE /wishlist/{item_id}
```
---
## 8. Groups
### Create Group
```http
POST /groups
Content-Type: application/json
{
"name": "Standard Players",
"description": "Casual Standard players",
"is_public": true,
"max_members": 50
}
```
**Response:**
```json
{
"id": 1,
"name": "Standard Players",
"description": "Casual Standard players",
"owner_id": 42,
"is_public": true,
"max_members": 50,
"created_at": "2026-01-01T12:00:00Z",
"updated_at": "2026-01-01T12:00:00Z",
"member_count": 1,
"is_member": true
}
```
### Get User Groups
```http
GET /groups?page=1&page_size=50&is_public=true
```
**Response:**
```json
{
"groups": [...],
"total": 5,
"page": 1,
"page_size": 50,
"total_pages": 1
}
```
### Update Group
```http
PATCH /groups/{group_id}
Content-Type: application/json
{
"description": "Updated description",
"max_members": 100
}
```
### Delete Group
```http
DELETE /groups/{group_id}
```
---
## 9. Group Members
### Add Group Member
```http
POST /groups/{group_id}/members
Content-Type: application/json
{
"user_id": 99,
"role": "MEMBER"
}
```
**Response:**
```json
{
"message": "Member added",
"member_id": 5
}
```
### Update Group Member Role
```http
PATCH /groups/{group_id}/members/{member_id}
Content-Type: application/json
{
"role": "ADMIN"
}
```
**Response:**
```json
{
"message": "Member role updated"
}
```
### Remove Group Member
```http
DELETE /groups/{group_id}/members/{member_id}
```
---
## 10. Group Chat Messages
### Send Group Message
```http
POST /groups/{group_id}/messages
Content-Type: application/json
{
"message": "Hey everyone! Ready for a game?"
}
```
**Response:**
```json
{
"id": 1,
"group_id": 1,
"sender_id": 42,
"sender_username": "player42",
"message": "Hey everyone! Ready for a game?",
"created_at": "2026-01-01T12:00:00Z"
}
```
### Get Group Messages
```http
GET /groups/{group_id}/messages?page=1&page_size=50
```
**Response:**
```json
{
"messages": [...],
"total": 25,
"page": 1,
"page_size": 50,
"total_pages": 1
}
```
---
## 11. Networks
### Create Network
```http
POST /networks
Content-Type: application/json
{
"name": "MTG Enthusiasts",
"description": "Friends who play Magic",
"is_public": true
}
```
**Response:**
```json
{
"id": 1,
"name": "MTG Enthusiasts",
"description": "Friends who play Magic",
"creator_id": 42,
"is_public": true,
"created_at": "2026-01-01T12:00:00Z",
"member_count": 1,
"is_member": true
}
```
### Get User Networks
```http
GET /networks?page=1&page_size=50
```
### Update Network
```http
PATCH /networks/{network_id}
Content-Type: application/json
{
"description": "Updated network description"
}
```
### Delete Network
```http
DELETE /networks/{network_id}
```
---
## 12. Network Members
### Add Network Member
```http
POST /networks/{network_id}/members
Content-Type: application/json
{
"user_id": 99,
"role": "MEMBER"
}
```
**Response:**
```json
{
"message": "Member added",
"member_id": 3
}
```
---
## 13. User Preferences
### Get User Preferences
```http
GET /preferences
```
**Response:**
```json
{
"user_id": 42,
"theme": "light",
"notifications_enabled": true,
"email_notifications": true,
"auto_save_decks": true,
"default_format": "standard",
"language": "EN",
"updated_at": "2026-01-01T12:00:00Z"
}
```
### Update User Preferences
```http
PATCH /preferences
Content-Type: application/json
{
"theme": "dark",
"notifications_enabled": false,
"default_format": "modern"
}
```
**Response:**
```json
{
"user_id": 42,
"theme": "dark",
"notifications_enabled": false,
"email_notifications": true,
"auto_save_decks": true,
"default_format": "modern",
"language": "EN",
"updated_at": "2026-01-01T12:05:00Z"
}
```
---
## 14. Activity Log
### Get Activity Log
```http
GET /activity?page=1&page_size=50&activity_type=LOGIN
```
**Response:**
```json
{
"entries": [
{
"id": 1,
"user_id": 42,
"activity_type": "LOGIN",
"activity_data": {"ip": "192.168.1.1"},
"ip_address": "192.168.1.1",
"created_at": "2026-01-01T12:00:00Z"
}
],
"total": 100,
"page": 1,
"page_size": 50,
"total_pages": 2
}
```
---
## Error Responses
All endpoints return consistent error responses:
```json
{
"detail": "Error message"
}
```
### Common Error Codes
| Status Code | Description |
|------------|-------------|
| 400 | Bad Request - Invalid input |
| 401 | Unauthorized - Missing or invalid token |
| 403 | Forbidden - Insufficient permissions |
| 404 | Not Found - Resource doesn't exist |
| 409 | Conflict - Resource already exists |
| 500 | Internal Server Error |
---
## Testing with cURL
### Example: Create a Deck Version
```bash
curl -X POST "http://localhost:8000/api/v1/user-data/decks/42/versions" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"content": "4x Thoughtseize, 4x Lightning Bolt, ...",
"status": "DRAFT",
"comment": "Updated for meta"
}'
```
### Example: Get Card Collection
```bash
curl "http://localhost:8000/api/v1/user-data/collection?page=1&page_size=10" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
### Example: Add to Wishlist
```bash
curl -X POST "http://localhost:8000/api/v1/user-data/wishlist" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"card_id": 12345,
"max_price": 50.00,
"notes": "Looking for foil"
}'
```
---
## Available Endpoints Summary
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/sessions/me` | Get active sessions |
| DELETE | `/sessions/cleanup` | Cleanup expired sessions |
| POST | `/sessions/logout` | Logout current session |
| POST | `/decks/{id}/versions` | Create deck version |
| GET | `/decks/{id}/versions` | Get deck versions |
| PATCH | `/decks/{id}/versions/{vid}` | Update deck version |
| DELETE | `/decks/{id}/versions/{vid}` | Delete deck version |
| POST | `/replays` | Create game replay |
| GET | `/replays` | Get game replays |
| GET | `/replays/{id}` | Get specific replay |
| PATCH | `/replays/{id}` | Update replay |
| DELETE | `/replays/{id}` | Delete replay |
| POST | `/replays/{id}/players` | Add player to replay |
| GET | `/replays/{id}/players` | Get replay players |
| POST | `/outcomes` | Create game outcome |
| GET | `/outcomes` | Get game outcomes |
| GET | `/statistics/{id}` | Get user statistics |
| POST | `/statistics/update` | Update user statistics |
| POST | `/collection` | Add card to collection |
| GET | `/collection` | Get card collection |
| PATCH | `/collection/{id}` | Update card |
| DELETE | `/collection/{id}` | Remove card |
| POST | `/wishlist` | Add to wishlist |
| GET | `/wishlist` | Get wishlist |
| PATCH | `/wishlist/{id}` | Update wishlist item |
| DELETE | `/wishlist/{id}` | Remove from wishlist |
| POST | `/groups` | Create group |
| GET | `/groups` | Get user groups |
| GET | `/groups/{id}` | Get specific group |
| PATCH | `/groups/{id}` | Update group |
| DELETE | `/groups/{id}` | Delete group |
| POST | `/groups/{id}/members` | Add group member |
| PATCH | `/groups/{id}/members/{mid}` | Update member role |
| DELETE | `/groups/{id}/members/{mid}` | Remove member |
| POST | `/groups/{id}/messages` | Send group message |
| GET | `/groups/{id}/messages` | Get group messages |
| POST | `/networks` | Create network |
| GET | `/networks` | Get user networks |
| GET | `/networks/{id}` | Get specific network |
| PATCH | `/networks/{id}` | Update network |
| DELETE | `/networks/{id}` | Delete network |
| POST | `/networks/{id}/members` | Add network member |
| GET | `/preferences` | Get user preferences |
| PATCH | `/preferences` | Update preferences |
| GET | `/activity` | Get activity log |
---
## Next Steps
1. **Test endpoints** with curl or Postman
2. **Create integration tests** for each endpoint
3. **Add rate limiting** for production
4. **Implement pagination optimization** for large datasets
5. **Add search functionality** for cards and decks
6. **Create webhook endpoints** for real-time notifications
+7 -2
View File
@@ -29,11 +29,15 @@ ENV PATH=/app/.local/bin:$PATH
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
RUN mkdir -p /app/data /app/uploads /app/logs /app/scripts && chown -R appuser:appuser /app RUN mkdir -p /app/data /app/uploads /app/logs /app/scripts /app/alembic/versions && chown -R appuser:appuser /app
# Copy application code # Copy application code
COPY --chown=appuser:appuser app/ ./app/ COPY --chown=appuser:appuser app/ ./app/
# Copy Alembic configuration
COPY --chown=appuser:appuser alembic.ini ./
COPY --chown=appuser:appuser alembic/ ./alembic/
# Copy interaction pipeline scripts # Copy interaction pipeline scripts
COPY --chown=appuser:appuser scripts/ ./scripts/ COPY --chown=appuser:appuser scripts/ ./scripts/
RUN chmod +x /app/scripts/*.py RUN chmod +x /app/scripts/*.py
@@ -52,4 +56,5 @@ USER appuser
EXPOSE 8000 EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] # Run migrations before starting the app
CMD ["sh", "-c", "python -m alembic upgrade head && python -m uvicorn app.main:app --host 0.0.0.0 --port 8000"]
+142
View File
@@ -0,0 +1,142 @@
# Alembic Migration Test Plan
## Overview
Test the Alembic migration setup to verify all user data tables are created correctly in PostgreSQL.
## Test Steps
### 1. Verify File Structure
- [x] Create `alembic.ini` with database URL configuration
- [x] Create `alembic/env.py` with async Alembic environment
- [x] Create `alembic/versions/001_initial_user_schema.py` with migration script
- [x] Create `alembic/versions/__init__.py`
- [x] Create `app/models/user_data.py` with all new models
- [x] Update `app/models/__init__.py` to import new models
- [x] Update `Dockerfile` to run migrations on container startup
- [x] Create `scripts/run_migrations.sh` for migration execution
### 2. Test Migration Execution
- [ ] Verify Alembic configuration is correct
- [ ] Test migration in offline mode
- [ ] Test migration in online mode (if database is available)
- [ ] Verify all tables are created with correct schema
### 3. Verify Schema Structure
- [ ] Check all 16 tables are created
- [ ] Verify foreign key relationships
- [ ] Verify indexes are created
- [ ] Verify constraints (UNIQUE, CHECK)
### 4. Test Data Operations
- [ ] Insert test data into each table
- [ ] Verify CASCADE deletes work correctly
- [ ] Verify UNIQUE constraints prevent duplicates
- [ ] Verify JSONB columns store data correctly
### 5. Test Rollback
- [ ] Execute downgrade migration
- [ ] Verify all tables are dropped
- [ ] Verify columns are removed from existing tables
## Files Created
### Core Alembic Files
1. **alembic.ini** - Alembic configuration with database URL
2. **alembic/env.py** - Async Alembic environment for PostgreSQL
3. **alembic/versions/001_initial_user_schema.py** - Initial migration script
### New Models
4. **app/models/user_data.py** - All user data models (16 models)
- UserSession, DeckVersion, GameReplay, ReplayPlayer
- GameOutcome, UserStatistics, UserCardCollection, CardWishlist
- UserGroup, GroupMember, GroupChatMessage
- UserNetwork, NetworkMember, UserPreference, UserActivityLog
### Updated Files
5. **app/models/__init__.py** - Added imports for new models
6. **Dockerfile** - Added migration step to container startup
7. **scripts/run_migrations.sh** - Migration execution script
## Expected Tables
### User Authentication
1. `user_sessions` - Session management with token hashing
### Deck Management
2. `mtgonline_decklist_files` - Enhanced with description, format, etc.
3. `deck_versions` - Deck version history
### Game Tracking
4. `game_replays` - Game replay recordings
5. `replay_players` - Players in game replays
6. `game_outcomes` - Game win/loss records
7. `user_statistics` - User game statistics summary
### Card Collection
8. `user_card_collection` - User-owned cards
9. `card_wishlist` - Cards users want
### Social Features
10. `user_groups` - User groups
11. `group_members` - Group membership
12. `group_chat_messages` - Group chat
13. `user_networks` - Extended social connections
14. `network_members` - Network membership
### User Settings
15. `user_preferences` - User preferences and settings
16. `user_activity_log` - User activity tracking
## Migration Commands
### Run Migrations
```bash
# Online mode (requires database connection)
alembic upgrade head
# Offline mode (for testing schema generation)
alembic upgrade head --sql
# Check migration status
alembic current
alembic history
# Generate new migration (after model changes)
alembic revision --autogenerate -m "Description"
```
### Test Commands
```bash
# Test alembic configuration
alembic --config alembic.ini current
# Test migration generation
alembic --config alembic.ini upgrade head --sql
# Run migration
alembic --config alembic.ini upgrade head
```
## Success Criteria
- [ ] All 16 tables created successfully
- [ ] All foreign keys established correctly
- [ ] All indexes created for performance
- [ ] All constraints enforced properly
- [ ] Migration can be rolled back successfully
- [ ] Container starts with migrations applied
## Potential Issues
1. **Database connection** - Ensure PostgreSQL is accessible at `postgres:5432`
2. **Model imports** - Verify all models are imported in env.py
3. **Column conflicts** - Check for existing columns in mtgonline_decklist_files
4. **Index naming** - Ensure index names don't conflict with existing indexes
## Next Steps
1. Run the container and verify migrations execute
2. Test data insertion and retrieval
3. Verify CASCADE deletes work correctly
4. Test downgrade migration
5. Create API endpoints for new features
+86
View File
@@ -0,0 +1,86 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or python-dateutil library.
# https://alembic.sqlalchemy.org/en/latest/cookbook.html#using-the-_new_tzinfo_techique_to_run_in_a_specific_timezone
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a .py source will be used as the source for the executed
# .py source files.
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --start-version.
# version_path_separator = os
# output encoding. If set to utf-8, it will encode all output for utf-8 encoding.
# If set to utf-8-sig, the BOM will be written to the output.
# This is useful for files that will be opened in Windows editors.
output_encoding = utf-8
sqlalchemy.url = postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See https://alembic.sqlalchemy.org/en/latest/hooks.html
# for hooks documentation.
# Logging configuration
[loggers]
keys = root, sqlalchemy, alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+86
View File
@@ -0,0 +1,86 @@
"""
Alembic environment configuration for async SQLAlchemy.
Supports async database operations for migrations.
"""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import all models so Alembic can detect changes
from app.core.database import Base
from app.models import (
User, DecklistFile, DecklistFolder, Room, RoomGameType,
Ban, GameLog, AuditLog, MtgCardMirror, DeckCardLink
)
# Import new models
from app.models.user_data import (
UserSession, DeckVersion, GameReplay, ReplayPlayer,
GameOutcome, UserStatistics, UserCardCollection, CardWishlist,
UserGroup, GroupMember, GroupChatMessage, UserNetwork,
NetworkMember, UserPreference, UserActivityLog
)
# this is the Alembic Config object
config = context.config
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine instance though.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Run migrations with proper async context."""
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async engine."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
options={"pool_pre_ping": True},
class_=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,281 @@
"""initial user schema
Revision ID: 001
Revises:
Create Date: 2026-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create all user data tables."""
# 1. User Sessions Table
op.create_table(
'user_sessions',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False),
sa.Column('session_token_hash', sa.String(255), unique=True, nullable=False),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), default=True),
)
op.create_index('idx_sessions_user', 'user_sessions', ['user_id'])
op.create_index('idx_sessions_token', 'user_sessions', ['session_token_hash'])
op.create_index('idx_sessions_expires', 'user_sessions', ['expires_at'])
# 2. Enhanced Decklist File columns
op.add_column('mtgonline_decklist_files', sa.Column('description', sa.Text(), nullable=True))
op.add_column('mtgonline_decklist_files', sa.Column('format', sa.String(50), server_default='standard'))
op.add_column('mtgonline_decklist_files', sa.Column('is_favorite', sa.Boolean(), default=False))
op.add_column('mtgonline_decklist_files', sa.Column('import_source', sa.String(50), nullable=True))
op.add_column('mtgonline_decklist_files', sa.Column('import_confidence', sa.Float(), nullable=True))
op.add_column('mtgonline_decklist_files', sa.Column('last_played', sa.DateTime(), nullable=True))
# 3. Deck Versions Table
op.create_table(
'deck_versions',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('deck_id', sa.Integer(), sa.ForeignKey('mtgonline_decklist_files.id', ondelete='CASCADE'), nullable=False),
sa.Column('version_number', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('status', sa.String(20), server_default='DRAFT'),
sa.Column('comment', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_deck_versions_deck', 'deck_versions', ['deck_id'])
# 4. Game Replays Table
op.create_table(
'game_replays',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('game_uuid', sa.String(36), unique=True, nullable=False),
sa.Column('room_id', sa.Integer(), sa.ForeignKey('mtgonline_rooms.id'), nullable=True),
sa.Column('game_type', sa.String(50), nullable=True),
sa.Column('format', sa.String(50), nullable=True),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(20), server_default='IN_PROGRESS'),
sa.Column('replay_data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index('idx_replays_room', 'game_replays', ['room_id'])
op.create_index('idx_replays_start', 'game_replays', ['start_time'])
op.create_index('idx_replays_status', 'game_replays', ['status'])
# 5. Replay Players Table
op.create_table(
'replay_players',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('replay_id', sa.BigInteger(), sa.ForeignKey('game_replays.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('position', sa.Integer(), nullable=True),
sa.Column('deck_id', sa.Integer(), sa.ForeignKey('mtgonline_decklist_files.id'), nullable=True),
sa.Column('won', sa.Boolean(), nullable=True),
sa.Column('lost', sa.Boolean(), nullable=True),
sa.Column('concession', sa.Boolean(), default=False),
sa.Column('turn_one', sa.Boolean(), default=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_replay_players_replay', 'replay_players', ['replay_id'])
op.create_index('idx_replay_players_user', 'replay_players', ['user_id'])
# 6. Game Outcomes Table
op.create_table(
'game_outcomes',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('game_uuid', sa.String(36), sa.ForeignKey('game_replays.game_uuid'), nullable=False),
sa.Column('outcome', sa.String(20), nullable=False),
sa.Column('opponent_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=True),
sa.Column('format', sa.String(50), nullable=True),
sa.Column('rating_before', sa.Integer(), nullable=True),
sa.Column('rating_after', sa.Integer(), nullable=True),
sa.Column('rating_change', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_outcomes_user', 'game_outcomes', ['user_id'])
op.create_index('idx_outcomes_game', 'game_outcomes', ['game_uuid'])
op.create_index('idx_outcomes_outcome', 'game_outcomes', ['outcome'])
# 7. User Statistics Table
op.create_table(
'user_statistics',
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), primary_key=True),
sa.Column('total_games', sa.Integer(), default=0),
sa.Column('total_wins', sa.Integer(), default=0),
sa.Column('total_losses', sa.Integer(), default=0),
sa.Column('total_concessions', sa.Integer(), default=0),
sa.Column('win_rate', sa.Float(), default=0.0),
sa.Column('current_streak', sa.Integer(), default=0),
sa.Column('best_streak', sa.Integer(), default=0),
sa.Column('average_rating', sa.Float(), default=0.0),
sa.Column('last_game_date', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
# 8. User Card Collection Table
op.create_table(
'user_card_collection',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False),
sa.Column('card_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), default=1),
sa.Column('condition', sa.String(20), server_default='NEAR_MINT'),
sa.Column('language', sa.String(5), server_default='EN'),
sa.Column('is_foil', sa.Boolean(), default=False),
sa.Column('is_alt_art', sa.Boolean(), default=False),
sa.Column('acquired_date', sa.DateTime(), server_default=sa.func.now()),
sa.Column('acquisition_method', sa.String(50), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index('idx_collection_user', 'user_card_collection', ['user_id'])
op.create_index('idx_collection_card', 'user_card_collection', ['card_id'])
op.create_unique_constraint('uq_collection_unique', 'user_card_collection', ['user_id', 'card_id', 'is_foil', 'is_alt_art'])
# 9. Card Wishlist Table
op.create_table(
'card_wishlist',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False),
sa.Column('card_id', sa.Integer(), nullable=False),
sa.Column('max_price', sa.Float(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_unique_constraint('uq_wishlist_user_card', 'card_wishlist', ['user_id', 'card_id'])
# 10. User Groups Table
op.create_table(
'user_groups',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('owner_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('is_public', sa.Boolean(), default=True),
sa.Column('max_members', sa.Integer(), default=50),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index('idx_groups_owner', 'user_groups', ['owner_id'])
# 11. Group Members Table
op.create_table(
'group_members',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('group_id', sa.BigInteger(), sa.ForeignKey('user_groups.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('role', sa.String(20), server_default='MEMBER'),
sa.Column('joined_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_members_group', 'group_members', ['group_id'])
op.create_index('idx_members_user', 'group_members', ['user_id'])
op.create_unique_constraint('uq_group_member', 'group_members', ['group_id', 'user_id'])
# 12. Group Chat Messages Table
op.create_table(
'group_chat_messages',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('group_id', sa.BigInteger(), sa.ForeignKey('user_groups.id', ondelete='CASCADE'), nullable=False),
sa.Column('sender_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_group_messages_group', 'group_chat_messages', ['group_id'])
op.create_index('idx_group_messages_sender', 'group_chat_messages', ['sender_id'])
op.create_index('idx_group_messages_created', 'group_chat_messages', ['created_at'])
# 13. User Networks Table
op.create_table(
'user_networks',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('creator_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('is_public', sa.Boolean(), default=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
# 14. Network Members Table
op.create_table(
'network_members',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('network_id', sa.BigInteger(), sa.ForeignKey('user_networks.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('role', sa.String(20), server_default='MEMBER'),
sa.Column('joined_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_network_members_network', 'network_members', ['network_id'])
op.create_index('idx_network_members_user', 'network_members', ['user_id'])
op.create_unique_constraint('uq_network_member', 'network_members', ['network_id', 'user_id'])
# 15. User Preferences Table
op.create_table(
'user_preferences',
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), primary_key=True),
sa.Column('theme', sa.String(20), server_default='light'),
sa.Column('notifications_enabled', sa.Boolean(), default=True),
sa.Column('email_notifications', sa.Boolean(), default=True),
sa.Column('auto_save_decks', sa.Boolean(), default=True),
sa.Column('default_format', sa.String(50), server_default='standard'),
sa.Column('language', sa.String(5), server_default='EN'),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
# 16. User Activity Log Table
op.create_table(
'user_activity_log',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('activity_type', sa.String(50), nullable=False),
sa.Column('activity_data', sa.JSON(), nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_activity_user', 'user_activity_log', ['user_id'])
op.create_index('idx_activity_type', 'user_activity_log', ['activity_type'])
op.create_index('idx_activity_created', 'user_activity_log', ['created_at'])
def downgrade() -> None:
"""Drop all user data tables."""
op.drop_table('user_activity_log')
op.drop_table('user_preferences')
op.drop_table('network_members')
op.drop_table('user_networks')
op.drop_table('group_chat_messages')
op.drop_table('group_members')
op.drop_table('user_groups')
op.drop_table('card_wishlist')
op.drop_table('user_card_collection')
op.drop_table('user_statistics')
op.drop_table('game_outcomes')
op.drop_table('replay_players')
op.drop_table('game_replays')
op.drop_table('deck_versions')
op.drop_table('user_sessions')
# Drop added columns from existing table
op.drop_column('mtgonline_decklist_files', 'last_played')
op.drop_column('mtgonline_decklist_files', 'import_confidence')
op.drop_column('mtgonline_decklist_files', 'import_source')
op.drop_column('mtgonline_decklist_files', 'is_favorite')
op.drop_column('mtgonline_decklist_files', 'format')
op.drop_column('mtgonline_decklist_files', 'description')
+2
View File
@@ -0,0 +1,2 @@
# Alembic migration scripts
# These files are generated by Alembic and should not be edited
+2 -1
View File
@@ -24,7 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.core.settings import get_settings from app.core.settings import get_settings
from app.core.database import engine, mtg_engine, async_session, mtg_async_session from app.core.database import engine, mtg_engine, async_session, mtg_async_session
from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions, refresh from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions, refresh, user_data
from app.services.mtgjson_manager import MTGJSONManager from app.services.mtgjson_manager import MTGJSONManager
@@ -140,6 +140,7 @@ app.include_router(admin.router, prefix="/admin", tags=["Admin"])
app.include_router(card_router.router, prefix="/api", tags=["MTG Cards"]) app.include_router(card_router.router, prefix="/api", tags=["MTG Cards"])
app.include_router(interactions.router, tags=["Card Interactions"]) app.include_router(interactions.router, tags=["Card Interactions"])
app.include_router(refresh.router) app.include_router(refresh.router)
app.include_router(user_data.router, prefix="/api/v1/user-data", tags=["User Data"])
@app.get("/health", tags=["Health"]) @app.get("/health", tags=["Health"])
+21
View File
@@ -2,6 +2,12 @@
from app.models.models import User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog from app.models.models import User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog
from app.models.mtg_models import MtgSet, MtgCard from app.models.mtg_models import MtgSet, MtgCard
from app.models.mirror_models import MtgCardMirror, DeckCardLink from app.models.mirror_models import MtgCardMirror, DeckCardLink
from app.models.user_data import (
UserSession, DeckVersion, GameReplay, ReplayPlayer,
GameOutcome, UserStatistics, UserCardCollection, CardWishlist,
UserGroup, GroupMember, GroupChatMessage, UserNetwork,
NetworkMember, UserPreference, UserActivityLog
)
__all__ = [ __all__ = [
"User", "User",
@@ -16,4 +22,19 @@ __all__ = [
"MtgCard", "MtgCard",
"MtgCardMirror", "MtgCardMirror",
"DeckCardLink", "DeckCardLink",
"UserSession",
"DeckVersion",
"GameReplay",
"ReplayPlayer",
"GameOutcome",
"UserStatistics",
"UserCardCollection",
"CardWishlist",
"UserGroup",
"GroupMember",
"GroupChatMessage",
"UserNetwork",
"NetworkMember",
"UserPreference",
"UserActivityLog",
] ]
+325
View File
@@ -0,0 +1,325 @@
"""
SQLAlchemy ORM models for user data features.
Includes sessions, decks, replays, cards, groups, and networks.
"""
from sqlalchemy import (
Column, Integer, String, BigInteger, Boolean, DateTime, Text,
ForeignKey, Index, UniqueConstraint, Float, JSON
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class UserSession(Base):
"""User authentication session."""
__tablename__ = "user_sessions"
id = Column(BigInteger, primary_key=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True)
session_token_hash = Column(String(255), unique=True, nullable=False, index=True)
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
expires_at = Column(DateTime, nullable=False)
is_active = Column(Boolean, default=True)
user = relationship("User", backref="sessions")
def __repr__(self) -> str:
return f"<UserSession user={self.user_id} expires={self.expires_at}>"
class DeckVersion(Base):
"""Deck version history."""
__tablename__ = "deck_versions"
id = Column(BigInteger, primary_key=True)
deck_id = Column(Integer, ForeignKey("mtgonline_decklist_files.id", ondelete="CASCADE"), nullable=False, index=True)
version_number = Column(Integer, nullable=False)
content = Column(Text, nullable=False)
status = Column(String(20), server_default="DRAFT")
comment = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
deck = relationship("DecklistFile", backref="versions")
def __repr__(self) -> str:
return f"<DeckVersion deck={self.deck_id} v={self.version_number}>"
class GameReplay(Base):
"""Game replay recording."""
__tablename__ = "game_replays"
id = Column(BigInteger, primary_key=True)
game_uuid = Column(String(36), unique=True, nullable=False)
room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=True, index=True)
game_type = Column(String(50), nullable=True)
format = Column(String(50), nullable=True)
duration_seconds = Column(Integer, nullable=True)
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=True)
status = Column(String(20), server_default="IN_PROGRESS")
replay_data = Column(JSON, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
players = relationship("ReplayPlayer", back_populates="replay", cascade="all, delete-orphan")
outcomes = relationship("GameOutcome", back_populates="replay", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<GameReplay {self.game_uuid} status={self.status}>"
class ReplayPlayer(Base):
"""Player in a game replay."""
__tablename__ = "replay_players"
id = Column(BigInteger, primary_key=True)
replay_id = Column(BigInteger, ForeignKey("game_replays.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
position = Column(Integer, nullable=True)
deck_id = Column(Integer, ForeignKey("mtgonline_decklist_files.id"), nullable=True)
won = Column(Boolean, nullable=True)
lost = Column(Boolean, nullable=True)
concession = Column(Boolean, default=False)
turn_one = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())
replay = relationship("GameReplay", back_populates="players")
user = relationship("User")
deck = relationship("DecklistFile")
def __repr__(self) -> str:
return f"<ReplayPlayer replay={self.replay_id} user={self.user_id}>"
class GameOutcome(Base):
"""Game outcome record."""
__tablename__ = "game_outcomes"
id = Column(BigInteger, primary_key=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
game_uuid = Column(String(36), ForeignKey("game_replays.game_uuid"), nullable=False, index=True)
outcome = Column(String(20), nullable=False, index=True)
opponent_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=True)
format = Column(String(50), nullable=True)
rating_before = Column(Integer, nullable=True)
rating_after = Column(Integer, nullable=True)
rating_change = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
replay = relationship("GameReplay", back_populates="outcomes")
user = relationship("User", foreign_keys=[user_id])
opponent = relationship("User", foreign_keys=[opponent_id])
def __repr__(self) -> str:
return f"<GameOutcome user={self.user_id} {self.outcome}>"
class UserStatistics(Base):
"""User game statistics summary."""
__tablename__ = "user_statistics"
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), primary_key=True)
total_games = Column(Integer, default=0)
total_wins = Column(Integer, default=0)
total_losses = Column(Integer, default=0)
total_concessions = Column(Integer, default=0)
win_rate = Column(Float, default=0.0)
current_streak = Column(Integer, default=0)
best_streak = Column(Integer, default=0)
average_rating = Column(Float, default=0.0)
last_game_date = Column(DateTime, nullable=True)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
user = relationship("User")
def __repr__(self) -> str:
return f"<UserStatistics user={self.user_id} wins={self.total_wins} losses={self.total_losses}>"
class UserCardCollection(Base):
"""User card collection."""
__tablename__ = "user_card_collection"
id = Column(BigInteger, primary_key=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True)
card_id = Column(Integer, nullable=False, index=True)
quantity = Column(Integer, default=1)
condition = Column(String(20), server_default="NEAR_MINT")
language = Column(String(5), server_default="EN")
is_foil = Column(Boolean, default=False)
is_alt_art = Column(Boolean, default=False)
acquired_date = Column(DateTime, server_default=func.now())
acquisition_method = Column(String(50), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint('user_id', 'card_id', 'is_foil', 'is_alt_art', name='uq_collection_unique'),
Index('idx_collection_user_card', 'user_id', 'card_id'),
)
user = relationship("User", backref="card_collection")
def __repr__(self) -> str:
return f"<UserCard user={self.user_id} card={self.card_id} qty={self.quantity}>"
class CardWishlist(Base):
"""User card wishlist."""
__tablename__ = "card_wishlist"
id = Column(BigInteger, primary_key=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False)
card_id = Column(Integer, nullable=False)
max_price = Column(Float, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
UniqueConstraint('user_id', 'card_id', name='uq_wishlist_user_card'),
)
user = relationship("User", backref="wishlist")
def __repr__(self) -> str:
return f"<CardWishlist user={self.user_id} card={self.card_id}>"
class UserGroup(Base):
"""User group."""
__tablename__ = "user_groups"
id = Column(BigInteger, primary_key=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
is_public = Column(Boolean, default=True)
max_members = Column(Integer, default=50)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
owner = relationship("User", foreign_keys=[owner_id])
members = relationship("GroupMember", back_populates="group", cascade="all, delete-orphan")
messages = relationship("GroupChatMessage", back_populates="group", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<UserGroup {self.name} owner={self.owner_id}>"
class GroupMember(Base):
"""Group member."""
__tablename__ = "group_members"
id = Column(BigInteger, primary_key=True)
group_id = Column(BigInteger, ForeignKey("user_groups.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
role = Column(String(20), server_default="MEMBER")
joined_at = Column(DateTime, server_default=func.now())
group = relationship("UserGroup", back_populates="members")
user = relationship("User")
__table_args__ = (
UniqueConstraint('group_id', 'user_id', name='uq_group_member'),
)
def __repr__(self) -> str:
return f"<GroupMember group={self.group_id} user={self.user_id} role={self.role}>"
class GroupChatMessage(Base):
"""Group chat message."""
__tablename__ = "group_chat_messages"
id = Column(BigInteger, primary_key=True)
group_id = Column(BigInteger, ForeignKey("user_groups.id", ondelete="CASCADE"), nullable=False, index=True)
sender_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
message = Column(Text, nullable=False)
created_at = Column(DateTime, server_default=func.now(), index=True)
group = relationship("UserGroup", back_populates="messages")
sender = relationship("User", foreign_keys=[sender_id])
def __repr__(self) -> str:
return f"<GroupChatMessage group={self.group_id} sender={self.sender_id}>"
class UserNetwork(Base):
"""User network (extended social connection)."""
__tablename__ = "user_networks"
id = Column(BigInteger, primary_key=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
creator_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False)
is_public = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
creator = relationship("User", foreign_keys=[creator_id])
members = relationship("NetworkMember", back_populates="network", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<UserNetwork {self.name} creator={self.creator_id}>"
class NetworkMember(Base):
"""Network member."""
__tablename__ = "network_members"
id = Column(BigInteger, primary_key=True)
network_id = Column(BigInteger, ForeignKey("user_networks.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
role = Column(String(20), server_default="MEMBER")
joined_at = Column(DateTime, server_default=func.now())
network = relationship("UserNetwork", back_populates="members")
user = relationship("User")
__table_args__ = (
UniqueConstraint('network_id', 'user_id', name='uq_network_member'),
)
def __repr__(self) -> str:
return f"<NetworkMember network={self.network_id} user={self.user_id} role={self.role}>"
class UserPreference(Base):
"""User preferences and settings."""
__tablename__ = "user_preferences"
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), primary_key=True)
theme = Column(String(20), server_default="light")
notifications_enabled = Column(Boolean, default=True)
email_notifications = Column(Boolean, default=True)
auto_save_decks = Column(Boolean, default=True)
default_format = Column(String(50), server_default="standard")
language = Column(String(5), server_default="EN")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
user = relationship("User")
def __repr__(self) -> str:
return f"<UserPreference user={self.user_id} theme={self.theme}>"
class UserActivityLog(Base):
"""User activity log."""
__tablename__ = "user_activity_log"
id = Column(BigInteger, primary_key=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
activity_type = Column(String(50), nullable=False, index=True)
activity_data = Column(JSON, nullable=True)
ip_address = Column(String(45), nullable=True)
created_at = Column(DateTime, server_default=func.now(), index=True)
user = relationship("User", backref="activity_logs")
def __repr__(self) -> str:
return f"<UserActivity user={self.user_id} type={self.activity_type}>"
File diff suppressed because it is too large Load Diff
+526
View File
@@ -0,0 +1,526 @@
"""
Pydantic schemas for user data features.
Covers sessions, decks, replays, cards, groups, networks, preferences, and activity logs.
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
# ===== Enum Types =====
class DeckVersionStatus(str, Enum):
DRAFT = "DRAFT"
FINAL = "FINAL"
ARCHIVED = "ARCHIVED"
class GameReplayStatus(str, Enum):
IN_PROGRESS = "IN_PROGRESS"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
class GameOutcomeType(str, Enum):
WIN = "WIN"
LOSS = "LOSS"
CONCESSION = "CONCESSION"
DISCONNECT = "DISCONNECT"
class GroupMemberRole(str, Enum):
OWNER = "OWNER"
ADMIN = "ADMIN"
MEMBER = "MEMBER"
class NetworkMemberRole(str, Enum):
OWNER = "OWNER"
ADMIN = "ADMIN"
MEMBER = "MEMBER"
class UserPreferenceTheme(str, Enum):
LIGHT = "light"
DARK = "dark"
SYSTEM = "system"
class ActivityType(str, Enum):
LOGIN = "LOGIN"
LOGOUT = "LOGOUT"
DECK_EDIT = "DECK_EDIT"
GAME_PLAYED = "GAME_PLAYED"
CARD_ACQUIRED = "CARD_ACQUIRED"
CARD_TRADED = "CARD_TRADED"
GROUP_CREATED = "GROUP_CREATED"
GROUP_JOINED = "GROUP_JOINED"
# ===== Session Schemas =====
class SessionResponse(BaseModel):
"""User session response."""
id: int
user_id: int
ip_address: Optional[str] = None
user_agent: Optional[str] = None
created_at: datetime
expires_at: datetime
is_active: bool
class Config:
from_attributes = True
class SessionCleanupResponse(BaseModel):
"""Response after cleaning expired sessions."""
cleaned_count: int
message: str
# ===== Deck Version Schemas =====
class DeckVersionCreate(BaseModel):
"""Deck version creation request."""
content: str = Field(..., min_length=1)
status: DeckVersionStatus = DeckVersionStatus.DRAFT
comment: Optional[str] = None
class DeckVersionUpdate(BaseModel):
"""Deck version update request."""
content: Optional[str] = None
status: Optional[DeckVersionStatus] = None
comment: Optional[str] = None
class DeckVersionResponse(BaseModel):
"""Deck version response."""
id: int
deck_id: int
version_number: int
content: str
status: str
comment: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class DeckVersionListResponse(BaseModel):
"""List of deck versions."""
versions: List[DeckVersionResponse]
total: int
# ===== Game Replay Schemas =====
class GameReplayCreate(BaseModel):
"""Game replay creation request."""
game_uuid: str = Field(..., min_length=36, max_length=36)
room_id: Optional[int] = None
game_type: Optional[str] = None
format: Optional[str] = None
duration_seconds: Optional[int] = None
start_time: datetime
end_time: Optional[datetime] = None
status: GameReplayStatus = GameReplayStatus.IN_PROGRESS
replay_data: Optional[Dict[str, Any]] = None
class GameReplayUpdate(BaseModel):
"""Game replay update request."""
room_id: Optional[int] = None
game_type: Optional[str] = None
format: Optional[str] = None
duration_seconds: Optional[int] = None
end_time: Optional[datetime] = None
status: Optional[GameReplayStatus] = None
replay_data: Optional[Dict[str, Any]] = None
class GameReplayResponse(BaseModel):
"""Game replay response."""
id: int
game_uuid: str
room_id: Optional[int]
game_type: Optional[str]
format: Optional[str]
duration_seconds: Optional[int]
start_time: datetime
end_time: Optional[datetime]
status: str
replay_data: Optional[Dict[str, Any]]
created_at: datetime
updated_at: datetime
players: List[Dict[str, Any]] = []
class Config:
from_attributes = True
class GameReplayListResponse(BaseModel):
"""List of game replays."""
replays: List[GameReplayResponse]
total: int
page: int
page_size: int
total_pages: int
# ===== Game Outcome Schemas =====
class GameOutcomeCreate(BaseModel):
"""Game outcome creation request."""
game_uuid: str
outcome: GameOutcomeType
opponent_id: Optional[int] = None
format: Optional[str] = None
rating_before: Optional[int] = None
rating_after: Optional[int] = None
rating_change: Optional[int] = None
class GameOutcomeResponse(BaseModel):
"""Game outcome response."""
id: int
user_id: int
game_uuid: str
outcome: str
opponent_id: Optional[int]
format: Optional[str]
rating_before: Optional[int]
rating_after: Optional[int]
rating_change: Optional[int]
created_at: datetime
class Config:
from_attributes = True
class GameOutcomeListResponse(BaseModel):
"""List of game outcomes."""
outcomes: List[GameOutcomeResponse]
total: int
# ===== User Statistics Schemas =====
class UserStatisticsResponse(BaseModel):
"""User statistics summary response."""
user_id: int
total_games: int
total_wins: int
total_losses: int
total_concessions: int
win_rate: float
current_streak: int
best_streak: int
average_rating: float
last_game_date: Optional[datetime]
updated_at: datetime
class Config:
from_attributes = True
class StatisticsUpdateResponse(BaseModel):
"""Response after updating statistics."""
user_id: int
total_games: int
total_wins: int
total_losses: int
win_rate: float
current_streak: int
updated_at: datetime
# ===== Card Collection Schemas =====
class CardCollectionCreate(BaseModel):
"""Card collection item creation request."""
card_id: int
quantity: int = Field(1, ge=1)
condition: str = Field("NEAR_MINT", max_length=20)
language: str = Field("EN", max_length=5)
is_foil: bool = False
is_alt_art: bool = False
acquired_date: Optional[datetime] = None
acquisition_method: Optional[str] = None
notes: Optional[str] = None
class CardCollectionUpdate(BaseModel):
"""Card collection item update request."""
quantity: Optional[int] = None
condition: Optional[str] = None
language: Optional[str] = None
is_foil: Optional[bool] = None
is_alt_art: Optional[bool] = None
acquired_date: Optional[datetime] = None
acquisition_method: Optional[str] = None
notes: Optional[str] = None
class CardCollectionResponse(BaseModel):
"""Card collection item response."""
id: int
user_id: int
card_id: int
quantity: int
condition: str
language: str
is_foil: bool
is_alt_art: bool
acquired_date: Optional[datetime]
acquisition_method: Optional[str]
notes: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class CardCollectionListResponse(BaseModel):
"""List of user card collection."""
cards: List[CardCollectionResponse]
total: int
page: int
page_size: int
total_pages: int
# ===== Wishlist Schemas =====
class WishlistCreate(BaseModel):
"""Wishlist item creation request."""
card_id: int
max_price: Optional[float] = None
notes: Optional[str] = None
class WishlistUpdate(BaseModel):
"""Wishlist item update request."""
max_price: Optional[float] = None
notes: Optional[str] = None
class WishlistResponse(BaseModel):
"""Wishlist item response."""
id: int
user_id: int
card_id: int
max_price: Optional[float]
notes: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class WishlistListResponse(BaseModel):
"""List of wishlist items."""
items: List[WishlistResponse]
total: int
# ===== Group Schemas =====
class GroupCreate(BaseModel):
"""User group creation request."""
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
is_public: bool = True
max_members: int = Field(50, ge=2, le=500)
class GroupUpdate(BaseModel):
"""User group update request."""
name: Optional[str] = None
description: Optional[str] = None
is_public: Optional[bool] = None
max_members: Optional[int] = None
class GroupMemberCreate(BaseModel):
"""Group member addition request."""
user_id: int
role: GroupMemberRole = GroupMemberRole.MEMBER
class GroupMemberUpdate(BaseModel):
"""Group member role update request."""
role: GroupMemberRole
class GroupMemberRemove(BaseModel):
"""Group member removal request."""
user_id: int
class GroupResponse(BaseModel):
"""User group response."""
id: int
name: str
description: Optional[str]
owner_id: int
is_public: bool
max_members: int
created_at: datetime
updated_at: datetime
member_count: int = 0
is_member: bool = False
class Config:
from_attributes = True
class GroupListResponse(BaseModel):
"""List of user groups."""
groups: List[GroupResponse]
total: int
class GroupChatMessageCreate(BaseModel):
"""Group chat message creation request."""
message: str = Field(..., min_length=1, max_length=2000)
class GroupChatMessageResponse(BaseModel):
"""Group chat message response."""
id: int
group_id: int
sender_id: int
sender_username: Optional[str] = None
message: str
created_at: datetime
class Config:
from_attributes = True
class GroupChatMessageListResponse(BaseModel):
"""List of group chat messages."""
messages: List[GroupChatMessageResponse]
total: int
page: int
page_size: int
total_pages: int
# ===== Network Schemas =====
class NetworkCreate(BaseModel):
"""User network creation request."""
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
is_public: bool = True
class NetworkUpdate(BaseModel):
"""User network update request."""
name: Optional[str] = None
description: Optional[str] = None
is_public: Optional[bool] = None
class NetworkMemberCreate(BaseModel):
"""Network member addition request."""
user_id: int
role: NetworkMemberRole = NetworkMemberRole.MEMBER
class NetworkResponse(BaseModel):
"""User network response."""
id: int
name: str
description: Optional[str]
creator_id: int
is_public: bool
created_at: datetime
member_count: int = 0
is_member: bool = False
class Config:
from_attributes = True
class NetworkListResponse(BaseModel):
"""List of user networks."""
networks: List[NetworkResponse]
total: int
# ===== Preference Schemas =====
class UserPreferenceUpdate(BaseModel):
"""User preference update request."""
theme: Optional[UserPreferenceTheme] = None
notifications_enabled: Optional[bool] = None
email_notifications: Optional[bool] = None
auto_save_decks: Optional[bool] = None
default_format: Optional[str] = None
language: Optional[str] = None
class UserPreferenceResponse(BaseModel):
"""User preference response."""
user_id: int
theme: str
notifications_enabled: bool
email_notifications: bool
auto_save_decks: bool
default_format: str
language: str
updated_at: datetime
class Config:
from_attributes = True
# ===== Activity Log Schemas =====
class ActivityLogEntry(BaseModel):
"""Activity log entry."""
id: int
user_id: int
activity_type: str
activity_data: Optional[Dict[str, Any]]
ip_address: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class ActivityLogListResponse(BaseModel):
"""List of activity log entries."""
entries: List[ActivityLogEntry]
total: int
page: int
page_size: int
total_pages: int
# ===== Generic Response Schemas =====
class MessageResponse(BaseModel):
"""Generic message response."""
message: str
class CountResponse(BaseModel):
"""Generic count response."""
count: int
class ErrorDetail(BaseModel):
"""Error detail."""
error: str
detail: str
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# Run Alembic migrations with database connectivity check
set -e
echo "Running Alembic migrations..."
# Check if database is reachable
echo "Checking database connectivity..."
until python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
async def check():
engine = create_async_engine('postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline')
async with engine.connect() as conn:
await conn.execute(sqlalchemy.text('SELECT 1'))
await engine.dispose()
print('Database connection successful')
import sqlalchemy
asyncio.run(check())
" 2>/dev/null; do
echo "Waiting for database to be ready..."
sleep 2
done
# Run migrations
echo "Applying migrations..."
alembic upgrade head
echo "Migrations completed successfully!"