from sqlalchemy import create_engine, Column, Integer, String, Text, Date, DateTime, ForeignKey, Index, UniqueConstraint, text, Boolean from sqlalchemy.orm import declarative_base, sessionmaker, relationship, scoped_session from datetime import datetime import os import uuid import bcrypt # Load dotenv to get PostgreSQL connection string try: from dotenv import load_dotenv load_dotenv() except ImportError: pass # Get PostgreSQL URI from environment or use default POSTGRESQL_URI = os.environ.get('POSTGRESQL_URI', 'postgresql://songlyric_user:your_password@localhost:5432/church_songlyric') # Validate connection string doesn't contain default password in production if 'your_password' in POSTGRESQL_URI and os.environ.get('FLASK_ENV') == 'production': raise ValueError("SECURITY: Cannot use default password in production! Set POSTGRESQL_URI environment variable.") # Optimized engine settings for production engine = create_engine( POSTGRESQL_URI, echo=False, # Disable SQL logging for performance future=True, pool_pre_ping=True, # Verify connections before using pool_recycle=3600, # Recycle connections after 1 hour pool_size=10, # Connection pool size max_overflow=20, # Max overflow connections pool_timeout=30, # Timeout for getting connection from pool connect_args={ 'connect_timeout': 10, # Connection timeout in seconds 'options': '-c statement_timeout=60000' # 60 second query timeout } ) SessionLocal = scoped_session(sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)) Base = declarative_base() class Profile(Base): __tablename__ = 'profiles' id = Column(String(255), primary_key=True) first_name = Column(String(255), default='') last_name = Column(String(255), default='') name = Column(String(255), default='', nullable=False) email = Column(String(255), default='') contact_number = Column(String(50), default='') notes = Column(Text, default='') default_key = Column(String(10), default='C') __table_args__ = ( Index('idx_profile_name', 'name'), ) class Song(Base): __tablename__ = 'songs' id = Column(String(255), primary_key=True) title = Column(String(500), nullable=False) artist = Column(String(500), default='') band = Column(String(500), default='') singer = Column(String(500), default='') lyrics = Column(Text, default='') chords = Column(Text, default='') memo = Column(Text, default='') created_at = Column(Integer, default=lambda: int(datetime.now().timestamp())) updated_at = Column(Integer, default=lambda: int(datetime.now().timestamp())) __table_args__ = ( Index('idx_song_title', 'title'), Index('idx_song_artist', 'artist'), Index('idx_song_band', 'band'), ) class Plan(Base): __tablename__ = 'plans' id = Column(String(255), primary_key=True) date = Column(String(50), nullable=False) profile_id = Column(String(255), ForeignKey('profiles.id', ondelete='SET NULL')) notes = Column(Text, default='') created_at = Column(Integer, default=lambda: int(datetime.now().timestamp())) __table_args__ = ( Index('idx_plan_date', 'date'), Index('idx_plan_profile', 'profile_id'), ) class PlanSong(Base): __tablename__ = 'plan_songs' id = Column(String(255), primary_key=True, default=lambda: str(uuid.uuid4())) plan_id = Column(String(255), ForeignKey('plans.id', ondelete='CASCADE')) song_id = Column(String(255), ForeignKey('songs.id', ondelete='CASCADE')) order_index = Column(Integer, default=0) __table_args__ = ( UniqueConstraint('plan_id', 'song_id', name='uq_plan_song'), Index('idx_plan_songs_plan', 'plan_id'), Index('idx_plan_songs_order', 'plan_id', 'order_index'), ) class ProfileSong(Base): __tablename__ = 'profile_songs' id = Column(String(255), primary_key=True) profile_id = Column(String(255), ForeignKey('profiles.id', ondelete='CASCADE')) song_id = Column(String(255), ForeignKey('songs.id', ondelete='CASCADE')) __table_args__ = ( UniqueConstraint('profile_id', 'song_id', name='uq_profile_song'), Index('idx_profile_songs_profile', 'profile_id'), ) class ProfileSongKey(Base): __tablename__ = 'profile_song_keys' id = Column(String(255), primary_key=True) profile_id = Column(String(255), ForeignKey('profiles.id', ondelete='CASCADE')) song_id = Column(String(255), ForeignKey('songs.id', ondelete='CASCADE')) song_key = Column(String(10), default='C') __table_args__ = ( UniqueConstraint('profile_id', 'song_id', name='uq_profile_song_key'), Index('idx_profile_song_keys', 'profile_id', 'song_id'), ) class BiometricCredential(Base): __tablename__ = 'biometric_credentials' id = Column(String(255), primary_key=True, default=lambda: str(uuid.uuid4())) username = Column(String(255), nullable=False) # Username for authentication (backwards compatibility) user_id = Column(String(255), ForeignKey('users.id'), nullable=True) # Link to User table credential_id = Column(Text, nullable=False) # Base64 encoded credential ID public_key = Column(Text, nullable=False) # Base64 encoded public key device_name = Column(String(255), default='') # User-friendly device name device_info = Column(Text, default='') # Device information (browser, OS) device_fingerprint = Column(Text, default='') # Device fingerprint for mobile compatibility enabled = Column(Integer, default=1) # 1=enabled, 0=disabled created_at = Column(DateTime, default=datetime.now) last_used = Column(DateTime, default=datetime.now) # Relationship to User user = relationship('User', backref='biometric_credentials', foreign_keys=[user_id]) __table_args__ = ( UniqueConstraint('credential_id', name='uq_credential_id'), Index('idx_biometric_username', 'username'), Index('idx_biometric_user_id', 'user_id'), Index('idx_biometric_enabled', 'username', 'enabled'), ) class User(Base): __tablename__ = 'users' id = Column(String(255), primary_key=True, default=lambda: str(uuid.uuid4())) username = Column(String(255), nullable=False, unique=True) password_hash = Column(String(255), nullable=False) # bcrypt hash (60 chars) role = Column(String(100), default='viewer') # admin, worship_leader, bass_guitar, piano, acoustic, viewer permissions = Column(Text, default='view') # Comma-separated: view,edit,modify,settings active = Column(Boolean, default=True) created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) last_login = Column(DateTime) __table_args__ = ( Index('idx_user_username', 'username'), Index('idx_user_role', 'role'), Index('idx_user_active', 'active'), ) def set_password(self, password): """Hash password using bcrypt with salt""" salt = bcrypt.gensalt(rounds=12) # 12 rounds provides good balance self.password_hash = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') def check_password(self, password): """Verify password against bcrypt hash""" try: return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8')) except (ValueError, AttributeError): return False def has_permission(self, permission): """Check if user has a specific permission""" if not self.active: return False if self.role == 'admin': return True # Admin has all permissions perms = [p.strip() for p in (self.permissions or '').split(',')] return permission in perms def get_permissions_list(self): """Get list of permissions""" if not self.permissions: return [] return [p.strip() for p in self.permissions.split(',') if p.strip()] def init_db(): """Initialize database - create tables and apply optimizations""" Base.metadata.create_all(engine) apply_optimizations() def apply_optimizations(): """Apply database optimizations (indexes) if they don't exist""" try: with engine.begin() as conn: # Try to add recommended indexes (will silently fail if no permissions) try: conn.execute(text("CREATE INDEX IF NOT EXISTS idx_song_title ON songs(title)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_song_artist ON songs(artist)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_song_band ON songs(band)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_plan_date ON plans(date)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_plan_profile ON plans(profile_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_profile_name ON profiles(name)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_plan_songs_order ON plan_songs(plan_id, order_index)")) except Exception: # Silently ignore permission errors - indexes will be added by DBA later pass except Exception: pass def get_db(): """Create a new database session""" return SessionLocal() def close_db(db): """Close database session""" if db: db.close() # Song helper functions def get_all_songs(db): return db.query(Song).all() def get_song_by_id(db, song_id): return db.query(Song).filter(Song.id == song_id).first() def create_song(db, title, artist='', band='', singer='', lyrics='', chords=''): song_id = str(uuid.uuid4()) song = Song(id=song_id, title=title, artist=artist, band=band, singer=singer, lyrics=lyrics, chords=chords) db.add(song) db.commit() db.refresh(song) return song def update_song(db, song_id, **kwargs): song = get_song_by_id(db, song_id) if not song: return None for key, value in kwargs.items(): if hasattr(song, key): setattr(song, key, value) song.updated_at = datetime.now() db.commit() db.refresh(song) return song def delete_song(db, song_id): song = get_song_by_id(db, song_id) if song: db.delete(song) db.commit() return True return False def search_songs(db, query): search = f"%{query}%" return db.query(Song).filter( (Song.title.ilike(search)) | (Song.artist.ilike(search)) | (Song.band.ilike(search)) | (Song.singer.ilike(search)) | (Song.lyrics.ilike(search)) ).all() # Profile helper functions def get_all_profiles(db): return db.query(Profile).all() def get_profile_by_id(db, profile_id): return db.query(Profile).filter(Profile.id == profile_id).first() # Plan helper functions def get_all_plans(db): return db.query(Plan).all() def get_plan_by_id(db, plan_id): return db.query(Plan).filter(Plan.id == plan_id).first()