feat: Implement SQLAlchemy database layer with comprehensive models
Implemented a complete database layer for persistent storage of anime series, episodes, download queue, and user sessions using SQLAlchemy ORM. Features: - 4 SQLAlchemy models: AnimeSeries, Episode, DownloadQueueItem, UserSession - Automatic timestamp tracking via TimestampMixin - Foreign key relationships with cascade deletes - Async and sync database session support - FastAPI dependency injection integration - SQLite optimizations (WAL mode, foreign keys) - Enum types for status and priority fields Models: - AnimeSeries: Series metadata with one-to-many relationships - Episode: Individual episodes linked to series - DownloadQueueItem: Queue persistence with progress tracking - UserSession: JWT session storage with expiry and revocation Database Management: - Async engine creation with aiosqlite - Session factory with proper lifecycle - Connection pooling configuration - Automatic table creation on initialization Testing: - 19 comprehensive unit tests (all passing) - In-memory SQLite for test isolation - Relationship and constraint validation - Query operation testing Documentation: - Comprehensive database section in infrastructure.md - Database package README with examples - Implementation summary document - Usage guides and troubleshooting Dependencies: - Added: sqlalchemy>=2.0.35 (Python 3.13 compatible) - Added: alembic==1.13.0 (for future migrations) - Added: aiosqlite>=0.19.0 (async SQLite driver) Files: - src/server/database/__init__.py (package exports) - src/server/database/base.py (base classes and mixins) - src/server/database/models.py (ORM models, ~435 lines) - src/server/database/connection.py (connection management) - src/server/database/migrations.py (migration placeholder) - src/server/database/README.md (package documentation) - tests/unit/test_database_models.py (19 test cases) - DATABASE_IMPLEMENTATION_SUMMARY.md (implementation summary) Closes #9 Database Layer implementation task
This commit is contained in:
429
src/server/database/models.py
Normal file
429
src/server/database/models.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""SQLAlchemy ORM models for the Aniworld web application.
|
||||
|
||||
This module defines database models for anime series, episodes, download queue,
|
||||
and user sessions. Models use SQLAlchemy 2.0 style with type annotations.
|
||||
|
||||
Models:
|
||||
- AnimeSeries: Represents an anime series with metadata
|
||||
- Episode: Individual episodes linked to series
|
||||
- DownloadQueueItem: Download queue with status and progress tracking
|
||||
- UserSession: User authentication sessions with JWT tokens
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy import Enum as SQLEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.server.database.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class AnimeSeries(Base, TimestampMixin):
|
||||
"""SQLAlchemy model for anime series.
|
||||
|
||||
Represents an anime series with metadata, provider information,
|
||||
and links to episodes. Corresponds to the core Serie class.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
key: Unique identifier used by provider
|
||||
name: Series name
|
||||
site: Provider site URL
|
||||
folder: Local filesystem path
|
||||
description: Optional series description
|
||||
status: Current status (ongoing, completed, etc.)
|
||||
total_episodes: Total number of episodes
|
||||
cover_url: URL to series cover image
|
||||
episodes: Relationship to Episode models
|
||||
download_items: Relationship to DownloadQueueItem models
|
||||
created_at: Creation timestamp (from TimestampMixin)
|
||||
updated_at: Last update timestamp (from TimestampMixin)
|
||||
"""
|
||||
__tablename__ = "anime_series"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True
|
||||
)
|
||||
|
||||
# Core identification
|
||||
key: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True,
|
||||
doc="Unique provider key"
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(500), nullable=False, index=True,
|
||||
doc="Series name"
|
||||
)
|
||||
site: Mapped[str] = mapped_column(
|
||||
String(500), nullable=False,
|
||||
doc="Provider site URL"
|
||||
)
|
||||
folder: Mapped[str] = mapped_column(
|
||||
String(1000), nullable=False,
|
||||
doc="Local filesystem path"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
doc="Series description"
|
||||
)
|
||||
status: Mapped[Optional[str]] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
doc="Series status (ongoing, completed, etc.)"
|
||||
)
|
||||
total_episodes: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True,
|
||||
doc="Total number of episodes"
|
||||
)
|
||||
cover_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(1000), nullable=True,
|
||||
doc="URL to cover image"
|
||||
)
|
||||
|
||||
# JSON field for episode dictionary (season -> [episodes])
|
||||
episode_dict: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON, nullable=True,
|
||||
doc="Episode dictionary {season: [episodes]}"
|
||||
)
|
||||
|
||||
# Relationships
|
||||
episodes: Mapped[List["Episode"]] = relationship(
|
||||
"Episode",
|
||||
back_populates="series",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
download_items: Mapped[List["DownloadQueueItem"]] = relationship(
|
||||
"DownloadQueueItem",
|
||||
back_populates="series",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AnimeSeries(id={self.id}, key='{self.key}', name='{self.name}')>"
|
||||
|
||||
|
||||
class Episode(Base, TimestampMixin):
|
||||
"""SQLAlchemy model for anime episodes.
|
||||
|
||||
Represents individual episodes linked to an anime series.
|
||||
Tracks download status and file location.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
series_id: Foreign key to AnimeSeries
|
||||
season: Season number
|
||||
episode_number: Episode number within season
|
||||
title: Episode title
|
||||
file_path: Local file path if downloaded
|
||||
file_size: File size in bytes
|
||||
is_downloaded: Whether episode is downloaded
|
||||
download_date: When episode was downloaded
|
||||
series: Relationship to AnimeSeries
|
||||
created_at: Creation timestamp (from TimestampMixin)
|
||||
updated_at: Last update timestamp (from TimestampMixin)
|
||||
"""
|
||||
__tablename__ = "episodes"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True
|
||||
)
|
||||
|
||||
# Foreign key to series
|
||||
series_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("anime_series.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Episode identification
|
||||
season: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False,
|
||||
doc="Season number"
|
||||
)
|
||||
episode_number: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False,
|
||||
doc="Episode number within season"
|
||||
)
|
||||
title: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True,
|
||||
doc="Episode title"
|
||||
)
|
||||
|
||||
# Download information
|
||||
file_path: Mapped[Optional[str]] = mapped_column(
|
||||
String(1000), nullable=True,
|
||||
doc="Local file path"
|
||||
)
|
||||
file_size: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True,
|
||||
doc="File size in bytes"
|
||||
)
|
||||
is_downloaded: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False,
|
||||
doc="Whether episode is downloaded"
|
||||
)
|
||||
download_date: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
doc="When episode was downloaded"
|
||||
)
|
||||
|
||||
# Relationship
|
||||
series: Mapped["AnimeSeries"] = relationship(
|
||||
"AnimeSeries",
|
||||
back_populates="episodes"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<Episode(id={self.id}, series_id={self.series_id}, "
|
||||
f"S{self.season:02d}E{self.episode_number:02d})>"
|
||||
)
|
||||
|
||||
|
||||
class DownloadStatus(str, Enum):
|
||||
"""Status enum for download queue items."""
|
||||
PENDING = "pending"
|
||||
DOWNLOADING = "downloading"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class DownloadPriority(str, Enum):
|
||||
"""Priority enum for download queue items."""
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class DownloadQueueItem(Base, TimestampMixin):
|
||||
"""SQLAlchemy model for download queue items.
|
||||
|
||||
Tracks download queue with status, progress, and error information.
|
||||
Provides persistence for the DownloadService queue state.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
series_id: Foreign key to AnimeSeries
|
||||
season: Season number
|
||||
episode_number: Episode number
|
||||
status: Current download status
|
||||
priority: Download priority
|
||||
progress_percent: Download progress (0-100)
|
||||
downloaded_bytes: Bytes downloaded
|
||||
total_bytes: Total file size
|
||||
download_speed: Current speed in bytes/sec
|
||||
error_message: Error description if failed
|
||||
retry_count: Number of retry attempts
|
||||
download_url: Provider download URL
|
||||
file_destination: Target file path
|
||||
started_at: When download started
|
||||
completed_at: When download completed
|
||||
series: Relationship to AnimeSeries
|
||||
created_at: Creation timestamp (from TimestampMixin)
|
||||
updated_at: Last update timestamp (from TimestampMixin)
|
||||
"""
|
||||
__tablename__ = "download_queue"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True
|
||||
)
|
||||
|
||||
# Foreign key to series
|
||||
series_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("anime_series.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Episode identification
|
||||
season: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False,
|
||||
doc="Season number"
|
||||
)
|
||||
episode_number: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False,
|
||||
doc="Episode number"
|
||||
)
|
||||
|
||||
# Queue management
|
||||
status: Mapped[str] = mapped_column(
|
||||
SQLEnum(DownloadStatus),
|
||||
default=DownloadStatus.PENDING,
|
||||
nullable=False,
|
||||
index=True,
|
||||
doc="Current download status"
|
||||
)
|
||||
priority: Mapped[str] = mapped_column(
|
||||
SQLEnum(DownloadPriority),
|
||||
default=DownloadPriority.NORMAL,
|
||||
nullable=False,
|
||||
doc="Download priority"
|
||||
)
|
||||
|
||||
# Progress tracking
|
||||
progress_percent: Mapped[float] = mapped_column(
|
||||
Float, default=0.0, nullable=False,
|
||||
doc="Progress percentage (0-100)"
|
||||
)
|
||||
downloaded_bytes: Mapped[int] = mapped_column(
|
||||
Integer, default=0, nullable=False,
|
||||
doc="Bytes downloaded"
|
||||
)
|
||||
total_bytes: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True,
|
||||
doc="Total file size"
|
||||
)
|
||||
download_speed: Mapped[Optional[float]] = mapped_column(
|
||||
Float, nullable=True,
|
||||
doc="Current download speed (bytes/sec)"
|
||||
)
|
||||
|
||||
# Error handling
|
||||
error_message: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
doc="Error description"
|
||||
)
|
||||
retry_count: Mapped[int] = mapped_column(
|
||||
Integer, default=0, nullable=False,
|
||||
doc="Number of retry attempts"
|
||||
)
|
||||
|
||||
# Download details
|
||||
download_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(1000), nullable=True,
|
||||
doc="Provider download URL"
|
||||
)
|
||||
file_destination: Mapped[Optional[str]] = mapped_column(
|
||||
String(1000), nullable=True,
|
||||
doc="Target file path"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
doc="When download started"
|
||||
)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
doc="When download completed"
|
||||
)
|
||||
|
||||
# Relationship
|
||||
series: Mapped["AnimeSeries"] = relationship(
|
||||
"AnimeSeries",
|
||||
back_populates="download_items"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<DownloadQueueItem(id={self.id}, "
|
||||
f"series_id={self.series_id}, "
|
||||
f"S{self.season:02d}E{self.episode_number:02d}, "
|
||||
f"status={self.status})>"
|
||||
)
|
||||
|
||||
|
||||
class UserSession(Base, TimestampMixin):
|
||||
"""SQLAlchemy model for user sessions.
|
||||
|
||||
Tracks authenticated user sessions with JWT tokens.
|
||||
Supports session management, revocation, and expiry.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
session_id: Unique session identifier
|
||||
token_hash: Hashed JWT token for validation
|
||||
user_id: User identifier (for multi-user support)
|
||||
ip_address: Client IP address
|
||||
user_agent: Client user agent string
|
||||
expires_at: Session expiration timestamp
|
||||
is_active: Whether session is active
|
||||
last_activity: Last activity timestamp
|
||||
created_at: Creation timestamp (from TimestampMixin)
|
||||
updated_at: Last update timestamp (from TimestampMixin)
|
||||
"""
|
||||
__tablename__ = "user_sessions"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True
|
||||
)
|
||||
|
||||
# Session identification
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True,
|
||||
doc="Unique session identifier"
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False,
|
||||
doc="Hashed JWT token"
|
||||
)
|
||||
|
||||
# User information
|
||||
user_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(255), nullable=True, index=True,
|
||||
doc="User identifier (for multi-user)"
|
||||
)
|
||||
|
||||
# Client information
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||
String(45), nullable=True,
|
||||
doc="Client IP address"
|
||||
)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True,
|
||||
doc="Client user agent"
|
||||
)
|
||||
|
||||
# Session management
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
doc="Session expiration"
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, nullable=False, index=True,
|
||||
doc="Whether session is active"
|
||||
)
|
||||
last_activity: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
doc="Last activity timestamp"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<UserSession(id={self.id}, "
|
||||
f"session_id='{self.session_id}', "
|
||||
f"is_active={self.is_active})>"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if session has expired."""
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
def revoke(self) -> None:
|
||||
"""Revoke this session."""
|
||||
self.is_active = False
|
||||
Reference in New Issue
Block a user