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.
+
+
+
+
+
+
+ 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(