Spaces:
Running
Running
| 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 | |
| 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 | |
| } | |
| } | |
| 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 | |
| ] | |
| 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 | |
| } | |
| 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 | |
| } | |
| 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"} | |
| 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 | |
| ] | |
| 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 | |
| ] | |
| 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 | |
| ] | |
| 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"} | |
| 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"} | |
| 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) --- | |
| 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 | |
| } | |
| 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 | |
| ] | |
| 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 | |
| } | |
| 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"} | |
| 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"} | |
| 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} | |
| 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": '<path d="M4 10h16M4 14h16M12 4v16"/>', | |
| "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": '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>', | |
| "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": '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>', | |
| "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": '<path d="M18 10.286A6.5 6.5 0 0 0 5.828 7.32a4 4 0 1 0-.22 7.848H18a3.42 3.42 0 0 0 0-6.882z"/>', | |
| "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() | |
| 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 | |
| ] | |
| 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 | |
| ] | |
| 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, | |
| } | |
| 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, | |
| } | |
| 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() | |
| 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 | |
| ] | |
| 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 | |
| ] | |
| 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, | |
| } | |
| 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, | |
| } | |
| 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() | |
| 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 | |
| ] | |
| 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 | |
| ] | |
| 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, | |
| } | |
| 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, | |
| } | |
| 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() | |
| 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 | |
| ] | |
| 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 | |
| ] | |
| 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, | |
| } | |
| 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, | |
| } | |
| 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"} | |