284 lines
11 KiB
Python
284 lines
11 KiB
Python
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()
|
|
|