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:
@@ -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
@@ -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 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 --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 --chown=appuser:appuser scripts/ ./scripts/
|
||||
RUN chmod +x /app/scripts/*.py
|
||||
@@ -52,4 +56,5 @@ USER appuser
|
||||
|
||||
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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -0,0 +1,2 @@
|
||||
# Alembic migration scripts
|
||||
# These files are generated by Alembic and should not be edited
|
||||
+2
-1
@@ -24,7 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.settings import get_settings
|
||||
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
|
||||
|
||||
|
||||
@@ -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(interactions.router, tags=["Card Interactions"])
|
||||
app.include_router(refresh.router)
|
||||
app.include_router(user_data.router, prefix="/api/v1/user-data", tags=["User Data"])
|
||||
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
from app.models.models import User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog
|
||||
from app.models.mtg_models import MtgSet, MtgCard
|
||||
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__ = [
|
||||
"User",
|
||||
@@ -16,4 +22,19 @@ __all__ = [
|
||||
"MtgCard",
|
||||
"MtgCardMirror",
|
||||
"DeckCardLink",
|
||||
"UserSession",
|
||||
"DeckVersion",
|
||||
"GameReplay",
|
||||
"ReplayPlayer",
|
||||
"GameOutcome",
|
||||
"UserStatistics",
|
||||
"UserCardCollection",
|
||||
"CardWishlist",
|
||||
"UserGroup",
|
||||
"GroupMember",
|
||||
"GroupChatMessage",
|
||||
"UserNetwork",
|
||||
"NetworkMember",
|
||||
"UserPreference",
|
||||
"UserActivityLog",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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!"
|
||||
Reference in New Issue
Block a user