from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from sqlalchemy.orm import Session from app.database import get_db from app.models.user import User, CreditTransaction, AdminUser, AdminNotification, LandingFeature, LandingPlatform, LandingTutorial, LandingGalleryItem from app.models.iglesia import Iglesia from app.models.lyrics import Group from app.routers.auth import SECRET_KEY, ALGORITHM, verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES, get_password_hash from pydantic import BaseModel from typing import List, Optional from datetime import datetime, timedelta from jose import JWTError, jwt router = APIRouter(prefix="/admin", tags=["Admin"]) class CreditsUpdate(BaseModel): credits: int class PremiumUpdate(BaseModel): is_premium: bool class AdminTokenResponse(BaseModel): access_token: str token_type: str expires_in: int user: dict class AdminCreate(BaseModel): username: str password: str perm_users: Optional[bool] = True perm_workers: Optional[bool] = True perm_churches: Optional[bool] = True perm_groups: Optional[bool] = True perm_notifications: Optional[bool] = True class AdminPasswordUpdate(BaseModel): password: str class AdminPermissionsUpdate(BaseModel): perm_users: bool perm_workers: bool perm_churches: bool perm_groups: bool perm_notifications: bool class AdminAvatarUpdate(BaseModel): avatar_url: str class NotificationCreate(BaseModel): title: str message: str oauth2_scheme_admin = OAuth2PasswordBearer(tokenUrl="admin/token") def get_current_admin( token: str = Depends(oauth2_scheme_admin), db: Session = Depends(get_db) ) -> AdminUser: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Credenciales de administrador inválidas", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM], options={"verify_aud": False}) username: str = payload.get("sub") role: str = payload.get("role") if username is None or role != "admin": raise credentials_exception except JWTError: raise credentials_exception admin = db.query(AdminUser).filter(AdminUser.username == username).first() if admin is None: raise credentials_exception return admin @router.post("/token", response_model=AdminTokenResponse) async def login_admin( form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) ): admin = db.query(AdminUser).filter(AdminUser.username == form_data.username).first() if not admin: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Usuario o contraseña incorrectos", headers={"WWW-Authenticate": "Bearer"}, ) if not verify_password(form_data.password, admin.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Usuario o contraseña incorrectos", headers={"WWW-Authenticate": "Bearer"}, ) # Generar token con role="admin" access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) token_payload = {"sub": admin.username, "role": "admin"} access_token = create_access_token( data=token_payload, expires_delta=access_token_expires ) return { "access_token": access_token, "token_type": "bearer", "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60, "user": { "id": admin.id, "username": admin.username, "role": "admin", "avatar_url": admin.avatar_url, "perm_users": admin.perm_users, "perm_workers": admin.perm_workers, "perm_churches": admin.perm_churches, "perm_groups": admin.perm_groups, "perm_notifications": admin.perm_notifications } } @router.get("/users") def list_users(db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): if not current_admin.perm_users: raise HTTPException(status_code=403, detail="No tiene permisos para gestionar usuarios") users = db.query(User).all() return [ { "id": u.id, "username": u.username, "email": u.email, "credits": u.credits, "is_premium": u.is_premium } for u in users ] @router.put("/users/{user_id}/credits") def update_user_credits(user_id: int, data: CreditsUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): if not current_admin.perm_users: raise HTTPException(status_code=403, detail="No tiene permisos para gestionar usuarios") user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="Usuario no encontrado") old_credits = user.credits user.credits = data.credits # Registrar la transacción de crédito tx = CreditTransaction( user_id=user.id, amount=data.credits - old_credits, concept="Ajuste administrativo de créditos", created_at=datetime.utcnow() ) db.add(tx) db.commit() db.refresh(user) # Notificar al usuario por WebSocket en tiempo real try: from app.websocket_manager import send_sync_message send_sync_message({ "type": "user_credits_updated", "credits": user.credits }, user.id) except Exception: pass return { "id": user.id, "username": user.username, "credits": user.credits } @router.put("/users/{user_id}/premium") def update_user_premium(user_id: int, data: PremiumUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): if not current_admin.perm_users: raise HTTPException(status_code=403, detail="No tiene permisos para gestionar usuarios") user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="Usuario no encontrado") user.is_premium = data.is_premium db.commit() db.refresh(user) # Notificar al usuario por WebSocket en tiempo real try: from app.websocket_manager import send_sync_message send_sync_message({ "type": "user_premium_updated", "is_premium": user.is_premium }, user.id) except Exception: pass return { "id": user.id, "username": user.username, "is_premium": user.is_premium } @router.put("/users/{user_id}/password") def update_user_password(user_id: int, data: AdminPasswordUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): if not current_admin.perm_users: raise HTTPException(status_code=403, detail="No tiene permisos para gestionar usuarios") user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="Usuario no encontrado") user.hashed_password = get_password_hash(data.password) db.commit() return {"message": "Contraseña de usuario actualizada exitosamente"} @router.get("/churches") def list_churches(db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): if not current_admin.perm_churches: raise HTTPException(status_code=403, detail="No tiene permisos para gestionar iglesias") churches = db.query(Iglesia).all() return [ { "id": c.id, "nombre": c.nombre, "pastor": c.pastor, "codigo": c.codigo, "admin_id": c.admin_id, "created_at": c.created_at.isoformat() if c.created_at else None } for c in churches ] @router.get("/groups") def list_groups(db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): if not current_admin.perm_groups: raise HTTPException(status_code=403, detail="No tiene permisos para gestionar grupos") groups = db.query(Group).all() return [ { "id": g.id, "name": g.name, "codigo": g.codigo, "created_at": g.created_at.isoformat() if g.created_at else None } for g in groups ] @router.get("/notifications") def list_notifications(db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): if not current_admin.perm_notifications: raise HTTPException(status_code=403, detail="No tiene permisos para gestionar notificaciones") notifications = db.query(AdminNotification).order_by(AdminNotification.created_at.desc()).all() return [ { "id": n.id, "title": n.title, "message": n.message, "created_at": n.created_at.isoformat() if n.created_at else None, "read": n.read } for n in notifications ] @router.put("/notifications/{notif_id}/read") def mark_notification_read(notif_id: int, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): if not current_admin.perm_notifications: raise HTTPException(status_code=403, detail="No tiene permisos para gestionar notificaciones") notif = db.query(AdminNotification).filter(AdminNotification.id == notif_id).first() if not notif: raise HTTPException(status_code=404, detail="Notificación no encontrada") notif.read = True db.commit() return {"message": "Notificación marcada como leída"} @router.delete("/notifications/{notif_id}") def delete_notification(notif_id: int, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): if not current_admin.perm_notifications: raise HTTPException(status_code=403, detail="No tiene permisos para gestionar notificaciones") notif = db.query(AdminNotification).filter(AdminNotification.id == notif_id).first() if not notif: raise HTTPException(status_code=404, detail="Notificación no encontrada") db.delete(notif) db.commit() return {"message": "Notificación eliminada exitosamente"} @router.post("/notifications") def create_notification(data: NotificationCreate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): if not current_admin.perm_notifications: raise HTTPException(status_code=403, detail="No tiene permisos para gestionar notificaciones") notif = AdminNotification( title=data.title, message=data.message, created_at=datetime.utcnow(), read=False ) db.add(notif) db.commit() db.refresh(notif) return { "id": notif.id, "title": notif.title, "message": notif.message, "created_at": notif.created_at.isoformat(), "read": notif.read } # --- Rutas de la pestaña "Cuenta" (Account & Administradores) --- @router.get("/me") def get_me(current_admin: AdminUser = Depends(get_current_admin)): return { "id": current_admin.id, "username": current_admin.username, "role": "admin", "avatar_url": current_admin.avatar_url, "perm_users": current_admin.perm_users, "perm_workers": current_admin.perm_workers, "perm_churches": current_admin.perm_churches, "perm_groups": current_admin.perm_groups, "perm_notifications": current_admin.perm_notifications } @router.get("/admins") def list_admins(db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): admins = db.query(AdminUser).order_by(AdminUser.username).all() return [ { "id": a.id, "username": a.username, "avatar_url": a.avatar_url, "perm_users": a.perm_users, "perm_workers": a.perm_workers, "perm_churches": a.perm_churches, "perm_groups": a.perm_groups, "perm_notifications": a.perm_notifications, "created_at": a.created_at.isoformat() if a.created_at else None } for a in admins ] @router.post("/admins") def create_admin(data: AdminCreate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): existing = db.query(AdminUser).filter(AdminUser.username == data.username).first() if existing: raise HTTPException(status_code=400, detail="El nombre de usuario ya está registrado") hashed = get_password_hash(data.password) new_admin = AdminUser( username=data.username, hashed_password=hashed, perm_users=data.perm_users, perm_workers=data.perm_workers, perm_churches=data.perm_churches, perm_groups=data.perm_groups, perm_notifications=data.perm_notifications ) db.add(new_admin) db.commit() db.refresh(new_admin) return { "id": new_admin.id, "username": new_admin.username, "avatar_url": new_admin.avatar_url } @router.put("/admins/{admin_id}/password") def update_admin_password(admin_id: int, data: AdminPasswordUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): admin = db.query(AdminUser).filter(AdminUser.id == admin_id).first() if not admin: raise HTTPException(status_code=404, detail="Administrador no encontrado") admin.hashed_password = get_password_hash(data.password) db.commit() return {"message": "Contraseña actualizada exitosamente"} @router.put("/admins/{admin_id}/permissions") def update_admin_permissions(admin_id: int, data: AdminPermissionsUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): admin = db.query(AdminUser).filter(AdminUser.id == admin_id).first() if not admin: raise HTTPException(status_code=404, detail="Administrador no encontrado") admin.perm_users = data.perm_users admin.perm_workers = data.perm_workers admin.perm_churches = data.perm_churches admin.perm_groups = data.perm_groups admin.perm_notifications = data.perm_notifications db.commit() return {"message": "Permisos actualizados exitosamente"} @router.put("/admins/{admin_id}/avatar") def update_admin_avatar(admin_id: int, data: AdminAvatarUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): admin = db.query(AdminUser).filter(AdminUser.id == admin_id).first() if not admin: raise HTTPException(status_code=404, detail="Administrador no encontrado") admin.avatar_url = data.avatar_url db.commit() return {"avatar_url": admin.avatar_url} @router.delete("/users/{user_id}") def delete_user(user_id: int, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): if not current_admin.perm_users: raise HTTPException(status_code=403, detail="No tiene permisos para gestionar usuarios") user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="Usuario no encontrado") try: from sqlalchemy import inspect, text inspector = inspect(db.bind) db_tables = set(inspector.get_table_names()) # 1. Borrar de tablas secundarias (Cascading manually to prevent FK errors) deletes = [ ("devices", "user_id"), ("credit_transactions", "user_id"), ("tasks", "user_id"), ("partituras_midi", "user_id"), ("user_groups", "user_id"), ("iglesia_members", "user_id"), ("dismissed_visitas", "user_id"), ("progresos_plan", "user_id"), ("puntuaciones_liga", "user_id"), ("participantes_torneo", "user_id") ] for table, col in deletes: if table in db_tables: columns = {c["name"] for c in inspector.get_columns(table)} if col in columns: db.execute(text(f"DELETE FROM {table} WHERE {col} = :id"), {"id": user_id}) # 2. Desvincular de tablas (poner en NULL para evitar FK errors) updates = [ ("workers", "user_id"), ("lyrics", "created_by"), ("repertorios", "created_by"), ("iglesias", "admin_id"), ("miembros", "user_id"), ("calendarios", "created_by"), ("asistencias", "registrado_por"), ("finanzas", "registrado_por"), ("momentos", "registrado_por"), ("inventario", "registrado_por"), ("torneos", "creador_id"), ("torneos", "ganador_id") ] for table, col in updates: if table in db_tables: columns = {c["name"] for c in inspector.get_columns(table)} if col in columns: db.execute(text(f"UPDATE {table} SET {col} = NULL WHERE {col} = :id"), {"id": user_id}) # 3. Eliminar el usuario principal db.delete(user) db.commit() except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"Error al eliminar usuario: {str(e)}") return {"message": "Usuario eliminado exitosamente"} # --- Rutas de la pestaña "Contenido" (Landing Page Features) --- class LandingFeatureUpdate(BaseModel): icon_svg: Optional[str] = None icon_color: Optional[str] = None title: Optional[str] = None description: Optional[str] = None order: Optional[int] = None class LandingFeatureCreate(BaseModel): icon_svg: Optional[str] = None icon_color: str = "cyan" title: str description: str order: int = 0 DEFAULT_FEATURES = [ { "icon_svg": '', "icon_color": "cyan", "title": "Separación de Stems con IA", "description": "Divide cualquier audio en múltiples pistas independientes: voz, batería, bajo, piano y otros instrumentos con una claridad asombrosa.", "order": 0, }, { "icon_svg": '', "icon_color": "purple", "title": "Letras Integradas", "description": "Agrega y edita fácilmente las letras y cifrados de tus canciones. Visualízalas sincronizadas con tus pistas de acompañamiento.", "order": 1, }, { "icon_svg": '', "icon_color": "green", "title": "Transposición Inteligente", "description": "Modifica el tono de la canción para adaptarla a tu registro vocal, o disminuye la velocidad (tempo) para practicar secciones complejas sin perder nitidez.", "order": 2, }, { "icon_svg": '', "icon_color": "blue", "title": "Sincronización Multi-dispositivo", "description": "Sincroniza tus pistas directamente entre tus dispositivos usando P2P o nube temporal para migrar sin perder nada.", "order": 3, }, ] def _seed_features_if_empty(db: Session): """Auto-seeds the landing_features table with defaults if it is empty.""" count = db.query(LandingFeature).count() if count == 0: for f in DEFAULT_FEATURES: db.add(LandingFeature(**f)) db.commit() @router.get("/features") def list_features(db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): _seed_features_if_empty(db) features = db.query(LandingFeature).order_by(LandingFeature.order).all() return [ { "id": f.id, "icon_svg": f.icon_svg, "icon_color": f.icon_color, "title": f.title, "description": f.description, "order": f.order, } for f in features ] @router.get("/features/public") def list_features_public(db: Session = Depends(get_db)): """Public endpoint — no auth required — used by the landing page.""" _seed_features_if_empty(db) features = db.query(LandingFeature).order_by(LandingFeature.order).all() return [ { "id": f.id, "icon_svg": f.icon_svg, "icon_color": f.icon_color, "title": f.title, "description": f.description, "order": f.order, } for f in features ] @router.put("/features/{feature_id}") def update_feature( feature_id: int, data: LandingFeatureUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): feature = db.query(LandingFeature).filter(LandingFeature.id == feature_id).first() if not feature: raise HTTPException(status_code=404, detail="Característica no encontrada") if data.icon_svg is not None: feature.icon_svg = data.icon_svg if data.icon_color is not None: feature.icon_color = data.icon_color if data.title is not None: feature.title = data.title if data.description is not None: feature.description = data.description if data.order is not None: feature.order = data.order db.commit() db.refresh(feature) return { "id": feature.id, "icon_svg": feature.icon_svg, "icon_color": feature.icon_color, "title": feature.title, "description": feature.description, "order": feature.order, } @router.post("/features") def create_feature( data: LandingFeatureCreate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): feature = LandingFeature( icon_svg=data.icon_svg, icon_color=data.icon_color, title=data.title, description=data.description, order=data.order, ) db.add(feature) db.commit() db.refresh(feature) return { "id": feature.id, "icon_svg": feature.icon_svg, "icon_color": feature.icon_color, "title": feature.title, "description": feature.description, "order": feature.order, } @router.delete("/features/{feature_id}") def delete_feature( feature_id: int, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): feature = db.query(LandingFeature).filter(LandingFeature.id == feature_id).first() if not feature: raise HTTPException(status_code=404, detail="Característica no encontrada") db.delete(feature) db.commit() return {"message": "Característica eliminada exitosamente"} # --- Rutas de la pestaña "Contenido" (Landing Page Platforms) --- class LandingPlatformUpdate(BaseModel): name: Optional[str] = None version: Optional[str] = None os_details: Optional[str] = None description: Optional[str] = None download_url: Optional[str] = None button_text: Optional[str] = None os_type: Optional[str] = None # windows | android | apple is_visible: Optional[bool] = None order: Optional[int] = None class LandingPlatformCreate(BaseModel): name: str version: Optional[str] = None os_details: Optional[str] = None description: Optional[str] = None download_url: Optional[str] = None button_text: Optional[str] = None os_type: str = "windows" is_visible: bool = True order: int = 0 DEFAULT_PLATFORMS = [ { "name": "Windows Desktop", "version": "1.0.4", "os_details": "Windows 10/11 x64", "description": "Optimizado para computadoras personales con soporte para procesamiento local fuera de línea.", "download_url": "#", "button_text": "Descargar para Windows", "os_type": "windows", "is_visible": True, "order": 0 }, { "name": "Google Play Store", "version": "1.2.0", "os_details": "Android 8.0+", "description": "Lleva tus ensayos a cualquier parte. Reproduce tus stems y visualiza los acordes en tu tablet o móvil.", "download_url": "#", "button_text": "Obtener en Google Play", "os_type": "android", "is_visible": True, "order": 1 }, { "name": "Apple App Store", "version": "1.2.0", "os_details": "iOS / macOS", "description": "Perfectamente integrado con iPadOS para ver las letras y pistas en tus atriles digitales durante el servicio.", "download_url": "#", "button_text": "Descargar para App Store", "os_type": "apple", "is_visible": True, "order": 2 } ] def _seed_platforms_if_empty(db: Session): """Auto-seeds the landing_platforms table with defaults if it is empty.""" count = db.query(LandingPlatform).count() if count == 0: for p in DEFAULT_PLATFORMS: db.add(LandingPlatform(**p)) db.commit() @router.get("/platforms") def list_platforms(db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): _seed_platforms_if_empty(db) platforms = db.query(LandingPlatform).order_by(LandingPlatform.order).all() return [ { "id": p.id, "name": p.name, "version": p.version, "os_details": p.os_details, "description": p.description, "download_url": p.download_url, "button_text": p.button_text, "os_type": p.os_type, "is_visible": p.is_visible, "order": p.order, } for p in platforms ] @router.get("/platforms/public") def list_platforms_public(db: Session = Depends(get_db)): """Public endpoint — no auth required — used by the landing page.""" _seed_platforms_if_empty(db) # Only return visible platforms platforms = db.query(LandingPlatform).filter(LandingPlatform.is_visible == True).order_by(LandingPlatform.order).all() return [ { "id": p.id, "name": p.name, "version": p.version, "os_details": p.os_details, "description": p.description, "download_url": p.download_url, "button_text": p.button_text, "os_type": p.os_type, "order": p.order, } for p in platforms ] @router.post("/platforms") def create_platform( data: LandingPlatformCreate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): platform = LandingPlatform( name=data.name, version=data.version, os_details=data.os_details, description=data.description, download_url=data.download_url, button_text=data.button_text, os_type=data.os_type, is_visible=data.is_visible, order=data.order, ) db.add(platform) db.commit() db.refresh(platform) return { "id": platform.id, "name": platform.name, "version": platform.version, "os_details": platform.os_details, "description": platform.description, "download_url": platform.download_url, "button_text": platform.button_text, "os_type": platform.os_type, "is_visible": platform.is_visible, "order": platform.order, } @router.put("/platforms/{platform_id}") def update_platform( platform_id: int, data: LandingPlatformUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): platform = db.query(LandingPlatform).filter(LandingPlatform.id == platform_id).first() if not platform: raise HTTPException(status_code=404, detail="Plataforma no encontrada") if data.name is not None: platform.name = data.name if data.version is not None: platform.version = data.version if data.os_details is not None: platform.os_details = data.os_details if data.description is not None: platform.description = data.description if data.download_url is not None: platform.download_url = data.download_url if data.button_text is not None: platform.button_text = data.button_text if data.os_type is not None: platform.os_type = data.os_type if data.is_visible is not None: platform.is_visible = data.is_visible if data.order is not None: platform.order = data.order db.commit() db.refresh(platform) return { "id": platform.id, "name": platform.name, "version": platform.version, "os_details": platform.os_details, "description": platform.description, "download_url": platform.download_url, "button_text": platform.button_text, "os_type": platform.os_type, "is_visible": platform.is_visible, "order": platform.order, } @router.delete("/platforms/{platform_id}") def delete_platform( platform_id: int, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): platform = db.query(LandingPlatform).filter(LandingPlatform.id == platform_id).first() if not platform: raise HTTPException(status_code=404, detail="Plataforma no encontrada") db.delete(platform) db.commit() return {"message": "Plataforma eliminada exitosamente"} class LandingTutorialCreate(BaseModel): title: str desc: Optional[str] = None steps: List[str] = [] order: int = 0 class LandingTutorialUpdate(BaseModel): title: Optional[str] = None desc: Optional[str] = None steps: Optional[List[str]] = None order: Optional[int] = None DEFAULT_TUTORIALS = [ { "title": "Cómo separar voz e instrumentos", "desc": "Guía paso a paso para procesar canciones y extraer pistas aisladas (vocales, batería, bajo, etc.) en segundos.", "steps": [ "Abre la aplicación móvil o de escritorio de Melodix.", "Dirígete a la pestaña \"Pistas\" y haz clic en el botón de añadir (+).", "Selecciona el archivo de audio desde tu dispositivo o ingresa un enlace compatible.", "Elige el modo de separación deseado (por ejemplo: Vocales + Instrumentos, o Stems completos).", "Confirma y espera a que nuestros servidores de IA procesen tu pista. Esto consumirá 1 crédito.", "¡Listo! Ya puedes descargar o reproducir cada pista de forma independiente, ajustar volúmenes o mutear instrumentos." ], "order": 0 }, { "title": "Ajuste de Tempo y Transposición", "desc": "Aprende a modificar la velocidad de reproducción y cambiar la clave musical de tus pistas para adaptarlas a tu voz.", "steps": [ "Selecciona cualquier pista de tu repertorio y abre el reproductor integrado.", "Para cambiar la clave de tono (transposición), utiliza los botones + y - al lado de \"Tono\". Esto adaptará la pista a tu rango vocal.", "Para modificar la velocidad, desliza el control de \"Tempo\" o selecciona los valores predeterminados (e.g., 0.75x, 1.25x) para ensayar pasajes difíciles.", "Los algoritmos de alta fidelidad de Melodix preservarán la calidad del sonido sin distorsiones molestas." ], "order": 1 }, { "title": "Migración y Backup Multi-dispositivo", "desc": "Cambia de teléfono o sincroniza tus dispositivos sin perder nada. (Disponible de forma automática en cuentas premium)", "steps": [ "Tus pistas procesadas se almacenan temporalmente para que otros dispositivos las descarguen.", "Sincronización P2P o nube dependiendo de tu plan.", "Ahorra espacio y mantén tus repertorios idénticos en tus celulares o tablets." ], "order": 2 } ] def _seed_tutorials_if_empty(db: Session): """Auto-seeds the landing_tutorials table with defaults if it is empty.""" count = db.query(LandingTutorial).count() if count == 0: for t in DEFAULT_TUTORIALS: db.add(LandingTutorial(**t)) db.commit() @router.get("/tutorials") def list_tutorials(db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): _seed_tutorials_if_empty(db) tutorials = db.query(LandingTutorial).order_by(LandingTutorial.order).all() return [ { "id": t.id, "title": t.title, "desc": t.desc, "steps": t.steps, "order": t.order, } for t in tutorials ] @router.get("/tutorials/public") def list_tutorials_public(db: Session = Depends(get_db)): """Public endpoint — no auth required — used by the landing page.""" _seed_tutorials_if_empty(db) tutorials = db.query(LandingTutorial).order_by(LandingTutorial.order).all() return [ { "id": t.id, "title": t.title, "desc": t.desc, "steps": t.steps, "order": t.order, } for t in tutorials ] @router.post("/tutorials") def create_tutorial( data: LandingTutorialCreate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): tutorial = LandingTutorial( title=data.title, desc=data.desc, steps=data.steps, order=data.order, ) db.add(tutorial) db.commit() db.refresh(tutorial) return { "id": tutorial.id, "title": tutorial.title, "desc": tutorial.desc, "steps": tutorial.steps, "order": tutorial.order, } @router.put("/tutorials/{tutorial_id}") def update_tutorial( tutorial_id: int, data: LandingTutorialUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): tutorial = db.query(LandingTutorial).filter(LandingTutorial.id == tutorial_id).first() if not tutorial: raise HTTPException(status_code=404, detail="Tutorial no encontrado") if data.title is not None: tutorial.title = data.title if data.desc is not None: tutorial.desc = data.desc if data.steps is not None: tutorial.steps = data.steps if data.order is not None: tutorial.order = data.order db.commit() db.refresh(tutorial) return { "id": tutorial.id, "title": tutorial.title, "desc": tutorial.desc, "steps": tutorial.steps, "order": tutorial.order, } @router.delete("/tutorials/{tutorial_id}") def delete_tutorial( tutorial_id: int, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): tutorial = db.query(LandingTutorial).filter(LandingTutorial.id == tutorial_id).first() if not tutorial: raise HTTPException(status_code=404, detail="Tutorial no encontrado") db.delete(tutorial) db.commit() return {"message": "Tutorial de landing page eliminado exitosamente"} class LandingGalleryItemCreate(BaseModel): image_url: str caption: Optional[str] = None order: int = 0 class LandingGalleryItemUpdate(BaseModel): image_url: Optional[str] = None caption: Optional[str] = None order: Optional[int] = None DEFAULT_GALLERY = [ { "image_url": "/hero_banner.png", "caption": "Plan de Estudios & Desafíos", "order": 0 }, { "image_url": "/app_mockup.png", "caption": "Liga de la Iglesia, Perfil & Finanzas", "order": 1 } ] def _seed_gallery_if_empty(db: Session): """Auto-seeds the landing_gallery table with defaults if empty.""" count = db.query(LandingGalleryItem).count() if count == 0: for item in DEFAULT_GALLERY: db.add(LandingGalleryItem(**item)) db.commit() @router.get("/gallery") def list_gallery(db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)): _seed_gallery_if_empty(db) items = db.query(LandingGalleryItem).order_by(LandingGalleryItem.order).all() return [ { "id": i.id, "image_url": i.image_url, "caption": i.caption, "order": i.order, } for i in items ] @router.get("/gallery/public") def list_gallery_public(db: Session = Depends(get_db)): """Public endpoint for the landing page gallery.""" _seed_gallery_if_empty(db) items = db.query(LandingGalleryItem).order_by(LandingGalleryItem.order).all() return [ { "id": i.id, "image_url": i.image_url, "caption": i.caption, "order": i.order, } for i in items ] @router.post("/gallery") def create_gallery_item( data: LandingGalleryItemCreate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): item = LandingGalleryItem( image_url=data.image_url, caption=data.caption, order=data.order, ) db.add(item) db.commit() db.refresh(item) return { "id": item.id, "image_url": item.image_url, "caption": item.caption, "order": item.order, } @router.put("/gallery/{item_id}") def update_gallery_item( item_id: int, data: LandingGalleryItemUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): item = db.query(LandingGalleryItem).filter(LandingGalleryItem.id == item_id).first() if not item: raise HTTPException(status_code=404, detail="Item de galería no encontrado") if data.image_url is not None: item.image_url = data.image_url if data.caption is not None: item.caption = data.caption if data.order is not None: item.order = data.order db.commit() db.refresh(item) return { "id": item.id, "image_url": item.image_url, "caption": item.caption, "order": item.order, } @router.delete("/gallery/{item_id}") def delete_gallery_item( item_id: int, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin), ): item = db.query(LandingGalleryItem).filter(LandingGalleryItem.id == item_id).first() if not item: raise HTTPException(status_code=404, detail="Item de galería no encontrado") db.delete(item) db.commit() return {"message": "Item de galería eliminado exitosamente"}