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:
66
models.py
66
models.py
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user