From 9e101b1cf49e76c10b2633d4059dfe8af6c5e12f Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:33:53 +0600 Subject: [PATCH 01/17] model for email verification token --- backend/.env.example | 8 ++++ backend/app/db/base.py | 1 + backend/app/db/models/email_verification.py | 42 +++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 backend/app/db/models/email_verification.py diff --git a/backend/.env.example b/backend/.env.example index 279d84c..90f2ff6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -39,3 +39,11 @@ TEST_LISTENER_EMAIL=listener@test.com TEST_LISTENER_PASSWORD=ListenerPass123! TEST_LISTENER_FIRST_NAME=Test TEST_LISTENER_LAST_NAME=Listener + + +# Email Configuration +EMAIL_PROVIDER= +SENDGRID_API_KEY= +FROM_EMAIL= +VERIFICATION_BASE_URL= + diff --git a/backend/app/db/base.py b/backend/app/db/base.py index e45f802..8ff5680 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -22,3 +22,4 @@ from app.db.models.user_subscription import UserSubscription #19 from app.db.models.system_config import SystemConfig #20 from app.db.models.refresh_token import RefreshToken #21 +from app.db.models.email_verification import EmailVerificationToken #22 diff --git a/backend/app/db/models/email_verification.py b/backend/app/db/models/email_verification.py new file mode 100644 index 0000000..572fbb9 --- /dev/null +++ b/backend/app/db/models/email_verification.py @@ -0,0 +1,42 @@ +from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Index +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base_class import Base +from datetime import datetime, timezone + + +class EmailVerificationToken(Base): + """Model for email verification and password reset tokens""" + __tablename__ = "email_verification_tokens" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + token = Column(String(255), unique=True, nullable=False, index=True) + token_type = Column(String(50), default="email_verification", nullable=False) # email_verification, password_reset + expires_at = Column(DateTime(timezone=True), nullable=False) + used_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), default=func.now(), nullable=False) + + # Relationship with User model + user = relationship("User", back_populates="email_verification_tokens") + + __table_args__ = ( + Index('idx_user_token_type', 'user_id', 'token_type'), + Index('idx_expires_at', 'expires_at'), + ) + + def is_expired(self) -> bool: + """Check if token is expired using timezone-aware datetime""" + return datetime.now(timezone.utc) > self.expires_at + + def is_used(self) -> bool: + """Check if token has been used""" + return self.used_at is not None + + def is_valid(self) -> bool: + """Check if token is valid (not expired and not used)""" + return not self.is_expired() and not self.is_used() + + def mark_as_used(self) -> None: + """Mark token as used with current timestamp""" + self.used_at = datetime.now(timezone.utc) From 2bf114a2a44cfeaae96d2ba06dd9d30114b20fc9 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:55:20 +0600 Subject: [PATCH 02/17] clean new migration script with changes reflected on email_verification_token and user models --- .../0caba04c2ae1_refresh_token_rotation.py | 42 -------- .../0cf1441527ec_added_refresh_token_model.py | 51 --------- ...> 0da5ab17c70f_clean_initial_migration.py} | 100 ++++++++++++++---- ...7106d49b66_make_band_owner_non_nullable.py | 40 ------- ...add_playlist_sharing_and_collaboration_.py | 43 -------- .../95b5ebff5e7a_add_is_cleared_to_history.py | 34 ------ .../d26c51e9f683_add_band_owner_field.py | 34 ------ backend/app/core/config.py | 10 ++ backend/app/db/models/user.py | 5 + 9 files changed, 95 insertions(+), 264 deletions(-) delete mode 100644 backend/alembic/versions/0caba04c2ae1_refresh_token_rotation.py delete mode 100644 backend/alembic/versions/0cf1441527ec_added_refresh_token_model.py rename backend/alembic/versions/{e18b0c60c1c6_updated_schema.py => 0da5ab17c70f_clean_initial_migration.py} (83%) delete mode 100644 backend/alembic/versions/407106d49b66_make_band_owner_non_nullable.py delete mode 100644 backend/alembic/versions/51eb42f5babc_add_playlist_sharing_and_collaboration_.py delete mode 100644 backend/alembic/versions/95b5ebff5e7a_add_is_cleared_to_history.py delete mode 100644 backend/alembic/versions/d26c51e9f683_add_band_owner_field.py diff --git a/backend/alembic/versions/0caba04c2ae1_refresh_token_rotation.py b/backend/alembic/versions/0caba04c2ae1_refresh_token_rotation.py deleted file mode 100644 index 753eccf..0000000 --- a/backend/alembic/versions/0caba04c2ae1_refresh_token_rotation.py +++ /dev/null @@ -1,42 +0,0 @@ -"""refresh token rotation - -Revision ID: 0caba04c2ae1 -Revises: 0cf1441527ec -Create Date: 2025-07-31 11:55:19.221826 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '0caba04c2ae1' -down_revision: Union[str, Sequence[str], None] = '0cf1441527ec' -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.add_column('refresh_tokens', sa.Column('is_rotated', sa.Boolean(), nullable=False)) - op.add_column('refresh_tokens', sa.Column('rotated_at', sa.DateTime(timezone=True), nullable=True)) - op.add_column('refresh_tokens', sa.Column('previous_token_id', sa.Integer(), nullable=True)) - op.create_index('idx_refresh_token_previous', 'refresh_tokens', ['previous_token_id'], unique=False) - op.create_index('idx_refresh_token_rotated', 'refresh_tokens', ['is_rotated'], unique=False) - op.create_index(op.f('ix_refresh_tokens_previous_token_id'), 'refresh_tokens', ['previous_token_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_refresh_tokens_previous_token_id'), table_name='refresh_tokens') - op.drop_index('idx_refresh_token_rotated', table_name='refresh_tokens') - op.drop_index('idx_refresh_token_previous', table_name='refresh_tokens') - op.drop_column('refresh_tokens', 'previous_token_id') - op.drop_column('refresh_tokens', 'rotated_at') - op.drop_column('refresh_tokens', 'is_rotated') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/0cf1441527ec_added_refresh_token_model.py b/backend/alembic/versions/0cf1441527ec_added_refresh_token_model.py deleted file mode 100644 index 1149231..0000000 --- a/backend/alembic/versions/0cf1441527ec_added_refresh_token_model.py +++ /dev/null @@ -1,51 +0,0 @@ -"""added refresh token model - -Revision ID: 0cf1441527ec -Revises: e18b0c60c1c6 -Create Date: 2025-07-31 11:27:57.608581 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '0cf1441527ec' -down_revision: Union[str, Sequence[str], None] = 'e18b0c60c1c6' -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('refresh_tokens', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('token', sa.Text(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('is_revoked', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_refresh_token_expires', 'refresh_tokens', ['expires_at'], unique=False) - op.create_index('idx_refresh_token_revoked', 'refresh_tokens', ['is_revoked'], unique=False) - op.create_index('idx_refresh_token_user', 'refresh_tokens', ['user_id'], unique=False) - op.create_index(op.f('ix_refresh_tokens_token'), 'refresh_tokens', ['token'], unique=True) - op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') - op.drop_index(op.f('ix_refresh_tokens_token'), table_name='refresh_tokens') - op.drop_index('idx_refresh_token_user', table_name='refresh_tokens') - op.drop_index('idx_refresh_token_revoked', table_name='refresh_tokens') - op.drop_index('idx_refresh_token_expires', table_name='refresh_tokens') - op.drop_table('refresh_tokens') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/e18b0c60c1c6_updated_schema.py b/backend/alembic/versions/0da5ab17c70f_clean_initial_migration.py similarity index 83% rename from backend/alembic/versions/e18b0c60c1c6_updated_schema.py rename to backend/alembic/versions/0da5ab17c70f_clean_initial_migration.py index 6bff5b5..104d982 100644 --- a/backend/alembic/versions/e18b0c60c1c6_updated_schema.py +++ b/backend/alembic/versions/0da5ab17c70f_clean_initial_migration.py @@ -1,8 +1,8 @@ -"""Updated Schema +"""Clean initial migration -Revision ID: e18b0c60c1c6 +Revision ID: 0da5ab17c70f Revises: -Create Date: 2025-07-23 16:28:40.730597 +Create Date: 2025-08-21 09:53:48.250482 """ from typing import Sequence, Union @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. -revision: str = 'e18b0c60c1c6' +revision: str = '0da5ab17c70f' down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,19 +21,6 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('bands', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('name', sa.String(length=50), nullable=False), - sa.Column('bio', sa.Text(), nullable=True), - sa.Column('profile_picture', sa.String(length=255), nullable=True), - sa.Column('social_link', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('is_disabled', sa.Boolean(), nullable=False), - sa.Column('disabled_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name', name='uq_band_name') - ) - op.create_index('idx_band_name', 'bands', ['name'], unique=False) op.create_table('genres', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=255), nullable=False), @@ -46,6 +33,27 @@ def upgrade() -> None: ) op.create_index('idx_genre_name', 'genres', ['name'], unique=False) op.create_index(op.f('ix_genres_name'), 'genres', ['name'], unique=True) + op.create_table('refresh_tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('token', sa.Text(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('is_revoked', sa.Boolean(), nullable=False), + sa.Column('is_rotated', sa.Boolean(), nullable=False), + sa.Column('rotated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('previous_token_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_refresh_token_expires', 'refresh_tokens', ['expires_at'], unique=False) + op.create_index('idx_refresh_token_previous', 'refresh_tokens', ['previous_token_id'], unique=False) + op.create_index('idx_refresh_token_revoked', 'refresh_tokens', ['is_revoked'], unique=False) + op.create_index('idx_refresh_token_rotated', 'refresh_tokens', ['is_rotated'], unique=False) + op.create_index('idx_refresh_token_user', 'refresh_tokens', ['user_id'], unique=False) + op.create_index(op.f('ix_refresh_tokens_previous_token_id'), 'refresh_tokens', ['previous_token_id'], unique=False) + op.create_index(op.f('ix_refresh_tokens_token'), 'refresh_tokens', ['token'], unique=True) + op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) op.create_table('subscription_plans', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=100), nullable=False), @@ -68,6 +76,8 @@ def upgrade() -> None: sa.Column('password', sa.String(length=255), nullable=False), sa.Column('role', sa.String(length=100), nullable=False), sa.Column('email', sa.String(length=100), nullable=False), + sa.Column('email_verified', sa.Boolean(), nullable=True), + sa.Column('email_verified_at', sa.DateTime(timezone=True), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), sa.Column('last_login', sa.DateTime(), nullable=True), sa.Column('is_active', sa.Boolean(), nullable=True), @@ -109,6 +119,36 @@ def upgrade() -> None: ) op.create_index('idx_audit_log_action_type', 'audit_logs', ['action_type'], unique=False) op.create_index('idx_audit_log_target_table', 'audit_logs', ['target_table'], unique=False) + op.create_table('bands', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('bio', sa.Text(), nullable=True), + sa.Column('profile_picture', sa.String(length=255), nullable=True), + sa.Column('social_link', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('is_disabled', sa.Boolean(), nullable=False), + sa.Column('disabled_at', sa.DateTime(), nullable=True), + sa.Column('created_by_user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name', name='uq_band_name') + ) + op.create_index('idx_band_name', 'bands', ['name'], unique=False) + op.create_table('email_verification_tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token', sa.String(length=255), nullable=False), + sa.Column('token_type', sa.String(length=50), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_expires_at', 'email_verification_tokens', ['expires_at'], unique=False) + op.create_index('idx_user_token_type', 'email_verification_tokens', ['user_id', 'token_type'], unique=False) + op.create_index(op.f('ix_email_verification_tokens_id'), 'email_verification_tokens', ['id'], unique=False) + op.create_index(op.f('ix_email_verification_tokens_token'), 'email_verification_tokens', ['token'], unique=True) op.create_table('localizations', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('translation_key', sa.String(length=100), nullable=False), @@ -143,9 +183,12 @@ def upgrade() -> None: sa.Column('name', sa.String(length=100), nullable=False), sa.Column('description', sa.String(length=255), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('share_token', sa.String(length=64), nullable=True), + sa.Column('allow_collaboration', sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(['owner_id'], ['users.id'], name='fk_playlist_owner_id_users'), sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('owner_id', 'name', name='uq_playlist_owner_name') + sa.UniqueConstraint('owner_id', 'name', name='uq_playlist_owner_name'), + sa.UniqueConstraint('share_token') ) op.create_index('idx_playlist_owner_id', 'playlists', ['owner_id'], unique=False) op.create_table('system_configs', @@ -281,10 +324,12 @@ def upgrade() -> None: sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('song_id', sa.Integer(), nullable=False), sa.Column('played_at', sa.DateTime(), nullable=False), + sa.Column('is_cleared', sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(['song_id'], ['songs.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id') ) + op.create_index('idx_history_cleared', 'histories', ['is_cleared'], unique=False) op.create_index('idx_history_user_song', 'histories', ['user_id', 'song_id'], unique=False) op.create_table('likes', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), @@ -318,6 +363,7 @@ def downgrade() -> None: op.drop_index('idx_likes_user_song', table_name='likes') op.drop_table('likes') op.drop_index('idx_history_user_song', table_name='histories') + op.drop_index('idx_history_cleared', table_name='histories') op.drop_table('histories') op.drop_index('idx_album_song_album_id', table_name='album_songs') op.drop_table('album_songs') @@ -355,6 +401,13 @@ def downgrade() -> None: op.drop_table('payments') op.drop_index('idx_localization_translation_key', table_name='localizations') op.drop_table('localizations') + op.drop_index(op.f('ix_email_verification_tokens_token'), table_name='email_verification_tokens') + op.drop_index(op.f('ix_email_verification_tokens_id'), table_name='email_verification_tokens') + op.drop_index('idx_user_token_type', table_name='email_verification_tokens') + op.drop_index('idx_expires_at', table_name='email_verification_tokens') + op.drop_table('email_verification_tokens') + op.drop_index('idx_band_name', table_name='bands') + op.drop_table('bands') op.drop_index('idx_audit_log_target_table', table_name='audit_logs') op.drop_index('idx_audit_log_action_type', table_name='audit_logs') op.drop_table('audit_logs') @@ -370,9 +423,16 @@ def downgrade() -> None: op.drop_index('idx_subscription_plans_is_active', table_name='subscription_plans') op.drop_index('idx_subscription_plans_duration', table_name='subscription_plans') op.drop_table('subscription_plans') + op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_token'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_previous_token_id'), table_name='refresh_tokens') + op.drop_index('idx_refresh_token_user', table_name='refresh_tokens') + op.drop_index('idx_refresh_token_rotated', table_name='refresh_tokens') + op.drop_index('idx_refresh_token_revoked', table_name='refresh_tokens') + op.drop_index('idx_refresh_token_previous', table_name='refresh_tokens') + op.drop_index('idx_refresh_token_expires', table_name='refresh_tokens') + op.drop_table('refresh_tokens') op.drop_index(op.f('ix_genres_name'), table_name='genres') op.drop_index('idx_genre_name', table_name='genres') op.drop_table('genres') - op.drop_index('idx_band_name', table_name='bands') - op.drop_table('bands') # ### end Alembic commands ### diff --git a/backend/alembic/versions/407106d49b66_make_band_owner_non_nullable.py b/backend/alembic/versions/407106d49b66_make_band_owner_non_nullable.py deleted file mode 100644 index 5d181b2..0000000 --- a/backend/alembic/versions/407106d49b66_make_band_owner_non_nullable.py +++ /dev/null @@ -1,40 +0,0 @@ -"""make_band_owner_non_nullable - -Revision ID: 407106d49b66 -Revises: d26c51e9f683 -Create Date: 2025-08-08 10:33:34.085079 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '407106d49b66' -down_revision: Union[str, Sequence[str], None] = 'd26c51e9f683' -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! ### - # Delete existing bands that don't have created_by_user_id - op.execute("DELETE FROM bands WHERE created_by_user_id IS NULL") - - # Make the column non-nullable - op.alter_column('bands', 'created_by_user_id', - existing_type=sa.INTEGER(), - nullable=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('bands', 'created_by_user_id', - existing_type=sa.INTEGER(), - nullable=True) - # ### end Alembic commands ### diff --git a/backend/alembic/versions/51eb42f5babc_add_playlist_sharing_and_collaboration_.py b/backend/alembic/versions/51eb42f5babc_add_playlist_sharing_and_collaboration_.py deleted file mode 100644 index 8568902..0000000 --- a/backend/alembic/versions/51eb42f5babc_add_playlist_sharing_and_collaboration_.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Add playlist sharing and collaboration fields - -Revision ID: 51eb42f5babc -Revises: 95b5ebff5e7a -Create Date: 2025-08-12 03:31:03.437557 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '51eb42f5babc' -down_revision: Union[str, Sequence[str], None] = '95b5ebff5e7a' -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.add_column('playlists', sa.Column('share_token', sa.String(length=64), nullable=True)) - op.add_column('playlists', sa.Column('allow_collaboration', sa.Boolean(), nullable=True)) - - # Set default value for existing records - op.execute("UPDATE playlists SET allow_collaboration = FALSE WHERE allow_collaboration IS NULL") - - # Make the column NOT NULL after setting default values - op.alter_column('playlists', 'allow_collaboration', nullable=False) - - op.create_unique_constraint(None, 'playlists', ['share_token']) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'playlists', type_='unique') - op.drop_column('playlists', 'allow_collaboration') - op.drop_column('playlists', 'share_token') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/95b5ebff5e7a_add_is_cleared_to_history.py b/backend/alembic/versions/95b5ebff5e7a_add_is_cleared_to_history.py deleted file mode 100644 index 4e2c8ed..0000000 --- a/backend/alembic/versions/95b5ebff5e7a_add_is_cleared_to_history.py +++ /dev/null @@ -1,34 +0,0 @@ -"""add_is_cleared_to_history - -Revision ID: 95b5ebff5e7a -Revises: 407106d49b66 -Create Date: 2025-08-11 06:37:39.301845 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '95b5ebff5e7a' -down_revision: Union[str, Sequence[str], None] = '407106d49b66' -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.add_column('histories', sa.Column('is_cleared', sa.Boolean(), nullable=False)) - op.create_index('idx_history_cleared', 'histories', ['is_cleared'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index('idx_history_cleared', table_name='histories') - op.drop_column('histories', 'is_cleared') - # ### end Alembic commands ### diff --git a/backend/alembic/versions/d26c51e9f683_add_band_owner_field.py b/backend/alembic/versions/d26c51e9f683_add_band_owner_field.py deleted file mode 100644 index b8b7c78..0000000 --- a/backend/alembic/versions/d26c51e9f683_add_band_owner_field.py +++ /dev/null @@ -1,34 +0,0 @@ -"""add_band_owner_field - -Revision ID: d26c51e9f683 -Revises: 0caba04c2ae1 -Create Date: 2025-08-08 10:30:29.755232 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'd26c51e9f683' -down_revision: Union[str, Sequence[str], None] = '0caba04c2ae1' -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.add_column('bands', sa.Column('created_by_user_id', sa.Integer(), nullable=True)) - op.create_foreign_key(None, 'bands', 'users', ['created_by_user_id'], ['id']) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'bands', type_='foreignkey') - op.drop_column('bands', 'created_by_user_id') - # ### end Alembic commands ### diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f25d71d..e781a1b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -29,6 +29,16 @@ class Settings(BaseSettings): # Password pepper PASSWORD_PEPPER: str + + # Email Configuration + EMAIL_PROVIDER: str = "sendgrid" + SENDGRID_API_KEY: Optional[str] = None + GMAIL_USERNAME: Optional[str] = None + GMAIL_PASSWORD: Optional[str] = None + FROM_EMAIL: str = "mysha.shemontee@monstar-lab.com" + VERIFICATION_BASE_URL: str = "http://localhost:8000/auth/verify-email" + + # Test User Credentials (optional, only for development) TEST_ADMIN_USERNAME: Optional[str] = None TEST_ADMIN_EMAIL: Optional[str] = None diff --git a/backend/app/db/models/user.py b/backend/app/db/models/user.py index 29467b6..5c3eee2 100644 --- a/backend/app/db/models/user.py +++ b/backend/app/db/models/user.py @@ -19,6 +19,9 @@ class User(Base): password = Column(String(255), nullable=False) role = Column(String(100), nullable=False) email = Column(String(100), unique=True, nullable=False, index=True) + # Email verification fields + email_verified = Column(Boolean, default=False) + email_verified_at = Column(DateTime(timezone=True), nullable=True) # Audit Fields @@ -30,6 +33,8 @@ class User(Base): # Relationships + + email_verification_tokens = relationship("EmailVerificationToken", back_populates="user",cascade="all, delete-orphan",lazy="select",) # system_config entries updated by this user system_configs = relationship("SystemConfig", back_populates="updated_by_admin", lazy="select") # localization entries updated by this user From 6e6d26ac71ca89a7184b04d561b7450cde62d3be Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:13:40 +0600 Subject: [PATCH 03/17] Email schema added for request response models --- backend/app/schemas/email.py | 49 +++++++++++++++++++++++++ backend/poetry.lock | 71 +++++++++++++++++++++++++++++++++++- backend/pyproject.toml | 1 + 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 backend/app/schemas/email.py diff --git a/backend/app/schemas/email.py b/backend/app/schemas/email.py new file mode 100644 index 0000000..64e4900 --- /dev/null +++ b/backend/app/schemas/email.py @@ -0,0 +1,49 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional + + +class EmailVerificationRequest(BaseModel): + """Request schema for email verification""" + token: str + + +class EmailVerificationResponse(BaseModel): + """Response schema for email verification""" + message: str + user_id: Optional[int] = None + email: Optional[str] = None + + + +class ResendVerificationRequest(BaseModel): + """Request schema for resending verification email""" + email: EmailStr + + +class ResendVerificationResponse(BaseModel): + """Response schema for resending verification email""" + message: str + +''' +TODO: Future Scope +class ForgotPasswordRequest(BaseModel): + """Request schema for forgot password""" + email: EmailStr + + +class ForgotPasswordResponse(BaseModel): + """Response schema for forgot password""" + message: str + + +class ResetPasswordRequest(BaseModel): + """Request schema for password reset""" + token: str + new_password: str + + +class ResetPasswordResponse(BaseModel): + """Response schema for password reset""" + message: str + +''' diff --git a/backend/poetry.lock b/backend/poetry.lock index 77ce8aa..dbf71c4 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -329,6 +329,25 @@ files = [ [package.dependencies] python-dotenv = "*" +[[package]] +name = "ecdsa" +version = "0.19.1" +description = "ECDSA cryptographic signature library (pure python)" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" +groups = ["main"] +files = [ + {file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"}, + {file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + [[package]] name = "email-validator" version = "2.2.0" @@ -972,6 +991,18 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-http-client" +version = "3.3.7" +description = "HTTP REST client, simplified for Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "python_http_client-3.3.7-py3-none-any.whl", hash = "sha256:ad371d2bbedc6ea15c26179c6222a78bc9308d272435ddf1d5c84f068f249a36"}, + {file = "python_http_client-3.3.7.tar.gz", hash = "sha256:bf841ee45262747e00dec7ee9971dfb8c7d83083f5713596488d67739170cea0"}, +] + [[package]] name = "python-multipart" version = "0.0.9" @@ -987,6 +1018,26 @@ files = [ [package.extras] dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] +[[package]] +name = "sendgrid" +version = "6.12.4" +description = "Twilio SendGrid library for Python" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +groups = ["main"] +files = [ + {file = "sendgrid-6.12.4-py3-none-any.whl", hash = "sha256:9a211b96241e63bd5b9ed9afcc8608f4bcac426e4a319b3920ab877c8426e92c"}, + {file = "sendgrid-6.12.4.tar.gz", hash = "sha256:9e88b849daf0fa4bdf256c3b5da9f5a3272402c0c2fd6b1928c9de440db0a03d"}, +] + +[package.dependencies] +ecdsa = ">=0.19.1,<1" +python-http-client = ">=3.2.1" +werkzeug = [ + {version = ">=2.3.5", markers = "python_version >= \"3.12\""}, + {version = ">=2.2.0", markers = "python_version == \"3.11\""}, +] + [[package]] name = "six" version = "1.17.0" @@ -1190,7 +1241,25 @@ h11 = ">=0.8" [package.extras] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +[[package]] +name = "werkzeug" +version = "3.1.3" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, + {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "6677e8a4c7cf456e65e905e6bd6850ab86fb17ad650511a001c62dca39005339" +content-hash = "11cdb0b41626dfafe5bda8a7bbe899449faad44a6ccbfef0c95d52ff1682e5e9" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 40793c2..e0c899f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -29,6 +29,7 @@ python-multipart = "^0.0.9" mutagen = ">=1.45.0" aiofiles = "^24.1.0" Pillow = "^11.1.0" +sendgrid = "^6.12.4" From 45d885211b2542c53a273bf38c14be691dcf06d2 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:22:23 +0600 Subject: [PATCH 04/17] created utility file --- backend/app/core/utils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 backend/app/core/utils.py diff --git a/backend/app/core/utils.py b/backend/app/core/utils.py new file mode 100644 index 0000000..4e3a28e --- /dev/null +++ b/backend/app/core/utils.py @@ -0,0 +1,11 @@ +from datetime import datetime, timezone + +def utc_now() -> datetime: + """Return timezone-aware UTC now.""" + return datetime.now(timezone.utc) + +# duration in hours +EMAIL_VERIFICATION_TOKEN_HOURS: int = 24 + + + From e3123d5495309d6eba2b57888602136b181dffc3 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:22:42 +0600 Subject: [PATCH 05/17] added email templates for verification --- backend/app/services/template_renderer.py | 12 +++++ backend/app/templates/email/verification.html | 51 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 backend/app/services/template_renderer.py create mode 100644 backend/app/templates/email/verification.html diff --git a/backend/app/services/template_renderer.py b/backend/app/services/template_renderer.py new file mode 100644 index 0000000..69b9e8d --- /dev/null +++ b/backend/app/services/template_renderer.py @@ -0,0 +1,12 @@ +from pathlib import Path + + +class TemplateRenderer: + """file-based template renderer using Python format placeholders.""" + + def render(self, template_path: str, context: dict) -> str: + template_file = Path(template_path) + content = template_file.read_text(encoding="utf-8") + return content.format(**context) + + diff --git a/backend/app/templates/email/verification.html b/backend/app/templates/email/verification.html new file mode 100644 index 0000000..e7b76f9 --- /dev/null +++ b/backend/app/templates/email/verification.html @@ -0,0 +1,51 @@ +
+
+

🎵 Music App

+

Welcome to the music streaming platform!

+
+ +
+

Verify Your Email Address

+ +

+ Hi {first_name},

+ Thank you for signing up! To complete your registration and start enjoying music, + please verify your email address by clicking the button below. +

+ +
+ + ✅ Verify Email Address + +
+ +
+

+ If the button doesn't work, copy and paste this link into your browser: +

+

+ {verification_url} +

+
+ +

+ Important: This verification link will expire in 24 hours. +

+ +

+ If you didn't create this account, please ignore this email. +

+ +
+ +

+ This is an automated email from Music App. Please do not reply to this email. +

+
+
+ + From c282fbe654947fe13a79f26f91d4c749a3119669 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:30:44 +0600 Subject: [PATCH 06/17] email services implemented --- backend/app/services/email_service.py | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 backend/app/services/email_service.py diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..1d88965 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,39 @@ +import sendgrid +from sendgrid.helpers.mail import Mail +from app.core.config import settings +from app.services.template_renderer import TemplateRenderer +from app.db.models.user import User + + +class EmailService: + """Email service for SendGrid using file-based HTML templates.""" + + def __init__(self, renderer: TemplateRenderer | None = None): + self.from_email = settings.FROM_EMAIL + self.verification_base_url = settings.VERIFICATION_BASE_URL + self.sg = sendgrid.SendGridAPIClient(api_key=settings.SENDGRID_API_KEY) + self.renderer = renderer or TemplateRenderer() + + def send_verification_email(self, user: User, token: str) -> bool: + verification_url = f"{self.verification_base_url}?token={token}" + subject = "🎵 Verify Your Music App Account" + html_content = self.renderer.render( + "app/templates/email/verification.html", + {"first_name": user.first_name, "verification_url": verification_url}, + ) + return self._send(user.email, subject, html_content) + + def _send(self, to_email: str, subject: str, html_content: str) -> bool: + try: + message = Mail( + from_email=self.from_email, + to_emails=to_email, + subject=subject, + html_content=html_content, + ) + response = self.sg.send(message) + return response.status_code == 202 + except Exception: + return False + + From 46af1b7cbb41af288b17a2d3d311cd8b071c9513 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:31:19 +0600 Subject: [PATCH 07/17] token services implemented --- backend/app/services/token_service.py | 83 +++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 backend/app/services/token_service.py diff --git a/backend/app/services/token_service.py b/backend/app/services/token_service.py new file mode 100644 index 0000000..1bac13a --- /dev/null +++ b/backend/app/services/token_service.py @@ -0,0 +1,83 @@ +import secrets +from datetime import timedelta +from typing import Optional +from sqlalchemy.orm import Session + +from app.core.utils import utc_now, EMAIL_VERIFICATION_TOKEN_HOURS, PASSWORD_RESET_TOKEN_HOURS +from app.db.models.email_verification import EmailVerificationToken + + +class TokenService: + """Service for generating and managing verification/reset tokens.""" + + @staticmethod + def generate_token() -> str: + return secrets.token_urlsafe(32) + + @staticmethod + def create_token( + db: Session, + user_id: int, + token_type: str = "email_verification", + expires_in_hours: Optional[int] = None, + ) -> EmailVerificationToken: + if expires_in_hours is None: + expires_in_hours = EMAIL_VERIFICATION_TOKEN_HOURS if token_type == "email_verification" else PASSWORD_RESET_TOKEN_HOURS + + token_value = TokenService.generate_token() + expires_at = utc_now() + timedelta(hours=expires_in_hours) + + token = EmailVerificationToken( + user_id=user_id, + token=token_value, + token_type=token_type, + expires_at=expires_at, + ) + + db.add(token) + db.commit() + db.refresh(token) + return token + + @staticmethod + def get_token_by_value(db: Session, token: str) -> Optional[EmailVerificationToken]: + return db.query(EmailVerificationToken).filter(EmailVerificationToken.token == token).first() + + @staticmethod + def get_valid_token(db: Session, token: str, token_type: str = "email_verification") -> Optional[EmailVerificationToken]: + token_obj = ( + db.query(EmailVerificationToken) + .filter( + EmailVerificationToken.token == token, + EmailVerificationToken.token_type == token_type, + ) + .first() + ) + if token_obj and token_obj.is_valid(): + return token_obj + return None + + @staticmethod + def mark_token_as_used(db: Session, token_obj: EmailVerificationToken) -> None: + token_obj.used_at = utc_now() + db.commit() + + @staticmethod + def invalidate_user_tokens(db: Session, user_id: int, token_type: str = "email_verification") -> None: + db.query(EmailVerificationToken).filter( + EmailVerificationToken.user_id == user_id, + EmailVerificationToken.token_type == token_type, + EmailVerificationToken.used_at.is_(None), + ).update({"used_at": utc_now()}) + db.commit() + + @staticmethod + def cleanup_expired_tokens(db: Session) -> int: + expired = db.query(EmailVerificationToken).filter(EmailVerificationToken.expires_at < utc_now()).all() + count = len(expired) + for t in expired: + db.delete(t) + db.commit() + return count + + From b3648270085bab15142ab60164b0a9b05a732612 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:49:45 +0600 Subject: [PATCH 08/17] create singleton EmailService dependency instance --- backend/app/dependencies.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 backend/app/dependencies.py diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..b8efef1 --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,10 @@ +from app.services.email_service import EmailService + + +_email_service_singleton = EmailService() + +def get_email_service() -> EmailService: + """Provide a singleton EmailService instance""" + return _email_service_singleton + + From 0e232ff01c4c88019af088a46b980282fe0c2290 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:01:22 +0600 Subject: [PATCH 09/17] crud methods for email --- backend/app/crud/email.py | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 backend/app/crud/email.py diff --git a/backend/app/crud/email.py b/backend/app/crud/email.py new file mode 100644 index 0000000..bb4add6 --- /dev/null +++ b/backend/app/crud/email.py @@ -0,0 +1,47 @@ +from typing import Optional, Tuple +from sqlalchemy.orm import Session + +from app.db.models.user import User +from app.core.utils import utc_now +from app.services.token_service import TokenService +from app.services.email_service import EmailService + + +class EmailCRUD: + """CRUD operations for email verification and password reset.""" + + @staticmethod + def verify_email(db: Session, token: str) -> Tuple[bool, str, Optional[User]]: + verification_token = TokenService.get_valid_token(db, token, "email_verification") + if not verification_token: + return False, "Invalid, expired, or already used verification token", None + + user = db.query(User).filter(User.id == verification_token.user_id).first() + if not user: + return False, "User not found", None + if user.email_verified: + return False, "Email is already verified", user + + TokenService.mark_token_as_used(db, verification_token) + user.email_verified = True + user.email_verified_at = utc_now() + TokenService.invalidate_user_tokens(db, user.id, "email_verification") + db.commit() + return True, "Email verified successfully", user + + @staticmethod + def resend_verification_email(db: Session, email: str, email_service: EmailService) -> Tuple[bool, str]: + user = db.query(User).filter(User.email == email).first() + if not user: + return True, "If the email exists, a verification link has been sent" + if user.email_verified: + return True, "Email is already verified" + + TokenService.invalidate_user_tokens(db, user.id, "email_verification") + verification_token = TokenService.create_token(db, user.id, "email_verification") + success = email_service.send_verification_email(user, verification_token.token) + if success: + return True, "Verification email sent successfully" + return False, "Failed to send verification email" + + From 6ccb7681e89ce9df386fc92a6de0a0866f438d6c Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:09:48 +0600 Subject: [PATCH 10/17] not allow unauthorized emails from logging in --- backend/app/api/v1/auth.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 81904b8..d63e421 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -33,6 +33,11 @@ async def login( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password" ) + if not user.email_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email not verified" + ) # create tokens token_response = auth_service.create_tokens(user) From a9eb8507dc300652735b354d8c66ba4b43298984 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:15:57 +0600 Subject: [PATCH 11/17] email token generation endpoints, and incorporation of verification on signup by user and artists --- backend/app/api/v1/artist.py | 12 ++++++++- backend/app/api/v1/email.py | 49 ++++++++++++++++++++++++++++++++++++ backend/app/api/v1/user.py | 12 ++++++++- backend/app/main.py | 2 ++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/v1/email.py diff --git a/backend/app/api/v1/artist.py b/backend/app/api/v1/artist.py index da164b8..524dccd 100644 --- a/backend/app/api/v1/artist.py +++ b/backend/app/api/v1/artist.py @@ -20,6 +20,9 @@ from app.core.deps import ( get_current_active_user, get_current_admin, get_current_musician ) +from app.services.token_service import TokenService +from app.services.email_service import EmailService +from app.dependencies import get_email_service router = APIRouter() @@ -27,7 +30,8 @@ @router.post("/signup", response_model=ArtistSignupResponse, status_code=status.HTTP_201_CREATED) async def create_artist_signup( artist_signup_data: ArtistSignup, - db: Session = Depends(get_db) + db: Session = Depends(get_db), + email_service: EmailService = Depends(get_email_service), ) -> ArtistSignupResponse: """ Creates both user (with musician role) and artist profile in one transaction. @@ -37,6 +41,12 @@ async def create_artist_signup( """ try: user, artist = create_artist_with_user(db, artist_signup_data) + + token = TokenService.create_token(db, user.id, "email_verification") + try: + email_service.send_verification_email(user, token.token) + except Exception: + pass return ArtistSignupResponse( message="Artist account created successfully", user=ArtistSignupUserInfo( diff --git a/backend/app/api/v1/email.py b/backend/app/api/v1/email.py new file mode 100644 index 0000000..d1bb9ea --- /dev/null +++ b/backend/app/api/v1/email.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.email import ( + EmailVerificationResponse, + ResendVerificationRequest, + ResendVerificationResponse, +) +from app.crud.email import EmailCRUD +from app.services.email_service import EmailService +from app.dependencies import get_email_service + + +router = APIRouter(prefix="/auth", tags=["email"]) + + +@router.get("/verify-email", response_model=EmailVerificationResponse) +async def verify_email( + token: str = Query(..., description="Verification token from email"), + db: Session = Depends(get_db), +): + """ + Verify email address with token. + Returns JSON only. The frontend gonna consume this and render UI. + // TODO(frontend): This endpoint should be called from a frontend route /verify?token={token} + """ + success, message, _ = EmailCRUD.verify_email(db, token) + if not success: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) + return EmailVerificationResponse(message=message) + + +@router.post("/resend-verification", response_model=ResendVerificationResponse) +async def resend_verification_email( + request: ResendVerificationRequest, + db: Session = Depends(get_db), + email_service: EmailService = Depends(get_email_service), +): + """ + Resend verification email + // TODO(frontend): Trigger from a "Resend verification" button. + """ + success, _ = EmailCRUD.resend_verification_email(db, request.email, email_service) + if not success: + raise HTTPException(status_code=status.HTTP_424_FAILED_DEPENDENCY, detail="Email delivery failed. Please try again later.") + # Mask existence return generic message on success + return ResendVerificationResponse(message="If the email exists, a verification link has been sent") + diff --git a/backend/app/api/v1/user.py b/backend/app/api/v1/user.py index a16bb81..b3a48e2 100644 --- a/backend/app/api/v1/user.py +++ b/backend/app/api/v1/user.py @@ -21,13 +21,17 @@ from app.core.deps import ( get_current_active_user, get_current_admin ) +from app.services.token_service import TokenService +from app.services.email_service import EmailService +from app.dependencies import get_email_service router = APIRouter() @router.post("/signup", response_model=UserOut, status_code=status.HTTP_201_CREATED) async def create_user_signup( user_data: UserSignup, - db: Session = Depends(get_db) + db: Session = Depends(get_db), + email_service: EmailService = Depends(get_email_service), ): """ Create a new user account @@ -63,6 +67,12 @@ async def create_user_signup( ) user = create_user(db, user_create_data) + + token = TokenService.create_token(db, user.id, "email_verification") + try: + email_service.send_verification_email(user, token.token) + except Exception: + pass return user except Exception as e: raise HTTPException( diff --git a/backend/app/main.py b/backend/app/main.py index 4049f0c..2410978 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -47,6 +47,7 @@ from app.api.v1.stream import router as stream_router from app.api.v1.album import router as album_router from app.api.v1.album_song import router as album_song_router +from app.api.v1.email import router as email_router # Include routers with proper prefixes and tags app.include_router(auth_router, prefix="/auth", tags=["authentication"]) @@ -66,6 +67,7 @@ app.include_router(stream_router, tags=["streaming"], prefix="/stream") app.include_router(album_router, tags=["albums"], prefix="/album") app.include_router(album_song_router, tags=["album-songs"], prefix="/album") +app.include_router(email_router) # CORS configuration app.add_middleware( From ae1afdc2b45b5dd8c507f31dec405b87d1b2d2c8 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:32:33 +0600 Subject: [PATCH 12/17] removed reference to password reset utils --- backend/app/services/token_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/services/token_service.py b/backend/app/services/token_service.py index 1bac13a..403edc9 100644 --- a/backend/app/services/token_service.py +++ b/backend/app/services/token_service.py @@ -3,7 +3,7 @@ from typing import Optional from sqlalchemy.orm import Session -from app.core.utils import utc_now, EMAIL_VERIFICATION_TOKEN_HOURS, PASSWORD_RESET_TOKEN_HOURS +from app.core.utils import utc_now, EMAIL_VERIFICATION_TOKEN_HOURS from app.db.models.email_verification import EmailVerificationToken @@ -22,7 +22,7 @@ def create_token( expires_in_hours: Optional[int] = None, ) -> EmailVerificationToken: if expires_in_hours is None: - expires_in_hours = EMAIL_VERIFICATION_TOKEN_HOURS if token_type == "email_verification" else PASSWORD_RESET_TOKEN_HOURS + expires_in_hours = EMAIL_VERIFICATION_TOKEN_HOURS token_value = TokenService.generate_token() expires_at = utc_now() + timedelta(hours=expires_in_hours) From 113f776cc27e3c2f87b07ce5f669907096423f38 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:45:30 +0600 Subject: [PATCH 13/17] renamed dependency file and related imports --- backend/app/api/v1/artist.py | 2 +- backend/app/api/v1/email.py | 2 +- backend/app/api/v1/user.py | 2 +- backend/app/dependencies/__init__.py | 2 ++ backend/app/dependencies/auth_deps.py | 7 ------- backend/app/dependencies/email_dependency.py | 10 ++++++++++ 6 files changed, 15 insertions(+), 10 deletions(-) create mode 100644 backend/app/dependencies/__init__.py delete mode 100644 backend/app/dependencies/auth_deps.py create mode 100644 backend/app/dependencies/email_dependency.py diff --git a/backend/app/api/v1/artist.py b/backend/app/api/v1/artist.py index 524dccd..e983630 100644 --- a/backend/app/api/v1/artist.py +++ b/backend/app/api/v1/artist.py @@ -22,7 +22,7 @@ ) from app.services.token_service import TokenService from app.services.email_service import EmailService -from app.dependencies import get_email_service +from app.dependencies.email_dependency import get_email_service router = APIRouter() diff --git a/backend/app/api/v1/email.py b/backend/app/api/v1/email.py index d1bb9ea..584e0eb 100644 --- a/backend/app/api/v1/email.py +++ b/backend/app/api/v1/email.py @@ -9,7 +9,7 @@ ) from app.crud.email import EmailCRUD from app.services.email_service import EmailService -from app.dependencies import get_email_service +from app.dependencies.email_dependency import get_email_service router = APIRouter(prefix="/auth", tags=["email"]) diff --git a/backend/app/api/v1/user.py b/backend/app/api/v1/user.py index b3a48e2..c98e9c0 100644 --- a/backend/app/api/v1/user.py +++ b/backend/app/api/v1/user.py @@ -23,7 +23,7 @@ ) from app.services.token_service import TokenService from app.services.email_service import EmailService -from app.dependencies import get_email_service +from app.dependencies.email_dependency import get_email_service router = APIRouter() diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py new file mode 100644 index 0000000..29423b2 --- /dev/null +++ b/backend/app/dependencies/__init__.py @@ -0,0 +1,2 @@ +# Package marker for app.dependencies + diff --git a/backend/app/dependencies/auth_deps.py b/backend/app/dependencies/auth_deps.py deleted file mode 100644 index 54d8256..0000000 --- a/backend/app/dependencies/auth_deps.py +++ /dev/null @@ -1,7 +0,0 @@ -# Extracts current user from JWT token -# Checks if user is active, has proper roles or permissions -# Raises proper HTTP errors if auth fails -# -# -# -# Implemented after crud/db operations diff --git a/backend/app/dependencies/email_dependency.py b/backend/app/dependencies/email_dependency.py new file mode 100644 index 0000000..b8efef1 --- /dev/null +++ b/backend/app/dependencies/email_dependency.py @@ -0,0 +1,10 @@ +from app.services.email_service import EmailService + + +_email_service_singleton = EmailService() + +def get_email_service() -> EmailService: + """Provide a singleton EmailService instance""" + return _email_service_singleton + + From 9b4c7ce2b0c3734c1e6e27ca197c6b31978fa732 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:13:29 +0600 Subject: [PATCH 14/17] renamed search to name --- backend/app/api/v1/artist.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/artist.py b/backend/app/api/v1/artist.py index ed89d03..e118b3b 100644 --- a/backend/app/api/v1/artist.py +++ b/backend/app/api/v1/artist.py @@ -68,10 +68,10 @@ async def get_artists_public( db: Session = Depends(get_db), skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), - search: Optional[str] = Query(None, min_length=1, description="Search artists by stage name"), active_only: bool = Query(True, description="Return only active artists"), artist_id: Optional[int] = Query(None, description="Get specific artist by ID"), - stage_name: Optional[str] = Query(None, min_length=1, description="Get specific artist by stage name") + stage_name: Optional[str] = Query(None, min_length=1, description="Get specific artist by stage name"), + name: Optional[str] = Query(None, min_length=1, description="Search artists by stage name") ): """ Get artists with flexible filtering options. @@ -79,7 +79,7 @@ async def get_artists_public( Query Parameters: - skip: Number of records to skip (pagination) - limit: Maximum number of records to return (pagination) - - search: Search artists by stage name (returns multiple results) + - name: Search artists by stage name (returns multiple results) - active_only: Return only active artists (default: True) - artist_id: Get specific artist by ID (returns single result) - stage_name: Get specific artist by stage name (returns single result) @@ -105,8 +105,8 @@ async def get_artists_public( detail="Artist not found" ) return [artist] - if search: - artists = search_artists_by_name(db, search, skip=skip, limit=limit) + if name: + artists = search_artists_by_name(db, name, skip=skip, limit=limit) # partial search, stagename = exact match elif active_only: artists = get_all_active_artists(db, skip=skip, limit=limit) else: From 269864ff97aefbf7ba6faffabedae62418536e4d Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:23:03 +0600 Subject: [PATCH 15/17] Fix for PR issues: - genre search is case insensitive - genre name taken check is case insensitive - no redundant double GET endpoints for genre, only one get endpoint to get all and get via query param. - Get endpoint allows close matches - fixed redundant and useless usage of /admin endpoints to : no deletion option for genre, only disable option., admin see all genre others only active ones - single db call for genre create, removed prechecks and returning exception error based on crud returns --- backend/app/api/v1/genre.py | 131 ++++++++++++------------------------ backend/app/crud/genre.py | 93 ++++++++++++++++++++++--- 2 files changed, 125 insertions(+), 99 deletions(-) diff --git a/backend/app/api/v1/genre.py b/backend/app/api/v1/genre.py index c3a2602..d0da105 100644 --- a/backend/app/api/v1/genre.py +++ b/backend/app/api/v1/genre.py @@ -1,22 +1,46 @@ -from typing import List -from fastapi import APIRouter, Depends, HTTPException, status +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from app.db.session import get_db -from app.core.deps import get_current_admin +from app.core.deps import get_current_admin, get_current_user_optional from app.schemas.genre import GenreCreate, GenreUpdate, GenreOut, GenreStats from app.crud.genre import ( - create_genre, get_genre_by_id, get_genre_by_name, get_all_genres, - get_all_active_genres, update_genre, disable_genre, enable_genre, - genre_exists, genre_name_taken, get_genre_statistics + create_genre, genre_exists, get_genre_by_id, get_genre_by_name, get_all_genres, + get_all_active_genres, get_genres_by_fuzzy_name, get_genres_by_partial_name, update_genre, disable_genre, enable_genre, + genre_name_taken, get_genre_statistics, + get_genre_by_name_any, get_genres_by_partial_name_any, get_genres_by_fuzzy_name_any ) router = APIRouter() @router.get("/", response_model=List[GenreOut]) -async def get_active_genres(db: Session = Depends(get_db)): - """Get all active genres - public access""" - return get_all_active_genres(db) +async def list_genres( + name: Optional[str] = Query(None, description="Exact genre name to filter"), + q: Optional[str] = Query(None, description="Partial/fuzzy name search"), + db: Session = Depends(get_db), + current_user = Depends(get_current_user_optional) +): + """List genres with rbac: admins see all; others see active only.""" + is_admin = bool(current_user and getattr(current_user, "role", None) == "admin") + + if name: + genre = get_genre_by_name_any(db, name) if is_admin else get_genre_by_name(db, name) + if not genre: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found") + return [genre] + if q: + if is_admin: + partial = get_genres_by_partial_name_any(db, q) + if partial: + return partial + return get_genres_by_fuzzy_name_any(db, q) + else: + partial = get_genres_by_partial_name(db, q) + if partial: + return partial + return get_genres_by_fuzzy_name(db, q) + return get_all_genres(db) if is_admin else get_all_active_genres(db) @router.get("/{genre_id}", response_model=GenreOut) @@ -31,16 +55,7 @@ async def get_genre(genre_id: int, db: Session = Depends(get_db)): return genre -@router.get("/name/{name}", response_model=GenreOut) -async def get_genre_by_name_endpoint(name: str, db: Session = Depends(get_db)): - """Get a specific genre by name - public access""" - genre = get_genre_by_name(db, name) - if not genre: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Genre not found" - ) - return genre + @router.post("/", response_model=GenreOut, status_code=status.HTTP_201_CREATED) @@ -50,13 +65,10 @@ async def create_new_genre( current_admin: dict = Depends(get_current_admin) ): """Create a new genre - admin only""" - if genre_name_taken(db, genre_data.name): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Genre name already exists" - ) - - return create_genre(db, genre_data) + created = create_genre(db, genre_data) + if created is None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Genre name already exists") + return created @router.put("/{genre_id}", response_model=GenreOut) @@ -67,12 +79,6 @@ async def update_genre_endpoint( current_admin: dict = Depends(get_current_admin) ): """Update a genre - admin only""" - if not genre_exists(db, genre_id): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Genre not found" - ) - if genre_data.name and genre_name_taken(db, genre_data.name, exclude_genre_id=genre_id): raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -97,12 +103,6 @@ async def partial_update_genre( current_admin: dict = Depends(get_current_admin) ): """Partially update a genre - admin only""" - if not genre_exists(db, genre_id): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Genre not found" - ) - if genre_data.name and genre_name_taken(db, genre_data.name, exclude_genre_id=genre_id): raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -119,29 +119,6 @@ async def partial_update_genre( return updated_genre -@router.delete("/{genre_id}") -async def delete_genre( - genre_id: int, - db: Session = Depends(get_db), - current_admin: dict = Depends(get_current_admin) -): - """Delete a genre - admin only""" - if not genre_exists(db, genre_id): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Genre not found" - ) - - success = disable_genre(db, genre_id) - if not success: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Genre not found" - ) - - return {"message": "Genre disabled successfully"} - - @router.post("/{genre_id}/disable") async def disable_genre_endpoint( genre_id: int, @@ -149,19 +126,9 @@ async def disable_genre_endpoint( current_admin: dict = Depends(get_current_admin) ): """Disable a genre - admin only""" - if not genre_exists(db, genre_id): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Genre not found" - ) - success = disable_genre(db, genre_id) if not success: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Genre not found" - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found") return {"message": "Genre disabled successfully"} @@ -172,12 +139,6 @@ async def enable_genre_endpoint( current_admin: dict = Depends(get_current_admin) ): """Enable a genre - admin only""" - if not genre_exists(db, genre_id): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Genre not found" - ) - success = enable_genre(db, genre_id) if not success: raise HTTPException( @@ -187,21 +148,13 @@ async def enable_genre_endpoint( return {"message": "Genre enabled successfully"} + -@router.get("/admin/all", response_model=List[GenreOut]) -async def get_all_genres_admin( - db: Session = Depends(get_db), - current_admin: dict = Depends(get_current_admin) -): - """Get all genres including inactive ones - admin only""" - return get_all_genres(db) - - -@router.get("/admin/statistics", response_model=GenreStats) +@router.get("/statistics", response_model=GenreStats) async def get_genre_statistics_endpoint( db: Session = Depends(get_db), current_admin: dict = Depends(get_current_admin) ): - """Get genre statistics - admin only""" + """Get genre statistics""" stats = get_genre_statistics(db) return GenreStats(**stats) diff --git a/backend/app/crud/genre.py b/backend/app/crud/genre.py index 5a76009..7561e00 100644 --- a/backend/app/crud/genre.py +++ b/backend/app/crud/genre.py @@ -1,22 +1,28 @@ -from typing import List, Optional +from typing import List, Optional, Tuple from sqlalchemy.orm import Session from sqlalchemy import func +import difflib +from sqlalchemy.exc import IntegrityError from datetime import datetime, timezone from app.db.models.genre import Genre from app.schemas.genre import GenreCreate, GenreUpdate -def create_genre(db: Session, genre_data: GenreCreate) -> Genre: +def create_genre(db: Session, genre_data: GenreCreate) -> Optional[Genre]: """Create a new genre in the database""" db_genre = Genre( name=genre_data.name, description=genre_data.description, is_active=True ) - db.add(db_genre) - db.commit() - db.refresh(db_genre) - return db_genre + try: + db.add(db_genre) + db.commit() + db.refresh(db_genre) + return db_genre + except IntegrityError: # this does the same job as checking if name exists in raw sql + db.rollback() + return None def get_genre_by_id(db: Session, genre_id: int) -> Optional[Genre]: @@ -25,8 +31,21 @@ def get_genre_by_id(db: Session, genre_id: int) -> Optional[Genre]: def get_genre_by_name(db: Session, name: str) -> Optional[Genre]: - """Get a genre by its name""" - return db.query(Genre).filter(Genre.name == name).first() + """Get an active genre by its name case-insensitive exact match""" + return ( + db.query(Genre) + .filter(func.lower(Genre.name) == name.lower(), Genre.is_active == True) + .first() + ) + + +def get_genre_by_name_any(db: Session, name: str) -> Optional[Genre]: + """Get a genre by its name (case-insensitive), regardless of active status.""" + return ( + db.query(Genre) + .filter(func.lower(Genre.name) == name.lower()) + .first() + ) def get_all_genres(db: Session) -> List[Genre]: @@ -39,6 +58,60 @@ def get_all_active_genres(db: Session) -> List[Genre]: return db.query(Genre).filter(Genre.is_active == True).all() +def get_genres_by_partial_name(db: Session, query_text: str) -> List[Genre]: + """Get active genres whose names partially match the query """ + like_pattern = f"%{query_text}%" + return ( + db.query(Genre) + .filter(Genre.is_active == True) + .filter(Genre.name.ilike(like_pattern)) + .all() + ) + + +def get_genres_by_partial_name_any(db: Session, query_text: str) -> List[Genre]: + """Get genres whose names partially match the query (any status).""" + like_pattern = f"%{query_text}%" + return ( + db.query(Genre) + .filter(Genre.name.ilike(like_pattern)) + .all() + ) + + +def get_genres_by_fuzzy_name( + db: Session,query_text: str, + max_results: int = 10, min_ratio: float = 0.6, +) -> List[Genre]: + """Fuzzy search, time consuming + """ + active_genres: List[Genre] = get_all_active_genres(db) + scored: List[Tuple[float, Genre]] = [] + for genre in active_genres: + ratio = difflib.SequenceMatcher(None, query_text.lower(), genre.name.lower()).ratio() + if ratio >= min_ratio: + scored.append((ratio, genre)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [g for _, g in scored[:max_results]] + + +def get_genres_by_fuzzy_name_any( + db: Session, query_text: str, + max_results: int = 10, min_ratio: float = 0.6, +) -> List[Genre]: + """Fuzzy search across all genres (any status).""" + all_genres: List[Genre] = get_all_genres(db) + scored: List[Tuple[float, Genre]] = [] + for genre in all_genres: + ratio = difflib.SequenceMatcher(None, query_text.lower(), genre.name.lower()).ratio() + if ratio >= min_ratio: + scored.append((ratio, genre)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [g for _, g in scored[:max_results]] + + def update_genre(db: Session, genre_id: int, genre_data: GenreUpdate) -> Optional[Genre]: """Update a genre with new data""" db_genre = get_genre_by_id(db, genre_id) @@ -84,8 +157,8 @@ def genre_exists(db: Session, genre_id: int) -> bool: def genre_name_taken(db: Session, name: str, exclude_genre_id: Optional[int] = None) -> bool: - """Check if a genre name is already taken""" - query = db.query(Genre).filter(Genre.name == name) + """Check if a genre name is already taken (case-insensitive)""" + query = db.query(Genre).filter(func.lower(Genre.name) == name.lower()) if exclude_genre_id: query = query.filter(Genre.id != exclude_genre_id) return query.first() is not None From 8fc2e541f41e8c9376b5452a3f3cc90fcbe96ac5 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:29:55 +0600 Subject: [PATCH 16/17] linter issue fix --- backend/app/api/v1/genre.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/backend/app/api/v1/genre.py b/backend/app/api/v1/genre.py index d0da105..58a7add 100644 --- a/backend/app/api/v1/genre.py +++ b/backend/app/api/v1/genre.py @@ -55,9 +55,6 @@ async def get_genre(genre_id: int, db: Session = Depends(get_db)): return genre - - - @router.post("/", response_model=GenreOut, status_code=status.HTTP_201_CREATED) async def create_new_genre( genre_data: GenreCreate, @@ -148,7 +145,6 @@ async def enable_genre_endpoint( return {"message": "Genre enabled successfully"} - @router.get("/statistics", response_model=GenreStats) async def get_genre_statistics_endpoint( @@ -158,3 +154,4 @@ async def get_genre_statistics_endpoint( """Get genre statistics""" stats = get_genre_statistics(db) return GenreStats(**stats) + From 40aad431ddaa37794a68bd689c531fed2e5ff075 Mon Sep 17 00:00:00 2001 From: Myndaaa <127163546+myndaaa@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:49:31 +0600 Subject: [PATCH 17/17] - added to do comment on usage of proper rest naming and using names as {name} in endpoints which might have whitespaces. - added fuzzy search to song. - rmeoved /search endpoint in song and resuing current get endpoint with query params --- backend/app/api/v1/band.py | 36 +++++++++--------------------------- backend/app/api/v1/genre.py | 3 ++- backend/app/api/v1/song.py | 25 ++++++++++--------------- backend/app/crud/song.py | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 44 deletions(-) diff --git a/backend/app/api/v1/band.py b/backend/app/api/v1/band.py index ff99627..0ae4e5d 100644 --- a/backend/app/api/v1/band.py +++ b/backend/app/api/v1/band.py @@ -9,7 +9,7 @@ BandCreate, BandOut, BandUpdate, BandStats, BandWithRelations ) from app.crud.band import ( - create_band, get_band_by_id, get_band_by_name, get_all_bands, + create_band, get_band_by_id, get_all_bands, get_active_bands, search_bands_by_name, update_band, disable_band, enable_band, delete_band_permanently, get_band_statistics ) @@ -18,6 +18,8 @@ ) router = APIRouter() +# TODO: add slug later on; +# resource: https://stackoverflow.com/questions/10018100/identify-item-by-either-an-id-or-a-slug-in-a-restful-api """ AUTHENTICATION LEVELS: - None: Public endpoint, no authentication required @@ -61,7 +63,7 @@ async def get_bands_public( db: Session = Depends(get_db), skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), - search: Optional[str] = Query(None, min_length=1, description="Search bands by name"), + name: Optional[str] = Query(None, min_length=1, description="Filter bands by name (case-insensitive, partial)"), active_only: bool = Query(True, description="Return only active bands") ): """ @@ -70,13 +72,13 @@ async def get_bands_public( Query Parameters: - skip: Number of records to skip (pagination) - limit: Maximum number of records to return (pagination) - - search: Search bands by name + - name: Filter bands by name (case-insensitive, partial) - active_only: Return only active bands (default: True) Returns: 200 OK - List of bands """ - if search: - bands = search_bands_by_name(db, search, skip=skip, limit=limit) + if name: + bands = search_bands_by_name(db, name, skip=skip, limit=limit) elif active_only: bands = get_active_bands(db, skip=skip, limit=limit) else: @@ -105,28 +107,8 @@ async def get_band_public( ) return band - - -@router.get("/name/{name}", response_model=BandOut) -async def get_band_by_name_public( - name: str, - db: Session = Depends(get_db) -): - """ - Get public band profile by name. - Returns basic band information for public viewing. - Only active bands are returned. - Returns: 200 OK - Band profile found - Returns: 404 Not Found - Band not found or inactive - """ - band = get_band_by_name(db, name) - if not band or band.is_disabled: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Band not found" - ) - - return band +# removed {name} function as it can be fetched alrdy via query param in get_bands_public + @router.get("/me/bands", response_model=List[BandOut]) diff --git a/backend/app/api/v1/genre.py b/backend/app/api/v1/genre.py index 58a7add..c3d09ec 100644 --- a/backend/app/api/v1/genre.py +++ b/backend/app/api/v1/genre.py @@ -12,7 +12,8 @@ ) router = APIRouter() - +# TODO: add slug later on; +# https://stackoverflow.com/questions/10018100/identify-item-by-either-an-id-or-a-slug-in-a-restful-api @router.get("/", response_model=List[GenreOut]) async def list_genres( diff --git a/backend/app/api/v1/song.py b/backend/app/api/v1/song.py index 660ddda..4079dc3 100644 --- a/backend/app/api/v1/song.py +++ b/backend/app/api/v1/song.py @@ -9,10 +9,10 @@ ) from app.crud.song import ( create_song_by_artist, create_song_by_band, create_song_by_admin, - get_song_by_id, get_all_songs_paginated, search_songs, - get_songs_by_artist, get_songs_by_band, get_songs_by_genre, + get_song_by_id, get_all_songs_paginated, search_songs, search_songs_fuzzy, + get_songs_by_artist, get_songs_by_band, get_songs_by_genre, song_exists, update_song_file_path, update_song_metadata, disable_song, enable_song, - song_exists, can_user_upload_for_band, get_song_statistics + can_user_upload_for_band, get_song_statistics ) from app.crud.user import get_user_by_id from app.db.models.user import User @@ -24,23 +24,18 @@ async def get_all_songs( skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), + q: Optional[str] = Query(None, min_length=1, description="Search by title/artist/band"), db: Session = Depends(get_db) ): - """Get all active songs with pagination - public access""" + """List songs: when query param is provided, performs search; otherwise paginated list.""" + if q: + results = search_songs(db, q, skip=skip, limit=limit) + if results: + return results + return search_songs_fuzzy(db, q, skip=skip, limit=limit) return get_all_songs_paginated(db, skip=skip, limit=limit) -@router.get("/search", response_model=List[SongOut]) -async def search_songs_endpoint( - query: str = Query(..., min_length=1, description="Search query"), - skip: int = Query(0, ge=0, description="Number of records to skip"), - limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), - db: Session = Depends(get_db) -): - """Search songs by title, artist name, or band name - public access""" - return search_songs(db, query, skip=skip, limit=limit) - - @router.get("/{song_id}", response_model=SongOut) async def get_song(song_id: int, db: Session = Depends(get_db)): """Get a specific song by ID - public access""" diff --git a/backend/app/crud/song.py b/backend/app/crud/song.py index 612e089..e2c6baa 100644 --- a/backend/app/crud/song.py +++ b/backend/app/crud/song.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Dict, Any +from typing import List, Optional, Dict, Any, Tuple from sqlalchemy.orm import Session from sqlalchemy import func from datetime import datetime, timezone @@ -8,6 +8,7 @@ from app.db.models.genre import Genre from app.db.models.user import User from app.schemas.song import SongUploadByArtist, SongUploadByBand, SongUploadByAdmin, SongUpdate +import difflib def create_song_by_artist(db: Session, song_data: SongUploadByArtist, uploaded_by_user_id: int) -> Song: @@ -118,6 +119,40 @@ def search_songs(db: Session, query: str, skip: int = 0, limit: int = 20) -> Lis ).offset(skip).limit(limit).all() +def search_songs_fuzzy( + db: Session, + query: str, + skip: int = 0, + limit: int = 20, + min_ratio: float = 0.6, +) -> List[Song]: + """Fuzzy search songs by comparing query with title/artist_name/bandname + """ + active_songs: List[Song] = db.query(Song).filter(Song.is_disabled == False).all() + + scored: List[Tuple[float, Song]] = [] + q = query.lower() + for song in active_songs: + candidates = [ + (song.title or ""), + (song.artist_name or ""), + (song.band_name or ""), + ] + best = 0.0 + for text in candidates: + if not text: + continue + r = difflib.SequenceMatcher(None, q, text.lower()).ratio() + if r > best: + best = r + if best >= min_ratio: + scored.append((best, song)) + + scored.sort(key=lambda x: x[0], reverse=True) + sliced = scored[skip: skip + limit] if limit is not None else scored[skip:] + return [s for _, s in sliced] + + def get_songs_by_artist(db: Session, artist_id: int, skip: int = 0, limit: int = 20) -> List[Song]: """Get songs by artist ID""" return db.query(Song).filter(