Files
Church-Music/legacy-site/backend/postgresql_models.py

284 lines
11 KiB
Python
Raw Normal View History

2026-01-27 18:04:50 -06:00
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()