Add user authentication system with schema refactor

BREAKING CHANGE: This commit introduces significant schema changes that require
a fresh database. See migration instructions below.

Changes:
- Added Flask-Login and bcrypt dependencies to pyproject.toml
- Created User model with password hashing methods
- Updated Channel model:
  - Added user_id foreign key relationship
  - Added rss_url field
  - Renamed last_fetched to last_fetched_at
  - Added composite unique index on (user_id, channel_id)
- Updated VideoEntry model:
  - Added video_id, video_url, thumbnail_url, description, published_at fields
  - Renamed link to video_url
  - Added indexes for performance
- Updated feed_parser.py:
  - Enhanced FeedEntry to extract thumbnail, description, published date
  - Added _extract_video_id() method for parsing YouTube URLs
  - Updated save_to_db() to require user_id parameter
  - Parse and store all metadata from RSS feeds
- Generated Alembic migration: a3c56d47f42a

Migration Instructions:
1. Stop all services: docker-compose down -v
2. Apply migrations: docker-compose up -d && docker-compose exec app alembic upgrade head
3. Or for local dev: rm yottob.db && alembic upgrade head

Next Steps (TODO):
- Configure Flask-Login in main.py
- Create login/register/logout routes
- Add @login_required decorators to protected routes
- Update all routes to filter by current_user
- Create auth templates (login.html, register.html)
- Update base.html with auth navigation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-26 14:24:47 -05:00
parent 9bcd439024
commit 403d65e4ea
5 changed files with 294 additions and 24 deletions

View File

@@ -4,7 +4,9 @@ from datetime import datetime
from typing import List, Optional
from enum import Enum as PyEnum
from sqlalchemy import String, DateTime, ForeignKey, Index, Enum, BigInteger
import bcrypt
from flask_login import UserMixin
from sqlalchemy import String, DateTime, ForeignKey, Index, Enum, BigInteger, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -21,19 +23,53 @@ class Base(DeclarativeBase):
pass
class User(Base, UserMixin):
"""User model for authentication."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
# Relationships
channels: Mapped[List["Channel"]] = relationship("Channel", back_populates="user", cascade="all, delete-orphan")
def set_password(self, password: str) -> None:
"""Hash and set the password."""
self.password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def check_password(self, password: str) -> bool:
"""Check if the provided password matches the hash."""
return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
def __repr__(self) -> str:
return f"<User(id={self.id}, username='{self.username}', email='{self.email}')>"
class Channel(Base):
"""YouTube channel model."""
__tablename__ = "channels"
id: Mapped[int] = mapped_column(primary_key=True)
channel_id: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
channel_id: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
link: Mapped[str] = mapped_column(String(500), nullable=False)
last_fetched: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
rss_url: Mapped[str] = mapped_column(String(500), nullable=False)
last_fetched_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Relationship to video entries
videos: Mapped[List["VideoEntry"]] = relationship("VideoEntry", back_populates="channel", cascade="all, delete-orphan")
# Relationships
user: Mapped["User"] = relationship("User", back_populates="channels")
video_entries: Mapped[List["VideoEntry"]] = relationship("VideoEntry", back_populates="channel", cascade="all, delete-orphan")
# Composite unique constraint on user_id and channel_id (one user can't subscribe to same channel twice)
__table_args__ = (
Index('idx_user_channel', 'user_id', 'channel_id', unique=True),
)
def __repr__(self) -> str:
return f"<Channel(id={self.id}, channel_id='{self.channel_id}', title='{self.title}')>"
@@ -46,8 +82,12 @@ class VideoEntry(Base):
id: Mapped[int] = mapped_column(primary_key=True)
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id"), nullable=False)
video_id: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
link: Mapped[str] = mapped_column(String(500), unique=True, nullable=False)
video_url: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
thumbnail_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
published_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
# Download tracking fields
@@ -63,23 +103,29 @@ class VideoEntry(Base):
file_size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
# Relationship to channel
channel: Mapped["Channel"] = relationship("Channel", back_populates="videos")
channel: Mapped["Channel"] = relationship("Channel", back_populates="video_entries")
# Index for faster queries
# Indexes for faster queries
__table_args__ = (
Index('idx_channel_created', 'channel_id', 'created_at'),
Index('idx_download_status', 'download_status'),
Index('idx_published_at', 'published_at'),
Index('idx_video_id_channel', 'video_id', 'channel_id', unique=True),
)
def __repr__(self) -> str:
return f"<VideoEntry(id={self.id}, title='{self.title}', link='{self.link}', status='{self.download_status.value}')>"
return f"<VideoEntry(id={self.id}, title='{self.title}', video_url='{self.video_url}', status='{self.download_status.value}')>"
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
"id": self.id,
"video_id": self.video_id,
"title": self.title,
"link": self.link,
"video_url": self.video_url,
"thumbnail_url": self.thumbnail_url,
"description": self.description,
"published_at": self.published_at.isoformat(),
"created_at": self.created_at.isoformat(),
"download_status": self.download_status.value,
"download_path": self.download_path,