From 403d65e4ea0e814b56177748774e702ddaab8b86 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Wed, 26 Nov 2025 14:24:47 -0500 Subject: [PATCH] Add user authentication system with schema refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...2a_add_user_authentication_and_enhance_.py | 83 ++++++++++++++++ feed_parser.py | 99 ++++++++++++++++--- models.py | 66 +++++++++++-- pyproject.toml | 2 + uv.lock | 68 +++++++++++++ 5 files changed, 294 insertions(+), 24 deletions(-) create mode 100644 alembic/versions/a3c56d47f42a_add_user_authentication_and_enhance_.py diff --git a/alembic/versions/a3c56d47f42a_add_user_authentication_and_enhance_.py b/alembic/versions/a3c56d47f42a_add_user_authentication_and_enhance_.py new file mode 100644 index 0000000..9697e9b --- /dev/null +++ b/alembic/versions/a3c56d47f42a_add_user_authentication_and_enhance_.py @@ -0,0 +1,83 @@ +"""Add user authentication and enhance video fields + +Revision ID: a3c56d47f42a +Revises: 1b18a0e65b0d +Create Date: 2025-11-26 14:22:55.689811 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a3c56d47f42a' +down_revision: Union[str, Sequence[str], None] = '1b18a0e65b0d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + op.add_column('channels', sa.Column('user_id', sa.Integer(), nullable=False)) + op.add_column('channels', sa.Column('rss_url', sa.String(length=500), nullable=False)) + op.add_column('channels', sa.Column('last_fetched_at', sa.DateTime(), nullable=True)) + op.drop_index(op.f('ix_channels_channel_id'), table_name='channels') + op.create_index(op.f('ix_channels_channel_id'), 'channels', ['channel_id'], unique=False) + op.create_index('idx_user_channel', 'channels', ['user_id', 'channel_id'], unique=True) + op.create_index(op.f('ix_channels_user_id'), 'channels', ['user_id'], unique=False) + op.create_foreign_key(None, 'channels', 'users', ['user_id'], ['id']) + op.drop_column('channels', 'last_fetched') + op.add_column('video_entries', sa.Column('video_id', sa.String(length=50), nullable=False)) + op.add_column('video_entries', sa.Column('video_url', sa.String(length=500), nullable=False)) + op.add_column('video_entries', sa.Column('thumbnail_url', sa.String(length=500), nullable=True)) + op.add_column('video_entries', sa.Column('description', sa.Text(), nullable=True)) + op.add_column('video_entries', sa.Column('published_at', sa.DateTime(), nullable=False)) + op.create_index('idx_published_at', 'video_entries', ['published_at'], unique=False) + op.create_index('idx_video_id_channel', 'video_entries', ['video_id', 'channel_id'], unique=True) + op.create_index(op.f('ix_video_entries_published_at'), 'video_entries', ['published_at'], unique=False) + op.create_index(op.f('ix_video_entries_video_id'), 'video_entries', ['video_id'], unique=False) + op.create_index(op.f('ix_video_entries_video_url'), 'video_entries', ['video_url'], unique=False) + op.drop_column('video_entries', 'link') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('video_entries', sa.Column('link', sa.VARCHAR(length=500), nullable=False)) + op.drop_index(op.f('ix_video_entries_video_url'), table_name='video_entries') + op.drop_index(op.f('ix_video_entries_video_id'), table_name='video_entries') + op.drop_index(op.f('ix_video_entries_published_at'), table_name='video_entries') + op.drop_index('idx_video_id_channel', table_name='video_entries') + op.drop_index('idx_published_at', table_name='video_entries') + op.drop_column('video_entries', 'published_at') + op.drop_column('video_entries', 'description') + op.drop_column('video_entries', 'thumbnail_url') + op.drop_column('video_entries', 'video_url') + op.drop_column('video_entries', 'video_id') + op.add_column('channels', sa.Column('last_fetched', sa.DATETIME(), nullable=False)) + op.drop_constraint(None, 'channels', type_='foreignkey') + op.drop_index(op.f('ix_channels_user_id'), table_name='channels') + op.drop_index('idx_user_channel', table_name='channels') + op.drop_index(op.f('ix_channels_channel_id'), table_name='channels') + op.create_index(op.f('ix_channels_channel_id'), 'channels', ['channel_id'], unique=1) + op.drop_column('channels', 'last_fetched_at') + op.drop_column('channels', 'rss_url') + op.drop_column('channels', 'user_id') + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/feed_parser.py b/feed_parser.py index f6dc466..50778bc 100644 --- a/feed_parser.py +++ b/feed_parser.py @@ -7,6 +7,7 @@ with filtering capabilities to exclude unwanted content like Shorts. from datetime import datetime import feedparser from typing import Dict, List, Optional +import re from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError @@ -17,15 +18,25 @@ from models import Channel, VideoEntry class FeedEntry: """Represents a single entry in a YouTube RSS feed.""" - def __init__(self, title: str, link: str): + def __init__(self, title: str, video_url: str, video_id: str, + published_at: datetime, thumbnail_url: Optional[str] = None, + description: Optional[str] = None): self.title = title - self.link = link + self.video_url = video_url + self.video_id = video_id + self.published_at = published_at + self.thumbnail_url = thumbnail_url + self.description = description - def to_dict(self) -> Dict[str, str]: + def to_dict(self) -> Dict: """Convert entry to dictionary.""" return { "title": self.title, - "link": self.link + "video_url": self.video_url, + "video_id": self.video_id, + "published_at": self.published_at.isoformat(), + "thumbnail_url": self.thumbnail_url, + "description": self.description } @@ -62,34 +73,81 @@ class YouTubeFeedParser: if filter_shorts and "shorts" in entry.link: continue + # Extract video ID from URL + video_id = self._extract_video_id(entry.link) + if not video_id: + continue + + # Get thumbnail URL (YouTube provides this in media:group) + thumbnail_url = None + if hasattr(entry, 'media_thumbnail') and entry.media_thumbnail: + thumbnail_url = entry.media_thumbnail[0]['url'] + + # Get description + description = None + if hasattr(entry, 'summary'): + description = entry.summary + + # Parse published date + published_at = datetime(*entry.published_parsed[:6]) + entries.append(FeedEntry( title=entry.title, - link=entry.link + video_url=entry.link, + video_id=video_id, + published_at=published_at, + thumbnail_url=thumbnail_url, + description=description )) return { "feed_title": feed.feed.title, "feed_link": feed.feed.link, + "rss_url": self.url, "entries": [entry.to_dict() for entry in entries] } - def save_to_db(self, db_session: Session, feed_data: Dict) -> Channel: + @staticmethod + def _extract_video_id(url: str) -> Optional[str]: + """Extract video ID from YouTube URL. + + Args: + url: YouTube video URL + + Returns: + Video ID or None if not found + """ + # Match patterns like: youtube.com/watch?v=VIDEO_ID + match = re.search(r'[?&]v=([a-zA-Z0-9_-]{11})', url) + if match: + return match.group(1) + + # Match patterns like: youtu.be/VIDEO_ID + match = re.search(r'youtu\.be/([a-zA-Z0-9_-]{11})', url) + if match: + return match.group(1) + + return None + + def save_to_db(self, db_session: Session, feed_data: Dict, user_id: int) -> Channel: """Save feed data to the database. Args: db_session: SQLAlchemy database session feed_data: Dictionary containing feed metadata and entries (from fetch_feed) + user_id: ID of the user subscribing to this channel Returns: The Channel model instance This method uses upsert logic: - - Updates existing channel if it exists + - Updates existing channel if it exists for this user - Creates new channel if it doesn't exist - - Only inserts new video entries (ignores duplicates) + - Only inserts new video entries (ignores duplicates based on video_id and channel_id) """ - # Get or create channel + # Get or create channel for this user channel = db_session.query(Channel).filter_by( + user_id=user_id, channel_id=self.channel_id ).first() @@ -97,30 +155,43 @@ class YouTubeFeedParser: # Update existing channel channel.title = feed_data["feed_title"] channel.link = feed_data["feed_link"] - channel.last_fetched = datetime.utcnow() + channel.rss_url = feed_data["rss_url"] + channel.last_fetched_at = datetime.utcnow() else: # Create new channel channel = Channel( + user_id=user_id, channel_id=self.channel_id, title=feed_data["feed_title"], link=feed_data["feed_link"], - last_fetched=datetime.utcnow() + rss_url=feed_data["rss_url"], + last_fetched_at=datetime.utcnow() ) db_session.add(channel) db_session.flush() # Get the channel ID # Add video entries (ignore duplicates) for entry_data in feed_data["entries"]: - # Check if video already exists + # Check if video already exists for this channel existing = db_session.query(VideoEntry).filter_by( - link=entry_data["link"] + channel_id=channel.id, + video_id=entry_data["video_id"] ).first() if not existing: + # Parse published_at if it's a string + published_at = entry_data["published_at"] + if isinstance(published_at, str): + published_at = datetime.fromisoformat(published_at.replace('Z', '+00:00')) + video = VideoEntry( channel_id=channel.id, + video_id=entry_data["video_id"], title=entry_data["title"], - link=entry_data["link"], + video_url=entry_data["video_url"], + thumbnail_url=entry_data.get("thumbnail_url"), + description=entry_data.get("description"), + published_at=published_at, created_at=datetime.utcnow() ) db_session.add(video) diff --git a/models.py b/models.py index cfe9ed9..b088113 100644 --- a/models.py +++ b/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"" + + 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"" @@ -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"" + return f"" 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, diff --git a/pyproject.toml b/pyproject.toml index fe73982..472d9b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,11 @@ readme = "README.md" requires-python = ">=3.14" dependencies = [ "alembic>=1.13.0", + "bcrypt>=4.0.0", "celery>=5.3.0", "feedparser>=6.0.12", "flask>=3.1.2", + "flask-login>=0.6.0", "psycopg2-binary>=2.9.0", "redis>=5.0.0", "sqlalchemy>=2.0.0", diff --git a/uv.lock b/uv.lock index 6207f19..38cd9fb 100644 --- a/uv.lock +++ b/uv.lock @@ -28,6 +28,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + [[package]] name = "billiard" version = "4.2.3" @@ -152,6 +203,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, ] +[[package]] +name = "flask-login" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" }, +] + [[package]] name = "greenlet" version = "3.2.4" @@ -390,9 +454,11 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "bcrypt" }, { name = "celery" }, { name = "feedparser" }, { name = "flask" }, + { name = "flask-login" }, { name = "psycopg2-binary" }, { name = "redis" }, { name = "sqlalchemy" }, @@ -402,9 +468,11 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.13.0" }, + { name = "bcrypt", specifier = ">=4.0.0" }, { name = "celery", specifier = ">=5.3.0" }, { name = "feedparser", specifier = ">=6.0.12" }, { name = "flask", specifier = ">=3.1.2" }, + { name = "flask-login", specifier = ">=0.6.0" }, { name = "psycopg2-binary", specifier = ">=2.9.0" }, { name = "redis", specifier = ">=5.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" },