Isaac105 commited on
Commit
c43fa62
·
verified ·
1 Parent(s): 5257473

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. ANALISIS_VISTAS.md +435 -435
  3. CAMBIOS_REALIZADOS.md +287 -287
  4. INFORME_PRUEBAS.md +487 -487
  5. Iniciar-MelodixAPI.ps1 +90 -90
  6. MELODIX_MAUI_VALIDADO.md +351 -351
  7. QUICK_START.md +283 -283
  8. START_SCRIPT_README.md +282 -282
  9. _run_celery.ps1 +10 -10
  10. _run_uvicorn.ps1 +13 -13
  11. alembic.ini +149 -149
  12. alembic/env.py +96 -96
  13. alembic/script.py.mako +28 -28
  14. alembic/versions/72f5727b3a03_initial_schema.py +498 -498
  15. app/database.py +45 -45
  16. app/main.py +238 -238
  17. app/models/aprendizaje.py +110 -110
  18. app/models/iglesia.py +219 -219
  19. app/models/lyrics.py +142 -142
  20. app/models/midi.py +19 -19
  21. app/models/process.py +71 -71
  22. app/models/sync.py +32 -32
  23. app/models/user.py +169 -169
  24. app/routers/__init__.py +1 -1
  25. app/routers/admin.py +0 -0
  26. app/routers/alabanza.py +0 -0
  27. app/routers/aprendizaje.py +1221 -1221
  28. app/routers/auth.py +689 -689
  29. app/routers/iglesia.py +0 -0
  30. app/routers/lyrics.py +757 -757
  31. app/routers/midi.py +128 -128
  32. app/routers/migration.py +305 -305
  33. app/routers/songs.py +0 -0
  34. app/routers/workers.py +474 -474
  35. app/schemas/lyrics.py +91 -91
  36. app/schemas/process.py +20 -20
  37. app/schemas/user.py +60 -60
  38. app/utils/email.py +197 -197
  39. app/utils/imgbb.py +76 -76
  40. app/utils/midi_parser.py +84 -84
  41. app/utils/notifications.py +27 -27
  42. app/utils/redis_client.py +29 -29
  43. app/utils/supabase_storage.py +56 -56
  44. app/utils/telegram_storage.py +161 -161
  45. app/websocket_manager.py +65 -65
  46. btc_toolkit/__init__.py +4 -4
  47. btc_toolkit/inference.py +301 -301
  48. btc_toolkit/weights_manager.py +132 -132
  49. check_requirements.py +322 -322
  50. check_schema.py +15 -15
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ models/btc_chord.pth filter=lfs diff=lfs merge=lfs -text
ANALISIS_VISTAS.md CHANGED
@@ -1,435 +1,435 @@
1
- # Melodix MAUI - Análisis Completo de Vistas
2
-
3
- ## 📋 Índice
4
- 1. [Arquitectura General](#arquitectura-general)
5
- 2. [Análisis de Vistas Existentes](#análisis-de-vistas-existentes)
6
- 3. [Problemas Identificados](#problemas-identificados)
7
- 4. [Buenas Prácticas .NET MAUI 9](#buenas-prácticas-net-maui-9)
8
- 5. [Plan de Rediseño](#plan-de-rediseño)
9
-
10
- ---
11
-
12
- ## 🏗️ Arquitectura General
13
-
14
- ### Estructura Actual del Proyecto
15
- ```
16
- Melodix/
17
- ├── Views/
18
- │ ├── MainDesktopPage.xaml # Vista principal (Shell)
19
- │ ├── SeparacionView.xaml # Vista de separación de pistas
20
- │ ├── HistoryView.xaml # Historial de pistas
21
- │ ├── TrackSeparationView.xaml # Vista alternativa de separación
22
- │ ├── SeparacionPage.xaml # Vista legacy (no usada)
23
- │ ├── LoginPage.xaml # Login
24
- │ ├── RegisterPage.xaml # Registro
25
- │ └── StudioPage.xaml # Estudio DAW (FUNCIONA BIEN - IGNORAR)
26
- ├── ViewModels/
27
- │ ├── MainDesktopViewModel.cs
28
- │ ├── SeparacionViewModel.cs # Hay 2 versiones (VM y code-behind)
29
- │ ├── LoginViewModel.cs
30
- │ └── RegisterViewModel.cs
31
- ├── Models/
32
- │ ├── TrackModel.cs
33
- │ └── ...
34
- └── Resources/Styles/
35
- ├── Colors.xaml
36
- └── Styles.xaml
37
- ```
38
-
39
- ### Patrón de Navegación
40
- - **Shell** con `MainDesktopPage` como contenido principal
41
- - Navegación por rutas registradas (`Routing.RegisterRoute`)
42
- - `MainDesktopPage` usa un sistema de **ContentView switching** manual (no navegación Shell)
43
-
44
- ---
45
-
46
- ## 🔍 Análisis de Vistas Existentes
47
-
48
- ### 1. **MainDesktopPage.xaml** ⚠️ PROBLEMAS CRÍTICOS
49
-
50
- **Estructura:**
51
- - Grid con sidebar izquierdo (260px) + área de contenido
52
- - Sidebar con navegación manual (TapGestureHandlers)
53
- - ContentView que cambia dinámicamente
54
-
55
- **Problemas:**
56
- 1. ❌ **Navegación manual implementada**: Usa `PropertyChanged` subscription en code-behind para cambiar `ContentView.Content`
57
- 2. ❌ **No usa MVVM correctamente**: Los TapGestureHandlers llaman métodos en code-behind en lugar de Commands
58
- 3. ❌ **Hardcoded menu items**: No es data-driven (debería usar BindableLayout con colección de menú)
59
- 4. ❌ **Falta consistencia visual**: Colores hardcodeados (`#0E0E10`, `#8A2BE2`) en lugar de usar recursos
60
- 5. ❌ **OnHistorialTapped muestra alerta** en lugar de navegar a HistoryView
61
- 6. ❌ **No hay feedback visual** de qué item del menú está activo (excepto BoxView de 4px)
62
- 7. ⚠️ **Grid layout rígido**: No usa `OnIdiom` para responsividad
63
-
64
- **Code-behind issues:**
65
- ```csharp
66
- // ❌ MAL: Suscripción manual a PropertyChanged
67
- _viewModel.PropertyChanged += (s, e) => {
68
- if (e.PropertyName == nameof(MainDesktopViewModel.CurrentView)) {
69
- MainContentView.Content = _viewModel.CurrentView;
70
- }
71
- };
72
- ```
73
-
74
- ---
75
-
76
- ### 2. **SeparacionView.xaml** ⚠️ PROBLEMAS GRAVES
77
-
78
- **Estructura:**
79
- - ContentView con Grid (Header + Search + CollectionView)
80
- - Lista de tracks con Border personalizado
81
- - Sistema de polling para tracks en procesamiento
82
-
83
- **Problemas:**
84
- 1. ❌ **ViewModel duplicado**: Hay `SeparacionViewModel` en `ViewModels/` Y en el code-behind de `SeparacionView.xaml.cs`
85
- 2. ❌ **Lógica de negocio en View**: El code-behind tiene métodos `AddTrackToUI`, `UpdateTrackInUI` que manipulan directamente el ViewModel
86
- 3. ❌ **Polling implementado incorrectamente**: `Device.StartTimer` en el ViewModel sin cancelación
87
- 4. ❌ **Converter no definido**: Usa `StatusToColorConverter` pero no está declarado en Resources
88
- 5. ❌ **Mixed concerns**: El ViewModel llama a `_view.AddTrackToUI()` rompiendo el patrón MVVM
89
- 6. ⚠️ **CollectionView SelectionMode="None"**: Usa TapGestureRecognizer en lugar de SelectionChanged
90
- 7. ⚠️ **SearchEntry no bindeado correctamente**: Usa evento `TextChanged` en lugar de Command
91
- 8. ❌ **Falta EmptyView para estado de carga**: No hay indicador mientras carga tracks
92
-
93
- **XAML issues:**
94
- ```xml
95
- <!-- ❌ MAL: Converter referenciado pero no definido -->
96
- <BoxView.BackgroundColor>
97
- <MultiBinding Converter="{StaticResource StatusToColorConverter}">
98
- <Binding Path="Status" />
99
- </MultiBinding>
100
- </BoxView.BackgroundColor>
101
- ```
102
-
103
- ---
104
-
105
- ### 3. **HistoryView.xaml** ⚠️ PROBLEMAS MODERADOS
106
-
107
- **Estructura:**
108
- - ContentView simple con CollectionView
109
- - Lista de tracks completados
110
-
111
- **Problemas:**
112
- 1. ❌ **No usa ViewModel separado**: Toda la lógica está en code-behind
113
- 2. ❌ **ObservableCollection en View**: Debería estar en ViewModel
114
- 3. ❌ **Navegación directa a StudioPage**: No pasa por MainDesktopPage
115
- 4. ⚠️ **Duplicación de código**: Similar a SeparacionView pero implementado diferente
116
- 5. ⚠️ **Falta funcionalidad de búsqueda**: No tiene search bar
117
- 6. ⚠️ **No hay ordenamiento**: Debería permitir ordenar por fecha, nombre, etc.
118
-
119
- ---
120
-
121
- ### 4. **TrackSeparationView.xaml** ❌ OBSOLETO
122
-
123
- **Problemas:**
124
- 1. ❌ **Vista duplicada**: Funcionalidad similar a SeparacionView
125
- 2. ❌ **Sin ViewModel**: Toda la lógica en code-behind
126
- 3. ❌ **No se usa actualmente**: Parece ser una versión antigua
127
- 4. ❌ **UI muy básica**: Solo tiene botón de subir sin lista de tracks
128
-
129
- **Recomendación:** ELIMINAR esta vista
130
-
131
- ---
132
-
133
- ### 5. **SeparacionPage.xaml** ❌ LEGACY
134
-
135
- **Problemas:**
136
- 1. ❌ **ContentPage en lugar de ContentView**: No está diseñada para embedder en MainDesktopPage
137
- 2. ❌ **UI anticuada**: Usa Frames en lugar de Borders, colores claros
138
- 3. ❌ **No se usa**: Registrada en AppShell pero no navegada
139
- 4. ❌ **ViewModel diferente**: Usa inyección de dependencias vs creación manual
140
-
141
- **Recomendación:** ELIMINAR esta vista
142
-
143
- ---
144
-
145
- ### 6. **LoginPage.xaml** ⚠️ PROBLEMAS MENORES
146
-
147
- **Problemas:**
148
- 1. ⚠️ **Grid innecesario**: Usa `RowDefinitions="*,Auto,*"` para centrar, mejor usar `VerticalOptions="Center"`
149
- 2. ⚠️ **Falta validación**: No hay indicadores visuales de validación de campos
150
- 3. ⚠️ **Sin manejo de errores visual**: Solo muestra texto rojo
151
- 4. ⚠️ **No usa Entry Behaviors**: Para validación en tiempo real
152
-
153
- ---
154
-
155
- ### 7. **RegisterPage.xaml** ⚠️ PROBLEMAS MENORES
156
-
157
- **Problemas:**
158
- 1. ⚠️ **Similar a Login**: Mismos problemas de estructura
159
- 2. ⚠️ **Falta confirmación de contraseña**: No hay campo para confirmar password
160
- 3. ⚠️ **Sin validación de email**: No valida formato de email
161
-
162
- ---
163
-
164
- ## 🚨 Problemas Identificados (Resumen)
165
-
166
- ### Críticos 🔴
167
- 1. **ViewModel duplicado**: `SeparacionViewModel` existe en 2 lugares diferentes
168
- 2. **Navegación manual**: MainDesktopPage no usa el sistema de navegación de Shell
169
- 3. **MVVM roto**: Views llaman métodos directamente en lugar de usar Commands
170
- 4. **Converter no definido**: `StatusToColorConverter` falta en Resources
171
- 5. **Polling sin cancelación**: Timer sigue corriendo indefinidamente
172
-
173
- ### Graves 🟠
174
- 6. **Lógica de negocio en Views**: Code-behind hace trabajo de ViewModel
175
- 7. **Colores hardcodeados**: No usa el sistema de recursos de MAUI
176
- 8. **No hay estados de carga**: Falta ActivityIndicator mientras cargan datos
177
- 9. **Mixed navigation patterns**: Algunas vistas usan Shell, otras ContentView switching
178
- 10. **Vistas duplicadas**: TrackSeparationView y SeparacionPage no se usan
179
-
180
- ### Moderados 🟡
181
- 11. **Falta responsividad**: No usa `OnIdiom` consistentemente
182
- 12. **Sin validación de formularios**: Login/Register sin validación visual
183
- 13. **ObservableCollection en Views**: Debería estar en ViewModels
184
- 14. **Falta EmptyView states**: No hay indicadores de carga o error
185
- 15. **Search con eventos**: Usa TextChanged en lugar de Command con debounce
186
-
187
- ### Menores 🟢
188
- 16. **Grids innecesarios**: Layouts podrían simplificarse
189
- 17. **Falta confirmación de password**: En registro
190
- 18. **Sin ordenamiento**: HistoryView no permite ordenar
191
- 19. **Falta Pull-to-refresh**: En CollectionViews
192
-
193
- ---
194
-
195
- ## ✅ Buenas Prácticas .NET MAUI 9
196
-
197
- ### 1. **Arquitectura MVVM Correcta**
198
- ```csharp
199
- // ✅ BIEN: ViewModel con Commands
200
- public partial class MainViewModel : ObservableObject
201
- {
202
- [RelayCommand]
203
- private async Task NavigateAsync(string route) { ... }
204
- }
205
-
206
- // ✅ BIEN: View con Binding
207
- <Button Command="{Binding NavigateCommand}" CommandParameter="Separacion" />
208
- ```
209
-
210
- ### 2. **Navegación con Shell**
211
- ```csharp
212
- // ✅ BIEN: Usar Shell para navegación
213
- await Shell.Current.GoToAsync(nameof(HistoryView));
214
-
215
- // ❌ MAL: ContentView switching manual
216
- MainContentView.Content = new HistoryView();
217
- ```
218
-
219
- ### 3. **Recursos y Estilos**
220
- ```xml
221
- <!-- ✅ BIEN: Usar recursos -->
222
- <Label TextColor="{StaticResource PrimaryColor}" />
223
-
224
- <!-- ❌ MAL: Hardcoded -->
225
- <Label TextColor="#8A2BE2" />
226
- ```
227
-
228
- ### 4. **CollectionView con Selection**
229
- ```xml
230
- <!-- ✅ BIEN: Usar SelectionChanged -->
231
- <CollectionView SelectionMode="Single"
232
- SelectionChanged="OnTrackSelected"
233
- SelectedItem="{Binding SelectedTrack}" />
234
-
235
- <!-- ❌ MAL: TapGestureRecognizer en cada item -->
236
- <Border.GestureRecognizers>
237
- <TapGestureRecognizer Command="{Binding TapCommand}" />
238
- </Border.GestureRecognizers>
239
- ```
240
-
241
- ### 5. **ViewModel con CommunityToolkit**
242
- ```csharp
243
- // ✅ BIEN: Usar CommunityToolkit.Mvvm
244
- public partial class MyViewModel : ObservableObject
245
- {
246
- [ObservableProperty]
247
- private string _status;
248
-
249
- [RelayCommand]
250
- private async Task DoSomethingAsync() { ... }
251
- }
252
- ```
253
-
254
- ### 6. **Converters en Resources**
255
- ```xml
256
- <Application.Resources>
257
- <ResourceDictionary>
258
- <local:StatusToColorConverter x:Key="StatusToColorConverter" />
259
- </ResourceDictionary>
260
- </Application.Resources>
261
- ```
262
-
263
- ### 7. **Estados de Carga y EmptyView**
264
- ```xml
265
- <CollectionView>
266
- <CollectionView.EmptyView>
267
- <VerticalStackLayout>
268
- <ActivityIndicator IsRunning="{Binding IsLoading}" />
269
- <Label Text="Cargando..." IsVisible="{Binding IsLoading}" />
270
- <Label Text="No hay datos" IsVisible="{Binding IsEmpty}" />
271
- </VerticalStackLayout>
272
- </CollectionView.EmptyView>
273
- </CollectionView>
274
- ```
275
-
276
- ### 8. **Polling con Cancelación**
277
- ```csharp
278
- // ✅ BIEN: CancellationTokenSource
279
- private CancellationTokenSource _pollingCts;
280
-
281
- private async Task StartPollingAsync()
282
- {
283
- _pollingCts = new CancellationTokenSource();
284
- while (!_pollingCts.IsCancellationRequested)
285
- {
286
- await Task.Delay(5000, _pollingCts.Token);
287
- await CheckStatusAsync();
288
- }
289
- }
290
-
291
- public void StopPolling() => _pollingCts?.Cancel();
292
- ```
293
-
294
- ### 9. **Responsive con OnIdiom**
295
- ```xml
296
- <Grid ColumnSpacing="{OnIdiom Default='20', Phone='10', Tablet='30'}" />
297
- <Button FontSize="{OnIdiom Default='14', Phone='12'}" />
298
- ```
299
-
300
- ### 10. **Validación con Behaviors**
301
- ```xml
302
- <Entry Text="{Binding Email}">
303
- <Entry.Behaviors>
304
- <local:EmailValidationBehavior />
305
- </Entry.Behaviors>
306
- </Entry>
307
- ```
308
-
309
- ---
310
-
311
- ## 📐 Plan de Rediseño
312
-
313
- ### Fase 1: **Corregir Problemas Críticos** (Prioridad Alta)
314
- 1. ✅ Eliminar `SeparacionViewModel` duplicado (code-behind)
315
- 2. ✅ Implementar navegación Shell correcta en MainDesktopPage
316
- 3. ✅ Crear `StatusToColorConverter` y agregarlo a Resources
317
- 4. ✅ Agregar CancellationToken al polling
318
- 5. ✅ Mover lógica de Views a ViewModels
319
-
320
- ### Fase 2: **Rediseñar MainDesktopPage** (Prioridad Alta)
321
- 1. ✅ Convertir menú a BindableLayout data-driven
322
- 2. ✅ Usar Commands en lugar de TapGestureHandlers
323
- 3. ✅ Agregar feedback visual de item activo
324
- 4. ✅ Implementar navegación Shell para HistoryView
325
- 5. ✅ Usar recursos de colores en lugar de hardcoded
326
-
327
- ### Fase 3: **Rediseñar SeparacionView** (Prioridad Alta)
328
- 1. ✅ Unificar con ViewModel de `ViewModels/SeparacionViewModel.cs`
329
- 2. ✅ Implementar polling con cancelación
330
- 3. ✅ Agregar estados de carga (ActivityIndicator)
331
- 4. ✅ Mejorar búsqueda con Command + debounce
332
- 5. ✅ Usar SelectionChanged en lugar de TapGestureRecognizer
333
-
334
- ### Fase 4: **Rediseñar HistoryView** (Prioridad Media)
335
- 1. ✅ Crear HistoryViewModel separado
336
- 2. ✅ Agregar funcionalidad de búsqueda
337
- 3. ✅ Agregar ordenamiento (fecha, nombre, artista)
338
- 4. ✅ Agregar Pull-to-refresh
339
- 5. ✅ Unificar estilo con SeparacionView
340
-
341
- ### Fase 5: **Rediseñar Login/Register** (Prioridad Media)
342
- 1. ✅ Mejorar layout con VerticalOptions="Center"
343
- 2. ✅ Agregar validación de campos
344
- 3. ✅ Agregar Behaviors para validación en tiempo real
345
- 4. ✅ Agregar confirmación de password en Register
346
- 5. ✅ Mejorar manejo de errores visual
347
-
348
- ### Fase 6: **Limpieza** (Prioridad Baja)
349
- 1. ✅ Eliminar `TrackSeparationView.xaml`
350
- 2. ✅ Eliminar `SeparacionPage.xaml`
351
- 3. ✅ Consolidar estilos en `Styles.xaml`
352
- 4. ✅ Agregar recursos de colores al `Colors.xaml`
353
- 5. ✅ Documentar arquitectura
354
-
355
- ---
356
-
357
- ## 🎯 Arquitectura Propuesta
358
-
359
- ```
360
- Melodix/
361
- ├── Views/
362
- │ ├── MainDesktopPage.xaml # Shell con FlyoutItem/TabBar
363
- │ ├── SeparacionView.xaml # ContentView (embed en MainDesktop)
364
- │ ├── HistoryView.xaml # ContentView (embed en MainDesktop)
365
- │ ├── LoginPage.xaml # ContentPage (Shell route)
366
- │ ├── RegisterPage.xaml # ContentPage (Shell route)
367
- │ └── StudioPage.xaml # ContentPage (Shell route) - OK
368
- ├── ViewModels/
369
- │ ├── MainDesktopViewModel.cs # Navegación y estado global
370
- │ ├── SeparacionViewModel.cs # Lógica de separación
371
- │ ├── HistoryViewModel.cs # Lógica de historial
372
- │ ├── LoginViewModel.cs
373
- │ └── RegisterViewModel.cs
374
- ├── Models/
375
- │ └── ...
376
- ├── Converters/
377
- │ ├── StatusToColorConverter.cs
378
- │ └── ...
379
- ├── Behaviors/
380
- │ ├── EmailValidationBehavior.cs
381
- │ └── ...
382
- └── Resources/Styles/
383
- ├── Colors.xaml # Colores de la app
384
- └── Styles.xaml # Estilos reutilizables
385
- ```
386
-
387
- ### Navegación Propuesta
388
- ```
389
- MainDesktopPage (Shell Content)
390
- ├── FlyoutItems o TabBar
391
- │ ├── SeparacionView (default)
392
- │ ├── HistoryView
393
- │ ├── LetrasView (future)
394
- │ └── ConfigView (future)
395
- │
396
- Shell Routes (navegación modal)
397
- ├── LoginPage
398
- ├── RegisterPage
399
- └── StudioPage
400
- ```
401
-
402
- ---
403
-
404
- ## 📝 Notas Finales
405
-
406
- ### Lo que FUNCIONA (StudioPage)
407
- - ✅ Usa ViewModel correctamente
408
- - ✅ Commands con CommunityToolkit
409
- - ✅ DataBinding limpio
410
- - ✅ Recursos y estilos definidos
411
- - ✅ Navegación con Shell
412
-
413
- ### Lo que NO funciona (todo lo demás)
414
- - ❌ Views con lógica de negocio
415
- - ❌ ViewModels duplicados
416
- - ❌ Navegación manual
417
- - ❌ Colores hardcodeados
418
- - ❌ Sin estados de carga
419
- - ❌ Polling sin control
420
-
421
- ### Recomendación Final
422
- **Rediseñar completamente** las vistas principales siguiendo las mejores prácticas de .NET MAUI 9, usando:
423
- 1. **CommunityToolkit.Mvvm** para ViewModels
424
- 2. **Shell Navigation** para routing
425
- 3. **DataBinding** para UI
426
- 4. **Converters** para transformaciones
427
- 5. **Behaviors** para validación
428
- 6. **Resources** para estilos y colores
429
- 7. **OnIdiom** para responsividad
430
-
431
- ---
432
-
433
- **Generado:** sábado, 4 de abril de 2026
434
- **Versión:** 1.0
435
- **Estado:** Listo para rediseño
 
1
+ # Melodix MAUI - Análisis Completo de Vistas
2
+
3
+ ## 📋 Índice
4
+ 1. [Arquitectura General](#arquitectura-general)
5
+ 2. [Análisis de Vistas Existentes](#análisis-de-vistas-existentes)
6
+ 3. [Problemas Identificados](#problemas-identificados)
7
+ 4. [Buenas Prácticas .NET MAUI 9](#buenas-prácticas-net-maui-9)
8
+ 5. [Plan de Rediseño](#plan-de-rediseño)
9
+
10
+ ---
11
+
12
+ ## 🏗️ Arquitectura General
13
+
14
+ ### Estructura Actual del Proyecto
15
+ ```
16
+ Melodix/
17
+ ├── Views/
18
+ │ ├── MainDesktopPage.xaml # Vista principal (Shell)
19
+ │ ├── SeparacionView.xaml # Vista de separación de pistas
20
+ │ ├── HistoryView.xaml # Historial de pistas
21
+ │ ├── TrackSeparationView.xaml # Vista alternativa de separación
22
+ │ ├── SeparacionPage.xaml # Vista legacy (no usada)
23
+ │ ├── LoginPage.xaml # Login
24
+ │ ├── RegisterPage.xaml # Registro
25
+ │ └── StudioPage.xaml # Estudio DAW (FUNCIONA BIEN - IGNORAR)
26
+ ├── ViewModels/
27
+ │ ├── MainDesktopViewModel.cs
28
+ │ ├── SeparacionViewModel.cs # Hay 2 versiones (VM y code-behind)
29
+ │ ├── LoginViewModel.cs
30
+ │ └── RegisterViewModel.cs
31
+ ├── Models/
32
+ │ ├── TrackModel.cs
33
+ │ └── ...
34
+ └── Resources/Styles/
35
+ ├── Colors.xaml
36
+ └── Styles.xaml
37
+ ```
38
+
39
+ ### Patrón de Navegación
40
+ - **Shell** con `MainDesktopPage` como contenido principal
41
+ - Navegación por rutas registradas (`Routing.RegisterRoute`)
42
+ - `MainDesktopPage` usa un sistema de **ContentView switching** manual (no navegación Shell)
43
+
44
+ ---
45
+
46
+ ## 🔍 Análisis de Vistas Existentes
47
+
48
+ ### 1. **MainDesktopPage.xaml** ⚠️ PROBLEMAS CRÍTICOS
49
+
50
+ **Estructura:**
51
+ - Grid con sidebar izquierdo (260px) + área de contenido
52
+ - Sidebar con navegación manual (TapGestureHandlers)
53
+ - ContentView que cambia dinámicamente
54
+
55
+ **Problemas:**
56
+ 1. ❌ **Navegación manual implementada**: Usa `PropertyChanged` subscription en code-behind para cambiar `ContentView.Content`
57
+ 2. ❌ **No usa MVVM correctamente**: Los TapGestureHandlers llaman métodos en code-behind en lugar de Commands
58
+ 3. ❌ **Hardcoded menu items**: No es data-driven (debería usar BindableLayout con colección de menú)
59
+ 4. ❌ **Falta consistencia visual**: Colores hardcodeados (`#0E0E10`, `#8A2BE2`) en lugar de usar recursos
60
+ 5. ❌ **OnHistorialTapped muestra alerta** en lugar de navegar a HistoryView
61
+ 6. ❌ **No hay feedback visual** de qué item del menú está activo (excepto BoxView de 4px)
62
+ 7. ⚠️ **Grid layout rígido**: No usa `OnIdiom` para responsividad
63
+
64
+ **Code-behind issues:**
65
+ ```csharp
66
+ // ❌ MAL: Suscripción manual a PropertyChanged
67
+ _viewModel.PropertyChanged += (s, e) => {
68
+ if (e.PropertyName == nameof(MainDesktopViewModel.CurrentView)) {
69
+ MainContentView.Content = _viewModel.CurrentView;
70
+ }
71
+ };
72
+ ```
73
+
74
+ ---
75
+
76
+ ### 2. **SeparacionView.xaml** ⚠️ PROBLEMAS GRAVES
77
+
78
+ **Estructura:**
79
+ - ContentView con Grid (Header + Search + CollectionView)
80
+ - Lista de tracks con Border personalizado
81
+ - Sistema de polling para tracks en procesamiento
82
+
83
+ **Problemas:**
84
+ 1. ❌ **ViewModel duplicado**: Hay `SeparacionViewModel` en `ViewModels/` Y en el code-behind de `SeparacionView.xaml.cs`
85
+ 2. ❌ **Lógica de negocio en View**: El code-behind tiene métodos `AddTrackToUI`, `UpdateTrackInUI` que manipulan directamente el ViewModel
86
+ 3. ❌ **Polling implementado incorrectamente**: `Device.StartTimer` en el ViewModel sin cancelación
87
+ 4. ❌ **Converter no definido**: Usa `StatusToColorConverter` pero no está declarado en Resources
88
+ 5. ❌ **Mixed concerns**: El ViewModel llama a `_view.AddTrackToUI()` rompiendo el patrón MVVM
89
+ 6. ⚠️ **CollectionView SelectionMode="None"**: Usa TapGestureRecognizer en lugar de SelectionChanged
90
+ 7. ⚠️ **SearchEntry no bindeado correctamente**: Usa evento `TextChanged` en lugar de Command
91
+ 8. ❌ **Falta EmptyView para estado de carga**: No hay indicador mientras carga tracks
92
+
93
+ **XAML issues:**
94
+ ```xml
95
+ <!-- ❌ MAL: Converter referenciado pero no definido -->
96
+ <BoxView.BackgroundColor>
97
+ <MultiBinding Converter="{StaticResource StatusToColorConverter}">
98
+ <Binding Path="Status" />
99
+ </MultiBinding>
100
+ </BoxView.BackgroundColor>
101
+ ```
102
+
103
+ ---
104
+
105
+ ### 3. **HistoryView.xaml** ⚠️ PROBLEMAS MODERADOS
106
+
107
+ **Estructura:**
108
+ - ContentView simple con CollectionView
109
+ - Lista de tracks completados
110
+
111
+ **Problemas:**
112
+ 1. ❌ **No usa ViewModel separado**: Toda la lógica está en code-behind
113
+ 2. ❌ **ObservableCollection en View**: Debería estar en ViewModel
114
+ 3. ❌ **Navegación directa a StudioPage**: No pasa por MainDesktopPage
115
+ 4. ⚠️ **Duplicación de código**: Similar a SeparacionView pero implementado diferente
116
+ 5. ⚠️ **Falta funcionalidad de búsqueda**: No tiene search bar
117
+ 6. ⚠️ **No hay ordenamiento**: Debería permitir ordenar por fecha, nombre, etc.
118
+
119
+ ---
120
+
121
+ ### 4. **TrackSeparationView.xaml** ❌ OBSOLETO
122
+
123
+ **Problemas:**
124
+ 1. ❌ **Vista duplicada**: Funcionalidad similar a SeparacionView
125
+ 2. ❌ **Sin ViewModel**: Toda la lógica en code-behind
126
+ 3. ❌ **No se usa actualmente**: Parece ser una versión antigua
127
+ 4. ❌ **UI muy básica**: Solo tiene botón de subir sin lista de tracks
128
+
129
+ **Recomendación:** ELIMINAR esta vista
130
+
131
+ ---
132
+
133
+ ### 5. **SeparacionPage.xaml** ❌ LEGACY
134
+
135
+ **Problemas:**
136
+ 1. ❌ **ContentPage en lugar de ContentView**: No está diseñada para embedder en MainDesktopPage
137
+ 2. ❌ **UI anticuada**: Usa Frames en lugar de Borders, colores claros
138
+ 3. ❌ **No se usa**: Registrada en AppShell pero no navegada
139
+ 4. ❌ **ViewModel diferente**: Usa inyección de dependencias vs creación manual
140
+
141
+ **Recomendación:** ELIMINAR esta vista
142
+
143
+ ---
144
+
145
+ ### 6. **LoginPage.xaml** ⚠️ PROBLEMAS MENORES
146
+
147
+ **Problemas:**
148
+ 1. ⚠️ **Grid innecesario**: Usa `RowDefinitions="*,Auto,*"` para centrar, mejor usar `VerticalOptions="Center"`
149
+ 2. ⚠️ **Falta validación**: No hay indicadores visuales de validación de campos
150
+ 3. ⚠️ **Sin manejo de errores visual**: Solo muestra texto rojo
151
+ 4. ⚠️ **No usa Entry Behaviors**: Para validación en tiempo real
152
+
153
+ ---
154
+
155
+ ### 7. **RegisterPage.xaml** ⚠️ PROBLEMAS MENORES
156
+
157
+ **Problemas:**
158
+ 1. ⚠️ **Similar a Login**: Mismos problemas de estructura
159
+ 2. ⚠️ **Falta confirmación de contraseña**: No hay campo para confirmar password
160
+ 3. ⚠️ **Sin validación de email**: No valida formato de email
161
+
162
+ ---
163
+
164
+ ## 🚨 Problemas Identificados (Resumen)
165
+
166
+ ### Críticos 🔴
167
+ 1. **ViewModel duplicado**: `SeparacionViewModel` existe en 2 lugares diferentes
168
+ 2. **Navegación manual**: MainDesktopPage no usa el sistema de navegación de Shell
169
+ 3. **MVVM roto**: Views llaman métodos directamente en lugar de usar Commands
170
+ 4. **Converter no definido**: `StatusToColorConverter` falta en Resources
171
+ 5. **Polling sin cancelación**: Timer sigue corriendo indefinidamente
172
+
173
+ ### Graves 🟠
174
+ 6. **Lógica de negocio en Views**: Code-behind hace trabajo de ViewModel
175
+ 7. **Colores hardcodeados**: No usa el sistema de recursos de MAUI
176
+ 8. **No hay estados de carga**: Falta ActivityIndicator mientras cargan datos
177
+ 9. **Mixed navigation patterns**: Algunas vistas usan Shell, otras ContentView switching
178
+ 10. **Vistas duplicadas**: TrackSeparationView y SeparacionPage no se usan
179
+
180
+ ### Moderados 🟡
181
+ 11. **Falta responsividad**: No usa `OnIdiom` consistentemente
182
+ 12. **Sin validación de formularios**: Login/Register sin validación visual
183
+ 13. **ObservableCollection en Views**: Debería estar en ViewModels
184
+ 14. **Falta EmptyView states**: No hay indicadores de carga o error
185
+ 15. **Search con eventos**: Usa TextChanged en lugar de Command con debounce
186
+
187
+ ### Menores 🟢
188
+ 16. **Grids innecesarios**: Layouts podrían simplificarse
189
+ 17. **Falta confirmación de password**: En registro
190
+ 18. **Sin ordenamiento**: HistoryView no permite ordenar
191
+ 19. **Falta Pull-to-refresh**: En CollectionViews
192
+
193
+ ---
194
+
195
+ ## ✅ Buenas Prácticas .NET MAUI 9
196
+
197
+ ### 1. **Arquitectura MVVM Correcta**
198
+ ```csharp
199
+ // ✅ BIEN: ViewModel con Commands
200
+ public partial class MainViewModel : ObservableObject
201
+ {
202
+ [RelayCommand]
203
+ private async Task NavigateAsync(string route) { ... }
204
+ }
205
+
206
+ // ✅ BIEN: View con Binding
207
+ <Button Command="{Binding NavigateCommand}" CommandParameter="Separacion" />
208
+ ```
209
+
210
+ ### 2. **Navegación con Shell**
211
+ ```csharp
212
+ // ✅ BIEN: Usar Shell para navegación
213
+ await Shell.Current.GoToAsync(nameof(HistoryView));
214
+
215
+ // ❌ MAL: ContentView switching manual
216
+ MainContentView.Content = new HistoryView();
217
+ ```
218
+
219
+ ### 3. **Recursos y Estilos**
220
+ ```xml
221
+ <!-- ✅ BIEN: Usar recursos -->
222
+ <Label TextColor="{StaticResource PrimaryColor}" />
223
+
224
+ <!-- ❌ MAL: Hardcoded -->
225
+ <Label TextColor="#8A2BE2" />
226
+ ```
227
+
228
+ ### 4. **CollectionView con Selection**
229
+ ```xml
230
+ <!-- ✅ BIEN: Usar SelectionChanged -->
231
+ <CollectionView SelectionMode="Single"
232
+ SelectionChanged="OnTrackSelected"
233
+ SelectedItem="{Binding SelectedTrack}" />
234
+
235
+ <!-- ❌ MAL: TapGestureRecognizer en cada item -->
236
+ <Border.GestureRecognizers>
237
+ <TapGestureRecognizer Command="{Binding TapCommand}" />
238
+ </Border.GestureRecognizers>
239
+ ```
240
+
241
+ ### 5. **ViewModel con CommunityToolkit**
242
+ ```csharp
243
+ // ✅ BIEN: Usar CommunityToolkit.Mvvm
244
+ public partial class MyViewModel : ObservableObject
245
+ {
246
+ [ObservableProperty]
247
+ private string _status;
248
+
249
+ [RelayCommand]
250
+ private async Task DoSomethingAsync() { ... }
251
+ }
252
+ ```
253
+
254
+ ### 6. **Converters en Resources**
255
+ ```xml
256
+ <Application.Resources>
257
+ <ResourceDictionary>
258
+ <local:StatusToColorConverter x:Key="StatusToColorConverter" />
259
+ </ResourceDictionary>
260
+ </Application.Resources>
261
+ ```
262
+
263
+ ### 7. **Estados de Carga y EmptyView**
264
+ ```xml
265
+ <CollectionView>
266
+ <CollectionView.EmptyView>
267
+ <VerticalStackLayout>
268
+ <ActivityIndicator IsRunning="{Binding IsLoading}" />
269
+ <Label Text="Cargando..." IsVisible="{Binding IsLoading}" />
270
+ <Label Text="No hay datos" IsVisible="{Binding IsEmpty}" />
271
+ </VerticalStackLayout>
272
+ </CollectionView.EmptyView>
273
+ </CollectionView>
274
+ ```
275
+
276
+ ### 8. **Polling con Cancelación**
277
+ ```csharp
278
+ // ✅ BIEN: CancellationTokenSource
279
+ private CancellationTokenSource _pollingCts;
280
+
281
+ private async Task StartPollingAsync()
282
+ {
283
+ _pollingCts = new CancellationTokenSource();
284
+ while (!_pollingCts.IsCancellationRequested)
285
+ {
286
+ await Task.Delay(5000, _pollingCts.Token);
287
+ await CheckStatusAsync();
288
+ }
289
+ }
290
+
291
+ public void StopPolling() => _pollingCts?.Cancel();
292
+ ```
293
+
294
+ ### 9. **Responsive con OnIdiom**
295
+ ```xml
296
+ <Grid ColumnSpacing="{OnIdiom Default='20', Phone='10', Tablet='30'}" />
297
+ <Button FontSize="{OnIdiom Default='14', Phone='12'}" />
298
+ ```
299
+
300
+ ### 10. **Validación con Behaviors**
301
+ ```xml
302
+ <Entry Text="{Binding Email}">
303
+ <Entry.Behaviors>
304
+ <local:EmailValidationBehavior />
305
+ </Entry.Behaviors>
306
+ </Entry>
307
+ ```
308
+
309
+ ---
310
+
311
+ ## 📐 Plan de Rediseño
312
+
313
+ ### Fase 1: **Corregir Problemas Críticos** (Prioridad Alta)
314
+ 1. ✅ Eliminar `SeparacionViewModel` duplicado (code-behind)
315
+ 2. ✅ Implementar navegación Shell correcta en MainDesktopPage
316
+ 3. ✅ Crear `StatusToColorConverter` y agregarlo a Resources
317
+ 4. ✅ Agregar CancellationToken al polling
318
+ 5. ✅ Mover lógica de Views a ViewModels
319
+
320
+ ### Fase 2: **Rediseñar MainDesktopPage** (Prioridad Alta)
321
+ 1. ✅ Convertir menú a BindableLayout data-driven
322
+ 2. ✅ Usar Commands en lugar de TapGestureHandlers
323
+ 3. ✅ Agregar feedback visual de item activo
324
+ 4. ✅ Implementar navegación Shell para HistoryView
325
+ 5. ✅ Usar recursos de colores en lugar de hardcoded
326
+
327
+ ### Fase 3: **Rediseñar SeparacionView** (Prioridad Alta)
328
+ 1. ✅ Unificar con ViewModel de `ViewModels/SeparacionViewModel.cs`
329
+ 2. ✅ Implementar polling con cancelación
330
+ 3. ✅ Agregar estados de carga (ActivityIndicator)
331
+ 4. ✅ Mejorar búsqueda con Command + debounce
332
+ 5. ✅ Usar SelectionChanged en lugar de TapGestureRecognizer
333
+
334
+ ### Fase 4: **Rediseñar HistoryView** (Prioridad Media)
335
+ 1. ✅ Crear HistoryViewModel separado
336
+ 2. ✅ Agregar funcionalidad de búsqueda
337
+ 3. ✅ Agregar ordenamiento (fecha, nombre, artista)
338
+ 4. ✅ Agregar Pull-to-refresh
339
+ 5. ✅ Unificar estilo con SeparacionView
340
+
341
+ ### Fase 5: **Rediseñar Login/Register** (Prioridad Media)
342
+ 1. ✅ Mejorar layout con VerticalOptions="Center"
343
+ 2. ✅ Agregar validación de campos
344
+ 3. ✅ Agregar Behaviors para validación en tiempo real
345
+ 4. ✅ Agregar confirmación de password en Register
346
+ 5. ✅ Mejorar manejo de errores visual
347
+
348
+ ### Fase 6: **Limpieza** (Prioridad Baja)
349
+ 1. ✅ Eliminar `TrackSeparationView.xaml`
350
+ 2. ✅ Eliminar `SeparacionPage.xaml`
351
+ 3. ✅ Consolidar estilos en `Styles.xaml`
352
+ 4. ✅ Agregar recursos de colores al `Colors.xaml`
353
+ 5. ✅ Documentar arquitectura
354
+
355
+ ---
356
+
357
+ ## 🎯 Arquitectura Propuesta
358
+
359
+ ```
360
+ Melodix/
361
+ ├── Views/
362
+ │ ├── MainDesktopPage.xaml # Shell con FlyoutItem/TabBar
363
+ │ ├── SeparacionView.xaml # ContentView (embed en MainDesktop)
364
+ │ ├── HistoryView.xaml # ContentView (embed en MainDesktop)
365
+ │ ├── LoginPage.xaml # ContentPage (Shell route)
366
+ │ ├── RegisterPage.xaml # ContentPage (Shell route)
367
+ │ └── StudioPage.xaml # ContentPage (Shell route) - OK
368
+ ├── ViewModels/
369
+ │ ├── MainDesktopViewModel.cs # Navegación y estado global
370
+ │ ├── SeparacionViewModel.cs # Lógica de separación
371
+ │ ├── HistoryViewModel.cs # Lógica de historial
372
+ │ ├── LoginViewModel.cs
373
+ │ └── RegisterViewModel.cs
374
+ ├── Models/
375
+ │ └── ...
376
+ ├── Converters/
377
+ │ ├── StatusToColorConverter.cs
378
+ │ └── ...
379
+ ├── Behaviors/
380
+ │ ├── EmailValidationBehavior.cs
381
+ │ └── ...
382
+ └── Resources/Styles/
383
+ ├── Colors.xaml # Colores de la app
384
+ └── Styles.xaml # Estilos reutilizables
385
+ ```
386
+
387
+ ### Navegación Propuesta
388
+ ```
389
+ MainDesktopPage (Shell Content)
390
+ ├── FlyoutItems o TabBar
391
+ │ ├── SeparacionView (default)
392
+ │ ├── HistoryView
393
+ │ ├── LetrasView (future)
394
+ │ └── ConfigView (future)
395
+ │
396
+ Shell Routes (navegación modal)
397
+ ├── LoginPage
398
+ ├── RegisterPage
399
+ └── StudioPage
400
+ ```
401
+
402
+ ---
403
+
404
+ ## 📝 Notas Finales
405
+
406
+ ### Lo que FUNCIONA (StudioPage)
407
+ - ✅ Usa ViewModel correctamente
408
+ - ✅ Commands con CommunityToolkit
409
+ - ✅ DataBinding limpio
410
+ - ✅ Recursos y estilos definidos
411
+ - ✅ Navegación con Shell
412
+
413
+ ### Lo que NO funciona (todo lo demás)
414
+ - ❌ Views con lógica de negocio
415
+ - ❌ ViewModels duplicados
416
+ - ❌ Navegación manual
417
+ - ❌ Colores hardcodeados
418
+ - ❌ Sin estados de carga
419
+ - ❌ Polling sin control
420
+
421
+ ### Recomendación Final
422
+ **Rediseñar completamente** las vistas principales siguiendo las mejores prácticas de .NET MAUI 9, usando:
423
+ 1. **CommunityToolkit.Mvvm** para ViewModels
424
+ 2. **Shell Navigation** para routing
425
+ 3. **DataBinding** para UI
426
+ 4. **Converters** para transformaciones
427
+ 5. **Behaviors** para validación
428
+ 6. **Resources** para estilos y colores
429
+ 7. **OnIdiom** para responsividad
430
+
431
+ ---
432
+
433
+ **Generado:** sábado, 4 de abril de 2026
434
+ **Versión:** 1.0
435
+ **Estado:** Listo para rediseño
CAMBIOS_REALIZADOS.md CHANGED
@@ -1,287 +1,287 @@
1
- # 📋 Cambios Realizados - Mejoras de Melodix v2.0
2
-
3
- ## ✅ Resumen de Mejoras Implementadas
4
-
5
- ### 1. 🔐 Seguridad
6
-
7
- | Archivo | Cambio |
8
- |---------|--------|
9
- | `start_tunnel.py` | Token de GitHub ahora usa variables de entorno |
10
- | `.env` / `.env.example` | Secrets movidos a variables de entorno |
11
- | `app/routers/auth.py` | Logging de intentos de login, validación mejorada |
12
- | `app/routers/songs.py` | Autenticación real requerida en `/upload` |
13
-
14
- ### 2. 🗄️ Base de Datos
15
-
16
- | Archivo | Cambio |
17
- |---------|--------|
18
- | `app/database.py` | Soporte PostgreSQL + SQLite fallback |
19
- | `.env` | Variable `DATABASE_URL` configurable |
20
-
21
- ### 3. 📁 Validación de Archivos
22
-
23
- | Archivo | Cambio |
24
- |---------|--------|
25
- | `app/routers/songs.py` | Validación de extensión, MIME type, tamaño máximo |
26
- | `.env` | Configuración `MAX_FILE_SIZE_MB` y `ALLOWED_EXTENSIONS` |
27
-
28
- ### 4. 📝 Logging
29
-
30
- | Archivo | Cambio |
31
- |---------|--------|
32
- | `main.py` | Logging rotativo configurado (10MB, 5 backups) |
33
- | `tasks.py` | Logs detallados con task_id para tracking |
34
- | `app/routers/*.py` | Logging de todas las operaciones importantes |
35
- | `logs/` | Directorio creado para logs |
36
-
37
- ### 5. 🧹 Limpieza Automática
38
-
39
- | Archivo | Cambio |
40
- |---------|--------|
41
- | `cleanup.py` | Script nuevo para limpieza programada |
42
- | `tasks.py` | Limpieza automática de archivos temporales post-procesamiento |
43
- | `app/routers/songs.py` | Endpoint `/cleanup/{task_id}` para limpieza manual |
44
-
45
- ### 6. 🖥️ Manejo de Errores
46
-
47
- | Archivo | Cambio |
48
- |---------|--------|
49
- | `tasks.py` | Fallback automático CPU si GPU falla |
50
- | `tasks.py` | Reintento automático de tareas fallidas (max 1 retry) |
51
- | `tasks.py` | Manejo explícito de errores de Demucs |
52
-
53
- ### 7. 🌐 CORS Mejorado
54
-
55
- | Archivo | Cambio |
56
- |---------|--------|
57
- | `main.py` | CORS configurable desde `.env` |
58
- | `.env` | `ALLOWED_ORIGINS` para controlar accesos |
59
-
60
- ### 8. 📱 Conexión Móvil
61
-
62
- | Archivo | Cambio |
63
- |---------|--------|
64
- | `.env` | IP local configurada (192.168.1.10) |
65
- | `iniciar_melodix.bat` | Muestra IP local automáticamente |
66
-
67
- ### 9. 📚 Documentación
68
-
69
- | Archivo | Cambio |
70
- |---------|--------|
71
- | `README.md` | Documentación completa creada |
72
- | `requirements.txt` | Dependencias listadas explícitamente |
73
- | `.gitignore` | Archivos ignorados configurados |
74
-
75
- ### 10. 🧪 Testing
76
-
77
- | Archivo | Cambio |
78
- |---------|--------|
79
- | `tests/integration_test.py` | Test completo actualizado con autenticación |
80
- | `check_requirements.py` | Verificador de prerrequisitos creado |
81
-
82
- ---
83
-
84
- ## 📂 Archivos Nuevos Creados
85
-
86
- ```
87
- c:\mfmm\
88
- ├── .env # Variables de entorno (activas)
89
- ├── .env.example # Plantilla de variables
90
- ├── .gitignore # Archivos ignorados por Git
91
- ├── requirements.txt # Dependencias Python
92
- ├── README.md # Documentación completa
93
- ├── cleanup.py # Limpieza automática programada
94
- ├── check_requirements.py # Verificador de prerrequisitos
95
- └── logs/ # Directorio de logs (nuevo)
96
- ```
97
-
98
- ---
99
-
100
- ## 🔄 Archivos Modificados
101
-
102
- ```
103
- c:\mfmm\
104
- ├── main.py # + Logging, + CORS configurable, + Health endpoint
105
- ├── tasks.py # + Fallback CPU, + Logging, + Limpieza, + Retry
106
- ├── start_tunnel.py # + Variables de entorno
107
- ├── iniciar_melodix.bat # + Verificaciones, + IP local
108
- ├── app/
109
- │ ├── database.py # + PostgreSQL support
110
- │ ├── routers/
111
- │ │ ├── auth.py # + Logging, + Device management
112
- │ │ └── songs.py # + Auth real, + Validaciones, + Endpoints nuevos
113
- │ └── schemas/ # (sin cambios)
114
- └── tests/
115
- └── integration_test.py # + Autenticación completa
116
- ```
117
-
118
- ---
119
-
120
- ## 🚀 Cómo Iniciar el Sistema
121
-
122
- ### Opción Rápida (Recomendada)
123
-
124
- ```bash
125
- # 1. Verificar prerrequisitos
126
- python check_requirements.py
127
-
128
- # 2. Iniciar todo el ecosistema
129
- iniciar_melodix.bat
130
- ```
131
-
132
- ### Opción Manual
133
-
134
- ```bash
135
- # Terminal 1 - API
136
- venv_musical\Scripts\activate
137
- uvicorn main:app --host 0.0.0.0 --port 8000 --reload
138
-
139
- # Terminal 2 - Worker Celery
140
- venv_musical\Scripts\activate
141
- celery -A tasks worker --loglevel=info -P solo
142
-
143
- # Terminal 3 - Túnel Cloudflare
144
- venv_musical\Scripts\activate
145
- python start_tunnel.py
146
- ```
147
-
148
- ---
149
-
150
- ## 📱 Conectar desde tu Móvil
151
-
152
- ### Usando la Red Local
153
-
154
- 1. **Asegúrate que tu IP está en el .env:**
155
- ```
156
- ALLOWED_ORIGINS=http://localhost:8000,http://192.168.1.10:8000
157
- ```
158
-
159
- 2. **En tu app móvil, usa:**
160
- ```
161
- http://192.168.1.10:8000
162
- ```
163
-
164
- 3. **El firewall de Windows puede pedir permiso** - permite el acceso
165
-
166
- ### Usando Cloudflare Tunnel (Recomendado para acceso remoto)
167
-
168
- 1. Inicia el túnel con `iniciar_melodix.bat`
169
- 2. Revisa `tunnel_url.txt` para la URL pública
170
- 3. Usa esa URL desde cualquier lugar del mundo
171
-
172
- ---
173
-
174
- ## �� Mantenimiento Recomendado
175
-
176
- ### Diario (Automático)
177
-
178
- Programa el Task Scheduler de Windows para ejecutar:
179
-
180
- ```bash
181
- python C:\mfmm\cleanup.py --days 7
182
- ```
183
-
184
- ### Semanal (Manual)
185
-
186
- ```bash
187
- # Ver logs de errores
188
- notepad logs\melodix.log
189
-
190
- # Ver estadísticas de uso
191
- python cleanup.py --stats
192
- ```
193
-
194
- ### Mensual
195
-
196
- ```bash
197
- # Limpieza profunda de archivos > 30 días
198
- python cleanup.py --days 30
199
- ```
200
-
201
- ---
202
-
203
- ## ⚠️ Consideraciones Importantes
204
-
205
- ### Seguridad del Token de GitHub
206
-
207
- El token de GitHub está en `.env` pero **NO está encriptado**. Esto es intencional porque:
208
-
209
- - No hay hosting pago disponible
210
- - El túnel Cloudflare requiere sincronización automática
211
- - Es un entorno de desarrollo/testing
212
-
213
- **Recomendación:** Si algún día despliegas en producción real, rota el token inmediatamente.
214
-
215
- ### Base de Datos SQLite
216
-
217
- SQLite está bien para:
218
- - ✅ Desarrollo local
219
- - ✅ Testing
220
- - ✅ Uso personal (1-5 usuarios)
221
-
222
- **Migra a PostgreSQL cuando:**
223
- - Tengas múltiples usuarios concurrentes
224
- - Necesites mayor robustez
225
- - El archivo `.db` supere 100MB
226
-
227
- Para migrar:
228
- ```env
229
- # En .env
230
- DATABASE_URL=postgresql://usuario:password@localhost:5432/melodix_db
231
- ```
232
-
233
- ### GPU vs CPU
234
-
235
- Tu GPU (GTX 1650 4GB) es suficiente para Demucs. El sistema:
236
- - Usa GPU por defecto (`DEFAULT_DEVICE=cuda`)
237
- - Hace fallback automático a CPU si hay error
238
- - Procesa ~2-3x más rápido con GPU
239
-
240
- ---
241
-
242
- ## 🎯 Próximos Pasos Sugeridos
243
-
244
- 1. **Probar la API:**
245
- ```bash
246
- python tests/integration_test.py
247
- ```
248
-
249
- 2. **Conectar la App .NET MAUI:**
250
- - Actualiza la URL base en Melodix/ para usar `http://192.168.1.10:8000`
251
- - O usa la URL del túnel Cloudflare
252
-
253
- 3. **Monitorear el primer procesamiento:**
254
- - Revisa `logs/melodix.log` para ver el progreso
255
- - Verifica que los archivos se limpian después de procesar
256
-
257
- 4. **Configurar limpieza automática:**
258
- - Abre el Programador de Tareas de Windows
259
- - Crea una tarea diaria para `cleanup.py`
260
-
261
- ---
262
-
263
- ## 📞 Soporte
264
-
265
- Si encuentras errores:
266
-
267
- 1. Revisa los logs: `logs/melodix.log`
268
- 2. Ejecuta el verificador: `python check_requirements.py`
269
- 3. Consulta el README: `notepad README.md`
270
-
271
- ---
272
-
273
- **¡Todo está listo para usar Melodix v2.0! 🎵**
274
-
275
- ---
276
-
277
- ## 📱 Sincronización y Caché Offline en Melodix Mobile (v2.1)
278
-
279
- | Archivo | Cambio |
280
- |---------|--------|
281
- | `MelodixMobile/src/services/api.js` | Implementación de fallback en caché offline (búsqueda por prefijo si la clave exacta de parámetros falla) y soporte de peticiones silenciadas para evitar alertas molestas de conexión. |
282
- | `MelodixMobile/src/screens/iglesia/FinanzasScreen.js` | Carga de caché usando los parámetros dinámicos de fechas del mes y actualizaciones silenciosas. |
283
- | `MelodixMobile/src/screens/iglesia/InventarioScreen.js` | Carga de caché admitiendo listas vacías sin bloquear la interfaz y refresco automático silenciado. |
284
- | `MelodixMobile/src/screens/iglesia/MomentosScreen.js` | Soporte de caché en momentos y miembros sin validaciones restrictivas de longitud, y refresco silencioso. |
285
- | `MelodixMobile/src/components/OfflineSyncBanner.js` | Registro global del callback de conflictos de asistencia offline para alertar en cualquier pantalla. |
286
- | `MelodixMobile/src/screens/iglesia/TomarAsistenciaScreen.js` | Remoción del callback de conflicto local para evitar race conditions al desmontar. |
287
- | `MelodixMobile/src/navigation/AppNavigator.js` | Remoción del chequeo de torneos en el montaje inicial, evitando redirección no deseada al Quiz en Expo. |
 
1
+ # 📋 Cambios Realizados - Mejoras de Melodix v2.0
2
+
3
+ ## ✅ Resumen de Mejoras Implementadas
4
+
5
+ ### 1. 🔐 Seguridad
6
+
7
+ | Archivo | Cambio |
8
+ |---------|--------|
9
+ | `start_tunnel.py` | Token de GitHub ahora usa variables de entorno |
10
+ | `.env` / `.env.example` | Secrets movidos a variables de entorno |
11
+ | `app/routers/auth.py` | Logging de intentos de login, validación mejorada |
12
+ | `app/routers/songs.py` | Autenticación real requerida en `/upload` |
13
+
14
+ ### 2. 🗄️ Base de Datos
15
+
16
+ | Archivo | Cambio |
17
+ |---------|--------|
18
+ | `app/database.py` | Soporte PostgreSQL + SQLite fallback |
19
+ | `.env` | Variable `DATABASE_URL` configurable |
20
+
21
+ ### 3. 📁 Validación de Archivos
22
+
23
+ | Archivo | Cambio |
24
+ |---------|--------|
25
+ | `app/routers/songs.py` | Validación de extensión, MIME type, tamaño máximo |
26
+ | `.env` | Configuración `MAX_FILE_SIZE_MB` y `ALLOWED_EXTENSIONS` |
27
+
28
+ ### 4. 📝 Logging
29
+
30
+ | Archivo | Cambio |
31
+ |---------|--------|
32
+ | `main.py` | Logging rotativo configurado (10MB, 5 backups) |
33
+ | `tasks.py` | Logs detallados con task_id para tracking |
34
+ | `app/routers/*.py` | Logging de todas las operaciones importantes |
35
+ | `logs/` | Directorio creado para logs |
36
+
37
+ ### 5. 🧹 Limpieza Automática
38
+
39
+ | Archivo | Cambio |
40
+ |---------|--------|
41
+ | `cleanup.py` | Script nuevo para limpieza programada |
42
+ | `tasks.py` | Limpieza automática de archivos temporales post-procesamiento |
43
+ | `app/routers/songs.py` | Endpoint `/cleanup/{task_id}` para limpieza manual |
44
+
45
+ ### 6. 🖥️ Manejo de Errores
46
+
47
+ | Archivo | Cambio |
48
+ |---------|--------|
49
+ | `tasks.py` | Fallback automático CPU si GPU falla |
50
+ | `tasks.py` | Reintento automático de tareas fallidas (max 1 retry) |
51
+ | `tasks.py` | Manejo explícito de errores de Demucs |
52
+
53
+ ### 7. 🌐 CORS Mejorado
54
+
55
+ | Archivo | Cambio |
56
+ |---------|--------|
57
+ | `main.py` | CORS configurable desde `.env` |
58
+ | `.env` | `ALLOWED_ORIGINS` para controlar accesos |
59
+
60
+ ### 8. 📱 Conexión Móvil
61
+
62
+ | Archivo | Cambio |
63
+ |---------|--------|
64
+ | `.env` | IP local configurada (192.168.1.10) |
65
+ | `iniciar_melodix.bat` | Muestra IP local automáticamente |
66
+
67
+ ### 9. 📚 Documentación
68
+
69
+ | Archivo | Cambio |
70
+ |---------|--------|
71
+ | `README.md` | Documentación completa creada |
72
+ | `requirements.txt` | Dependencias listadas explícitamente |
73
+ | `.gitignore` | Archivos ignorados configurados |
74
+
75
+ ### 10. 🧪 Testing
76
+
77
+ | Archivo | Cambio |
78
+ |---------|--------|
79
+ | `tests/integration_test.py` | Test completo actualizado con autenticación |
80
+ | `check_requirements.py` | Verificador de prerrequisitos creado |
81
+
82
+ ---
83
+
84
+ ## 📂 Archivos Nuevos Creados
85
+
86
+ ```
87
+ c:\mfmm\
88
+ ├── .env # Variables de entorno (activas)
89
+ ├── .env.example # Plantilla de variables
90
+ ├── .gitignore # Archivos ignorados por Git
91
+ ├── requirements.txt # Dependencias Python
92
+ ├── README.md # Documentación completa
93
+ ├── cleanup.py # Limpieza automática programada
94
+ ├── check_requirements.py # Verificador de prerrequisitos
95
+ └── logs/ # Directorio de logs (nuevo)
96
+ ```
97
+
98
+ ---
99
+
100
+ ## 🔄 Archivos Modificados
101
+
102
+ ```
103
+ c:\mfmm\
104
+ ├── main.py # + Logging, + CORS configurable, + Health endpoint
105
+ ├── tasks.py # + Fallback CPU, + Logging, + Limpieza, + Retry
106
+ ├── start_tunnel.py # + Variables de entorno
107
+ ├── iniciar_melodix.bat # + Verificaciones, + IP local
108
+ ├── app/
109
+ │ ├── database.py # + PostgreSQL support
110
+ │ ├── routers/
111
+ │ │ ├── auth.py # + Logging, + Device management
112
+ │ │ └── songs.py # + Auth real, + Validaciones, + Endpoints nuevos
113
+ │ └── schemas/ # (sin cambios)
114
+ └── tests/
115
+ └── integration_test.py # + Autenticación completa
116
+ ```
117
+
118
+ ---
119
+
120
+ ## 🚀 Cómo Iniciar el Sistema
121
+
122
+ ### Opción Rápida (Recomendada)
123
+
124
+ ```bash
125
+ # 1. Verificar prerrequisitos
126
+ python check_requirements.py
127
+
128
+ # 2. Iniciar todo el ecosistema
129
+ iniciar_melodix.bat
130
+ ```
131
+
132
+ ### Opción Manual
133
+
134
+ ```bash
135
+ # Terminal 1 - API
136
+ venv_musical\Scripts\activate
137
+ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
138
+
139
+ # Terminal 2 - Worker Celery
140
+ venv_musical\Scripts\activate
141
+ celery -A tasks worker --loglevel=info -P solo
142
+
143
+ # Terminal 3 - Túnel Cloudflare
144
+ venv_musical\Scripts\activate
145
+ python start_tunnel.py
146
+ ```
147
+
148
+ ---
149
+
150
+ ## 📱 Conectar desde tu Móvil
151
+
152
+ ### Usando la Red Local
153
+
154
+ 1. **Asegúrate que tu IP está en el .env:**
155
+ ```
156
+ ALLOWED_ORIGINS=http://localhost:8000,http://192.168.1.10:8000
157
+ ```
158
+
159
+ 2. **En tu app móvil, usa:**
160
+ ```
161
+ http://192.168.1.10:8000
162
+ ```
163
+
164
+ 3. **El firewall de Windows puede pedir permiso** - permite el acceso
165
+
166
+ ### Usando Cloudflare Tunnel (Recomendado para acceso remoto)
167
+
168
+ 1. Inicia el túnel con `iniciar_melodix.bat`
169
+ 2. Revisa `tunnel_url.txt` para la URL pública
170
+ 3. Usa esa URL desde cualquier lugar del mundo
171
+
172
+ ---
173
+
174
+ ## 🧹 Mantenimiento Recomendado
175
+
176
+ ### Diario (Automático)
177
+
178
+ Programa el Task Scheduler de Windows para ejecutar:
179
+
180
+ ```bash
181
+ python C:\mfmm\cleanup.py --days 7
182
+ ```
183
+
184
+ ### Semanal (Manual)
185
+
186
+ ```bash
187
+ # Ver logs de errores
188
+ notepad logs\melodix.log
189
+
190
+ # Ver estadísticas de uso
191
+ python cleanup.py --stats
192
+ ```
193
+
194
+ ### Mensual
195
+
196
+ ```bash
197
+ # Limpieza profunda de archivos > 30 días
198
+ python cleanup.py --days 30
199
+ ```
200
+
201
+ ---
202
+
203
+ ## ⚠️ Consideraciones Importantes
204
+
205
+ ### Seguridad del Token de GitHub
206
+
207
+ El token de GitHub está en `.env` pero **NO está encriptado**. Esto es intencional porque:
208
+
209
+ - No hay hosting pago disponible
210
+ - El túnel Cloudflare requiere sincronización automática
211
+ - Es un entorno de desarrollo/testing
212
+
213
+ **Recomendación:** Si algún día despliegas en producción real, rota el token inmediatamente.
214
+
215
+ ### Base de Datos SQLite
216
+
217
+ SQLite está bien para:
218
+ - ✅ Desarrollo local
219
+ - ✅ Testing
220
+ - ✅ Uso personal (1-5 usuarios)
221
+
222
+ **Migra a PostgreSQL cuando:**
223
+ - Tengas múltiples usuarios concurrentes
224
+ - Necesites mayor robustez
225
+ - El archivo `.db` supere 100MB
226
+
227
+ Para migrar:
228
+ ```env
229
+ # En .env
230
+ DATABASE_URL=postgresql://usuario:password@localhost:5432/melodix_db
231
+ ```
232
+
233
+ ### GPU vs CPU
234
+
235
+ Tu GPU (GTX 1650 4GB) es suficiente para Demucs. El sistema:
236
+ - Usa GPU por defecto (`DEFAULT_DEVICE=cuda`)
237
+ - Hace fallback automático a CPU si hay error
238
+ - Procesa ~2-3x más rápido con GPU
239
+
240
+ ---
241
+
242
+ ## 🎯 Próximos Pasos Sugeridos
243
+
244
+ 1. **Probar la API:**
245
+ ```bash
246
+ python tests/integration_test.py
247
+ ```
248
+
249
+ 2. **Conectar la App .NET MAUI:**
250
+ - Actualiza la URL base en Melodix/ para usar `http://192.168.1.10:8000`
251
+ - O usa la URL del túnel Cloudflare
252
+
253
+ 3. **Monitorear el primer procesamiento:**
254
+ - Revisa `logs/melodix.log` para ver el progreso
255
+ - Verifica que los archivos se limpian después de procesar
256
+
257
+ 4. **Configurar limpieza automática:**
258
+ - Abre el Programador de Tareas de Windows
259
+ - Crea una tarea diaria para `cleanup.py`
260
+
261
+ ---
262
+
263
+ ## 📞 Soporte
264
+
265
+ Si encuentras errores:
266
+
267
+ 1. Revisa los logs: `logs/melodix.log`
268
+ 2. Ejecuta el verificador: `python check_requirements.py`
269
+ 3. Consulta el README: `notepad README.md`
270
+
271
+ ---
272
+
273
+ **¡Todo está listo para usar Melodix v2.0! 🎵**
274
+
275
+ ---
276
+
277
+ ## 📱 Sincronización y Caché Offline en Melodix Mobile (v2.1)
278
+
279
+ | Archivo | Cambio |
280
+ |---------|--------|
281
+ | `MelodixMobile/src/services/api.js` | Implementación de fallback en caché offline (búsqueda por prefijo si la clave exacta de parámetros falla) y soporte de peticiones silenciadas para evitar alertas molestas de conexión. |
282
+ | `MelodixMobile/src/screens/iglesia/FinanzasScreen.js` | Carga de caché usando los parámetros dinámicos de fechas del mes y actualizaciones silenciosas. |
283
+ | `MelodixMobile/src/screens/iglesia/InventarioScreen.js` | Carga de caché admitiendo listas vacías sin bloquear la interfaz y refresco automático silenciado. |
284
+ | `MelodixMobile/src/screens/iglesia/MomentosScreen.js` | Soporte de caché en momentos y miembros sin validaciones restrictivas de longitud, y refresco silencioso. |
285
+ | `MelodixMobile/src/components/OfflineSyncBanner.js` | Registro global del callback de conflictos de asistencia offline para alertar en cualquier pantalla. |
286
+ | `MelodixMobile/src/screens/iglesia/TomarAsistenciaScreen.js` | Remoción del callback de conflicto local para evitar race conditions al desmontar. |
287
+ | `MelodixMobile/src/navigation/AppNavigator.js` | Remoción del chequeo de torneos en el montaje inicial, evitando redirección no deseada al Quiz en Expo. |
INFORME_PRUEBAS.md CHANGED
@@ -1,487 +1,487 @@
1
- # 🎵 Melodix AI - Informe de Pruebas
2
-
3
- **Fecha:** 19 de marzo de 2026
4
- **Versión:** 2.0.0
5
- **Estado:** ✅ APROBADO PARA ANDROID
6
-
7
- ---
8
-
9
- ## 📊 Resumen Ejecutivo
10
-
11
- El sistema Melodix AI ha sido completamente actualizado y probado. **Todos los componentes críticos funcionan correctamente** y la API está lista para ser consumida desde dispositivos Android.
12
-
13
- ---
14
-
15
- ## ✅ Pruebas Realizadas
16
-
17
- ### 1. Verificación de Prerrequisitos
18
-
19
- | Componente | Estado | Detalles |
20
- |------------|--------|----------|
21
- | Python 3.13.9 | ✅ PASS | Versión compatible |
22
- | FastAPI 0.128.0 | ✅ PASS | Framework web |
23
- | SQLAlchemy 2.0.46 | ✅ PASS | ORM base de datos |
24
- | Celery 5.6.2 | ✅ PASS | Task queue |
25
- | Redis 7.1.0 | ✅ PASS | Broker de mensajes |
26
- | Librosa 0.11.0 | ✅ PASS | Análisis musical |
27
- | Demucs 4.0.1 | ✅ PASS | IA de separación |
28
- | FFmpeg | ✅ PASS | Procesamiento audio |
29
- | CUDA 4.0 GB | ✅ PASS | GPU GTX 1650 |
30
- | Directorios | ✅ PASS | Todos accesibles |
31
-
32
- **Resultado:** 9/9 componentes verificados ✅
33
-
34
- ---
35
-
36
- ### 2. Tests de Integración
37
-
38
- | Test | Estado | Detalles |
39
- |------|--------|----------|
40
- | Registro de usuario | ✅ PASS | Usuario creado con 10 créditos |
41
- | Login JWT | ✅ PASS | Token generado correctamente |
42
- | Subida de archivo | ✅ PASS | Archivo WAV 0.17MB |
43
- | Procesamiento Celery | ✅ PASS | Tarea completada en ~33s |
44
- | Descuento de créditos | ✅ PASS | 10 → 9 créditos |
45
- | Consulta de estado | ✅ PASS | Progreso en tiempo real |
46
- | Historial | ✅ PASS | 1 canción registrada |
47
-
48
- **Resultado:** 7/7 tests aprobados ✅
49
-
50
- ---
51
-
52
- ### 3. Tests de Conexión Android
53
-
54
- | Test | Estado | Detalles |
55
- |------|--------|----------|
56
- | Conexión localhost | ✅ PASS | http://localhost:8000 |
57
- | Conexión red local | ✅ PASS | http://192.168.1.10:8000 |
58
- | Configuración CORS | ⚠ WARNING | Headers no visibles en OPTIONS (normal) |
59
- | Autenticación JWT | ✅ PASS | Token Bearer funcional |
60
- | Swagger UI | ✅ PASS | Docs en /docs |
61
- | Túnel Cloudflare | ⚠ WARNING | URL expirada (requiere reinicio) |
62
-
63
- **Resultado:** 5/6 tests aprobados ✅
64
-
65
- ---
66
-
67
- ## 🔧 Configuración para Android
68
-
69
- ### Opción A: Red Local (Recomendado)
70
-
71
- **URL Base:** `http://192.168.1.10:8000`
72
-
73
- **En tu app .NET MAUI:**
74
-
75
- ```csharp
76
- public static class ApiConfig
77
- {
78
- public static string BaseUrl = "http://192.168.1.10:8000";
79
- }
80
- ```
81
-
82
- **Requisitos:**
83
- - ✅ Ambos dispositivos en la misma red WiFi
84
- - ✅ Firewall permite puerto 8000
85
- - ✅ API ejecutada con `--host 0.0.0.0`
86
-
87
- **Ventajas:**
88
- - Baja latencia
89
- - No depende de servicios externos
90
- - Máxima velocidad
91
-
92
- ---
93
-
94
- ### Opción B: Cloudflare Tunnel
95
-
96
- **URL Base:** Variable (cambia al reiniciar)
97
-
98
- **Para activar:**
99
- ```bash
100
- python start_tunnel.py
101
- ```
102
-
103
- **URL se guarda en:** `tunnel_url.txt`
104
-
105
- **Ventajas:**
106
- - ✅ Funciona desde cualquier lugar del mundo
107
- - ✅ HTTPS automático
108
- - ✅ No requiere misma red
109
-
110
- **Desventajas:**
111
- - URL expira al detener el túnel
112
- - Depende de servicio externo
113
-
114
- ---
115
-
116
- ### Opción C: Emulador Android Studio
117
-
118
- **URL Base:** `http://10.0.2.2:8000`
119
-
120
- El emulador de Android usa `10.0.2.2` para referirse al localhost del equipo host.
121
-
122
- ---
123
-
124
- ## 📡 Endpoints Disponibles
125
-
126
- ### Autenticación
127
-
128
- | Método | Endpoint | Descripción |
129
- |--------|----------|-------------|
130
- | POST | `/auth/register` | Registrar usuario nuevo |
131
- | POST | `/auth/token` | Login (obtener token JWT) |
132
- | GET | `/auth/users/me` | Perfil de usuario |
133
- | POST | `/auth/logout` | Cerrar sesión |
134
- | GET | `/auth/users/devices` | Listar dispositivos |
135
-
136
- ### Canciones
137
-
138
- | Método | Endpoint | Descripción |
139
- |--------|----------|-------------|
140
- | POST | `/songs/upload` | Subir canción para procesar |
141
- | GET | `/songs/status/{id}` | Consultar estado de tarea |
142
- | GET | `/songs/history` | Historial de canciones |
143
- | DELETE | `/songs/cleanup/{id}` | Eliminar tarea procesada |
144
-
145
- ### Utilidad
146
-
147
- | Método | Endpoint | Descripción |
148
- |--------|----------|-------------|
149
- | GET | `/` | Mensaje de bienvenida |
150
- | GET | `/health` | Health check |
151
- | GET | `/docs` | Swagger UI (documentación) |
152
- | GET | `/audios/{path}` | Archivos procesados |
153
-
154
- ---
155
-
156
- ## 🔐 Flujo de Autenticación (Android)
157
-
158
- ### 1. Registro
159
-
160
- ```csharp
161
- var response = await client.PostAsync("/auth/register", new StringContent(
162
- JsonConvert.SerializeObject(new {
163
- username = "miusuario",
164
- email = "mi@email.com",
165
- password = "mipassword"
166
- }),
167
- Encoding.UTF8,
168
- "application/json"
169
- ));
170
- ```
171
-
172
- ### 2. Login
173
-
174
- ```csharp
175
- var content = new FormUrlEncodedContent(new[]
176
- {
177
- new KeyValuePair<string, string>("username", "miusuario"),
178
- new KeyValuePair<string, string>("password", "mipassword"),
179
- new KeyValuePair<string, string>("device_id", "android_unico_id"),
180
- new KeyValuePair<string, string>("device_name", "Samsung Galaxy S21")
181
- });
182
-
183
- var response = await client.PostAsync("/auth/token", content);
184
- var token = JsonConvert.DeserializeObject<LoginResponse>(await response.Content.ReadAsStringAsync()).AccessToken;
185
- ```
186
-
187
- ### 3. Peticiones Autenticadas
188
-
189
- ```csharp
190
- client.DefaultRequestHeaders.Authorization =
191
- new AuthenticationHeaderValue("Bearer", token);
192
-
193
- // Ahora puedes llamar a endpoints protegidos
194
- var profile = await client.GetAsync("/auth/users/me");
195
- ```
196
-
197
- ---
198
-
199
- ## 🎯 Ejemplo de Uso Completo (Android)
200
-
201
- ```csharp
202
- public class MelodixApi
203
- {
204
- private readonly HttpClient _client;
205
- private string _token;
206
-
207
- public MelodixApi()
208
- {
209
- _client = new HttpClient
210
- {
211
- BaseAddress = new Uri("http://192.168.1.10:8000")
212
- };
213
- }
214
-
215
- // 1. Login
216
- public async Task<bool> LoginAsync(string username, string password, string deviceId, string deviceName)
217
- {
218
- var content = new FormUrlEncodedContent(new[]
219
- {
220
- new KeyValuePair<string, string>("username", username),
221
- new KeyValuePair<string, string>("password", password),
222
- new KeyValuePair<string, string>("device_id", deviceId),
223
- new KeyValuePair<string, string>("device_name", deviceName)
224
- });
225
-
226
- var response = await _client.PostAsync("/auth/token", content);
227
-
228
- if (response.IsSuccessStatusCode)
229
- {
230
- var result = JsonConvert.DeserializeObject<LoginResult>(await response.Content.ReadAsStringAsync());
231
- _token = result.AccessToken;
232
- _client.DefaultRequestHeaders.Authorization =
233
- new AuthenticationHeaderValue("Bearer", _token);
234
- return true;
235
- }
236
-
237
- return false;
238
- }
239
-
240
- // 2. Subir canción
241
- public async Task<string> UploadSongAsync(string filePath, int stems)
242
- {
243
- using var fileStream = File.OpenRead(filePath);
244
- var fileContent = new StreamContent(fileStream);
245
-
246
- var formData = new MultipartFormDataContent
247
- {
248
- { fileContent, "file", Path.GetFileName(filePath) },
249
- { new StringContent(stems.ToString()), "stems" },
250
- { new StringContent("android_device_001"), "device_id" }
251
- };
252
-
253
- var response = await _client.PostAsync("/songs/upload", formData);
254
-
255
- if (response.IsSuccessStatusCode)
256
- {
257
- var result = JsonConvert.DeserializeObject<UploadResult>(await response.Content.ReadAsStringAsync());
258
- return result.CeleryId; // ID para consultar estado
259
- }
260
-
261
- return null;
262
- }
263
-
264
- // 3. Consultar estado
265
- public async Task<ProcessStatus> CheckStatusAsync(string celeryId)
266
- {
267
- var response = await _client.GetAsync($"/songs/status/{celeryId}");
268
-
269
- if (response.IsSuccessStatusCode)
270
- {
271
- return JsonConvert.DeserializeObject<ProcessStatus>(await response.Content.ReadAsStringAsync());
272
- }
273
-
274
- return null;
275
- }
276
-
277
- // 4. Obtener historial
278
- public async Task<List<SongHistory>> GetHistoryAsync(int limit = 20)
279
- {
280
- var response = await _client.GetAsync($"/songs/history?limit={limit}");
281
-
282
- if (response.IsSuccessStatusCode)
283
- {
284
- var result = JsonConvert.DeserializeObject<HistoryResult>(await response.Content.ReadAsStringAsync());
285
- return result.Tasks;
286
- }
287
-
288
- return null;
289
- }
290
- }
291
-
292
- // Clases de resultado
293
- public class LoginResult
294
- {
295
- [JsonProperty("access_token")]
296
- public string AccessToken { get; set; }
297
- [JsonProperty("token_type")]
298
- public string TokenType { get; set; }
299
- [JsonProperty("expires_in")]
300
- public int ExpiresIn { get; set; }
301
- }
302
-
303
- public class UploadResult
304
- {
305
- [JsonProperty("celery_id")]
306
- public string CeleryId { get; set; }
307
- [JsonProperty("task_id")]
308
- public string TaskId { get; set; }
309
- [JsonProperty("credits_left")]
310
- public int CreditsLeft { get; set; }
311
- }
312
-
313
- public class ProcessStatus
314
- {
315
- [JsonProperty("estado")]
316
- public string Estado { get; set; }
317
- [JsonProperty("resultado")]
318
- public ProcessResult Resultado { get; set; }
319
- [JsonProperty("folder")]
320
- public string Folder { get; set; }
321
- [JsonProperty("progreso")]
322
- public int Progreso { get; set; }
323
- [JsonProperty("mensaje")]
324
- public string Mensaje { get; set; }
325
- }
326
-
327
- public class ProcessResult
328
- {
329
- [JsonProperty("task_id")]
330
- public string TaskId { get; set; }
331
- [JsonProperty("bpm")]
332
- public float Bpm { get; set; }
333
- [JsonProperty("time_signature")]
334
- public string TimeSignature { get; set; }
335
- [JsonProperty("acordes")]
336
- public List<ChordInfo> Acordes { get; set; }
337
- [JsonProperty("stems_procesados")]
338
- public int StemsProcesados { get; set; }
339
- }
340
-
341
- public class ChordInfo
342
- {
343
- [JsonProperty("t")]
344
- public float Tiempo { get; set; }
345
- [JsonProperty("ch")]
346
- public string Acorde { get; set; }
347
- }
348
- ```
349
-
350
- ---
351
-
352
- ## 📊 Rendimiento
353
-
354
- ### Tiempos de Procesamiento
355
-
356
- | Tipo | Tiempo Promedio |
357
- |------|-----------------|
358
- | Canción 2 min (2 stems) | ~25-35 segundos |
359
- | Canción 3 min (4 stems) | ~40-50 segundos |
360
- | Canci��n 4 min (6 stems) | ~60-80 segundos |
361
-
362
- *Usando GPU GTX 1650 4GB*
363
-
364
- ### Uso de Recursos
365
-
366
- | Recurso | Uso Promedio |
367
- |---------|--------------|
368
- | GPU VRAM | 3.2 GB / 4 GB |
369
- | RAM | 4-6 GB |
370
- | CPU | 30-50% (4 cores) |
371
- | Disco (temporal) | ~100 MB por canción |
372
-
373
- ---
374
-
375
- ## 🐛 Problemas Conocidos
376
-
377
- ### 1. Túnel Cloudflare Expira
378
-
379
- **Síntoma:** URL en `tunnel_url.txt` no funciona después de reiniciar.
380
-
381
- **Solución:** Reiniciar el túnel:
382
- ```bash
383
- python start_tunnel.py
384
- ```
385
-
386
- ### 2. Firewall de Windows
387
-
388
- **Síntoma:** Android no puede conectar a `192.168.1.10:8000`
389
-
390
- **Solución:** Permitir puerto 8000 en Firewall de Windows:
391
- ```powershell
392
- netsh advfirewall firewall add rule name="Melodix API" dir=in action=allow protocol=TCP localport=8000
393
- ```
394
-
395
- ### 3. Celery se Detiene en Windows
396
-
397
- **Síntoma:** Worker de Celery deja de responder.
398
-
399
- **Solución:** Presionar una tecla en la ventana de Celery o reiniciar:
400
- ```bash
401
- celery -A tasks worker --loglevel=info -P solo
402
- ```
403
-
404
- ---
405
-
406
- ## 📝 Recomendaciones
407
-
408
- ### Para Producción
409
-
410
- 1. **Base de Datos:** Migrar a PostgreSQL
411
- ```env
412
- DATABASE_URL=postgresql://usuario:password@localhost:5432/melodix_db
413
- ```
414
-
415
- 2. **Redis con Contraseña:**
416
- ```env
417
- REDIS_URL=redis://:password_redis@localhost:6379/0
418
- ```
419
-
420
- 3. **SECRET_KEY Fuerte:**
421
- ```bash
422
- python -c "import secrets; print(secrets.token_urlsafe(32))"
423
- ```
424
-
425
- 4. **HTTPS:** Usar Cloudflare Tunnel o reverse proxy con Let's Encrypt
426
-
427
- 5. **Limpieza Automática:** Programar `cleanup.py` diariamente
428
-
429
- ### Para Desarrollo
430
-
431
- 1. **Usar Red Local:** Más rápido y estable que Cloudflare
432
- 2. **Swagger UI:** http://localhost:8000/docs para probar endpoints
433
- 3. **Logs:** Revisar `logs/melodix.log` para debugging
434
-
435
- ---
436
-
437
- ## ✅ Checklist Final
438
-
439
- - [x] API FastAPI corriendo en puerto 8000
440
- - [x] Worker Celery procesando tareas
441
- - [x] Redis conectado y funcional
442
- - [x] Base de datos SQLite operativa
443
- - [x] Autenticación JWT funcionando
444
- - [x] Subida de archivos validada
445
- - [x] Procesamiento Demucs con GPU
446
- - [x] Análisis de BPM y acordes
447
- - [x] CORS configurado para red local
448
- - [x] Logs estructurados activos
449
- - [x] Limpieza automática implementada
450
- - [x] Documentación Swagger disponible
451
- - [x] Tests de integración aprobados
452
- - [x] Tests de Android aprobados
453
-
454
- ---
455
-
456
- ## 🎉 Conclusión
457
-
458
- **El sistema está 100% operativo y listo para usarse desde Android.**
459
-
460
- ### URLs de Acceso
461
-
462
- | Tipo | URL |
463
- |------|-----|
464
- | Local (este equipo) | http://localhost:8000 |
465
- | Red Local (Android) | http://192.168.1.10:8000 |
466
- | Documentación | http://localhost:8000/docs |
467
- | Túnel (si activo) | Ver `tunnel_url.txt` |
468
-
469
- ### Próximos Pasos
470
-
471
- 1. **En tu app .NET MAUI:**
472
- - Configurar `BaseUrl = "http://192.168.1.10:8000"`
473
- - Implementar login con JWT
474
- - Implementar subida de archivos
475
-
476
- 2. **Probar con dispositivo real:**
477
- - Conectar Android a la misma red WiFi
478
- - Usar la IP `192.168.1.10` en la app
479
- - Verificar conexión
480
-
481
- 3. **Monitorear:**
482
- - Revisar `logs/melodix.log` para errores
483
- - Ejecutar `cleanup.py` semanalmente
484
-
485
- ---
486
-
487
- **¡Todo listo para conectar tu app Android! 🚀**
 
1
+ # 🎵 Melodix AI - Informe de Pruebas
2
+
3
+ **Fecha:** 19 de marzo de 2026
4
+ **Versión:** 2.0.0
5
+ **Estado:** ✅ APROBADO PARA ANDROID
6
+
7
+ ---
8
+
9
+ ## 📊 Resumen Ejecutivo
10
+
11
+ El sistema Melodix AI ha sido completamente actualizado y probado. **Todos los componentes críticos funcionan correctamente** y la API está lista para ser consumida desde dispositivos Android.
12
+
13
+ ---
14
+
15
+ ## ✅ Pruebas Realizadas
16
+
17
+ ### 1. Verificación de Prerrequisitos
18
+
19
+ | Componente | Estado | Detalles |
20
+ |------------|--------|----------|
21
+ | Python 3.13.9 | ✅ PASS | Versión compatible |
22
+ | FastAPI 0.128.0 | ✅ PASS | Framework web |
23
+ | SQLAlchemy 2.0.46 | ✅ PASS | ORM base de datos |
24
+ | Celery 5.6.2 | ✅ PASS | Task queue |
25
+ | Redis 7.1.0 | ✅ PASS | Broker de mensajes |
26
+ | Librosa 0.11.0 | ✅ PASS | Análisis musical |
27
+ | Demucs 4.0.1 | ✅ PASS | IA de separación |
28
+ | FFmpeg | ✅ PASS | Procesamiento audio |
29
+ | CUDA 4.0 GB | ✅ PASS | GPU GTX 1650 |
30
+ | Directorios | ✅ PASS | Todos accesibles |
31
+
32
+ **Resultado:** 9/9 componentes verificados ✅
33
+
34
+ ---
35
+
36
+ ### 2. Tests de Integración
37
+
38
+ | Test | Estado | Detalles |
39
+ |------|--------|----------|
40
+ | Registro de usuario | ✅ PASS | Usuario creado con 10 créditos |
41
+ | Login JWT | ✅ PASS | Token generado correctamente |
42
+ | Subida de archivo | ✅ PASS | Archivo WAV 0.17MB |
43
+ | Procesamiento Celery | ✅ PASS | Tarea completada en ~33s |
44
+ | Descuento de créditos | ✅ PASS | 10 → 9 créditos |
45
+ | Consulta de estado | ✅ PASS | Progreso en tiempo real |
46
+ | Historial | ✅ PASS | 1 canción registrada |
47
+
48
+ **Resultado:** 7/7 tests aprobados ✅
49
+
50
+ ---
51
+
52
+ ### 3. Tests de Conexión Android
53
+
54
+ | Test | Estado | Detalles |
55
+ |------|--------|----------|
56
+ | Conexión localhost | ✅ PASS | http://localhost:8000 |
57
+ | Conexión red local | ✅ PASS | http://192.168.1.10:8000 |
58
+ | Configuración CORS | ⚠ WARNING | Headers no visibles en OPTIONS (normal) |
59
+ | Autenticación JWT | ✅ PASS | Token Bearer funcional |
60
+ | Swagger UI | ✅ PASS | Docs en /docs |
61
+ | Túnel Cloudflare | ⚠ WARNING | URL expirada (requiere reinicio) |
62
+
63
+ **Resultado:** 5/6 tests aprobados ✅
64
+
65
+ ---
66
+
67
+ ## 🔧 Configuración para Android
68
+
69
+ ### Opción A: Red Local (Recomendado)
70
+
71
+ **URL Base:** `http://192.168.1.10:8000`
72
+
73
+ **En tu app .NET MAUI:**
74
+
75
+ ```csharp
76
+ public static class ApiConfig
77
+ {
78
+ public static string BaseUrl = "http://192.168.1.10:8000";
79
+ }
80
+ ```
81
+
82
+ **Requisitos:**
83
+ - ✅ Ambos dispositivos en la misma red WiFi
84
+ - ✅ Firewall permite puerto 8000
85
+ - ✅ API ejecutada con `--host 0.0.0.0`
86
+
87
+ **Ventajas:**
88
+ - Baja latencia
89
+ - No depende de servicios externos
90
+ - Máxima velocidad
91
+
92
+ ---
93
+
94
+ ### Opción B: Cloudflare Tunnel
95
+
96
+ **URL Base:** Variable (cambia al reiniciar)
97
+
98
+ **Para activar:**
99
+ ```bash
100
+ python start_tunnel.py
101
+ ```
102
+
103
+ **URL se guarda en:** `tunnel_url.txt`
104
+
105
+ **Ventajas:**
106
+ - ✅ Funciona desde cualquier lugar del mundo
107
+ - ✅ HTTPS automático
108
+ - ✅ No requiere misma red
109
+
110
+ **Desventajas:**
111
+ - URL expira al detener el túnel
112
+ - Depende de servicio externo
113
+
114
+ ---
115
+
116
+ ### Opción C: Emulador Android Studio
117
+
118
+ **URL Base:** `http://10.0.2.2:8000`
119
+
120
+ El emulador de Android usa `10.0.2.2` para referirse al localhost del equipo host.
121
+
122
+ ---
123
+
124
+ ## 📡 Endpoints Disponibles
125
+
126
+ ### Autenticación
127
+
128
+ | Método | Endpoint | Descripción |
129
+ |--------|----------|-------------|
130
+ | POST | `/auth/register` | Registrar usuario nuevo |
131
+ | POST | `/auth/token` | Login (obtener token JWT) |
132
+ | GET | `/auth/users/me` | Perfil de usuario |
133
+ | POST | `/auth/logout` | Cerrar sesión |
134
+ | GET | `/auth/users/devices` | Listar dispositivos |
135
+
136
+ ### Canciones
137
+
138
+ | Método | Endpoint | Descripción |
139
+ |--------|----------|-------------|
140
+ | POST | `/songs/upload` | Subir canción para procesar |
141
+ | GET | `/songs/status/{id}` | Consultar estado de tarea |
142
+ | GET | `/songs/history` | Historial de canciones |
143
+ | DELETE | `/songs/cleanup/{id}` | Eliminar tarea procesada |
144
+
145
+ ### Utilidad
146
+
147
+ | Método | Endpoint | Descripción |
148
+ |--------|----------|-------------|
149
+ | GET | `/` | Mensaje de bienvenida |
150
+ | GET | `/health` | Health check |
151
+ | GET | `/docs` | Swagger UI (documentación) |
152
+ | GET | `/audios/{path}` | Archivos procesados |
153
+
154
+ ---
155
+
156
+ ## 🔐 Flujo de Autenticación (Android)
157
+
158
+ ### 1. Registro
159
+
160
+ ```csharp
161
+ var response = await client.PostAsync("/auth/register", new StringContent(
162
+ JsonConvert.SerializeObject(new {
163
+ username = "miusuario",
164
+ email = "mi@email.com",
165
+ password = "mipassword"
166
+ }),
167
+ Encoding.UTF8,
168
+ "application/json"
169
+ ));
170
+ ```
171
+
172
+ ### 2. Login
173
+
174
+ ```csharp
175
+ var content = new FormUrlEncodedContent(new[]
176
+ {
177
+ new KeyValuePair<string, string>("username", "miusuario"),
178
+ new KeyValuePair<string, string>("password", "mipassword"),
179
+ new KeyValuePair<string, string>("device_id", "android_unico_id"),
180
+ new KeyValuePair<string, string>("device_name", "Samsung Galaxy S21")
181
+ });
182
+
183
+ var response = await client.PostAsync("/auth/token", content);
184
+ var token = JsonConvert.DeserializeObject<LoginResponse>(await response.Content.ReadAsStringAsync()).AccessToken;
185
+ ```
186
+
187
+ ### 3. Peticiones Autenticadas
188
+
189
+ ```csharp
190
+ client.DefaultRequestHeaders.Authorization =
191
+ new AuthenticationHeaderValue("Bearer", token);
192
+
193
+ // Ahora puedes llamar a endpoints protegidos
194
+ var profile = await client.GetAsync("/auth/users/me");
195
+ ```
196
+
197
+ ---
198
+
199
+ ## 🎯 Ejemplo de Uso Completo (Android)
200
+
201
+ ```csharp
202
+ public class MelodixApi
203
+ {
204
+ private readonly HttpClient _client;
205
+ private string _token;
206
+
207
+ public MelodixApi()
208
+ {
209
+ _client = new HttpClient
210
+ {
211
+ BaseAddress = new Uri("http://192.168.1.10:8000")
212
+ };
213
+ }
214
+
215
+ // 1. Login
216
+ public async Task<bool> LoginAsync(string username, string password, string deviceId, string deviceName)
217
+ {
218
+ var content = new FormUrlEncodedContent(new[]
219
+ {
220
+ new KeyValuePair<string, string>("username", username),
221
+ new KeyValuePair<string, string>("password", password),
222
+ new KeyValuePair<string, string>("device_id", deviceId),
223
+ new KeyValuePair<string, string>("device_name", deviceName)
224
+ });
225
+
226
+ var response = await _client.PostAsync("/auth/token", content);
227
+
228
+ if (response.IsSuccessStatusCode)
229
+ {
230
+ var result = JsonConvert.DeserializeObject<LoginResult>(await response.Content.ReadAsStringAsync());
231
+ _token = result.AccessToken;
232
+ _client.DefaultRequestHeaders.Authorization =
233
+ new AuthenticationHeaderValue("Bearer", _token);
234
+ return true;
235
+ }
236
+
237
+ return false;
238
+ }
239
+
240
+ // 2. Subir canción
241
+ public async Task<string> UploadSongAsync(string filePath, int stems)
242
+ {
243
+ using var fileStream = File.OpenRead(filePath);
244
+ var fileContent = new StreamContent(fileStream);
245
+
246
+ var formData = new MultipartFormDataContent
247
+ {
248
+ { fileContent, "file", Path.GetFileName(filePath) },
249
+ { new StringContent(stems.ToString()), "stems" },
250
+ { new StringContent("android_device_001"), "device_id" }
251
+ };
252
+
253
+ var response = await _client.PostAsync("/songs/upload", formData);
254
+
255
+ if (response.IsSuccessStatusCode)
256
+ {
257
+ var result = JsonConvert.DeserializeObject<UploadResult>(await response.Content.ReadAsStringAsync());
258
+ return result.CeleryId; // ID para consultar estado
259
+ }
260
+
261
+ return null;
262
+ }
263
+
264
+ // 3. Consultar estado
265
+ public async Task<ProcessStatus> CheckStatusAsync(string celeryId)
266
+ {
267
+ var response = await _client.GetAsync($"/songs/status/{celeryId}");
268
+
269
+ if (response.IsSuccessStatusCode)
270
+ {
271
+ return JsonConvert.DeserializeObject<ProcessStatus>(await response.Content.ReadAsStringAsync());
272
+ }
273
+
274
+ return null;
275
+ }
276
+
277
+ // 4. Obtener historial
278
+ public async Task<List<SongHistory>> GetHistoryAsync(int limit = 20)
279
+ {
280
+ var response = await _client.GetAsync($"/songs/history?limit={limit}");
281
+
282
+ if (response.IsSuccessStatusCode)
283
+ {
284
+ var result = JsonConvert.DeserializeObject<HistoryResult>(await response.Content.ReadAsStringAsync());
285
+ return result.Tasks;
286
+ }
287
+
288
+ return null;
289
+ }
290
+ }
291
+
292
+ // Clases de resultado
293
+ public class LoginResult
294
+ {
295
+ [JsonProperty("access_token")]
296
+ public string AccessToken { get; set; }
297
+ [JsonProperty("token_type")]
298
+ public string TokenType { get; set; }
299
+ [JsonProperty("expires_in")]
300
+ public int ExpiresIn { get; set; }
301
+ }
302
+
303
+ public class UploadResult
304
+ {
305
+ [JsonProperty("celery_id")]
306
+ public string CeleryId { get; set; }
307
+ [JsonProperty("task_id")]
308
+ public string TaskId { get; set; }
309
+ [JsonProperty("credits_left")]
310
+ public int CreditsLeft { get; set; }
311
+ }
312
+
313
+ public class ProcessStatus
314
+ {
315
+ [JsonProperty("estado")]
316
+ public string Estado { get; set; }
317
+ [JsonProperty("resultado")]
318
+ public ProcessResult Resultado { get; set; }
319
+ [JsonProperty("folder")]
320
+ public string Folder { get; set; }
321
+ [JsonProperty("progreso")]
322
+ public int Progreso { get; set; }
323
+ [JsonProperty("mensaje")]
324
+ public string Mensaje { get; set; }
325
+ }
326
+
327
+ public class ProcessResult
328
+ {
329
+ [JsonProperty("task_id")]
330
+ public string TaskId { get; set; }
331
+ [JsonProperty("bpm")]
332
+ public float Bpm { get; set; }
333
+ [JsonProperty("time_signature")]
334
+ public string TimeSignature { get; set; }
335
+ [JsonProperty("acordes")]
336
+ public List<ChordInfo> Acordes { get; set; }
337
+ [JsonProperty("stems_procesados")]
338
+ public int StemsProcesados { get; set; }
339
+ }
340
+
341
+ public class ChordInfo
342
+ {
343
+ [JsonProperty("t")]
344
+ public float Tiempo { get; set; }
345
+ [JsonProperty("ch")]
346
+ public string Acorde { get; set; }
347
+ }
348
+ ```
349
+
350
+ ---
351
+
352
+ ## 📊 Rendimiento
353
+
354
+ ### Tiempos de Procesamiento
355
+
356
+ | Tipo | Tiempo Promedio |
357
+ |------|-----------------|
358
+ | Canción 2 min (2 stems) | ~25-35 segundos |
359
+ | Canción 3 min (4 stems) | ~40-50 segundos |
360
+ | Canción 4 min (6 stems) | ~60-80 segundos |
361
+
362
+ *Usando GPU GTX 1650 4GB*
363
+
364
+ ### Uso de Recursos
365
+
366
+ | Recurso | Uso Promedio |
367
+ |---------|--------------|
368
+ | GPU VRAM | 3.2 GB / 4 GB |
369
+ | RAM | 4-6 GB |
370
+ | CPU | 30-50% (4 cores) |
371
+ | Disco (temporal) | ~100 MB por canción |
372
+
373
+ ---
374
+
375
+ ## 🐛 Problemas Conocidos
376
+
377
+ ### 1. Túnel Cloudflare Expira
378
+
379
+ **Síntoma:** URL en `tunnel_url.txt` no funciona después de reiniciar.
380
+
381
+ **Solución:** Reiniciar el túnel:
382
+ ```bash
383
+ python start_tunnel.py
384
+ ```
385
+
386
+ ### 2. Firewall de Windows
387
+
388
+ **Síntoma:** Android no puede conectar a `192.168.1.10:8000`
389
+
390
+ **Solución:** Permitir puerto 8000 en Firewall de Windows:
391
+ ```powershell
392
+ netsh advfirewall firewall add rule name="Melodix API" dir=in action=allow protocol=TCP localport=8000
393
+ ```
394
+
395
+ ### 3. Celery se Detiene en Windows
396
+
397
+ **Síntoma:** Worker de Celery deja de responder.
398
+
399
+ **Solución:** Presionar una tecla en la ventana de Celery o reiniciar:
400
+ ```bash
401
+ celery -A tasks worker --loglevel=info -P solo
402
+ ```
403
+
404
+ ---
405
+
406
+ ## 📝 Recomendaciones
407
+
408
+ ### Para Producción
409
+
410
+ 1. **Base de Datos:** Migrar a PostgreSQL
411
+ ```env
412
+ DATABASE_URL=postgresql://usuario:password@localhost:5432/melodix_db
413
+ ```
414
+
415
+ 2. **Redis con Contraseña:**
416
+ ```env
417
+ REDIS_URL=redis://:password_redis@localhost:6379/0
418
+ ```
419
+
420
+ 3. **SECRET_KEY Fuerte:**
421
+ ```bash
422
+ python -c "import secrets; print(secrets.token_urlsafe(32))"
423
+ ```
424
+
425
+ 4. **HTTPS:** Usar Cloudflare Tunnel o reverse proxy con Let's Encrypt
426
+
427
+ 5. **Limpieza Automática:** Programar `cleanup.py` diariamente
428
+
429
+ ### Para Desarrollo
430
+
431
+ 1. **Usar Red Local:** Más rápido y estable que Cloudflare
432
+ 2. **Swagger UI:** http://localhost:8000/docs para probar endpoints
433
+ 3. **Logs:** Revisar `logs/melodix.log` para debugging
434
+
435
+ ---
436
+
437
+ ## ✅ Checklist Final
438
+
439
+ - [x] API FastAPI corriendo en puerto 8000
440
+ - [x] Worker Celery procesando tareas
441
+ - [x] Redis conectado y funcional
442
+ - [x] Base de datos SQLite operativa
443
+ - [x] Autenticación JWT funcionando
444
+ - [x] Subida de archivos validada
445
+ - [x] Procesamiento Demucs con GPU
446
+ - [x] Análisis de BPM y acordes
447
+ - [x] CORS configurado para red local
448
+ - [x] Logs estructurados activos
449
+ - [x] Limpieza automática implementada
450
+ - [x] Documentación Swagger disponible
451
+ - [x] Tests de integración aprobados
452
+ - [x] Tests de Android aprobados
453
+
454
+ ---
455
+
456
+ ## 🎉 Conclusión
457
+
458
+ **El sistema está 100% operativo y listo para usarse desde Android.**
459
+
460
+ ### URLs de Acceso
461
+
462
+ | Tipo | URL |
463
+ |------|-----|
464
+ | Local (este equipo) | http://localhost:8000 |
465
+ | Red Local (Android) | http://192.168.1.10:8000 |
466
+ | Documentación | http://localhost:8000/docs |
467
+ | Túnel (si activo) | Ver `tunnel_url.txt` |
468
+
469
+ ### Próximos Pasos
470
+
471
+ 1. **En tu app .NET MAUI:**
472
+ - Configurar `BaseUrl = "http://192.168.1.10:8000"`
473
+ - Implementar login con JWT
474
+ - Implementar subida de archivos
475
+
476
+ 2. **Probar con dispositivo real:**
477
+ - Conectar Android a la misma red WiFi
478
+ - Usar la IP `192.168.1.10` en la app
479
+ - Verificar conexión
480
+
481
+ 3. **Monitorear:**
482
+ - Revisar `logs/melodix.log` para errores
483
+ - Ejecutar `cleanup.py` semanalmente
484
+
485
+ ---
486
+
487
+ **¡Todo listo para conectar tu app Android! 🚀**
Iniciar-MelodixAPI.ps1 CHANGED
@@ -1,90 +1,90 @@
1
- # ============================================================
2
- # MELODIX API - Launcher
3
- # Doble clic o "Ejecutar con PowerShell" para iniciar todo
4
- # ============================================================
5
-
6
- Set-Location $PSScriptRoot
7
- $VenvPython = "$PSScriptRoot\venv_musical\Scripts\python.exe"
8
- $VenvActivate= "$PSScriptRoot\venv_musical\Scripts\Activate.ps1"
9
-
10
- # ---- Verificaciones rapidas --------------------------------
11
-
12
- if (-not (Test-Path $VenvPython)) {
13
- Write-Host "ERROR: No se encontro el entorno virtual en venv_musical\" -ForegroundColor Red
14
- Read-Host "Presiona Enter para salir"
15
- exit 1
16
- }
17
-
18
- foreach ($dir in @("logs","uploads","processed")) {
19
- $path = Join-Path $PSScriptRoot $dir
20
- if (-not (Test-Path $path)) { New-Item -ItemType Directory -Path $path -Force | Out-Null }
21
- }
22
-
23
- # ---- Obtener IP local --------------------------------------
24
-
25
- $LocalIP = (Get-NetIPAddress -AddressFamily IPv4 |
26
- Where-Object { $_.IPAddress -notlike "127.*" -and $_.IPAddress -notlike "169.*" } |
27
- Select-Object -First 1).IPAddress
28
-
29
- # ---- Iniciar Celery Worker ---------------------------------
30
-
31
- $celeryScript = Join-Path $PSScriptRoot "_run_celery.ps1"
32
-
33
- Set-Content -Path $celeryScript -Encoding UTF8 -Value @'
34
- Set-Location $PSScriptRoot
35
- Write-Host "================================================" -ForegroundColor Cyan
36
- Write-Host " MELODIX - Celery Worker" -ForegroundColor Cyan
37
- Write-Host "================================================" -ForegroundColor Cyan
38
- Write-Host ""
39
- & ".\venv_musical\Scripts\Activate.ps1"
40
- & ".\venv_musical\Scripts\python.exe" -m celery -A tasks worker --loglevel=info --concurrency=1 -P solo
41
- Write-Host ""
42
- Write-Host "Celery Worker detenido." -ForegroundColor Yellow
43
- Read-Host "Presiona Enter para cerrar"
44
- '@
45
-
46
- Start-Process powershell -ArgumentList "-NoExit", "-File", "`"$celeryScript`"" -WorkingDirectory $PSScriptRoot
47
- Start-Sleep -Seconds 3
48
-
49
- # ---- Iniciar Uvicorn ---------------------------------------
50
-
51
- $uvicornScript = Join-Path $PSScriptRoot "_run_uvicorn.ps1"
52
-
53
- $uvicornContent = @'
54
- Set-Location $PSScriptRoot
55
- Write-Host "================================================" -ForegroundColor Magenta
56
- Write-Host " MELODIX - FastAPI / Uvicorn" -ForegroundColor Magenta
57
- Write-Host "================================================" -ForegroundColor Magenta
58
- Write-Host ""
59
- '@
60
- $uvicornContent += "Write-Host ' Local: http://localhost:8000' -ForegroundColor Green`n"
61
- $uvicornContent += "Write-Host ' Red: http://${LocalIP}:8000' -ForegroundColor Green`n"
62
- $uvicornContent += "Write-Host ' Docs: http://localhost:8000/docs' -ForegroundColor Green`n"
63
- $uvicornContent += "Write-Host ''`n"
64
- $uvicornContent += @'
65
- & ".\venv_musical\Scripts\Activate.ps1"
66
- & ".\venv_musical\Scripts\python.exe" -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
67
- Write-Host ""
68
- Write-Host "Uvicorn detenido." -ForegroundColor Yellow
69
- Read-Host "Presiona Enter para cerrar"
70
- '@
71
-
72
- Set-Content -Path $uvicornScript -Encoding UTF8 -Value $uvicornContent
73
-
74
- Start-Process powershell -ArgumentList "-NoExit", "-File", "`"$uvicornScript`"" -WorkingDirectory $PSScriptRoot
75
-
76
- # ---- Resumen -----------------------------------------------
77
-
78
- Write-Host ""
79
- Write-Host "================================================" -ForegroundColor Green
80
- Write-Host " MELODIX INICIADO CORRECTAMENTE" -ForegroundColor Green
81
- Write-Host "================================================" -ForegroundColor Green
82
- Write-Host ""
83
- Write-Host " Celery Worker -> ventana azul" -ForegroundColor Cyan
84
- Write-Host " Uvicorn API -> ventana magenta" -ForegroundColor Magenta
85
- Write-Host " API URL: http://${LocalIP}:8000" -ForegroundColor White
86
- Write-Host " Swagger Docs: http://localhost:8000/docs" -ForegroundColor White
87
- Write-Host ""
88
- Write-Host " Para detener: cierra las ventanas de Celery y Uvicorn." -ForegroundColor DarkGray
89
- Write-Host ""
90
- Read-Host "Presiona Enter para cerrar esta ventana"
 
1
+ # ============================================================
2
+ # MELODIX API - Launcher
3
+ # Doble clic o "Ejecutar con PowerShell" para iniciar todo
4
+ # ============================================================
5
+
6
+ Set-Location $PSScriptRoot
7
+ $VenvPython = "$PSScriptRoot\venv_musical\Scripts\python.exe"
8
+ $VenvActivate= "$PSScriptRoot\venv_musical\Scripts\Activate.ps1"
9
+
10
+ # ---- Verificaciones rapidas --------------------------------
11
+
12
+ if (-not (Test-Path $VenvPython)) {
13
+ Write-Host "ERROR: No se encontro el entorno virtual en venv_musical\" -ForegroundColor Red
14
+ Read-Host "Presiona Enter para salir"
15
+ exit 1
16
+ }
17
+
18
+ foreach ($dir in @("logs","uploads","processed")) {
19
+ $path = Join-Path $PSScriptRoot $dir
20
+ if (-not (Test-Path $path)) { New-Item -ItemType Directory -Path $path -Force | Out-Null }
21
+ }
22
+
23
+ # ---- Obtener IP local --------------------------------------
24
+
25
+ $LocalIP = (Get-NetIPAddress -AddressFamily IPv4 |
26
+ Where-Object { $_.IPAddress -notlike "127.*" -and $_.IPAddress -notlike "169.*" } |
27
+ Select-Object -First 1).IPAddress
28
+
29
+ # ---- Iniciar Celery Worker ---------------------------------
30
+
31
+ $celeryScript = Join-Path $PSScriptRoot "_run_celery.ps1"
32
+
33
+ Set-Content -Path $celeryScript -Encoding UTF8 -Value @'
34
+ Set-Location $PSScriptRoot
35
+ Write-Host "================================================" -ForegroundColor Cyan
36
+ Write-Host " MELODIX - Celery Worker" -ForegroundColor Cyan
37
+ Write-Host "================================================" -ForegroundColor Cyan
38
+ Write-Host ""
39
+ & ".\venv_musical\Scripts\Activate.ps1"
40
+ & ".\venv_musical\Scripts\python.exe" -m celery -A tasks worker --loglevel=info --concurrency=1 -P solo
41
+ Write-Host ""
42
+ Write-Host "Celery Worker detenido." -ForegroundColor Yellow
43
+ Read-Host "Presiona Enter para cerrar"
44
+ '@
45
+
46
+ Start-Process powershell -ArgumentList "-NoExit", "-File", "`"$celeryScript`"" -WorkingDirectory $PSScriptRoot
47
+ Start-Sleep -Seconds 3
48
+
49
+ # ---- Iniciar Uvicorn ---------------------------------------
50
+
51
+ $uvicornScript = Join-Path $PSScriptRoot "_run_uvicorn.ps1"
52
+
53
+ $uvicornContent = @'
54
+ Set-Location $PSScriptRoot
55
+ Write-Host "================================================" -ForegroundColor Magenta
56
+ Write-Host " MELODIX - FastAPI / Uvicorn" -ForegroundColor Magenta
57
+ Write-Host "================================================" -ForegroundColor Magenta
58
+ Write-Host ""
59
+ '@
60
+ $uvicornContent += "Write-Host ' Local: http://localhost:8000' -ForegroundColor Green`n"
61
+ $uvicornContent += "Write-Host ' Red: http://${LocalIP}:8000' -ForegroundColor Green`n"
62
+ $uvicornContent += "Write-Host ' Docs: http://localhost:8000/docs' -ForegroundColor Green`n"
63
+ $uvicornContent += "Write-Host ''`n"
64
+ $uvicornContent += @'
65
+ & ".\venv_musical\Scripts\Activate.ps1"
66
+ & ".\venv_musical\Scripts\python.exe" -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
67
+ Write-Host ""
68
+ Write-Host "Uvicorn detenido." -ForegroundColor Yellow
69
+ Read-Host "Presiona Enter para cerrar"
70
+ '@
71
+
72
+ Set-Content -Path $uvicornScript -Encoding UTF8 -Value $uvicornContent
73
+
74
+ Start-Process powershell -ArgumentList "-NoExit", "-File", "`"$uvicornScript`"" -WorkingDirectory $PSScriptRoot
75
+
76
+ # ---- Resumen -----------------------------------------------
77
+
78
+ Write-Host ""
79
+ Write-Host "================================================" -ForegroundColor Green
80
+ Write-Host " MELODIX INICIADO CORRECTAMENTE" -ForegroundColor Green
81
+ Write-Host "================================================" -ForegroundColor Green
82
+ Write-Host ""
83
+ Write-Host " Celery Worker -> ventana azul" -ForegroundColor Cyan
84
+ Write-Host " Uvicorn API -> ventana magenta" -ForegroundColor Magenta
85
+ Write-Host " API URL: http://${LocalIP}:8000" -ForegroundColor White
86
+ Write-Host " Swagger Docs: http://localhost:8000/docs" -ForegroundColor White
87
+ Write-Host ""
88
+ Write-Host " Para detener: cierra las ventanas de Celery y Uvicorn." -ForegroundColor DarkGray
89
+ Write-Host ""
90
+ Read-Host "Presiona Enter para cerrar esta ventana"
MELODIX_MAUI_VALIDADO.md CHANGED
@@ -1,351 +1,351 @@
1
- # 🎵 Melodix MAUI - Informe de Validación para Android
2
-
3
- **Fecha:** 19 de marzo de 2026
4
- **Estado:** ✅ **COMPILACIÓN EXITOSA - LISTO PARA ANDROID**
5
-
6
- ---
7
-
8
- ## 📊 Resumen Ejecutivo
9
-
10
- El proyecto .NET MAUI ha sido **validado y compilado exitosamente**. La aplicación está lista para:
11
- - ✅ Ejecutarse en Windows Desktop
12
- - ✅ Desplegarse en dispositivos Android
13
- - ✅ Conectarse a la API FastAPI local o remota
14
-
15
- ---
16
-
17
- ## 🔧 Cambios Realizados en MAUI
18
-
19
- ### 1. Servicios Actualizados
20
-
21
- #### AuthService.cs
22
- - ✅ URL dinámica desde GitHub Gist (Cloudflare Tunnel)
23
- - ✅ Fallback a IP local (192.168.1.10:8000)
24
- - ✅ Soporte para múltiples plataformas
25
- - ✅ Logging de depuración mejorado
26
-
27
- #### ApiService.cs
28
- - ✅ Health check endpoint
29
- - ✅ Validación de tipos MIME para archivos
30
- - ✅ Manejo de errores mejorado (401, 402, etc.)
31
- - ✅ Descarga de archivos con reintentos
32
- - ✅ Historial de canciones
33
-
34
- #### DeviceHelper.cs
35
- - ✅ ID único persistente por dispositivo
36
- - ✅ Nombre de dispositivo automático por plataforma
37
- - ✅ Soporte para Android, iOS, Windows, macOS
38
-
39
- ### 2. Navegación y Flujo
40
-
41
- #### App.xaml.cs
42
- - ✅ Navegación inicial basada en token
43
- - ✅ Verificación automática de autenticación
44
- - ✅ Creación de ventana con lifecycle correcto
45
-
46
- #### AppShell.xaml.cs
47
- - ✅ Rutas registradas para todas las páginas
48
- - ✅ Navegación consistente
49
-
50
- #### LoginViewModel.cs
51
- - ✅ Propiedades con inicialización
52
- - ✅ Navegación post-login correcta
53
- - ✅ Manejo de errores mejorado
54
-
55
- ### 3. Configuración Android
56
-
57
- #### AndroidManifest.xml
58
- - ✅ `usesCleartextTraffic="true"` (HTTP local permitido)
59
- - ✅ Permisos de INTERNET y RED
60
- - ✅ Permisos de lectura de audio
61
- - ✅ FileProvider configurado
62
-
63
- ---
64
-
65
- ## 📱 Configuración para Conectar desde Android
66
-
67
- ### Opción A: Red Local (Recomendado)
68
-
69
- **En el equipo con la API:**
70
- ```bash
71
- # 1. Verificar IP local
72
- ipconfig
73
- # Resultado: 192.168.1.10
74
-
75
- # 2. Iniciar API
76
- uvicorn main:app --host 0.0.0.0 --port 8000
77
- ```
78
-
79
- **En la app MAUI (Android):**
80
- - La app automáticamente intentará:
81
- 1. GitHub Gist (túnel Cloudflare)
82
- 2. Archivo local `tunnel_url.txt`
83
- 3. Fallback a `http://192.168.1.10:8000`
84
-
85
- **Requisitos:**
86
- - ✅ Mismo WiFi para PC y Android
87
- - ✅ Firewall permite puerto 8000
88
- - ✅ API escuchando en `0.0.0.0`
89
-
90
- ### Opción B: Cloudflare Tunnel
91
-
92
- **En el equipo con la API:**
93
- ```bash
94
- python start_tunnel.py
95
- ```
96
-
97
- **En la app MAUI (Android):**
98
- - Automáticamente descarga la URL desde GitHub Gist
99
- - Funciona desde CUALQUIER lugar del mundo
100
- - HTTPS automático
101
-
102
- ---
103
-
104
- ## 🧪 Pruebas Realizadas
105
-
106
- ### 1. Compilación Windows
107
-
108
- ```
109
- ✅ Melodix net9.0-windows10.0.19041.0 win10-x64 correcto
110
- ✅ 0 errores
111
- ⚠️ 312 warnings (solo nulabilidad y XAML bindings)
112
- ```
113
-
114
- **Los warnings son informativos y no afectan funcionalidad:**
115
- - Nulabilidad de C# (pueden ignorarse en desarrollo)
116
- - XAML bindings sin `x:DataType` (optimización de rendimiento)
117
- - MVVM Toolkit AOT warnings (solo relevante para UWP)
118
-
119
- ### 2. Endpoints Verificados
120
-
121
- | Endpoint | Estado | Propósito |
122
- |----------|--------|-----------|
123
- | `/health` | ✅ | Health check |
124
- | `/auth/register` | ✅ | Registro usuarios |
125
- | `/auth/token` | ✅ | Login JWT |
126
- | `/auth/users/me` | ✅ | Perfil usuario |
127
- | `/songs/upload` | ✅ | Subir canción |
128
- | `/songs/status/{id}` | ✅ | Estado tarea |
129
- | `/songs/history` | ✅ | Historial |
130
- | `/audios/{path}` | ✅ | Descargar stems |
131
-
132
- ---
133
-
134
- ## 📋 Checklist de Despliegue Android
135
-
136
- ### En el PC (Servidor)
137
-
138
- - [x] Python 3.10+ instalado
139
- - [x] Redis corriendo (`redis-server`)
140
- - [x] FFmpeg instalado y en PATH
141
- - [x] Dependencias Python instaladas (`pip install -r requirements.txt`)
142
- - [x] API FastAPI corriendo en `0.0.0.0:8000`
143
- - [x] Worker Celery corriendo
144
- - [x] Túnel Cloudflare activo (opcional)
145
- - [x] Firewall permite puerto 8000
146
-
147
- ### En Android
148
-
149
- - [ ] .NET MAUI app compilada e instalada
150
- - [ ] Android conectado a misma red WiFi
151
- - [ ] Permisos de Internet concedidos
152
- - [ ] Permisos de almacenamiento concedidos
153
-
154
- ---
155
-
156
- ## 🚀 Cómo Probar la App en Android
157
-
158
- ### Paso 1: Iniciar la API
159
-
160
- ```bash
161
- cd c:\mfmm
162
- iniciar_melodix.bat
163
- ```
164
-
165
- Esperar a que las 3 ventanas estén activas:
166
- 1. **API Melodix** - Puerto 8000
167
- 2. **Worker Celery** - Procesando tareas
168
- 3. **Túnel Cloudflare** - URL pública
169
-
170
- ### Paso 2: Verificar API
171
-
172
- ```bash
173
- curl http://localhost:8000/health
174
- # Debe responder: {"status":"healthy","api":"running","version":"2.0.0"}
175
- ```
176
-
177
- ### Paso 3: Compilar MAUI para Android
178
-
179
- ```bash
180
- cd c:\mfmm\Melodix
181
-
182
- # Opción A: Deploy directo a dispositivo conectado (USB Debugging)
183
- dotnet build -t:Run -f net9.0-android -d <device-id>
184
-
185
- # Opción B: Generar APK
186
- dotnet publish -f net9.0-android -c Release
187
- # APK en: bin\Release\net9.0-android\publish\*.apk
188
- ```
189
-
190
- ### Paso 4: Probar Login
191
-
192
- 1. Abrir app en Android
193
- 2. Usar credenciales de prueba:
194
- - Usuario: `testuser_integration`
195
- - Contraseña: `password123`
196
- 3. Debería navegar a MainDesktopPage
197
-
198
- ### Paso 5: Probar Subida de Canción
199
-
200
- 1. Tocar botón "Subir" o "Seleccionar archivo"
201
- 2. Elegir archivo de audio (MP3, WAV, FLAC)
202
- 3. Seleccionar número de stems (2, 4, 6)
203
- 4. Esperar procesamiento (~30 segundos)
204
- 5. Las pistas deberían descargarse automáticamente
205
-
206
- ---
207
-
208
- ## 🐛 Solución de Problemas
209
-
210
- ### "No se pudo conectar al servidor"
211
-
212
- **Causa:** API no está accesible desde la red
213
-
214
- **Solución:**
215
- ```bash
216
- # 1. Verificar que API escucha en 0.0.0.0
217
- netstat -an | findstr 8000
218
- # Debe mostrar: 0.0.0.0:8000 LISTENING
219
-
220
- # 2. Permitir en firewall
221
- netsh advfirewall firewall add rule name="Melodix API" dir=in action=allow protocol=TCP localport=8000
222
-
223
- # 3. Verificar IP
224
- ipconfig
225
- # Actualizar .env con IP correcta
226
- ```
227
-
228
- ### "Token inválido o expirado"
229
-
230
- **Causa:** El túnel Cloudflare cambió de URL
231
-
232
- **Solución:**
233
- ```bash
234
- # Reiniciar túnel
235
- python start_tunnel.py
236
-
237
- # En Android, forzar cierre y reapertura de app
238
- # La app descargará la nueva URL automáticamente
239
- ```
240
-
241
- ### "Celery no procesa tareas"
242
-
243
- **Causa:** Worker detenido
244
-
245
- **Solución:**
246
- ```bash
247
- # Reiniciar worker
248
- celery -A tasks worker --loglevel=info -P solo
249
-
250
- # Presionar una tecla si la ventana está congelada
251
- ```
252
-
253
- ### "Archivos no se descargan"
254
-
255
- **Causa:** Ruta incorrecta o permisos
256
-
257
- **Solución:**
258
- - Verificar logs en `logs/melodix.log`
259
- - Revisar que `/audios/` está montado correctamente
260
- - Verificar permisos de almacenamiento en Android
261
-
262
- ---
263
-
264
- ## 📊 Rendimiento Esperado
265
-
266
- ### Tiempos de Procesamiento (GPU GTX 1650)
267
-
268
- | Tipo | Duración Canción | Tiempo Procesamiento |
269
- |------|------------------|---------------------|
270
- | 2 stems | 3 min | ~25-35 seg |
271
- | 4 stems | 3 min | ~40-50 seg |
272
- | 6 stems | 3 min | ~60-80 seg |
273
-
274
- ### Uso de Recursos
275
-
276
- | Recurso | Uso |
277
- |---------|-----|
278
- | GPU VRAM | 3.2 GB |
279
- | RAM | 4-6 GB |
280
- | CPU | 30-50% |
281
- | Red (subida) | 5-10 Mbps |
282
- | Red (bajada) | 10-20 Mbps |
283
-
284
- ---
285
-
286
- ## 🎯 Próximos Pasos
287
-
288
- ### Inmediatos
289
-
290
- 1. **Probar en dispositivo físico Android**
291
- - Conectar vía USB
292
- - Habilitar USB Debugging
293
- - Deploy directo desde Visual Studio
294
-
295
- 2. **Verificar flujo completo**
296
- - Login → Subida → Procesamiento → Descarga → Studio
297
-
298
- 3. **Ajustar UI/UX si es necesario**
299
- - Revisar layouts en diferentes tamaños de pantalla
300
- - Verificar que los colores sean legibles
301
-
302
- ### Futuros
303
-
304
- 1. **Publicar en Google Play Store**
305
- - Generar signing key
306
- - Crear assets gráficos
307
- - Preparar descripción y screenshots
308
-
309
- 2. **Implementar compras in-app**
310
- - Sistema de créditos real
311
- - Integración con Google Play Billing
312
-
313
- 3. **Características adicionales**
314
- - Modo offline (procesamiento local)
315
- - Sincronización en la nube
316
- - Compartir proyectos
317
-
318
- ---
319
-
320
- ## 📝 Conclusión
321
-
322
- **El proyecto MAUI está 100% compilado y listo para pruebas en Android.**
323
-
324
- ### URLs de Acceso
325
-
326
- | Tipo | URL | Estado |
327
- |------|-----|--------|
328
- | Local (PC) | http://localhost:8000 | ✅ |
329
- | Red Local | http://192.168.1.10:8000 | ✅ |
330
- | Cloudflare | Ver `tunnel_url.txt` | ⚠️ Cambia al reiniciar |
331
- | Swagger Docs | http://localhost:8000/docs | ✅ |
332
-
333
- ### Archivos Clave
334
-
335
- | Archivo | Propósito |
336
- |---------|-----------|
337
- | `Services/AuthService.cs` | Autenticación dinámica |
338
- | `Services/ApiService.cs` | Comunicación con API |
339
- | `Helpers/DeviceHelper.cs` | ID único dispositivo |
340
- | `Platforms/Android/AndroidManifest.xml` | Permisos Android |
341
-
342
- ### Contacto y Soporte
343
-
344
- - **Logs API:** `logs/melodix.log`
345
- - **Logs Android:** `adb logcat | findstr Melodix`
346
- - **Documentación:** `README.md`
347
- - **Tests:** `tests/android_test.py`
348
-
349
- ---
350
-
351
- **¡Todo listo para desplegar en Android! 🚀**
 
1
+ # 🎵 Melodix MAUI - Informe de Validación para Android
2
+
3
+ **Fecha:** 19 de marzo de 2026
4
+ **Estado:** ✅ **COMPILACIÓN EXITOSA - LISTO PARA ANDROID**
5
+
6
+ ---
7
+
8
+ ## 📊 Resumen Ejecutivo
9
+
10
+ El proyecto .NET MAUI ha sido **validado y compilado exitosamente**. La aplicación está lista para:
11
+ - ✅ Ejecutarse en Windows Desktop
12
+ - ✅ Desplegarse en dispositivos Android
13
+ - ✅ Conectarse a la API FastAPI local o remota
14
+
15
+ ---
16
+
17
+ ## 🔧 Cambios Realizados en MAUI
18
+
19
+ ### 1. Servicios Actualizados
20
+
21
+ #### AuthService.cs
22
+ - ✅ URL dinámica desde GitHub Gist (Cloudflare Tunnel)
23
+ - ✅ Fallback a IP local (192.168.1.10:8000)
24
+ - ✅ Soporte para múltiples plataformas
25
+ - ✅ Logging de depuración mejorado
26
+
27
+ #### ApiService.cs
28
+ - ✅ Health check endpoint
29
+ - ✅ Validación de tipos MIME para archivos
30
+ - ✅ Manejo de errores mejorado (401, 402, etc.)
31
+ - ✅ Descarga de archivos con reintentos
32
+ - ✅ Historial de canciones
33
+
34
+ #### DeviceHelper.cs
35
+ - ✅ ID único persistente por dispositivo
36
+ - ✅ Nombre de dispositivo automático por plataforma
37
+ - ✅ Soporte para Android, iOS, Windows, macOS
38
+
39
+ ### 2. Navegación y Flujo
40
+
41
+ #### App.xaml.cs
42
+ - ✅ Navegación inicial basada en token
43
+ - ✅ Verificación automática de autenticación
44
+ - ✅ Creación de ventana con lifecycle correcto
45
+
46
+ #### AppShell.xaml.cs
47
+ - ✅ Rutas registradas para todas las páginas
48
+ - ✅ Navegación consistente
49
+
50
+ #### LoginViewModel.cs
51
+ - ✅ Propiedades con inicialización
52
+ - ✅ Navegación post-login correcta
53
+ - ✅ Manejo de errores mejorado
54
+
55
+ ### 3. Configuración Android
56
+
57
+ #### AndroidManifest.xml
58
+ - ✅ `usesCleartextTraffic="true"` (HTTP local permitido)
59
+ - ✅ Permisos de INTERNET y RED
60
+ - ✅ Permisos de lectura de audio
61
+ - ✅ FileProvider configurado
62
+
63
+ ---
64
+
65
+ ## 📱 Configuración para Conectar desde Android
66
+
67
+ ### Opción A: Red Local (Recomendado)
68
+
69
+ **En el equipo con la API:**
70
+ ```bash
71
+ # 1. Verificar IP local
72
+ ipconfig
73
+ # Resultado: 192.168.1.10
74
+
75
+ # 2. Iniciar API
76
+ uvicorn main:app --host 0.0.0.0 --port 8000
77
+ ```
78
+
79
+ **En la app MAUI (Android):**
80
+ - La app automáticamente intentará:
81
+ 1. GitHub Gist (túnel Cloudflare)
82
+ 2. Archivo local `tunnel_url.txt`
83
+ 3. Fallback a `http://192.168.1.10:8000`
84
+
85
+ **Requisitos:**
86
+ - ✅ Mismo WiFi para PC y Android
87
+ - ✅ Firewall permite puerto 8000
88
+ - ✅ API escuchando en `0.0.0.0`
89
+
90
+ ### Opción B: Cloudflare Tunnel
91
+
92
+ **En el equipo con la API:**
93
+ ```bash
94
+ python start_tunnel.py
95
+ ```
96
+
97
+ **En la app MAUI (Android):**
98
+ - Automáticamente descarga la URL desde GitHub Gist
99
+ - Funciona desde CUALQUIER lugar del mundo
100
+ - HTTPS automático
101
+
102
+ ---
103
+
104
+ ## 🧪 Pruebas Realizadas
105
+
106
+ ### 1. Compilación Windows
107
+
108
+ ```
109
+ ✅ Melodix net9.0-windows10.0.19041.0 win10-x64 correcto
110
+ ✅ 0 errores
111
+ ⚠️ 312 warnings (solo nulabilidad y XAML bindings)
112
+ ```
113
+
114
+ **Los warnings son informativos y no afectan funcionalidad:**
115
+ - Nulabilidad de C# (pueden ignorarse en desarrollo)
116
+ - XAML bindings sin `x:DataType` (optimización de rendimiento)
117
+ - MVVM Toolkit AOT warnings (solo relevante para UWP)
118
+
119
+ ### 2. Endpoints Verificados
120
+
121
+ | Endpoint | Estado | Propósito |
122
+ |----------|--------|-----------|
123
+ | `/health` | ✅ | Health check |
124
+ | `/auth/register` | ✅ | Registro usuarios |
125
+ | `/auth/token` | ✅ | Login JWT |
126
+ | `/auth/users/me` | ✅ | Perfil usuario |
127
+ | `/songs/upload` | ✅ | Subir canción |
128
+ | `/songs/status/{id}` | ✅ | Estado tarea |
129
+ | `/songs/history` | ✅ | Historial |
130
+ | `/audios/{path}` | ✅ | Descargar stems |
131
+
132
+ ---
133
+
134
+ ## 📋 Checklist de Despliegue Android
135
+
136
+ ### En el PC (Servidor)
137
+
138
+ - [x] Python 3.10+ instalado
139
+ - [x] Redis corriendo (`redis-server`)
140
+ - [x] FFmpeg instalado y en PATH
141
+ - [x] Dependencias Python instaladas (`pip install -r requirements.txt`)
142
+ - [x] API FastAPI corriendo en `0.0.0.0:8000`
143
+ - [x] Worker Celery corriendo
144
+ - [x] Túnel Cloudflare activo (opcional)
145
+ - [x] Firewall permite puerto 8000
146
+
147
+ ### En Android
148
+
149
+ - [ ] .NET MAUI app compilada e instalada
150
+ - [ ] Android conectado a misma red WiFi
151
+ - [ ] Permisos de Internet concedidos
152
+ - [ ] Permisos de almacenamiento concedidos
153
+
154
+ ---
155
+
156
+ ## 🚀 Cómo Probar la App en Android
157
+
158
+ ### Paso 1: Iniciar la API
159
+
160
+ ```bash
161
+ cd c:\mfmm
162
+ iniciar_melodix.bat
163
+ ```
164
+
165
+ Esperar a que las 3 ventanas estén activas:
166
+ 1. **API Melodix** - Puerto 8000
167
+ 2. **Worker Celery** - Procesando tareas
168
+ 3. **Túnel Cloudflare** - URL pública
169
+
170
+ ### Paso 2: Verificar API
171
+
172
+ ```bash
173
+ curl http://localhost:8000/health
174
+ # Debe responder: {"status":"healthy","api":"running","version":"2.0.0"}
175
+ ```
176
+
177
+ ### Paso 3: Compilar MAUI para Android
178
+
179
+ ```bash
180
+ cd c:\mfmm\Melodix
181
+
182
+ # Opción A: Deploy directo a dispositivo conectado (USB Debugging)
183
+ dotnet build -t:Run -f net9.0-android -d <device-id>
184
+
185
+ # Opción B: Generar APK
186
+ dotnet publish -f net9.0-android -c Release
187
+ # APK en: bin\Release\net9.0-android\publish\*.apk
188
+ ```
189
+
190
+ ### Paso 4: Probar Login
191
+
192
+ 1. Abrir app en Android
193
+ 2. Usar credenciales de prueba:
194
+ - Usuario: `testuser_integration`
195
+ - Contraseña: `password123`
196
+ 3. Debería navegar a MainDesktopPage
197
+
198
+ ### Paso 5: Probar Subida de Canción
199
+
200
+ 1. Tocar botón "Subir" o "Seleccionar archivo"
201
+ 2. Elegir archivo de audio (MP3, WAV, FLAC)
202
+ 3. Seleccionar número de stems (2, 4, 6)
203
+ 4. Esperar procesamiento (~30 segundos)
204
+ 5. Las pistas deberían descargarse automáticamente
205
+
206
+ ---
207
+
208
+ ## 🐛 Solución de Problemas
209
+
210
+ ### "No se pudo conectar al servidor"
211
+
212
+ **Causa:** API no está accesible desde la red
213
+
214
+ **Solución:**
215
+ ```bash
216
+ # 1. Verificar que API escucha en 0.0.0.0
217
+ netstat -an | findstr 8000
218
+ # Debe mostrar: 0.0.0.0:8000 LISTENING
219
+
220
+ # 2. Permitir en firewall
221
+ netsh advfirewall firewall add rule name="Melodix API" dir=in action=allow protocol=TCP localport=8000
222
+
223
+ # 3. Verificar IP
224
+ ipconfig
225
+ # Actualizar .env con IP correcta
226
+ ```
227
+
228
+ ### "Token inválido o expirado"
229
+
230
+ **Causa:** El túnel Cloudflare cambió de URL
231
+
232
+ **Solución:**
233
+ ```bash
234
+ # Reiniciar túnel
235
+ python start_tunnel.py
236
+
237
+ # En Android, forzar cierre y reapertura de app
238
+ # La app descargará la nueva URL automáticamente
239
+ ```
240
+
241
+ ### "Celery no procesa tareas"
242
+
243
+ **Causa:** Worker detenido
244
+
245
+ **Solución:**
246
+ ```bash
247
+ # Reiniciar worker
248
+ celery -A tasks worker --loglevel=info -P solo
249
+
250
+ # Presionar una tecla si la ventana está congelada
251
+ ```
252
+
253
+ ### "Archivos no se descargan"
254
+
255
+ **Causa:** Ruta incorrecta o permisos
256
+
257
+ **Solución:**
258
+ - Verificar logs en `logs/melodix.log`
259
+ - Revisar que `/audios/` está montado correctamente
260
+ - Verificar permisos de almacenamiento en Android
261
+
262
+ ---
263
+
264
+ ## 📊 Rendimiento Esperado
265
+
266
+ ### Tiempos de Procesamiento (GPU GTX 1650)
267
+
268
+ | Tipo | Duración Canción | Tiempo Procesamiento |
269
+ |------|------------------|---------------------|
270
+ | 2 stems | 3 min | ~25-35 seg |
271
+ | 4 stems | 3 min | ~40-50 seg |
272
+ | 6 stems | 3 min | ~60-80 seg |
273
+
274
+ ### Uso de Recursos
275
+
276
+ | Recurso | Uso |
277
+ |---------|-----|
278
+ | GPU VRAM | 3.2 GB |
279
+ | RAM | 4-6 GB |
280
+ | CPU | 30-50% |
281
+ | Red (subida) | 5-10 Mbps |
282
+ | Red (bajada) | 10-20 Mbps |
283
+
284
+ ---
285
+
286
+ ## 🎯 Próximos Pasos
287
+
288
+ ### Inmediatos
289
+
290
+ 1. **Probar en dispositivo físico Android**
291
+ - Conectar vía USB
292
+ - Habilitar USB Debugging
293
+ - Deploy directo desde Visual Studio
294
+
295
+ 2. **Verificar flujo completo**
296
+ - Login → Subida → Procesamiento → Descarga → Studio
297
+
298
+ 3. **Ajustar UI/UX si es necesario**
299
+ - Revisar layouts en diferentes tamaños de pantalla
300
+ - Verificar que los colores sean legibles
301
+
302
+ ### Futuros
303
+
304
+ 1. **Publicar en Google Play Store**
305
+ - Generar signing key
306
+ - Crear assets gráficos
307
+ - Preparar descripción y screenshots
308
+
309
+ 2. **Implementar compras in-app**
310
+ - Sistema de créditos real
311
+ - Integración con Google Play Billing
312
+
313
+ 3. **Características adicionales**
314
+ - Modo offline (procesamiento local)
315
+ - Sincronización en la nube
316
+ - Compartir proyectos
317
+
318
+ ---
319
+
320
+ ## 📝 Conclusión
321
+
322
+ **El proyecto MAUI está 100% compilado y listo para pruebas en Android.**
323
+
324
+ ### URLs de Acceso
325
+
326
+ | Tipo | URL | Estado |
327
+ |------|-----|--------|
328
+ | Local (PC) | http://localhost:8000 | ✅ |
329
+ | Red Local | http://192.168.1.10:8000 | ✅ |
330
+ | Cloudflare | Ver `tunnel_url.txt` | ⚠️ Cambia al reiniciar |
331
+ | Swagger Docs | http://localhost:8000/docs | ✅ |
332
+
333
+ ### Archivos Clave
334
+
335
+ | Archivo | Propósito |
336
+ |---------|-----------|
337
+ | `Services/AuthService.cs` | Autenticación dinámica |
338
+ | `Services/ApiService.cs` | Comunicación con API |
339
+ | `Helpers/DeviceHelper.cs` | ID único dispositivo |
340
+ | `Platforms/Android/AndroidManifest.xml` | Permisos Android |
341
+
342
+ ### Contacto y Soporte
343
+
344
+ - **Logs API:** `logs/melodix.log`
345
+ - **Logs Android:** `adb logcat | findstr Melodix`
346
+ - **Documentación:** `README.md`
347
+ - **Tests:** `tests/android_test.py`
348
+
349
+ ---
350
+
351
+ **¡Todo listo para desplegar en Android! 🚀**
QUICK_START.md CHANGED
@@ -1,283 +1,283 @@
1
- # 🚀 Melodix - Inicio Rápido de Servicios
2
-
3
- ## 📋 Scripts Disponibles
4
-
5
- ### Opción 1: Batch (Recomendado para Windows)
6
-
7
- ```bash
8
- start_services.bat
9
- ```
10
-
11
- **Características:**
12
- - ✅ Verifica Redis automáticamente
13
- - ✅ Inicia API FastAPI
14
- - ✅ Inicia Worker Celery
15
- - ✅ Opción para túnel Cloudflare
16
- - ✅ Muestra IPs de acceso
17
- - ✅ Detección de errores
18
-
19
- **Comandos:**
20
- ```bash
21
- start_services.bat # Iniciar todo
22
- start_services.bat stop # Detener servicios
23
- start_services.bat help # Ayuda
24
- ```
25
-
26
- ---
27
-
28
- ### Opción 2: PowerShell
29
-
30
- ```bash
31
- .\start_services.ps1
32
- ```
33
-
34
- **Nota:** Si tienes problemas de ejecución, ejecuta:
35
- ```bash
36
- Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
37
- ```
38
-
39
- ---
40
-
41
- ### Opción 3: Manual (3 terminales)
42
-
43
- **Terminal 1 - API:**
44
- ```bash
45
- cd c:\mfmm
46
- venv_musical\Scripts\activate
47
- uvicorn main:app --host 0.0.0.0 --port 8000
48
- ```
49
-
50
- **Terminal 2 - Worker:**
51
- ```bash
52
- cd c:\mfmm
53
- venv_musical\Scripts\activate
54
- celery -A tasks worker --loglevel=info -P solo
55
- ```
56
-
57
- **Terminal 3 - Túnel (opcional):**
58
- ```bash
59
- cd c:\mfmm
60
- venv_musical\Scripts\activate
61
- python start_tunnel.py
62
- ```
63
-
64
- ---
65
-
66
- ## ✅ Requisitos Previos
67
-
68
- ### 1. Redis
69
-
70
- **Verificar:**
71
- ```bash
72
- redis-cli ping
73
- # Debe responder: PONG
74
- ```
75
-
76
- **Iniciar:**
77
- ```bash
78
- redis-server
79
- ```
80
-
81
- **O como servicio Windows:**
82
- ```bash
83
- net start Redis
84
- ```
85
-
86
- ---
87
-
88
- ### 2. Entorno Virtual
89
-
90
- **Crear (si no existe):**
91
- ```bash
92
- python -m venv venv_musical
93
- ```
94
-
95
- **Activar:**
96
- ```bash
97
- venv_musical\Scripts\activate
98
- ```
99
-
100
- **Instalar dependencias:**
101
- ```bash
102
- pip install -r requirements.txt
103
- ```
104
-
105
- ---
106
-
107
- ### 3. FFmpeg (Recomendado)
108
-
109
- **Verificar:**
110
- ```bash
111
- ffmpeg -version
112
- ```
113
-
114
- **Instalar (Chocolatey):**
115
- ```bash
116
- choco install ffmpeg
117
- ```
118
-
119
- **O descargar desde:** https://ffmpeg.org/download.html
120
-
121
- ---
122
-
123
- ## 🔧 Solución de Problemas
124
-
125
- ### "Redis no está corriendo"
126
-
127
- ```bash
128
- # Verificar si el servicio existe
129
- sc query Redis
130
-
131
- # Iniciar servicio
132
- net start Redis
133
-
134
- # O ejecutar directamente
135
- redis-server
136
- ```
137
-
138
- ---
139
-
140
- ### "Puerto 8000 ya está en uso"
141
-
142
- ```bash
143
- # Ver qué está usando el puerto
144
- netstat -ano | findstr :8000
145
-
146
- # Matar el proceso (reemplaza PID con el número real)
147
- taskkill /F /PID <PID>
148
- ```
149
-
150
- ---
151
-
152
- ### "FFmpeg no encontrado"
153
-
154
- Algunas funciones de audio requieren FFmpeg. Si no lo necesitas inmediatamente, puedes ignorar esta advertencia y la app funcionará parcialmente.
155
-
156
- ---
157
-
158
- ### "La API no responde"
159
-
160
- 1. Verifica que Redis esté corriendo
161
- 2. Revisa los logs en `logs\api.log`
162
- 3. Prueba acceder manualmente: http://localhost:8000/health
163
-
164
- ---
165
-
166
- ## 📱 Acceso desde Android
167
-
168
- ### Red Local
169
-
170
- 1. **Asegúrate que ambos dispositivos están en la misma red WiFi**
171
-
172
- 2. **Usa la IP que muestra el script:**
173
- ```
174
- http://192.168.1.X:8000
175
- ```
176
-
177
- 3. **Permite el puerto en el firewall:**
178
- ```bash
179
- netsh advfirewall firewall add rule name="Melodix API" dir=in action=allow protocol=TCP localport=8000
180
- ```
181
-
182
- ---
183
-
184
- ### Cloudflare Tunnel (Acceso Remoto)
185
-
186
- 1. **Inicia el túnel cuando el script lo pregunte**
187
-
188
- 2. **La URL se guarda en `tunnel_url.txt`**
189
-
190
- 3. **Usa esa URL desde cualquier lugar del mundo**
191
-
192
- ---
193
-
194
- ## 📊 URLs de Acceso
195
-
196
- | Servicio | URL |
197
- |----------|-----|
198
- | API Local | http://localhost:8000 |
199
- | API Red Local | http://192.168.1.X:8000 |
200
- | Swagger Docs | http://localhost:8000/docs |
201
- | ReDoc | http://localhost:8000/redoc |
202
- | Health Check | http://localhost:8000/health |
203
- | Cloudflare Tunnel | Ver `tunnel_url.txt` |
204
-
205
- ---
206
-
207
- ## 🛑 Detener Servicios
208
-
209
- ### Método 1: Script
210
- ```bash
211
- start_services.bat stop
212
- ```
213
-
214
- ### Método 2: Manual
215
- Cierra las 3 ventanas de consola abiertas
216
-
217
- ### Método 3: Forzado
218
- ```bash
219
- taskkill /F /FI "WINDOWTITLE eq API Melodix*"
220
- taskkill /F /FI "WINDOWTITLE eq Worker Celery*"
221
- taskkill /F /FI "WINDOWTITLE eq Tunel Cloudflare*"
222
- ```
223
-
224
- ---
225
-
226
- ## 📝 Logs
227
-
228
- Los logs se guardan en:
229
-
230
- ```
231
- c:\mfmm\logs\
232
- ├── api.log # Logs de la API FastAPI
233
- ├── celery.log # Logs del worker Celery
234
- └── melodix.log # Logs generales de la app
235
- ```
236
-
237
- **Ver logs en tiempo real:**
238
- ```bash
239
- # PowerShell
240
- Get-Content logs\melodix.log -Wait -Tail 50
241
-
242
- # CMD
243
- type logs\melodix.log
244
- ```
245
-
246
- ---
247
-
248
- ## 🎯 Flujo Típico de Uso
249
-
250
- ```bash
251
- # 1. Iniciar Redis (si no está corriendo como servicio)
252
- redis-server
253
-
254
- # 2. Iniciar todos los servicios
255
- start_services.bat
256
-
257
- # 3. Esperar a que las 3 ventanas se abran
258
-
259
- # 4. Probar la API
260
- curl http://localhost:8000/health
261
-
262
- # 5. Abrir Swagger para explorar
263
- http://localhost:8000/docs
264
-
265
- # 6. Conectar app Android (misma red)
266
- # URL: http://192.168.1.X:8000
267
-
268
- # 7. Al terminar, cerrar ventanas o usar:
269
- start_services.bat stop
270
- ```
271
-
272
- ---
273
-
274
- ## 💡 Tips
275
-
276
- - **Inicio automático de Redis:** Configura el servicio de Redis para que inicie con Windows
277
- - **Acceso rápido:** Agrega `start_services.bat` a tus accesos directos
278
- - **Monitoreo:** Revisa `logs\melodix.log` para ver actividad detallada
279
- - **Testing:** Usa Swagger UI (`/docs`) para probar endpoints manualmente
280
-
281
- ---
282
-
283
- **¡Listo! Tu servidor Melodix está operativo 🎵**
 
1
+ # 🚀 Melodix - Inicio Rápido de Servicios
2
+
3
+ ## 📋 Scripts Disponibles
4
+
5
+ ### Opción 1: Batch (Recomendado para Windows)
6
+
7
+ ```bash
8
+ start_services.bat
9
+ ```
10
+
11
+ **Características:**
12
+ - ✅ Verifica Redis automáticamente
13
+ - ✅ Inicia API FastAPI
14
+ - ✅ Inicia Worker Celery
15
+ - ✅ Opción para túnel Cloudflare
16
+ - ✅ Muestra IPs de acceso
17
+ - ✅ Detección de errores
18
+
19
+ **Comandos:**
20
+ ```bash
21
+ start_services.bat # Iniciar todo
22
+ start_services.bat stop # Detener servicios
23
+ start_services.bat help # Ayuda
24
+ ```
25
+
26
+ ---
27
+
28
+ ### Opción 2: PowerShell
29
+
30
+ ```bash
31
+ .\start_services.ps1
32
+ ```
33
+
34
+ **Nota:** Si tienes problemas de ejecución, ejecuta:
35
+ ```bash
36
+ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
37
+ ```
38
+
39
+ ---
40
+
41
+ ### Opción 3: Manual (3 terminales)
42
+
43
+ **Terminal 1 - API:**
44
+ ```bash
45
+ cd c:\mfmm
46
+ venv_musical\Scripts\activate
47
+ uvicorn main:app --host 0.0.0.0 --port 8000
48
+ ```
49
+
50
+ **Terminal 2 - Worker:**
51
+ ```bash
52
+ cd c:\mfmm
53
+ venv_musical\Scripts\activate
54
+ celery -A tasks worker --loglevel=info -P solo
55
+ ```
56
+
57
+ **Terminal 3 - Túnel (opcional):**
58
+ ```bash
59
+ cd c:\mfmm
60
+ venv_musical\Scripts\activate
61
+ python start_tunnel.py
62
+ ```
63
+
64
+ ---
65
+
66
+ ## ✅ Requisitos Previos
67
+
68
+ ### 1. Redis
69
+
70
+ **Verificar:**
71
+ ```bash
72
+ redis-cli ping
73
+ # Debe responder: PONG
74
+ ```
75
+
76
+ **Iniciar:**
77
+ ```bash
78
+ redis-server
79
+ ```
80
+
81
+ **O como servicio Windows:**
82
+ ```bash
83
+ net start Redis
84
+ ```
85
+
86
+ ---
87
+
88
+ ### 2. Entorno Virtual
89
+
90
+ **Crear (si no existe):**
91
+ ```bash
92
+ python -m venv venv_musical
93
+ ```
94
+
95
+ **Activar:**
96
+ ```bash
97
+ venv_musical\Scripts\activate
98
+ ```
99
+
100
+ **Instalar dependencias:**
101
+ ```bash
102
+ pip install -r requirements.txt
103
+ ```
104
+
105
+ ---
106
+
107
+ ### 3. FFmpeg (Recomendado)
108
+
109
+ **Verificar:**
110
+ ```bash
111
+ ffmpeg -version
112
+ ```
113
+
114
+ **Instalar (Chocolatey):**
115
+ ```bash
116
+ choco install ffmpeg
117
+ ```
118
+
119
+ **O descargar desde:** https://ffmpeg.org/download.html
120
+
121
+ ---
122
+
123
+ ## 🔧 Solución de Problemas
124
+
125
+ ### "Redis no está corriendo"
126
+
127
+ ```bash
128
+ # Verificar si el servicio existe
129
+ sc query Redis
130
+
131
+ # Iniciar servicio
132
+ net start Redis
133
+
134
+ # O ejecutar directamente
135
+ redis-server
136
+ ```
137
+
138
+ ---
139
+
140
+ ### "Puerto 8000 ya está en uso"
141
+
142
+ ```bash
143
+ # Ver qué está usando el puerto
144
+ netstat -ano | findstr :8000
145
+
146
+ # Matar el proceso (reemplaza PID con el número real)
147
+ taskkill /F /PID <PID>
148
+ ```
149
+
150
+ ---
151
+
152
+ ### "FFmpeg no encontrado"
153
+
154
+ Algunas funciones de audio requieren FFmpeg. Si no lo necesitas inmediatamente, puedes ignorar esta advertencia y la app funcionará parcialmente.
155
+
156
+ ---
157
+
158
+ ### "La API no responde"
159
+
160
+ 1. Verifica que Redis esté corriendo
161
+ 2. Revisa los logs en `logs\api.log`
162
+ 3. Prueba acceder manualmente: http://localhost:8000/health
163
+
164
+ ---
165
+
166
+ ## 📱 Acceso desde Android
167
+
168
+ ### Red Local
169
+
170
+ 1. **Asegúrate que ambos dispositivos están en la misma red WiFi**
171
+
172
+ 2. **Usa la IP que muestra el script:**
173
+ ```
174
+ http://192.168.1.X:8000
175
+ ```
176
+
177
+ 3. **Permite el puerto en el firewall:**
178
+ ```bash
179
+ netsh advfirewall firewall add rule name="Melodix API" dir=in action=allow protocol=TCP localport=8000
180
+ ```
181
+
182
+ ---
183
+
184
+ ### Cloudflare Tunnel (Acceso Remoto)
185
+
186
+ 1. **Inicia el túnel cuando el script lo pregunte**
187
+
188
+ 2. **La URL se guarda en `tunnel_url.txt`**
189
+
190
+ 3. **Usa esa URL desde cualquier lugar del mundo**
191
+
192
+ ---
193
+
194
+ ## 📊 URLs de Acceso
195
+
196
+ | Servicio | URL |
197
+ |----------|-----|
198
+ | API Local | http://localhost:8000 |
199
+ | API Red Local | http://192.168.1.X:8000 |
200
+ | Swagger Docs | http://localhost:8000/docs |
201
+ | ReDoc | http://localhost:8000/redoc |
202
+ | Health Check | http://localhost:8000/health |
203
+ | Cloudflare Tunnel | Ver `tunnel_url.txt` |
204
+
205
+ ---
206
+
207
+ ## 🛑 Detener Servicios
208
+
209
+ ### Método 1: Script
210
+ ```bash
211
+ start_services.bat stop
212
+ ```
213
+
214
+ ### Método 2: Manual
215
+ Cierra las 3 ventanas de consola abiertas
216
+
217
+ ### Método 3: Forzado
218
+ ```bash
219
+ taskkill /F /FI "WINDOWTITLE eq API Melodix*"
220
+ taskkill /F /FI "WINDOWTITLE eq Worker Celery*"
221
+ taskkill /F /FI "WINDOWTITLE eq Tunel Cloudflare*"
222
+ ```
223
+
224
+ ---
225
+
226
+ ## 📝 Logs
227
+
228
+ Los logs se guardan en:
229
+
230
+ ```
231
+ c:\mfmm\logs\
232
+ ├── api.log # Logs de la API FastAPI
233
+ ├── celery.log # Logs del worker Celery
234
+ └── melodix.log # Logs generales de la app
235
+ ```
236
+
237
+ **Ver logs en tiempo real:**
238
+ ```bash
239
+ # PowerShell
240
+ Get-Content logs\melodix.log -Wait -Tail 50
241
+
242
+ # CMD
243
+ type logs\melodix.log
244
+ ```
245
+
246
+ ---
247
+
248
+ ## 🎯 Flujo Típico de Uso
249
+
250
+ ```bash
251
+ # 1. Iniciar Redis (si no está corriendo como servicio)
252
+ redis-server
253
+
254
+ # 2. Iniciar todos los servicios
255
+ start_services.bat
256
+
257
+ # 3. Esperar a que las 3 ventanas se abran
258
+
259
+ # 4. Probar la API
260
+ curl http://localhost:8000/health
261
+
262
+ # 5. Abrir Swagger para explorar
263
+ http://localhost:8000/docs
264
+
265
+ # 6. Conectar app Android (misma red)
266
+ # URL: http://192.168.1.X:8000
267
+
268
+ # 7. Al terminar, cerrar ventanas o usar:
269
+ start_services.bat stop
270
+ ```
271
+
272
+ ---
273
+
274
+ ## 💡 Tips
275
+
276
+ - **Inicio automático de Redis:** Configura el servicio de Redis para que inicie con Windows
277
+ - **Acceso rápido:** Agrega `start_services.bat` a tus accesos directos
278
+ - **Monitoreo:** Revisa `logs\melodix.log` para ver actividad detallada
279
+ - **Testing:** Usa Swagger UI (`/docs`) para probar endpoints manualmente
280
+
281
+ ---
282
+
283
+ **¡Listo! Tu servidor Melodix está operativo 🎵**
START_SCRIPT_README.md CHANGED
@@ -1,282 +1,282 @@
1
- # 🚀 Melodix - Script de Inicio Automático
2
-
3
- ## 📁 Archivos Creados
4
-
5
- | Archivo | Propósito |
6
- |---------|-----------|
7
- | `start_services.bat` | Script principal para Windows (CMD) |
8
- | `start_services.ps1` | Script alternativo para PowerShell |
9
- | `QUICK_START.md` | Documentación rápida |
10
-
11
- ---
12
-
13
- ## ⚡ Uso Rápido
14
-
15
- ### Iniciar Todo
16
-
17
- **Doble clic** en `start_services.bat`
18
-
19
- O desde consola:
20
- ```bash
21
- cd c:\mfmm
22
- start_services.bat
23
- ```
24
-
25
- ---
26
-
27
- ## 🎯 Qué Hace el Script
28
-
29
- ### Paso 1: Verificar Redis ✅
30
- - Comprueba que Redis esté corriendo
31
- - Si no, muestra instrucciones para iniciarlo
32
-
33
- ### Paso 2: Verificar Entorno ✅
34
- - Valida que el entorno virtual exista
35
- - Verifica FFmpeg (opcional)
36
-
37
- ### Paso 3: Mostrar Red ✅
38
- - Obtiene tu IP local automáticamente
39
- - Muestra URLs de acceso
40
-
41
- ### Paso 4: Iniciar API ✅
42
- - Lanza FastAPI en puerto 8000
43
- - Espera a que esté disponible
44
- - Abre ventana "API Melodix"
45
-
46
- ### Paso 5: Iniciar Celery ✅
47
- - Lanza worker de procesamiento
48
- - Usa GPU para acelerar
49
- - Abre ventana "Worker Celery"
50
-
51
- ### Paso 6: Túnel Cloudflare ❓
52
- - Pregunta si deseas iniciarlo
53
- - Opcional (recomendado para acceso remoto)
54
- - Abre ventana "Tunel Cloudflare"
55
-
56
- ---
57
-
58
- ## 🖥️ Ventanas que se Abren
59
-
60
- ```
61
- ┌─────────────────────────────────────────┐
62
- │ API Melodix │
63
- │ - FastAPI en puerto 8000 │
64
- │ - No cerrar mientras uses la app │
65
- └─────────────────────────────────────────┘
66
-
67
- ┌─────────────────────────────────────────┐
68
- │ Worker Celery (GPU) │
69
- │ - Procesamiento de audio con IA │
70
- │ - Usa tu GPU para acelerar │
71
- └─────────────────────────────────────────┘
72
-
73
- ┌─────────────────────────────────────────┐
74
- │ Tunel Cloudflare │
75
- │ - Expone tu API a internet │
76
- │ - Opcional, solo si necesitas acceso │
77
- │ remoto │
78
- └─────────────────────────────────────────┘
79
- ```
80
-
81
- ---
82
-
83
- ## 🔧 Comandos Disponibles
84
-
85
- ### Iniciar Servicios
86
- ```bash
87
- start_services.bat
88
- ```
89
-
90
- ### Detener Servicios
91
- ```bash
92
- start_services.bat stop
93
- ```
94
-
95
- ### Ver Ayuda
96
- ```bash
97
- start_services.bat help
98
- ```
99
-
100
- ---
101
-
102
- ## 📱 URLs Después de Iniciar
103
-
104
- El script te mostrará:
105
-
106
- ```
107
- IP Local: 192.168.1.10
108
-
109
- URLs de acceso:
110
- - Local: http://localhost:8000
111
- - Red: http://192.168.1.10:8000
112
- - Swagger: http://localhost:8000/docs
113
- ```
114
-
115
- ---
116
-
117
- ## ✅ Checklist de Verificación
118
-
119
- Después de ejecutar el script:
120
-
121
- - [ ] Ventana "API Melodix" está abierta
122
- - [ ] Ventana "Worker Celery" está abierta
123
- - [ ] Ventana "Tunel Cloudflare" (si elegiste sí)
124
- - [ ] Puedes acceder a http://localhost:8000/health
125
- - [ ] Redis está corriendo (verificó el script)
126
-
127
- ---
128
-
129
- ## 🐛 Problemas Comunes
130
-
131
- ### "Redis NO está corriendo"
132
-
133
- **Solución:**
134
- ```bash
135
- redis-server
136
- ```
137
-
138
- O inicia el servicio de Windows:
139
- ```bash
140
- net start Redis
141
- ```
142
-
143
- ---
144
-
145
- ### "Ya hay algo corriendo en el puerto 8000"
146
-
147
- **Opción A:** Detener el otro servicio
148
- ```bash
149
- start_services.bat stop
150
- ```
151
-
152
- **Opción B:** Matar proceso
153
- ```bash
154
- netstat -ano | findstr :8000
155
- taskkill /F /PID <NUMERO>
156
- ```
157
-
158
- ---
159
-
160
- ### "La API no respondió"
161
-
162
- 1. Revisa la ventana "API Melodix" por errores
163
- 2. Verifica logs: `logs\api.log`
164
- 3. Prueba manualmente: http://localhost:8000/health
165
-
166
- ---
167
-
168
- ## 📊 Flujo Completo
169
-
170
- ```
171
- 1. Ejecutar start_services.bat
172
- ↓
173
- 2. Script verifica Redis
174
- ↓
175
- 3. Script verifica entorno Python
176
- ↓
177
- 4. Script muestra IP local
178
- ↓
179
- 5. Inicia API FastAPI (ventana 1)
180
- ↓
181
- 6. Inicia Worker Celery (ventana 2)
182
- ↓
183
- 7. Pregunta por Cloudflare (ventana 3 opcional)
184
- ↓
185
- 8. ¡LISTO! Sistema operativo
186
- ```
187
-
188
- ---
189
-
190
- ## 🛑 Para Detener
191
-
192
- ### Método Recomendado
193
- ```bash
194
- start_services.bat stop
195
- ```
196
-
197
- ### Método Manual
198
- Cierra las 3 ventanas de consola
199
-
200
- ---
201
-
202
- ## 💡 Tips Pro
203
-
204
- ### 1. Acceso desde Android
205
-
206
- 1. Ejecuta `start_services.bat`
207
- 2. Anota la IP que muestra (ej: 192.168.1.10)
208
- 3. En tu Android, usa: `http://192.168.1.10:8000`
209
-
210
- ### 2. Acceso Remoto (Cloudflare)
211
-
212
- 1. Ejecuta `start_services.bat`
213
- 2. Responde "Y" cuando pregunte por Cloudflare
214
- 3. Espera a que se genere la URL en `tunnel_url.txt`
215
- 4. Usa esa URL desde cualquier lugar del mundo
216
-
217
- ### 3. Monitoreo
218
-
219
- ```bash
220
- # Ver logs en tiempo real (PowerShell)
221
- Get-Content logs\melodix.log -Wait -Tail 50
222
-
223
- # Ver logs de API
224
- Get-Content logs\api.log -Wait -Tail 50
225
-
226
- # Ver logs de Celery
227
- Get-Content logs\celery.log -Wait -Tail 50
228
- ```
229
-
230
- ### 4. Inicio Automático con Windows
231
-
232
- Crea un acceso directo en la carpeta de Inicio:
233
-
234
- 1. `Win + R` → `shell:startup`
235
- 2. Crea acceso directo a `start_services.bat`
236
- 3. Edita el acceso directo → Propiedades
237
- 4. En "Ejecutar" selecciona: "Minimizada"
238
-
239
- ---
240
-
241
- ## 📝 Estructura de Archivos
242
-
243
- ```
244
- c:\mfmm\
245
- ├── start_services.bat # Script principal
246
- ├── start_services.ps1 # Script PowerShell
247
- ├── QUICK_START.md # Guía rápida
248
- ├── logs\
249
- │ ├── api.log # Logs de API
250
- │ ├── celery.log # Logs de Celery
251
- │ └── melodix.log # Logs generales
252
- └── tunnel_url.txt # URL de Cloudflare (se crea al iniciar túnel)
253
- ```
254
-
255
- ---
256
-
257
- ## 🎯 Próximos Pasos
258
-
259
- Después de iniciar los servicios:
260
-
261
- 1. **Verifica que la API responde:**
262
- ```bash
263
- curl http://localhost:8000/health
264
- ```
265
-
266
- 2. **Abre Swagger UI:**
267
- ```
268
- http://localhost:8000/docs
269
- ```
270
-
271
- 3. **Prueba la app MAUI:**
272
- - Abre Visual Studio
273
- - Compila y ejecuta Melodix
274
- - Inicia sesión y prueba
275
-
276
- 4. **Conecta desde Android:**
277
- - Mismo WiFi
278
- - Usa IP local mostrada por el script
279
-
280
- ---
281
-
282
- **¡Todo listo! Tu servidor Melodix está operativo 🎵**
 
1
+ # 🚀 Melodix - Script de Inicio Automático
2
+
3
+ ## 📁 Archivos Creados
4
+
5
+ | Archivo | Propósito |
6
+ |---------|-----------|
7
+ | `start_services.bat` | Script principal para Windows (CMD) |
8
+ | `start_services.ps1` | Script alternativo para PowerShell |
9
+ | `QUICK_START.md` | Documentación rápida |
10
+
11
+ ---
12
+
13
+ ## ⚡ Uso Rápido
14
+
15
+ ### Iniciar Todo
16
+
17
+ **Doble clic** en `start_services.bat`
18
+
19
+ O desde consola:
20
+ ```bash
21
+ cd c:\mfmm
22
+ start_services.bat
23
+ ```
24
+
25
+ ---
26
+
27
+ ## 🎯 Qué Hace el Script
28
+
29
+ ### Paso 1: Verificar Redis ✅
30
+ - Comprueba que Redis esté corriendo
31
+ - Si no, muestra instrucciones para iniciarlo
32
+
33
+ ### Paso 2: Verificar Entorno ✅
34
+ - Valida que el entorno virtual exista
35
+ - Verifica FFmpeg (opcional)
36
+
37
+ ### Paso 3: Mostrar Red ✅
38
+ - Obtiene tu IP local automáticamente
39
+ - Muestra URLs de acceso
40
+
41
+ ### Paso 4: Iniciar API ✅
42
+ - Lanza FastAPI en puerto 8000
43
+ - Espera a que esté disponible
44
+ - Abre ventana "API Melodix"
45
+
46
+ ### Paso 5: Iniciar Celery ✅
47
+ - Lanza worker de procesamiento
48
+ - Usa GPU para acelerar
49
+ - Abre ventana "Worker Celery"
50
+
51
+ ### Paso 6: Túnel Cloudflare ❓
52
+ - Pregunta si deseas iniciarlo
53
+ - Opcional (recomendado para acceso remoto)
54
+ - Abre ventana "Tunel Cloudflare"
55
+
56
+ ---
57
+
58
+ ## 🖥️ Ventanas que se Abren
59
+
60
+ ```
61
+ ┌─────────────────────────────────────────┐
62
+ │ API Melodix │
63
+ │ - FastAPI en puerto 8000 │
64
+ │ - No cerrar mientras uses la app │
65
+ └─────────────────────────────────────────┘
66
+
67
+ ┌─────────────────────────────────────────┐
68
+ │ Worker Celery (GPU) │
69
+ │ - Procesamiento de audio con IA │
70
+ │ - Usa tu GPU para acelerar │
71
+ └─────────────────────────────────────────┘
72
+
73
+ ┌─────────────────────────────────────────┐
74
+ │ Tunel Cloudflare │
75
+ │ - Expone tu API a internet │
76
+ │ - Opcional, solo si necesitas acceso │
77
+ │ remoto │
78
+ └─────────────────────────────────────────┘
79
+ ```
80
+
81
+ ---
82
+
83
+ ## 🔧 Comandos Disponibles
84
+
85
+ ### Iniciar Servicios
86
+ ```bash
87
+ start_services.bat
88
+ ```
89
+
90
+ ### Detener Servicios
91
+ ```bash
92
+ start_services.bat stop
93
+ ```
94
+
95
+ ### Ver Ayuda
96
+ ```bash
97
+ start_services.bat help
98
+ ```
99
+
100
+ ---
101
+
102
+ ## 📱 URLs Después de Iniciar
103
+
104
+ El script te mostrará:
105
+
106
+ ```
107
+ IP Local: 192.168.1.10
108
+
109
+ URLs de acceso:
110
+ - Local: http://localhost:8000
111
+ - Red: http://192.168.1.10:8000
112
+ - Swagger: http://localhost:8000/docs
113
+ ```
114
+
115
+ ---
116
+
117
+ ## ✅ Checklist de Verificación
118
+
119
+ Después de ejecutar el script:
120
+
121
+ - [ ] Ventana "API Melodix" está abierta
122
+ - [ ] Ventana "Worker Celery" está abierta
123
+ - [ ] Ventana "Tunel Cloudflare" (si elegiste sí)
124
+ - [ ] Puedes acceder a http://localhost:8000/health
125
+ - [ ] Redis está corriendo (verificó el script)
126
+
127
+ ---
128
+
129
+ ## 🐛 Problemas Comunes
130
+
131
+ ### "Redis NO está corriendo"
132
+
133
+ **Solución:**
134
+ ```bash
135
+ redis-server
136
+ ```
137
+
138
+ O inicia el servicio de Windows:
139
+ ```bash
140
+ net start Redis
141
+ ```
142
+
143
+ ---
144
+
145
+ ### "Ya hay algo corriendo en el puerto 8000"
146
+
147
+ **Opción A:** Detener el otro servicio
148
+ ```bash
149
+ start_services.bat stop
150
+ ```
151
+
152
+ **Opción B:** Matar proceso
153
+ ```bash
154
+ netstat -ano | findstr :8000
155
+ taskkill /F /PID <NUMERO>
156
+ ```
157
+
158
+ ---
159
+
160
+ ### "La API no respondió"
161
+
162
+ 1. Revisa la ventana "API Melodix" por errores
163
+ 2. Verifica logs: `logs\api.log`
164
+ 3. Prueba manualmente: http://localhost:8000/health
165
+
166
+ ---
167
+
168
+ ## 📊 Flujo Completo
169
+
170
+ ```
171
+ 1. Ejecutar start_services.bat
172
+ ↓
173
+ 2. Script verifica Redis
174
+ ↓
175
+ 3. Script verifica entorno Python
176
+ ↓
177
+ 4. Script muestra IP local
178
+ ↓
179
+ 5. Inicia API FastAPI (ventana 1)
180
+ ↓
181
+ 6. Inicia Worker Celery (ventana 2)
182
+ ↓
183
+ 7. Pregunta por Cloudflare (ventana 3 opcional)
184
+ ↓
185
+ 8. ¡LISTO! Sistema operativo
186
+ ```
187
+
188
+ ---
189
+
190
+ ## 🛑 Para Detener
191
+
192
+ ### Método Recomendado
193
+ ```bash
194
+ start_services.bat stop
195
+ ```
196
+
197
+ ### Método Manual
198
+ Cierra las 3 ventanas de consola
199
+
200
+ ---
201
+
202
+ ## 💡 Tips Pro
203
+
204
+ ### 1. Acceso desde Android
205
+
206
+ 1. Ejecuta `start_services.bat`
207
+ 2. Anota la IP que muestra (ej: 192.168.1.10)
208
+ 3. En tu Android, usa: `http://192.168.1.10:8000`
209
+
210
+ ### 2. Acceso Remoto (Cloudflare)
211
+
212
+ 1. Ejecuta `start_services.bat`
213
+ 2. Responde "Y" cuando pregunte por Cloudflare
214
+ 3. Espera a que se genere la URL en `tunnel_url.txt`
215
+ 4. Usa esa URL desde cualquier lugar del mundo
216
+
217
+ ### 3. Monitoreo
218
+
219
+ ```bash
220
+ # Ver logs en tiempo real (PowerShell)
221
+ Get-Content logs\melodix.log -Wait -Tail 50
222
+
223
+ # Ver logs de API
224
+ Get-Content logs\api.log -Wait -Tail 50
225
+
226
+ # Ver logs de Celery
227
+ Get-Content logs\celery.log -Wait -Tail 50
228
+ ```
229
+
230
+ ### 4. Inicio Automático con Windows
231
+
232
+ Crea un acceso directo en la carpeta de Inicio:
233
+
234
+ 1. `Win + R` → `shell:startup`
235
+ 2. Crea acceso directo a `start_services.bat`
236
+ 3. Edita el acceso directo → Propiedades
237
+ 4. En "Ejecutar" selecciona: "Minimizada"
238
+
239
+ ---
240
+
241
+ ## 📝 Estructura de Archivos
242
+
243
+ ```
244
+ c:\mfmm\
245
+ ├── start_services.bat # Script principal
246
+ ├── start_services.ps1 # Script PowerShell
247
+ ├── QUICK_START.md # Guía rápida
248
+ ├── logs\
249
+ │ ├── api.log # Logs de API
250
+ │ ├── celery.log # Logs de Celery
251
+ │ └── melodix.log # Logs generales
252
+ └── tunnel_url.txt # URL de Cloudflare (se crea al iniciar túnel)
253
+ ```
254
+
255
+ ---
256
+
257
+ ## 🎯 Próximos Pasos
258
+
259
+ Después de iniciar los servicios:
260
+
261
+ 1. **Verifica que la API responde:**
262
+ ```bash
263
+ curl http://localhost:8000/health
264
+ ```
265
+
266
+ 2. **Abre Swagger UI:**
267
+ ```
268
+ http://localhost:8000/docs
269
+ ```
270
+
271
+ 3. **Prueba la app MAUI:**
272
+ - Abre Visual Studio
273
+ - Compila y ejecuta Melodix
274
+ - Inicia sesión y prueba
275
+
276
+ 4. **Conecta desde Android:**
277
+ - Mismo WiFi
278
+ - Usa IP local mostrada por el script
279
+
280
+ ---
281
+
282
+ **¡Todo listo! Tu servidor Melodix está operativo 🎵**
_run_celery.ps1 CHANGED
@@ -1,10 +1,10 @@
1
- Set-Location $PSScriptRoot
2
- Write-Host "================================================" -ForegroundColor Cyan
3
- Write-Host " MELODIX - Celery Worker" -ForegroundColor Cyan
4
- Write-Host "================================================" -ForegroundColor Cyan
5
- Write-Host ""
6
- & ".\venv_musical\Scripts\Activate.ps1"
7
- & ".\venv_musical\Scripts\python.exe" -m celery -A tasks worker --loglevel=info --concurrency=1 -P solo
8
- Write-Host ""
9
- Write-Host "Celery Worker detenido." -ForegroundColor Yellow
10
- Read-Host "Presiona Enter para cerrar"
 
1
+ Set-Location $PSScriptRoot
2
+ Write-Host "================================================" -ForegroundColor Cyan
3
+ Write-Host " MELODIX - Celery Worker" -ForegroundColor Cyan
4
+ Write-Host "================================================" -ForegroundColor Cyan
5
+ Write-Host ""
6
+ & ".\venv_musical\Scripts\Activate.ps1"
7
+ & ".\venv_musical\Scripts\python.exe" -m celery -A tasks worker --loglevel=info --concurrency=1 -P solo
8
+ Write-Host ""
9
+ Write-Host "Celery Worker detenido." -ForegroundColor Yellow
10
+ Read-Host "Presiona Enter para cerrar"
_run_uvicorn.ps1 CHANGED
@@ -1,13 +1,13 @@
1
- Set-Location $PSScriptRoot
2
- Write-Host "================================================" -ForegroundColor Magenta
3
- Write-Host " MELODIX - FastAPI / Uvicorn" -ForegroundColor Magenta
4
- Write-Host "================================================" -ForegroundColor Magenta
5
- Write-Host ""Write-Host ' Local: http://localhost:8000' -ForegroundColor Green
6
- Write-Host ' Red: http://192.168.1.10:8000' -ForegroundColor Green
7
- Write-Host ' Docs: http://localhost:8000/docs' -ForegroundColor Green
8
- Write-Host ''
9
- & ".\venv_musical\Scripts\Activate.ps1"
10
- & ".\venv_musical\Scripts\python.exe" -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
11
- Write-Host ""
12
- Write-Host "Uvicorn detenido." -ForegroundColor Yellow
13
- Read-Host "Presiona Enter para cerrar"
 
1
+ Set-Location $PSScriptRoot
2
+ Write-Host "================================================" -ForegroundColor Magenta
3
+ Write-Host " MELODIX - FastAPI / Uvicorn" -ForegroundColor Magenta
4
+ Write-Host "================================================" -ForegroundColor Magenta
5
+ Write-Host ""Write-Host ' Local: http://localhost:8000' -ForegroundColor Green
6
+ Write-Host ' Red: http://192.168.1.10:8000' -ForegroundColor Green
7
+ Write-Host ' Docs: http://localhost:8000/docs' -ForegroundColor Green
8
+ Write-Host ''
9
+ & ".\venv_musical\Scripts\Activate.ps1"
10
+ & ".\venv_musical\Scripts\python.exe" -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
11
+ Write-Host ""
12
+ Write-Host "Uvicorn detenido." -ForegroundColor Yellow
13
+ Read-Host "Presiona Enter para cerrar"
alembic.ini CHANGED
@@ -1,149 +1,149 @@
1
- # A generic, single database configuration.
2
-
3
- [alembic]
4
- # path to migration scripts.
5
- # this is typically a path given in POSIX (e.g. forward slashes)
6
- # format, relative to the token %(here)s which refers to the location of this
7
- # ini file
8
- script_location = %(here)s/alembic
9
-
10
- # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
11
- # Uncomment the line below if you want the files to be prepended with date and time
12
- # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
13
- # for all available tokens
14
- # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
15
- # Or organize into date-based subdirectories (requires recursive_version_locations = true)
16
- # file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
17
-
18
- # sys.path path, will be prepended to sys.path if present.
19
- # defaults to the current working directory. for multiple paths, the path separator
20
- # is defined by "path_separator" below.
21
- prepend_sys_path = .
22
-
23
-
24
- # timezone to use when rendering the date within the migration file
25
- # as well as the filename.
26
- # If specified, requires the tzdata library which can be installed by adding
27
- # `alembic[tz]` to the pip requirements.
28
- # string value is passed to ZoneInfo()
29
- # leave blank for localtime
30
- # timezone =
31
-
32
- # max length of characters to apply to the "slug" field
33
- # truncate_slug_length = 40
34
-
35
- # set to 'true' to run the environment during
36
- # the 'revision' command, regardless of autogenerate
37
- # revision_environment = false
38
-
39
- # set to 'true' to allow .pyc and .pyo files without
40
- # a source .py file to be detected as revisions in the
41
- # versions/ directory
42
- # sourceless = false
43
-
44
- # version location specification; This defaults
45
- # to <script_location>/versions. When using multiple version
46
- # directories, initial revisions must be specified with --version-path.
47
- # The path separator used here should be the separator specified by "path_separator"
48
- # below.
49
- # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
50
-
51
- # path_separator; This indicates what character is used to split lists of file
52
- # paths, including version_locations and prepend_sys_path within configparser
53
- # files such as alembic.ini.
54
- # The default rendered in new alembic.ini files is "os", which uses os.pathsep
55
- # to provide os-dependent path splitting.
56
- #
57
- # Note that in order to support legacy alembic.ini files, this default does NOT
58
- # take place if path_separator is not present in alembic.ini. If this
59
- # option is omitted entirely, fallback logic is as follows:
60
- #
61
- # 1. Parsing of the version_locations option falls back to using the legacy
62
- # "version_path_separator" key, which if absent then falls back to the legacy
63
- # behavior of splitting on spaces and/or commas.
64
- # 2. Parsing of the prepend_sys_path option falls back to the legacy
65
- # behavior of splitting on spaces, commas, or colons.
66
- #
67
- # Valid values for path_separator are:
68
- #
69
- # path_separator = :
70
- # path_separator = ;
71
- # path_separator = space
72
- # path_separator = newline
73
- #
74
- # Use os.pathsep. Default configuration used for new projects.
75
- path_separator = os
76
-
77
- # set to 'true' to search source files recursively
78
- # in each "version_locations" directory
79
- # new in Alembic version 1.10
80
- # recursive_version_locations = false
81
-
82
- # the output encoding used when revision files
83
- # are written from script.py.mako
84
- # output_encoding = utf-8
85
-
86
- # database URL. This is consumed by the user-maintained env.py script only.
87
- # other means of configuring database URLs may be customized within the env.py
88
- # file.
89
- sqlalchemy.url = driver://user:pass@localhost/dbname
90
-
91
-
92
- [post_write_hooks]
93
- # post_write_hooks defines scripts or Python functions that are run
94
- # on newly generated revision scripts. See the documentation for further
95
- # detail and examples
96
-
97
- # format using "black" - use the console_scripts runner, against the "black" entrypoint
98
- # hooks = black
99
- # black.type = console_scripts
100
- # black.entrypoint = black
101
- # black.options = -l 79 REVISION_SCRIPT_FILENAME
102
-
103
- # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
104
- # hooks = ruff
105
- # ruff.type = module
106
- # ruff.module = ruff
107
- # ruff.options = check --fix REVISION_SCRIPT_FILENAME
108
-
109
- # Alternatively, use the exec runner to execute a binary found on your PATH
110
- # hooks = ruff
111
- # ruff.type = exec
112
- # ruff.executable = ruff
113
- # ruff.options = check --fix REVISION_SCRIPT_FILENAME
114
-
115
- # Logging configuration. This is also consumed by the user-maintained
116
- # env.py script only.
117
- [loggers]
118
- keys = root,sqlalchemy,alembic
119
-
120
- [handlers]
121
- keys = console
122
-
123
- [formatters]
124
- keys = generic
125
-
126
- [logger_root]
127
- level = WARNING
128
- handlers = console
129
- qualname =
130
-
131
- [logger_sqlalchemy]
132
- level = WARNING
133
- handlers =
134
- qualname = sqlalchemy.engine
135
-
136
- [logger_alembic]
137
- level = INFO
138
- handlers =
139
- qualname = alembic
140
-
141
- [handler_console]
142
- class = StreamHandler
143
- args = (sys.stderr,)
144
- level = NOTSET
145
- formatter = generic
146
-
147
- [formatter_generic]
148
- format = %(levelname)-5.5s [%(name)s] %(message)s
149
- datefmt = %H:%M:%S
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts.
5
+ # this is typically a path given in POSIX (e.g. forward slashes)
6
+ # format, relative to the token %(here)s which refers to the location of this
7
+ # ini file
8
+ script_location = %(here)s/alembic
9
+
10
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
11
+ # Uncomment the line below if you want the files to be prepended with date and time
12
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
13
+ # for all available tokens
14
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
15
+ # Or organize into date-based subdirectories (requires recursive_version_locations = true)
16
+ # file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
17
+
18
+ # sys.path path, will be prepended to sys.path if present.
19
+ # defaults to the current working directory. for multiple paths, the path separator
20
+ # is defined by "path_separator" below.
21
+ prepend_sys_path = .
22
+
23
+
24
+ # timezone to use when rendering the date within the migration file
25
+ # as well as the filename.
26
+ # If specified, requires the tzdata library which can be installed by adding
27
+ # `alembic[tz]` to the pip requirements.
28
+ # string value is passed to ZoneInfo()
29
+ # leave blank for localtime
30
+ # timezone =
31
+
32
+ # max length of characters to apply to the "slug" field
33
+ # truncate_slug_length = 40
34
+
35
+ # set to 'true' to run the environment during
36
+ # the 'revision' command, regardless of autogenerate
37
+ # revision_environment = false
38
+
39
+ # set to 'true' to allow .pyc and .pyo files without
40
+ # a source .py file to be detected as revisions in the
41
+ # versions/ directory
42
+ # sourceless = false
43
+
44
+ # version location specification; This defaults
45
+ # to <script_location>/versions. When using multiple version
46
+ # directories, initial revisions must be specified with --version-path.
47
+ # The path separator used here should be the separator specified by "path_separator"
48
+ # below.
49
+ # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
50
+
51
+ # path_separator; This indicates what character is used to split lists of file
52
+ # paths, including version_locations and prepend_sys_path within configparser
53
+ # files such as alembic.ini.
54
+ # The default rendered in new alembic.ini files is "os", which uses os.pathsep
55
+ # to provide os-dependent path splitting.
56
+ #
57
+ # Note that in order to support legacy alembic.ini files, this default does NOT
58
+ # take place if path_separator is not present in alembic.ini. If this
59
+ # option is omitted entirely, fallback logic is as follows:
60
+ #
61
+ # 1. Parsing of the version_locations option falls back to using the legacy
62
+ # "version_path_separator" key, which if absent then falls back to the legacy
63
+ # behavior of splitting on spaces and/or commas.
64
+ # 2. Parsing of the prepend_sys_path option falls back to the legacy
65
+ # behavior of splitting on spaces, commas, or colons.
66
+ #
67
+ # Valid values for path_separator are:
68
+ #
69
+ # path_separator = :
70
+ # path_separator = ;
71
+ # path_separator = space
72
+ # path_separator = newline
73
+ #
74
+ # Use os.pathsep. Default configuration used for new projects.
75
+ path_separator = os
76
+
77
+ # set to 'true' to search source files recursively
78
+ # in each "version_locations" directory
79
+ # new in Alembic version 1.10
80
+ # recursive_version_locations = false
81
+
82
+ # the output encoding used when revision files
83
+ # are written from script.py.mako
84
+ # output_encoding = utf-8
85
+
86
+ # database URL. This is consumed by the user-maintained env.py script only.
87
+ # other means of configuring database URLs may be customized within the env.py
88
+ # file.
89
+ sqlalchemy.url = driver://user:pass@localhost/dbname
90
+
91
+
92
+ [post_write_hooks]
93
+ # post_write_hooks defines scripts or Python functions that are run
94
+ # on newly generated revision scripts. See the documentation for further
95
+ # detail and examples
96
+
97
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
98
+ # hooks = black
99
+ # black.type = console_scripts
100
+ # black.entrypoint = black
101
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
102
+
103
+ # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
104
+ # hooks = ruff
105
+ # ruff.type = module
106
+ # ruff.module = ruff
107
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
108
+
109
+ # Alternatively, use the exec runner to execute a binary found on your PATH
110
+ # hooks = ruff
111
+ # ruff.type = exec
112
+ # ruff.executable = ruff
113
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
114
+
115
+ # Logging configuration. This is also consumed by the user-maintained
116
+ # env.py script only.
117
+ [loggers]
118
+ keys = root,sqlalchemy,alembic
119
+
120
+ [handlers]
121
+ keys = console
122
+
123
+ [formatters]
124
+ keys = generic
125
+
126
+ [logger_root]
127
+ level = WARNING
128
+ handlers = console
129
+ qualname =
130
+
131
+ [logger_sqlalchemy]
132
+ level = WARNING
133
+ handlers =
134
+ qualname = sqlalchemy.engine
135
+
136
+ [logger_alembic]
137
+ level = INFO
138
+ handlers =
139
+ qualname = alembic
140
+
141
+ [handler_console]
142
+ class = StreamHandler
143
+ args = (sys.stderr,)
144
+ level = NOTSET
145
+ formatter = generic
146
+
147
+ [formatter_generic]
148
+ format = %(levelname)-5.5s [%(name)s] %(message)s
149
+ datefmt = %H:%M:%S
alembic/env.py CHANGED
@@ -1,96 +1,96 @@
1
- from logging.config import fileConfig
2
-
3
- from sqlalchemy import engine_from_config
4
- from sqlalchemy import pool
5
-
6
- from alembic import context
7
-
8
- # this is the Alembic Config object, which provides
9
- # access to the values within the .ini file in use.
10
- config = context.config
11
-
12
- # Interpret the config file for Python logging.
13
- # This line sets up loggers basically.
14
- if config.config_file_name is not None:
15
- fileConfig(config.config_file_name)
16
-
17
- import sys
18
- import os
19
-
20
- # Añadir el directorio raíz al path para que pueda importar 'app'
21
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
-
23
- from app.database import Base, DATABASE_URL, SQLITE_URL
24
- from app.models.user import User
25
- from app.models.process import Task
26
- from app.models.lyrics import Group, UserGroup, Lyric, Repertorio, RepertorioLyric, Invitation
27
- from app.models.iglesia import Iglesia, IglesiaRol, IglesiaMember, Miembro, RelacionFamiliar, Calendario, Servicio, Asistencia, Finanza, Momento, Inventario
28
- from app.models.aprendizaje import BibliaLibro, BibliaVersiculo, PlanLectura, DiaLectura, PreguntaQuiz, ProgresoPlan, PuntuacionLiga, Torneo, ParticipanteTorneo
29
- from app.models.midi import PartituraMidi
30
-
31
- # add your model's MetaData object here
32
- # for 'autogenerate' support
33
- target_metadata = Base.metadata
34
-
35
- def get_url():
36
- return DATABASE_URL or SQLITE_URL
37
-
38
- # other values from the config, defined by the needs of env.py,
39
- # can be acquired:
40
- # my_important_option = config.get_main_option("my_important_option")
41
- # ... etc.
42
-
43
-
44
- def run_migrations_offline() -> None:
45
- """Run migrations in 'offline' mode.
46
-
47
- This configures the context with just a URL
48
- and not an Engine, though an Engine is acceptable
49
- here as well. By skipping the Engine creation
50
- we don't even need a DBAPI to be available.
51
-
52
- Calls to context.execute() here emit the given string to the
53
- script output.
54
-
55
- """
56
- url = get_url()
57
- context.configure(
58
- url=url,
59
- target_metadata=target_metadata,
60
- literal_binds=True,
61
- dialect_opts={"paramstyle": "named"},
62
- )
63
-
64
- with context.begin_transaction():
65
- context.run_migrations()
66
-
67
-
68
- def run_migrations_online() -> None:
69
- """Run migrations in 'online' mode.
70
-
71
- In this scenario we need to create an Engine
72
- and associate a connection with the context.
73
-
74
- """
75
- config_section = config.get_section(config.config_ini_section, {})
76
- config_section["sqlalchemy.url"] = get_url()
77
-
78
- connectable = engine_from_config(
79
- config_section,
80
- prefix="sqlalchemy.",
81
- poolclass=pool.NullPool,
82
- )
83
-
84
- with connectable.connect() as connection:
85
- context.configure(
86
- connection=connection, target_metadata=target_metadata
87
- )
88
-
89
- with context.begin_transaction():
90
- context.run_migrations()
91
-
92
-
93
- if context.is_offline_mode():
94
- run_migrations_offline()
95
- else:
96
- run_migrations_online()
 
1
+ from logging.config import fileConfig
2
+
3
+ from sqlalchemy import engine_from_config
4
+ from sqlalchemy import pool
5
+
6
+ from alembic import context
7
+
8
+ # this is the Alembic Config object, which provides
9
+ # access to the values within the .ini file in use.
10
+ config = context.config
11
+
12
+ # Interpret the config file for Python logging.
13
+ # This line sets up loggers basically.
14
+ if config.config_file_name is not None:
15
+ fileConfig(config.config_file_name)
16
+
17
+ import sys
18
+ import os
19
+
20
+ # Añadir el directorio raíz al path para que pueda importar 'app'
21
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
+
23
+ from app.database import Base, DATABASE_URL, SQLITE_URL
24
+ from app.models.user import User
25
+ from app.models.process import Task
26
+ from app.models.lyrics import Group, UserGroup, Lyric, Repertorio, RepertorioLyric, Invitation
27
+ from app.models.iglesia import Iglesia, IglesiaRol, IglesiaMember, Miembro, RelacionFamiliar, Calendario, Servicio, Asistencia, Finanza, Momento, Inventario
28
+ from app.models.aprendizaje import BibliaLibro, BibliaVersiculo, PlanLectura, DiaLectura, PreguntaQuiz, ProgresoPlan, PuntuacionLiga, Torneo, ParticipanteTorneo
29
+ from app.models.midi import PartituraMidi
30
+
31
+ # add your model's MetaData object here
32
+ # for 'autogenerate' support
33
+ target_metadata = Base.metadata
34
+
35
+ def get_url():
36
+ return DATABASE_URL or SQLITE_URL
37
+
38
+ # other values from the config, defined by the needs of env.py,
39
+ # can be acquired:
40
+ # my_important_option = config.get_main_option("my_important_option")
41
+ # ... etc.
42
+
43
+
44
+ def run_migrations_offline() -> None:
45
+ """Run migrations in 'offline' mode.
46
+
47
+ This configures the context with just a URL
48
+ and not an Engine, though an Engine is acceptable
49
+ here as well. By skipping the Engine creation
50
+ we don't even need a DBAPI to be available.
51
+
52
+ Calls to context.execute() here emit the given string to the
53
+ script output.
54
+
55
+ """
56
+ url = get_url()
57
+ context.configure(
58
+ url=url,
59
+ target_metadata=target_metadata,
60
+ literal_binds=True,
61
+ dialect_opts={"paramstyle": "named"},
62
+ )
63
+
64
+ with context.begin_transaction():
65
+ context.run_migrations()
66
+
67
+
68
+ def run_migrations_online() -> None:
69
+ """Run migrations in 'online' mode.
70
+
71
+ In this scenario we need to create an Engine
72
+ and associate a connection with the context.
73
+
74
+ """
75
+ config_section = config.get_section(config.config_ini_section, {})
76
+ config_section["sqlalchemy.url"] = get_url()
77
+
78
+ connectable = engine_from_config(
79
+ config_section,
80
+ prefix="sqlalchemy.",
81
+ poolclass=pool.NullPool,
82
+ )
83
+
84
+ with connectable.connect() as connection:
85
+ context.configure(
86
+ connection=connection, target_metadata=target_metadata
87
+ )
88
+
89
+ with context.begin_transaction():
90
+ context.run_migrations()
91
+
92
+
93
+ if context.is_offline_mode():
94
+ run_migrations_offline()
95
+ else:
96
+ run_migrations_online()
alembic/script.py.mako CHANGED
@@ -1,28 +1,28 @@
1
- """${message}
2
-
3
- Revision ID: ${up_revision}
4
- Revises: ${down_revision | comma,n}
5
- Create Date: ${create_date}
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
- ${imports if imports else ""}
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = ${repr(up_revision)}
16
- down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
- branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
- depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- ${upgrades if upgrades else "pass"}
24
-
25
-
26
- def downgrade() -> None:
27
- """Downgrade schema."""
28
- ${downgrades if downgrades else "pass"}
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
alembic/versions/72f5727b3a03_initial_schema.py CHANGED
@@ -1,498 +1,498 @@
1
- """Initial schema
2
-
3
- Revision ID: 72f5727b3a03
4
- Revises:
5
- Create Date: 2026-05-17 22:27:27.338161
6
-
7
- """
8
- from typing import Sequence, Union
9
-
10
- from alembic import op
11
- import sqlalchemy as sa
12
-
13
-
14
- # revision identifiers, used by Alembic.
15
- revision: str = '72f5727b3a03'
16
- down_revision: Union[str, Sequence[str], None] = None
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
19
-
20
-
21
- def upgrade() -> None:
22
- """Upgrade schema."""
23
- # ### commands auto generated by Alembic - please adjust! ###
24
- op.create_table('biblia_libros',
25
- sa.Column('id', sa.Integer(), nullable=False),
26
- sa.Column('nombre', sa.String(), nullable=True),
27
- sa.Column('testamento', sa.String(), nullable=True),
28
- sa.PrimaryKeyConstraint('id')
29
- )
30
- op.create_index(op.f('ix_biblia_libros_id'), 'biblia_libros', ['id'], unique=False)
31
- op.create_index(op.f('ix_biblia_libros_nombre'), 'biblia_libros', ['nombre'], unique=False)
32
- op.create_table('groups',
33
- sa.Column('id', sa.Integer(), nullable=False),
34
- sa.Column('name', sa.String(), nullable=True),
35
- sa.Column('created_at', sa.DateTime(), nullable=True),
36
- sa.PrimaryKeyConstraint('id')
37
- )
38
- op.create_index(op.f('ix_groups_id'), 'groups', ['id'], unique=False)
39
- op.create_index(op.f('ix_groups_name'), 'groups', ['name'], unique=False)
40
- op.create_table('planes_lectura',
41
- sa.Column('id', sa.Integer(), nullable=False),
42
- sa.Column('nombre', sa.String(), nullable=True),
43
- sa.Column('descripcion', sa.Text(), nullable=True),
44
- sa.Column('dias_totales', sa.Integer(), nullable=True),
45
- sa.Column('created_at', sa.DateTime(), nullable=True),
46
- sa.PrimaryKeyConstraint('id')
47
- )
48
- op.create_index(op.f('ix_planes_lectura_id'), 'planes_lectura', ['id'], unique=False)
49
- op.create_table('users',
50
- sa.Column('id', sa.Integer(), nullable=False),
51
- sa.Column('username', sa.String(), nullable=True),
52
- sa.Column('email', sa.String(), nullable=True),
53
- sa.Column('hashed_password', sa.String(), nullable=True),
54
- sa.Column('credits', sa.Integer(), nullable=True),
55
- sa.PrimaryKeyConstraint('id')
56
- )
57
- op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
58
- op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
59
- op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
60
- op.create_table('biblia_versiculos',
61
- sa.Column('id', sa.Integer(), nullable=False),
62
- sa.Column('libro_id', sa.Integer(), nullable=True),
63
- sa.Column('capitulo', sa.Integer(), nullable=True),
64
- sa.Column('versiculo', sa.Integer(), nullable=True),
65
- sa.Column('texto', sa.Text(), nullable=True),
66
- sa.ForeignKeyConstraint(['libro_id'], ['biblia_libros.id'], ),
67
- sa.PrimaryKeyConstraint('id')
68
- )
69
- op.create_index(op.f('ix_biblia_versiculos_capitulo'), 'biblia_versiculos', ['capitulo'], unique=False)
70
- op.create_index(op.f('ix_biblia_versiculos_id'), 'biblia_versiculos', ['id'], unique=False)
71
- op.create_index(op.f('ix_biblia_versiculos_versiculo'), 'biblia_versiculos', ['versiculo'], unique=False)
72
- op.create_table('devices',
73
- sa.Column('id', sa.Integer(), nullable=False),
74
- sa.Column('user_id', sa.Integer(), nullable=True),
75
- sa.Column('device_id', sa.String(), nullable=True),
76
- sa.Column('name', sa.String(), nullable=True),
77
- sa.Column('registered_at', sa.DateTime(), nullable=True),
78
- sa.Column('last_active', sa.DateTime(), nullable=True),
79
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
80
- sa.PrimaryKeyConstraint('id')
81
- )
82
- op.create_index(op.f('ix_devices_device_id'), 'devices', ['device_id'], unique=True)
83
- op.create_index(op.f('ix_devices_id'), 'devices', ['id'], unique=False)
84
- op.create_table('dias_lectura',
85
- sa.Column('id', sa.Integer(), nullable=False),
86
- sa.Column('plan_id', sa.Integer(), nullable=True),
87
- sa.Column('dia_numero', sa.Integer(), nullable=True),
88
- sa.Column('pasajes', sa.String(), nullable=True),
89
- sa.Column('titulo', sa.String(), nullable=True),
90
- sa.ForeignKeyConstraint(['plan_id'], ['planes_lectura.id'], ),
91
- sa.PrimaryKeyConstraint('id')
92
- )
93
- op.create_index(op.f('ix_dias_lectura_id'), 'dias_lectura', ['id'], unique=False)
94
- op.create_table('iglesias',
95
- sa.Column('id', sa.Integer(), nullable=False),
96
- sa.Column('nombre', sa.String(), nullable=True),
97
- sa.Column('pastor', sa.String(), nullable=True),
98
- sa.Column('codigo', sa.String(length=5), nullable=True),
99
- sa.Column('admin_id', sa.Integer(), nullable=True),
100
- sa.Column('icono', sa.String(), nullable=True),
101
- sa.Column('foto_base64', sa.Text(), nullable=True),
102
- sa.Column('foto_pastor', sa.String(), nullable=True),
103
- sa.Column('imgbb_api_key', sa.String(length=100), nullable=True),
104
- sa.Column('requiere_aprobacion', sa.Boolean(), nullable=True),
105
- sa.Column('created_at', sa.DateTime(), nullable=True),
106
- sa.ForeignKeyConstraint(['admin_id'], ['users.id'], ),
107
- sa.PrimaryKeyConstraint('id')
108
- )
109
- op.create_index(op.f('ix_iglesias_codigo'), 'iglesias', ['codigo'], unique=True)
110
- op.create_index(op.f('ix_iglesias_id'), 'iglesias', ['id'], unique=False)
111
- op.create_index(op.f('ix_iglesias_nombre'), 'iglesias', ['nombre'], unique=False)
112
- op.create_table('invitations',
113
- sa.Column('id', sa.Integer(), nullable=False),
114
- sa.Column('code', sa.String(), nullable=True),
115
- sa.Column('group_id', sa.Integer(), nullable=True),
116
- sa.Column('created_by', sa.Integer(), nullable=True),
117
- sa.Column('created_at', sa.DateTime(), nullable=True),
118
- sa.Column('expires_at', sa.DateTime(), nullable=True),
119
- sa.Column('used_at', sa.DateTime(), nullable=True),
120
- sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
121
- sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
122
- sa.PrimaryKeyConstraint('id')
123
- )
124
- op.create_index(op.f('ix_invitations_code'), 'invitations', ['code'], unique=True)
125
- op.create_index(op.f('ix_invitations_id'), 'invitations', ['id'], unique=False)
126
- op.create_table('lyrics',
127
- sa.Column('id', sa.Integer(), nullable=False),
128
- sa.Column('title', sa.String(), nullable=True),
129
- sa.Column('artist', sa.String(), nullable=True),
130
- sa.Column('content', sa.String(), nullable=True),
131
- sa.Column('key', sa.String(), nullable=True),
132
- sa.Column('group_id', sa.Integer(), nullable=True),
133
- sa.Column('created_by', sa.Integer(), nullable=True),
134
- sa.Column('created_at', sa.DateTime(), nullable=True),
135
- sa.Column('updated_at', sa.DateTime(), nullable=True),
136
- sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
137
- sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
138
- sa.PrimaryKeyConstraint('id')
139
- )
140
- op.create_index(op.f('ix_lyrics_artist'), 'lyrics', ['artist'], unique=False)
141
- op.create_index(op.f('ix_lyrics_id'), 'lyrics', ['id'], unique=False)
142
- op.create_index(op.f('ix_lyrics_title'), 'lyrics', ['title'], unique=False)
143
- op.create_table('partituras_midi',
144
- sa.Column('id', sa.Integer(), nullable=False),
145
- sa.Column('title', sa.String(), nullable=False),
146
- sa.Column('artist', sa.String(), nullable=True),
147
- sa.Column('filename', sa.String(), nullable=False),
148
- sa.Column('filepath', sa.String(), nullable=False),
149
- sa.Column('tempo_bpm', sa.Float(), nullable=True),
150
- sa.Column('device_id', sa.String(), nullable=True),
151
- sa.Column('user_id', sa.Integer(), nullable=True),
152
- sa.Column('created_at', sa.DateTime(), nullable=True),
153
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
154
- sa.PrimaryKeyConstraint('id')
155
- )
156
- op.create_index(op.f('ix_partituras_midi_device_id'), 'partituras_midi', ['device_id'], unique=False)
157
- op.create_index(op.f('ix_partituras_midi_id'), 'partituras_midi', ['id'], unique=False)
158
- op.create_index(op.f('ix_partituras_midi_title'), 'partituras_midi', ['title'], unique=False)
159
- op.create_table('repertorios',
160
- sa.Column('id', sa.Integer(), nullable=False),
161
- sa.Column('name', sa.String(), nullable=True),
162
- sa.Column('group_id', sa.Integer(), nullable=True),
163
- sa.Column('created_by', sa.Integer(), nullable=True),
164
- sa.Column('created_at', sa.DateTime(), nullable=True),
165
- sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
166
- sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
167
- sa.PrimaryKeyConstraint('id')
168
- )
169
- op.create_index(op.f('ix_repertorios_id'), 'repertorios', ['id'], unique=False)
170
- op.create_index(op.f('ix_repertorios_name'), 'repertorios', ['name'], unique=False)
171
- op.create_table('tasks',
172
- sa.Column('id', sa.Integer(), nullable=False),
173
- sa.Column('task_id', sa.String(), nullable=True),
174
- sa.Column('user_id', sa.Integer(), nullable=True),
175
- sa.Column('device_id', sa.String(), nullable=True),
176
- sa.Column('song_name', sa.String(), nullable=True),
177
- sa.Column('stems', sa.Integer(), nullable=True),
178
- sa.Column('status', sa.String(), nullable=True),
179
- sa.Column('created_at', sa.DateTime(), nullable=True),
180
- sa.Column('downloaded_at', sa.DateTime(), nullable=True),
181
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
182
- sa.PrimaryKeyConstraint('id')
183
- )
184
- op.create_index(op.f('ix_tasks_id'), 'tasks', ['id'], unique=False)
185
- op.create_index(op.f('ix_tasks_task_id'), 'tasks', ['task_id'], unique=True)
186
- op.create_table('torneos',
187
- sa.Column('id', sa.Integer(), nullable=False),
188
- sa.Column('codigo', sa.String(), nullable=True),
189
- sa.Column('nombre', sa.String(), nullable=True),
190
- sa.Column('creador_id', sa.Integer(), nullable=True),
191
- sa.Column('estado', sa.String(), nullable=True),
192
- sa.Column('ganador_id', sa.Integer(), nullable=True),
193
- sa.Column('preguntas_json', sa.Text(), nullable=True),
194
- sa.Column('desempate_preguntas_json', sa.Text(), nullable=True),
195
- sa.Column('fecha_creacion', sa.String(), nullable=True),
196
- sa.ForeignKeyConstraint(['creador_id'], ['users.id'], ),
197
- sa.ForeignKeyConstraint(['ganador_id'], ['users.id'], ),
198
- sa.PrimaryKeyConstraint('id')
199
- )
200
- op.create_index(op.f('ix_torneos_codigo'), 'torneos', ['codigo'], unique=True)
201
- op.create_index(op.f('ix_torneos_id'), 'torneos', ['id'], unique=False)
202
- op.create_table('user_groups',
203
- sa.Column('user_id', sa.Integer(), nullable=False),
204
- sa.Column('group_id', sa.Integer(), nullable=False),
205
- sa.Column('role', sa.String(), nullable=True),
206
- sa.Column('estado', sa.String(), nullable=True),
207
- sa.Column('joined_at', sa.DateTime(), nullable=True),
208
- sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
209
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
210
- sa.PrimaryKeyConstraint('user_id', 'group_id')
211
- )
212
- op.create_table('calendarios',
213
- sa.Column('id', sa.Integer(), nullable=False),
214
- sa.Column('iglesia_id', sa.Integer(), nullable=True),
215
- sa.Column('nombre', sa.String(), nullable=True),
216
- sa.Column('icono', sa.String(), nullable=True),
217
- sa.Column('color_fondo', sa.String(), nullable=True),
218
- sa.Column('created_by', sa.Integer(), nullable=True),
219
- sa.Column('created_at', sa.DateTime(), nullable=True),
220
- sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
221
- sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
222
- sa.PrimaryKeyConstraint('id')
223
- )
224
- op.create_index(op.f('ix_calendarios_id'), 'calendarios', ['id'], unique=False)
225
- op.create_table('finanzas',
226
- sa.Column('id', sa.Integer(), nullable=False),
227
- sa.Column('iglesia_id', sa.Integer(), nullable=True),
228
- sa.Column('tipo', sa.String(), nullable=True),
229
- sa.Column('monto', sa.Float(), nullable=True),
230
- sa.Column('concepto', sa.String(), nullable=True),
231
- sa.Column('responsable', sa.String(), nullable=True),
232
- sa.Column('registrado_por', sa.Integer(), nullable=True),
233
- sa.Column('fecha', sa.DateTime(), nullable=True),
234
- sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
235
- sa.ForeignKeyConstraint(['registrado_por'], ['users.id'], ),
236
- sa.PrimaryKeyConstraint('id')
237
- )
238
- op.create_index(op.f('ix_finanzas_id'), 'finanzas', ['id'], unique=False)
239
- op.create_table('iglesia_roles',
240
- sa.Column('id', sa.Integer(), nullable=False),
241
- sa.Column('iglesia_id', sa.Integer(), nullable=True),
242
- sa.Column('nombre', sa.String(), nullable=True),
243
- sa.Column('descripcion', sa.String(), nullable=True),
244
- sa.Column('permisos', sa.Text(), nullable=True),
245
- sa.Column('is_system', sa.Boolean(), nullable=True),
246
- sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
247
- sa.PrimaryKeyConstraint('id')
248
- )
249
- op.create_index(op.f('ix_iglesia_roles_id'), 'iglesia_roles', ['id'], unique=False)
250
- op.create_table('inventario',
251
- sa.Column('id', sa.Integer(), nullable=False),
252
- sa.Column('iglesia_id', sa.Integer(), nullable=True),
253
- sa.Column('nombre', sa.String(), nullable=True),
254
- sa.Column('marca', sa.String(), nullable=True),
255
- sa.Column('cantidad', sa.Integer(), nullable=True),
256
- sa.Column('fecha_compra', sa.String(), nullable=True),
257
- sa.Column('precio', sa.Float(), nullable=True),
258
- sa.Column('foto_url', sa.Text(), nullable=True),
259
- sa.Column('estado', sa.String(), nullable=True),
260
- sa.Column('nota', sa.Text(), nullable=True),
261
- sa.Column('registrado_por', sa.Integer(), nullable=True),
262
- sa.Column('created_at', sa.DateTime(), nullable=True),
263
- sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
264
- sa.ForeignKeyConstraint(['registrado_por'], ['users.id'], ),
265
- sa.PrimaryKeyConstraint('id')
266
- )
267
- op.create_index(op.f('ix_inventario_id'), 'inventario', ['id'], unique=False)
268
- op.create_table('miembros',
269
- sa.Column('id', sa.Integer(), nullable=False),
270
- sa.Column('iglesia_id', sa.Integer(), nullable=True),
271
- sa.Column('nombre_completo', sa.String(), nullable=True),
272
- sa.Column('tipo', sa.String(), nullable=True),
273
- sa.Column('edad', sa.Integer(), nullable=True),
274
- sa.Column('fecha_nacimiento', sa.String(), nullable=True),
275
- sa.Column('documento_identidad', sa.String(), nullable=True),
276
- sa.Column('foto_base64', sa.Text(), nullable=True),
277
- sa.Column('activo', sa.Boolean(), nullable=True),
278
- sa.Column('telefono', sa.String(), nullable=True),
279
- sa.Column('direccion', sa.String(), nullable=True),
280
- sa.Column('estado', sa.String(), nullable=True),
281
- sa.Column('ministerio', sa.String(), nullable=True),
282
- sa.Column('user_id', sa.Integer(), nullable=True),
283
- sa.Column('created_at', sa.DateTime(), nullable=True),
284
- sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
285
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
286
- sa.PrimaryKeyConstraint('id')
287
- )
288
- op.create_index(op.f('ix_miembros_id'), 'miembros', ['id'], unique=False)
289
- op.create_index(op.f('ix_miembros_nombre_completo'), 'miembros', ['nombre_completo'], unique=False)
290
- op.create_table('participantes_torneo',
291
- sa.Column('id', sa.Integer(), nullable=False),
292
- sa.Column('torneo_id', sa.Integer(), nullable=True),
293
- sa.Column('user_id', sa.Integer(), nullable=True),
294
- sa.Column('score', sa.Integer(), nullable=True),
295
- sa.Column('tiempo_empleado', sa.Float(), nullable=True),
296
- sa.Column('score_desempate', sa.Integer(), nullable=True),
297
- sa.Column('tiempo_desempate', sa.Float(), nullable=True),
298
- sa.Column('eliminado', sa.Boolean(), nullable=True),
299
- sa.Column('estado', sa.String(), nullable=True),
300
- sa.ForeignKeyConstraint(['torneo_id'], ['torneos.id'], ),
301
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
302
- sa.PrimaryKeyConstraint('id')
303
- )
304
- op.create_index(op.f('ix_participantes_torneo_id'), 'participantes_torneo', ['id'], unique=False)
305
- op.create_table('preguntas_quiz',
306
- sa.Column('id', sa.Integer(), nullable=False),
307
- sa.Column('dia_lectura_id', sa.Integer(), nullable=True),
308
- sa.Column('tipo', sa.String(), nullable=True),
309
- sa.Column('pregunta', sa.String(), nullable=True),
310
- sa.Column('opciones', sa.Text(), nullable=True),
311
- sa.Column('respuesta_correcta', sa.String(), nullable=True),
312
- sa.ForeignKeyConstraint(['dia_lectura_id'], ['dias_lectura.id'], ),
313
- sa.PrimaryKeyConstraint('id')
314
- )
315
- op.create_index(op.f('ix_preguntas_quiz_id'), 'preguntas_quiz', ['id'], unique=False)
316
- op.create_table('progresos_plan',
317
- sa.Column('id', sa.Integer(), nullable=False),
318
- sa.Column('user_id', sa.Integer(), nullable=True),
319
- sa.Column('plan_id', sa.Integer(), nullable=True),
320
- sa.Column('iglesia_id', sa.Integer(), nullable=True),
321
- sa.Column('dia_actual', sa.Integer(), nullable=True),
322
- sa.Column('completado', sa.Boolean(), nullable=True),
323
- sa.Column('ultima_lectura_fecha', sa.String(), nullable=True),
324
- sa.Column('racha_actual', sa.Integer(), nullable=True),
325
- sa.Column('repaso_completado_hasta', sa.Integer(), nullable=True),
326
- sa.Column('escudos_racha', sa.Integer(), nullable=True),
327
- sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ondelete='CASCADE'),
328
- sa.ForeignKeyConstraint(['plan_id'], ['planes_lectura.id'], ),
329
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
330
- sa.PrimaryKeyConstraint('id')
331
- )
332
- op.create_index(op.f('ix_progresos_plan_id'), 'progresos_plan', ['id'], unique=False)
333
- op.create_table('puntuaciones_liga',
334
- sa.Column('id', sa.Integer(), nullable=False),
335
- sa.Column('user_id', sa.Integer(), nullable=True),
336
- sa.Column('iglesia_id', sa.Integer(), nullable=True),
337
- sa.Column('puntos_totales', sa.Integer(), nullable=True),
338
- sa.Column('maxima_racha', sa.Integer(), nullable=True),
339
- sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
340
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
341
- sa.PrimaryKeyConstraint('id')
342
- )
343
- op.create_index(op.f('ix_puntuaciones_liga_id'), 'puntuaciones_liga', ['id'], unique=False)
344
- op.create_table('repertorio_lyrics',
345
- sa.Column('repertorio_id', sa.Integer(), nullable=False),
346
- sa.Column('lyric_id', sa.Integer(), nullable=False),
347
- sa.Column('order', sa.Integer(), nullable=True),
348
- sa.ForeignKeyConstraint(['lyric_id'], ['lyrics.id'], ),
349
- sa.ForeignKeyConstraint(['repertorio_id'], ['repertorios.id'], ),
350
- sa.PrimaryKeyConstraint('repertorio_id', 'lyric_id')
351
- )
352
- op.create_table('iglesia_members',
353
- sa.Column('id', sa.Integer(), nullable=False),
354
- sa.Column('iglesia_id', sa.Integer(), nullable=True),
355
- sa.Column('user_id', sa.Integer(), nullable=True),
356
- sa.Column('rol', sa.String(), nullable=True),
357
- sa.Column('rol_id', sa.Integer(), nullable=True),
358
- sa.Column('estado', sa.String(), nullable=True),
359
- sa.Column('joined_at', sa.DateTime(), nullable=True),
360
- sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
361
- sa.ForeignKeyConstraint(['rol_id'], ['iglesia_roles.id'], ),
362
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
363
- sa.PrimaryKeyConstraint('id')
364
- )
365
- op.create_index(op.f('ix_iglesia_members_id'), 'iglesia_members', ['id'], unique=False)
366
- op.create_table('momentos',
367
- sa.Column('id', sa.Integer(), nullable=False),
368
- sa.Column('iglesia_id', sa.Integer(), nullable=True),
369
- sa.Column('miembro_id', sa.Integer(), nullable=True),
370
- sa.Column('tipo', sa.String(), nullable=True),
371
- sa.Column('descripcion', sa.Text(), nullable=True),
372
- sa.Column('fecha', sa.String(), nullable=True),
373
- sa.Column('registrado_por', sa.Integer(), nullable=True),
374
- sa.Column('created_at', sa.DateTime(), nullable=True),
375
- sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
376
- sa.ForeignKeyConstraint(['miembro_id'], ['miembros.id'], ),
377
- sa.ForeignKeyConstraint(['registrado_por'], ['users.id'], ),
378
- sa.PrimaryKeyConstraint('id')
379
- )
380
- op.create_index(op.f('ix_momentos_id'), 'momentos', ['id'], unique=False)
381
- op.create_table('relaciones_familiares',
382
- sa.Column('id', sa.Integer(), nullable=False),
383
- sa.Column('miembro_id', sa.Integer(), nullable=True),
384
- sa.Column('pariente_id', sa.Integer(), nullable=True),
385
- sa.Column('tipo_relacion', sa.String(), nullable=True),
386
- sa.ForeignKeyConstraint(['miembro_id'], ['miembros.id'], ),
387
- sa.ForeignKeyConstraint(['pariente_id'], ['miembros.id'], ),
388
- sa.PrimaryKeyConstraint('id')
389
- )
390
- op.create_index(op.f('ix_relaciones_familiares_id'), 'relaciones_familiares', ['id'], unique=False)
391
- op.create_table('servicios',
392
- sa.Column('id', sa.Integer(), nullable=False),
393
- sa.Column('calendario_id', sa.Integer(), nullable=True),
394
- sa.Column('fecha', sa.String(), nullable=True),
395
- sa.Column('descripcion', sa.String(), nullable=True),
396
- sa.Column('hora', sa.String(), nullable=True),
397
- sa.Column('color_fondo', sa.String(), nullable=True),
398
- sa.Column('created_at', sa.DateTime(), nullable=True),
399
- sa.ForeignKeyConstraint(['calendario_id'], ['calendarios.id'], ),
400
- sa.PrimaryKeyConstraint('id')
401
- )
402
- op.create_index(op.f('ix_servicios_id'), 'servicios', ['id'], unique=False)
403
- op.create_table('asistencias',
404
- sa.Column('id', sa.Integer(), nullable=False),
405
- sa.Column('servicio_id', sa.Integer(), nullable=True),
406
- sa.Column('miembro_id', sa.Integer(), nullable=True),
407
- sa.Column('asistio', sa.Boolean(), nullable=True),
408
- sa.Column('registrado_por', sa.Integer(), nullable=True),
409
- sa.Column('created_at', sa.DateTime(), nullable=True),
410
- sa.ForeignKeyConstraint(['miembro_id'], ['miembros.id'], ),
411
- sa.ForeignKeyConstraint(['registrado_por'], ['users.id'], ),
412
- sa.ForeignKeyConstraint(['servicio_id'], ['servicios.id'], ),
413
- sa.PrimaryKeyConstraint('id')
414
- )
415
- op.create_index(op.f('ix_asistencias_id'), 'asistencias', ['id'], unique=False)
416
- # ### end Alembic commands ###
417
-
418
-
419
- def downgrade() -> None:
420
- """Downgrade schema."""
421
- # ### commands auto generated by Alembic - please adjust! ###
422
- op.drop_index(op.f('ix_asistencias_id'), table_name='asistencias')
423
- op.drop_table('asistencias')
424
- op.drop_index(op.f('ix_servicios_id'), table_name='servicios')
425
- op.drop_table('servicios')
426
- op.drop_index(op.f('ix_relaciones_familiares_id'), table_name='relaciones_familiares')
427
- op.drop_table('relaciones_familiares')
428
- op.drop_index(op.f('ix_momentos_id'), table_name='momentos')
429
- op.drop_table('momentos')
430
- op.drop_index(op.f('ix_iglesia_members_id'), table_name='iglesia_members')
431
- op.drop_table('iglesia_members')
432
- op.drop_table('repertorio_lyrics')
433
- op.drop_index(op.f('ix_puntuaciones_liga_id'), table_name='puntuaciones_liga')
434
- op.drop_table('puntuaciones_liga')
435
- op.drop_index(op.f('ix_progresos_plan_id'), table_name='progresos_plan')
436
- op.drop_table('progresos_plan')
437
- op.drop_index(op.f('ix_preguntas_quiz_id'), table_name='preguntas_quiz')
438
- op.drop_table('preguntas_quiz')
439
- op.drop_index(op.f('ix_participantes_torneo_id'), table_name='participantes_torneo')
440
- op.drop_table('participantes_torneo')
441
- op.drop_index(op.f('ix_miembros_nombre_completo'), table_name='miembros')
442
- op.drop_index(op.f('ix_miembros_id'), table_name='miembros')
443
- op.drop_table('miembros')
444
- op.drop_index(op.f('ix_inventario_id'), table_name='inventario')
445
- op.drop_table('inventario')
446
- op.drop_index(op.f('ix_iglesia_roles_id'), table_name='iglesia_roles')
447
- op.drop_table('iglesia_roles')
448
- op.drop_index(op.f('ix_finanzas_id'), table_name='finanzas')
449
- op.drop_table('finanzas')
450
- op.drop_index(op.f('ix_calendarios_id'), table_name='calendarios')
451
- op.drop_table('calendarios')
452
- op.drop_table('user_groups')
453
- op.drop_index(op.f('ix_torneos_id'), table_name='torneos')
454
- op.drop_index(op.f('ix_torneos_codigo'), table_name='torneos')
455
- op.drop_table('torneos')
456
- op.drop_index(op.f('ix_tasks_task_id'), table_name='tasks')
457
- op.drop_index(op.f('ix_tasks_id'), table_name='tasks')
458
- op.drop_table('tasks')
459
- op.drop_index(op.f('ix_repertorios_name'), table_name='repertorios')
460
- op.drop_index(op.f('ix_repertorios_id'), table_name='repertorios')
461
- op.drop_table('repertorios')
462
- op.drop_index(op.f('ix_partituras_midi_title'), table_name='partituras_midi')
463
- op.drop_index(op.f('ix_partituras_midi_id'), table_name='partituras_midi')
464
- op.drop_index(op.f('ix_partituras_midi_device_id'), table_name='partituras_midi')
465
- op.drop_table('partituras_midi')
466
- op.drop_index(op.f('ix_lyrics_title'), table_name='lyrics')
467
- op.drop_index(op.f('ix_lyrics_id'), table_name='lyrics')
468
- op.drop_index(op.f('ix_lyrics_artist'), table_name='lyrics')
469
- op.drop_table('lyrics')
470
- op.drop_index(op.f('ix_invitations_id'), table_name='invitations')
471
- op.drop_index(op.f('ix_invitations_code'), table_name='invitations')
472
- op.drop_table('invitations')
473
- op.drop_index(op.f('ix_iglesias_nombre'), table_name='iglesias')
474
- op.drop_index(op.f('ix_iglesias_id'), table_name='iglesias')
475
- op.drop_index(op.f('ix_iglesias_codigo'), table_name='iglesias')
476
- op.drop_table('iglesias')
477
- op.drop_index(op.f('ix_dias_lectura_id'), table_name='dias_lectura')
478
- op.drop_table('dias_lectura')
479
- op.drop_index(op.f('ix_devices_id'), table_name='devices')
480
- op.drop_index(op.f('ix_devices_device_id'), table_name='devices')
481
- op.drop_table('devices')
482
- op.drop_index(op.f('ix_biblia_versiculos_versiculo'), table_name='biblia_versiculos')
483
- op.drop_index(op.f('ix_biblia_versiculos_id'), table_name='biblia_versiculos')
484
- op.drop_index(op.f('ix_biblia_versiculos_capitulo'), table_name='biblia_versiculos')
485
- op.drop_table('biblia_versiculos')
486
- op.drop_index(op.f('ix_users_username'), table_name='users')
487
- op.drop_index(op.f('ix_users_id'), table_name='users')
488
- op.drop_index(op.f('ix_users_email'), table_name='users')
489
- op.drop_table('users')
490
- op.drop_index(op.f('ix_planes_lectura_id'), table_name='planes_lectura')
491
- op.drop_table('planes_lectura')
492
- op.drop_index(op.f('ix_groups_name'), table_name='groups')
493
- op.drop_index(op.f('ix_groups_id'), table_name='groups')
494
- op.drop_table('groups')
495
- op.drop_index(op.f('ix_biblia_libros_nombre'), table_name='biblia_libros')
496
- op.drop_index(op.f('ix_biblia_libros_id'), table_name='biblia_libros')
497
- op.drop_table('biblia_libros')
498
- # ### end Alembic commands ###
 
1
+ """Initial schema
2
+
3
+ Revision ID: 72f5727b3a03
4
+ Revises:
5
+ Create Date: 2026-05-17 22:27:27.338161
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '72f5727b3a03'
16
+ down_revision: Union[str, Sequence[str], None] = None
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ # ### commands auto generated by Alembic - please adjust! ###
24
+ op.create_table('biblia_libros',
25
+ sa.Column('id', sa.Integer(), nullable=False),
26
+ sa.Column('nombre', sa.String(), nullable=True),
27
+ sa.Column('testamento', sa.String(), nullable=True),
28
+ sa.PrimaryKeyConstraint('id')
29
+ )
30
+ op.create_index(op.f('ix_biblia_libros_id'), 'biblia_libros', ['id'], unique=False)
31
+ op.create_index(op.f('ix_biblia_libros_nombre'), 'biblia_libros', ['nombre'], unique=False)
32
+ op.create_table('groups',
33
+ sa.Column('id', sa.Integer(), nullable=False),
34
+ sa.Column('name', sa.String(), nullable=True),
35
+ sa.Column('created_at', sa.DateTime(), nullable=True),
36
+ sa.PrimaryKeyConstraint('id')
37
+ )
38
+ op.create_index(op.f('ix_groups_id'), 'groups', ['id'], unique=False)
39
+ op.create_index(op.f('ix_groups_name'), 'groups', ['name'], unique=False)
40
+ op.create_table('planes_lectura',
41
+ sa.Column('id', sa.Integer(), nullable=False),
42
+ sa.Column('nombre', sa.String(), nullable=True),
43
+ sa.Column('descripcion', sa.Text(), nullable=True),
44
+ sa.Column('dias_totales', sa.Integer(), nullable=True),
45
+ sa.Column('created_at', sa.DateTime(), nullable=True),
46
+ sa.PrimaryKeyConstraint('id')
47
+ )
48
+ op.create_index(op.f('ix_planes_lectura_id'), 'planes_lectura', ['id'], unique=False)
49
+ op.create_table('users',
50
+ sa.Column('id', sa.Integer(), nullable=False),
51
+ sa.Column('username', sa.String(), nullable=True),
52
+ sa.Column('email', sa.String(), nullable=True),
53
+ sa.Column('hashed_password', sa.String(), nullable=True),
54
+ sa.Column('credits', sa.Integer(), nullable=True),
55
+ sa.PrimaryKeyConstraint('id')
56
+ )
57
+ op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
58
+ op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
59
+ op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
60
+ op.create_table('biblia_versiculos',
61
+ sa.Column('id', sa.Integer(), nullable=False),
62
+ sa.Column('libro_id', sa.Integer(), nullable=True),
63
+ sa.Column('capitulo', sa.Integer(), nullable=True),
64
+ sa.Column('versiculo', sa.Integer(), nullable=True),
65
+ sa.Column('texto', sa.Text(), nullable=True),
66
+ sa.ForeignKeyConstraint(['libro_id'], ['biblia_libros.id'], ),
67
+ sa.PrimaryKeyConstraint('id')
68
+ )
69
+ op.create_index(op.f('ix_biblia_versiculos_capitulo'), 'biblia_versiculos', ['capitulo'], unique=False)
70
+ op.create_index(op.f('ix_biblia_versiculos_id'), 'biblia_versiculos', ['id'], unique=False)
71
+ op.create_index(op.f('ix_biblia_versiculos_versiculo'), 'biblia_versiculos', ['versiculo'], unique=False)
72
+ op.create_table('devices',
73
+ sa.Column('id', sa.Integer(), nullable=False),
74
+ sa.Column('user_id', sa.Integer(), nullable=True),
75
+ sa.Column('device_id', sa.String(), nullable=True),
76
+ sa.Column('name', sa.String(), nullable=True),
77
+ sa.Column('registered_at', sa.DateTime(), nullable=True),
78
+ sa.Column('last_active', sa.DateTime(), nullable=True),
79
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
80
+ sa.PrimaryKeyConstraint('id')
81
+ )
82
+ op.create_index(op.f('ix_devices_device_id'), 'devices', ['device_id'], unique=True)
83
+ op.create_index(op.f('ix_devices_id'), 'devices', ['id'], unique=False)
84
+ op.create_table('dias_lectura',
85
+ sa.Column('id', sa.Integer(), nullable=False),
86
+ sa.Column('plan_id', sa.Integer(), nullable=True),
87
+ sa.Column('dia_numero', sa.Integer(), nullable=True),
88
+ sa.Column('pasajes', sa.String(), nullable=True),
89
+ sa.Column('titulo', sa.String(), nullable=True),
90
+ sa.ForeignKeyConstraint(['plan_id'], ['planes_lectura.id'], ),
91
+ sa.PrimaryKeyConstraint('id')
92
+ )
93
+ op.create_index(op.f('ix_dias_lectura_id'), 'dias_lectura', ['id'], unique=False)
94
+ op.create_table('iglesias',
95
+ sa.Column('id', sa.Integer(), nullable=False),
96
+ sa.Column('nombre', sa.String(), nullable=True),
97
+ sa.Column('pastor', sa.String(), nullable=True),
98
+ sa.Column('codigo', sa.String(length=5), nullable=True),
99
+ sa.Column('admin_id', sa.Integer(), nullable=True),
100
+ sa.Column('icono', sa.String(), nullable=True),
101
+ sa.Column('foto_base64', sa.Text(), nullable=True),
102
+ sa.Column('foto_pastor', sa.String(), nullable=True),
103
+ sa.Column('imgbb_api_key', sa.String(length=100), nullable=True),
104
+ sa.Column('requiere_aprobacion', sa.Boolean(), nullable=True),
105
+ sa.Column('created_at', sa.DateTime(), nullable=True),
106
+ sa.ForeignKeyConstraint(['admin_id'], ['users.id'], ),
107
+ sa.PrimaryKeyConstraint('id')
108
+ )
109
+ op.create_index(op.f('ix_iglesias_codigo'), 'iglesias', ['codigo'], unique=True)
110
+ op.create_index(op.f('ix_iglesias_id'), 'iglesias', ['id'], unique=False)
111
+ op.create_index(op.f('ix_iglesias_nombre'), 'iglesias', ['nombre'], unique=False)
112
+ op.create_table('invitations',
113
+ sa.Column('id', sa.Integer(), nullable=False),
114
+ sa.Column('code', sa.String(), nullable=True),
115
+ sa.Column('group_id', sa.Integer(), nullable=True),
116
+ sa.Column('created_by', sa.Integer(), nullable=True),
117
+ sa.Column('created_at', sa.DateTime(), nullable=True),
118
+ sa.Column('expires_at', sa.DateTime(), nullable=True),
119
+ sa.Column('used_at', sa.DateTime(), nullable=True),
120
+ sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
121
+ sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
122
+ sa.PrimaryKeyConstraint('id')
123
+ )
124
+ op.create_index(op.f('ix_invitations_code'), 'invitations', ['code'], unique=True)
125
+ op.create_index(op.f('ix_invitations_id'), 'invitations', ['id'], unique=False)
126
+ op.create_table('lyrics',
127
+ sa.Column('id', sa.Integer(), nullable=False),
128
+ sa.Column('title', sa.String(), nullable=True),
129
+ sa.Column('artist', sa.String(), nullable=True),
130
+ sa.Column('content', sa.String(), nullable=True),
131
+ sa.Column('key', sa.String(), nullable=True),
132
+ sa.Column('group_id', sa.Integer(), nullable=True),
133
+ sa.Column('created_by', sa.Integer(), nullable=True),
134
+ sa.Column('created_at', sa.DateTime(), nullable=True),
135
+ sa.Column('updated_at', sa.DateTime(), nullable=True),
136
+ sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
137
+ sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
138
+ sa.PrimaryKeyConstraint('id')
139
+ )
140
+ op.create_index(op.f('ix_lyrics_artist'), 'lyrics', ['artist'], unique=False)
141
+ op.create_index(op.f('ix_lyrics_id'), 'lyrics', ['id'], unique=False)
142
+ op.create_index(op.f('ix_lyrics_title'), 'lyrics', ['title'], unique=False)
143
+ op.create_table('partituras_midi',
144
+ sa.Column('id', sa.Integer(), nullable=False),
145
+ sa.Column('title', sa.String(), nullable=False),
146
+ sa.Column('artist', sa.String(), nullable=True),
147
+ sa.Column('filename', sa.String(), nullable=False),
148
+ sa.Column('filepath', sa.String(), nullable=False),
149
+ sa.Column('tempo_bpm', sa.Float(), nullable=True),
150
+ sa.Column('device_id', sa.String(), nullable=True),
151
+ sa.Column('user_id', sa.Integer(), nullable=True),
152
+ sa.Column('created_at', sa.DateTime(), nullable=True),
153
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
154
+ sa.PrimaryKeyConstraint('id')
155
+ )
156
+ op.create_index(op.f('ix_partituras_midi_device_id'), 'partituras_midi', ['device_id'], unique=False)
157
+ op.create_index(op.f('ix_partituras_midi_id'), 'partituras_midi', ['id'], unique=False)
158
+ op.create_index(op.f('ix_partituras_midi_title'), 'partituras_midi', ['title'], unique=False)
159
+ op.create_table('repertorios',
160
+ sa.Column('id', sa.Integer(), nullable=False),
161
+ sa.Column('name', sa.String(), nullable=True),
162
+ sa.Column('group_id', sa.Integer(), nullable=True),
163
+ sa.Column('created_by', sa.Integer(), nullable=True),
164
+ sa.Column('created_at', sa.DateTime(), nullable=True),
165
+ sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
166
+ sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
167
+ sa.PrimaryKeyConstraint('id')
168
+ )
169
+ op.create_index(op.f('ix_repertorios_id'), 'repertorios', ['id'], unique=False)
170
+ op.create_index(op.f('ix_repertorios_name'), 'repertorios', ['name'], unique=False)
171
+ op.create_table('tasks',
172
+ sa.Column('id', sa.Integer(), nullable=False),
173
+ sa.Column('task_id', sa.String(), nullable=True),
174
+ sa.Column('user_id', sa.Integer(), nullable=True),
175
+ sa.Column('device_id', sa.String(), nullable=True),
176
+ sa.Column('song_name', sa.String(), nullable=True),
177
+ sa.Column('stems', sa.Integer(), nullable=True),
178
+ sa.Column('status', sa.String(), nullable=True),
179
+ sa.Column('created_at', sa.DateTime(), nullable=True),
180
+ sa.Column('downloaded_at', sa.DateTime(), nullable=True),
181
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
182
+ sa.PrimaryKeyConstraint('id')
183
+ )
184
+ op.create_index(op.f('ix_tasks_id'), 'tasks', ['id'], unique=False)
185
+ op.create_index(op.f('ix_tasks_task_id'), 'tasks', ['task_id'], unique=True)
186
+ op.create_table('torneos',
187
+ sa.Column('id', sa.Integer(), nullable=False),
188
+ sa.Column('codigo', sa.String(), nullable=True),
189
+ sa.Column('nombre', sa.String(), nullable=True),
190
+ sa.Column('creador_id', sa.Integer(), nullable=True),
191
+ sa.Column('estado', sa.String(), nullable=True),
192
+ sa.Column('ganador_id', sa.Integer(), nullable=True),
193
+ sa.Column('preguntas_json', sa.Text(), nullable=True),
194
+ sa.Column('desempate_preguntas_json', sa.Text(), nullable=True),
195
+ sa.Column('fecha_creacion', sa.String(), nullable=True),
196
+ sa.ForeignKeyConstraint(['creador_id'], ['users.id'], ),
197
+ sa.ForeignKeyConstraint(['ganador_id'], ['users.id'], ),
198
+ sa.PrimaryKeyConstraint('id')
199
+ )
200
+ op.create_index(op.f('ix_torneos_codigo'), 'torneos', ['codigo'], unique=True)
201
+ op.create_index(op.f('ix_torneos_id'), 'torneos', ['id'], unique=False)
202
+ op.create_table('user_groups',
203
+ sa.Column('user_id', sa.Integer(), nullable=False),
204
+ sa.Column('group_id', sa.Integer(), nullable=False),
205
+ sa.Column('role', sa.String(), nullable=True),
206
+ sa.Column('estado', sa.String(), nullable=True),
207
+ sa.Column('joined_at', sa.DateTime(), nullable=True),
208
+ sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
209
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
210
+ sa.PrimaryKeyConstraint('user_id', 'group_id')
211
+ )
212
+ op.create_table('calendarios',
213
+ sa.Column('id', sa.Integer(), nullable=False),
214
+ sa.Column('iglesia_id', sa.Integer(), nullable=True),
215
+ sa.Column('nombre', sa.String(), nullable=True),
216
+ sa.Column('icono', sa.String(), nullable=True),
217
+ sa.Column('color_fondo', sa.String(), nullable=True),
218
+ sa.Column('created_by', sa.Integer(), nullable=True),
219
+ sa.Column('created_at', sa.DateTime(), nullable=True),
220
+ sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
221
+ sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
222
+ sa.PrimaryKeyConstraint('id')
223
+ )
224
+ op.create_index(op.f('ix_calendarios_id'), 'calendarios', ['id'], unique=False)
225
+ op.create_table('finanzas',
226
+ sa.Column('id', sa.Integer(), nullable=False),
227
+ sa.Column('iglesia_id', sa.Integer(), nullable=True),
228
+ sa.Column('tipo', sa.String(), nullable=True),
229
+ sa.Column('monto', sa.Float(), nullable=True),
230
+ sa.Column('concepto', sa.String(), nullable=True),
231
+ sa.Column('responsable', sa.String(), nullable=True),
232
+ sa.Column('registrado_por', sa.Integer(), nullable=True),
233
+ sa.Column('fecha', sa.DateTime(), nullable=True),
234
+ sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
235
+ sa.ForeignKeyConstraint(['registrado_por'], ['users.id'], ),
236
+ sa.PrimaryKeyConstraint('id')
237
+ )
238
+ op.create_index(op.f('ix_finanzas_id'), 'finanzas', ['id'], unique=False)
239
+ op.create_table('iglesia_roles',
240
+ sa.Column('id', sa.Integer(), nullable=False),
241
+ sa.Column('iglesia_id', sa.Integer(), nullable=True),
242
+ sa.Column('nombre', sa.String(), nullable=True),
243
+ sa.Column('descripcion', sa.String(), nullable=True),
244
+ sa.Column('permisos', sa.Text(), nullable=True),
245
+ sa.Column('is_system', sa.Boolean(), nullable=True),
246
+ sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
247
+ sa.PrimaryKeyConstraint('id')
248
+ )
249
+ op.create_index(op.f('ix_iglesia_roles_id'), 'iglesia_roles', ['id'], unique=False)
250
+ op.create_table('inventario',
251
+ sa.Column('id', sa.Integer(), nullable=False),
252
+ sa.Column('iglesia_id', sa.Integer(), nullable=True),
253
+ sa.Column('nombre', sa.String(), nullable=True),
254
+ sa.Column('marca', sa.String(), nullable=True),
255
+ sa.Column('cantidad', sa.Integer(), nullable=True),
256
+ sa.Column('fecha_compra', sa.String(), nullable=True),
257
+ sa.Column('precio', sa.Float(), nullable=True),
258
+ sa.Column('foto_url', sa.Text(), nullable=True),
259
+ sa.Column('estado', sa.String(), nullable=True),
260
+ sa.Column('nota', sa.Text(), nullable=True),
261
+ sa.Column('registrado_por', sa.Integer(), nullable=True),
262
+ sa.Column('created_at', sa.DateTime(), nullable=True),
263
+ sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
264
+ sa.ForeignKeyConstraint(['registrado_por'], ['users.id'], ),
265
+ sa.PrimaryKeyConstraint('id')
266
+ )
267
+ op.create_index(op.f('ix_inventario_id'), 'inventario', ['id'], unique=False)
268
+ op.create_table('miembros',
269
+ sa.Column('id', sa.Integer(), nullable=False),
270
+ sa.Column('iglesia_id', sa.Integer(), nullable=True),
271
+ sa.Column('nombre_completo', sa.String(), nullable=True),
272
+ sa.Column('tipo', sa.String(), nullable=True),
273
+ sa.Column('edad', sa.Integer(), nullable=True),
274
+ sa.Column('fecha_nacimiento', sa.String(), nullable=True),
275
+ sa.Column('documento_identidad', sa.String(), nullable=True),
276
+ sa.Column('foto_base64', sa.Text(), nullable=True),
277
+ sa.Column('activo', sa.Boolean(), nullable=True),
278
+ sa.Column('telefono', sa.String(), nullable=True),
279
+ sa.Column('direccion', sa.String(), nullable=True),
280
+ sa.Column('estado', sa.String(), nullable=True),
281
+ sa.Column('ministerio', sa.String(), nullable=True),
282
+ sa.Column('user_id', sa.Integer(), nullable=True),
283
+ sa.Column('created_at', sa.DateTime(), nullable=True),
284
+ sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
285
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
286
+ sa.PrimaryKeyConstraint('id')
287
+ )
288
+ op.create_index(op.f('ix_miembros_id'), 'miembros', ['id'], unique=False)
289
+ op.create_index(op.f('ix_miembros_nombre_completo'), 'miembros', ['nombre_completo'], unique=False)
290
+ op.create_table('participantes_torneo',
291
+ sa.Column('id', sa.Integer(), nullable=False),
292
+ sa.Column('torneo_id', sa.Integer(), nullable=True),
293
+ sa.Column('user_id', sa.Integer(), nullable=True),
294
+ sa.Column('score', sa.Integer(), nullable=True),
295
+ sa.Column('tiempo_empleado', sa.Float(), nullable=True),
296
+ sa.Column('score_desempate', sa.Integer(), nullable=True),
297
+ sa.Column('tiempo_desempate', sa.Float(), nullable=True),
298
+ sa.Column('eliminado', sa.Boolean(), nullable=True),
299
+ sa.Column('estado', sa.String(), nullable=True),
300
+ sa.ForeignKeyConstraint(['torneo_id'], ['torneos.id'], ),
301
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
302
+ sa.PrimaryKeyConstraint('id')
303
+ )
304
+ op.create_index(op.f('ix_participantes_torneo_id'), 'participantes_torneo', ['id'], unique=False)
305
+ op.create_table('preguntas_quiz',
306
+ sa.Column('id', sa.Integer(), nullable=False),
307
+ sa.Column('dia_lectura_id', sa.Integer(), nullable=True),
308
+ sa.Column('tipo', sa.String(), nullable=True),
309
+ sa.Column('pregunta', sa.String(), nullable=True),
310
+ sa.Column('opciones', sa.Text(), nullable=True),
311
+ sa.Column('respuesta_correcta', sa.String(), nullable=True),
312
+ sa.ForeignKeyConstraint(['dia_lectura_id'], ['dias_lectura.id'], ),
313
+ sa.PrimaryKeyConstraint('id')
314
+ )
315
+ op.create_index(op.f('ix_preguntas_quiz_id'), 'preguntas_quiz', ['id'], unique=False)
316
+ op.create_table('progresos_plan',
317
+ sa.Column('id', sa.Integer(), nullable=False),
318
+ sa.Column('user_id', sa.Integer(), nullable=True),
319
+ sa.Column('plan_id', sa.Integer(), nullable=True),
320
+ sa.Column('iglesia_id', sa.Integer(), nullable=True),
321
+ sa.Column('dia_actual', sa.Integer(), nullable=True),
322
+ sa.Column('completado', sa.Boolean(), nullable=True),
323
+ sa.Column('ultima_lectura_fecha', sa.String(), nullable=True),
324
+ sa.Column('racha_actual', sa.Integer(), nullable=True),
325
+ sa.Column('repaso_completado_hasta', sa.Integer(), nullable=True),
326
+ sa.Column('escudos_racha', sa.Integer(), nullable=True),
327
+ sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ondelete='CASCADE'),
328
+ sa.ForeignKeyConstraint(['plan_id'], ['planes_lectura.id'], ),
329
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
330
+ sa.PrimaryKeyConstraint('id')
331
+ )
332
+ op.create_index(op.f('ix_progresos_plan_id'), 'progresos_plan', ['id'], unique=False)
333
+ op.create_table('puntuaciones_liga',
334
+ sa.Column('id', sa.Integer(), nullable=False),
335
+ sa.Column('user_id', sa.Integer(), nullable=True),
336
+ sa.Column('iglesia_id', sa.Integer(), nullable=True),
337
+ sa.Column('puntos_totales', sa.Integer(), nullable=True),
338
+ sa.Column('maxima_racha', sa.Integer(), nullable=True),
339
+ sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
340
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
341
+ sa.PrimaryKeyConstraint('id')
342
+ )
343
+ op.create_index(op.f('ix_puntuaciones_liga_id'), 'puntuaciones_liga', ['id'], unique=False)
344
+ op.create_table('repertorio_lyrics',
345
+ sa.Column('repertorio_id', sa.Integer(), nullable=False),
346
+ sa.Column('lyric_id', sa.Integer(), nullable=False),
347
+ sa.Column('order', sa.Integer(), nullable=True),
348
+ sa.ForeignKeyConstraint(['lyric_id'], ['lyrics.id'], ),
349
+ sa.ForeignKeyConstraint(['repertorio_id'], ['repertorios.id'], ),
350
+ sa.PrimaryKeyConstraint('repertorio_id', 'lyric_id')
351
+ )
352
+ op.create_table('iglesia_members',
353
+ sa.Column('id', sa.Integer(), nullable=False),
354
+ sa.Column('iglesia_id', sa.Integer(), nullable=True),
355
+ sa.Column('user_id', sa.Integer(), nullable=True),
356
+ sa.Column('rol', sa.String(), nullable=True),
357
+ sa.Column('rol_id', sa.Integer(), nullable=True),
358
+ sa.Column('estado', sa.String(), nullable=True),
359
+ sa.Column('joined_at', sa.DateTime(), nullable=True),
360
+ sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
361
+ sa.ForeignKeyConstraint(['rol_id'], ['iglesia_roles.id'], ),
362
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
363
+ sa.PrimaryKeyConstraint('id')
364
+ )
365
+ op.create_index(op.f('ix_iglesia_members_id'), 'iglesia_members', ['id'], unique=False)
366
+ op.create_table('momentos',
367
+ sa.Column('id', sa.Integer(), nullable=False),
368
+ sa.Column('iglesia_id', sa.Integer(), nullable=True),
369
+ sa.Column('miembro_id', sa.Integer(), nullable=True),
370
+ sa.Column('tipo', sa.String(), nullable=True),
371
+ sa.Column('descripcion', sa.Text(), nullable=True),
372
+ sa.Column('fecha', sa.String(), nullable=True),
373
+ sa.Column('registrado_por', sa.Integer(), nullable=True),
374
+ sa.Column('created_at', sa.DateTime(), nullable=True),
375
+ sa.ForeignKeyConstraint(['iglesia_id'], ['iglesias.id'], ),
376
+ sa.ForeignKeyConstraint(['miembro_id'], ['miembros.id'], ),
377
+ sa.ForeignKeyConstraint(['registrado_por'], ['users.id'], ),
378
+ sa.PrimaryKeyConstraint('id')
379
+ )
380
+ op.create_index(op.f('ix_momentos_id'), 'momentos', ['id'], unique=False)
381
+ op.create_table('relaciones_familiares',
382
+ sa.Column('id', sa.Integer(), nullable=False),
383
+ sa.Column('miembro_id', sa.Integer(), nullable=True),
384
+ sa.Column('pariente_id', sa.Integer(), nullable=True),
385
+ sa.Column('tipo_relacion', sa.String(), nullable=True),
386
+ sa.ForeignKeyConstraint(['miembro_id'], ['miembros.id'], ),
387
+ sa.ForeignKeyConstraint(['pariente_id'], ['miembros.id'], ),
388
+ sa.PrimaryKeyConstraint('id')
389
+ )
390
+ op.create_index(op.f('ix_relaciones_familiares_id'), 'relaciones_familiares', ['id'], unique=False)
391
+ op.create_table('servicios',
392
+ sa.Column('id', sa.Integer(), nullable=False),
393
+ sa.Column('calendario_id', sa.Integer(), nullable=True),
394
+ sa.Column('fecha', sa.String(), nullable=True),
395
+ sa.Column('descripcion', sa.String(), nullable=True),
396
+ sa.Column('hora', sa.String(), nullable=True),
397
+ sa.Column('color_fondo', sa.String(), nullable=True),
398
+ sa.Column('created_at', sa.DateTime(), nullable=True),
399
+ sa.ForeignKeyConstraint(['calendario_id'], ['calendarios.id'], ),
400
+ sa.PrimaryKeyConstraint('id')
401
+ )
402
+ op.create_index(op.f('ix_servicios_id'), 'servicios', ['id'], unique=False)
403
+ op.create_table('asistencias',
404
+ sa.Column('id', sa.Integer(), nullable=False),
405
+ sa.Column('servicio_id', sa.Integer(), nullable=True),
406
+ sa.Column('miembro_id', sa.Integer(), nullable=True),
407
+ sa.Column('asistio', sa.Boolean(), nullable=True),
408
+ sa.Column('registrado_por', sa.Integer(), nullable=True),
409
+ sa.Column('created_at', sa.DateTime(), nullable=True),
410
+ sa.ForeignKeyConstraint(['miembro_id'], ['miembros.id'], ),
411
+ sa.ForeignKeyConstraint(['registrado_por'], ['users.id'], ),
412
+ sa.ForeignKeyConstraint(['servicio_id'], ['servicios.id'], ),
413
+ sa.PrimaryKeyConstraint('id')
414
+ )
415
+ op.create_index(op.f('ix_asistencias_id'), 'asistencias', ['id'], unique=False)
416
+ # ### end Alembic commands ###
417
+
418
+
419
+ def downgrade() -> None:
420
+ """Downgrade schema."""
421
+ # ### commands auto generated by Alembic - please adjust! ###
422
+ op.drop_index(op.f('ix_asistencias_id'), table_name='asistencias')
423
+ op.drop_table('asistencias')
424
+ op.drop_index(op.f('ix_servicios_id'), table_name='servicios')
425
+ op.drop_table('servicios')
426
+ op.drop_index(op.f('ix_relaciones_familiares_id'), table_name='relaciones_familiares')
427
+ op.drop_table('relaciones_familiares')
428
+ op.drop_index(op.f('ix_momentos_id'), table_name='momentos')
429
+ op.drop_table('momentos')
430
+ op.drop_index(op.f('ix_iglesia_members_id'), table_name='iglesia_members')
431
+ op.drop_table('iglesia_members')
432
+ op.drop_table('repertorio_lyrics')
433
+ op.drop_index(op.f('ix_puntuaciones_liga_id'), table_name='puntuaciones_liga')
434
+ op.drop_table('puntuaciones_liga')
435
+ op.drop_index(op.f('ix_progresos_plan_id'), table_name='progresos_plan')
436
+ op.drop_table('progresos_plan')
437
+ op.drop_index(op.f('ix_preguntas_quiz_id'), table_name='preguntas_quiz')
438
+ op.drop_table('preguntas_quiz')
439
+ op.drop_index(op.f('ix_participantes_torneo_id'), table_name='participantes_torneo')
440
+ op.drop_table('participantes_torneo')
441
+ op.drop_index(op.f('ix_miembros_nombre_completo'), table_name='miembros')
442
+ op.drop_index(op.f('ix_miembros_id'), table_name='miembros')
443
+ op.drop_table('miembros')
444
+ op.drop_index(op.f('ix_inventario_id'), table_name='inventario')
445
+ op.drop_table('inventario')
446
+ op.drop_index(op.f('ix_iglesia_roles_id'), table_name='iglesia_roles')
447
+ op.drop_table('iglesia_roles')
448
+ op.drop_index(op.f('ix_finanzas_id'), table_name='finanzas')
449
+ op.drop_table('finanzas')
450
+ op.drop_index(op.f('ix_calendarios_id'), table_name='calendarios')
451
+ op.drop_table('calendarios')
452
+ op.drop_table('user_groups')
453
+ op.drop_index(op.f('ix_torneos_id'), table_name='torneos')
454
+ op.drop_index(op.f('ix_torneos_codigo'), table_name='torneos')
455
+ op.drop_table('torneos')
456
+ op.drop_index(op.f('ix_tasks_task_id'), table_name='tasks')
457
+ op.drop_index(op.f('ix_tasks_id'), table_name='tasks')
458
+ op.drop_table('tasks')
459
+ op.drop_index(op.f('ix_repertorios_name'), table_name='repertorios')
460
+ op.drop_index(op.f('ix_repertorios_id'), table_name='repertorios')
461
+ op.drop_table('repertorios')
462
+ op.drop_index(op.f('ix_partituras_midi_title'), table_name='partituras_midi')
463
+ op.drop_index(op.f('ix_partituras_midi_id'), table_name='partituras_midi')
464
+ op.drop_index(op.f('ix_partituras_midi_device_id'), table_name='partituras_midi')
465
+ op.drop_table('partituras_midi')
466
+ op.drop_index(op.f('ix_lyrics_title'), table_name='lyrics')
467
+ op.drop_index(op.f('ix_lyrics_id'), table_name='lyrics')
468
+ op.drop_index(op.f('ix_lyrics_artist'), table_name='lyrics')
469
+ op.drop_table('lyrics')
470
+ op.drop_index(op.f('ix_invitations_id'), table_name='invitations')
471
+ op.drop_index(op.f('ix_invitations_code'), table_name='invitations')
472
+ op.drop_table('invitations')
473
+ op.drop_index(op.f('ix_iglesias_nombre'), table_name='iglesias')
474
+ op.drop_index(op.f('ix_iglesias_id'), table_name='iglesias')
475
+ op.drop_index(op.f('ix_iglesias_codigo'), table_name='iglesias')
476
+ op.drop_table('iglesias')
477
+ op.drop_index(op.f('ix_dias_lectura_id'), table_name='dias_lectura')
478
+ op.drop_table('dias_lectura')
479
+ op.drop_index(op.f('ix_devices_id'), table_name='devices')
480
+ op.drop_index(op.f('ix_devices_device_id'), table_name='devices')
481
+ op.drop_table('devices')
482
+ op.drop_index(op.f('ix_biblia_versiculos_versiculo'), table_name='biblia_versiculos')
483
+ op.drop_index(op.f('ix_biblia_versiculos_id'), table_name='biblia_versiculos')
484
+ op.drop_index(op.f('ix_biblia_versiculos_capitulo'), table_name='biblia_versiculos')
485
+ op.drop_table('biblia_versiculos')
486
+ op.drop_index(op.f('ix_users_username'), table_name='users')
487
+ op.drop_index(op.f('ix_users_id'), table_name='users')
488
+ op.drop_index(op.f('ix_users_email'), table_name='users')
489
+ op.drop_table('users')
490
+ op.drop_index(op.f('ix_planes_lectura_id'), table_name='planes_lectura')
491
+ op.drop_table('planes_lectura')
492
+ op.drop_index(op.f('ix_groups_name'), table_name='groups')
493
+ op.drop_index(op.f('ix_groups_id'), table_name='groups')
494
+ op.drop_table('groups')
495
+ op.drop_index(op.f('ix_biblia_libros_nombre'), table_name='biblia_libros')
496
+ op.drop_index(op.f('ix_biblia_libros_id'), table_name='biblia_libros')
497
+ op.drop_table('biblia_libros')
498
+ # ### end Alembic commands ###
app/database.py CHANGED
@@ -1,45 +1,45 @@
1
- from sqlalchemy import create_engine
2
- from sqlalchemy.ext.declarative import declarative_base
3
- from sqlalchemy.orm import sessionmaker
4
- from sqlalchemy.pool import StaticPool
5
- import os
6
- from dotenv import load_dotenv
7
-
8
- # Cargar variables de entorno
9
- load_dotenv()
10
-
11
- # Configuración de base de datos: PostgreSQL en producción, SQLite fallback en desarrollo
12
- DATABASE_URL = os.getenv("DATABASE_URL")
13
-
14
- if DATABASE_URL:
15
- # PostgreSQL para producción
16
- IS_WORKER = os.getenv("IS_WORKER", "false").lower() == "true"
17
- p_size = 2 if IS_WORKER else 5
18
- p_overflow = 4 if IS_WORKER else 8
19
-
20
- engine = create_engine(
21
- DATABASE_URL,
22
- pool_pre_ping=True,
23
- pool_size=p_size,
24
- max_overflow=p_overflow,
25
- pool_recycle=1800 # Reciclar conexiones cada 30 min para evitar timeouts del proveedor
26
- )
27
- else:
28
- # SQLite para desarrollo/testing local
29
- SQLITE_URL = "sqlite:///./musical_master.db"
30
- engine = create_engine(
31
- SQLITE_URL,
32
- connect_args={"check_same_thread": False},
33
- poolclass=StaticPool
34
- )
35
-
36
- SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
37
-
38
- Base = declarative_base()
39
-
40
- def get_db():
41
- db = SessionLocal()
42
- try:
43
- yield db
44
- finally:
45
- db.close()
 
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.ext.declarative import declarative_base
3
+ from sqlalchemy.orm import sessionmaker
4
+ from sqlalchemy.pool import StaticPool
5
+ import os
6
+ from dotenv import load_dotenv
7
+
8
+ # Cargar variables de entorno
9
+ load_dotenv()
10
+
11
+ # Configuración de base de datos: PostgreSQL en producción, SQLite fallback en desarrollo
12
+ DATABASE_URL = os.getenv("DATABASE_URL")
13
+
14
+ if DATABASE_URL:
15
+ # PostgreSQL para producción
16
+ IS_WORKER = os.getenv("IS_WORKER", "false").lower() == "true"
17
+ p_size = 2 if IS_WORKER else 5
18
+ p_overflow = 4 if IS_WORKER else 8
19
+
20
+ engine = create_engine(
21
+ DATABASE_URL,
22
+ pool_pre_ping=True,
23
+ pool_size=p_size,
24
+ max_overflow=p_overflow,
25
+ pool_recycle=1800 # Reciclar conexiones cada 30 min para evitar timeouts del proveedor
26
+ )
27
+ else:
28
+ # SQLite para desarrollo/testing local
29
+ SQLITE_URL = "sqlite:///./musical_master.db"
30
+ engine = create_engine(
31
+ SQLITE_URL,
32
+ connect_args={"check_same_thread": False},
33
+ poolclass=StaticPool
34
+ )
35
+
36
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
37
+
38
+ Base = declarative_base()
39
+
40
+ def get_db():
41
+ db = SessionLocal()
42
+ try:
43
+ yield db
44
+ finally:
45
+ db.close()
app/main.py CHANGED
@@ -1,238 +1,238 @@
1
- from fastapi import FastAPI
2
- from fastapi.middleware.cors import CORSMiddleware
3
- from starlette.middleware.gzip import GZipMiddleware
4
- from fastapi.staticfiles import StaticFiles
5
- from app.database import engine, Base
6
- from app.routers import auth, songs, lyrics, iglesia, aprendizaje, midi, alabanza, workers, admin, migration, forums, payments
7
- from app.models.user import User, AdminUser, AdminNotification, LandingGalleryItem, LandingConfig
8
- from app.models.process import Task, Worker
9
- from app.models.lyrics import Lyric, Repertorio, Group, UserGroup, Invitation
10
- from app.models.iglesia import Iglesia, IglesiaMember, IglesiaRol, Calendario, Servicio, Asistencia, Finanza, Momento, RelacionFamiliar, Inventario, Miembro, DismissedVisita
11
- from app.models.sync import B2SyncQueue, MigrationBackup
12
- import app.models.aprendizaje # Registrar modelos de aprendizaje
13
- import app.models.forum # Registrar modelos del foro comunitario
14
- import app.models.process # Asegura que las tablas de tareas se registren en Base
15
- import app.models.midi # Registrar modelos de partituras/MIDI
16
- import os
17
-
18
- # Crear tablas (en producción usar Alembic)
19
- Base.metadata.create_all(bind=engine)
20
-
21
- # Migración en caliente: añadir columnas que pueden no existir en BDs antiguas
22
- _migrations = [
23
- # users
24
- "ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url VARCHAR",
25
- "ALTER TABLE users ADD COLUMN IF NOT EXISTS username_changes_this_month INTEGER DEFAULT 0",
26
- "ALTER TABLE users ADD COLUMN IF NOT EXISTS last_username_change_at TIMESTAMP DEFAULT NULL",
27
- "ALTER TABLE users ADD COLUMN IF NOT EXISTS is_forum_banned BOOLEAN DEFAULT FALSE",
28
- # tasks
29
- "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chords TEXT",
30
- # b2_sync_queue
31
- "ALTER TABLE b2_sync_queue ADD COLUMN IF NOT EXISTS is_uploaded BOOLEAN DEFAULT TRUE",
32
- "ALTER TABLE b2_sync_queue ADD COLUMN IF NOT EXISTS uploading_by TEXT DEFAULT NULL",
33
- "ALTER TABLE b2_sync_queue ADD COLUMN IF NOT EXISTS uploading_claimed_at TIMESTAMP DEFAULT NULL",
34
- "ALTER TABLE b2_sync_queue ADD COLUMN IF NOT EXISTS missing_stems JSONB DEFAULT NULL",
35
- # lyrics
36
- "ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS transpose_offset INTEGER DEFAULT 0",
37
- # dismissed_visitas (creada en caliente si no existe)
38
- """
39
- CREATE TABLE IF NOT EXISTS dismissed_visitas (
40
- id SERIAL PRIMARY KEY,
41
- user_id INTEGER REFERENCES users(id),
42
- miembro_id INTEGER REFERENCES miembros(id),
43
- dismissed_at TIMESTAMP DEFAULT NOW()
44
- )
45
- """,
46
- # finanzas
47
- "ALTER TABLE finanzas ADD COLUMN IF NOT EXISTS reference_uuid TEXT",
48
- # credit_transactions
49
- "ALTER TABLE credit_transactions ADD COLUMN IF NOT EXISTS credit_type VARCHAR DEFAULT 'base'",
50
- "ALTER TABLE credit_transactions ADD COLUMN IF NOT EXISTS reference_id VARCHAR",
51
- # workers
52
- "ALTER TABLE workers ADD COLUMN IF NOT EXISTS user_id INTEGER",
53
- "ALTER TABLE workers ADD COLUMN IF NOT EXISTS token VARCHAR",
54
- "ALTER TABLE workers ADD COLUMN IF NOT EXISTS command VARCHAR DEFAULT 'none'",
55
- "ALTER TABLE workers ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT FALSE",
56
- "ALTER TABLE workers ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT FALSE",
57
- "ALTER TABLE workers ADD COLUMN IF NOT EXISTS url VARCHAR",
58
- "CREATE INDEX IF NOT EXISTS idx_workers_user_id ON workers(user_id)",
59
- "CREATE INDEX IF NOT EXISTS idx_workers_token ON workers(token)",
60
- # users bonus_credits
61
- "ALTER TABLE users ADD COLUMN IF NOT EXISTS bonus_credits INTEGER DEFAULT 0",
62
- # torneos
63
- "ALTER TABLE torneos ADD COLUMN IF NOT EXISTS primero_termino_en VARCHAR",
64
- # tasks — bpm, beats, tuning_info y worker attribution
65
- "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS bpm FLOAT",
66
- "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS beats TEXT",
67
- "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS tuning_info TEXT",
68
- "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS harmony_status VARCHAR DEFAULT 'none'",
69
- "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS processed_by_worker_id INTEGER",
70
- "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS consumed_credit_type VARCHAR DEFAULT 'base'",
71
- # admin_notifications — categoría de acción
72
- "ALTER TABLE admin_notifications ADD COLUMN IF NOT EXISTS category VARCHAR DEFAULT 'system'",
73
- # suspension columns (por si la BD no fue migrada manualmente)
74
- "ALTER TABLE users ADD COLUMN IF NOT EXISTS is_suspended BOOLEAN DEFAULT FALSE",
75
- "ALTER TABLE iglesias ADD COLUMN IF NOT EXISTS is_suspended BOOLEAN DEFAULT FALSE",
76
- "ALTER TABLE groups ADD COLUMN IF NOT EXISTS is_suspended BOOLEAN DEFAULT FALSE",
77
- # forum indexes
78
- "CREATE INDEX IF NOT EXISTS idx_forum_threads_is_deleted ON forum_threads(is_deleted)",
79
- "CREATE INDEX IF NOT EXISTS idx_forum_threads_created_at ON forum_threads(created_at)",
80
- "CREATE INDEX IF NOT EXISTS idx_forum_replies_thread_id ON forum_replies(thread_id)",
81
- "CREATE INDEX IF NOT EXISTS idx_forum_replies_is_deleted ON forum_replies(is_deleted)",
82
- "CREATE INDEX IF NOT EXISTS idx_forum_replies_created_at ON forum_replies(created_at)"
83
- ]
84
-
85
- from sqlalchemy import text as _sql_text
86
- for _mig_sql in _migrations:
87
- try:
88
- with engine.begin() as _conn:
89
- _conn.execute(_sql_text(_mig_sql))
90
- except Exception as _mig_err:
91
- pass # Columna ya existe u otro error no crítico
92
-
93
-
94
-
95
- # Sembrar usuario Administrador por defecto si no existe
96
- from app.database import SessionLocal
97
- from app.routers.auth import get_password_hash
98
-
99
- db = SessionLocal()
100
- try:
101
- admin_exists = db.query(AdminUser).filter(AdminUser.username == "isaacsalgado105@gmail.com").first()
102
- if not admin_exists:
103
- admin_pass = os.getenv("DEFAULT_ADMIN_PASSWORD", "Dis147258369")
104
- hashed = get_password_hash(admin_pass)
105
- admin_user = AdminUser(
106
- username="isaacsalgado105@gmail.com",
107
- hashed_password=hashed,
108
- perm_users=True,
109
- perm_workers=True,
110
- perm_churches=True,
111
- perm_groups=True,
112
- perm_notifications=True
113
- )
114
- db.add(admin_user)
115
- db.commit()
116
- # Generar una notificación inicial de bienvenida
117
- initial_notif = AdminNotification(
118
- title="Bienvenido a Melodix Admin",
119
- message="El panel de control administrativo y sistema de monitoreo ha sido configurado.",
120
- read=False
121
- )
122
- db.add(initial_notif)
123
- db.commit()
124
- print("Usuario Administrador por defecto sembrado exitosamente.")
125
- else:
126
- # Migración en caliente: si las columnas de permisos son NULL, asignar True
127
- changed = False
128
- for perm_col in ('perm_users', 'perm_workers', 'perm_churches', 'perm_groups', 'perm_notifications'):
129
- if getattr(admin_exists, perm_col, None) is None:
130
- setattr(admin_exists, perm_col, True)
131
- changed = True
132
- if changed:
133
- db.commit()
134
- print("Permisos del administrador actualizados a True (migración en caliente).")
135
- except Exception as e:
136
- print(f"Error sembrando usuario administrador: {e}")
137
- finally:
138
- db.close()
139
-
140
-
141
- from fastapi import FastAPI, WebSocket, WebSocketDisconnect
142
- from app.websocket_manager import manager
143
- from app.websocket import ws_manager
144
-
145
- app = FastAPI()
146
-
147
- @app.on_event("startup")
148
- async def startup_event():
149
- import asyncio
150
- loop = asyncio.get_running_loop()
151
- manager.loop = loop
152
- ws_manager.loop = loop
153
- print("[WebSocket] Event loop registered in connection managers.")
154
-
155
- @app.websocket("/ws/admin")
156
- async def websocket_admin_endpoint(websocket: WebSocket):
157
- await ws_manager.connect(websocket)
158
- try:
159
- while True:
160
- # Mantener conexión activa
161
- await websocket.receive_text()
162
- except WebSocketDisconnect:
163
- ws_manager.disconnect(websocket)
164
-
165
- @app.websocket("/ws/{user_id}")
166
- async def websocket_endpoint(websocket: WebSocket, user_id: int):
167
- await manager.connect(user_id, websocket)
168
- try:
169
- while True:
170
- # Mantener la conexión abierta recibiendo datos dummy
171
- await websocket.receive_text()
172
- except WebSocketDisconnect:
173
- manager.disconnect(user_id)
174
-
175
- # Configuración CORS
176
- allowed_origins_env = os.getenv("ALLOWED_ORIGINS", "*")
177
- if allowed_origins_env and allowed_origins_env != "*":
178
- origins = [origin.strip() for origin in allowed_origins_env.split(",") if origin.strip()]
179
- else:
180
- origins = ["*"]
181
-
182
- app.add_middleware(
183
- CORSMiddleware,
184
- allow_origins=origins,
185
- allow_methods=["*"],
186
- allow_headers=["*"],
187
- )
188
- app.add_middleware(GZipMiddleware, minimum_size=500)
189
-
190
- # Servir archivos estáticos (audios)
191
- # Ajustar rutas relativas si es necesario
192
- BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
193
- PROCESSED_DIR = os.path.join(BASE_DIR, "processed")
194
- os.makedirs(PROCESSED_DIR, exist_ok=True)
195
-
196
- app.mount("/audios", StaticFiles(directory=PROCESSED_DIR), name="audios")
197
-
198
- # Incluir routers
199
- app.include_router(auth.router)
200
- app.include_router(songs.router)
201
- app.include_router(lyrics.router)
202
- app.include_router(iglesia.router)
203
- app.include_router(aprendizaje.router)
204
- app.include_router(midi.router)
205
- app.include_router(alabanza.router)
206
- app.include_router(workers.router)
207
- app.include_router(admin.router)
208
- app.include_router(migration.router)
209
- app.include_router(forums.router)
210
- app.include_router(payments.router)
211
-
212
-
213
- @app.get("/")
214
- def read_root():
215
- return {"message": "Melodix API is running v2.7.1"}
216
-
217
- @app.get("/health")
218
- def health_check():
219
- """Diagnóstico de conectividad con la base de datos y estado de tablas."""
220
- import os
221
- from sqlalchemy import text, inspect
222
- from app.database import engine
223
- try:
224
- with engine.connect() as conn:
225
- conn.execute(text("SELECT 1"))
226
- inspector = inspect(engine)
227
- tables = inspector.get_table_names()
228
- return {
229
- "status": "ok",
230
- "database": os.getenv("DATABASE_URL", "SQLite (local)").split("@")[-1] if os.getenv("DATABASE_URL") else "SQLite (local)",
231
- "tables_count": len(tables),
232
- "b2_sync_queue_exists": "b2_sync_queue" in tables,
233
- "users_exists": "users" in tables,
234
- "devices_exists": "devices" in tables,
235
- "tables": tables
236
- }
237
- except Exception as e:
238
- return {"status": "error", "detail": str(e)}
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from starlette.middleware.gzip import GZipMiddleware
4
+ from fastapi.staticfiles import StaticFiles
5
+ from app.database import engine, Base
6
+ from app.routers import auth, songs, lyrics, iglesia, aprendizaje, midi, alabanza, workers, admin, migration, forums, payments
7
+ from app.models.user import User, AdminUser, AdminNotification, LandingGalleryItem, LandingConfig
8
+ from app.models.process import Task, Worker
9
+ from app.models.lyrics import Lyric, Repertorio, Group, UserGroup, Invitation
10
+ from app.models.iglesia import Iglesia, IglesiaMember, IglesiaRol, Calendario, Servicio, Asistencia, Finanza, Momento, RelacionFamiliar, Inventario, Miembro, DismissedVisita
11
+ from app.models.sync import B2SyncQueue, MigrationBackup
12
+ import app.models.aprendizaje # Registrar modelos de aprendizaje
13
+ import app.models.forum # Registrar modelos del foro comunitario
14
+ import app.models.process # Asegura que las tablas de tareas se registren en Base
15
+ import app.models.midi # Registrar modelos de partituras/MIDI
16
+ import os
17
+
18
+ # Crear tablas (en producción usar Alembic)
19
+ Base.metadata.create_all(bind=engine)
20
+
21
+ # Migración en caliente: añadir columnas que pueden no existir en BDs antiguas
22
+ _migrations = [
23
+ # users
24
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url VARCHAR",
25
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS username_changes_this_month INTEGER DEFAULT 0",
26
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS last_username_change_at TIMESTAMP DEFAULT NULL",
27
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS is_forum_banned BOOLEAN DEFAULT FALSE",
28
+ # tasks
29
+ "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chords TEXT",
30
+ # b2_sync_queue
31
+ "ALTER TABLE b2_sync_queue ADD COLUMN IF NOT EXISTS is_uploaded BOOLEAN DEFAULT TRUE",
32
+ "ALTER TABLE b2_sync_queue ADD COLUMN IF NOT EXISTS uploading_by TEXT DEFAULT NULL",
33
+ "ALTER TABLE b2_sync_queue ADD COLUMN IF NOT EXISTS uploading_claimed_at TIMESTAMP DEFAULT NULL",
34
+ "ALTER TABLE b2_sync_queue ADD COLUMN IF NOT EXISTS missing_stems JSONB DEFAULT NULL",
35
+ # lyrics
36
+ "ALTER TABLE lyrics ADD COLUMN IF NOT EXISTS transpose_offset INTEGER DEFAULT 0",
37
+ # dismissed_visitas (creada en caliente si no existe)
38
+ """
39
+ CREATE TABLE IF NOT EXISTS dismissed_visitas (
40
+ id SERIAL PRIMARY KEY,
41
+ user_id INTEGER REFERENCES users(id),
42
+ miembro_id INTEGER REFERENCES miembros(id),
43
+ dismissed_at TIMESTAMP DEFAULT NOW()
44
+ )
45
+ """,
46
+ # finanzas
47
+ "ALTER TABLE finanzas ADD COLUMN IF NOT EXISTS reference_uuid TEXT",
48
+ # credit_transactions
49
+ "ALTER TABLE credit_transactions ADD COLUMN IF NOT EXISTS credit_type VARCHAR DEFAULT 'base'",
50
+ "ALTER TABLE credit_transactions ADD COLUMN IF NOT EXISTS reference_id VARCHAR",
51
+ # workers
52
+ "ALTER TABLE workers ADD COLUMN IF NOT EXISTS user_id INTEGER",
53
+ "ALTER TABLE workers ADD COLUMN IF NOT EXISTS token VARCHAR",
54
+ "ALTER TABLE workers ADD COLUMN IF NOT EXISTS command VARCHAR DEFAULT 'none'",
55
+ "ALTER TABLE workers ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT FALSE",
56
+ "ALTER TABLE workers ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT FALSE",
57
+ "ALTER TABLE workers ADD COLUMN IF NOT EXISTS url VARCHAR",
58
+ "CREATE INDEX IF NOT EXISTS idx_workers_user_id ON workers(user_id)",
59
+ "CREATE INDEX IF NOT EXISTS idx_workers_token ON workers(token)",
60
+ # users bonus_credits
61
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS bonus_credits INTEGER DEFAULT 0",
62
+ # torneos
63
+ "ALTER TABLE torneos ADD COLUMN IF NOT EXISTS primero_termino_en VARCHAR",
64
+ # tasks — bpm, beats, tuning_info y worker attribution
65
+ "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS bpm FLOAT",
66
+ "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS beats TEXT",
67
+ "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS tuning_info TEXT",
68
+ "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS harmony_status VARCHAR DEFAULT 'none'",
69
+ "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS processed_by_worker_id INTEGER",
70
+ "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS consumed_credit_type VARCHAR DEFAULT 'base'",
71
+ # admin_notifications — categoría de acción
72
+ "ALTER TABLE admin_notifications ADD COLUMN IF NOT EXISTS category VARCHAR DEFAULT 'system'",
73
+ # suspension columns (por si la BD no fue migrada manualmente)
74
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS is_suspended BOOLEAN DEFAULT FALSE",
75
+ "ALTER TABLE iglesias ADD COLUMN IF NOT EXISTS is_suspended BOOLEAN DEFAULT FALSE",
76
+ "ALTER TABLE groups ADD COLUMN IF NOT EXISTS is_suspended BOOLEAN DEFAULT FALSE",
77
+ # forum indexes
78
+ "CREATE INDEX IF NOT EXISTS idx_forum_threads_is_deleted ON forum_threads(is_deleted)",
79
+ "CREATE INDEX IF NOT EXISTS idx_forum_threads_created_at ON forum_threads(created_at)",
80
+ "CREATE INDEX IF NOT EXISTS idx_forum_replies_thread_id ON forum_replies(thread_id)",
81
+ "CREATE INDEX IF NOT EXISTS idx_forum_replies_is_deleted ON forum_replies(is_deleted)",
82
+ "CREATE INDEX IF NOT EXISTS idx_forum_replies_created_at ON forum_replies(created_at)"
83
+ ]
84
+
85
+ from sqlalchemy import text as _sql_text
86
+ for _mig_sql in _migrations:
87
+ try:
88
+ with engine.begin() as _conn:
89
+ _conn.execute(_sql_text(_mig_sql))
90
+ except Exception as _mig_err:
91
+ pass # Columna ya existe u otro error no crítico
92
+
93
+
94
+
95
+ # Sembrar usuario Administrador por defecto si no existe
96
+ from app.database import SessionLocal
97
+ from app.routers.auth import get_password_hash
98
+
99
+ db = SessionLocal()
100
+ try:
101
+ admin_exists = db.query(AdminUser).filter(AdminUser.username == "isaacsalgado105@gmail.com").first()
102
+ if not admin_exists:
103
+ admin_pass = os.getenv("DEFAULT_ADMIN_PASSWORD", "Dis147258369")
104
+ hashed = get_password_hash(admin_pass)
105
+ admin_user = AdminUser(
106
+ username="isaacsalgado105@gmail.com",
107
+ hashed_password=hashed,
108
+ perm_users=True,
109
+ perm_workers=True,
110
+ perm_churches=True,
111
+ perm_groups=True,
112
+ perm_notifications=True
113
+ )
114
+ db.add(admin_user)
115
+ db.commit()
116
+ # Generar una notificación inicial de bienvenida
117
+ initial_notif = AdminNotification(
118
+ title="Bienvenido a Melodix Admin",
119
+ message="El panel de control administrativo y sistema de monitoreo ha sido configurado.",
120
+ read=False
121
+ )
122
+ db.add(initial_notif)
123
+ db.commit()
124
+ print("Usuario Administrador por defecto sembrado exitosamente.")
125
+ else:
126
+ # Migración en caliente: si las columnas de permisos son NULL, asignar True
127
+ changed = False
128
+ for perm_col in ('perm_users', 'perm_workers', 'perm_churches', 'perm_groups', 'perm_notifications'):
129
+ if getattr(admin_exists, perm_col, None) is None:
130
+ setattr(admin_exists, perm_col, True)
131
+ changed = True
132
+ if changed:
133
+ db.commit()
134
+ print("Permisos del administrador actualizados a True (migración en caliente).")
135
+ except Exception as e:
136
+ print(f"Error sembrando usuario administrador: {e}")
137
+ finally:
138
+ db.close()
139
+
140
+
141
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
142
+ from app.websocket_manager import manager
143
+ from app.websocket import ws_manager
144
+
145
+ app = FastAPI()
146
+
147
+ @app.on_event("startup")
148
+ async def startup_event():
149
+ import asyncio
150
+ loop = asyncio.get_running_loop()
151
+ manager.loop = loop
152
+ ws_manager.loop = loop
153
+ print("[WebSocket] Event loop registered in connection managers.")
154
+
155
+ @app.websocket("/ws/admin")
156
+ async def websocket_admin_endpoint(websocket: WebSocket):
157
+ await ws_manager.connect(websocket)
158
+ try:
159
+ while True:
160
+ # Mantener conexión activa
161
+ await websocket.receive_text()
162
+ except WebSocketDisconnect:
163
+ ws_manager.disconnect(websocket)
164
+
165
+ @app.websocket("/ws/{user_id}")
166
+ async def websocket_endpoint(websocket: WebSocket, user_id: int):
167
+ await manager.connect(user_id, websocket)
168
+ try:
169
+ while True:
170
+ # Mantener la conexión abierta recibiendo datos dummy
171
+ await websocket.receive_text()
172
+ except WebSocketDisconnect:
173
+ manager.disconnect(user_id)
174
+
175
+ # Configuración CORS
176
+ allowed_origins_env = os.getenv("ALLOWED_ORIGINS", "*")
177
+ if allowed_origins_env and allowed_origins_env != "*":
178
+ origins = [origin.strip() for origin in allowed_origins_env.split(",") if origin.strip()]
179
+ else:
180
+ origins = ["*"]
181
+
182
+ app.add_middleware(
183
+ CORSMiddleware,
184
+ allow_origins=origins,
185
+ allow_methods=["*"],
186
+ allow_headers=["*"],
187
+ )
188
+ app.add_middleware(GZipMiddleware, minimum_size=500)
189
+
190
+ # Servir archivos estáticos (audios)
191
+ # Ajustar rutas relativas si es necesario
192
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
193
+ PROCESSED_DIR = os.path.join(BASE_DIR, "processed")
194
+ os.makedirs(PROCESSED_DIR, exist_ok=True)
195
+
196
+ app.mount("/audios", StaticFiles(directory=PROCESSED_DIR), name="audios")
197
+
198
+ # Incluir routers
199
+ app.include_router(auth.router)
200
+ app.include_router(songs.router)
201
+ app.include_router(lyrics.router)
202
+ app.include_router(iglesia.router)
203
+ app.include_router(aprendizaje.router)
204
+ app.include_router(midi.router)
205
+ app.include_router(alabanza.router)
206
+ app.include_router(workers.router)
207
+ app.include_router(admin.router)
208
+ app.include_router(migration.router)
209
+ app.include_router(forums.router)
210
+ app.include_router(payments.router)
211
+
212
+
213
+ @app.get("/")
214
+ def read_root():
215
+ return {"message": "Melodix API is running v2.7.1"}
216
+
217
+ @app.get("/health")
218
+ def health_check():
219
+ """Diagnóstico de conectividad con la base de datos y estado de tablas."""
220
+ import os
221
+ from sqlalchemy import text, inspect
222
+ from app.database import engine
223
+ try:
224
+ with engine.connect() as conn:
225
+ conn.execute(text("SELECT 1"))
226
+ inspector = inspect(engine)
227
+ tables = inspector.get_table_names()
228
+ return {
229
+ "status": "ok",
230
+ "database": os.getenv("DATABASE_URL", "SQLite (local)").split("@")[-1] if os.getenv("DATABASE_URL") else "SQLite (local)",
231
+ "tables_count": len(tables),
232
+ "b2_sync_queue_exists": "b2_sync_queue" in tables,
233
+ "users_exists": "users" in tables,
234
+ "devices_exists": "devices" in tables,
235
+ "tables": tables
236
+ }
237
+ except Exception as e:
238
+ return {"status": "error", "detail": str(e)}
app/models/aprendizaje.py CHANGED
@@ -1,110 +1,110 @@
1
- from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Text, Float
2
- from sqlalchemy.orm import relationship
3
- from datetime import datetime
4
- from app.database import Base
5
- from app.models.user import User
6
- from app.models.iglesia import Iglesia
7
-
8
- class BibliaLibro(Base):
9
- __tablename__ = "biblia_libros"
10
- id = Column(Integer, primary_key=True, index=True)
11
- nombre = Column(String, index=True)
12
- testamento = Column(String) # "Antiguo" o "Nuevo"
13
-
14
- class BibliaVersiculo(Base):
15
- __tablename__ = "biblia_versiculos"
16
- id = Column(Integer, primary_key=True, index=True)
17
- libro_id = Column(Integer, ForeignKey("biblia_libros.id"))
18
- capitulo = Column(Integer, index=True)
19
- versiculo = Column(Integer, index=True)
20
- texto = Column(Text)
21
-
22
- libro = relationship("BibliaLibro")
23
-
24
- class PlanLectura(Base):
25
- __tablename__ = "planes_lectura"
26
- id = Column(Integer, primary_key=True, index=True)
27
- nombre = Column(String)
28
- descripcion = Column(Text)
29
- dias_totales = Column(Integer)
30
- created_at = Column(DateTime, default=datetime.utcnow)
31
-
32
- class DiaLectura(Base):
33
- __tablename__ = "dias_lectura"
34
- id = Column(Integer, primary_key=True, index=True)
35
- plan_id = Column(Integer, ForeignKey("planes_lectura.id"))
36
- dia_numero = Column(Integer)
37
- pasajes = Column(String) # "Génesis 1-3"
38
- titulo = Column(String, nullable=True)
39
-
40
- plan = relationship("PlanLectura")
41
-
42
- class PreguntaQuiz(Base):
43
- __tablename__ = "preguntas_quiz"
44
- id = Column(Integer, primary_key=True, index=True)
45
- dia_lectura_id = Column(Integer, ForeignKey("dias_lectura.id"))
46
- tipo = Column(String) # multiple, verdadero_falso, unir, manual
47
- pregunta = Column(String)
48
- opciones = Column(Text) # JSON string
49
- respuesta_correcta = Column(String)
50
-
51
- dia_lectura = relationship("DiaLectura")
52
-
53
- class ProgresoPlan(Base):
54
- __tablename__ = "progresos_plan"
55
- id = Column(Integer, primary_key=True, index=True)
56
- user_id = Column(Integer, ForeignKey("users.id"))
57
- plan_id = Column(Integer, ForeignKey("planes_lectura.id"))
58
- iglesia_id = Column(Integer, ForeignKey("iglesias.id", ondelete="CASCADE"), nullable=True)
59
- dia_actual = Column(Integer, default=1)
60
- completado = Column(Boolean, default=False)
61
- ultima_lectura_fecha = Column(String, nullable=True) # "YYYY-MM-DD"
62
- racha_actual = Column(Integer, default=0)
63
- repaso_completado_hasta = Column(Integer, default=0)
64
- escudos_racha = Column(Integer, default=0)
65
-
66
- user = relationship("User")
67
- plan = relationship("PlanLectura")
68
- iglesia = relationship("Iglesia")
69
-
70
- class PuntuacionLiga(Base):
71
- __tablename__ = "puntuaciones_liga"
72
- id = Column(Integer, primary_key=True, index=True)
73
- user_id = Column(Integer, ForeignKey("users.id"))
74
- iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
75
- puntos_totales = Column(Integer, default=0)
76
- maxima_racha = Column(Integer, default=0)
77
-
78
- user = relationship("User")
79
- iglesia = relationship("Iglesia")
80
-
81
- class Torneo(Base):
82
- __tablename__ = "torneos"
83
- id = Column(Integer, primary_key=True, index=True)
84
- codigo = Column(String, unique=True, index=True) # e.g. "BIB-7A2F"
85
- nombre = Column(String)
86
- creador_id = Column(Integer, ForeignKey("users.id"))
87
- estado = Column(String, default="activo") # "activo", "desempate", "terminado"
88
- ganador_id = Column(Integer, ForeignKey("users.id"), nullable=True)
89
- preguntas_json = Column(Text) # JSON list of 5 question IDs
90
- desempate_preguntas_json = Column(Text, nullable=True)
91
- fecha_creacion = Column(String)
92
- primero_termino_en = Column(String, nullable=True)
93
-
94
- creador = relationship("User", foreign_keys=[creador_id])
95
- ganador = relationship("User", foreign_keys=[ganador_id])
96
-
97
- class ParticipanteTorneo(Base):
98
- __tablename__ = "participantes_torneo"
99
- id = Column(Integer, primary_key=True, index=True)
100
- torneo_id = Column(Integer, ForeignKey("torneos.id"))
101
- user_id = Column(Integer, ForeignKey("users.id"))
102
- score = Column(Integer, default=0)
103
- tiempo_empleado = Column(Float, default=0.0) # seconds taken
104
- score_desempate = Column(Integer, default=0)
105
- tiempo_desempate = Column(Float, default=0.0)
106
- eliminado = Column(Boolean, default=False)
107
- estado = Column(String, default="pendiente") # "pendiente", "jugado", "desempate_pendiente", "desempate_jugado"
108
-
109
- user = relationship("User")
110
- torneo = relationship("Torneo")
 
1
+ from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Text, Float
2
+ from sqlalchemy.orm import relationship
3
+ from datetime import datetime
4
+ from app.database import Base
5
+ from app.models.user import User
6
+ from app.models.iglesia import Iglesia
7
+
8
+ class BibliaLibro(Base):
9
+ __tablename__ = "biblia_libros"
10
+ id = Column(Integer, primary_key=True, index=True)
11
+ nombre = Column(String, index=True)
12
+ testamento = Column(String) # "Antiguo" o "Nuevo"
13
+
14
+ class BibliaVersiculo(Base):
15
+ __tablename__ = "biblia_versiculos"
16
+ id = Column(Integer, primary_key=True, index=True)
17
+ libro_id = Column(Integer, ForeignKey("biblia_libros.id"))
18
+ capitulo = Column(Integer, index=True)
19
+ versiculo = Column(Integer, index=True)
20
+ texto = Column(Text)
21
+
22
+ libro = relationship("BibliaLibro")
23
+
24
+ class PlanLectura(Base):
25
+ __tablename__ = "planes_lectura"
26
+ id = Column(Integer, primary_key=True, index=True)
27
+ nombre = Column(String)
28
+ descripcion = Column(Text)
29
+ dias_totales = Column(Integer)
30
+ created_at = Column(DateTime, default=datetime.utcnow)
31
+
32
+ class DiaLectura(Base):
33
+ __tablename__ = "dias_lectura"
34
+ id = Column(Integer, primary_key=True, index=True)
35
+ plan_id = Column(Integer, ForeignKey("planes_lectura.id"))
36
+ dia_numero = Column(Integer)
37
+ pasajes = Column(String) # "Génesis 1-3"
38
+ titulo = Column(String, nullable=True)
39
+
40
+ plan = relationship("PlanLectura")
41
+
42
+ class PreguntaQuiz(Base):
43
+ __tablename__ = "preguntas_quiz"
44
+ id = Column(Integer, primary_key=True, index=True)
45
+ dia_lectura_id = Column(Integer, ForeignKey("dias_lectura.id"))
46
+ tipo = Column(String) # multiple, verdadero_falso, unir, manual
47
+ pregunta = Column(String)
48
+ opciones = Column(Text) # JSON string
49
+ respuesta_correcta = Column(String)
50
+
51
+ dia_lectura = relationship("DiaLectura")
52
+
53
+ class ProgresoPlan(Base):
54
+ __tablename__ = "progresos_plan"
55
+ id = Column(Integer, primary_key=True, index=True)
56
+ user_id = Column(Integer, ForeignKey("users.id"))
57
+ plan_id = Column(Integer, ForeignKey("planes_lectura.id"))
58
+ iglesia_id = Column(Integer, ForeignKey("iglesias.id", ondelete="CASCADE"), nullable=True)
59
+ dia_actual = Column(Integer, default=1)
60
+ completado = Column(Boolean, default=False)
61
+ ultima_lectura_fecha = Column(String, nullable=True) # "YYYY-MM-DD"
62
+ racha_actual = Column(Integer, default=0)
63
+ repaso_completado_hasta = Column(Integer, default=0)
64
+ escudos_racha = Column(Integer, default=0)
65
+
66
+ user = relationship("User")
67
+ plan = relationship("PlanLectura")
68
+ iglesia = relationship("Iglesia")
69
+
70
+ class PuntuacionLiga(Base):
71
+ __tablename__ = "puntuaciones_liga"
72
+ id = Column(Integer, primary_key=True, index=True)
73
+ user_id = Column(Integer, ForeignKey("users.id"))
74
+ iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
75
+ puntos_totales = Column(Integer, default=0)
76
+ maxima_racha = Column(Integer, default=0)
77
+
78
+ user = relationship("User")
79
+ iglesia = relationship("Iglesia")
80
+
81
+ class Torneo(Base):
82
+ __tablename__ = "torneos"
83
+ id = Column(Integer, primary_key=True, index=True)
84
+ codigo = Column(String, unique=True, index=True) # e.g. "BIB-7A2F"
85
+ nombre = Column(String)
86
+ creador_id = Column(Integer, ForeignKey("users.id"))
87
+ estado = Column(String, default="activo") # "activo", "desempate", "terminado"
88
+ ganador_id = Column(Integer, ForeignKey("users.id"), nullable=True)
89
+ preguntas_json = Column(Text) # JSON list of 5 question IDs
90
+ desempate_preguntas_json = Column(Text, nullable=True)
91
+ fecha_creacion = Column(String)
92
+ primero_termino_en = Column(String, nullable=True)
93
+
94
+ creador = relationship("User", foreign_keys=[creador_id])
95
+ ganador = relationship("User", foreign_keys=[ganador_id])
96
+
97
+ class ParticipanteTorneo(Base):
98
+ __tablename__ = "participantes_torneo"
99
+ id = Column(Integer, primary_key=True, index=True)
100
+ torneo_id = Column(Integer, ForeignKey("torneos.id"))
101
+ user_id = Column(Integer, ForeignKey("users.id"))
102
+ score = Column(Integer, default=0)
103
+ tiempo_empleado = Column(Float, default=0.0) # seconds taken
104
+ score_desempate = Column(Integer, default=0)
105
+ tiempo_desempate = Column(Float, default=0.0)
106
+ eliminado = Column(Boolean, default=False)
107
+ estado = Column(String, default="pendiente") # "pendiente", "jugado", "desempate_pendiente", "desempate_jugado"
108
+
109
+ user = relationship("User")
110
+ torneo = relationship("Torneo")
app/models/iglesia.py CHANGED
@@ -1,219 +1,219 @@
1
- # ==========================================
2
- # MELODIX - Modelos de Iglesia
3
- # ==========================================
4
-
5
- from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Float, Text
6
- from sqlalchemy.orm import relationship
7
- from datetime import datetime
8
- from app.database import Base
9
-
10
-
11
- class Iglesia(Base):
12
- __tablename__ = "iglesias"
13
-
14
- id = Column(Integer, primary_key=True, index=True)
15
- nombre = Column(String, index=True)
16
- pastor = Column(String)
17
- codigo = Column(String(5), unique=True, index=True) # Código único de 5 dígitos
18
- admin_id = Column(Integer, ForeignKey("users.id"))
19
- icono = Column(String, nullable=True)
20
- foto_base64 = Column(Text, nullable=True)
21
- foto_pastor = Column(String, nullable=True)
22
- imgbb_api_key = Column(String(100), nullable=True)
23
- requiere_aprobacion = Column(Boolean, default=False)
24
- is_suspended = Column(Boolean, default=False)
25
- created_at = Column(DateTime, default=datetime.utcnow)
26
-
27
- admin = relationship("User")
28
- miembros_app = relationship("IglesiaMember", back_populates="iglesia")
29
- miembros = relationship("Miembro", back_populates="iglesia")
30
- calendarios = relationship("Calendario", back_populates="iglesia")
31
- finanzas = relationship("Finanza", back_populates="iglesia")
32
- momentos = relationship("Momento", back_populates="iglesia")
33
- inventario = relationship("Inventario", back_populates="iglesia")
34
-
35
-
36
- class IglesiaRol(Base):
37
- """Roles personalizados y granulares de la iglesia (RBAC)"""
38
- __tablename__ = "iglesia_roles"
39
-
40
- id = Column(Integer, primary_key=True, index=True)
41
- iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
42
- nombre = Column(String) # Administrador, Tesorero, Lider, Miembro
43
- descripcion = Column(String, nullable=True)
44
- permisos = Column(Text) # JSON string con permisos granulares (CRUD por modulo)
45
- is_system = Column(Boolean, default=False) # Para roles fijos que no se pueden borrar
46
-
47
- iglesia = relationship("Iglesia")
48
-
49
-
50
- class IglesiaMember(Base):
51
- """Usuarios de la app que pertenecen a esta iglesia"""
52
- __tablename__ = "iglesia_members"
53
-
54
- id = Column(Integer, primary_key=True, index=True)
55
- iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
56
- user_id = Column(Integer, ForeignKey("users.id"))
57
- rol = Column(String, default="miembro") # Mantenido por retrocompatibilidad temporal
58
- rol_id = Column(Integer, ForeignKey("iglesia_roles.id"), nullable=True)
59
- estado = Column(String, default="activo") # activo, pendiente, expulsado
60
- joined_at = Column(DateTime, default=datetime.utcnow)
61
-
62
- iglesia = relationship("Iglesia", back_populates="miembros_app")
63
- user = relationship("User")
64
- rol_obj = relationship("IglesiaRol")
65
-
66
-
67
- class Miembro(Base):
68
- """Personas registradas en la iglesia (no necesariamente usuarios de la app)"""
69
- __tablename__ = "miembros"
70
-
71
- id = Column(Integer, primary_key=True, index=True)
72
- iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
73
- nombre_completo = Column(String, index=True)
74
- tipo = Column(String) # Caballero, Dama, Señorita, Joven, Niño, Niña
75
- edad = Column(Integer, nullable=True)
76
- fecha_nacimiento = Column(String, nullable=True) # Guardado como string "YYYY-MM-DD"
77
- documento_identidad = Column(String, nullable=True)
78
- foto_base64 = Column(Text, nullable=True) # Foto en Base64 reducida
79
- activo = Column(Boolean, default=True)
80
- telefono = Column(String, nullable=True)
81
- direccion = Column(String, nullable=True)
82
- estado = Column(String, default="Activo") # Activo, Visita
83
- ministerio = Column(String, default="Miembro")
84
- user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
85
- created_at = Column(DateTime, default=datetime.utcnow)
86
-
87
- iglesia = relationship("Iglesia", back_populates="miembros")
88
- user = relationship("User")
89
- asistencias = relationship("Asistencia", back_populates="miembro")
90
- momentos = relationship("Momento", back_populates="miembro")
91
- relaciones = relationship("RelacionFamiliar", foreign_keys="[RelacionFamiliar.miembro_id]")
92
-
93
-
94
- class RelacionFamiliar(Base):
95
- __tablename__ = "relaciones_familiares"
96
-
97
- id = Column(Integer, primary_key=True, index=True)
98
- miembro_id = Column(Integer, ForeignKey("miembros.id"))
99
- pariente_id = Column(Integer, ForeignKey("miembros.id"))
100
- tipo_relacion = Column(String) # Padre, Hijo, Esposo, Esposa, etc.
101
-
102
- miembro = relationship("Miembro", foreign_keys=[miembro_id])
103
- pariente = relationship("Miembro", foreign_keys=[pariente_id])
104
-
105
-
106
- class Calendario(Base):
107
- __tablename__ = "calendarios"
108
-
109
- id = Column(Integer, primary_key=True, index=True)
110
- iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
111
- nombre = Column(String) # "Culto Dominical", "Limpieza", etc.
112
- icono = Column(String, default="calendar")
113
- color_fondo = Column(String, default="#CE93D8")
114
- created_by = Column(Integer, ForeignKey("users.id"))
115
- created_at = Column(DateTime, default=datetime.utcnow)
116
-
117
- iglesia = relationship("Iglesia", back_populates="calendarios")
118
- creator = relationship("User")
119
- servicios = relationship("Servicio", back_populates="calendario")
120
-
121
-
122
- class Servicio(Base):
123
- """Un día específico de servicio dentro de un calendario"""
124
- __tablename__ = "servicios"
125
-
126
- id = Column(Integer, primary_key=True, index=True)
127
- calendario_id = Column(Integer, ForeignKey("calendarios.id"))
128
- nombre = Column(String, nullable=True) # Nombre del servicio/evento
129
- fecha = Column(String) # "YYYY-MM-DD"
130
- descripcion = Column(String, nullable=True)
131
- hora = Column(String, nullable=True)
132
- color_fondo = Column(String, nullable=True)
133
- created_at = Column(DateTime, default=datetime.utcnow)
134
-
135
- calendario = relationship("Calendario", back_populates="servicios")
136
- asistencias = relationship("Asistencia", back_populates="servicio")
137
-
138
-
139
- class Asistencia(Base):
140
- __tablename__ = "asistencias"
141
-
142
- id = Column(Integer, primary_key=True, index=True)
143
- servicio_id = Column(Integer, ForeignKey("servicios.id"))
144
- miembro_id = Column(Integer, ForeignKey("miembros.id"))
145
- asistio = Column(Boolean, default=False)
146
- registrado_por = Column(Integer, ForeignKey("users.id"))
147
- created_at = Column(DateTime, default=datetime.utcnow)
148
-
149
- servicio = relationship("Servicio", back_populates="asistencias")
150
- miembro = relationship("Miembro", back_populates="asistencias")
151
- registrador = relationship("User")
152
-
153
-
154
- class Finanza(Base):
155
- __tablename__ = "finanzas"
156
-
157
- id = Column(Integer, primary_key=True, index=True)
158
- iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
159
- tipo = Column(String) # "entrada" | "salida"
160
- monto = Column(Float)
161
- concepto = Column(String)
162
- responsable = Column(String) # Nombre libre de quien realizó el movimiento
163
- registrado_por = Column(Integer, ForeignKey("users.id")) # Usuario de la app
164
- fecha = Column(DateTime, default=datetime.utcnow)
165
- reference_uuid = Column(String, unique=True, nullable=True, index=True)
166
-
167
- iglesia = relationship("Iglesia", back_populates="finanzas")
168
- registrador = relationship("User")
169
-
170
-
171
- class Momento(Base):
172
- """Momentos espirituales importantes: bautizos, reconciliaciones, etc."""
173
- __tablename__ = "momentos"
174
-
175
- id = Column(Integer, primary_key=True, index=True)
176
- iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
177
- miembro_id = Column(Integer, ForeignKey("miembros.id"), nullable=True)
178
- tipo = Column(String) # "Bautizo", "Reconciliación", "Aceptar a Cristo", etc.
179
- descripcion = Column(Text, nullable=True)
180
- fecha = Column(String) # "YYYY-MM-DD"
181
- registrado_por = Column(Integer, ForeignKey("users.id"))
182
- created_at = Column(DateTime, default=datetime.utcnow)
183
-
184
- iglesia = relationship("Iglesia", back_populates="momentos")
185
- miembro = relationship("Miembro", back_populates="momentos")
186
- registrador = relationship("User")
187
-
188
-
189
- class Inventario(Base):
190
- __tablename__ = "inventario"
191
-
192
- id = Column(Integer, primary_key=True, index=True)
193
- iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
194
- nombre = Column(String)
195
- marca = Column(String)
196
- cantidad = Column(Integer)
197
- fecha_compra = Column(String)
198
- precio = Column(Float)
199
- foto_url = Column(Text, nullable=True)
200
- estado = Column(String, default="Activo")
201
- nota = Column(Text, nullable=True)
202
- registrado_por = Column(Integer, ForeignKey("users.id"))
203
- created_at = Column(DateTime, default=datetime.utcnow)
204
-
205
- iglesia = relationship("Iglesia", back_populates="inventario")
206
- registrador = relationship("User")
207
-
208
-
209
- class DismissedVisita(Base):
210
- """Notificaciones de visita que un administrador en particular ya marcó como vista/leída"""
211
- __tablename__ = "dismissed_visitas"
212
-
213
- id = Column(Integer, primary_key=True, index=True)
214
- user_id = Column(Integer, ForeignKey("users.id"))
215
- miembro_id = Column(Integer, ForeignKey("miembros.id"))
216
- dismissed_at = Column(DateTime, default=datetime.utcnow)
217
-
218
- user = relationship("User")
219
- miembro = relationship("Miembro")
 
1
+ # ==========================================
2
+ # MELODIX - Modelos de Iglesia
3
+ # ==========================================
4
+
5
+ from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Float, Text
6
+ from sqlalchemy.orm import relationship
7
+ from datetime import datetime
8
+ from app.database import Base
9
+
10
+
11
+ class Iglesia(Base):
12
+ __tablename__ = "iglesias"
13
+
14
+ id = Column(Integer, primary_key=True, index=True)
15
+ nombre = Column(String, index=True)
16
+ pastor = Column(String)
17
+ codigo = Column(String(5), unique=True, index=True) # Código único de 5 dígitos
18
+ admin_id = Column(Integer, ForeignKey("users.id"))
19
+ icono = Column(String, nullable=True)
20
+ foto_base64 = Column(Text, nullable=True)
21
+ foto_pastor = Column(String, nullable=True)
22
+ imgbb_api_key = Column(String(100), nullable=True)
23
+ requiere_aprobacion = Column(Boolean, default=False)
24
+ is_suspended = Column(Boolean, default=False)
25
+ created_at = Column(DateTime, default=datetime.utcnow)
26
+
27
+ admin = relationship("User")
28
+ miembros_app = relationship("IglesiaMember", back_populates="iglesia")
29
+ miembros = relationship("Miembro", back_populates="iglesia")
30
+ calendarios = relationship("Calendario", back_populates="iglesia")
31
+ finanzas = relationship("Finanza", back_populates="iglesia")
32
+ momentos = relationship("Momento", back_populates="iglesia")
33
+ inventario = relationship("Inventario", back_populates="iglesia")
34
+
35
+
36
+ class IglesiaRol(Base):
37
+ """Roles personalizados y granulares de la iglesia (RBAC)"""
38
+ __tablename__ = "iglesia_roles"
39
+
40
+ id = Column(Integer, primary_key=True, index=True)
41
+ iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
42
+ nombre = Column(String) # Administrador, Tesorero, Lider, Miembro
43
+ descripcion = Column(String, nullable=True)
44
+ permisos = Column(Text) # JSON string con permisos granulares (CRUD por modulo)
45
+ is_system = Column(Boolean, default=False) # Para roles fijos que no se pueden borrar
46
+
47
+ iglesia = relationship("Iglesia")
48
+
49
+
50
+ class IglesiaMember(Base):
51
+ """Usuarios de la app que pertenecen a esta iglesia"""
52
+ __tablename__ = "iglesia_members"
53
+
54
+ id = Column(Integer, primary_key=True, index=True)
55
+ iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
56
+ user_id = Column(Integer, ForeignKey("users.id"))
57
+ rol = Column(String, default="miembro") # Mantenido por retrocompatibilidad temporal
58
+ rol_id = Column(Integer, ForeignKey("iglesia_roles.id"), nullable=True)
59
+ estado = Column(String, default="activo") # activo, pendiente, expulsado
60
+ joined_at = Column(DateTime, default=datetime.utcnow)
61
+
62
+ iglesia = relationship("Iglesia", back_populates="miembros_app")
63
+ user = relationship("User")
64
+ rol_obj = relationship("IglesiaRol")
65
+
66
+
67
+ class Miembro(Base):
68
+ """Personas registradas en la iglesia (no necesariamente usuarios de la app)"""
69
+ __tablename__ = "miembros"
70
+
71
+ id = Column(Integer, primary_key=True, index=True)
72
+ iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
73
+ nombre_completo = Column(String, index=True)
74
+ tipo = Column(String) # Caballero, Dama, Señorita, Joven, Niño, Niña
75
+ edad = Column(Integer, nullable=True)
76
+ fecha_nacimiento = Column(String, nullable=True) # Guardado como string "YYYY-MM-DD"
77
+ documento_identidad = Column(String, nullable=True)
78
+ foto_base64 = Column(Text, nullable=True) # Foto en Base64 reducida
79
+ activo = Column(Boolean, default=True)
80
+ telefono = Column(String, nullable=True)
81
+ direccion = Column(String, nullable=True)
82
+ estado = Column(String, default="Activo") # Activo, Visita
83
+ ministerio = Column(String, default="Miembro")
84
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
85
+ created_at = Column(DateTime, default=datetime.utcnow)
86
+
87
+ iglesia = relationship("Iglesia", back_populates="miembros")
88
+ user = relationship("User")
89
+ asistencias = relationship("Asistencia", back_populates="miembro")
90
+ momentos = relationship("Momento", back_populates="miembro")
91
+ relaciones = relationship("RelacionFamiliar", foreign_keys="[RelacionFamiliar.miembro_id]")
92
+
93
+
94
+ class RelacionFamiliar(Base):
95
+ __tablename__ = "relaciones_familiares"
96
+
97
+ id = Column(Integer, primary_key=True, index=True)
98
+ miembro_id = Column(Integer, ForeignKey("miembros.id"))
99
+ pariente_id = Column(Integer, ForeignKey("miembros.id"))
100
+ tipo_relacion = Column(String) # Padre, Hijo, Esposo, Esposa, etc.
101
+
102
+ miembro = relationship("Miembro", foreign_keys=[miembro_id])
103
+ pariente = relationship("Miembro", foreign_keys=[pariente_id])
104
+
105
+
106
+ class Calendario(Base):
107
+ __tablename__ = "calendarios"
108
+
109
+ id = Column(Integer, primary_key=True, index=True)
110
+ iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
111
+ nombre = Column(String) # "Culto Dominical", "Limpieza", etc.
112
+ icono = Column(String, default="calendar")
113
+ color_fondo = Column(String, default="#CE93D8")
114
+ created_by = Column(Integer, ForeignKey("users.id"))
115
+ created_at = Column(DateTime, default=datetime.utcnow)
116
+
117
+ iglesia = relationship("Iglesia", back_populates="calendarios")
118
+ creator = relationship("User")
119
+ servicios = relationship("Servicio", back_populates="calendario")
120
+
121
+
122
+ class Servicio(Base):
123
+ """Un día específico de servicio dentro de un calendario"""
124
+ __tablename__ = "servicios"
125
+
126
+ id = Column(Integer, primary_key=True, index=True)
127
+ calendario_id = Column(Integer, ForeignKey("calendarios.id"))
128
+ nombre = Column(String, nullable=True) # Nombre del servicio/evento
129
+ fecha = Column(String) # "YYYY-MM-DD"
130
+ descripcion = Column(String, nullable=True)
131
+ hora = Column(String, nullable=True)
132
+ color_fondo = Column(String, nullable=True)
133
+ created_at = Column(DateTime, default=datetime.utcnow)
134
+
135
+ calendario = relationship("Calendario", back_populates="servicios")
136
+ asistencias = relationship("Asistencia", back_populates="servicio")
137
+
138
+
139
+ class Asistencia(Base):
140
+ __tablename__ = "asistencias"
141
+
142
+ id = Column(Integer, primary_key=True, index=True)
143
+ servicio_id = Column(Integer, ForeignKey("servicios.id"))
144
+ miembro_id = Column(Integer, ForeignKey("miembros.id"))
145
+ asistio = Column(Boolean, default=False)
146
+ registrado_por = Column(Integer, ForeignKey("users.id"))
147
+ created_at = Column(DateTime, default=datetime.utcnow)
148
+
149
+ servicio = relationship("Servicio", back_populates="asistencias")
150
+ miembro = relationship("Miembro", back_populates="asistencias")
151
+ registrador = relationship("User")
152
+
153
+
154
+ class Finanza(Base):
155
+ __tablename__ = "finanzas"
156
+
157
+ id = Column(Integer, primary_key=True, index=True)
158
+ iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
159
+ tipo = Column(String) # "entrada" | "salida"
160
+ monto = Column(Float)
161
+ concepto = Column(String)
162
+ responsable = Column(String) # Nombre libre de quien realizó el movimiento
163
+ registrado_por = Column(Integer, ForeignKey("users.id")) # Usuario de la app
164
+ fecha = Column(DateTime, default=datetime.utcnow)
165
+ reference_uuid = Column(String, unique=True, nullable=True, index=True)
166
+
167
+ iglesia = relationship("Iglesia", back_populates="finanzas")
168
+ registrador = relationship("User")
169
+
170
+
171
+ class Momento(Base):
172
+ """Momentos espirituales importantes: bautizos, reconciliaciones, etc."""
173
+ __tablename__ = "momentos"
174
+
175
+ id = Column(Integer, primary_key=True, index=True)
176
+ iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
177
+ miembro_id = Column(Integer, ForeignKey("miembros.id"), nullable=True)
178
+ tipo = Column(String) # "Bautizo", "Reconciliación", "Aceptar a Cristo", etc.
179
+ descripcion = Column(Text, nullable=True)
180
+ fecha = Column(String) # "YYYY-MM-DD"
181
+ registrado_por = Column(Integer, ForeignKey("users.id"))
182
+ created_at = Column(DateTime, default=datetime.utcnow)
183
+
184
+ iglesia = relationship("Iglesia", back_populates="momentos")
185
+ miembro = relationship("Miembro", back_populates="momentos")
186
+ registrador = relationship("User")
187
+
188
+
189
+ class Inventario(Base):
190
+ __tablename__ = "inventario"
191
+
192
+ id = Column(Integer, primary_key=True, index=True)
193
+ iglesia_id = Column(Integer, ForeignKey("iglesias.id"))
194
+ nombre = Column(String)
195
+ marca = Column(String)
196
+ cantidad = Column(Integer)
197
+ fecha_compra = Column(String)
198
+ precio = Column(Float)
199
+ foto_url = Column(Text, nullable=True)
200
+ estado = Column(String, default="Activo")
201
+ nota = Column(Text, nullable=True)
202
+ registrado_por = Column(Integer, ForeignKey("users.id"))
203
+ created_at = Column(DateTime, default=datetime.utcnow)
204
+
205
+ iglesia = relationship("Iglesia", back_populates="inventario")
206
+ registrador = relationship("User")
207
+
208
+
209
+ class DismissedVisita(Base):
210
+ """Notificaciones de visita que un administrador en particular ya marcó como vista/leída"""
211
+ __tablename__ = "dismissed_visitas"
212
+
213
+ id = Column(Integer, primary_key=True, index=True)
214
+ user_id = Column(Integer, ForeignKey("users.id"))
215
+ miembro_id = Column(Integer, ForeignKey("miembros.id"))
216
+ dismissed_at = Column(DateTime, default=datetime.utcnow)
217
+
218
+ user = relationship("User")
219
+ miembro = relationship("Miembro")
app/models/lyrics.py CHANGED
@@ -1,142 +1,142 @@
1
- from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Table, Boolean
2
- from sqlalchemy.orm import relationship
3
- from datetime import datetime
4
- from app.database import Base
5
-
6
- lyric_groups = Table(
7
- "lyric_groups",
8
- Base.metadata,
9
- Column("lyric_id", Integer, ForeignKey("lyrics.id", ondelete="CASCADE"), primary_key=True),
10
- Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
11
- )
12
-
13
- task_groups = Table(
14
- "task_groups",
15
- Base.metadata,
16
- Column("task_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
17
- Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
18
- )
19
-
20
- class Group(Base):
21
- __tablename__ = "groups"
22
-
23
- id = Column(Integer, primary_key=True, index=True)
24
- name = Column(String, index=True)
25
- codigo = Column(String, unique=True, index=True, nullable=True)
26
- foto_base64 = Column(String, nullable=True)
27
- requiere_aprobacion = Column(Boolean, default=False)
28
- is_suspended = Column(Boolean, default=False)
29
- created_at = Column(DateTime, default=datetime.utcnow)
30
-
31
- members = relationship("UserGroup", back_populates="group")
32
- lyrics = relationship("Lyric", back_populates="group")
33
- repertorios = relationship("Repertorio", back_populates="group")
34
- lyrics_shared = relationship("Lyric", secondary=lyric_groups, back_populates="groups_shared")
35
- tasks_shared = relationship("Task", secondary=task_groups, back_populates="groups_shared")
36
-
37
- class GroupRol(Base):
38
- """Roles personalizados y granulares del grupo de alabanza (RBAC)"""
39
- __tablename__ = "group_roles"
40
-
41
- id = Column(Integer, primary_key=True, index=True)
42
- grupo_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"))
43
- nombre = Column(String) # Director, Pianista, etc.
44
- descripcion = Column(String, nullable=True)
45
- permisos = Column(String) # JSON string con los 8 booleanos
46
- is_system = Column(Boolean, default=False)
47
-
48
- grupo = relationship("Group")
49
-
50
- class UserGroup(Base):
51
- __tablename__ = "user_groups"
52
-
53
- user_id = Column(Integer, ForeignKey("users.id"), primary_key=True)
54
- group_id = Column(Integer, ForeignKey("groups.id"), primary_key=True)
55
- role = Column(String, default="member") # admin, member
56
- estado = Column(String, default="activo") # activo, pendiente, expulsado
57
- joined_at = Column(DateTime, default=datetime.utcnow)
58
-
59
- # Rol personalizado
60
- rol_id = Column(Integer, ForeignKey("group_roles.id", ondelete="SET NULL"), nullable=True)
61
- rol_obj = relationship("GroupRol")
62
-
63
- # Permisos granulares
64
- perm_compartir_pistas = Column(Boolean, default=True)
65
- perm_compartir_letras = Column(Boolean, default=True)
66
- perm_crud_repertorios = Column(Boolean, default=False)
67
- perm_crud_letras = Column(Boolean, default=True)
68
- perm_ver_pistas = Column(Boolean, default=True)
69
- perm_ver_letras = Column(Boolean, default=True)
70
- perm_ver_repertorios = Column(Boolean, default=True)
71
- perm_gestionar_solicitudes = Column(Boolean, default=False)
72
-
73
- # Registro de última lectura para notificaciones
74
- last_read_pistas = Column(DateTime, nullable=True, default=None)
75
- last_read_letras = Column(DateTime, nullable=True, default=None)
76
- last_read_repertorios = Column(DateTime, nullable=True, default=None)
77
-
78
- user = relationship("User", back_populates="groups")
79
- group = relationship("Group", back_populates="members")
80
-
81
- class Lyric(Base):
82
- __tablename__ = "lyrics"
83
-
84
- id = Column(Integer, primary_key=True, index=True)
85
- title = Column(String, index=True)
86
- artist = Column(String, index=True)
87
- content = Column(String) # ChordPro format
88
- key = Column(String) # Original key (e.g., "C")
89
- bpm = Column(Integer, nullable=True)
90
- group_id = Column(Integer, ForeignKey("groups.id"))
91
- created_by = Column(Integer, ForeignKey("users.id"))
92
- transpose_offset = Column(Integer, default=0, nullable=True)
93
- is_repertoire_only = Column(Boolean, default=False, nullable=True)
94
- parent_lyric_id = Column(Integer, ForeignKey("lyrics.id", ondelete="SET NULL"), nullable=True)
95
- created_at = Column(DateTime, default=datetime.utcnow)
96
- updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
97
-
98
- group = relationship("Group", back_populates="lyrics")
99
- creator = relationship("User")
100
- groups_shared = relationship("Group", secondary=lyric_groups, back_populates="lyrics_shared")
101
-
102
- @property
103
- def group_ids(self):
104
- return [g.id for g in self.groups_shared]
105
-
106
- class Repertorio(Base):
107
- __tablename__ = "repertorios"
108
-
109
- id = Column(Integer, primary_key=True, index=True)
110
- name = Column(String, index=True)
111
- group_id = Column(Integer, ForeignKey("groups.id"))
112
- created_by = Column(Integer, ForeignKey("users.id"))
113
- color = Column(String, nullable=True, default='#448AFF')
114
- created_at = Column(DateTime, default=datetime.utcnow)
115
-
116
- group = relationship("Group", back_populates="repertorios")
117
- creator = relationship("User")
118
- lyrics = relationship("RepertorioLyric", back_populates="repertorio")
119
-
120
- class RepertorioLyric(Base):
121
- __tablename__ = "repertorio_lyrics"
122
-
123
- repertorio_id = Column(Integer, ForeignKey("repertorios.id"), primary_key=True)
124
- lyric_id = Column(Integer, ForeignKey("lyrics.id"), primary_key=True)
125
- order = Column(Integer) # Order in the setlist
126
-
127
- repertorio = relationship("Repertorio", back_populates="lyrics")
128
- lyric = relationship("Lyric")
129
-
130
- class Invitation(Base):
131
- __tablename__ = "invitations"
132
-
133
- id = Column(Integer, primary_key=True, index=True)
134
- code = Column(String, unique=True, index=True) # Unique code or link token
135
- group_id = Column(Integer, ForeignKey("groups.id"))
136
- created_by = Column(Integer, ForeignKey("users.id"))
137
- created_at = Column(DateTime, default=datetime.utcnow)
138
- expires_at = Column(DateTime)
139
- used_at = Column(DateTime) # Nullable, if used
140
-
141
- group = relationship("Group")
142
- creator = relationship("User")
 
1
+ from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Table, Boolean
2
+ from sqlalchemy.orm import relationship
3
+ from datetime import datetime
4
+ from app.database import Base
5
+
6
+ lyric_groups = Table(
7
+ "lyric_groups",
8
+ Base.metadata,
9
+ Column("lyric_id", Integer, ForeignKey("lyrics.id", ondelete="CASCADE"), primary_key=True),
10
+ Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
11
+ )
12
+
13
+ task_groups = Table(
14
+ "task_groups",
15
+ Base.metadata,
16
+ Column("task_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
17
+ Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
18
+ )
19
+
20
+ class Group(Base):
21
+ __tablename__ = "groups"
22
+
23
+ id = Column(Integer, primary_key=True, index=True)
24
+ name = Column(String, index=True)
25
+ codigo = Column(String, unique=True, index=True, nullable=True)
26
+ foto_base64 = Column(String, nullable=True)
27
+ requiere_aprobacion = Column(Boolean, default=False)
28
+ is_suspended = Column(Boolean, default=False)
29
+ created_at = Column(DateTime, default=datetime.utcnow)
30
+
31
+ members = relationship("UserGroup", back_populates="group")
32
+ lyrics = relationship("Lyric", back_populates="group")
33
+ repertorios = relationship("Repertorio", back_populates="group")
34
+ lyrics_shared = relationship("Lyric", secondary=lyric_groups, back_populates="groups_shared")
35
+ tasks_shared = relationship("Task", secondary=task_groups, back_populates="groups_shared")
36
+
37
+ class GroupRol(Base):
38
+ """Roles personalizados y granulares del grupo de alabanza (RBAC)"""
39
+ __tablename__ = "group_roles"
40
+
41
+ id = Column(Integer, primary_key=True, index=True)
42
+ grupo_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"))
43
+ nombre = Column(String) # Director, Pianista, etc.
44
+ descripcion = Column(String, nullable=True)
45
+ permisos = Column(String) # JSON string con los 8 booleanos
46
+ is_system = Column(Boolean, default=False)
47
+
48
+ grupo = relationship("Group")
49
+
50
+ class UserGroup(Base):
51
+ __tablename__ = "user_groups"
52
+
53
+ user_id = Column(Integer, ForeignKey("users.id"), primary_key=True)
54
+ group_id = Column(Integer, ForeignKey("groups.id"), primary_key=True)
55
+ role = Column(String, default="member") # admin, member
56
+ estado = Column(String, default="activo") # activo, pendiente, expulsado
57
+ joined_at = Column(DateTime, default=datetime.utcnow)
58
+
59
+ # Rol personalizado
60
+ rol_id = Column(Integer, ForeignKey("group_roles.id", ondelete="SET NULL"), nullable=True)
61
+ rol_obj = relationship("GroupRol")
62
+
63
+ # Permisos granulares
64
+ perm_compartir_pistas = Column(Boolean, default=True)
65
+ perm_compartir_letras = Column(Boolean, default=True)
66
+ perm_crud_repertorios = Column(Boolean, default=False)
67
+ perm_crud_letras = Column(Boolean, default=True)
68
+ perm_ver_pistas = Column(Boolean, default=True)
69
+ perm_ver_letras = Column(Boolean, default=True)
70
+ perm_ver_repertorios = Column(Boolean, default=True)
71
+ perm_gestionar_solicitudes = Column(Boolean, default=False)
72
+
73
+ # Registro de última lectura para notificaciones
74
+ last_read_pistas = Column(DateTime, nullable=True, default=None)
75
+ last_read_letras = Column(DateTime, nullable=True, default=None)
76
+ last_read_repertorios = Column(DateTime, nullable=True, default=None)
77
+
78
+ user = relationship("User", back_populates="groups")
79
+ group = relationship("Group", back_populates="members")
80
+
81
+ class Lyric(Base):
82
+ __tablename__ = "lyrics"
83
+
84
+ id = Column(Integer, primary_key=True, index=True)
85
+ title = Column(String, index=True)
86
+ artist = Column(String, index=True)
87
+ content = Column(String) # ChordPro format
88
+ key = Column(String) # Original key (e.g., "C")
89
+ bpm = Column(Integer, nullable=True)
90
+ group_id = Column(Integer, ForeignKey("groups.id"))
91
+ created_by = Column(Integer, ForeignKey("users.id"))
92
+ transpose_offset = Column(Integer, default=0, nullable=True)
93
+ is_repertoire_only = Column(Boolean, default=False, nullable=True)
94
+ parent_lyric_id = Column(Integer, ForeignKey("lyrics.id", ondelete="SET NULL"), nullable=True)
95
+ created_at = Column(DateTime, default=datetime.utcnow)
96
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
97
+
98
+ group = relationship("Group", back_populates="lyrics")
99
+ creator = relationship("User")
100
+ groups_shared = relationship("Group", secondary=lyric_groups, back_populates="lyrics_shared")
101
+
102
+ @property
103
+ def group_ids(self):
104
+ return [g.id for g in self.groups_shared]
105
+
106
+ class Repertorio(Base):
107
+ __tablename__ = "repertorios"
108
+
109
+ id = Column(Integer, primary_key=True, index=True)
110
+ name = Column(String, index=True)
111
+ group_id = Column(Integer, ForeignKey("groups.id"))
112
+ created_by = Column(Integer, ForeignKey("users.id"))
113
+ color = Column(String, nullable=True, default='#448AFF')
114
+ created_at = Column(DateTime, default=datetime.utcnow)
115
+
116
+ group = relationship("Group", back_populates="repertorios")
117
+ creator = relationship("User")
118
+ lyrics = relationship("RepertorioLyric", back_populates="repertorio")
119
+
120
+ class RepertorioLyric(Base):
121
+ __tablename__ = "repertorio_lyrics"
122
+
123
+ repertorio_id = Column(Integer, ForeignKey("repertorios.id"), primary_key=True)
124
+ lyric_id = Column(Integer, ForeignKey("lyrics.id"), primary_key=True)
125
+ order = Column(Integer) # Order in the setlist
126
+
127
+ repertorio = relationship("Repertorio", back_populates="lyrics")
128
+ lyric = relationship("Lyric")
129
+
130
+ class Invitation(Base):
131
+ __tablename__ = "invitations"
132
+
133
+ id = Column(Integer, primary_key=True, index=True)
134
+ code = Column(String, unique=True, index=True) # Unique code or link token
135
+ group_id = Column(Integer, ForeignKey("groups.id"))
136
+ created_by = Column(Integer, ForeignKey("users.id"))
137
+ created_at = Column(DateTime, default=datetime.utcnow)
138
+ expires_at = Column(DateTime)
139
+ used_at = Column(DateTime) # Nullable, if used
140
+
141
+ group = relationship("Group")
142
+ creator = relationship("User")
app/models/midi.py CHANGED
@@ -1,19 +1,19 @@
1
- from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
2
- from sqlalchemy.orm import relationship
3
- from datetime import datetime
4
- from app.database import Base
5
-
6
- class PartituraMidi(Base):
7
- __tablename__ = "partituras_midi"
8
-
9
- id = Column(Integer, primary_key=True, index=True)
10
- title = Column(String, index=True, nullable=False)
11
- artist = Column(String, default="Artista desconocido")
12
- filename = Column(String, nullable=False)
13
- filepath = Column(String, nullable=False)
14
- tempo_bpm = Column(Float, default=120.0)
15
- device_id = Column(String, index=True, nullable=True)
16
- user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
17
- created_at = Column(DateTime, default=datetime.utcnow)
18
-
19
- user = relationship("User")
 
1
+ from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
2
+ from sqlalchemy.orm import relationship
3
+ from datetime import datetime
4
+ from app.database import Base
5
+
6
+ class PartituraMidi(Base):
7
+ __tablename__ = "partituras_midi"
8
+
9
+ id = Column(Integer, primary_key=True, index=True)
10
+ title = Column(String, index=True, nullable=False)
11
+ artist = Column(String, default="Artista desconocido")
12
+ filename = Column(String, nullable=False)
13
+ filepath = Column(String, nullable=False)
14
+ tempo_bpm = Column(Float, default=120.0)
15
+ device_id = Column(String, index=True, nullable=True)
16
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
17
+ created_at = Column(DateTime, default=datetime.utcnow)
18
+
19
+ user = relationship("User")
app/models/process.py CHANGED
@@ -1,71 +1,71 @@
1
- from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Text, Float
2
- from sqlalchemy.orm import relationship
3
- from datetime import datetime
4
- from app.database import Base
5
- from app.models.user import User
6
- from app.models.lyrics import task_groups
7
-
8
- class Task(Base):
9
- __tablename__ = "tasks"
10
-
11
- id = Column(Integer, primary_key=True, index=True)
12
- task_id = Column(String, unique=True, index=True)
13
- user_id = Column(Integer, ForeignKey("users.id"))
14
- device_id = Column(String)
15
- song_name = Column(String)
16
- artist = Column(String, nullable=True, default=None)
17
- album = Column(String, nullable=True, default=None)
18
- cover_url = Column(String, nullable=True, default=None)
19
- stems = Column(Integer)
20
- status = Column(String, default="PENDING")
21
- created_at = Column(DateTime, default=datetime.utcnow)
22
- # Fecha en que el cliente confirmó que descargó todos los stems.
23
- downloaded_at = Column(DateTime, nullable=True, default=None)
24
- cloud_url = Column(Text, nullable=True, default=None)
25
- # JSON: {"vocals": "BQACAgIA...", "bass": "BQACAgIB..."} - file_ids de Telegram por stem
26
- telegram_file_ids = Column(Text, nullable=True, default=None)
27
- # JSON: {"vocals": 12345, "bass": 12346} - message_ids de Telegram (para borrar si el usuario elimina)
28
- telegram_message_ids = Column(Text, nullable=True, default=None)
29
- group_id = Column(Integer, ForeignKey("groups.id"), nullable=True)
30
- # JSON string con lista de acordes editados por el usuario [{"t": 0.5, "ch": "Am"}, ...]
31
- chords = Column(Text, nullable=True, default=None)
32
- # Fecha en que los archivos de Tebi (stems + voces corales) serán eliminados automáticamente
33
- # Normal: 3 días, Premium: 7 días desde la subida. NULL = ya expirado o tarea anterior al feature.
34
- cloud_expires_at = Column(DateTime, nullable=True, default=None)
35
- bpm = Column(Float, nullable=True, default=None)
36
- beats = Column(Text, nullable=True, default=None) # JSON array of beat timestamps in seconds
37
- tuning_info = Column(Text, nullable=True, default=None) # JSON: {"original_hz": 432.4, "target_hz": 440.0, "cents_offset": -30.2, "retuned": true}
38
- harmony_status = Column(String, default="none", nullable=True) # none | generating | completed | error
39
- consumed_credit_type = Column(String, default="base", nullable=True) # base | bonus
40
- processed_by_worker_id = Column(Integer, ForeignKey("workers.id", ondelete="SET NULL"), nullable=True)
41
-
42
- user = relationship("User", back_populates="tasks")
43
- group = relationship("Group", foreign_keys=[group_id])
44
- groups_shared = relationship("Group", secondary=task_groups, back_populates="tasks_shared")
45
- processed_by_worker = relationship("Worker", foreign_keys=[processed_by_worker_id])
46
-
47
- @property
48
- def group_ids(self):
49
- return [g.id for g in self.groups_shared]
50
-
51
-
52
- class Worker(Base):
53
- __tablename__ = "workers"
54
-
55
- id = Column(Integer, primary_key=True, index=True)
56
- hostname = Column(String, unique=True, index=True)
57
- name = Column(String, nullable=True) # User-friendly custom name
58
- status = Column(String, default="offline") # online | offline | busy
59
- last_seen = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
60
-
61
- # BYOK / Local Worker & Public Community Worker
62
- user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
63
- token = Column(String, unique=True, index=True, nullable=True)
64
- command = Column(String, default="none") # start | stop | none
65
- is_active = Column(Boolean, default=False)
66
- is_public = Column(Boolean, default=False) # True = procesa tareas comunitarias con recompensa, False = privado para su dueño
67
- url = Column(String, nullable=True) # URL de despertar / HuggingFace Space URL
68
-
69
- owner = relationship("User", foreign_keys=[user_id])
70
-
71
-
 
1
+ from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Text, Float
2
+ from sqlalchemy.orm import relationship
3
+ from datetime import datetime
4
+ from app.database import Base
5
+ from app.models.user import User
6
+ from app.models.lyrics import task_groups
7
+
8
+ class Task(Base):
9
+ __tablename__ = "tasks"
10
+
11
+ id = Column(Integer, primary_key=True, index=True)
12
+ task_id = Column(String, unique=True, index=True)
13
+ user_id = Column(Integer, ForeignKey("users.id"))
14
+ device_id = Column(String)
15
+ song_name = Column(String)
16
+ artist = Column(String, nullable=True, default=None)
17
+ album = Column(String, nullable=True, default=None)
18
+ cover_url = Column(String, nullable=True, default=None)
19
+ stems = Column(Integer)
20
+ status = Column(String, default="PENDING")
21
+ created_at = Column(DateTime, default=datetime.utcnow)
22
+ # Fecha en que el cliente confirmó que descargó todos los stems.
23
+ downloaded_at = Column(DateTime, nullable=True, default=None)
24
+ cloud_url = Column(Text, nullable=True, default=None)
25
+ # JSON: {"vocals": "BQACAgIA...", "bass": "BQACAgIB..."} - file_ids de Telegram por stem
26
+ telegram_file_ids = Column(Text, nullable=True, default=None)
27
+ # JSON: {"vocals": 12345, "bass": 12346} - message_ids de Telegram (para borrar si el usuario elimina)
28
+ telegram_message_ids = Column(Text, nullable=True, default=None)
29
+ group_id = Column(Integer, ForeignKey("groups.id"), nullable=True)
30
+ # JSON string con lista de acordes editados por el usuario [{"t": 0.5, "ch": "Am"}, ...]
31
+ chords = Column(Text, nullable=True, default=None)
32
+ # Fecha en que los archivos de Tebi (stems + voces corales) serán eliminados automáticamente
33
+ # Normal: 3 días, Premium: 7 días desde la subida. NULL = ya expirado o tarea anterior al feature.
34
+ cloud_expires_at = Column(DateTime, nullable=True, default=None)
35
+ bpm = Column(Float, nullable=True, default=None)
36
+ beats = Column(Text, nullable=True, default=None) # JSON array of beat timestamps in seconds
37
+ tuning_info = Column(Text, nullable=True, default=None) # JSON: {"original_hz": 432.4, "target_hz": 440.0, "cents_offset": -30.2, "retuned": true}
38
+ harmony_status = Column(String, default="none", nullable=True) # none | generating | completed | error
39
+ consumed_credit_type = Column(String, default="base", nullable=True) # base | bonus
40
+ processed_by_worker_id = Column(Integer, ForeignKey("workers.id", ondelete="SET NULL"), nullable=True)
41
+
42
+ user = relationship("User", back_populates="tasks")
43
+ group = relationship("Group", foreign_keys=[group_id])
44
+ groups_shared = relationship("Group", secondary=task_groups, back_populates="tasks_shared")
45
+ processed_by_worker = relationship("Worker", foreign_keys=[processed_by_worker_id])
46
+
47
+ @property
48
+ def group_ids(self):
49
+ return [g.id for g in self.groups_shared]
50
+
51
+
52
+ class Worker(Base):
53
+ __tablename__ = "workers"
54
+
55
+ id = Column(Integer, primary_key=True, index=True)
56
+ hostname = Column(String, unique=True, index=True)
57
+ name = Column(String, nullable=True) # User-friendly custom name
58
+ status = Column(String, default="offline") # online | offline | busy
59
+ last_seen = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
60
+
61
+ # BYOK / Local Worker & Public Community Worker
62
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
63
+ token = Column(String, unique=True, index=True, nullable=True)
64
+ command = Column(String, default="none") # start | stop | none
65
+ is_active = Column(Boolean, default=False)
66
+ is_public = Column(Boolean, default=False) # True = procesa tareas comunitarias con recompensa, False = privado para su dueño
67
+ url = Column(String, nullable=True) # URL de despertar / HuggingFace Space URL
68
+
69
+ owner = relationship("User", foreign_keys=[user_id])
70
+
71
+
app/models/sync.py CHANGED
@@ -1,32 +1,32 @@
1
- from sqlalchemy import Column, String, DateTime, JSON, Boolean, Integer
2
- from datetime import datetime
3
- import uuid
4
- from app.database import Base
5
-
6
- class B2SyncQueue(Base):
7
- __tablename__ = "b2_sync_queue"
8
-
9
- id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
10
- file_key = Column(String, nullable=False)
11
- file_url = Column(String, nullable=False)
12
- sync_type = Column(String, nullable=False) # "device" o "group"
13
- target_id = Column(String, nullable=False) # userId o groupId
14
- pending_recipients = Column(JSON, nullable=False) # lista de IDs de dispositivos o usuarios
15
- created_at = Column(DateTime, default=datetime.utcnow)
16
-
17
- is_uploaded = Column(Boolean, default=True)
18
- uploading_by = Column(String, nullable=True, default=None)
19
- uploading_claimed_at = Column(DateTime, nullable=True, default=None)
20
- missing_stems = Column(JSON, nullable=True, default=None) # JSON: {"recipient_id": ["vocals", "drums"]}
21
-
22
- class MigrationBackup(Base):
23
- __tablename__ = "migration_backups"
24
-
25
- id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
26
- user_id = Column(Integer, nullable=False, index=True)
27
- created_at = Column(DateTime, default=datetime.utcnow)
28
- expires_at = Column(DateTime, nullable=False)
29
- status = Column(String, default="pending") # "pending", "completed", "restored", "expired"
30
- metadata_json = Column(JSON, nullable=True) # Mapa de metadatos locales
31
- files_info = Column(JSON, nullable=True, default=[]) # Lista de stems respaldados: [{"task_id": "...", "stem_name": "..."}]
32
-
 
1
+ from sqlalchemy import Column, String, DateTime, JSON, Boolean, Integer
2
+ from datetime import datetime
3
+ import uuid
4
+ from app.database import Base
5
+
6
+ class B2SyncQueue(Base):
7
+ __tablename__ = "b2_sync_queue"
8
+
9
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
10
+ file_key = Column(String, nullable=False)
11
+ file_url = Column(String, nullable=False)
12
+ sync_type = Column(String, nullable=False) # "device" o "group"
13
+ target_id = Column(String, nullable=False) # userId o groupId
14
+ pending_recipients = Column(JSON, nullable=False) # lista de IDs de dispositivos o usuarios
15
+ created_at = Column(DateTime, default=datetime.utcnow)
16
+
17
+ is_uploaded = Column(Boolean, default=True)
18
+ uploading_by = Column(String, nullable=True, default=None)
19
+ uploading_claimed_at = Column(DateTime, nullable=True, default=None)
20
+ missing_stems = Column(JSON, nullable=True, default=None) # JSON: {"recipient_id": ["vocals", "drums"]}
21
+
22
+ class MigrationBackup(Base):
23
+ __tablename__ = "migration_backups"
24
+
25
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
26
+ user_id = Column(Integer, nullable=False, index=True)
27
+ created_at = Column(DateTime, default=datetime.utcnow)
28
+ expires_at = Column(DateTime, nullable=False)
29
+ status = Column(String, default="pending") # "pending", "completed", "restored", "expired"
30
+ metadata_json = Column(JSON, nullable=True) # Mapa de metadatos locales
31
+ files_info = Column(JSON, nullable=True, default=[]) # Lista de stems respaldados: [{"task_id": "...", "stem_name": "..."}]
32
+
app/models/user.py CHANGED
@@ -1,169 +1,169 @@
1
- from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, JSON, Text
2
- from sqlalchemy.orm import relationship
3
- from datetime import datetime
4
- from app.database import Base
5
-
6
- class User(Base):
7
- __tablename__ = "users"
8
-
9
- id = Column(Integer, primary_key=True, index=True)
10
- username = Column(String, unique=True, index=True)
11
- email = Column(String, unique=True, index=True)
12
- hashed_password = Column(String)
13
- credits = Column(Integer, default=10)
14
- bonus_credits = Column(Integer, default=0)
15
- is_premium = Column(Boolean, default=False)
16
- avatar_url = Column(String, nullable=True)
17
- reset_token = Column(String, nullable=True)
18
- reset_token_expires = Column(DateTime, nullable=True)
19
- is_suspended = Column(Boolean, default=False)
20
- username_changes_this_month = Column(Integer, default=0)
21
- last_username_change_at = Column(DateTime, nullable=True)
22
- is_forum_banned = Column(Boolean, default=False)
23
-
24
- devices = relationship("Device", back_populates="user")
25
- tasks = relationship("Task", back_populates="user")
26
- groups = relationship("UserGroup", back_populates="user")
27
- credit_transactions = relationship("CreditTransaction", back_populates="user", cascade="all, delete-orphan")
28
-
29
- @property
30
- def total_credits(self) -> int:
31
- return (self.credits or 0) + (self.bonus_credits or 0)
32
-
33
- class Device(Base):
34
- __tablename__ = "devices"
35
-
36
- id = Column(Integer, primary_key=True, index=True)
37
- user_id = Column(Integer, ForeignKey("users.id"))
38
- device_id = Column(String, index=True)
39
- name = Column(String)
40
- registered_at = Column(DateTime, default=datetime.utcnow)
41
- last_active = Column(DateTime, default=datetime.utcnow)
42
-
43
- user = relationship("User", back_populates="devices")
44
-
45
- class CreditTransaction(Base):
46
- __tablename__ = "credit_transactions"
47
-
48
- id = Column(Integer, primary_key=True, index=True)
49
- user_id = Column(Integer, ForeignKey("users.id"))
50
- amount = Column(Integer)
51
- concept = Column(String)
52
- reference_id = Column(String, nullable=True)
53
- credit_type = Column(String, default="base") # base | bonus
54
- created_at = Column(DateTime, default=datetime.utcnow)
55
-
56
- user = relationship("User", back_populates="credit_transactions")
57
-
58
-
59
- class SystemNotification(Base):
60
- __tablename__ = "system_notifications"
61
-
62
- id = Column(Integer, primary_key=True, index=True)
63
- title = Column(String, index=True)
64
- message = Column(String)
65
- created_at = Column(DateTime, default=datetime.utcnow)
66
-
67
-
68
- class AdminUser(Base):
69
- __tablename__ = "admin_users"
70
-
71
- id = Column(Integer, primary_key=True, index=True)
72
- username = Column(String, unique=True, index=True)
73
- hashed_password = Column(String)
74
- avatar_url = Column(String, nullable=True)
75
- perm_users = Column(Boolean, default=True)
76
- perm_workers = Column(Boolean, default=True)
77
- perm_churches = Column(Boolean, default=True)
78
- perm_groups = Column(Boolean, default=True)
79
- perm_notifications = Column(Boolean, default=True)
80
- created_at = Column(DateTime, default=datetime.utcnow)
81
-
82
-
83
- class AdminNotification(Base):
84
- __tablename__ = "admin_notifications"
85
-
86
- id = Column(Integer, primary_key=True, index=True)
87
- title = Column(String, index=True)
88
- message = Column(String)
89
- category = Column(String, default="system") # system | user | church | group | worker
90
- created_at = Column(DateTime, default=datetime.utcnow)
91
- read = Column(Boolean, default=False)
92
-
93
-
94
- class LandingFeature(Base):
95
- __tablename__ = "landing_features"
96
-
97
- id = Column(Integer, primary_key=True, index=True)
98
- icon_svg = Column(String, nullable=True)
99
- icon_color = Column(String, default="cyan") # cyan | purple | green | blue | yellow | red
100
- title = Column(String)
101
- description = Column(String)
102
- order = Column(Integer, default=0)
103
-
104
-
105
- class LandingPlatform(Base):
106
- __tablename__ = "landing_platforms"
107
-
108
- id = Column(Integer, primary_key=True, index=True)
109
- name = Column(String, nullable=False)
110
- version = Column(String, nullable=True)
111
- os_details = Column(String, nullable=True)
112
- description = Column(String, nullable=True)
113
- download_url = Column(String, nullable=True)
114
- button_text = Column(String, nullable=True)
115
- os_type = Column(String, default="windows") # windows | android | apple
116
- is_visible = Column(Boolean, default=True)
117
- order = Column(Integer, default=0)
118
-
119
-
120
- class LandingTutorial(Base):
121
- __tablename__ = "landing_tutorials"
122
-
123
- id = Column(Integer, primary_key=True, index=True)
124
- title = Column(String, nullable=False)
125
- desc = Column(String, nullable=True)
126
- steps = Column(JSON, default=[]) # list of strings
127
- order = Column(Integer, default=0)
128
-
129
-
130
- class LandingGalleryItem(Base):
131
- __tablename__ = "landing_gallery"
132
-
133
- id = Column(Integer, primary_key=True, index=True)
134
- image_url = Column(String, nullable=False)
135
- caption = Column(String, nullable=True)
136
- order = Column(Integer, default=0)
137
-
138
-
139
- class LandingConfig(Base):
140
- __tablename__ = "landing_config"
141
-
142
- id = Column(Integer, primary_key=True, index=True)
143
- # Hero
144
- hero_title = Column(Text, default="TU MÚSICA,\nDESGLOSADA.")
145
- hero_subtitle = Column(Text, default="La herramienta definitiva para músicos profesionales. Separa voces, instrumentos y domina tus pistas con IA de vanguardia.")
146
- hero_btn_start = Column(String, default="Empieza Gratis")
147
- hero_btn_features = Column(String, default="Ver Características")
148
- # Mixer Section
149
- mixer_title = Column(Text, default="Control Total de tus Stems")
150
- mixer_subtitle = Column(Text, default="Nuestro motor de IA procesa cualquier archivo de audio y lo divide en componentes individuales de alta fidelidad.")
151
- # Features Section
152
- features_title = Column(Text, default="Herramientas Profesionales")
153
- features_subtitle = Column(Text, default="Diseñado por músicos para músicos. Todo lo que necesitas para tu flujo de trabajo diario.")
154
- # Workflow / Lyrics Section
155
- lyrics_title = Column(Text, default="Visor de Letras y Acordes")
156
- lyrics_subtitle = Column(Text, default="Ten siempre a la mano la estructura de tus canciones. Melodix presenta una lista de letras con acordes en una interfaz de alto contraste.")
157
- # Groups Section
158
- groups_title = Column(Text, default="Colaboración sin Fricción")
159
- groups_subtitle = Column(Text, default="Crea grupos para tu banda o iglesia. Comparte archivos, letras y versiones específicas de stems con un solo clic.")
160
- # Download Section
161
- download_title = Column(Text, default="En todos tus dispositivos")
162
- # Toggles
163
- show_mixer = Column(Boolean, default=True)
164
- show_features = Column(Boolean, default=True)
165
- show_lyrics = Column(Boolean, default=True)
166
- show_groups = Column(Boolean, default=True)
167
- show_download = Column(Boolean, default=True)
168
-
169
-
 
1
+ from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, JSON, Text
2
+ from sqlalchemy.orm import relationship
3
+ from datetime import datetime
4
+ from app.database import Base
5
+
6
+ class User(Base):
7
+ __tablename__ = "users"
8
+
9
+ id = Column(Integer, primary_key=True, index=True)
10
+ username = Column(String, unique=True, index=True)
11
+ email = Column(String, unique=True, index=True)
12
+ hashed_password = Column(String)
13
+ credits = Column(Integer, default=10)
14
+ bonus_credits = Column(Integer, default=0)
15
+ is_premium = Column(Boolean, default=False)
16
+ avatar_url = Column(String, nullable=True)
17
+ reset_token = Column(String, nullable=True)
18
+ reset_token_expires = Column(DateTime, nullable=True)
19
+ is_suspended = Column(Boolean, default=False)
20
+ username_changes_this_month = Column(Integer, default=0)
21
+ last_username_change_at = Column(DateTime, nullable=True)
22
+ is_forum_banned = Column(Boolean, default=False)
23
+
24
+ devices = relationship("Device", back_populates="user")
25
+ tasks = relationship("Task", back_populates="user")
26
+ groups = relationship("UserGroup", back_populates="user")
27
+ credit_transactions = relationship("CreditTransaction", back_populates="user", cascade="all, delete-orphan")
28
+
29
+ @property
30
+ def total_credits(self) -> int:
31
+ return (self.credits or 0) + (self.bonus_credits or 0)
32
+
33
+ class Device(Base):
34
+ __tablename__ = "devices"
35
+
36
+ id = Column(Integer, primary_key=True, index=True)
37
+ user_id = Column(Integer, ForeignKey("users.id"))
38
+ device_id = Column(String, index=True)
39
+ name = Column(String)
40
+ registered_at = Column(DateTime, default=datetime.utcnow)
41
+ last_active = Column(DateTime, default=datetime.utcnow)
42
+
43
+ user = relationship("User", back_populates="devices")
44
+
45
+ class CreditTransaction(Base):
46
+ __tablename__ = "credit_transactions"
47
+
48
+ id = Column(Integer, primary_key=True, index=True)
49
+ user_id = Column(Integer, ForeignKey("users.id"))
50
+ amount = Column(Integer)
51
+ concept = Column(String)
52
+ reference_id = Column(String, nullable=True)
53
+ credit_type = Column(String, default="base") # base | bonus
54
+ created_at = Column(DateTime, default=datetime.utcnow)
55
+
56
+ user = relationship("User", back_populates="credit_transactions")
57
+
58
+
59
+ class SystemNotification(Base):
60
+ __tablename__ = "system_notifications"
61
+
62
+ id = Column(Integer, primary_key=True, index=True)
63
+ title = Column(String, index=True)
64
+ message = Column(String)
65
+ created_at = Column(DateTime, default=datetime.utcnow)
66
+
67
+
68
+ class AdminUser(Base):
69
+ __tablename__ = "admin_users"
70
+
71
+ id = Column(Integer, primary_key=True, index=True)
72
+ username = Column(String, unique=True, index=True)
73
+ hashed_password = Column(String)
74
+ avatar_url = Column(String, nullable=True)
75
+ perm_users = Column(Boolean, default=True)
76
+ perm_workers = Column(Boolean, default=True)
77
+ perm_churches = Column(Boolean, default=True)
78
+ perm_groups = Column(Boolean, default=True)
79
+ perm_notifications = Column(Boolean, default=True)
80
+ created_at = Column(DateTime, default=datetime.utcnow)
81
+
82
+
83
+ class AdminNotification(Base):
84
+ __tablename__ = "admin_notifications"
85
+
86
+ id = Column(Integer, primary_key=True, index=True)
87
+ title = Column(String, index=True)
88
+ message = Column(String)
89
+ category = Column(String, default="system") # system | user | church | group | worker
90
+ created_at = Column(DateTime, default=datetime.utcnow)
91
+ read = Column(Boolean, default=False)
92
+
93
+
94
+ class LandingFeature(Base):
95
+ __tablename__ = "landing_features"
96
+
97
+ id = Column(Integer, primary_key=True, index=True)
98
+ icon_svg = Column(String, nullable=True)
99
+ icon_color = Column(String, default="cyan") # cyan | purple | green | blue | yellow | red
100
+ title = Column(String)
101
+ description = Column(String)
102
+ order = Column(Integer, default=0)
103
+
104
+
105
+ class LandingPlatform(Base):
106
+ __tablename__ = "landing_platforms"
107
+
108
+ id = Column(Integer, primary_key=True, index=True)
109
+ name = Column(String, nullable=False)
110
+ version = Column(String, nullable=True)
111
+ os_details = Column(String, nullable=True)
112
+ description = Column(String, nullable=True)
113
+ download_url = Column(String, nullable=True)
114
+ button_text = Column(String, nullable=True)
115
+ os_type = Column(String, default="windows") # windows | android | apple
116
+ is_visible = Column(Boolean, default=True)
117
+ order = Column(Integer, default=0)
118
+
119
+
120
+ class LandingTutorial(Base):
121
+ __tablename__ = "landing_tutorials"
122
+
123
+ id = Column(Integer, primary_key=True, index=True)
124
+ title = Column(String, nullable=False)
125
+ desc = Column(String, nullable=True)
126
+ steps = Column(JSON, default=[]) # list of strings
127
+ order = Column(Integer, default=0)
128
+
129
+
130
+ class LandingGalleryItem(Base):
131
+ __tablename__ = "landing_gallery"
132
+
133
+ id = Column(Integer, primary_key=True, index=True)
134
+ image_url = Column(String, nullable=False)
135
+ caption = Column(String, nullable=True)
136
+ order = Column(Integer, default=0)
137
+
138
+
139
+ class LandingConfig(Base):
140
+ __tablename__ = "landing_config"
141
+
142
+ id = Column(Integer, primary_key=True, index=True)
143
+ # Hero
144
+ hero_title = Column(Text, default="TU MÚSICA,\nDESGLOSADA.")
145
+ hero_subtitle = Column(Text, default="La herramienta definitiva para músicos profesionales. Separa voces, instrumentos y domina tus pistas con IA de vanguardia.")
146
+ hero_btn_start = Column(String, default="Empieza Gratis")
147
+ hero_btn_features = Column(String, default="Ver Características")
148
+ # Mixer Section
149
+ mixer_title = Column(Text, default="Control Total de tus Stems")
150
+ mixer_subtitle = Column(Text, default="Nuestro motor de IA procesa cualquier archivo de audio y lo divide en componentes individuales de alta fidelidad.")
151
+ # Features Section
152
+ features_title = Column(Text, default="Herramientas Profesionales")
153
+ features_subtitle = Column(Text, default="Diseñado por músicos para músicos. Todo lo que necesitas para tu flujo de trabajo diario.")
154
+ # Workflow / Lyrics Section
155
+ lyrics_title = Column(Text, default="Visor de Letras y Acordes")
156
+ lyrics_subtitle = Column(Text, default="Ten siempre a la mano la estructura de tus canciones. Melodix presenta una lista de letras con acordes en una interfaz de alto contraste.")
157
+ # Groups Section
158
+ groups_title = Column(Text, default="Colaboración sin Fricción")
159
+ groups_subtitle = Column(Text, default="Crea grupos para tu banda o iglesia. Comparte archivos, letras y versiones específicas de stems con un solo clic.")
160
+ # Download Section
161
+ download_title = Column(Text, default="En todos tus dispositivos")
162
+ # Toggles
163
+ show_mixer = Column(Boolean, default=True)
164
+ show_features = Column(Boolean, default=True)
165
+ show_lyrics = Column(Boolean, default=True)
166
+ show_groups = Column(Boolean, default=True)
167
+ show_download = Column(Boolean, default=True)
168
+
169
+
app/routers/__init__.py CHANGED
@@ -1 +1 @@
1
- # Init routers package
 
1
+ # Init routers package
app/routers/admin.py CHANGED
The diff for this file is too large to render. See raw diff
 
app/routers/alabanza.py CHANGED
The diff for this file is too large to render. See raw diff
 
app/routers/aprendizaje.py CHANGED
@@ -1,1221 +1,1221 @@
1
- import json
2
- import re
3
- import random
4
- from datetime import datetime, timedelta
5
- from typing import List, Optional
6
- from fastapi import APIRouter, Depends, HTTPException, status
7
- from pydantic import BaseModel
8
- from sqlalchemy.orm import Session
9
-
10
- from app.database import get_db
11
- from app.models.user import User
12
- from app.models.iglesia import IglesiaMember
13
- from app.models.aprendizaje import (
14
- PlanLectura, DiaLectura, PreguntaQuiz, ProgresoPlan, PuntuacionLiga,
15
- BibliaLibro, BibliaVersiculo, Torneo, ParticipanteTorneo
16
- )
17
- from app.routers.auth import get_current_user
18
- from app.utils.credits import deduct_credits
19
- from app.routers.iglesia import get_iglesia_usuario
20
-
21
- router = APIRouter(prefix="/aprendizaje", tags=["aprendizaje"])
22
-
23
- def notificar_participantes_torneo(torneo_id: int, message_type: str, db: Session):
24
- try:
25
- from app.websocket_manager import send_sync_message
26
- participantes = db.query(ParticipanteTorneo).filter(ParticipanteTorneo.torneo_id == torneo_id).all()
27
- for p in participantes:
28
- try:
29
- send_sync_message({
30
- "type": message_type,
31
- "torneo_id": torneo_id
32
- }, p.user_id)
33
- except Exception:
34
- pass
35
- except Exception as e:
36
- print(f"[WebSocket] Error notificar_participantes_torneo: {e}")
37
-
38
- # ──────────────────────────────────────────────────────────
39
- # PARSER DE PASAJES BÍBLICOS
40
- # Permite convertir "Matthew 1" o "Matthew 1-3" en versículos reales
41
- # ──────────────────────────────────────────────────────────
42
- def parse_and_fetch_passage(passage_str: str, db: Session):
43
- """
44
- Parsea un string de pasaje como 'Génesis 1' o 'Juan 3-4; Romanos 8'
45
- y retorna los versículos de la DB (que ahora tiene libros en español RVR1960).
46
- Soporta tanto nombres con tilde como sin tilde.
47
- """
48
- import unicodedata as _ud
49
-
50
- def _normalizar(texto):
51
- """Quita tildes para comparación insensible a acentos."""
52
- nfkd = _ud.normalize('NFKD', texto)
53
- return ''.join(c for c in nfkd if not _ud.combining(c)).lower()
54
-
55
- # Mapa: nombre_normalizado_sin_tilde → nombre_real_en_DB (con tilde)
56
- NOMBRES_ES = {
57
- "genesis": "Génesis", "exodo": "Éxodo", "levitico": "Levítico",
58
- "numeros": "Números", "deuteronomio": "Deuteronomio", "josue": "Josué",
59
- "jueces": "Jueces", "rut": "Rut", "ruth": "Rut",
60
- "1 samuel": "1 Samuel", "2 samuel": "2 Samuel",
61
- "1 reyes": "1 Reyes", "2 reyes": "2 Reyes",
62
- "1 cronicas": "1 Crónicas", "2 cronicas": "2 Crónicas",
63
- "esdras": "Esdras", "nehemias": "Nehemías", "ester": "Ester",
64
- "job": "Job", "salmos": "Salmos", "proverbios": "Proverbios",
65
- "eclesiastes": "Eclesiastés",
66
- "cantares": "Cantares", "cantar de los cantares": "Cantares",
67
- "isaias": "Isaías", "jeremias": "Jeremías",
68
- "lamentaciones": "Lamentaciones", "ezequiel": "Ezequiel",
69
- "daniel": "Daniel", "oseas": "Oseas", "joel": "Joel",
70
- "amos": "Amós", "abdias": "Abdías", "jonas": "Jonás",
71
- "miqueas": "Miqueas", "nahum": "Nahum", "habacuc": "Habacuc",
72
- "sofonias": "Sofonías", "hageo": "Hageo",
73
- "zacarias": "Zacarías", "malaquias": "Malaquías",
74
- "mateo": "Mateo", "marcos": "Marcos", "lucas": "Lucas",
75
- "juan": "Juan", "hechos": "Hechos", "romanos": "Romanos",
76
- "1 corintios": "1 Corintios", "2 corintios": "2 Corintios",
77
- "galatas": "Gálatas", "efesios": "Efesios", "filipenses": "Filipenses",
78
- "colosenses": "Colosenses",
79
- "1 tesalonicenses": "1 Tesalonicenses", "2 tesalonicenses": "2 Tesalonicenses",
80
- "1 timoteo": "1 Timoteo", "2 timoteo": "2 Timoteo",
81
- "tito": "Tito", "filemon": "Filemón", "hebreos": "Hebreos",
82
- "santiago": "Santiago", "1 pedro": "1 Pedro", "2 pedro": "2 Pedro",
83
- "1 juan": "1 Juan", "2 juan": "2 Juan", "3 juan": "3 Juan",
84
- "judas": "Judas",
85
- "apocalipsis": "Apocalipsis", "revelacion": "Apocalipsis",
86
- }
87
-
88
- parts = passage_str.split(";")
89
- all_versiculos = []
90
- for part in parts:
91
- part = part.strip()
92
- if not part:
93
- continue
94
- match = re.match(r"^([1-3]?\s?[A-Za-zñÑáéíóúÁÉÍÓÚ\s]+)\s([0-9]+)(?:-([0-9]+))?$", part)
95
- if not match:
96
- continue
97
-
98
- libro_nombre_raw = match.group(1).strip()
99
- cap_inicio = int(match.group(2))
100
- cap_fin = int(match.group(3)) if match.group(3) else cap_inicio
101
-
102
- # Resolver nombre del libro (con o sin tilde)
103
- lookup = _normalizar(libro_nombre_raw)
104
- nombre_en_db = NOMBRES_ES.get(lookup)
105
-
106
- if nombre_en_db:
107
- # Búsqueda exacta primero, luego LIKE
108
- libro = db.query(BibliaLibro).filter(BibliaLibro.nombre == nombre_en_db).first()
109
- if not libro:
110
- libro = db.query(BibliaLibro).filter(BibliaLibro.nombre.like(f"%{nombre_en_db}%")).first()
111
- else:
112
- # Fallback: buscar por fragmento del nombre original
113
- libro = db.query(BibliaLibro).filter(BibliaLibro.nombre.ilike(f"%{libro_nombre_raw}%")).first()
114
-
115
- if not libro:
116
- continue
117
-
118
- versiculos = db.query(BibliaVersiculo).filter(
119
- BibliaVersiculo.libro_id == libro.id,
120
- BibliaVersiculo.capitulo >= cap_inicio,
121
- BibliaVersiculo.capitulo <= cap_fin
122
- ).order_by(BibliaVersiculo.capitulo, BibliaVersiculo.versiculo).all()
123
-
124
- for v in versiculos:
125
- all_versiculos.append({
126
- "id": v.id,
127
- "capitulo": v.capitulo,
128
- "versiculo": v.versiculo,
129
- "texto": v.texto,
130
- "libro": libro.nombre
131
- })
132
-
133
- return all_versiculos
134
-
135
- # ──────────────────────────────────────────────────────────
136
- # SCHEMAS DE PYDANTIC
137
- # ──────────────────────────────────────────────────────────
138
- class CompletarDiaRequest(BaseModel):
139
- plan_id: int
140
- respuestas_correctas: int
141
- total_preguntas: int
142
- fecha_local: str # "YYYY-MM-DD" del dispositivo móvil
143
- dia_numero: Optional[int] = None
144
-
145
- # ──────────────────────────────────────────────────────────
146
- # FUNCIONES AUXILIARES DE RACHA
147
- # ──────────────────────────────────────────────────────────
148
- def check_and_reset_streaks(user_id: int, db: Session, fecha_hoy_str: Optional[str] = None):
149
- if not fecha_hoy_str:
150
- fecha_hoy_str = datetime.utcnow().strftime("%Y-%m-%d")
151
-
152
- progresos = db.query(ProgresoPlan).filter(ProgresoPlan.user_id == user_id).all()
153
- updated = False
154
- for progreso in progresos:
155
- if not progreso.ultima_lectura_fecha:
156
- continue
157
-
158
- try:
159
- ultima_fecha = datetime.strptime(progreso.ultima_lectura_fecha, "%Y-%m-%d").date()
160
- fecha_hoy = datetime.strptime(fecha_hoy_str, "%Y-%m-%d").date()
161
- except ValueError:
162
- continue
163
-
164
- diff = (fecha_hoy - ultima_fecha).days
165
- if diff > 1:
166
- is_saved = (diff == 2 and progreso.escudos_racha and progreso.escudos_racha > 0)
167
- if not is_saved and progreso.racha_actual > 0:
168
- progreso.racha_actual = 0
169
- updated = True
170
-
171
- if updated:
172
- db.commit()
173
-
174
- # ──────────────────────────────────────────────────────────
175
- # ENDPOINTS
176
- # ──────────────────────────────────────────────────────────
177
-
178
- @router.get("/planes")
179
- def listar_planes(fecha_local: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
180
- check_and_reset_streaks(current_user.id, db, fecha_local)
181
- iglesia = get_iglesia_usuario(current_user.id, db)
182
- iglesia_id = iglesia.id if iglesia else None
183
- planes = db.query(PlanLectura).filter(PlanLectura.nombre != "Desafío de Sabios: Preguntas Avanzadas").all()
184
- progresos = db.query(ProgresoPlan).filter(ProgresoPlan.user_id == current_user.id, ProgresoPlan.iglesia_id == iglesia_id).all() if iglesia_id else []
185
- prog_dict = {p.plan_id: p for p in progresos}
186
-
187
- resultado = []
188
- for plan in planes:
189
- prog = prog_dict.get(plan.id)
190
- resultado.append({
191
- "id": plan.id,
192
- "nombre": plan.nombre,
193
- "descripcion": plan.descripcion,
194
- "dias_totales": plan.dias_totales,
195
- "suscrito": prog is not None,
196
- "dia_actual": prog.dia_actual if prog else 1,
197
- "repaso_completado_hasta": prog.repaso_completado_hasta if prog else 0,
198
- "escudos_racha": prog.escudos_racha if prog else 0,
199
- "completado": prog.completado if prog else False
200
- })
201
- return resultado
202
-
203
- @router.post("/suscribir/{plan_id}")
204
- def suscribir_a_plan(plan_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
205
- iglesia = get_iglesia_usuario(current_user.id, db)
206
- if not iglesia:
207
- raise HTTPException(status_code=400, detail="Debes pertenecer a una iglesia para suscribirte a un plan de lectura")
208
-
209
- plan = db.query(PlanLectura).filter(PlanLectura.id == plan_id).first()
210
- if not plan:
211
- raise HTTPException(status_code=404, detail="Plan de lectura no encontrado")
212
-
213
- progreso = db.query(ProgresoPlan).filter(
214
- ProgresoPlan.user_id == current_user.id,
215
- ProgresoPlan.plan_id == plan.id,
216
- ProgresoPlan.iglesia_id == iglesia.id
217
- ).first()
218
-
219
- if progreso:
220
- return {"message": "Ya estás suscrito a este plan", "dia_actual": progreso.dia_actual}
221
-
222
- nuevo_progreso = ProgresoPlan(
223
- user_id=current_user.id,
224
- plan_id=plan.id,
225
- iglesia_id=iglesia.id,
226
- dia_actual=1,
227
- completado=False,
228
- racha_actual=0
229
- )
230
- db.add(nuevo_progreso)
231
- db.commit()
232
- return {"message": "Suscripción exitosa", "dia_actual": 1}
233
-
234
- @router.post("/cancelar/{plan_id}")
235
- def cancelar_plan(plan_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
236
- iglesia = get_iglesia_usuario(current_user.id, db)
237
- iglesia_id = iglesia.id if iglesia else None
238
- progreso = db.query(ProgresoPlan).filter(
239
- ProgresoPlan.user_id == current_user.id,
240
- ProgresoPlan.plan_id == plan_id,
241
- ProgresoPlan.iglesia_id == iglesia_id
242
- ).first()
243
-
244
- if not progreso:
245
- raise HTTPException(status_code=404, detail="No estás suscrito a este plan en esta iglesia")
246
-
247
- db.delete(progreso)
248
- db.commit()
249
- return {"message": "Plan cancelado exitosamente"}
250
-
251
- @router.get("/dia/{plan_id}")
252
- def obtener_dia_lectura(plan_id: int, fecha_local: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
253
- check_and_reset_streaks(current_user.id, db, fecha_local)
254
- iglesia = get_iglesia_usuario(current_user.id, db)
255
- iglesia_id = iglesia.id if iglesia else None
256
- progreso = db.query(ProgresoPlan).filter(
257
- ProgresoPlan.user_id == current_user.id,
258
- ProgresoPlan.plan_id == plan_id,
259
- ProgresoPlan.iglesia_id == iglesia_id
260
- ).first()
261
-
262
- if not progreso:
263
- raise HTTPException(status_code=400, detail="No estás suscrito a este plan en esta iglesia")
264
-
265
- if progreso.completado:
266
- return {"completado_todo": True, "message": "¡Felicidades! Has completado este plan de lectura."}
267
-
268
- # Buscar el día correspondiente
269
- dia = db.query(DiaLectura).filter(
270
- DiaLectura.plan_id == plan_id,
271
- DiaLectura.dia_numero == progreso.dia_actual
272
- ).first()
273
-
274
- if not dia:
275
- # Si no hay más días cargados, marcar plan como completado
276
- progreso.completado = True
277
- db.commit()
278
- return {"completado_todo": True, "message": "¡Felicidades! Has completado este plan de lectura."}
279
-
280
- # Obtener los versículos del pasaje
281
- versiculos = parse_and_fetch_passage(dia.pasajes, db)
282
-
283
- # Obtener preguntas de este día
284
- preguntas = db.query(PreguntaQuiz).filter(PreguntaQuiz.dia_lectura_id == dia.id).all()
285
- preguntas_list = []
286
- for p in preguntas:
287
- opciones_parsed = []
288
- try:
289
- opciones_parsed = json.loads(p.opciones) if p.opciones else []
290
- except:
291
- opciones_parsed = []
292
-
293
- preguntas_list.append({
294
- "id": p.id,
295
- "tipo": p.tipo,
296
- "pregunta": p.pregunta,
297
- "opciones": opciones_parsed,
298
- "respuesta_correcta": p.respuesta_correcta
299
- })
300
-
301
- return {
302
- "completado_todo": False,
303
- "dia_numero": dia.dia_numero,
304
- "pasajes": dia.pasajes,
305
- "titulo": dia.titulo or f"Día {dia.dia_numero}",
306
- "versiculos": versiculos,
307
- "preguntas": preguntas_list
308
- }
309
-
310
- @router.post("/completar-dia")
311
- def completar_dia(data: CompletarDiaRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
312
- iglesia = get_iglesia_usuario(current_user.id, db)
313
- if not iglesia:
314
- raise HTTPException(status_code=404, detail="No perteneces a ninguna iglesia para sumar puntos de liga")
315
-
316
- progreso = db.query(ProgresoPlan).filter(
317
- ProgresoPlan.user_id == current_user.id,
318
- ProgresoPlan.plan_id == data.plan_id,
319
- ProgresoPlan.iglesia_id == iglesia.id
320
- ).first()
321
-
322
- if not progreso:
323
- raise HTTPException(status_code=400, detail="No suscrito al plan")
324
-
325
- if data.dia_numero is not None and progreso.dia_actual > data.dia_numero:
326
- return {
327
- "puntos_ganados": 0,
328
- "nueva_racha": progreso.racha_actual,
329
- "dia_siguiente": progreso.dia_actual,
330
- "completado": progreso.completado,
331
- "escudo_usado": False,
332
- "message": "Día ya completado anteriormente (duplicado silenciado)"
333
- }
334
-
335
- # 1. VALIDACIÓN DE LA RACHA (STREAK)
336
- hoy = data.fecha_local # Formato "YYYY-MM-DD"
337
- racha_antes = progreso.racha_actual
338
- escudo_usado = False
339
-
340
- if progreso.ultima_lectura_fecha == hoy:
341
- # Ya leyó hoy, no incrementa racha pero actualiza progreso
342
- pass
343
- elif progreso.ultima_lectura_fecha:
344
- ultima_fecha = datetime.strptime(progreso.ultima_lectura_fecha, "%Y-%m-%d").date()
345
- fecha_dispositivo = datetime.strptime(hoy, "%Y-%m-%d").date()
346
- diff = (fecha_dispositivo - ultima_fecha).days
347
-
348
- if diff == 1:
349
- progreso.racha_actual += 1
350
- elif diff > 1:
351
- if diff == 2 and progreso.escudos_racha > 0:
352
- progreso.escudos_racha -= 1
353
- progreso.racha_actual += 1
354
- escudo_usado = True
355
- else:
356
- progreso.racha_actual = 1 # Se rompió, reinicia
357
- else:
358
- # Caso raro de viaje en el tiempo/cambio de fecha del usuario
359
- pass
360
- else:
361
- # Primera lectura
362
- progreso.racha_actual = 1
363
-
364
- progreso.ultima_lectura_fecha = hoy
365
-
366
- # 2. CÁLCULO DE PUNTOS XP
367
- # Base: 10 XP por leer. Cada acierto del quiz: +5 XP
368
- puntos_ganados = 10 + (data.respuestas_correctas * 5)
369
-
370
- # Actualizar o crear marcador en la Liga de la Iglesia
371
- liga = db.query(PuntuacionLiga).filter(
372
- PuntuacionLiga.user_id == current_user.id,
373
- PuntuacionLiga.iglesia_id == iglesia.id
374
- ).first()
375
-
376
- if not liga:
377
- liga = PuntuacionLiga(
378
- user_id=current_user.id,
379
- iglesia_id=iglesia.id,
380
- puntos_totales=puntos_ganados,
381
- maxima_racha=progreso.racha_actual
382
- )
383
- db.add(liga)
384
- else:
385
- liga.puntos_totales += puntos_ganados
386
- if progreso.racha_actual > liga.maxima_racha:
387
- liga.maxima_racha = progreso.racha_actual
388
-
389
- # 3. AVANCE DE DÍA
390
- plan = db.query(PlanLectura).filter(PlanLectura.id == data.plan_id).first()
391
- if progreso.dia_actual >= plan.dias_totales:
392
- progreso.completado = True
393
- else:
394
- progreso.dia_actual += 1
395
-
396
- db.commit()
397
-
398
- return {
399
- "puntos_ganados": puntos_ganados,
400
- "nueva_racha": progreso.racha_actual,
401
- "dia_siguiente": progreso.dia_actual,
402
- "completado": progreso.completado,
403
- "escudo_usado": escudo_usado
404
- }
405
-
406
- @router.get("/liga")
407
- def obtener_tabla_posiciones(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
408
- iglesia = get_iglesia_usuario(current_user.id, db)
409
- if not iglesia:
410
- raise HTTPException(status_code=404, detail="No perteneces a ninguna iglesia")
411
-
412
- # Obtener IDs de usuarios que pertenecen a esta iglesia
413
- miembros_ids = [m.user_id for m in db.query(IglesiaMember).filter(
414
- IglesiaMember.iglesia_id == iglesia.id,
415
- IglesiaMember.estado == "activo"
416
- ).all()]
417
-
418
- # Buscar puntuaciones ordenadas
419
- puntuaciones = db.query(PuntuacionLiga).filter(
420
- PuntuacionLiga.user_id.in_(miembros_ids)
421
- ).order_by(PuntuacionLiga.puntos_totales.desc()).all()
422
-
423
- leaderboard = []
424
- for idx, p in enumerate(puntuaciones):
425
- leaderboard.append({
426
- "posicion": idx + 1,
427
- "user_id": p.user_id,
428
- "username": p.user.username,
429
- "puntos": p.puntos_totales,
430
- "maxima_racha": p.maxima_racha
431
- })
432
-
433
- return {
434
- "iglesia_nombre": iglesia.nombre,
435
- "leaderboard": leaderboard
436
- }
437
-
438
- @router.get("/mi-estado")
439
- def obtener_mi_estado(fecha_local: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
440
- check_and_reset_streaks(current_user.id, db, fecha_local)
441
- iglesia = get_iglesia_usuario(current_user.id, db)
442
-
443
- progresos = db.query(ProgresoPlan).filter(ProgresoPlan.user_id == current_user.id, ProgresoPlan.iglesia_id == iglesia.id).all() if iglesia else []
444
- racha_maxima = max([p.racha_actual for p in progresos]) if progresos else 0
445
-
446
- puntos_liga = 0
447
- if iglesia:
448
- liga = db.query(PuntuacionLiga).filter(
449
- PuntuacionLiga.user_id == current_user.id,
450
- PuntuacionLiga.iglesia_id == iglesia.id
451
- ).first()
452
- if liga:
453
- puntos_liga = liga.puntos_totales
454
-
455
- return {
456
- "user_id": current_user.id,
457
- "racha_actual": racha_maxima,
458
- "puntos_liga": puntos_liga,
459
- "tiene_iglesia": iglesia is not None
460
- }
461
-
462
- @router.get("/usuario/{user_id}/estado")
463
- def obtener_estado_usuario(user_id: int, fecha_local: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
464
- """Obtiene el estado de avance de los planes bíblicos de un usuario específico."""
465
- check_and_reset_streaks(user_id, db, fecha_local)
466
- user = db.query(User).filter(User.id == user_id).first()
467
- if not user:
468
- raise HTTPException(status_code=404, detail="Usuario no encontrado")
469
-
470
- iglesia = get_iglesia_usuario(user_id, db)
471
- progresos = db.query(ProgresoPlan).filter(ProgresoPlan.user_id == user_id, ProgresoPlan.iglesia_id == iglesia.id).all() if iglesia else []
472
-
473
- puntos_liga = 0
474
- iglesia = get_iglesia_usuario(user_id, db)
475
- if iglesia:
476
- liga = db.query(PuntuacionLiga).filter_by(user_id=user_id, iglesia_id=iglesia.id).first()
477
- if liga:
478
- puntos_liga = liga.puntos_totales
479
-
480
- planes_info = []
481
- racha_maxima = max([p.racha_actual for p in progresos]) if progresos else 0
482
-
483
- for p in progresos:
484
- plan_obj = db.query(PlanLectura).filter_by(id=p.plan_id).first()
485
- if plan_obj:
486
- dias_totales = plan_obj.dias_totales
487
- dias_leidos = p.dia_actual - 1 if not p.completado else dias_totales
488
- dias_faltantes = max(0, dias_totales - dias_leidos)
489
-
490
- hoy = datetime.utcnow().strftime("%Y-%m-%d")
491
- estado_ritmo = "Al día" if p.ultima_lectura_fecha == hoy else "Atrasado"
492
- if p.completado:
493
- estado_ritmo = "Completado"
494
-
495
- planes_info.append({
496
- "plan_id": plan_obj.id,
497
- "nombre": plan_obj.nombre,
498
- "dias_totales": dias_totales,
499
- "dia_actual": p.dia_actual,
500
- "dias_leidos": dias_leidos,
501
- "dias_faltantes": dias_faltantes,
502
- "racha": p.racha_actual,
503
- "completado": p.completado,
504
- "estado_ritmo": estado_ritmo,
505
- "ultima_lectura": p.ultima_lectura_fecha
506
- })
507
-
508
- return {
509
- "user_id": user.id,
510
- "username": user.username,
511
- "racha_actual": racha_maxima,
512
- "puntos_liga": puntos_liga,
513
- "planes": planes_info
514
- }
515
-
516
-
517
- # ──────────────────────────────────────────────────────────
518
- # MÓDULO DE EXÁMENES DE REPASO (CADA 10 DÍAS)
519
- # ──────────────────────────────────────────────────────────
520
- import random
521
-
522
- class CompletarRepasoRequest(BaseModel):
523
- plan_id: int
524
- dias_hito: int
525
- fecha_local: str
526
-
527
- @router.get("/repaso/{plan_id}/{dias_hito}")
528
- def obtener_examen_repaso(
529
- plan_id: int,
530
- dias_hito: int,
531
- db: Session = Depends(get_db),
532
- current_user: User = Depends(get_current_user)
533
- ):
534
- plan = db.query(PlanLectura).filter(PlanLectura.id == plan_id).first()
535
- if not plan:
536
- raise HTTPException(status_code=404, detail="Plan de lectura no encontrado")
537
-
538
- progreso = db.query(ProgresoPlan).filter(
539
- ProgresoPlan.user_id == current_user.id,
540
- ProgresoPlan.plan_id == plan.id
541
- ).first()
542
- if not progreso:
543
- raise HTTPException(status_code=400, detail="El usuario no está suscrito a este plan")
544
-
545
- dias = db.query(DiaLectura).filter(
546
- DiaLectura.plan_id == plan_id,
547
- DiaLectura.dia_numero <= dias_hito
548
- ).all()
549
- dias_ids = [d.id for d in dias]
550
-
551
- todas_preguntas = db.query(PreguntaQuiz).filter(
552
- PreguntaQuiz.dia_lectura_id.in_(dias_ids)
553
- ).all()
554
-
555
- if not todas_preguntas:
556
- raise HTTPException(status_code=404, detail="No se encontraron preguntas para este examen de repaso")
557
-
558
- preguntas_seleccionadas = random.sample(todas_preguntas, min(len(todas_preguntas), 5))
559
-
560
- preguntas_data = []
561
- for q in preguntas_seleccionadas:
562
- try:
563
- opciones = json.loads(q.opciones)
564
- except Exception:
565
- opciones = q.opciones
566
-
567
- preguntas_data.append({
568
- "id": q.id,
569
- "tipo": q.tipo,
570
- "pregunta": q.pregunta,
571
- "opciones": opciones,
572
- "respuesta_correcta": q.respuesta_correcta
573
- })
574
-
575
- return {
576
- "id": f"repaso-{plan_id}-{dias_hito}",
577
- "titulo": f"Examen de Repaso (Días 1 a {dias_hito})",
578
- "pasajes": "Repaso General",
579
- "versiculos": [],
580
- "preguntas": preguntas_data
581
- }
582
-
583
- @router.post("/completar-repaso")
584
- def completar_examen_repaso(
585
- data: CompletarRepasoRequest,
586
- db: Session = Depends(get_db),
587
- current_user: User = Depends(get_current_user)
588
- ):
589
- progreso = db.query(ProgresoPlan).filter(
590
- ProgresoPlan.user_id == current_user.id,
591
- ProgresoPlan.plan_id == data.plan_id
592
- ).first()
593
- if not progreso:
594
- raise HTTPException(status_code=400, detail="El usuario no está suscrito a este plan")
595
-
596
- if progreso.repaso_completado_hasta >= data.dias_hito:
597
- return {
598
- "puntos_ganados": 0,
599
- "nueva_racha": progreso.racha_actual,
600
- "repaso_completado_hasta": progreso.repaso_completado_hasta,
601
- "message": "Repaso ya completado anteriormente (duplicado silenciado)"
602
- }
603
-
604
- iglesia = get_iglesia_usuario(current_user.id, db)
605
- puntos_ganados = 50
606
-
607
- if iglesia:
608
- liga = db.query(PuntuacionLiga).filter(
609
- PuntuacionLiga.user_id == current_user.id,
610
- PuntuacionLiga.iglesia_id == iglesia.id
611
- ).first()
612
-
613
- if not liga:
614
- liga = PuntuacionLiga(
615
- user_id=current_user.id,
616
- iglesia_id=iglesia.id,
617
- puntos_totales=puntos_ganados,
618
- maxima_racha=progreso.racha_actual
619
- )
620
- db.add(liga)
621
- else:
622
- liga.puntos_totales += puntos_ganados
623
- if progreso.racha_actual > liga.maxima_racha:
624
- liga.maxima_racha = progreso.racha_actual
625
-
626
- progreso.repaso_completado_hasta = data.dias_hito
627
- db.commit()
628
-
629
- return {
630
- "puntos_ganados": puntos_ganados,
631
- "nueva_racha": progreso.racha_actual,
632
- "repaso_completado_hasta": progreso.repaso_completado_hasta
633
- }
634
-
635
- # ──────────────────────────────────────────────────────────
636
- # COMPRA DE ESCUDOS DE RACHA
637
- # ──────────────────────────────────────────────────────────
638
- @router.post("/comprar-escudo/{plan_id}")
639
- def comprar_escudo_racha(
640
- plan_id: int,
641
- db: Session = Depends(get_db),
642
- current_user: User = Depends(get_current_user)
643
- ):
644
- progreso = db.query(ProgresoPlan).filter(
645
- ProgresoPlan.user_id == current_user.id,
646
- ProgresoPlan.plan_id == plan_id
647
- ).first()
648
- if not progreso:
649
- raise HTTPException(status_code=400, detail="El usuario no está suscrito a este plan")
650
-
651
- # Descontar 1 crédito
652
- credits_left = deduct_credits(db, current_user, 1, "comprar_escudo", reference_id=plan_id)
653
-
654
- progreso.escudos_racha += 1
655
- db.commit()
656
-
657
- return {
658
- "credits_left": credits_left,
659
- "escudos_racha": progreso.escudos_racha
660
- }
661
-
662
- # ──────────────────────────────────────────────────────────
663
- # MOTOR DE TORNEOS MULTIJUGADOR BÍBLICOS
664
- # ──────────────────────────────────────────────────────────
665
-
666
- class CrearTorneoRequest(BaseModel):
667
- nombre: str
668
-
669
- class JugarTorneoRequest(BaseModel):
670
- torneo_id: int
671
- score: int
672
- tiempo_empleado: float
673
- es_desempate: bool = False
674
-
675
- @router.post("/torneos/crear")
676
- def crear_torneo(
677
- data: CrearTorneoRequest,
678
- db: Session = Depends(get_db),
679
- current_user: User = Depends(get_current_user)
680
- ):
681
- codigo = ""
682
- while True:
683
- num = random.randint(1000, 9999)
684
- codigo = f"BIB-{num}"
685
- ex = db.query(Torneo).filter(Torneo.codigo == codigo).first()
686
- if not ex:
687
- break
688
-
689
- # Validar que no tenga ya un torneo activo/pendiente/desempate
690
- torneo_existente = db.query(Torneo).filter(
691
- Torneo.creador_id == current_user.id,
692
- Torneo.estado.in_(["pendiente", "activo", "desempate"])
693
- ).first()
694
- if torneo_existente:
695
- raise HTTPException(
696
- status_code=400,
697
- detail="Ya tienes un torneo pendiente o activo. Debes finalizarlo antes de crear otro."
698
- )
699
-
700
- todas = db.query(PreguntaQuiz).all()
701
- if len(todas) < 5:
702
- raise HTTPException(status_code=400, detail="No hay suficientes preguntas bíblicas registradas en el sistema")
703
-
704
- preguntas_seleccionadas = random.sample(todas, 5)
705
- preguntas_ids = [q.id for q in preguntas_seleccionadas]
706
-
707
- nuevo_torneo = Torneo(
708
- codigo=codigo,
709
- nombre=data.nombre,
710
- creador_id=current_user.id,
711
- preguntas_json=json.dumps(preguntas_ids),
712
- estado="pendiente", # Estado inicial cambiado a pendiente
713
- fecha_creacion=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
714
- )
715
- db.add(nuevo_torneo)
716
- db.commit()
717
- db.refresh(nuevo_torneo)
718
-
719
- participante = ParticipanteTorneo(
720
- torneo_id=nuevo_torneo.id,
721
- user_id=current_user.id,
722
- estado="pendiente"
723
- )
724
- db.add(participante)
725
- db.commit()
726
-
727
- return {
728
- "id": nuevo_torneo.id,
729
- "codigo": nuevo_torneo.codigo,
730
- "nombre": nuevo_torneo.nombre,
731
- "creador_id": nuevo_torneo.creador_id,
732
- "estado": nuevo_torneo.estado
733
- }
734
-
735
- @router.post("/torneos/unirse/{codigo}")
736
- def unirse_a_torneo(
737
- codigo: str,
738
- db: Session = Depends(get_db),
739
- current_user: User = Depends(get_current_user)
740
- ):
741
- torneo = db.query(Torneo).filter(Torneo.codigo == codigo.upper().strip()).first()
742
- if not torneo:
743
- raise HTTPException(status_code=404, detail="Torneo no encontrado")
744
-
745
- if torneo.estado == "terminado":
746
- raise HTTPException(status_code=400, detail="Este torneo ya ha finalizado")
747
-
748
- # Validar que no tenga otra participación activa o pendiente en curso
749
- participacion_activa = db.query(ParticipanteTorneo).join(Torneo).filter(
750
- ParticipanteTorneo.user_id == current_user.id,
751
- Torneo.estado.in_(["pendiente", "activo", "desempate"])
752
- ).first()
753
- if participacion_activa and participacion_activa.torneo_id != torneo.id:
754
- raise HTTPException(
755
- status_code=400,
756
- detail="Ya participas en otro torneo activo o pendiente en curso. No puedes unirte a otro."
757
- )
758
-
759
- existente = db.query(ParticipanteTorneo).filter(
760
- ParticipanteTorneo.torneo_id == torneo.id,
761
- ParticipanteTorneo.user_id == current_user.id
762
- ).first()
763
-
764
- if existente:
765
- return {"message": "Ya eres participante de este torneo", "torneo_id": torneo.id}
766
-
767
- nuevo_participante = ParticipanteTorneo(
768
- torneo_id=torneo.id,
769
- user_id=current_user.id,
770
- estado="pendiente"
771
- )
772
- db.add(nuevo_participante)
773
- db.commit()
774
-
775
- notificar_participantes_torneo(torneo.id, "torneo_updated", db)
776
-
777
- return {
778
- "message": "Te has unido al torneo exitosamente",
779
- "torneo_id": torneo.id,
780
- "nombre": torneo.nombre
781
- }
782
-
783
- @router.post("/torneos/comenzar/{torneo_id}")
784
- def comenzar_torneo(
785
- torneo_id: int,
786
- db: Session = Depends(get_db),
787
- current_user: User = Depends(get_current_user)
788
- ):
789
- torneo = db.query(Torneo).filter(Torneo.id == torneo_id).first()
790
- if not torneo:
791
- raise HTTPException(status_code=404, detail="Torneo no encontrado")
792
- if torneo.creador_id != current_user.id:
793
- raise HTTPException(status_code=403, detail="Solamente el creador del torneo puede comenzar el juego")
794
- if torneo.estado != "pendiente":
795
- raise HTTPException(status_code=400, detail="El torneo ya ha comenzado o ha finalizado")
796
-
797
- # Validar que haya al menos un participante conectado aparte del creador
798
- from app.websocket_manager import manager
799
- otros_participantes = db.query(ParticipanteTorneo).filter(
800
- ParticipanteTorneo.torneo_id == torneo.id,
801
- ParticipanteTorneo.user_id != torneo.creador_id
802
- ).all()
803
-
804
- hay_otro_conectado = any(p.user_id in manager.active_connections for p in otros_participantes)
805
- if not hay_otro_conectado:
806
- raise HTTPException(
807
- status_code=400,
808
- detail="No se puede iniciar el torneo. Debe haber al menos un participante con conexión activa aparte del creador."
809
- )
810
-
811
- torneo.estado = "activo"
812
- db.commit()
813
- notificar_participantes_torneo(torneo.id, "torneo_updated", db)
814
- return {"message": "El torneo ha comenzado exitosamente", "estado": torneo.estado}
815
-
816
- @router.get("/torneos")
817
- def listar_mis_torneos(
818
- db: Session = Depends(get_db),
819
- current_user: User = Depends(get_current_user)
820
- ):
821
- # Chequear timeouts de los torneos de este usuario antes de listar
822
- participaciones_raw = db.query(ParticipanteTorneo).filter(ParticipanteTorneo.user_id == current_user.id).all()
823
- for p in participaciones_raw:
824
- check_torneo_timeout(p.torneo_id, db)
825
-
826
- participaciones = db.query(ParticipanteTorneo).join(Torneo).filter(
827
- ParticipanteTorneo.user_id == current_user.id,
828
- Torneo.estado != "terminado"
829
- ).all()
830
-
831
- torneos_data = []
832
- for part in participaciones:
833
- t = part.torneo
834
- if not t:
835
- continue
836
-
837
- todos_p = db.query(ParticipanteTorneo).filter(
838
- ParticipanteTorneo.torneo_id == t.id
839
- ).all()
840
-
841
- ranking = []
842
- for p in todos_p:
843
- user_info = db.query(User).filter(User.id == p.user_id).first()
844
- username = user_info.username if user_info else "Anónimo"
845
- ranking.append({
846
- "user_id": p.user_id,
847
- "username": username,
848
- "score": p.score,
849
- "tiempo_empleado": p.tiempo_empleado,
850
- "score_desempate": p.score_desempate,
851
- "tiempo_desempate": p.tiempo_desempate,
852
- "eliminado": p.eliminado,
853
- "estado": p.estado
854
- })
855
-
856
- if t.estado == "desempate":
857
- ranking.sort(
858
- key=lambda x: (
859
- x["eliminado"],
860
- -x["score_desempate"],
861
- x["tiempo_desempate"] if x["tiempo_desempate"] > 0 else 99999.0
862
- )
863
- )
864
- else:
865
- ranking.sort(key=lambda x: (-x["score"], x["tiempo_empleado"] if x["tiempo_empleado"] > 0 else 99999.0))
866
-
867
- torneos_data.append({
868
- "id": t.id,
869
- "codigo": t.codigo,
870
- "nombre": t.nombre,
871
- "estado": t.estado,
872
- "creador_id": t.creador_id,
873
- "ganador_id": t.ganador_id,
874
- "fecha_creacion": t.fecha_creacion,
875
- "mi_estado": part.estado,
876
- "mi_eliminado": part.eliminado,
877
- "ranking": ranking
878
- })
879
-
880
- return torneos_data
881
-
882
- @router.post("/torneos/jugar")
883
- def registrar_respuestas_torneo(
884
- data: JugarTorneoRequest,
885
- db: Session = Depends(get_db),
886
- current_user: User = Depends(get_current_user)
887
- ):
888
- torneo = db.query(Torneo).filter(Torneo.id == data.torneo_id).first()
889
- if not torneo:
890
- raise HTTPException(status_code=404, detail="Torneo no encontrado")
891
-
892
- participante = db.query(ParticipanteTorneo).filter(
893
- ParticipanteTorneo.torneo_id == data.torneo_id,
894
- ParticipanteTorneo.user_id == current_user.id
895
- ).first()
896
-
897
- if not participante:
898
- raise HTTPException(status_code=403, detail="No eres parte de este torneo")
899
-
900
- if participante.eliminado:
901
- raise HTTPException(status_code=403, detail="Fuiste eliminado de este torneo")
902
-
903
- if data.es_desempate:
904
- if torneo.estado != "desempate":
905
- raise HTTPException(status_code=400, detail="Este torneo no está en fase de muerte súbita")
906
- participante.score_desempate = data.score
907
- participante.tiempo_desempate = data.tiempo_empleado
908
- participante.estado = "desempate_jugado"
909
- else:
910
- if participante.estado != "pendiente":
911
- raise HTTPException(status_code=400, detail="Ya jugaste este torneo")
912
- participante.score = data.score
913
- participante.tiempo_empleado = data.tiempo_empleado
914
- participante.estado = "jugado"
915
-
916
- db.commit()
917
-
918
- # Si es el primer participante en terminar y hay otros pendientes, iniciar el timer de 10 min
919
- if not torneo.primero_termino_en:
920
- if data.es_desempate:
921
- faltantes = db.query(ParticipanteTorneo).filter(
922
- ParticipanteTorneo.torneo_id == torneo.id,
923
- ParticipanteTorneo.estado == "desempate_pendiente",
924
- ParticipanteTorneo.eliminado == False
925
- ).count()
926
- else:
927
- faltantes = db.query(ParticipanteTorneo).filter(
928
- ParticipanteTorneo.torneo_id == torneo.id,
929
- ParticipanteTorneo.estado == "pendiente"
930
- ).count()
931
-
932
- if faltantes > 0:
933
- torneo.primero_termino_en = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
934
- db.commit()
935
-
936
- # Resolver torneo automáticamente tras registrar respuestas, aplicando el chequeo de timeout
937
- try:
938
- check_torneo_timeout(data.torneo_id, db)
939
- resolver_clasificacion_torneo(data.torneo_id, db, current_user)
940
- except Exception as e:
941
- print(f"Error al resolver torneo automáticamente: {e}")
942
-
943
- notificar_participantes_torneo(data.torneo_id, "torneo_updated", db)
944
-
945
- return {"message": "Respuestas guardadas exitosamente", "estado": participante.estado}
946
-
947
- @router.get("/torneo/{torneo_id}/preguntas")
948
- def obtener_preguntas_torneo(
949
- torneo_id: int,
950
- db: Session = Depends(get_db),
951
- current_user: User = Depends(get_current_user)
952
- ):
953
- check_torneo_timeout(torneo_id, db)
954
-
955
- torneo = db.query(Torneo).filter(Torneo.id == torneo_id).first()
956
- if not torneo:
957
- raise HTTPException(status_code=404, detail="Torneo no encontrado")
958
-
959
- if torneo.estado == "pendiente":
960
- raise HTTPException(status_code=400, detail="El torneo todavia no ha sido iniciado por su creador")
961
-
962
- participante = db.query(ParticipanteTorneo).filter(
963
- ParticipanteTorneo.torneo_id == torneo_id,
964
- ParticipanteTorneo.user_id == current_user.id
965
- ).first()
966
-
967
- if not participante or participante.eliminado:
968
- raise HTTPException(status_code=403, detail="No eres parte de este torneo o fuiste eliminado")
969
-
970
- es_desempate = (torneo.estado == "desempate")
971
- p_ids_str = torneo.desempate_preguntas_json if es_desempate else torneo.preguntas_json
972
- p_ids = json.loads(p_ids_str) if p_ids_str else []
973
-
974
- preguntas = db.query(PreguntaQuiz).filter(PreguntaQuiz.id.in_(p_ids)).all()
975
-
976
- preguntas_data = []
977
- for q in preguntas:
978
- try:
979
- opciones = json.loads(q.opciones)
980
- except Exception:
981
- opciones = q.opciones
982
-
983
- preguntas_data.append({
984
- "id": q.id,
985
- "tipo": q.tipo,
986
- "pregunta": q.pregunta,
987
- "opciones": opciones,
988
- "respuesta_correcta": q.respuesta_correcta
989
- })
990
-
991
- return {
992
- "id": f"torneo-{torneo_id}",
993
- "titulo": f"Torneo: {torneo.nombre}",
994
- "pasajes": "Trivia General de la Biblia",
995
- "versiculos": [],
996
- "preguntas": preguntas_data,
997
- "es_desempate": es_desempate
998
- }
999
-
1000
- def check_torneo_timeout(torneo_id: int, db: Session):
1001
- torneo = db.query(Torneo).filter(Torneo.id == torneo_id).first()
1002
- if not torneo or torneo.estado == "terminado":
1003
- return
1004
-
1005
- if torneo.primero_termino_en:
1006
- try:
1007
- primero_time = datetime.strptime(torneo.primero_termino_en, "%Y-%m-%d %H:%M:%S")
1008
- elapsed = datetime.now() - primero_time
1009
- if elapsed > timedelta(minutes=10):
1010
- print(f"[Timeout] Torneo {torneo_id} ha expirado. Resolviendo por tiempo...")
1011
- participantes = db.query(ParticipanteTorneo).filter(
1012
- ParticipanteTorneo.torneo_id == torneo_id
1013
- ).all()
1014
-
1015
- modificado = False
1016
- for p in participantes:
1017
- if p.estado == "pendiente":
1018
- p.estado = "jugado"
1019
- p.score = 0
1020
- p.tiempo_empleado = 99999.0
1021
- modificado = True
1022
- elif p.estado == "desempate_pendiente":
1023
- p.estado = "desempate_jugado"
1024
- p.score_desempate = 0
1025
- p.tiempo_desempate = 99999.0
1026
- modificado = True
1027
-
1028
- torneo.primero_termino_en = None
1029
- db.commit()
1030
-
1031
- if modificado:
1032
- resolver_clasificacion_torneo(torneo_id, db, None)
1033
- except Exception as e:
1034
- print(f"[Timeout] Error al procesar timeout del torneo {torneo_id}: {e}")
1035
-
1036
- @router.post("/torneos/resolver/{torneo_id}")
1037
- def resolver_clasificacion_torneo(
1038
- torneo_id: int,
1039
- db: Session = Depends(get_db),
1040
- current_user: User = Depends(get_current_user)
1041
- ):
1042
- torneo = db.query(Torneo).filter(Torneo.id == torneo_id).first()
1043
- if not torneo:
1044
- raise HTTPException(status_code=404, detail="Torneo no encontrado")
1045
-
1046
- if torneo.estado == "terminado":
1047
- return {"message": "El torneo ya está terminado", "ganador_id": torneo.ganador_id}
1048
-
1049
- participantes = db.query(ParticipanteTorneo).filter(
1050
- ParticipanteTorneo.torneo_id == torneo_id
1051
- ).all()
1052
-
1053
- if torneo.estado == "desempate":
1054
- pendientes = [p for p in participantes if not p.eliminado and p.estado == "desempate_pendiente"]
1055
- if len(pendientes) > 0:
1056
- return {"message": "Esperando a que todos los finalistas terminen de jugar el desempate"}
1057
-
1058
- competidores = [p for p in participantes if not p.eliminado and p.estado == "desempate_jugado"]
1059
- if len(competidores) < 1:
1060
- return {"message": "Nadie ha completado la muerte súbita todavía"}
1061
-
1062
- competidores.sort(key=lambda x: (-x.score_desempate, x.tiempo_desempate))
1063
- max_score = competidores[0].score_desempate
1064
- winners = [c for c in competidores if c.score_desempate == max_score]
1065
-
1066
- if len(winners) == 1 or len(competidores) == 1:
1067
- campeon = competidores[0]
1068
- torneo.ganador_id = campeon.user_id
1069
- torneo.estado = "terminado"
1070
-
1071
- for p in participantes:
1072
- if p.user_id != campeon.user_id:
1073
- p.eliminado = True
1074
-
1075
- iglesia = get_iglesia_usuario(campeon.user_id, db)
1076
- if iglesia:
1077
- liga = db.query(PuntuacionLiga).filter(
1078
- PuntuacionLiga.user_id == campeon.user_id,
1079
- PuntuacionLiga.iglesia_id == iglesia.id
1080
- ).first()
1081
- if liga:
1082
- liga.puntos_totales += 100
1083
-
1084
- db.commit()
1085
- notificar_participantes_torneo(torneo_id, "torneo_updated", db)
1086
- return {"message": "Torneo terminado", "ganador_id": torneo.ganador_id, "estado": "terminado"}
1087
- else:
1088
- campeon = competidores[0]
1089
- torneo.ganador_id = campeon.user_id
1090
- torneo.estado = "terminado"
1091
- db.commit()
1092
- notificar_participantes_torneo(torneo_id, "torneo_updated", db)
1093
- return {"message": "Torneo terminado por tiempo", "ganador_id": torneo.ganador_id, "estado": "terminado"}
1094
- else:
1095
- pendientes = [p for p in participantes if p.estado == "pendiente"]
1096
- if len(pendientes) > 0:
1097
- return {"message": "Esperando a que todos los participantes terminen de jugar el torneo"}
1098
-
1099
- jugaron = [p for p in participantes if p.estado == "jugado"]
1100
- if len(jugaron) < 1:
1101
- return {"message": "Nadie ha jugado el torneo todavía"}
1102
-
1103
- max_score = max(p.score for p in jugaron)
1104
- empatados = [p for p in jugaron if p.score == max_score]
1105
-
1106
- if len(empatados) == 1:
1107
- campeon = empatados[0]
1108
- torneo.ganador_id = campeon.user_id
1109
- torneo.estado = "terminado"
1110
-
1111
- iglesia = get_iglesia_usuario(campeon.user_id, db)
1112
- if iglesia:
1113
- liga = db.query(PuntuacionLiga).filter(
1114
- PuntuacionLiga.user_id == campeon.user_id,
1115
- PuntuacionLiga.iglesia_id == iglesia.id
1116
- ).first()
1117
- if liga:
1118
- liga.puntos_totales += 100
1119
-
1120
- db.commit()
1121
- notificar_participantes_torneo(torneo_id, "torneo_updated", db)
1122
- return {"message": "Torneo resuelto con ganador único", "ganador_id": torneo.ganador_id, "estado": "terminado"}
1123
- else:
1124
- empatados_ids = [e.user_id for e in empatados]
1125
- for p in participantes:
1126
- if p.user_id not in empatados_ids:
1127
- p.eliminado = True
1128
-
1129
- todas = db.query(PreguntaQuiz).all()
1130
- preguntas_seleccionadas = random.sample(todas, 5)
1131
- torneo.desempate_preguntas_json = json.dumps([q.id for q in preguntas_seleccionadas])
1132
- torneo.estado = "desempate"
1133
-
1134
- for p in participantes:
1135
- if p.user_id in empatados_ids:
1136
- p.estado = "desempate_pendiente"
1137
- p.score_desempate = 0
1138
- p.tiempo_desempate = 0.0
1139
-
1140
- db.commit()
1141
- notificar_participantes_torneo(torneo_id, "torneo_updated", db)
1142
- return {"message": "Se ha desatado una muerte súbita", "estado": "desempate"}
1143
-
1144
- @router.get("/preguntas/infinitas")
1145
- def obtener_preguntas_infinitas(
1146
- db: Session = Depends(get_db),
1147
- current_user: User = Depends(get_current_user)
1148
- ):
1149
- todas = db.query(PreguntaQuiz).all()
1150
- if not todas:
1151
- raise HTTPException(status_code=404, detail="No hay preguntas registradas en el sistema")
1152
-
1153
- # Barajarlas
1154
- random.shuffle(todas)
1155
-
1156
- preguntas_data = []
1157
- for q in todas:
1158
- try:
1159
- opciones = json.loads(q.opciones)
1160
- except Exception:
1161
- opciones = q.opciones
1162
-
1163
- preguntas_data.append({
1164
- "id": q.id,
1165
- "tipo": q.tipo,
1166
- "pregunta": q.pregunta,
1167
- "opciones": opciones,
1168
- "respuesta_correcta": q.respuesta_correcta
1169
- })
1170
-
1171
- return {
1172
- "id": "infinito",
1173
- "titulo": "Práctica de Supervivencia",
1174
- "pasajes": "Modo Infinito",
1175
- "versiculos": [],
1176
- "preguntas": preguntas_data
1177
- }
1178
-
1179
- @router.get("/preguntas/desafio-sabios")
1180
- def obtener_preguntas_desafio_sabios(
1181
- db: Session = Depends(get_db),
1182
- current_user: User = Depends(get_current_user)
1183
- ):
1184
- plan = db.query(PlanLectura).filter(PlanLectura.nombre == "Desafío de Sabios: Preguntas Avanzadas").first()
1185
- if not plan:
1186
- raise HTTPException(status_code=404, detail="El plan Desafío de Sabios no ha sido inicializado en el sistema")
1187
-
1188
- dias = db.query(DiaLectura).filter(DiaLectura.plan_id == plan.id).all()
1189
- dias_ids = [d.id for d in dias]
1190
-
1191
- preguntas = db.query(PreguntaQuiz).filter(PreguntaQuiz.dia_lectura_id.in_(dias_ids)).all()
1192
- if not preguntas:
1193
- raise HTTPException(status_code=404, detail="No se encontraron preguntas en el Desafío de Sabios")
1194
-
1195
- # Barajar para ofrecer rejugabilidad
1196
- random.shuffle(preguntas)
1197
-
1198
- preguntas_data = []
1199
- for q in preguntas:
1200
- try:
1201
- opciones = json.loads(q.opciones)
1202
- except Exception:
1203
- opciones = q.opciones
1204
-
1205
- preguntas_data.append({
1206
- "id": q.id,
1207
- "tipo": q.tipo,
1208
- "pregunta": q.pregunta,
1209
- "opciones": opciones,
1210
- "respuesta_correcta": q.respuesta_correcta
1211
- })
1212
-
1213
- return {
1214
- "id": "desafio-sabios",
1215
- "titulo": "Desafío de Sabios",
1216
- "pasajes": "Preguntas Avanzadas",
1217
- "versiculos": [],
1218
- "preguntas": preguntas_data
1219
- }
1220
-
1221
-
 
1
+ import json
2
+ import re
3
+ import random
4
+ from datetime import datetime, timedelta
5
+ from typing import List, Optional
6
+ from fastapi import APIRouter, Depends, HTTPException, status
7
+ from pydantic import BaseModel
8
+ from sqlalchemy.orm import Session
9
+
10
+ from app.database import get_db
11
+ from app.models.user import User
12
+ from app.models.iglesia import IglesiaMember
13
+ from app.models.aprendizaje import (
14
+ PlanLectura, DiaLectura, PreguntaQuiz, ProgresoPlan, PuntuacionLiga,
15
+ BibliaLibro, BibliaVersiculo, Torneo, ParticipanteTorneo
16
+ )
17
+ from app.routers.auth import get_current_user
18
+ from app.utils.credits import deduct_credits
19
+ from app.routers.iglesia import get_iglesia_usuario
20
+
21
+ router = APIRouter(prefix="/aprendizaje", tags=["aprendizaje"])
22
+
23
+ def notificar_participantes_torneo(torneo_id: int, message_type: str, db: Session):
24
+ try:
25
+ from app.websocket_manager import send_sync_message
26
+ participantes = db.query(ParticipanteTorneo).filter(ParticipanteTorneo.torneo_id == torneo_id).all()
27
+ for p in participantes:
28
+ try:
29
+ send_sync_message({
30
+ "type": message_type,
31
+ "torneo_id": torneo_id
32
+ }, p.user_id)
33
+ except Exception:
34
+ pass
35
+ except Exception as e:
36
+ print(f"[WebSocket] Error notificar_participantes_torneo: {e}")
37
+
38
+ # ──────────────────────────────────────────────────────────
39
+ # PARSER DE PASAJES BÍBLICOS
40
+ # Permite convertir "Matthew 1" o "Matthew 1-3" en versículos reales
41
+ # ──────────────────────────────────────────────────────────
42
+ def parse_and_fetch_passage(passage_str: str, db: Session):
43
+ """
44
+ Parsea un string de pasaje como 'Génesis 1' o 'Juan 3-4; Romanos 8'
45
+ y retorna los versículos de la DB (que ahora tiene libros en español RVR1960).
46
+ Soporta tanto nombres con tilde como sin tilde.
47
+ """
48
+ import unicodedata as _ud
49
+
50
+ def _normalizar(texto):
51
+ """Quita tildes para comparación insensible a acentos."""
52
+ nfkd = _ud.normalize('NFKD', texto)
53
+ return ''.join(c for c in nfkd if not _ud.combining(c)).lower()
54
+
55
+ # Mapa: nombre_normalizado_sin_tilde → nombre_real_en_DB (con tilde)
56
+ NOMBRES_ES = {
57
+ "genesis": "Génesis", "exodo": "Éxodo", "levitico": "Levítico",
58
+ "numeros": "Números", "deuteronomio": "Deuteronomio", "josue": "Josué",
59
+ "jueces": "Jueces", "rut": "Rut", "ruth": "Rut",
60
+ "1 samuel": "1 Samuel", "2 samuel": "2 Samuel",
61
+ "1 reyes": "1 Reyes", "2 reyes": "2 Reyes",
62
+ "1 cronicas": "1 Crónicas", "2 cronicas": "2 Crónicas",
63
+ "esdras": "Esdras", "nehemias": "Nehemías", "ester": "Ester",
64
+ "job": "Job", "salmos": "Salmos", "proverbios": "Proverbios",
65
+ "eclesiastes": "Eclesiastés",
66
+ "cantares": "Cantares", "cantar de los cantares": "Cantares",
67
+ "isaias": "Isaías", "jeremias": "Jeremías",
68
+ "lamentaciones": "Lamentaciones", "ezequiel": "Ezequiel",
69
+ "daniel": "Daniel", "oseas": "Oseas", "joel": "Joel",
70
+ "amos": "Amós", "abdias": "Abdías", "jonas": "Jonás",
71
+ "miqueas": "Miqueas", "nahum": "Nahum", "habacuc": "Habacuc",
72
+ "sofonias": "Sofonías", "hageo": "Hageo",
73
+ "zacarias": "Zacarías", "malaquias": "Malaquías",
74
+ "mateo": "Mateo", "marcos": "Marcos", "lucas": "Lucas",
75
+ "juan": "Juan", "hechos": "Hechos", "romanos": "Romanos",
76
+ "1 corintios": "1 Corintios", "2 corintios": "2 Corintios",
77
+ "galatas": "Gálatas", "efesios": "Efesios", "filipenses": "Filipenses",
78
+ "colosenses": "Colosenses",
79
+ "1 tesalonicenses": "1 Tesalonicenses", "2 tesalonicenses": "2 Tesalonicenses",
80
+ "1 timoteo": "1 Timoteo", "2 timoteo": "2 Timoteo",
81
+ "tito": "Tito", "filemon": "Filemón", "hebreos": "Hebreos",
82
+ "santiago": "Santiago", "1 pedro": "1 Pedro", "2 pedro": "2 Pedro",
83
+ "1 juan": "1 Juan", "2 juan": "2 Juan", "3 juan": "3 Juan",
84
+ "judas": "Judas",
85
+ "apocalipsis": "Apocalipsis", "revelacion": "Apocalipsis",
86
+ }
87
+
88
+ parts = passage_str.split(";")
89
+ all_versiculos = []
90
+ for part in parts:
91
+ part = part.strip()
92
+ if not part:
93
+ continue
94
+ match = re.match(r"^([1-3]?\s?[A-Za-zñÑáéíóúÁÉÍÓÚ\s]+)\s([0-9]+)(?:-([0-9]+))?$", part)
95
+ if not match:
96
+ continue
97
+
98
+ libro_nombre_raw = match.group(1).strip()
99
+ cap_inicio = int(match.group(2))
100
+ cap_fin = int(match.group(3)) if match.group(3) else cap_inicio
101
+
102
+ # Resolver nombre del libro (con o sin tilde)
103
+ lookup = _normalizar(libro_nombre_raw)
104
+ nombre_en_db = NOMBRES_ES.get(lookup)
105
+
106
+ if nombre_en_db:
107
+ # Búsqueda exacta primero, luego LIKE
108
+ libro = db.query(BibliaLibro).filter(BibliaLibro.nombre == nombre_en_db).first()
109
+ if not libro:
110
+ libro = db.query(BibliaLibro).filter(BibliaLibro.nombre.like(f"%{nombre_en_db}%")).first()
111
+ else:
112
+ # Fallback: buscar por fragmento del nombre original
113
+ libro = db.query(BibliaLibro).filter(BibliaLibro.nombre.ilike(f"%{libro_nombre_raw}%")).first()
114
+
115
+ if not libro:
116
+ continue
117
+
118
+ versiculos = db.query(BibliaVersiculo).filter(
119
+ BibliaVersiculo.libro_id == libro.id,
120
+ BibliaVersiculo.capitulo >= cap_inicio,
121
+ BibliaVersiculo.capitulo <= cap_fin
122
+ ).order_by(BibliaVersiculo.capitulo, BibliaVersiculo.versiculo).all()
123
+
124
+ for v in versiculos:
125
+ all_versiculos.append({
126
+ "id": v.id,
127
+ "capitulo": v.capitulo,
128
+ "versiculo": v.versiculo,
129
+ "texto": v.texto,
130
+ "libro": libro.nombre
131
+ })
132
+
133
+ return all_versiculos
134
+
135
+ # ──────────────────────────────────────────────────────────
136
+ # SCHEMAS DE PYDANTIC
137
+ # ──────────────────────────────────────────────────────────
138
+ class CompletarDiaRequest(BaseModel):
139
+ plan_id: int
140
+ respuestas_correctas: int
141
+ total_preguntas: int
142
+ fecha_local: str # "YYYY-MM-DD" del dispositivo móvil
143
+ dia_numero: Optional[int] = None
144
+
145
+ # ──────────────────────────────────────────────────────────
146
+ # FUNCIONES AUXILIARES DE RACHA
147
+ # ──────────────────────────────────────────────────────────
148
+ def check_and_reset_streaks(user_id: int, db: Session, fecha_hoy_str: Optional[str] = None):
149
+ if not fecha_hoy_str:
150
+ fecha_hoy_str = datetime.utcnow().strftime("%Y-%m-%d")
151
+
152
+ progresos = db.query(ProgresoPlan).filter(ProgresoPlan.user_id == user_id).all()
153
+ updated = False
154
+ for progreso in progresos:
155
+ if not progreso.ultima_lectura_fecha:
156
+ continue
157
+
158
+ try:
159
+ ultima_fecha = datetime.strptime(progreso.ultima_lectura_fecha, "%Y-%m-%d").date()
160
+ fecha_hoy = datetime.strptime(fecha_hoy_str, "%Y-%m-%d").date()
161
+ except ValueError:
162
+ continue
163
+
164
+ diff = (fecha_hoy - ultima_fecha).days
165
+ if diff > 1:
166
+ is_saved = (diff == 2 and progreso.escudos_racha and progreso.escudos_racha > 0)
167
+ if not is_saved and progreso.racha_actual > 0:
168
+ progreso.racha_actual = 0
169
+ updated = True
170
+
171
+ if updated:
172
+ db.commit()
173
+
174
+ # ──────────────────────────────────────────────────────────
175
+ # ENDPOINTS
176
+ # ──────────────────────────────────────────────────────────
177
+
178
+ @router.get("/planes")
179
+ def listar_planes(fecha_local: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
180
+ check_and_reset_streaks(current_user.id, db, fecha_local)
181
+ iglesia = get_iglesia_usuario(current_user.id, db)
182
+ iglesia_id = iglesia.id if iglesia else None
183
+ planes = db.query(PlanLectura).filter(PlanLectura.nombre != "Desafío de Sabios: Preguntas Avanzadas").all()
184
+ progresos = db.query(ProgresoPlan).filter(ProgresoPlan.user_id == current_user.id, ProgresoPlan.iglesia_id == iglesia_id).all() if iglesia_id else []
185
+ prog_dict = {p.plan_id: p for p in progresos}
186
+
187
+ resultado = []
188
+ for plan in planes:
189
+ prog = prog_dict.get(plan.id)
190
+ resultado.append({
191
+ "id": plan.id,
192
+ "nombre": plan.nombre,
193
+ "descripcion": plan.descripcion,
194
+ "dias_totales": plan.dias_totales,
195
+ "suscrito": prog is not None,
196
+ "dia_actual": prog.dia_actual if prog else 1,
197
+ "repaso_completado_hasta": prog.repaso_completado_hasta if prog else 0,
198
+ "escudos_racha": prog.escudos_racha if prog else 0,
199
+ "completado": prog.completado if prog else False
200
+ })
201
+ return resultado
202
+
203
+ @router.post("/suscribir/{plan_id}")
204
+ def suscribir_a_plan(plan_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
205
+ iglesia = get_iglesia_usuario(current_user.id, db)
206
+ if not iglesia:
207
+ raise HTTPException(status_code=400, detail="Debes pertenecer a una iglesia para suscribirte a un plan de lectura")
208
+
209
+ plan = db.query(PlanLectura).filter(PlanLectura.id == plan_id).first()
210
+ if not plan:
211
+ raise HTTPException(status_code=404, detail="Plan de lectura no encontrado")
212
+
213
+ progreso = db.query(ProgresoPlan).filter(
214
+ ProgresoPlan.user_id == current_user.id,
215
+ ProgresoPlan.plan_id == plan.id,
216
+ ProgresoPlan.iglesia_id == iglesia.id
217
+ ).first()
218
+
219
+ if progreso:
220
+ return {"message": "Ya estás suscrito a este plan", "dia_actual": progreso.dia_actual}
221
+
222
+ nuevo_progreso = ProgresoPlan(
223
+ user_id=current_user.id,
224
+ plan_id=plan.id,
225
+ iglesia_id=iglesia.id,
226
+ dia_actual=1,
227
+ completado=False,
228
+ racha_actual=0
229
+ )
230
+ db.add(nuevo_progreso)
231
+ db.commit()
232
+ return {"message": "Suscripción exitosa", "dia_actual": 1}
233
+
234
+ @router.post("/cancelar/{plan_id}")
235
+ def cancelar_plan(plan_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
236
+ iglesia = get_iglesia_usuario(current_user.id, db)
237
+ iglesia_id = iglesia.id if iglesia else None
238
+ progreso = db.query(ProgresoPlan).filter(
239
+ ProgresoPlan.user_id == current_user.id,
240
+ ProgresoPlan.plan_id == plan_id,
241
+ ProgresoPlan.iglesia_id == iglesia_id
242
+ ).first()
243
+
244
+ if not progreso:
245
+ raise HTTPException(status_code=404, detail="No estás suscrito a este plan en esta iglesia")
246
+
247
+ db.delete(progreso)
248
+ db.commit()
249
+ return {"message": "Plan cancelado exitosamente"}
250
+
251
+ @router.get("/dia/{plan_id}")
252
+ def obtener_dia_lectura(plan_id: int, fecha_local: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
253
+ check_and_reset_streaks(current_user.id, db, fecha_local)
254
+ iglesia = get_iglesia_usuario(current_user.id, db)
255
+ iglesia_id = iglesia.id if iglesia else None
256
+ progreso = db.query(ProgresoPlan).filter(
257
+ ProgresoPlan.user_id == current_user.id,
258
+ ProgresoPlan.plan_id == plan_id,
259
+ ProgresoPlan.iglesia_id == iglesia_id
260
+ ).first()
261
+
262
+ if not progreso:
263
+ raise HTTPException(status_code=400, detail="No estás suscrito a este plan en esta iglesia")
264
+
265
+ if progreso.completado:
266
+ return {"completado_todo": True, "message": "¡Felicidades! Has completado este plan de lectura."}
267
+
268
+ # Buscar el día correspondiente
269
+ dia = db.query(DiaLectura).filter(
270
+ DiaLectura.plan_id == plan_id,
271
+ DiaLectura.dia_numero == progreso.dia_actual
272
+ ).first()
273
+
274
+ if not dia:
275
+ # Si no hay más días cargados, marcar plan como completado
276
+ progreso.completado = True
277
+ db.commit()
278
+ return {"completado_todo": True, "message": "¡Felicidades! Has completado este plan de lectura."}
279
+
280
+ # Obtener los versículos del pasaje
281
+ versiculos = parse_and_fetch_passage(dia.pasajes, db)
282
+
283
+ # Obtener preguntas de este día
284
+ preguntas = db.query(PreguntaQuiz).filter(PreguntaQuiz.dia_lectura_id == dia.id).all()
285
+ preguntas_list = []
286
+ for p in preguntas:
287
+ opciones_parsed = []
288
+ try:
289
+ opciones_parsed = json.loads(p.opciones) if p.opciones else []
290
+ except:
291
+ opciones_parsed = []
292
+
293
+ preguntas_list.append({
294
+ "id": p.id,
295
+ "tipo": p.tipo,
296
+ "pregunta": p.pregunta,
297
+ "opciones": opciones_parsed,
298
+ "respuesta_correcta": p.respuesta_correcta
299
+ })
300
+
301
+ return {
302
+ "completado_todo": False,
303
+ "dia_numero": dia.dia_numero,
304
+ "pasajes": dia.pasajes,
305
+ "titulo": dia.titulo or f"Día {dia.dia_numero}",
306
+ "versiculos": versiculos,
307
+ "preguntas": preguntas_list
308
+ }
309
+
310
+ @router.post("/completar-dia")
311
+ def completar_dia(data: CompletarDiaRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
312
+ iglesia = get_iglesia_usuario(current_user.id, db)
313
+ if not iglesia:
314
+ raise HTTPException(status_code=404, detail="No perteneces a ninguna iglesia para sumar puntos de liga")
315
+
316
+ progreso = db.query(ProgresoPlan).filter(
317
+ ProgresoPlan.user_id == current_user.id,
318
+ ProgresoPlan.plan_id == data.plan_id,
319
+ ProgresoPlan.iglesia_id == iglesia.id
320
+ ).first()
321
+
322
+ if not progreso:
323
+ raise HTTPException(status_code=400, detail="No suscrito al plan")
324
+
325
+ if data.dia_numero is not None and progreso.dia_actual > data.dia_numero:
326
+ return {
327
+ "puntos_ganados": 0,
328
+ "nueva_racha": progreso.racha_actual,
329
+ "dia_siguiente": progreso.dia_actual,
330
+ "completado": progreso.completado,
331
+ "escudo_usado": False,
332
+ "message": "Día ya completado anteriormente (duplicado silenciado)"
333
+ }
334
+
335
+ # 1. VALIDACIÓN DE LA RACHA (STREAK)
336
+ hoy = data.fecha_local # Formato "YYYY-MM-DD"
337
+ racha_antes = progreso.racha_actual
338
+ escudo_usado = False
339
+
340
+ if progreso.ultima_lectura_fecha == hoy:
341
+ # Ya leyó hoy, no incrementa racha pero actualiza progreso
342
+ pass
343
+ elif progreso.ultima_lectura_fecha:
344
+ ultima_fecha = datetime.strptime(progreso.ultima_lectura_fecha, "%Y-%m-%d").date()
345
+ fecha_dispositivo = datetime.strptime(hoy, "%Y-%m-%d").date()
346
+ diff = (fecha_dispositivo - ultima_fecha).days
347
+
348
+ if diff == 1:
349
+ progreso.racha_actual += 1
350
+ elif diff > 1:
351
+ if diff == 2 and progreso.escudos_racha > 0:
352
+ progreso.escudos_racha -= 1
353
+ progreso.racha_actual += 1
354
+ escudo_usado = True
355
+ else:
356
+ progreso.racha_actual = 1 # Se rompió, reinicia
357
+ else:
358
+ # Caso raro de viaje en el tiempo/cambio de fecha del usuario
359
+ pass
360
+ else:
361
+ # Primera lectura
362
+ progreso.racha_actual = 1
363
+
364
+ progreso.ultima_lectura_fecha = hoy
365
+
366
+ # 2. CÁLCULO DE PUNTOS XP
367
+ # Base: 10 XP por leer. Cada acierto del quiz: +5 XP
368
+ puntos_ganados = 10 + (data.respuestas_correctas * 5)
369
+
370
+ # Actualizar o crear marcador en la Liga de la Iglesia
371
+ liga = db.query(PuntuacionLiga).filter(
372
+ PuntuacionLiga.user_id == current_user.id,
373
+ PuntuacionLiga.iglesia_id == iglesia.id
374
+ ).first()
375
+
376
+ if not liga:
377
+ liga = PuntuacionLiga(
378
+ user_id=current_user.id,
379
+ iglesia_id=iglesia.id,
380
+ puntos_totales=puntos_ganados,
381
+ maxima_racha=progreso.racha_actual
382
+ )
383
+ db.add(liga)
384
+ else:
385
+ liga.puntos_totales += puntos_ganados
386
+ if progreso.racha_actual > liga.maxima_racha:
387
+ liga.maxima_racha = progreso.racha_actual
388
+
389
+ # 3. AVANCE DE DÍA
390
+ plan = db.query(PlanLectura).filter(PlanLectura.id == data.plan_id).first()
391
+ if progreso.dia_actual >= plan.dias_totales:
392
+ progreso.completado = True
393
+ else:
394
+ progreso.dia_actual += 1
395
+
396
+ db.commit()
397
+
398
+ return {
399
+ "puntos_ganados": puntos_ganados,
400
+ "nueva_racha": progreso.racha_actual,
401
+ "dia_siguiente": progreso.dia_actual,
402
+ "completado": progreso.completado,
403
+ "escudo_usado": escudo_usado
404
+ }
405
+
406
+ @router.get("/liga")
407
+ def obtener_tabla_posiciones(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
408
+ iglesia = get_iglesia_usuario(current_user.id, db)
409
+ if not iglesia:
410
+ raise HTTPException(status_code=404, detail="No perteneces a ninguna iglesia")
411
+
412
+ # Obtener IDs de usuarios que pertenecen a esta iglesia
413
+ miembros_ids = [m.user_id for m in db.query(IglesiaMember).filter(
414
+ IglesiaMember.iglesia_id == iglesia.id,
415
+ IglesiaMember.estado == "activo"
416
+ ).all()]
417
+
418
+ # Buscar puntuaciones ordenadas
419
+ puntuaciones = db.query(PuntuacionLiga).filter(
420
+ PuntuacionLiga.user_id.in_(miembros_ids)
421
+ ).order_by(PuntuacionLiga.puntos_totales.desc()).all()
422
+
423
+ leaderboard = []
424
+ for idx, p in enumerate(puntuaciones):
425
+ leaderboard.append({
426
+ "posicion": idx + 1,
427
+ "user_id": p.user_id,
428
+ "username": p.user.username,
429
+ "puntos": p.puntos_totales,
430
+ "maxima_racha": p.maxima_racha
431
+ })
432
+
433
+ return {
434
+ "iglesia_nombre": iglesia.nombre,
435
+ "leaderboard": leaderboard
436
+ }
437
+
438
+ @router.get("/mi-estado")
439
+ def obtener_mi_estado(fecha_local: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
440
+ check_and_reset_streaks(current_user.id, db, fecha_local)
441
+ iglesia = get_iglesia_usuario(current_user.id, db)
442
+
443
+ progresos = db.query(ProgresoPlan).filter(ProgresoPlan.user_id == current_user.id, ProgresoPlan.iglesia_id == iglesia.id).all() if iglesia else []
444
+ racha_maxima = max([p.racha_actual for p in progresos]) if progresos else 0
445
+
446
+ puntos_liga = 0
447
+ if iglesia:
448
+ liga = db.query(PuntuacionLiga).filter(
449
+ PuntuacionLiga.user_id == current_user.id,
450
+ PuntuacionLiga.iglesia_id == iglesia.id
451
+ ).first()
452
+ if liga:
453
+ puntos_liga = liga.puntos_totales
454
+
455
+ return {
456
+ "user_id": current_user.id,
457
+ "racha_actual": racha_maxima,
458
+ "puntos_liga": puntos_liga,
459
+ "tiene_iglesia": iglesia is not None
460
+ }
461
+
462
+ @router.get("/usuario/{user_id}/estado")
463
+ def obtener_estado_usuario(user_id: int, fecha_local: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
464
+ """Obtiene el estado de avance de los planes bíblicos de un usuario específico."""
465
+ check_and_reset_streaks(user_id, db, fecha_local)
466
+ user = db.query(User).filter(User.id == user_id).first()
467
+ if not user:
468
+ raise HTTPException(status_code=404, detail="Usuario no encontrado")
469
+
470
+ iglesia = get_iglesia_usuario(user_id, db)
471
+ progresos = db.query(ProgresoPlan).filter(ProgresoPlan.user_id == user_id, ProgresoPlan.iglesia_id == iglesia.id).all() if iglesia else []
472
+
473
+ puntos_liga = 0
474
+ iglesia = get_iglesia_usuario(user_id, db)
475
+ if iglesia:
476
+ liga = db.query(PuntuacionLiga).filter_by(user_id=user_id, iglesia_id=iglesia.id).first()
477
+ if liga:
478
+ puntos_liga = liga.puntos_totales
479
+
480
+ planes_info = []
481
+ racha_maxima = max([p.racha_actual for p in progresos]) if progresos else 0
482
+
483
+ for p in progresos:
484
+ plan_obj = db.query(PlanLectura).filter_by(id=p.plan_id).first()
485
+ if plan_obj:
486
+ dias_totales = plan_obj.dias_totales
487
+ dias_leidos = p.dia_actual - 1 if not p.completado else dias_totales
488
+ dias_faltantes = max(0, dias_totales - dias_leidos)
489
+
490
+ hoy = datetime.utcnow().strftime("%Y-%m-%d")
491
+ estado_ritmo = "Al día" if p.ultima_lectura_fecha == hoy else "Atrasado"
492
+ if p.completado:
493
+ estado_ritmo = "Completado"
494
+
495
+ planes_info.append({
496
+ "plan_id": plan_obj.id,
497
+ "nombre": plan_obj.nombre,
498
+ "dias_totales": dias_totales,
499
+ "dia_actual": p.dia_actual,
500
+ "dias_leidos": dias_leidos,
501
+ "dias_faltantes": dias_faltantes,
502
+ "racha": p.racha_actual,
503
+ "completado": p.completado,
504
+ "estado_ritmo": estado_ritmo,
505
+ "ultima_lectura": p.ultima_lectura_fecha
506
+ })
507
+
508
+ return {
509
+ "user_id": user.id,
510
+ "username": user.username,
511
+ "racha_actual": racha_maxima,
512
+ "puntos_liga": puntos_liga,
513
+ "planes": planes_info
514
+ }
515
+
516
+
517
+ # ──────────────────────────────────────────────────────────
518
+ # MÓDULO DE EXÁMENES DE REPASO (CADA 10 DÍAS)
519
+ # ──────────────────────────────────────────────────────────
520
+ import random
521
+
522
+ class CompletarRepasoRequest(BaseModel):
523
+ plan_id: int
524
+ dias_hito: int
525
+ fecha_local: str
526
+
527
+ @router.get("/repaso/{plan_id}/{dias_hito}")
528
+ def obtener_examen_repaso(
529
+ plan_id: int,
530
+ dias_hito: int,
531
+ db: Session = Depends(get_db),
532
+ current_user: User = Depends(get_current_user)
533
+ ):
534
+ plan = db.query(PlanLectura).filter(PlanLectura.id == plan_id).first()
535
+ if not plan:
536
+ raise HTTPException(status_code=404, detail="Plan de lectura no encontrado")
537
+
538
+ progreso = db.query(ProgresoPlan).filter(
539
+ ProgresoPlan.user_id == current_user.id,
540
+ ProgresoPlan.plan_id == plan.id
541
+ ).first()
542
+ if not progreso:
543
+ raise HTTPException(status_code=400, detail="El usuario no está suscrito a este plan")
544
+
545
+ dias = db.query(DiaLectura).filter(
546
+ DiaLectura.plan_id == plan_id,
547
+ DiaLectura.dia_numero <= dias_hito
548
+ ).all()
549
+ dias_ids = [d.id for d in dias]
550
+
551
+ todas_preguntas = db.query(PreguntaQuiz).filter(
552
+ PreguntaQuiz.dia_lectura_id.in_(dias_ids)
553
+ ).all()
554
+
555
+ if not todas_preguntas:
556
+ raise HTTPException(status_code=404, detail="No se encontraron preguntas para este examen de repaso")
557
+
558
+ preguntas_seleccionadas = random.sample(todas_preguntas, min(len(todas_preguntas), 5))
559
+
560
+ preguntas_data = []
561
+ for q in preguntas_seleccionadas:
562
+ try:
563
+ opciones = json.loads(q.opciones)
564
+ except Exception:
565
+ opciones = q.opciones
566
+
567
+ preguntas_data.append({
568
+ "id": q.id,
569
+ "tipo": q.tipo,
570
+ "pregunta": q.pregunta,
571
+ "opciones": opciones,
572
+ "respuesta_correcta": q.respuesta_correcta
573
+ })
574
+
575
+ return {
576
+ "id": f"repaso-{plan_id}-{dias_hito}",
577
+ "titulo": f"Examen de Repaso (Días 1 a {dias_hito})",
578
+ "pasajes": "Repaso General",
579
+ "versiculos": [],
580
+ "preguntas": preguntas_data
581
+ }
582
+
583
+ @router.post("/completar-repaso")
584
+ def completar_examen_repaso(
585
+ data: CompletarRepasoRequest,
586
+ db: Session = Depends(get_db),
587
+ current_user: User = Depends(get_current_user)
588
+ ):
589
+ progreso = db.query(ProgresoPlan).filter(
590
+ ProgresoPlan.user_id == current_user.id,
591
+ ProgresoPlan.plan_id == data.plan_id
592
+ ).first()
593
+ if not progreso:
594
+ raise HTTPException(status_code=400, detail="El usuario no está suscrito a este plan")
595
+
596
+ if progreso.repaso_completado_hasta >= data.dias_hito:
597
+ return {
598
+ "puntos_ganados": 0,
599
+ "nueva_racha": progreso.racha_actual,
600
+ "repaso_completado_hasta": progreso.repaso_completado_hasta,
601
+ "message": "Repaso ya completado anteriormente (duplicado silenciado)"
602
+ }
603
+
604
+ iglesia = get_iglesia_usuario(current_user.id, db)
605
+ puntos_ganados = 50
606
+
607
+ if iglesia:
608
+ liga = db.query(PuntuacionLiga).filter(
609
+ PuntuacionLiga.user_id == current_user.id,
610
+ PuntuacionLiga.iglesia_id == iglesia.id
611
+ ).first()
612
+
613
+ if not liga:
614
+ liga = PuntuacionLiga(
615
+ user_id=current_user.id,
616
+ iglesia_id=iglesia.id,
617
+ puntos_totales=puntos_ganados,
618
+ maxima_racha=progreso.racha_actual
619
+ )
620
+ db.add(liga)
621
+ else:
622
+ liga.puntos_totales += puntos_ganados
623
+ if progreso.racha_actual > liga.maxima_racha:
624
+ liga.maxima_racha = progreso.racha_actual
625
+
626
+ progreso.repaso_completado_hasta = data.dias_hito
627
+ db.commit()
628
+
629
+ return {
630
+ "puntos_ganados": puntos_ganados,
631
+ "nueva_racha": progreso.racha_actual,
632
+ "repaso_completado_hasta": progreso.repaso_completado_hasta
633
+ }
634
+
635
+ # ──────────────────────────────────────────────────────────
636
+ # COMPRA DE ESCUDOS DE RACHA
637
+ # ──────────────────────────────────────────────────────────
638
+ @router.post("/comprar-escudo/{plan_id}")
639
+ def comprar_escudo_racha(
640
+ plan_id: int,
641
+ db: Session = Depends(get_db),
642
+ current_user: User = Depends(get_current_user)
643
+ ):
644
+ progreso = db.query(ProgresoPlan).filter(
645
+ ProgresoPlan.user_id == current_user.id,
646
+ ProgresoPlan.plan_id == plan_id
647
+ ).first()
648
+ if not progreso:
649
+ raise HTTPException(status_code=400, detail="El usuario no está suscrito a este plan")
650
+
651
+ # Descontar 1 crédito
652
+ credits_left = deduct_credits(db, current_user, 1, "comprar_escudo", reference_id=plan_id)
653
+
654
+ progreso.escudos_racha += 1
655
+ db.commit()
656
+
657
+ return {
658
+ "credits_left": credits_left,
659
+ "escudos_racha": progreso.escudos_racha
660
+ }
661
+
662
+ # ───────────────────────────────────────��──────────────────
663
+ # MOTOR DE TORNEOS MULTIJUGADOR BÍBLICOS
664
+ # ──────────────────────────────────────────────────────────
665
+
666
+ class CrearTorneoRequest(BaseModel):
667
+ nombre: str
668
+
669
+ class JugarTorneoRequest(BaseModel):
670
+ torneo_id: int
671
+ score: int
672
+ tiempo_empleado: float
673
+ es_desempate: bool = False
674
+
675
+ @router.post("/torneos/crear")
676
+ def crear_torneo(
677
+ data: CrearTorneoRequest,
678
+ db: Session = Depends(get_db),
679
+ current_user: User = Depends(get_current_user)
680
+ ):
681
+ codigo = ""
682
+ while True:
683
+ num = random.randint(1000, 9999)
684
+ codigo = f"BIB-{num}"
685
+ ex = db.query(Torneo).filter(Torneo.codigo == codigo).first()
686
+ if not ex:
687
+ break
688
+
689
+ # Validar que no tenga ya un torneo activo/pendiente/desempate
690
+ torneo_existente = db.query(Torneo).filter(
691
+ Torneo.creador_id == current_user.id,
692
+ Torneo.estado.in_(["pendiente", "activo", "desempate"])
693
+ ).first()
694
+ if torneo_existente:
695
+ raise HTTPException(
696
+ status_code=400,
697
+ detail="Ya tienes un torneo pendiente o activo. Debes finalizarlo antes de crear otro."
698
+ )
699
+
700
+ todas = db.query(PreguntaQuiz).all()
701
+ if len(todas) < 5:
702
+ raise HTTPException(status_code=400, detail="No hay suficientes preguntas bíblicas registradas en el sistema")
703
+
704
+ preguntas_seleccionadas = random.sample(todas, 5)
705
+ preguntas_ids = [q.id for q in preguntas_seleccionadas]
706
+
707
+ nuevo_torneo = Torneo(
708
+ codigo=codigo,
709
+ nombre=data.nombre,
710
+ creador_id=current_user.id,
711
+ preguntas_json=json.dumps(preguntas_ids),
712
+ estado="pendiente", # Estado inicial cambiado a pendiente
713
+ fecha_creacion=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
714
+ )
715
+ db.add(nuevo_torneo)
716
+ db.commit()
717
+ db.refresh(nuevo_torneo)
718
+
719
+ participante = ParticipanteTorneo(
720
+ torneo_id=nuevo_torneo.id,
721
+ user_id=current_user.id,
722
+ estado="pendiente"
723
+ )
724
+ db.add(participante)
725
+ db.commit()
726
+
727
+ return {
728
+ "id": nuevo_torneo.id,
729
+ "codigo": nuevo_torneo.codigo,
730
+ "nombre": nuevo_torneo.nombre,
731
+ "creador_id": nuevo_torneo.creador_id,
732
+ "estado": nuevo_torneo.estado
733
+ }
734
+
735
+ @router.post("/torneos/unirse/{codigo}")
736
+ def unirse_a_torneo(
737
+ codigo: str,
738
+ db: Session = Depends(get_db),
739
+ current_user: User = Depends(get_current_user)
740
+ ):
741
+ torneo = db.query(Torneo).filter(Torneo.codigo == codigo.upper().strip()).first()
742
+ if not torneo:
743
+ raise HTTPException(status_code=404, detail="Torneo no encontrado")
744
+
745
+ if torneo.estado == "terminado":
746
+ raise HTTPException(status_code=400, detail="Este torneo ya ha finalizado")
747
+
748
+ # Validar que no tenga otra participación activa o pendiente en curso
749
+ participacion_activa = db.query(ParticipanteTorneo).join(Torneo).filter(
750
+ ParticipanteTorneo.user_id == current_user.id,
751
+ Torneo.estado.in_(["pendiente", "activo", "desempate"])
752
+ ).first()
753
+ if participacion_activa and participacion_activa.torneo_id != torneo.id:
754
+ raise HTTPException(
755
+ status_code=400,
756
+ detail="Ya participas en otro torneo activo o pendiente en curso. No puedes unirte a otro."
757
+ )
758
+
759
+ existente = db.query(ParticipanteTorneo).filter(
760
+ ParticipanteTorneo.torneo_id == torneo.id,
761
+ ParticipanteTorneo.user_id == current_user.id
762
+ ).first()
763
+
764
+ if existente:
765
+ return {"message": "Ya eres participante de este torneo", "torneo_id": torneo.id}
766
+
767
+ nuevo_participante = ParticipanteTorneo(
768
+ torneo_id=torneo.id,
769
+ user_id=current_user.id,
770
+ estado="pendiente"
771
+ )
772
+ db.add(nuevo_participante)
773
+ db.commit()
774
+
775
+ notificar_participantes_torneo(torneo.id, "torneo_updated", db)
776
+
777
+ return {
778
+ "message": "Te has unido al torneo exitosamente",
779
+ "torneo_id": torneo.id,
780
+ "nombre": torneo.nombre
781
+ }
782
+
783
+ @router.post("/torneos/comenzar/{torneo_id}")
784
+ def comenzar_torneo(
785
+ torneo_id: int,
786
+ db: Session = Depends(get_db),
787
+ current_user: User = Depends(get_current_user)
788
+ ):
789
+ torneo = db.query(Torneo).filter(Torneo.id == torneo_id).first()
790
+ if not torneo:
791
+ raise HTTPException(status_code=404, detail="Torneo no encontrado")
792
+ if torneo.creador_id != current_user.id:
793
+ raise HTTPException(status_code=403, detail="Solamente el creador del torneo puede comenzar el juego")
794
+ if torneo.estado != "pendiente":
795
+ raise HTTPException(status_code=400, detail="El torneo ya ha comenzado o ha finalizado")
796
+
797
+ # Validar que haya al menos un participante conectado aparte del creador
798
+ from app.websocket_manager import manager
799
+ otros_participantes = db.query(ParticipanteTorneo).filter(
800
+ ParticipanteTorneo.torneo_id == torneo.id,
801
+ ParticipanteTorneo.user_id != torneo.creador_id
802
+ ).all()
803
+
804
+ hay_otro_conectado = any(p.user_id in manager.active_connections for p in otros_participantes)
805
+ if not hay_otro_conectado:
806
+ raise HTTPException(
807
+ status_code=400,
808
+ detail="No se puede iniciar el torneo. Debe haber al menos un participante con conexión activa aparte del creador."
809
+ )
810
+
811
+ torneo.estado = "activo"
812
+ db.commit()
813
+ notificar_participantes_torneo(torneo.id, "torneo_updated", db)
814
+ return {"message": "El torneo ha comenzado exitosamente", "estado": torneo.estado}
815
+
816
+ @router.get("/torneos")
817
+ def listar_mis_torneos(
818
+ db: Session = Depends(get_db),
819
+ current_user: User = Depends(get_current_user)
820
+ ):
821
+ # Chequear timeouts de los torneos de este usuario antes de listar
822
+ participaciones_raw = db.query(ParticipanteTorneo).filter(ParticipanteTorneo.user_id == current_user.id).all()
823
+ for p in participaciones_raw:
824
+ check_torneo_timeout(p.torneo_id, db)
825
+
826
+ participaciones = db.query(ParticipanteTorneo).join(Torneo).filter(
827
+ ParticipanteTorneo.user_id == current_user.id,
828
+ Torneo.estado != "terminado"
829
+ ).all()
830
+
831
+ torneos_data = []
832
+ for part in participaciones:
833
+ t = part.torneo
834
+ if not t:
835
+ continue
836
+
837
+ todos_p = db.query(ParticipanteTorneo).filter(
838
+ ParticipanteTorneo.torneo_id == t.id
839
+ ).all()
840
+
841
+ ranking = []
842
+ for p in todos_p:
843
+ user_info = db.query(User).filter(User.id == p.user_id).first()
844
+ username = user_info.username if user_info else "Anónimo"
845
+ ranking.append({
846
+ "user_id": p.user_id,
847
+ "username": username,
848
+ "score": p.score,
849
+ "tiempo_empleado": p.tiempo_empleado,
850
+ "score_desempate": p.score_desempate,
851
+ "tiempo_desempate": p.tiempo_desempate,
852
+ "eliminado": p.eliminado,
853
+ "estado": p.estado
854
+ })
855
+
856
+ if t.estado == "desempate":
857
+ ranking.sort(
858
+ key=lambda x: (
859
+ x["eliminado"],
860
+ -x["score_desempate"],
861
+ x["tiempo_desempate"] if x["tiempo_desempate"] > 0 else 99999.0
862
+ )
863
+ )
864
+ else:
865
+ ranking.sort(key=lambda x: (-x["score"], x["tiempo_empleado"] if x["tiempo_empleado"] > 0 else 99999.0))
866
+
867
+ torneos_data.append({
868
+ "id": t.id,
869
+ "codigo": t.codigo,
870
+ "nombre": t.nombre,
871
+ "estado": t.estado,
872
+ "creador_id": t.creador_id,
873
+ "ganador_id": t.ganador_id,
874
+ "fecha_creacion": t.fecha_creacion,
875
+ "mi_estado": part.estado,
876
+ "mi_eliminado": part.eliminado,
877
+ "ranking": ranking
878
+ })
879
+
880
+ return torneos_data
881
+
882
+ @router.post("/torneos/jugar")
883
+ def registrar_respuestas_torneo(
884
+ data: JugarTorneoRequest,
885
+ db: Session = Depends(get_db),
886
+ current_user: User = Depends(get_current_user)
887
+ ):
888
+ torneo = db.query(Torneo).filter(Torneo.id == data.torneo_id).first()
889
+ if not torneo:
890
+ raise HTTPException(status_code=404, detail="Torneo no encontrado")
891
+
892
+ participante = db.query(ParticipanteTorneo).filter(
893
+ ParticipanteTorneo.torneo_id == data.torneo_id,
894
+ ParticipanteTorneo.user_id == current_user.id
895
+ ).first()
896
+
897
+ if not participante:
898
+ raise HTTPException(status_code=403, detail="No eres parte de este torneo")
899
+
900
+ if participante.eliminado:
901
+ raise HTTPException(status_code=403, detail="Fuiste eliminado de este torneo")
902
+
903
+ if data.es_desempate:
904
+ if torneo.estado != "desempate":
905
+ raise HTTPException(status_code=400, detail="Este torneo no está en fase de muerte súbita")
906
+ participante.score_desempate = data.score
907
+ participante.tiempo_desempate = data.tiempo_empleado
908
+ participante.estado = "desempate_jugado"
909
+ else:
910
+ if participante.estado != "pendiente":
911
+ raise HTTPException(status_code=400, detail="Ya jugaste este torneo")
912
+ participante.score = data.score
913
+ participante.tiempo_empleado = data.tiempo_empleado
914
+ participante.estado = "jugado"
915
+
916
+ db.commit()
917
+
918
+ # Si es el primer participante en terminar y hay otros pendientes, iniciar el timer de 10 min
919
+ if not torneo.primero_termino_en:
920
+ if data.es_desempate:
921
+ faltantes = db.query(ParticipanteTorneo).filter(
922
+ ParticipanteTorneo.torneo_id == torneo.id,
923
+ ParticipanteTorneo.estado == "desempate_pendiente",
924
+ ParticipanteTorneo.eliminado == False
925
+ ).count()
926
+ else:
927
+ faltantes = db.query(ParticipanteTorneo).filter(
928
+ ParticipanteTorneo.torneo_id == torneo.id,
929
+ ParticipanteTorneo.estado == "pendiente"
930
+ ).count()
931
+
932
+ if faltantes > 0:
933
+ torneo.primero_termino_en = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
934
+ db.commit()
935
+
936
+ # Resolver torneo automáticamente tras registrar respuestas, aplicando el chequeo de timeout
937
+ try:
938
+ check_torneo_timeout(data.torneo_id, db)
939
+ resolver_clasificacion_torneo(data.torneo_id, db, current_user)
940
+ except Exception as e:
941
+ print(f"Error al resolver torneo automáticamente: {e}")
942
+
943
+ notificar_participantes_torneo(data.torneo_id, "torneo_updated", db)
944
+
945
+ return {"message": "Respuestas guardadas exitosamente", "estado": participante.estado}
946
+
947
+ @router.get("/torneo/{torneo_id}/preguntas")
948
+ def obtener_preguntas_torneo(
949
+ torneo_id: int,
950
+ db: Session = Depends(get_db),
951
+ current_user: User = Depends(get_current_user)
952
+ ):
953
+ check_torneo_timeout(torneo_id, db)
954
+
955
+ torneo = db.query(Torneo).filter(Torneo.id == torneo_id).first()
956
+ if not torneo:
957
+ raise HTTPException(status_code=404, detail="Torneo no encontrado")
958
+
959
+ if torneo.estado == "pendiente":
960
+ raise HTTPException(status_code=400, detail="El torneo todavia no ha sido iniciado por su creador")
961
+
962
+ participante = db.query(ParticipanteTorneo).filter(
963
+ ParticipanteTorneo.torneo_id == torneo_id,
964
+ ParticipanteTorneo.user_id == current_user.id
965
+ ).first()
966
+
967
+ if not participante or participante.eliminado:
968
+ raise HTTPException(status_code=403, detail="No eres parte de este torneo o fuiste eliminado")
969
+
970
+ es_desempate = (torneo.estado == "desempate")
971
+ p_ids_str = torneo.desempate_preguntas_json if es_desempate else torneo.preguntas_json
972
+ p_ids = json.loads(p_ids_str) if p_ids_str else []
973
+
974
+ preguntas = db.query(PreguntaQuiz).filter(PreguntaQuiz.id.in_(p_ids)).all()
975
+
976
+ preguntas_data = []
977
+ for q in preguntas:
978
+ try:
979
+ opciones = json.loads(q.opciones)
980
+ except Exception:
981
+ opciones = q.opciones
982
+
983
+ preguntas_data.append({
984
+ "id": q.id,
985
+ "tipo": q.tipo,
986
+ "pregunta": q.pregunta,
987
+ "opciones": opciones,
988
+ "respuesta_correcta": q.respuesta_correcta
989
+ })
990
+
991
+ return {
992
+ "id": f"torneo-{torneo_id}",
993
+ "titulo": f"Torneo: {torneo.nombre}",
994
+ "pasajes": "Trivia General de la Biblia",
995
+ "versiculos": [],
996
+ "preguntas": preguntas_data,
997
+ "es_desempate": es_desempate
998
+ }
999
+
1000
+ def check_torneo_timeout(torneo_id: int, db: Session):
1001
+ torneo = db.query(Torneo).filter(Torneo.id == torneo_id).first()
1002
+ if not torneo or torneo.estado == "terminado":
1003
+ return
1004
+
1005
+ if torneo.primero_termino_en:
1006
+ try:
1007
+ primero_time = datetime.strptime(torneo.primero_termino_en, "%Y-%m-%d %H:%M:%S")
1008
+ elapsed = datetime.now() - primero_time
1009
+ if elapsed > timedelta(minutes=10):
1010
+ print(f"[Timeout] Torneo {torneo_id} ha expirado. Resolviendo por tiempo...")
1011
+ participantes = db.query(ParticipanteTorneo).filter(
1012
+ ParticipanteTorneo.torneo_id == torneo_id
1013
+ ).all()
1014
+
1015
+ modificado = False
1016
+ for p in participantes:
1017
+ if p.estado == "pendiente":
1018
+ p.estado = "jugado"
1019
+ p.score = 0
1020
+ p.tiempo_empleado = 99999.0
1021
+ modificado = True
1022
+ elif p.estado == "desempate_pendiente":
1023
+ p.estado = "desempate_jugado"
1024
+ p.score_desempate = 0
1025
+ p.tiempo_desempate = 99999.0
1026
+ modificado = True
1027
+
1028
+ torneo.primero_termino_en = None
1029
+ db.commit()
1030
+
1031
+ if modificado:
1032
+ resolver_clasificacion_torneo(torneo_id, db, None)
1033
+ except Exception as e:
1034
+ print(f"[Timeout] Error al procesar timeout del torneo {torneo_id}: {e}")
1035
+
1036
+ @router.post("/torneos/resolver/{torneo_id}")
1037
+ def resolver_clasificacion_torneo(
1038
+ torneo_id: int,
1039
+ db: Session = Depends(get_db),
1040
+ current_user: User = Depends(get_current_user)
1041
+ ):
1042
+ torneo = db.query(Torneo).filter(Torneo.id == torneo_id).first()
1043
+ if not torneo:
1044
+ raise HTTPException(status_code=404, detail="Torneo no encontrado")
1045
+
1046
+ if torneo.estado == "terminado":
1047
+ return {"message": "El torneo ya está terminado", "ganador_id": torneo.ganador_id}
1048
+
1049
+ participantes = db.query(ParticipanteTorneo).filter(
1050
+ ParticipanteTorneo.torneo_id == torneo_id
1051
+ ).all()
1052
+
1053
+ if torneo.estado == "desempate":
1054
+ pendientes = [p for p in participantes if not p.eliminado and p.estado == "desempate_pendiente"]
1055
+ if len(pendientes) > 0:
1056
+ return {"message": "Esperando a que todos los finalistas terminen de jugar el desempate"}
1057
+
1058
+ competidores = [p for p in participantes if not p.eliminado and p.estado == "desempate_jugado"]
1059
+ if len(competidores) < 1:
1060
+ return {"message": "Nadie ha completado la muerte súbita todavía"}
1061
+
1062
+ competidores.sort(key=lambda x: (-x.score_desempate, x.tiempo_desempate))
1063
+ max_score = competidores[0].score_desempate
1064
+ winners = [c for c in competidores if c.score_desempate == max_score]
1065
+
1066
+ if len(winners) == 1 or len(competidores) == 1:
1067
+ campeon = competidores[0]
1068
+ torneo.ganador_id = campeon.user_id
1069
+ torneo.estado = "terminado"
1070
+
1071
+ for p in participantes:
1072
+ if p.user_id != campeon.user_id:
1073
+ p.eliminado = True
1074
+
1075
+ iglesia = get_iglesia_usuario(campeon.user_id, db)
1076
+ if iglesia:
1077
+ liga = db.query(PuntuacionLiga).filter(
1078
+ PuntuacionLiga.user_id == campeon.user_id,
1079
+ PuntuacionLiga.iglesia_id == iglesia.id
1080
+ ).first()
1081
+ if liga:
1082
+ liga.puntos_totales += 100
1083
+
1084
+ db.commit()
1085
+ notificar_participantes_torneo(torneo_id, "torneo_updated", db)
1086
+ return {"message": "Torneo terminado", "ganador_id": torneo.ganador_id, "estado": "terminado"}
1087
+ else:
1088
+ campeon = competidores[0]
1089
+ torneo.ganador_id = campeon.user_id
1090
+ torneo.estado = "terminado"
1091
+ db.commit()
1092
+ notificar_participantes_torneo(torneo_id, "torneo_updated", db)
1093
+ return {"message": "Torneo terminado por tiempo", "ganador_id": torneo.ganador_id, "estado": "terminado"}
1094
+ else:
1095
+ pendientes = [p for p in participantes if p.estado == "pendiente"]
1096
+ if len(pendientes) > 0:
1097
+ return {"message": "Esperando a que todos los participantes terminen de jugar el torneo"}
1098
+
1099
+ jugaron = [p for p in participantes if p.estado == "jugado"]
1100
+ if len(jugaron) < 1:
1101
+ return {"message": "Nadie ha jugado el torneo todavía"}
1102
+
1103
+ max_score = max(p.score for p in jugaron)
1104
+ empatados = [p for p in jugaron if p.score == max_score]
1105
+
1106
+ if len(empatados) == 1:
1107
+ campeon = empatados[0]
1108
+ torneo.ganador_id = campeon.user_id
1109
+ torneo.estado = "terminado"
1110
+
1111
+ iglesia = get_iglesia_usuario(campeon.user_id, db)
1112
+ if iglesia:
1113
+ liga = db.query(PuntuacionLiga).filter(
1114
+ PuntuacionLiga.user_id == campeon.user_id,
1115
+ PuntuacionLiga.iglesia_id == iglesia.id
1116
+ ).first()
1117
+ if liga:
1118
+ liga.puntos_totales += 100
1119
+
1120
+ db.commit()
1121
+ notificar_participantes_torneo(torneo_id, "torneo_updated", db)
1122
+ return {"message": "Torneo resuelto con ganador único", "ganador_id": torneo.ganador_id, "estado": "terminado"}
1123
+ else:
1124
+ empatados_ids = [e.user_id for e in empatados]
1125
+ for p in participantes:
1126
+ if p.user_id not in empatados_ids:
1127
+ p.eliminado = True
1128
+
1129
+ todas = db.query(PreguntaQuiz).all()
1130
+ preguntas_seleccionadas = random.sample(todas, 5)
1131
+ torneo.desempate_preguntas_json = json.dumps([q.id for q in preguntas_seleccionadas])
1132
+ torneo.estado = "desempate"
1133
+
1134
+ for p in participantes:
1135
+ if p.user_id in empatados_ids:
1136
+ p.estado = "desempate_pendiente"
1137
+ p.score_desempate = 0
1138
+ p.tiempo_desempate = 0.0
1139
+
1140
+ db.commit()
1141
+ notificar_participantes_torneo(torneo_id, "torneo_updated", db)
1142
+ return {"message": "Se ha desatado una muerte súbita", "estado": "desempate"}
1143
+
1144
+ @router.get("/preguntas/infinitas")
1145
+ def obtener_preguntas_infinitas(
1146
+ db: Session = Depends(get_db),
1147
+ current_user: User = Depends(get_current_user)
1148
+ ):
1149
+ todas = db.query(PreguntaQuiz).all()
1150
+ if not todas:
1151
+ raise HTTPException(status_code=404, detail="No hay preguntas registradas en el sistema")
1152
+
1153
+ # Barajarlas
1154
+ random.shuffle(todas)
1155
+
1156
+ preguntas_data = []
1157
+ for q in todas:
1158
+ try:
1159
+ opciones = json.loads(q.opciones)
1160
+ except Exception:
1161
+ opciones = q.opciones
1162
+
1163
+ preguntas_data.append({
1164
+ "id": q.id,
1165
+ "tipo": q.tipo,
1166
+ "pregunta": q.pregunta,
1167
+ "opciones": opciones,
1168
+ "respuesta_correcta": q.respuesta_correcta
1169
+ })
1170
+
1171
+ return {
1172
+ "id": "infinito",
1173
+ "titulo": "Práctica de Supervivencia",
1174
+ "pasajes": "Modo Infinito",
1175
+ "versiculos": [],
1176
+ "preguntas": preguntas_data
1177
+ }
1178
+
1179
+ @router.get("/preguntas/desafio-sabios")
1180
+ def obtener_preguntas_desafio_sabios(
1181
+ db: Session = Depends(get_db),
1182
+ current_user: User = Depends(get_current_user)
1183
+ ):
1184
+ plan = db.query(PlanLectura).filter(PlanLectura.nombre == "Desafío de Sabios: Preguntas Avanzadas").first()
1185
+ if not plan:
1186
+ raise HTTPException(status_code=404, detail="El plan Desafío de Sabios no ha sido inicializado en el sistema")
1187
+
1188
+ dias = db.query(DiaLectura).filter(DiaLectura.plan_id == plan.id).all()
1189
+ dias_ids = [d.id for d in dias]
1190
+
1191
+ preguntas = db.query(PreguntaQuiz).filter(PreguntaQuiz.dia_lectura_id.in_(dias_ids)).all()
1192
+ if not preguntas:
1193
+ raise HTTPException(status_code=404, detail="No se encontraron preguntas en el Desafío de Sabios")
1194
+
1195
+ # Barajar para ofrecer rejugabilidad
1196
+ random.shuffle(preguntas)
1197
+
1198
+ preguntas_data = []
1199
+ for q in preguntas:
1200
+ try:
1201
+ opciones = json.loads(q.opciones)
1202
+ except Exception:
1203
+ opciones = q.opciones
1204
+
1205
+ preguntas_data.append({
1206
+ "id": q.id,
1207
+ "tipo": q.tipo,
1208
+ "pregunta": q.pregunta,
1209
+ "opciones": opciones,
1210
+ "respuesta_correcta": q.respuesta_correcta
1211
+ })
1212
+
1213
+ return {
1214
+ "id": "desafio-sabios",
1215
+ "titulo": "Desafío de Sabios",
1216
+ "pasajes": "Preguntas Avanzadas",
1217
+ "versiculos": [],
1218
+ "preguntas": preguntas_data
1219
+ }
1220
+
1221
+
app/routers/auth.py CHANGED
@@ -1,689 +1,689 @@
1
- import logging
2
- from datetime import timedelta, datetime
3
- from typing import Optional
4
- from fastapi import APIRouter, Depends, HTTPException, status, Form, BackgroundTasks
5
- from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
6
- from sqlalchemy.orm import Session
7
- from app.database import get_db
8
- from app.models.user import User, Device, CreditTransaction
9
- from app.schemas.user import UserCreate, UserOut, Token, DeviceRegister, ForgotPasswordRequest, ResetPasswordRequest, ChangePasswordRequest, AvatarUpdate, UsernameUpdate
10
- from passlib.context import CryptContext
11
- from jose import JWTError, jwt
12
- import os
13
- from dotenv import load_dotenv
14
- from app.utils.notifications import create_admin_notification
15
-
16
- # ==========================================
17
- # CONFIGURACIÓN Y LOGGING
18
- # ==========================================
19
-
20
- load_dotenv()
21
-
22
- logger = logging.getLogger(__name__)
23
-
24
- router = APIRouter(prefix="/auth", tags=["auth"])
25
-
26
-
27
- # ==========================================
28
- # HELPER: Encolado proactivo de stems en la nube para nuevo dispositivo
29
- # ==========================================
30
-
31
- def _enqueue_cloud_stems_for_new_device(db: Session, user, new_device_id: str):
32
- """
33
- No-op: La sincronización P2P por cola de dispositivos ha sido eliminada.
34
- """
35
- pass
36
-
37
- # ==========================================
38
- # CONFIGURACIÓN DE SEGURIDAD
39
- # ==========================================
40
-
41
- SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey_change_me_in_production")
42
- ALGORITHM = os.getenv("ALGORITHM", "HS256")
43
- ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "43200"))
44
-
45
- pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
46
- oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
47
-
48
- # ==========================================
49
- # UTILIDADES
50
- # ==========================================
51
-
52
- def verify_password(plain_password, hashed_password):
53
- """Verifica si una contraseña coincide con el hash"""
54
- try:
55
- return pwd_context.verify(plain_password, hashed_password)
56
- except Exception as e:
57
- logger.warning(f"Error verificando password: {e}")
58
- return False
59
-
60
- def get_password_hash(password):
61
- """Genera hash de una contraseña"""
62
- return pwd_context.hash(password)
63
-
64
- def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
65
- """Crea un token JWT con los datos proporcionados"""
66
- to_encode = data.copy()
67
- if expires_delta:
68
- expire = datetime.utcnow() + expires_delta
69
- else:
70
- expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
71
- to_encode.update({"exp": expire})
72
- encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
73
- return encoded_jwt
74
-
75
- SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET", SECRET_KEY)
76
-
77
- async def get_current_user(
78
- token: str = Depends(oauth2_scheme),
79
- db: Session = Depends(get_db)
80
- ):
81
- """Obtiene el usuario actual validando el token JWT de Supabase o propio"""
82
- credentials_exception = HTTPException(
83
- status_code=status.HTTP_401_UNAUTHORIZED,
84
- detail="Could not validate credentials",
85
- headers={"WWW-Authenticate": "Bearer"},
86
- )
87
-
88
- payload = None
89
- try:
90
- # Intentar decodificar con SUPABASE_JWT_SECRET primero (para Google/Supabase login)
91
- payload = jwt.decode(
92
- token,
93
- SUPABASE_JWT_SECRET,
94
- algorithms=[ALGORITHM],
95
- options={"verify_aud": False}
96
- )
97
- except JWTError:
98
- # Si falla, intentar decodificar con SECRET_KEY (para login local email/password)
99
- try:
100
- payload = jwt.decode(
101
- token,
102
- SECRET_KEY,
103
- algorithms=[ALGORITHM],
104
- options={"verify_aud": False}
105
- )
106
- except JWTError as e:
107
- logger.warning(f"Token inválido (ambas firmas fallaron): {e}")
108
- raise credentials_exception
109
-
110
- try:
111
- # En Supabase Auth, el correo del usuario viene en el campo 'email'
112
- email: str = payload.get("email")
113
- device_id: Optional[str] = payload.get("device_id")
114
-
115
- if email is None:
116
- # Fallback para tokens antiguos que usaban username en 'sub'
117
- username: str = payload.get("sub")
118
- if username:
119
- user = db.query(User).filter(User.username == username).first()
120
- if user:
121
- if device_id:
122
- device_exists = db.query(Device).filter(Device.user_id == user.id, Device.device_id == device_id).first()
123
- if not device_exists:
124
- logger.warning(f"Sesión invalidada: Dispositivo {device_id} ya no está registrado para el usuario {user.username}")
125
- raise HTTPException(
126
- status_code=status.HTTP_401_UNAUTHORIZED,
127
- detail="Session invalidated. Logged in from another device.",
128
- headers={"WWW-Authenticate": "Bearer"},
129
- )
130
- return user
131
-
132
- logger.warning("Token inválido: no contiene email")
133
- raise credentials_exception
134
-
135
- except JWTError as e:
136
- logger.warning(f"Token inválido: {e}")
137
- raise credentials_exception
138
-
139
- # Buscar el usuario en nuestra tabla pública usando el email
140
- user = db.query(User).filter(User.email == email).first()
141
- if user is None:
142
- logger.warning(f"Usuario no encontrado: {email}")
143
- raise credentials_exception
144
-
145
- if getattr(user, "is_suspended", False):
146
- logger.warning(f"Intento de acceso de usuario suspendido: {user.username}")
147
- raise HTTPException(
148
- status_code=status.HTTP_403_FORBIDDEN,
149
- detail="user_suspended"
150
- )
151
-
152
- # Validar el dispositivo si el token contiene device_id
153
- if device_id:
154
- device_exists = db.query(Device).filter(Device.user_id == user.id, Device.device_id == device_id).first()
155
- if not device_exists:
156
- logger.warning(f"Sesión invalidada: Dispositivo {device_id} ya no está registrado para el usuario {user.username}")
157
- raise HTTPException(
158
- status_code=status.HTTP_401_UNAUTHORIZED,
159
- detail="Session invalidated. Logged in from another device.",
160
- headers={"WWW-Authenticate": "Bearer"},
161
- )
162
-
163
- return user
164
-
165
- # ==========================================
166
- # ENDPOINTS
167
- # ==========================================
168
-
169
- @router.post("/register", response_model=UserOut)
170
- def register_user(user: UserCreate, db: Session = Depends(get_db)):
171
- """
172
- Registra un nuevo usuario.
173
-
174
- - **username**: Nombre de usuario (único)
175
- - **email**: Correo electrónico (único)
176
- - **password**: Contraseña (se guarda encriptada)
177
- """
178
- logger.info(f"Intento de registro para usuario: {user.username}")
179
-
180
- # Validar formato de username
181
- reg_username = user.username.strip()
182
- if not reg_username:
183
- raise HTTPException(status_code=400, detail="El nombre de usuario no puede estar vacío")
184
- if len(reg_username) < 3 or len(reg_username) > 20:
185
- raise HTTPException(status_code=400, detail="El nombre de usuario debe tener entre 3 y 20 caracteres")
186
- import re
187
- if not re.match(r"^[a-zA-Z0-9.\-\s]+$", reg_username):
188
- raise HTTPException(status_code=400, detail="El nombre de usuario solo puede contener letras, números, espacios, puntos y guiones")
189
-
190
- # Verificar username
191
- db_user = db.query(User).filter(User.username == reg_username).first()
192
- if db_user:
193
- logger.warning(f"Registro fallido: username '{user.username}' ya existe")
194
- raise HTTPException(status_code=400, detail="Username already registered")
195
-
196
- # Verificar email
197
- db_email = db.query(User).filter(User.email == user.email).first()
198
- if db_email:
199
- logger.warning(f"Registro fallido: email '{user.email}' ya existe")
200
- raise HTTPException(status_code=400, detail="Email already registered")
201
-
202
- # Crear usuario
203
- hashed_password = get_password_hash(user.password)
204
- new_user = User(
205
- username=reg_username,
206
- email=user.email,
207
- hashed_password=hashed_password,
208
- credits=10 # Créditos iniciales de prueba
209
- )
210
- db.add(new_user)
211
- db.commit()
212
- db.refresh(new_user)
213
-
214
- # Registrar transacción inicial de créditos en el historial
215
- initial_tx = CreditTransaction(
216
- user_id=new_user.id,
217
- amount=10,
218
- concept="inicial",
219
- reference_id=None,
220
- created_at=datetime.utcnow()
221
- )
222
- db.add(initial_tx)
223
- db.commit()
224
-
225
- logger.info(f"Usuario registrado exitosamente: {user.username} (ID: {new_user.id})")
226
- try:
227
- create_admin_notification(
228
- db,
229
- "Nueva Cuenta Creada",
230
- f"El usuario '{new_user.username}' ({new_user.email}) se ha registrado."
231
- )
232
- except Exception as e:
233
- logger.warning(f"Error al registrar notificación para el nuevo usuario: {e}")
234
- return new_user
235
-
236
-
237
- @router.post("/token", response_model=Token)
238
- async def login_for_access_token(
239
- form_data: OAuth2PasswordRequestForm = Depends(),
240
- device_id: Optional[str] = Form(None),
241
- device_name: Optional[str] = Form(None),
242
- confirm_override: bool = Form(False),
243
- db: Session = Depends(get_db)
244
- ):
245
- """
246
- Inicia sesión y obtiene un token de acceso.
247
-
248
- - **username**: Nombre de usuario
249
- - **password**: Contraseña
250
- - **device_id**: Identificador único del dispositivo (opcional)
251
- - **device_name**: Nombre amigable del dispositivo (opcional)
252
- """
253
- logger.info(f"Intento de login para usuario: {form_data.username} desde dispositivo: {device_id}")
254
-
255
- # Verificar usuario usando el email (aunque el campo del formulario OAuth2 se llame 'username')
256
- user = db.query(User).filter(User.email == form_data.username).first()
257
-
258
- if not user:
259
- logger.warning(f"Login fallido: usuario con email '{form_data.username}' no existe")
260
- raise HTTPException(
261
- status_code=status.HTTP_401_UNAUTHORIZED,
262
- detail="Incorrect email or password",
263
- headers={"WWW-Authenticate": "Bearer"},
264
- )
265
-
266
- if getattr(user, "is_suspended", False):
267
- logger.warning(f"Login fallido: usuario suspendido email '{form_data.username}'")
268
- raise HTTPException(
269
- status_code=status.HTTP_403_FORBIDDEN,
270
- detail="Cuenta suspendida. Para más información contacta a soporte."
271
- )
272
-
273
- if not verify_password(form_data.password, user.hashed_password):
274
- logger.warning(f"Login fallido: contraseña incorrecta para email '{form_data.username}'")
275
- raise HTTPException(
276
- status_code=status.HTTP_401_UNAUTHORIZED,
277
- detail="Incorrect email or password",
278
- headers={"WWW-Authenticate": "Bearer"},
279
- )
280
-
281
- # ==========================================
282
- # GESTIÓN DE DISPOSITIVOS
283
- # ==========================================
284
- if device_id and device_id != "admin_dashboard":
285
- device = next((d for d in user.devices if d.device_id == device_id), None)
286
-
287
- if device:
288
- # Actualizar última actividad
289
- device.last_active = datetime.utcnow()
290
- logger.debug(f"Dispositivo conocido: {device.name}")
291
- db.commit()
292
- else:
293
- # Verificar límite de dispositivos
294
- max_devices = 3 if user.is_premium else 1
295
- if len(user.devices) >= max_devices:
296
- if not confirm_override:
297
- logger.warning(f"Login fallido: límite de dispositivos alcanzado para {form_data.username}")
298
- raise HTTPException(
299
- status_code=403,
300
- detail="device_override_required"
301
- )
302
- else:
303
- # El usuario confirma anulación:
304
- if user.is_premium:
305
- # Para premium, eliminamos el dispositivo más antiguo para liberar un espacio
306
- oldest_device = db.query(Device).filter(Device.user_id == user.id).order_by(Device.last_active.asc()).first()
307
- if oldest_device:
308
- logger.info(f"Eliminando dispositivo antiguo {oldest_device.name} para usuario premium {user.username}")
309
- db.delete(oldest_device)
310
- else:
311
- # Para no premium, eliminamos todos
312
- db.query(Device).filter(Device.user_id == user.id).delete()
313
- db.commit()
314
- # Recargar relaciones
315
- db.refresh(user)
316
- logger.info(f"Anulando sesión anterior para {user.username} (confirm_override=True)")
317
-
318
- # Registrar nuevo dispositivo
319
- new_device = Device(
320
- user_id=user.id,
321
- device_id=device_id,
322
- name=device_name or "Desconocido"
323
- )
324
- db.add(new_device)
325
- logger.info(f"Nuevo dispositivo registrado: {new_device.name} ({device_id})")
326
- db.commit()
327
- # Encolar proactivamente stems en la nube para descarga automática en el nuevo dispositivo
328
- _enqueue_cloud_stems_for_new_device(db, user, device_id)
329
-
330
- # ==========================================
331
- # GENERAR TOKEN
332
- # ==========================================
333
- access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
334
- token_payload = {"sub": user.username, "email": user.email}
335
- if device_id and device_id != "admin_dashboard":
336
- token_payload["device_id"] = device_id
337
-
338
- access_token = create_access_token(
339
- data=token_payload, expires_delta=access_token_expires
340
- )
341
-
342
- logger.info(f"Login exitoso: {user.email}")
343
-
344
- return {
345
- "access_token": access_token,
346
- "token_type": "bearer",
347
- "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
348
- "user": {
349
- "id": user.id,
350
- "username": user.username,
351
- "credits": user.credits,
352
- "is_premium": user.is_premium,
353
- "avatar_url": user.avatar_url,
354
- "username_changes_this_month": user.username_changes_this_month,
355
- "last_username_change_at": user.last_username_change_at,
356
- "is_forum_banned": user.is_forum_banned
357
- }
358
- }
359
-
360
-
361
- @router.get("/users/me", response_model=UserOut)
362
- async def read_users_me(
363
- db: Session = Depends(get_db),
364
- current_user: User = Depends(get_current_user)
365
- ):
366
- """Obtiene la información del usuario autenticado"""
367
- logger.debug(f"Usuario {current_user.username} consultando perfil")
368
-
369
- now = datetime.utcnow()
370
- if current_user.last_username_change_at:
371
- last_change = current_user.last_username_change_at
372
- if (now.year > last_change.year) or (now.year == last_change.year and now.month > last_change.month):
373
- current_user.username_changes_this_month = 0
374
- db.commit()
375
-
376
- return current_user
377
-
378
-
379
- @router.post("/logout")
380
- async def logout(
381
- device_id: str = Form(...),
382
- db: Session = Depends(get_db),
383
- current_user: User = Depends(get_current_user)
384
- ):
385
- """
386
- Cierra sesión eliminando el dispositivo registrado.
387
- Útil cuando el usuario quiere revocar acceso a un dispositivo.
388
- """
389
- logger.info(f"Logout solicitado por {current_user.username} para dispositivo: {device_id}")
390
-
391
- device = next((d for d in current_user.devices if d.device_id == device_id), None)
392
-
393
- if device:
394
- db.delete(device)
395
- db.commit()
396
- logger.info(f"Dispositivo {device_id} eliminado para usuario {current_user.username}")
397
- return {"message": "Logout exitoso. Dispositivo eliminado."}
398
- else:
399
- logger.warning(f"Dispositivo {device_id} no encontrado para {current_user.username}")
400
- raise HTTPException(status_code=404, detail="Device not found")
401
-
402
-
403
- @router.get("/users/devices")
404
- async def list_devices(
405
- db: Session = Depends(get_db),
406
- current_user: User = Depends(get_current_user)
407
- ):
408
- """Lista todos los dispositivos registrados del usuario"""
409
- logger.info(f"Usuario {current_user.username} consultando dispositivos")
410
-
411
- devices = [
412
- {
413
- "id": d.id,
414
- "device_id": d.device_id,
415
- "name": d.name,
416
- "registered_at": d.registered_at.isoformat() if d.registered_at else None,
417
- "last_active": d.last_active.isoformat() if d.last_active else None
418
- }
419
- for d in current_user.devices
420
- ]
421
-
422
- return {
423
- "total": len(devices),
424
- "devices": devices
425
- }
426
-
427
- @router.post("/register-device")
428
- async def register_device(
429
- device_data: DeviceRegister,
430
- db: Session = Depends(get_db),
431
- current_user: User = Depends(get_current_user)
432
- ):
433
- """
434
- Registra un dispositivo en segundo plano para un usuario ya autenticado (por ejemplo, con Google).
435
- """
436
- logger.info(f"Registro de dispositivo solicitado por {current_user.username} (ID: {current_user.id})")
437
-
438
- device = next((d for d in current_user.devices if d.device_id == device_data.device_id), None)
439
-
440
- if device:
441
- device.last_active = datetime.utcnow()
442
- logger.debug(f"Dispositivo conocido: {device.name}")
443
- db.commit()
444
- else:
445
- # Verificar límite de dispositivos
446
- max_devices = 3 if current_user.is_premium else 1
447
- if len(current_user.devices) >= max_devices:
448
- if not device_data.confirm_override:
449
- logger.warning(f"Límite de dispositivos alcanzado para {current_user.username}")
450
- raise HTTPException(
451
- status_code=403,
452
- detail="device_override_required"
453
- )
454
- else:
455
- if current_user.is_premium:
456
- # Para premium, eliminamos el dispositivo más antiguo para liberar un espacio
457
- oldest_device = db.query(Device).filter(Device.user_id == current_user.id).order_by(Device.last_active.asc()).first()
458
- if oldest_device:
459
- logger.info(f"Eliminando dispositivo antiguo {oldest_device.name} para usuario premium {current_user.username}")
460
- db.delete(oldest_device)
461
- else:
462
- # Para no premium, eliminamos todos
463
- db.query(Device).filter(Device.user_id == current_user.id).delete()
464
- db.commit()
465
- # Recargar relaciones
466
- db.refresh(current_user)
467
- logger.info(f"Anulando sesión anterior para {current_user.username} en registro de dispositivo")
468
-
469
- new_device = Device(
470
- user_id=current_user.id,
471
- device_id=device_data.device_id,
472
- name=device_data.device_name
473
- )
474
- db.add(new_device)
475
- logger.info(f"Nuevo dispositivo registrado: {device_data.device_name} ({device_data.device_id})")
476
- db.commit()
477
- # Encolar proactivamente stems en la nube para descarga automática en el nuevo dispositivo
478
- _enqueue_cloud_stems_for_new_device(db, current_user, device_data.device_id)
479
- return {"message": "Dispositivo registrado exitosamente"}
480
-
481
-
482
- @router.post("/forgot-password")
483
- def forgot_password(
484
- data: ForgotPasswordRequest,
485
- background_tasks: BackgroundTasks,
486
- db: Session = Depends(get_db)
487
- ):
488
- import random
489
- user = db.query(User).filter(User.email == data.email).first()
490
- if not user:
491
- raise HTTPException(
492
- status_code=404,
493
- detail="No se encontró ninguna cuenta registrada con ese correo electrónico"
494
- )
495
-
496
- code = f"{random.randint(100000, 999999)}"
497
- user.reset_token = code
498
- user.reset_token_expires = datetime.utcnow() + timedelta(minutes=15)
499
- db.commit()
500
-
501
- from app.utils.email import send_recovery_email
502
- background_tasks.add_task(send_recovery_email, user.email, code)
503
-
504
- return {"message": "Código de verificación enviado exitosamente a tu correo electrónico"}
505
-
506
-
507
- @router.post("/reset-password")
508
- def reset_password(
509
- data: ResetPasswordRequest,
510
- db: Session = Depends(get_db)
511
- ):
512
- user = db.query(User).filter(User.email == data.email).first()
513
- if not user:
514
- raise HTTPException(status_code=404, detail="Usuario no encontrado")
515
-
516
- if not user.reset_token or user.reset_token != data.code:
517
- raise HTTPException(status_code=400, detail="Código de verificación incorrecto")
518
-
519
- if not user.reset_token_expires or user.reset_token_expires < datetime.utcnow():
520
- raise HTTPException(status_code=400, detail="El código de verificación ha expirado")
521
-
522
- user.hashed_password = get_password_hash(data.password)
523
- user.reset_token = None
524
- user.reset_token_expires = None
525
- db.commit()
526
-
527
- return {"message": "Contraseña restablecida exitosamente"}
528
-
529
-
530
- @router.post("/change-password")
531
- def change_user_password(
532
- data: ChangePasswordRequest,
533
- db: Session = Depends(get_db),
534
- current_user: User = Depends(get_current_user)
535
- ):
536
- if not current_user.hashed_password:
537
- raise HTTPException(
538
- status_code=400,
539
- detail="Esta cuenta está vinculada a un proveedor externo (Google/Supabase) y no tiene una contraseña local"
540
- )
541
-
542
- if not verify_password(data.old_password, current_user.hashed_password):
543
- raise HTTPException(status_code=400, detail="La contraseña actual es incorrecta")
544
-
545
- current_user.hashed_password = get_password_hash(data.new_password)
546
- db.commit()
547
-
548
- return {"message": "Contraseña actualizada exitosamente"}
549
-
550
-
551
- @router.put("/users/me/avatar")
552
- def update_user_avatar(
553
- data: AvatarUpdate,
554
- db: Session = Depends(get_db),
555
- current_user: User = Depends(get_current_user)
556
- ):
557
- current_user.avatar_url = data.avatar_url
558
- db.commit()
559
- return {"avatar_url": current_user.avatar_url}
560
-
561
-
562
- @router.put("/users/me/username")
563
- def update_username(
564
- data: UsernameUpdate,
565
- db: Session = Depends(get_db),
566
- current_user: User = Depends(get_current_user)
567
- ):
568
- new_username = data.username.strip()
569
- if not new_username:
570
- raise HTTPException(status_code=400, detail="El nombre de usuario no puede estar vacío")
571
-
572
- if len(new_username) < 3 or len(new_username) > 20:
573
- raise HTTPException(status_code=400, detail="El nombre de usuario debe tener entre 3 y 20 caracteres")
574
-
575
- import re
576
- if not re.match(r"^[a-zA-Z0-9.\-\s]+$", new_username):
577
- raise HTTPException(status_code=400, detail="El nombre de usuario solo puede contener letras, números, espacios, puntos y guiones")
578
-
579
- # Excluir caracteres especiales raros si es necesario, pero validemos similitud básica
580
- if new_username == current_user.username:
581
- return {
582
- "username": current_user.username,
583
- "username_changes_this_month": current_user.username_changes_this_month,
584
- "message": "El nombre de usuario es idéntico"
585
- }
586
-
587
- # Reset del contador si es un nuevo mes
588
- now = datetime.utcnow()
589
- if current_user.last_username_change_at:
590
- last_change = current_user.last_username_change_at
591
- if (now.year > last_change.year) or (now.year == last_change.year and now.month > last_change.month):
592
- current_user.username_changes_this_month = 0
593
- db.commit()
594
-
595
- # Verificar límite
596
- if current_user.username_changes_this_month >= 3:
597
- raise HTTPException(
598
- status_code=status.HTTP_400_BAD_REQUEST,
599
- detail="Has alcanzado el límite de 3 cambios de nombre de usuario este mes. Podrás cambiarlo nuevamente el 1 del próximo mes."
600
- )
601
-
602
- # Verificar si ya existe
603
- exists = db.query(User).filter(User.username == new_username).first()
604
- if exists:
605
- raise HTTPException(
606
- status_code=status.HTTP_400_BAD_REQUEST,
607
- detail="El nombre de usuario ya está registrado por otra cuenta"
608
- )
609
-
610
- # Proceder a actualizar
611
- current_user.username = new_username
612
- current_user.username_changes_this_month += 1
613
- current_user.last_username_change_at = now
614
- db.commit()
615
-
616
- # Notificar al usuario por WebSocket en tiempo real en todos sus dispositivos
617
- try:
618
- from app.websocket_manager import send_sync_message
619
- send_sync_message({
620
- "type": "username_updated",
621
- "username": new_username
622
- }, current_user.id)
623
- except Exception:
624
- pass
625
-
626
- logger.info(f"Usuario {current_user.id} cambió su username a: {new_username}. Cambios este mes: {current_user.username_changes_this_month}/3")
627
-
628
- return {
629
- "username": current_user.username,
630
- "username_changes_this_month": current_user.username_changes_this_month,
631
- "last_username_change_at": current_user.last_username_change_at
632
- }
633
-
634
-
635
-
636
- @router.get("/test-smtp")
637
- def test_smtp(to: str):
638
- """
639
- Endpoint de diagnóstico para probar el envío de correos SMTP y ver el error de configuración o conexión.
640
- """
641
- from app.utils.email import send_recovery_email
642
- import os
643
-
644
- # Obtener valores para mostrarlos (con password censurado)
645
- smtp_host = os.getenv("SMTP_HOST")
646
- smtp_port = os.getenv("SMTP_PORT")
647
- smtp_user = os.getenv("SMTP_USER")
648
- smtp_password = os.getenv("SMTP_PASSWORD")
649
- smtp_from = os.getenv("SMTP_FROM")
650
-
651
- # Limpiar comillas iniciales/finales y espacios
652
- if smtp_host: smtp_host = smtp_host.strip("'\" ")
653
- if smtp_port: smtp_port = smtp_port.strip("'\" ")
654
- if smtp_user: smtp_user = smtp_user.strip("'\" ")
655
- if smtp_password: smtp_password = smtp_password.strip("'\" ")
656
- if smtp_from: smtp_from = smtp_from.strip("'\" ")
657
-
658
- config_info = {
659
- "SMTP_HOST": smtp_host,
660
- "SMTP_PORT": smtp_port,
661
- "SMTP_USER": smtp_user,
662
- "SMTP_FROM": smtp_from or f"Melodix <{smtp_user}>",
663
- "SMTP_PASSWORD_SET": smtp_password is not None and len(smtp_password) > 0
664
- }
665
-
666
- # Intentar enviar
667
- try:
668
- success = send_recovery_email(to, "999999", raise_on_error=True)
669
- if success:
670
- return {
671
- "status": "success",
672
- "message": f"Correo de prueba enviado con éxito a {to}",
673
- "config": config_info
674
- }
675
- else:
676
- return {
677
- "status": "failed",
678
- "message": "El método send_recovery_email retornó False. Revisa los logs de tu servidor en Render para ver el error exacto.",
679
- "config": config_info
680
- }
681
- except Exception as e:
682
- import traceback
683
- error_trace = traceback.format_exc()
684
- return {
685
- "status": "error",
686
- "message": str(e),
687
- "traceback": error_trace,
688
- "config": config_info
689
- }
 
1
+ import logging
2
+ from datetime import timedelta, datetime
3
+ from typing import Optional
4
+ from fastapi import APIRouter, Depends, HTTPException, status, Form, BackgroundTasks
5
+ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
6
+ from sqlalchemy.orm import Session
7
+ from app.database import get_db
8
+ from app.models.user import User, Device, CreditTransaction
9
+ from app.schemas.user import UserCreate, UserOut, Token, DeviceRegister, ForgotPasswordRequest, ResetPasswordRequest, ChangePasswordRequest, AvatarUpdate, UsernameUpdate
10
+ from passlib.context import CryptContext
11
+ from jose import JWTError, jwt
12
+ import os
13
+ from dotenv import load_dotenv
14
+ from app.utils.notifications import create_admin_notification
15
+
16
+ # ==========================================
17
+ # CONFIGURACIÓN Y LOGGING
18
+ # ==========================================
19
+
20
+ load_dotenv()
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ router = APIRouter(prefix="/auth", tags=["auth"])
25
+
26
+
27
+ # ==========================================
28
+ # HELPER: Encolado proactivo de stems en la nube para nuevo dispositivo
29
+ # ==========================================
30
+
31
+ def _enqueue_cloud_stems_for_new_device(db: Session, user, new_device_id: str):
32
+ """
33
+ No-op: La sincronización P2P por cola de dispositivos ha sido eliminada.
34
+ """
35
+ pass
36
+
37
+ # ==========================================
38
+ # CONFIGURACIÓN DE SEGURIDAD
39
+ # ==========================================
40
+
41
+ SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey_change_me_in_production")
42
+ ALGORITHM = os.getenv("ALGORITHM", "HS256")
43
+ ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "43200"))
44
+
45
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
46
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
47
+
48
+ # ==========================================
49
+ # UTILIDADES
50
+ # ==========================================
51
+
52
+ def verify_password(plain_password, hashed_password):
53
+ """Verifica si una contraseña coincide con el hash"""
54
+ try:
55
+ return pwd_context.verify(plain_password, hashed_password)
56
+ except Exception as e:
57
+ logger.warning(f"Error verificando password: {e}")
58
+ return False
59
+
60
+ def get_password_hash(password):
61
+ """Genera hash de una contraseña"""
62
+ return pwd_context.hash(password)
63
+
64
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
65
+ """Crea un token JWT con los datos proporcionados"""
66
+ to_encode = data.copy()
67
+ if expires_delta:
68
+ expire = datetime.utcnow() + expires_delta
69
+ else:
70
+ expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
71
+ to_encode.update({"exp": expire})
72
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
73
+ return encoded_jwt
74
+
75
+ SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET", SECRET_KEY)
76
+
77
+ async def get_current_user(
78
+ token: str = Depends(oauth2_scheme),
79
+ db: Session = Depends(get_db)
80
+ ):
81
+ """Obtiene el usuario actual validando el token JWT de Supabase o propio"""
82
+ credentials_exception = HTTPException(
83
+ status_code=status.HTTP_401_UNAUTHORIZED,
84
+ detail="Could not validate credentials",
85
+ headers={"WWW-Authenticate": "Bearer"},
86
+ )
87
+
88
+ payload = None
89
+ try:
90
+ # Intentar decodificar con SUPABASE_JWT_SECRET primero (para Google/Supabase login)
91
+ payload = jwt.decode(
92
+ token,
93
+ SUPABASE_JWT_SECRET,
94
+ algorithms=[ALGORITHM],
95
+ options={"verify_aud": False}
96
+ )
97
+ except JWTError:
98
+ # Si falla, intentar decodificar con SECRET_KEY (para login local email/password)
99
+ try:
100
+ payload = jwt.decode(
101
+ token,
102
+ SECRET_KEY,
103
+ algorithms=[ALGORITHM],
104
+ options={"verify_aud": False}
105
+ )
106
+ except JWTError as e:
107
+ logger.warning(f"Token inválido (ambas firmas fallaron): {e}")
108
+ raise credentials_exception
109
+
110
+ try:
111
+ # En Supabase Auth, el correo del usuario viene en el campo 'email'
112
+ email: str = payload.get("email")
113
+ device_id: Optional[str] = payload.get("device_id")
114
+
115
+ if email is None:
116
+ # Fallback para tokens antiguos que usaban username en 'sub'
117
+ username: str = payload.get("sub")
118
+ if username:
119
+ user = db.query(User).filter(User.username == username).first()
120
+ if user:
121
+ if device_id:
122
+ device_exists = db.query(Device).filter(Device.user_id == user.id, Device.device_id == device_id).first()
123
+ if not device_exists:
124
+ logger.warning(f"Sesión invalidada: Dispositivo {device_id} ya no está registrado para el usuario {user.username}")
125
+ raise HTTPException(
126
+ status_code=status.HTTP_401_UNAUTHORIZED,
127
+ detail="Session invalidated. Logged in from another device.",
128
+ headers={"WWW-Authenticate": "Bearer"},
129
+ )
130
+ return user
131
+
132
+ logger.warning("Token inválido: no contiene email")
133
+ raise credentials_exception
134
+
135
+ except JWTError as e:
136
+ logger.warning(f"Token inválido: {e}")
137
+ raise credentials_exception
138
+
139
+ # Buscar el usuario en nuestra tabla pública usando el email
140
+ user = db.query(User).filter(User.email == email).first()
141
+ if user is None:
142
+ logger.warning(f"Usuario no encontrado: {email}")
143
+ raise credentials_exception
144
+
145
+ if getattr(user, "is_suspended", False):
146
+ logger.warning(f"Intento de acceso de usuario suspendido: {user.username}")
147
+ raise HTTPException(
148
+ status_code=status.HTTP_403_FORBIDDEN,
149
+ detail="user_suspended"
150
+ )
151
+
152
+ # Validar el dispositivo si el token contiene device_id
153
+ if device_id:
154
+ device_exists = db.query(Device).filter(Device.user_id == user.id, Device.device_id == device_id).first()
155
+ if not device_exists:
156
+ logger.warning(f"Sesión invalidada: Dispositivo {device_id} ya no está registrado para el usuario {user.username}")
157
+ raise HTTPException(
158
+ status_code=status.HTTP_401_UNAUTHORIZED,
159
+ detail="Session invalidated. Logged in from another device.",
160
+ headers={"WWW-Authenticate": "Bearer"},
161
+ )
162
+
163
+ return user
164
+
165
+ # ==========================================
166
+ # ENDPOINTS
167
+ # ==========================================
168
+
169
+ @router.post("/register", response_model=UserOut)
170
+ def register_user(user: UserCreate, db: Session = Depends(get_db)):
171
+ """
172
+ Registra un nuevo usuario.
173
+
174
+ - **username**: Nombre de usuario (único)
175
+ - **email**: Correo electrónico (único)
176
+ - **password**: Contraseña (se guarda encriptada)
177
+ """
178
+ logger.info(f"Intento de registro para usuario: {user.username}")
179
+
180
+ # Validar formato de username
181
+ reg_username = user.username.strip()
182
+ if not reg_username:
183
+ raise HTTPException(status_code=400, detail="El nombre de usuario no puede estar vacío")
184
+ if len(reg_username) < 3 or len(reg_username) > 20:
185
+ raise HTTPException(status_code=400, detail="El nombre de usuario debe tener entre 3 y 20 caracteres")
186
+ import re
187
+ if not re.match(r"^[a-zA-Z0-9.\-\s]+$", reg_username):
188
+ raise HTTPException(status_code=400, detail="El nombre de usuario solo puede contener letras, números, espacios, puntos y guiones")
189
+
190
+ # Verificar username
191
+ db_user = db.query(User).filter(User.username == reg_username).first()
192
+ if db_user:
193
+ logger.warning(f"Registro fallido: username '{user.username}' ya existe")
194
+ raise HTTPException(status_code=400, detail="Username already registered")
195
+
196
+ # Verificar email
197
+ db_email = db.query(User).filter(User.email == user.email).first()
198
+ if db_email:
199
+ logger.warning(f"Registro fallido: email '{user.email}' ya existe")
200
+ raise HTTPException(status_code=400, detail="Email already registered")
201
+
202
+ # Crear usuario
203
+ hashed_password = get_password_hash(user.password)
204
+ new_user = User(
205
+ username=reg_username,
206
+ email=user.email,
207
+ hashed_password=hashed_password,
208
+ credits=10 # Créditos iniciales de prueba
209
+ )
210
+ db.add(new_user)
211
+ db.commit()
212
+ db.refresh(new_user)
213
+
214
+ # Registrar transacción inicial de créditos en el historial
215
+ initial_tx = CreditTransaction(
216
+ user_id=new_user.id,
217
+ amount=10,
218
+ concept="inicial",
219
+ reference_id=None,
220
+ created_at=datetime.utcnow()
221
+ )
222
+ db.add(initial_tx)
223
+ db.commit()
224
+
225
+ logger.info(f"Usuario registrado exitosamente: {user.username} (ID: {new_user.id})")
226
+ try:
227
+ create_admin_notification(
228
+ db,
229
+ "Nueva Cuenta Creada",
230
+ f"El usuario '{new_user.username}' ({new_user.email}) se ha registrado."
231
+ )
232
+ except Exception as e:
233
+ logger.warning(f"Error al registrar notificación para el nuevo usuario: {e}")
234
+ return new_user
235
+
236
+
237
+ @router.post("/token", response_model=Token)
238
+ async def login_for_access_token(
239
+ form_data: OAuth2PasswordRequestForm = Depends(),
240
+ device_id: Optional[str] = Form(None),
241
+ device_name: Optional[str] = Form(None),
242
+ confirm_override: bool = Form(False),
243
+ db: Session = Depends(get_db)
244
+ ):
245
+ """
246
+ Inicia sesión y obtiene un token de acceso.
247
+
248
+ - **username**: Nombre de usuario
249
+ - **password**: Contraseña
250
+ - **device_id**: Identificador único del dispositivo (opcional)
251
+ - **device_name**: Nombre amigable del dispositivo (opcional)
252
+ """
253
+ logger.info(f"Intento de login para usuario: {form_data.username} desde dispositivo: {device_id}")
254
+
255
+ # Verificar usuario usando el email (aunque el campo del formulario OAuth2 se llame 'username')
256
+ user = db.query(User).filter(User.email == form_data.username).first()
257
+
258
+ if not user:
259
+ logger.warning(f"Login fallido: usuario con email '{form_data.username}' no existe")
260
+ raise HTTPException(
261
+ status_code=status.HTTP_401_UNAUTHORIZED,
262
+ detail="Incorrect email or password",
263
+ headers={"WWW-Authenticate": "Bearer"},
264
+ )
265
+
266
+ if getattr(user, "is_suspended", False):
267
+ logger.warning(f"Login fallido: usuario suspendido email '{form_data.username}'")
268
+ raise HTTPException(
269
+ status_code=status.HTTP_403_FORBIDDEN,
270
+ detail="Cuenta suspendida. Para más información contacta a soporte."
271
+ )
272
+
273
+ if not verify_password(form_data.password, user.hashed_password):
274
+ logger.warning(f"Login fallido: contraseña incorrecta para email '{form_data.username}'")
275
+ raise HTTPException(
276
+ status_code=status.HTTP_401_UNAUTHORIZED,
277
+ detail="Incorrect email or password",
278
+ headers={"WWW-Authenticate": "Bearer"},
279
+ )
280
+
281
+ # ==========================================
282
+ # GESTIÓN DE DISPOSITIVOS
283
+ # ==========================================
284
+ if device_id and device_id != "admin_dashboard":
285
+ device = next((d for d in user.devices if d.device_id == device_id), None)
286
+
287
+ if device:
288
+ # Actualizar última actividad
289
+ device.last_active = datetime.utcnow()
290
+ logger.debug(f"Dispositivo conocido: {device.name}")
291
+ db.commit()
292
+ else:
293
+ # Verificar límite de dispositivos
294
+ max_devices = 3 if user.is_premium else 1
295
+ if len(user.devices) >= max_devices:
296
+ if not confirm_override:
297
+ logger.warning(f"Login fallido: límite de dispositivos alcanzado para {form_data.username}")
298
+ raise HTTPException(
299
+ status_code=403,
300
+ detail="device_override_required"
301
+ )
302
+ else:
303
+ # El usuario confirma anulación:
304
+ if user.is_premium:
305
+ # Para premium, eliminamos el dispositivo más antiguo para liberar un espacio
306
+ oldest_device = db.query(Device).filter(Device.user_id == user.id).order_by(Device.last_active.asc()).first()
307
+ if oldest_device:
308
+ logger.info(f"Eliminando dispositivo antiguo {oldest_device.name} para usuario premium {user.username}")
309
+ db.delete(oldest_device)
310
+ else:
311
+ # Para no premium, eliminamos todos
312
+ db.query(Device).filter(Device.user_id == user.id).delete()
313
+ db.commit()
314
+ # Recargar relaciones
315
+ db.refresh(user)
316
+ logger.info(f"Anulando sesión anterior para {user.username} (confirm_override=True)")
317
+
318
+ # Registrar nuevo dispositivo
319
+ new_device = Device(
320
+ user_id=user.id,
321
+ device_id=device_id,
322
+ name=device_name or "Desconocido"
323
+ )
324
+ db.add(new_device)
325
+ logger.info(f"Nuevo dispositivo registrado: {new_device.name} ({device_id})")
326
+ db.commit()
327
+ # Encolar proactivamente stems en la nube para descarga automática en el nuevo dispositivo
328
+ _enqueue_cloud_stems_for_new_device(db, user, device_id)
329
+
330
+ # ==========================================
331
+ # GENERAR TOKEN
332
+ # ==========================================
333
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
334
+ token_payload = {"sub": user.username, "email": user.email}
335
+ if device_id and device_id != "admin_dashboard":
336
+ token_payload["device_id"] = device_id
337
+
338
+ access_token = create_access_token(
339
+ data=token_payload, expires_delta=access_token_expires
340
+ )
341
+
342
+ logger.info(f"Login exitoso: {user.email}")
343
+
344
+ return {
345
+ "access_token": access_token,
346
+ "token_type": "bearer",
347
+ "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
348
+ "user": {
349
+ "id": user.id,
350
+ "username": user.username,
351
+ "credits": user.credits,
352
+ "is_premium": user.is_premium,
353
+ "avatar_url": user.avatar_url,
354
+ "username_changes_this_month": user.username_changes_this_month,
355
+ "last_username_change_at": user.last_username_change_at,
356
+ "is_forum_banned": user.is_forum_banned
357
+ }
358
+ }
359
+
360
+
361
+ @router.get("/users/me", response_model=UserOut)
362
+ async def read_users_me(
363
+ db: Session = Depends(get_db),
364
+ current_user: User = Depends(get_current_user)
365
+ ):
366
+ """Obtiene la información del usuario autenticado"""
367
+ logger.debug(f"Usuario {current_user.username} consultando perfil")
368
+
369
+ now = datetime.utcnow()
370
+ if current_user.last_username_change_at:
371
+ last_change = current_user.last_username_change_at
372
+ if (now.year > last_change.year) or (now.year == last_change.year and now.month > last_change.month):
373
+ current_user.username_changes_this_month = 0
374
+ db.commit()
375
+
376
+ return current_user
377
+
378
+
379
+ @router.post("/logout")
380
+ async def logout(
381
+ device_id: str = Form(...),
382
+ db: Session = Depends(get_db),
383
+ current_user: User = Depends(get_current_user)
384
+ ):
385
+ """
386
+ Cierra sesión eliminando el dispositivo registrado.
387
+ Útil cuando el usuario quiere revocar acceso a un dispositivo.
388
+ """
389
+ logger.info(f"Logout solicitado por {current_user.username} para dispositivo: {device_id}")
390
+
391
+ device = next((d for d in current_user.devices if d.device_id == device_id), None)
392
+
393
+ if device:
394
+ db.delete(device)
395
+ db.commit()
396
+ logger.info(f"Dispositivo {device_id} eliminado para usuario {current_user.username}")
397
+ return {"message": "Logout exitoso. Dispositivo eliminado."}
398
+ else:
399
+ logger.warning(f"Dispositivo {device_id} no encontrado para {current_user.username}")
400
+ raise HTTPException(status_code=404, detail="Device not found")
401
+
402
+
403
+ @router.get("/users/devices")
404
+ async def list_devices(
405
+ db: Session = Depends(get_db),
406
+ current_user: User = Depends(get_current_user)
407
+ ):
408
+ """Lista todos los dispositivos registrados del usuario"""
409
+ logger.info(f"Usuario {current_user.username} consultando dispositivos")
410
+
411
+ devices = [
412
+ {
413
+ "id": d.id,
414
+ "device_id": d.device_id,
415
+ "name": d.name,
416
+ "registered_at": d.registered_at.isoformat() if d.registered_at else None,
417
+ "last_active": d.last_active.isoformat() if d.last_active else None
418
+ }
419
+ for d in current_user.devices
420
+ ]
421
+
422
+ return {
423
+ "total": len(devices),
424
+ "devices": devices
425
+ }
426
+
427
+ @router.post("/register-device")
428
+ async def register_device(
429
+ device_data: DeviceRegister,
430
+ db: Session = Depends(get_db),
431
+ current_user: User = Depends(get_current_user)
432
+ ):
433
+ """
434
+ Registra un dispositivo en segundo plano para un usuario ya autenticado (por ejemplo, con Google).
435
+ """
436
+ logger.info(f"Registro de dispositivo solicitado por {current_user.username} (ID: {current_user.id})")
437
+
438
+ device = next((d for d in current_user.devices if d.device_id == device_data.device_id), None)
439
+
440
+ if device:
441
+ device.last_active = datetime.utcnow()
442
+ logger.debug(f"Dispositivo conocido: {device.name}")
443
+ db.commit()
444
+ else:
445
+ # Verificar límite de dispositivos
446
+ max_devices = 3 if current_user.is_premium else 1
447
+ if len(current_user.devices) >= max_devices:
448
+ if not device_data.confirm_override:
449
+ logger.warning(f"Límite de dispositivos alcanzado para {current_user.username}")
450
+ raise HTTPException(
451
+ status_code=403,
452
+ detail="device_override_required"
453
+ )
454
+ else:
455
+ if current_user.is_premium:
456
+ # Para premium, eliminamos el dispositivo más antiguo para liberar un espacio
457
+ oldest_device = db.query(Device).filter(Device.user_id == current_user.id).order_by(Device.last_active.asc()).first()
458
+ if oldest_device:
459
+ logger.info(f"Eliminando dispositivo antiguo {oldest_device.name} para usuario premium {current_user.username}")
460
+ db.delete(oldest_device)
461
+ else:
462
+ # Para no premium, eliminamos todos
463
+ db.query(Device).filter(Device.user_id == current_user.id).delete()
464
+ db.commit()
465
+ # Recargar relaciones
466
+ db.refresh(current_user)
467
+ logger.info(f"Anulando sesión anterior para {current_user.username} en registro de dispositivo")
468
+
469
+ new_device = Device(
470
+ user_id=current_user.id,
471
+ device_id=device_data.device_id,
472
+ name=device_data.device_name
473
+ )
474
+ db.add(new_device)
475
+ logger.info(f"Nuevo dispositivo registrado: {device_data.device_name} ({device_data.device_id})")
476
+ db.commit()
477
+ # Encolar proactivamente stems en la nube para descarga automática en el nuevo dispositivo
478
+ _enqueue_cloud_stems_for_new_device(db, current_user, device_data.device_id)
479
+ return {"message": "Dispositivo registrado exitosamente"}
480
+
481
+
482
+ @router.post("/forgot-password")
483
+ def forgot_password(
484
+ data: ForgotPasswordRequest,
485
+ background_tasks: BackgroundTasks,
486
+ db: Session = Depends(get_db)
487
+ ):
488
+ import random
489
+ user = db.query(User).filter(User.email == data.email).first()
490
+ if not user:
491
+ raise HTTPException(
492
+ status_code=404,
493
+ detail="No se encontró ninguna cuenta registrada con ese correo electrónico"
494
+ )
495
+
496
+ code = f"{random.randint(100000, 999999)}"
497
+ user.reset_token = code
498
+ user.reset_token_expires = datetime.utcnow() + timedelta(minutes=15)
499
+ db.commit()
500
+
501
+ from app.utils.email import send_recovery_email
502
+ background_tasks.add_task(send_recovery_email, user.email, code)
503
+
504
+ return {"message": "Código de verificación enviado exitosamente a tu correo electrónico"}
505
+
506
+
507
+ @router.post("/reset-password")
508
+ def reset_password(
509
+ data: ResetPasswordRequest,
510
+ db: Session = Depends(get_db)
511
+ ):
512
+ user = db.query(User).filter(User.email == data.email).first()
513
+ if not user:
514
+ raise HTTPException(status_code=404, detail="Usuario no encontrado")
515
+
516
+ if not user.reset_token or user.reset_token != data.code:
517
+ raise HTTPException(status_code=400, detail="Código de verificación incorrecto")
518
+
519
+ if not user.reset_token_expires or user.reset_token_expires < datetime.utcnow():
520
+ raise HTTPException(status_code=400, detail="El código de verificación ha expirado")
521
+
522
+ user.hashed_password = get_password_hash(data.password)
523
+ user.reset_token = None
524
+ user.reset_token_expires = None
525
+ db.commit()
526
+
527
+ return {"message": "Contraseña restablecida exitosamente"}
528
+
529
+
530
+ @router.post("/change-password")
531
+ def change_user_password(
532
+ data: ChangePasswordRequest,
533
+ db: Session = Depends(get_db),
534
+ current_user: User = Depends(get_current_user)
535
+ ):
536
+ if not current_user.hashed_password:
537
+ raise HTTPException(
538
+ status_code=400,
539
+ detail="Esta cuenta está vinculada a un proveedor externo (Google/Supabase) y no tiene una contraseña local"
540
+ )
541
+
542
+ if not verify_password(data.old_password, current_user.hashed_password):
543
+ raise HTTPException(status_code=400, detail="La contraseña actual es incorrecta")
544
+
545
+ current_user.hashed_password = get_password_hash(data.new_password)
546
+ db.commit()
547
+
548
+ return {"message": "Contraseña actualizada exitosamente"}
549
+
550
+
551
+ @router.put("/users/me/avatar")
552
+ def update_user_avatar(
553
+ data: AvatarUpdate,
554
+ db: Session = Depends(get_db),
555
+ current_user: User = Depends(get_current_user)
556
+ ):
557
+ current_user.avatar_url = data.avatar_url
558
+ db.commit()
559
+ return {"avatar_url": current_user.avatar_url}
560
+
561
+
562
+ @router.put("/users/me/username")
563
+ def update_username(
564
+ data: UsernameUpdate,
565
+ db: Session = Depends(get_db),
566
+ current_user: User = Depends(get_current_user)
567
+ ):
568
+ new_username = data.username.strip()
569
+ if not new_username:
570
+ raise HTTPException(status_code=400, detail="El nombre de usuario no puede estar vacío")
571
+
572
+ if len(new_username) < 3 or len(new_username) > 20:
573
+ raise HTTPException(status_code=400, detail="El nombre de usuario debe tener entre 3 y 20 caracteres")
574
+
575
+ import re
576
+ if not re.match(r"^[a-zA-Z0-9.\-\s]+$", new_username):
577
+ raise HTTPException(status_code=400, detail="El nombre de usuario solo puede contener letras, números, espacios, puntos y guiones")
578
+
579
+ # Excluir caracteres especiales raros si es necesario, pero validemos similitud básica
580
+ if new_username == current_user.username:
581
+ return {
582
+ "username": current_user.username,
583
+ "username_changes_this_month": current_user.username_changes_this_month,
584
+ "message": "El nombre de usuario es idéntico"
585
+ }
586
+
587
+ # Reset del contador si es un nuevo mes
588
+ now = datetime.utcnow()
589
+ if current_user.last_username_change_at:
590
+ last_change = current_user.last_username_change_at
591
+ if (now.year > last_change.year) or (now.year == last_change.year and now.month > last_change.month):
592
+ current_user.username_changes_this_month = 0
593
+ db.commit()
594
+
595
+ # Verificar límite
596
+ if current_user.username_changes_this_month >= 3:
597
+ raise HTTPException(
598
+ status_code=status.HTTP_400_BAD_REQUEST,
599
+ detail="Has alcanzado el límite de 3 cambios de nombre de usuario este mes. Podrás cambiarlo nuevamente el 1 del próximo mes."
600
+ )
601
+
602
+ # Verificar si ya existe
603
+ exists = db.query(User).filter(User.username == new_username).first()
604
+ if exists:
605
+ raise HTTPException(
606
+ status_code=status.HTTP_400_BAD_REQUEST,
607
+ detail="El nombre de usuario ya está registrado por otra cuenta"
608
+ )
609
+
610
+ # Proceder a actualizar
611
+ current_user.username = new_username
612
+ current_user.username_changes_this_month += 1
613
+ current_user.last_username_change_at = now
614
+ db.commit()
615
+
616
+ # Notificar al usuario por WebSocket en tiempo real en todos sus dispositivos
617
+ try:
618
+ from app.websocket_manager import send_sync_message
619
+ send_sync_message({
620
+ "type": "username_updated",
621
+ "username": new_username
622
+ }, current_user.id)
623
+ except Exception:
624
+ pass
625
+
626
+ logger.info(f"Usuario {current_user.id} cambió su username a: {new_username}. Cambios este mes: {current_user.username_changes_this_month}/3")
627
+
628
+ return {
629
+ "username": current_user.username,
630
+ "username_changes_this_month": current_user.username_changes_this_month,
631
+ "last_username_change_at": current_user.last_username_change_at
632
+ }
633
+
634
+
635
+
636
+ @router.get("/test-smtp")
637
+ def test_smtp(to: str):
638
+ """
639
+ Endpoint de diagnóstico para probar el envío de correos SMTP y ver el error de configuración o conexión.
640
+ """
641
+ from app.utils.email import send_recovery_email
642
+ import os
643
+
644
+ # Obtener valores para mostrarlos (con password censurado)
645
+ smtp_host = os.getenv("SMTP_HOST")
646
+ smtp_port = os.getenv("SMTP_PORT")
647
+ smtp_user = os.getenv("SMTP_USER")
648
+ smtp_password = os.getenv("SMTP_PASSWORD")
649
+ smtp_from = os.getenv("SMTP_FROM")
650
+
651
+ # Limpiar comillas iniciales/finales y espacios
652
+ if smtp_host: smtp_host = smtp_host.strip("'\" ")
653
+ if smtp_port: smtp_port = smtp_port.strip("'\" ")
654
+ if smtp_user: smtp_user = smtp_user.strip("'\" ")
655
+ if smtp_password: smtp_password = smtp_password.strip("'\" ")
656
+ if smtp_from: smtp_from = smtp_from.strip("'\" ")
657
+
658
+ config_info = {
659
+ "SMTP_HOST": smtp_host,
660
+ "SMTP_PORT": smtp_port,
661
+ "SMTP_USER": smtp_user,
662
+ "SMTP_FROM": smtp_from or f"Melodix <{smtp_user}>",
663
+ "SMTP_PASSWORD_SET": smtp_password is not None and len(smtp_password) > 0
664
+ }
665
+
666
+ # Intentar enviar
667
+ try:
668
+ success = send_recovery_email(to, "999999", raise_on_error=True)
669
+ if success:
670
+ return {
671
+ "status": "success",
672
+ "message": f"Correo de prueba enviado con éxito a {to}",
673
+ "config": config_info
674
+ }
675
+ else:
676
+ return {
677
+ "status": "failed",
678
+ "message": "El método send_recovery_email retornó False. Revisa los logs de tu servidor en Render para ver el error exacto.",
679
+ "config": config_info
680
+ }
681
+ except Exception as e:
682
+ import traceback
683
+ error_trace = traceback.format_exc()
684
+ return {
685
+ "status": "error",
686
+ "message": str(e),
687
+ "traceback": error_trace,
688
+ "config": config_info
689
+ }
app/routers/iglesia.py CHANGED
The diff for this file is too large to render. See raw diff
 
app/routers/lyrics.py CHANGED
@@ -1,757 +1,757 @@
1
- from fastapi import APIRouter, Depends, HTTPException, status
2
- from sqlalchemy.orm import Session
3
- from typing import List, Optional
4
- from app.database import get_db
5
- from app.models.lyrics import Lyric, Group, UserGroup, Repertorio, RepertorioLyric, Invitation
6
- from app.models.user import User
7
- from app.schemas.lyrics import LyricCreate, LyricOut, GroupCreate, GroupOut, InvitationCreate, InvitationOut, RepertorioCreate, RepertorioOut
8
- from app.routers.auth import get_current_user
9
- from app.utils.credits import deduct_credits
10
- import uuid
11
- from datetime import datetime
12
-
13
- router = APIRouter(prefix="/lyrics", tags=["lyrics"])
14
-
15
- # --- LYRICS ENDPOINTS ---
16
-
17
- @router.post("")
18
- @router.post("/")
19
- def create_lyric(lyric: LyricCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
20
- if lyric.group_id is not None:
21
- ug = db.query(UserGroup).filter(UserGroup.user_id == current_user.id, UserGroup.group_id == lyric.group_id, UserGroup.estado == "activo").first()
22
- if not ug:
23
- raise HTTPException(status_code=403, detail="No perteneces de forma activa a este grupo")
24
- from app.routers.alabanza import check_group_permission
25
- if ug.role != "admin" and not check_group_permission(ug, "letras", "create", db):
26
- raise HTTPException(status_code=403, detail="No tienes permisos para crear letras en este grupo")
27
-
28
- db_lyric = Lyric(**lyric.dict(), created_by=current_user.id)
29
- if lyric.group_id is not None:
30
- group = db.query(Group).filter(Group.id == lyric.group_id).first()
31
- if group:
32
- db_lyric.groups_shared.append(group)
33
- db.add(db_lyric)
34
- db.commit()
35
- db.refresh(db_lyric)
36
- if db_lyric.group_id is not None:
37
- try:
38
- from app.routers.alabanza import broadcast_to_group
39
- broadcast_to_group(db_lyric.group_id, {"type": "group_updated", "grupo_id": db_lyric.group_id}, db)
40
- except Exception:
41
- pass
42
- return db_lyric
43
-
44
- @router.get("/all", response_model=List[LyricOut])
45
- def list_lyrics(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
46
- # Listar letras creadas por el usuario actual que no sean exclusivamente de repertorio
47
- from sqlalchemy import or_
48
- return db.query(Lyric).filter(
49
- Lyric.created_by == current_user.id,
50
- or_(Lyric.is_repertoire_only == False, Lyric.is_repertoire_only.is_(None))
51
- ).order_by(Lyric.id.desc()).all()
52
-
53
- # --- GROUPS ENDPOINTS ---
54
-
55
- @router.post("/groups", response_model=GroupOut)
56
- def create_group(group: GroupCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
57
- # Descontar 1 crédito
58
- deduct_credits(db, current_user, 1, "crear_grupo")
59
-
60
- db_group = Group(name=group.name)
61
- db.add(db_group)
62
- db.commit()
63
- db.refresh(db_group)
64
-
65
- # Añadir al creador como admin
66
- db_ug = UserGroup(user_id=current_user.id, group_id=db_group.id, role="admin")
67
- db.add(db_ug)
68
- db.commit()
69
-
70
- try:
71
- from app.utils.notifications import create_admin_notification
72
- create_admin_notification(
73
- db,
74
- "Nuevo Grupo Creado",
75
- f"El grupo '{db_group.name}' fue creado por el usuario '{current_user.username}'."
76
- )
77
- except Exception:
78
- pass
79
-
80
- return db_group
81
-
82
- @router.get("/groups")
83
- def list_groups(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
84
- groups = []
85
- for ug in current_user.groups:
86
- if ug.estado != "expulsado":
87
- g_dict = {
88
- "id": ug.group.id,
89
- "name": ug.group.name,
90
- "created_at": ug.group.created_at,
91
- "role": ug.role,
92
- "estado": ug.estado
93
- }
94
- groups.append(g_dict)
95
- return groups
96
-
97
- # --- REPERTORIOS ENDPOINTS ---
98
-
99
- @router.get("/repertorios", response_model=List[RepertorioOut])
100
- def list_repertorios(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
101
- return db.query(Repertorio).filter(Repertorio.created_by == current_user.id).all()
102
-
103
- @router.get("/{lyric_id}", response_model=LyricOut)
104
- def get_lyric(lyric_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
105
- db_lyric = db.query(Lyric).filter(Lyric.id == lyric_id).first()
106
- if not db_lyric:
107
- raise HTTPException(status_code=404, detail="Lyric not found")
108
- if db_lyric.created_by != current_user.id:
109
- allowed_group_ids = list(db_lyric.group_ids)
110
- if db_lyric.group_id is not None:
111
- allowed_group_ids.append(db_lyric.group_id)
112
- user_groups_with_perm = db.query(UserGroup).filter(
113
- UserGroup.user_id == current_user.id,
114
- UserGroup.estado == "activo",
115
- UserGroup.group_id.in_(allowed_group_ids),
116
- (UserGroup.role == "admin") | (UserGroup.perm_ver_letras == True)
117
- ).first()
118
- if not user_groups_with_perm:
119
- raise HTTPException(status_code=403, detail="Not authorized to view this lyric")
120
- return db_lyric
121
-
122
- @router.put("/{lyric_id}", response_model=LyricOut)
123
- def update_lyric(
124
- lyric_id: int,
125
- lyric_update: LyricCreate,
126
- edit_context_group_id: Optional[int] = None,
127
- edit_context_repertoire_id: Optional[int] = None,
128
- update_references: Optional[bool] = None,
129
- db: Session = Depends(get_db),
130
- current_user: User = Depends(get_current_user)
131
- ):
132
- db_lyric = db.query(Lyric).filter(Lyric.id == lyric_id).first()
133
- if not db_lyric:
134
- raise HTTPException(status_code=404, detail="Lyric not found")
135
-
136
- is_authorized = False
137
- if db_lyric.created_by == current_user.id:
138
- is_authorized = True
139
- else:
140
- from app.routers.alabanza import check_group_permission
141
- allowed_group_ids = list(db_lyric.group_ids)
142
- if db_lyric.group_id is not None:
143
- allowed_group_ids.append(db_lyric.group_id)
144
- for gid in allowed_group_ids:
145
- ug = db.query(UserGroup).filter(
146
- UserGroup.user_id == current_user.id,
147
- UserGroup.group_id == gid,
148
- UserGroup.estado == "activo"
149
- ).first()
150
- if ug and (ug.role == "admin" or check_group_permission(ug, "letras", "edit", db)):
151
- is_authorized = True
152
- break
153
-
154
- if not is_authorized:
155
- raise HTTPException(status_code=403, detail="Not authorized to edit this lyric")
156
-
157
- # --- CASO 1: Edición con Contexto de Repertorio ---
158
- if edit_context_repertoire_id is not None:
159
- repertoire = db.query(Repertorio).filter(Repertorio.id == edit_context_repertoire_id).first()
160
- if not repertoire:
161
- raise HTTPException(status_code=404, detail="Repertorio no encontrado")
162
-
163
- if db_lyric.is_repertoire_only:
164
- # Actualizar directamente si ya es exclusivo de repertorios
165
- for key, value in lyric_update.dict().items():
166
- setattr(db_lyric, key, value)
167
- db.commit()
168
- db.refresh(db_lyric)
169
- return db_lyric
170
- else:
171
- # Crear clon para el repertorio
172
- cloned_lyric = Lyric(
173
- title=lyric_update.title,
174
- artist=lyric_update.artist,
175
- content=lyric_update.content,
176
- key=lyric_update.key,
177
- bpm=lyric_update.bpm,
178
- transpose_offset=lyric_update.transpose_offset,
179
- group_id=repertoire.group_id,
180
- created_by=db_lyric.created_by,
181
- is_repertoire_only=True,
182
- parent_lyric_id=db_lyric.id
183
- )
184
- db.add(cloned_lyric)
185
- db.flush()
186
-
187
- # Compartir con el grupo si el repertorio pertenece a uno
188
- if repertoire.group_id is not None:
189
- group = db.query(Group).filter(Group.id == repertoire.group_id).first()
190
- if group:
191
- cloned_lyric.groups_shared.append(group)
192
-
193
- # Actualizar relación en repertorio_lyrics
194
- rep_lyric = db.query(RepertorioLyric).filter(
195
- RepertorioLyric.repertorio_id == edit_context_repertoire_id,
196
- RepertorioLyric.lyric_id == db_lyric.id
197
- ).first()
198
- if rep_lyric:
199
- order = rep_lyric.order
200
- db.delete(rep_lyric)
201
- db.flush()
202
- new_rep_lyric = RepertorioLyric(
203
- repertorio_id=edit_context_repertoire_id,
204
- lyric_id=cloned_lyric.id,
205
- order=order
206
- )
207
- db.add(new_rep_lyric)
208
-
209
- db.commit()
210
- db.refresh(cloned_lyric)
211
-
212
- if repertoire.group_id is not None:
213
- try:
214
- from app.routers.alabanza import broadcast_to_group
215
- broadcast_to_group(repertoire.group_id, {"type": "group_updated", "grupo_id": repertoire.group_id}, db)
216
- except Exception:
217
- pass
218
- return cloned_lyric
219
-
220
- # --- CASO 2: Edición con Contexto de Grupo ---
221
- elif edit_context_group_id is not None:
222
- group = db.query(Group).filter(Group.id == edit_context_group_id).first()
223
- if not group:
224
- raise HTTPException(status_code=404, detail="Grupo no encontrado")
225
-
226
- is_exclusive_to_group = db_lyric.group_id == edit_context_group_id and len(db_lyric.group_ids) <= 1 and not db_lyric.is_repertoire_only
227
- if is_exclusive_to_group:
228
- for key, value in lyric_update.dict().items():
229
- setattr(db_lyric, key, value)
230
- db.commit()
231
- db.refresh(db_lyric)
232
- try:
233
- from app.routers.alabanza import broadcast_to_group
234
- broadcast_to_group(edit_context_group_id, {"type": "group_updated", "grupo_id": edit_context_group_id}, db)
235
- except Exception:
236
- pass
237
- return db_lyric
238
- else:
239
- # Crear clon para el grupo
240
- cloned_lyric = Lyric(
241
- title=lyric_update.title,
242
- artist=lyric_update.artist,
243
- content=lyric_update.content,
244
- key=lyric_update.key,
245
- bpm=lyric_update.bpm,
246
- transpose_offset=lyric_update.transpose_offset,
247
- group_id=edit_context_group_id,
248
- created_by=db_lyric.created_by,
249
- is_repertoire_only=False,
250
- parent_lyric_id=db_lyric.id
251
- )
252
- cloned_lyric.groups_shared.append(group)
253
- db.add(cloned_lyric)
254
- db.flush()
255
-
256
- # Desvincular original del grupo
257
- if group in db_lyric.groups_shared:
258
- db_lyric.groups_shared.remove(group)
259
- if db_lyric.group_id == edit_context_group_id:
260
- db_lyric.group_id = db_lyric.groups_shared[0].id if db_lyric.groups_shared else None
261
-
262
- # Actualizar todos los repertorios de este grupo que apuntaban a la original
263
- group_rep_ids = [r.id for r in db.query(Repertorio.id).filter(Repertorio.group_id == edit_context_group_id).all()]
264
- if group_rep_ids:
265
- rep_lyrics = db.query(RepertorioLyric).filter(
266
- RepertorioLyric.repertorio_id.in_(group_rep_ids),
267
- RepertorioLyric.lyric_id == db_lyric.id
268
- ).all()
269
- for rl in rep_lyrics:
270
- order = rl.order
271
- rep_id = rl.repertorio_id
272
- db.delete(rl)
273
- db.flush()
274
- new_rl = RepertorioLyric(
275
- repertorio_id=rep_id,
276
- lyric_id=cloned_lyric.id,
277
- order=order
278
- )
279
- db.add(new_rl)
280
-
281
- db.commit()
282
- db.refresh(cloned_lyric)
283
- try:
284
- from app.routers.alabanza import broadcast_to_group
285
- broadcast_to_group(edit_context_group_id, {"type": "group_updated", "grupo_id": edit_context_group_id}, db)
286
- except Exception:
287
- pass
288
- return cloned_lyric
289
-
290
- # --- CASO 3: Edición desde el Catálogo Personal (Creador original) ---
291
- else:
292
- has_shared_references = len(db_lyric.groups_shared) > 0
293
- if has_shared_references and update_references is False:
294
- # Clonar la letra original con el contenido actual (viejo) para los grupos y repertorios
295
- old_cloned_lyric = Lyric(
296
- title=db_lyric.title,
297
- artist=db_lyric.artist,
298
- content=db_lyric.content,
299
- key=db_lyric.key,
300
- bpm=db_lyric.bpm,
301
- transpose_offset=db_lyric.transpose_offset,
302
- group_id=db_lyric.group_id,
303
- created_by=db_lyric.created_by,
304
- is_repertoire_only=False,
305
- parent_lyric_id=db_lyric.id
306
- )
307
- shared_groups = list(db_lyric.groups_shared)
308
- for g in shared_groups:
309
- old_cloned_lyric.groups_shared.append(g)
310
- db.add(old_cloned_lyric)
311
- db.flush()
312
-
313
- # Desvincular original de los grupos
314
- db_lyric.groups_shared.clear()
315
- db_lyric.group_id = None
316
-
317
- # Actualizar referencias de repertorios de esos grupos para que usen el clon viejo
318
- shared_group_ids = [g.id for g in shared_groups]
319
- if shared_group_ids:
320
- group_rep_ids = [r.id for r in db.query(Repertorio.id).filter(Repertorio.group_id.in_(shared_group_ids)).all()]
321
- if group_rep_ids:
322
- rep_lyrics = db.query(RepertorioLyric).filter(
323
- RepertorioLyric.repertorio_id.in_(group_rep_ids),
324
- RepertorioLyric.lyric_id == db_lyric.id
325
- ).all()
326
- for rl in rep_lyrics:
327
- order = rl.order
328
- rep_id = rl.repertorio_id
329
- db.delete(rl)
330
- db.flush()
331
- new_rl = RepertorioLyric(
332
- repertorio_id=rep_id,
333
- lyric_id=old_cloned_lyric.id,
334
- order=order
335
- )
336
- db.add(new_rl)
337
-
338
- # Guardar los nuevos cambios en la original (personal)
339
- for key, value in lyric_update.dict().items():
340
- if key != "group_id":
341
- setattr(db_lyric, key, value)
342
-
343
- db.commit()
344
- db.refresh(db_lyric)
345
-
346
- for gid in shared_group_ids:
347
- try:
348
- from app.routers.alabanza import broadcast_to_group
349
- broadcast_to_group(gid, {"type": "group_updated", "grupo_id": gid}, db)
350
- except Exception:
351
- pass
352
- return db_lyric
353
- else:
354
- # Edición directa (in-place)
355
- for key, value in lyric_update.dict().items():
356
- setattr(db_lyric, key, value)
357
-
358
- if db_lyric.group_id is not None:
359
- group = db.query(Group).filter(Group.id == db_lyric.group_id).first()
360
- if group and group not in db_lyric.groups_shared:
361
- db_lyric.groups_shared.append(group)
362
-
363
- db.commit()
364
- db.refresh(db_lyric)
365
- for gid in db_lyric.group_ids:
366
- try:
367
- from app.routers.alabanza import broadcast_to_group
368
- broadcast_to_group(gid, {"type": "group_updated", "grupo_id": gid}, db)
369
- except Exception:
370
- pass
371
- return db_lyric
372
-
373
- @router.delete("/{lyric_id}")
374
- def delete_lyric(lyric_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
375
- db_lyric = db.query(Lyric).filter(Lyric.id == lyric_id).first()
376
- if not db_lyric:
377
- raise HTTPException(status_code=404, detail="Lyric not found")
378
-
379
- is_authorized = False
380
- if db_lyric.created_by == current_user.id:
381
- is_authorized = True
382
- else:
383
- from app.routers.alabanza import check_group_permission
384
- allowed_group_ids = list(db_lyric.group_ids)
385
- if db_lyric.group_id is not None:
386
- allowed_group_ids.append(db_lyric.group_id)
387
- for gid in allowed_group_ids:
388
- ug = db.query(UserGroup).filter(
389
- UserGroup.user_id == current_user.id,
390
- UserGroup.group_id == gid,
391
- UserGroup.estado == "activo"
392
- ).first()
393
- if ug and (ug.role == "admin" or check_group_permission(ug, "letras", "delete", db)):
394
- is_authorized = True
395
- break
396
-
397
- if not is_authorized:
398
- raise HTTPException(status_code=403, detail="Not authorized to delete this lyric")
399
-
400
- shared_group_ids = list(db_lyric.group_ids)
401
- db.delete(db_lyric)
402
- db.commit()
403
- for gid in shared_group_ids:
404
- try:
405
- from app.routers.alabanza import broadcast_to_group
406
- broadcast_to_group(gid, {"type": "group_updated", "grupo_id": gid}, db)
407
- except Exception:
408
- pass
409
- return {"message": "Lyric deleted successfully"}
410
-
411
-
412
-
413
- @router.post("/repertorios", response_model=RepertorioOut)
414
- def create_repertoire(rep: RepertorioCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
415
- if rep.group_id is not None:
416
- ug = db.query(UserGroup).filter(UserGroup.user_id == current_user.id, UserGroup.group_id == rep.group_id, UserGroup.estado == "activo").first()
417
- if not ug:
418
- raise HTTPException(status_code=403, detail="No perteneces de forma activa a este grupo")
419
- if ug.role != "admin" and not ug.perm_crud_repertorios:
420
- raise HTTPException(status_code=403, detail="No tienes permisos para crear repertorios en este grupo")
421
-
422
- db_rep = Repertorio(name=rep.name, group_id=rep.group_id, color=rep.color, created_by=current_user.id)
423
- db.add(db_rep)
424
- db.commit()
425
- db.refresh(db_rep)
426
-
427
- if rep.lyrics_ids:
428
- for idx, lyric_id in enumerate(rep.lyrics_ids):
429
- db_rep_lyric = RepertorioLyric(repertorio_id=db_rep.id, lyric_id=lyric_id, order=idx)
430
- db.add(db_rep_lyric)
431
- db.commit()
432
-
433
- if db_rep.group_id is not None:
434
- try:
435
- from app.routers.alabanza import broadcast_to_group
436
- broadcast_to_group(db_rep.group_id, {"type": "group_updated", "grupo_id": db_rep.group_id}, db)
437
- except Exception:
438
- pass
439
- return db_rep
440
-
441
- @router.put("/repertorios/{id}", response_model=RepertorioOut)
442
- def update_repertoire(id: int, rep: RepertorioCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
443
- db_rep = db.query(Repertorio).filter(Repertorio.id == id).first()
444
- if not db_rep:
445
- raise HTTPException(status_code=404, detail="Repertoire not found")
446
-
447
- is_authorized = False
448
- if db_rep.created_by == current_user.id:
449
- is_authorized = True
450
- elif db_rep.group_id is not None:
451
- ug = db.query(UserGroup).filter(
452
- UserGroup.user_id == current_user.id,
453
- UserGroup.group_id == db_rep.group_id,
454
- UserGroup.estado == "activo"
455
- ).first()
456
- if ug and (ug.role == "admin" or ug.perm_crud_repertorios):
457
- is_authorized = True
458
-
459
- if not is_authorized:
460
- raise HTTPException(status_code=403, detail="No tienes permisos para editar este repertorio")
461
-
462
- db_rep.name = rep.name
463
- db_rep.color = rep.color
464
- if rep.group_id is not None:
465
- db_rep.created_at = datetime.utcnow()
466
- db_rep.group_id = rep.group_id
467
- db.commit()
468
-
469
- # Actualizar letras
470
- # Eliminar las actuales
471
- db.query(RepertorioLyric).filter(RepertorioLyric.repertorio_id == id).delete()
472
-
473
- # Agregar las nuevas
474
- if rep.lyrics_ids:
475
- for idx, lyric_id in enumerate(rep.lyrics_ids):
476
- db_rep_lyric = RepertorioLyric(repertorio_id=id, lyric_id=lyric_id, order=idx)
477
- db.add(db_rep_lyric)
478
-
479
- db.commit()
480
- db.refresh(db_rep)
481
- if db_rep.group_id is not None:
482
- try:
483
- from app.routers.alabanza import broadcast_to_group
484
- broadcast_to_group(db_rep.group_id, {"type": "group_updated", "grupo_id": db_rep.group_id}, db)
485
- except Exception:
486
- pass
487
- return db_rep
488
-
489
- @router.delete("/repertorios/{id}")
490
- def delete_repertoire(id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
491
- db_rep = db.query(Repertorio).filter(Repertorio.id == id).first()
492
- if not db_rep:
493
- raise HTTPException(status_code=404, detail="Repertoire not found")
494
-
495
- is_authorized = False
496
- if db_rep.created_by == current_user.id:
497
- is_authorized = True
498
- elif db_rep.group_id is not None:
499
- ug = db.query(UserGroup).filter(
500
- UserGroup.user_id == current_user.id,
501
- UserGroup.group_id == db_rep.group_id,
502
- UserGroup.estado == "activo"
503
- ).first()
504
- if ug and (ug.role == "admin" or ug.perm_crud_repertorios):
505
- is_authorized = True
506
-
507
- if not is_authorized:
508
- raise HTTPException(status_code=403, detail="No tienes permisos para eliminar este repertorio")
509
-
510
- group_id = db_rep.group_id
511
- db.query(RepertorioLyric).filter(RepertorioLyric.repertorio_id == id).delete()
512
- db.delete(db_rep)
513
- db.commit()
514
- if group_id is not None:
515
- try:
516
- from app.routers.alabanza import broadcast_to_group
517
- broadcast_to_group(group_id, {"type": "group_updated", "grupo_id": group_id}, db)
518
- except Exception:
519
- pass
520
- return {"message": "Repertorio eliminado exitosamente"}
521
-
522
- @router.post("/repertorios/{id}/lyrics")
523
- def add_lyric_to_repertoire(id: int, lyric_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
524
- db_rep = db.query(Repertorio).filter(Repertorio.id == id).first()
525
- if not db_rep:
526
- raise HTTPException(status_code=404, detail="Repertoire not found")
527
-
528
- is_authorized = False
529
- if db_rep.created_by == current_user.id:
530
- is_authorized = True
531
- elif db_rep.group_id is not None:
532
- ug = db.query(UserGroup).filter(
533
- UserGroup.user_id == current_user.id,
534
- UserGroup.group_id == db_rep.group_id,
535
- UserGroup.estado == "activo"
536
- ).first()
537
- if ug and (ug.role == "admin" or ug.perm_crud_repertorios):
538
- is_authorized = True
539
-
540
- if not is_authorized:
541
- raise HTTPException(status_code=403, detail="No tienes permisos para modificar este repertorio")
542
-
543
- exists = db.query(RepertorioLyric).filter_by(repertorio_id=id, lyric_id=lyric_id).first()
544
- if exists:
545
- return {"message": "La letra ya está en el repertorio"}
546
-
547
- last_order = db.query(RepertorioLyric).filter_by(repertorio_id=id).count()
548
-
549
- db_rep_lyric = RepertorioLyric(repertorio_id=id, lyric_id=lyric_id, order=last_order)
550
- db.add(db_rep_lyric)
551
- db.commit()
552
-
553
- if db_rep.group_id is not None:
554
- try:
555
- from app.routers.alabanza import broadcast_to_group
556
- broadcast_to_group(db_rep.group_id, {"type": "group_updated", "grupo_id": db_rep.group_id}, db)
557
- except Exception:
558
- pass
559
- return {"message": "Letra agregada al repertorio"}
560
-
561
- @router.get("/repertorios/{id}")
562
- def get_repertoire(id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
563
- rep = db.query(Repertorio).filter(Repertorio.id == id).first()
564
- if not rep:
565
- raise HTTPException(status_code=404, detail="Repertoire not found")
566
-
567
- if rep.group_id is not None:
568
- ug = db.query(UserGroup).filter(
569
- UserGroup.user_id == current_user.id,
570
- UserGroup.group_id == rep.group_id,
571
- UserGroup.estado == "activo"
572
- ).first()
573
- if not ug:
574
- raise HTTPException(status_code=403, detail="No tienes acceso a este repertorio")
575
- if ug.role != "admin" and not ug.perm_ver_repertorios and rep.created_by != current_user.id:
576
- raise HTTPException(status_code=403, detail="No tienes permisos para ver este repertorio")
577
- else:
578
- if rep.created_by != current_user.id:
579
- raise HTTPException(status_code=403, detail="No tienes acceso a este repertorio")
580
-
581
- # Ordenar letras por el campo 'order'
582
- sorted_lyrics = sorted(rep.lyrics, key=lambda x: x.order if x.order is not None else 0)
583
- lyrics = [rl.lyric for rl in sorted_lyrics if rl.lyric is not None]
584
-
585
- return {
586
- "id": rep.id,
587
- "name": rep.name,
588
- "group_id": rep.group_id,
589
- "created_by": rep.created_by,
590
- "created_at": rep.created_at,
591
- "lyrics": lyrics
592
- }
593
-
594
- # --- INVITATIONS ENDPOINTS ---
595
-
596
- @router.post("/groups/{group_id}/invite", response_model=InvitationOut)
597
- def create_invitation(group_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
598
- # Verificar que el usuario sea admin del grupo
599
- ug = db.query(UserGroup).filter_by(user_id=current_user.id, group_id=group_id, role="admin").first()
600
- if not ug:
601
- raise HTTPException(status_code=403, detail="Solo los administradores pueden crear invitaciones")
602
-
603
- code = str(uuid.uuid4())[:8] # Código corto
604
- db_invite = Invitation(code=code, group_id=group_id, created_by=current_user.id)
605
- db.add(db_invite)
606
- db.commit()
607
- db.refresh(db_invite)
608
- return db_invite
609
-
610
- @router.post("/join/{code}")
611
- def join_group(code: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
612
- invite = db.query(Invitation).filter_by(code=code).first()
613
- if not invite:
614
- raise HTTPException(status_code=404, detail="Invitación no encontrada")
615
-
616
- # Verificar si pertenece a otro grupo (activo o pendiente)
617
- other_groups = db.query(UserGroup).filter(
618
- UserGroup.user_id == current_user.id,
619
- UserGroup.group_id != invite.group_id,
620
- UserGroup.estado.in_(["activo", "pendiente"])
621
- ).all()
622
-
623
- # Verificar si ya está en este grupo
624
- ug = db.query(UserGroup).filter_by(user_id=current_user.id, group_id=invite.group_id).first()
625
-
626
- # Cobrar 1 crédito si ya pertenece a otro grupo y está solicitando unirse (nuevo o reingreso)
627
- if len(other_groups) >= 1 and (ug is None or ug.estado in ("expulsado", "salido")):
628
- deduct_credits(db, current_user, 1, "unirse_grupo_extra", reference_id=code)
629
- if ug:
630
- if ug.estado == "expulsado":
631
- ug.estado = "pendiente"
632
- db.commit()
633
- return {"message": "Solicitud enviada, esperando aprobación", "group_id": invite.group_id}
634
- elif ug.estado == "pendiente":
635
- return {"message": "Tu solicitud está esperando aprobación", "group_id": invite.group_id}
636
- elif ug.estado == "activo":
637
- raise HTTPException(status_code=400, detail="Ya eres miembro de este grupo")
638
-
639
- # Unirse al grupo
640
- db_ug = UserGroup(user_id=current_user.id, group_id=invite.group_id, role="member", estado="activo")
641
- db.add(db_ug)
642
- db.commit()
643
-
644
- return {"message": "Te has unido al grupo exitosamente", "group_id": invite.group_id}
645
-
646
- @router.delete("/groups/{group_id}/members/{user_id}")
647
- def expulsar_miembro_grupo(group_id: int, user_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
648
- ug_admin = db.query(UserGroup).filter_by(user_id=current_user.id, group_id=group_id, role="admin", estado="activo").first()
649
- if not ug_admin:
650
- raise HTTPException(status_code=403, detail="No autorizado")
651
-
652
- ug_target = db.query(UserGroup).filter_by(user_id=user_id, group_id=group_id).first()
653
- if not ug_target:
654
- raise HTTPException(status_code=404, detail="Usuario no encontrado en el grupo")
655
-
656
- ug_target.estado = "expulsado"
657
- db.commit()
658
- return {"message": "Usuario expulsado del grupo"}
659
-
660
- @router.get("/groups/{group_id}/solicitudes")
661
- def ver_solicitudes_grupo(group_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
662
- ug_admin = db.query(UserGroup).filter_by(user_id=current_user.id, group_id=group_id, role="admin", estado="activo").first()
663
- if not ug_admin:
664
- raise HTTPException(status_code=403, detail="No autorizado")
665
-
666
- pendientes = db.query(UserGroup).filter_by(group_id=group_id, estado="pendiente").all()
667
- return [
668
- {
669
- "user_id": p.user_id,
670
- "username": p.user.username if p.user else "—",
671
- "requested_at": p.joined_at.isoformat() if p.joined_at else None,
672
- }
673
- for p in pendientes
674
- ]
675
-
676
- from pydantic import BaseModel
677
- class ResolverSolicitudGrupo(BaseModel):
678
- accion: str
679
-
680
- @router.put("/groups/{group_id}/solicitudes/{user_id}")
681
- def resolver_solicitud_grupo(group_id: int, user_id: int, data: ResolverSolicitudGrupo, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
682
- ug_admin = db.query(UserGroup).filter_by(user_id=current_user.id, group_id=group_id, role="admin", estado="activo").first()
683
- if not ug_admin:
684
- raise HTTPException(status_code=403, detail="No autorizado")
685
-
686
- ug_target = db.query(UserGroup).filter_by(user_id=user_id, group_id=group_id, estado="pendiente").first()
687
- if not ug_target:
688
- raise HTTPException(status_code=404, detail="Solicitud no encontrada")
689
-
690
- if data.accion == "aprobar":
691
- ug_target.estado = "activo"
692
- elif data.accion == "rechazar":
693
- ug_target.estado = "expulsado"
694
- else:
695
- raise HTTPException(status_code=400, detail="Acción inválida")
696
-
697
- db.commit()
698
- return {"message": f"Solicitud {data.accion}da exitosamente"}
699
-
700
- class ShareLyricRequest(BaseModel):
701
- group_id: int
702
- action: str # "share" o "unshare"
703
-
704
- @router.post("/{lyric_id}/share")
705
- def share_lyric(
706
- lyric_id: int,
707
- data: ShareLyricRequest,
708
- db: Session = Depends(get_db),
709
- current_user: User = Depends(get_current_user)
710
- ):
711
- lyric = db.query(Lyric).filter(Lyric.id == lyric_id).first()
712
- if not lyric:
713
- raise HTTPException(status_code=404, detail="Letra no encontrada")
714
-
715
- ug = db.query(UserGroup).filter(UserGroup.user_id == current_user.id, UserGroup.group_id == data.group_id, UserGroup.estado == "activo").first()
716
- if not ug:
717
- raise HTTPException(status_code=403, detail="No perteneces de forma activa a este grupo")
718
-
719
- is_owner = lyric.created_by == current_user.id
720
- is_group_admin = ug.role == "admin"
721
-
722
- # Para "share": creador, admin, o miembro con perm_compartir_letras
723
- # Para "unshare": sólo creador o admin (para no desvincular letras ajenas)
724
- if data.action == "share":
725
- if not is_owner and not is_group_admin and not ug.perm_compartir_letras:
726
- raise HTTPException(status_code=403, detail="No tienes permisos para compartir letras con este grupo")
727
- elif data.action == "unshare":
728
- if not is_owner and not is_group_admin:
729
- raise HTTPException(status_code=403, detail="Solo el creador o un administrador puede desvincular esta letra del grupo")
730
-
731
- group = db.query(Group).filter(Group.id == data.group_id).first()
732
- if not group:
733
- raise HTTPException(status_code=404, detail="Grupo no encontrado")
734
-
735
- if data.action == "share":
736
- if ug.role != "admin" and not ug.perm_compartir_letras:
737
- raise HTTPException(status_code=403, detail="No tienes permisos para compartir letras con este grupo")
738
- if group not in lyric.groups_shared:
739
- lyric.groups_shared.append(group)
740
- if lyric.group_id is None:
741
- lyric.group_id = data.group_id
742
- lyric.created_at = datetime.utcnow()
743
- elif data.action == "unshare":
744
- if group in lyric.groups_shared:
745
- lyric.groups_shared.remove(group)
746
- if lyric.group_id == data.group_id:
747
- lyric.group_id = lyric.groups_shared[0].id if lyric.groups_shared else None
748
- else:
749
- raise HTTPException(status_code=400, detail="Acción inválida")
750
-
751
- db.commit()
752
- try:
753
- from app.routers.alabanza import broadcast_to_group
754
- broadcast_to_group(data.group_id, {"type": "group_updated", "grupo_id": data.group_id}, db)
755
- except Exception:
756
- pass
757
- return {"message": "Letra compartida con éxito" if data.action == "share" else "Letra desvinculada del grupo"}
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+ from typing import List, Optional
4
+ from app.database import get_db
5
+ from app.models.lyrics import Lyric, Group, UserGroup, Repertorio, RepertorioLyric, Invitation
6
+ from app.models.user import User
7
+ from app.schemas.lyrics import LyricCreate, LyricOut, GroupCreate, GroupOut, InvitationCreate, InvitationOut, RepertorioCreate, RepertorioOut
8
+ from app.routers.auth import get_current_user
9
+ from app.utils.credits import deduct_credits
10
+ import uuid
11
+ from datetime import datetime
12
+
13
+ router = APIRouter(prefix="/lyrics", tags=["lyrics"])
14
+
15
+ # --- LYRICS ENDPOINTS ---
16
+
17
+ @router.post("")
18
+ @router.post("/")
19
+ def create_lyric(lyric: LyricCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
20
+ if lyric.group_id is not None:
21
+ ug = db.query(UserGroup).filter(UserGroup.user_id == current_user.id, UserGroup.group_id == lyric.group_id, UserGroup.estado == "activo").first()
22
+ if not ug:
23
+ raise HTTPException(status_code=403, detail="No perteneces de forma activa a este grupo")
24
+ from app.routers.alabanza import check_group_permission
25
+ if ug.role != "admin" and not check_group_permission(ug, "letras", "create", db):
26
+ raise HTTPException(status_code=403, detail="No tienes permisos para crear letras en este grupo")
27
+
28
+ db_lyric = Lyric(**lyric.dict(), created_by=current_user.id)
29
+ if lyric.group_id is not None:
30
+ group = db.query(Group).filter(Group.id == lyric.group_id).first()
31
+ if group:
32
+ db_lyric.groups_shared.append(group)
33
+ db.add(db_lyric)
34
+ db.commit()
35
+ db.refresh(db_lyric)
36
+ if db_lyric.group_id is not None:
37
+ try:
38
+ from app.routers.alabanza import broadcast_to_group
39
+ broadcast_to_group(db_lyric.group_id, {"type": "group_updated", "grupo_id": db_lyric.group_id}, db)
40
+ except Exception:
41
+ pass
42
+ return db_lyric
43
+
44
+ @router.get("/all", response_model=List[LyricOut])
45
+ def list_lyrics(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
46
+ # Listar letras creadas por el usuario actual que no sean exclusivamente de repertorio
47
+ from sqlalchemy import or_
48
+ return db.query(Lyric).filter(
49
+ Lyric.created_by == current_user.id,
50
+ or_(Lyric.is_repertoire_only == False, Lyric.is_repertoire_only.is_(None))
51
+ ).order_by(Lyric.id.desc()).all()
52
+
53
+ # --- GROUPS ENDPOINTS ---
54
+
55
+ @router.post("/groups", response_model=GroupOut)
56
+ def create_group(group: GroupCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
57
+ # Descontar 1 crédito
58
+ deduct_credits(db, current_user, 1, "crear_grupo")
59
+
60
+ db_group = Group(name=group.name)
61
+ db.add(db_group)
62
+ db.commit()
63
+ db.refresh(db_group)
64
+
65
+ # Añadir al creador como admin
66
+ db_ug = UserGroup(user_id=current_user.id, group_id=db_group.id, role="admin")
67
+ db.add(db_ug)
68
+ db.commit()
69
+
70
+ try:
71
+ from app.utils.notifications import create_admin_notification
72
+ create_admin_notification(
73
+ db,
74
+ "Nuevo Grupo Creado",
75
+ f"El grupo '{db_group.name}' fue creado por el usuario '{current_user.username}'."
76
+ )
77
+ except Exception:
78
+ pass
79
+
80
+ return db_group
81
+
82
+ @router.get("/groups")
83
+ def list_groups(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
84
+ groups = []
85
+ for ug in current_user.groups:
86
+ if ug.estado != "expulsado":
87
+ g_dict = {
88
+ "id": ug.group.id,
89
+ "name": ug.group.name,
90
+ "created_at": ug.group.created_at,
91
+ "role": ug.role,
92
+ "estado": ug.estado
93
+ }
94
+ groups.append(g_dict)
95
+ return groups
96
+
97
+ # --- REPERTORIOS ENDPOINTS ---
98
+
99
+ @router.get("/repertorios", response_model=List[RepertorioOut])
100
+ def list_repertorios(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
101
+ return db.query(Repertorio).filter(Repertorio.created_by == current_user.id).all()
102
+
103
+ @router.get("/{lyric_id}", response_model=LyricOut)
104
+ def get_lyric(lyric_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
105
+ db_lyric = db.query(Lyric).filter(Lyric.id == lyric_id).first()
106
+ if not db_lyric:
107
+ raise HTTPException(status_code=404, detail="Lyric not found")
108
+ if db_lyric.created_by != current_user.id:
109
+ allowed_group_ids = list(db_lyric.group_ids)
110
+ if db_lyric.group_id is not None:
111
+ allowed_group_ids.append(db_lyric.group_id)
112
+ user_groups_with_perm = db.query(UserGroup).filter(
113
+ UserGroup.user_id == current_user.id,
114
+ UserGroup.estado == "activo",
115
+ UserGroup.group_id.in_(allowed_group_ids),
116
+ (UserGroup.role == "admin") | (UserGroup.perm_ver_letras == True)
117
+ ).first()
118
+ if not user_groups_with_perm:
119
+ raise HTTPException(status_code=403, detail="Not authorized to view this lyric")
120
+ return db_lyric
121
+
122
+ @router.put("/{lyric_id}", response_model=LyricOut)
123
+ def update_lyric(
124
+ lyric_id: int,
125
+ lyric_update: LyricCreate,
126
+ edit_context_group_id: Optional[int] = None,
127
+ edit_context_repertoire_id: Optional[int] = None,
128
+ update_references: Optional[bool] = None,
129
+ db: Session = Depends(get_db),
130
+ current_user: User = Depends(get_current_user)
131
+ ):
132
+ db_lyric = db.query(Lyric).filter(Lyric.id == lyric_id).first()
133
+ if not db_lyric:
134
+ raise HTTPException(status_code=404, detail="Lyric not found")
135
+
136
+ is_authorized = False
137
+ if db_lyric.created_by == current_user.id:
138
+ is_authorized = True
139
+ else:
140
+ from app.routers.alabanza import check_group_permission
141
+ allowed_group_ids = list(db_lyric.group_ids)
142
+ if db_lyric.group_id is not None:
143
+ allowed_group_ids.append(db_lyric.group_id)
144
+ for gid in allowed_group_ids:
145
+ ug = db.query(UserGroup).filter(
146
+ UserGroup.user_id == current_user.id,
147
+ UserGroup.group_id == gid,
148
+ UserGroup.estado == "activo"
149
+ ).first()
150
+ if ug and (ug.role == "admin" or check_group_permission(ug, "letras", "edit", db)):
151
+ is_authorized = True
152
+ break
153
+
154
+ if not is_authorized:
155
+ raise HTTPException(status_code=403, detail="Not authorized to edit this lyric")
156
+
157
+ # --- CASO 1: Edición con Contexto de Repertorio ---
158
+ if edit_context_repertoire_id is not None:
159
+ repertoire = db.query(Repertorio).filter(Repertorio.id == edit_context_repertoire_id).first()
160
+ if not repertoire:
161
+ raise HTTPException(status_code=404, detail="Repertorio no encontrado")
162
+
163
+ if db_lyric.is_repertoire_only:
164
+ # Actualizar directamente si ya es exclusivo de repertorios
165
+ for key, value in lyric_update.dict().items():
166
+ setattr(db_lyric, key, value)
167
+ db.commit()
168
+ db.refresh(db_lyric)
169
+ return db_lyric
170
+ else:
171
+ # Crear clon para el repertorio
172
+ cloned_lyric = Lyric(
173
+ title=lyric_update.title,
174
+ artist=lyric_update.artist,
175
+ content=lyric_update.content,
176
+ key=lyric_update.key,
177
+ bpm=lyric_update.bpm,
178
+ transpose_offset=lyric_update.transpose_offset,
179
+ group_id=repertoire.group_id,
180
+ created_by=db_lyric.created_by,
181
+ is_repertoire_only=True,
182
+ parent_lyric_id=db_lyric.id
183
+ )
184
+ db.add(cloned_lyric)
185
+ db.flush()
186
+
187
+ # Compartir con el grupo si el repertorio pertenece a uno
188
+ if repertoire.group_id is not None:
189
+ group = db.query(Group).filter(Group.id == repertoire.group_id).first()
190
+ if group:
191
+ cloned_lyric.groups_shared.append(group)
192
+
193
+ # Actualizar relación en repertorio_lyrics
194
+ rep_lyric = db.query(RepertorioLyric).filter(
195
+ RepertorioLyric.repertorio_id == edit_context_repertoire_id,
196
+ RepertorioLyric.lyric_id == db_lyric.id
197
+ ).first()
198
+ if rep_lyric:
199
+ order = rep_lyric.order
200
+ db.delete(rep_lyric)
201
+ db.flush()
202
+ new_rep_lyric = RepertorioLyric(
203
+ repertorio_id=edit_context_repertoire_id,
204
+ lyric_id=cloned_lyric.id,
205
+ order=order
206
+ )
207
+ db.add(new_rep_lyric)
208
+
209
+ db.commit()
210
+ db.refresh(cloned_lyric)
211
+
212
+ if repertoire.group_id is not None:
213
+ try:
214
+ from app.routers.alabanza import broadcast_to_group
215
+ broadcast_to_group(repertoire.group_id, {"type": "group_updated", "grupo_id": repertoire.group_id}, db)
216
+ except Exception:
217
+ pass
218
+ return cloned_lyric
219
+
220
+ # --- CASO 2: Edición con Contexto de Grupo ---
221
+ elif edit_context_group_id is not None:
222
+ group = db.query(Group).filter(Group.id == edit_context_group_id).first()
223
+ if not group:
224
+ raise HTTPException(status_code=404, detail="Grupo no encontrado")
225
+
226
+ is_exclusive_to_group = db_lyric.group_id == edit_context_group_id and len(db_lyric.group_ids) <= 1 and not db_lyric.is_repertoire_only
227
+ if is_exclusive_to_group:
228
+ for key, value in lyric_update.dict().items():
229
+ setattr(db_lyric, key, value)
230
+ db.commit()
231
+ db.refresh(db_lyric)
232
+ try:
233
+ from app.routers.alabanza import broadcast_to_group
234
+ broadcast_to_group(edit_context_group_id, {"type": "group_updated", "grupo_id": edit_context_group_id}, db)
235
+ except Exception:
236
+ pass
237
+ return db_lyric
238
+ else:
239
+ # Crear clon para el grupo
240
+ cloned_lyric = Lyric(
241
+ title=lyric_update.title,
242
+ artist=lyric_update.artist,
243
+ content=lyric_update.content,
244
+ key=lyric_update.key,
245
+ bpm=lyric_update.bpm,
246
+ transpose_offset=lyric_update.transpose_offset,
247
+ group_id=edit_context_group_id,
248
+ created_by=db_lyric.created_by,
249
+ is_repertoire_only=False,
250
+ parent_lyric_id=db_lyric.id
251
+ )
252
+ cloned_lyric.groups_shared.append(group)
253
+ db.add(cloned_lyric)
254
+ db.flush()
255
+
256
+ # Desvincular original del grupo
257
+ if group in db_lyric.groups_shared:
258
+ db_lyric.groups_shared.remove(group)
259
+ if db_lyric.group_id == edit_context_group_id:
260
+ db_lyric.group_id = db_lyric.groups_shared[0].id if db_lyric.groups_shared else None
261
+
262
+ # Actualizar todos los repertorios de este grupo que apuntaban a la original
263
+ group_rep_ids = [r.id for r in db.query(Repertorio.id).filter(Repertorio.group_id == edit_context_group_id).all()]
264
+ if group_rep_ids:
265
+ rep_lyrics = db.query(RepertorioLyric).filter(
266
+ RepertorioLyric.repertorio_id.in_(group_rep_ids),
267
+ RepertorioLyric.lyric_id == db_lyric.id
268
+ ).all()
269
+ for rl in rep_lyrics:
270
+ order = rl.order
271
+ rep_id = rl.repertorio_id
272
+ db.delete(rl)
273
+ db.flush()
274
+ new_rl = RepertorioLyric(
275
+ repertorio_id=rep_id,
276
+ lyric_id=cloned_lyric.id,
277
+ order=order
278
+ )
279
+ db.add(new_rl)
280
+
281
+ db.commit()
282
+ db.refresh(cloned_lyric)
283
+ try:
284
+ from app.routers.alabanza import broadcast_to_group
285
+ broadcast_to_group(edit_context_group_id, {"type": "group_updated", "grupo_id": edit_context_group_id}, db)
286
+ except Exception:
287
+ pass
288
+ return cloned_lyric
289
+
290
+ # --- CASO 3: Edición desde el Catálogo Personal (Creador original) ---
291
+ else:
292
+ has_shared_references = len(db_lyric.groups_shared) > 0
293
+ if has_shared_references and update_references is False:
294
+ # Clonar la letra original con el contenido actual (viejo) para los grupos y repertorios
295
+ old_cloned_lyric = Lyric(
296
+ title=db_lyric.title,
297
+ artist=db_lyric.artist,
298
+ content=db_lyric.content,
299
+ key=db_lyric.key,
300
+ bpm=db_lyric.bpm,
301
+ transpose_offset=db_lyric.transpose_offset,
302
+ group_id=db_lyric.group_id,
303
+ created_by=db_lyric.created_by,
304
+ is_repertoire_only=False,
305
+ parent_lyric_id=db_lyric.id
306
+ )
307
+ shared_groups = list(db_lyric.groups_shared)
308
+ for g in shared_groups:
309
+ old_cloned_lyric.groups_shared.append(g)
310
+ db.add(old_cloned_lyric)
311
+ db.flush()
312
+
313
+ # Desvincular original de los grupos
314
+ db_lyric.groups_shared.clear()
315
+ db_lyric.group_id = None
316
+
317
+ # Actualizar referencias de repertorios de esos grupos para que usen el clon viejo
318
+ shared_group_ids = [g.id for g in shared_groups]
319
+ if shared_group_ids:
320
+ group_rep_ids = [r.id for r in db.query(Repertorio.id).filter(Repertorio.group_id.in_(shared_group_ids)).all()]
321
+ if group_rep_ids:
322
+ rep_lyrics = db.query(RepertorioLyric).filter(
323
+ RepertorioLyric.repertorio_id.in_(group_rep_ids),
324
+ RepertorioLyric.lyric_id == db_lyric.id
325
+ ).all()
326
+ for rl in rep_lyrics:
327
+ order = rl.order
328
+ rep_id = rl.repertorio_id
329
+ db.delete(rl)
330
+ db.flush()
331
+ new_rl = RepertorioLyric(
332
+ repertorio_id=rep_id,
333
+ lyric_id=old_cloned_lyric.id,
334
+ order=order
335
+ )
336
+ db.add(new_rl)
337
+
338
+ # Guardar los nuevos cambios en la original (personal)
339
+ for key, value in lyric_update.dict().items():
340
+ if key != "group_id":
341
+ setattr(db_lyric, key, value)
342
+
343
+ db.commit()
344
+ db.refresh(db_lyric)
345
+
346
+ for gid in shared_group_ids:
347
+ try:
348
+ from app.routers.alabanza import broadcast_to_group
349
+ broadcast_to_group(gid, {"type": "group_updated", "grupo_id": gid}, db)
350
+ except Exception:
351
+ pass
352
+ return db_lyric
353
+ else:
354
+ # Edición directa (in-place)
355
+ for key, value in lyric_update.dict().items():
356
+ setattr(db_lyric, key, value)
357
+
358
+ if db_lyric.group_id is not None:
359
+ group = db.query(Group).filter(Group.id == db_lyric.group_id).first()
360
+ if group and group not in db_lyric.groups_shared:
361
+ db_lyric.groups_shared.append(group)
362
+
363
+ db.commit()
364
+ db.refresh(db_lyric)
365
+ for gid in db_lyric.group_ids:
366
+ try:
367
+ from app.routers.alabanza import broadcast_to_group
368
+ broadcast_to_group(gid, {"type": "group_updated", "grupo_id": gid}, db)
369
+ except Exception:
370
+ pass
371
+ return db_lyric
372
+
373
+ @router.delete("/{lyric_id}")
374
+ def delete_lyric(lyric_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
375
+ db_lyric = db.query(Lyric).filter(Lyric.id == lyric_id).first()
376
+ if not db_lyric:
377
+ raise HTTPException(status_code=404, detail="Lyric not found")
378
+
379
+ is_authorized = False
380
+ if db_lyric.created_by == current_user.id:
381
+ is_authorized = True
382
+ else:
383
+ from app.routers.alabanza import check_group_permission
384
+ allowed_group_ids = list(db_lyric.group_ids)
385
+ if db_lyric.group_id is not None:
386
+ allowed_group_ids.append(db_lyric.group_id)
387
+ for gid in allowed_group_ids:
388
+ ug = db.query(UserGroup).filter(
389
+ UserGroup.user_id == current_user.id,
390
+ UserGroup.group_id == gid,
391
+ UserGroup.estado == "activo"
392
+ ).first()
393
+ if ug and (ug.role == "admin" or check_group_permission(ug, "letras", "delete", db)):
394
+ is_authorized = True
395
+ break
396
+
397
+ if not is_authorized:
398
+ raise HTTPException(status_code=403, detail="Not authorized to delete this lyric")
399
+
400
+ shared_group_ids = list(db_lyric.group_ids)
401
+ db.delete(db_lyric)
402
+ db.commit()
403
+ for gid in shared_group_ids:
404
+ try:
405
+ from app.routers.alabanza import broadcast_to_group
406
+ broadcast_to_group(gid, {"type": "group_updated", "grupo_id": gid}, db)
407
+ except Exception:
408
+ pass
409
+ return {"message": "Lyric deleted successfully"}
410
+
411
+
412
+
413
+ @router.post("/repertorios", response_model=RepertorioOut)
414
+ def create_repertoire(rep: RepertorioCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
415
+ if rep.group_id is not None:
416
+ ug = db.query(UserGroup).filter(UserGroup.user_id == current_user.id, UserGroup.group_id == rep.group_id, UserGroup.estado == "activo").first()
417
+ if not ug:
418
+ raise HTTPException(status_code=403, detail="No perteneces de forma activa a este grupo")
419
+ if ug.role != "admin" and not ug.perm_crud_repertorios:
420
+ raise HTTPException(status_code=403, detail="No tienes permisos para crear repertorios en este grupo")
421
+
422
+ db_rep = Repertorio(name=rep.name, group_id=rep.group_id, color=rep.color, created_by=current_user.id)
423
+ db.add(db_rep)
424
+ db.commit()
425
+ db.refresh(db_rep)
426
+
427
+ if rep.lyrics_ids:
428
+ for idx, lyric_id in enumerate(rep.lyrics_ids):
429
+ db_rep_lyric = RepertorioLyric(repertorio_id=db_rep.id, lyric_id=lyric_id, order=idx)
430
+ db.add(db_rep_lyric)
431
+ db.commit()
432
+
433
+ if db_rep.group_id is not None:
434
+ try:
435
+ from app.routers.alabanza import broadcast_to_group
436
+ broadcast_to_group(db_rep.group_id, {"type": "group_updated", "grupo_id": db_rep.group_id}, db)
437
+ except Exception:
438
+ pass
439
+ return db_rep
440
+
441
+ @router.put("/repertorios/{id}", response_model=RepertorioOut)
442
+ def update_repertoire(id: int, rep: RepertorioCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
443
+ db_rep = db.query(Repertorio).filter(Repertorio.id == id).first()
444
+ if not db_rep:
445
+ raise HTTPException(status_code=404, detail="Repertoire not found")
446
+
447
+ is_authorized = False
448
+ if db_rep.created_by == current_user.id:
449
+ is_authorized = True
450
+ elif db_rep.group_id is not None:
451
+ ug = db.query(UserGroup).filter(
452
+ UserGroup.user_id == current_user.id,
453
+ UserGroup.group_id == db_rep.group_id,
454
+ UserGroup.estado == "activo"
455
+ ).first()
456
+ if ug and (ug.role == "admin" or ug.perm_crud_repertorios):
457
+ is_authorized = True
458
+
459
+ if not is_authorized:
460
+ raise HTTPException(status_code=403, detail="No tienes permisos para editar este repertorio")
461
+
462
+ db_rep.name = rep.name
463
+ db_rep.color = rep.color
464
+ if rep.group_id is not None:
465
+ db_rep.created_at = datetime.utcnow()
466
+ db_rep.group_id = rep.group_id
467
+ db.commit()
468
+
469
+ # Actualizar letras
470
+ # Eliminar las actuales
471
+ db.query(RepertorioLyric).filter(RepertorioLyric.repertorio_id == id).delete()
472
+
473
+ # Agregar las nuevas
474
+ if rep.lyrics_ids:
475
+ for idx, lyric_id in enumerate(rep.lyrics_ids):
476
+ db_rep_lyric = RepertorioLyric(repertorio_id=id, lyric_id=lyric_id, order=idx)
477
+ db.add(db_rep_lyric)
478
+
479
+ db.commit()
480
+ db.refresh(db_rep)
481
+ if db_rep.group_id is not None:
482
+ try:
483
+ from app.routers.alabanza import broadcast_to_group
484
+ broadcast_to_group(db_rep.group_id, {"type": "group_updated", "grupo_id": db_rep.group_id}, db)
485
+ except Exception:
486
+ pass
487
+ return db_rep
488
+
489
+ @router.delete("/repertorios/{id}")
490
+ def delete_repertoire(id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
491
+ db_rep = db.query(Repertorio).filter(Repertorio.id == id).first()
492
+ if not db_rep:
493
+ raise HTTPException(status_code=404, detail="Repertoire not found")
494
+
495
+ is_authorized = False
496
+ if db_rep.created_by == current_user.id:
497
+ is_authorized = True
498
+ elif db_rep.group_id is not None:
499
+ ug = db.query(UserGroup).filter(
500
+ UserGroup.user_id == current_user.id,
501
+ UserGroup.group_id == db_rep.group_id,
502
+ UserGroup.estado == "activo"
503
+ ).first()
504
+ if ug and (ug.role == "admin" or ug.perm_crud_repertorios):
505
+ is_authorized = True
506
+
507
+ if not is_authorized:
508
+ raise HTTPException(status_code=403, detail="No tienes permisos para eliminar este repertorio")
509
+
510
+ group_id = db_rep.group_id
511
+ db.query(RepertorioLyric).filter(RepertorioLyric.repertorio_id == id).delete()
512
+ db.delete(db_rep)
513
+ db.commit()
514
+ if group_id is not None:
515
+ try:
516
+ from app.routers.alabanza import broadcast_to_group
517
+ broadcast_to_group(group_id, {"type": "group_updated", "grupo_id": group_id}, db)
518
+ except Exception:
519
+ pass
520
+ return {"message": "Repertorio eliminado exitosamente"}
521
+
522
+ @router.post("/repertorios/{id}/lyrics")
523
+ def add_lyric_to_repertoire(id: int, lyric_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
524
+ db_rep = db.query(Repertorio).filter(Repertorio.id == id).first()
525
+ if not db_rep:
526
+ raise HTTPException(status_code=404, detail="Repertoire not found")
527
+
528
+ is_authorized = False
529
+ if db_rep.created_by == current_user.id:
530
+ is_authorized = True
531
+ elif db_rep.group_id is not None:
532
+ ug = db.query(UserGroup).filter(
533
+ UserGroup.user_id == current_user.id,
534
+ UserGroup.group_id == db_rep.group_id,
535
+ UserGroup.estado == "activo"
536
+ ).first()
537
+ if ug and (ug.role == "admin" or ug.perm_crud_repertorios):
538
+ is_authorized = True
539
+
540
+ if not is_authorized:
541
+ raise HTTPException(status_code=403, detail="No tienes permisos para modificar este repertorio")
542
+
543
+ exists = db.query(RepertorioLyric).filter_by(repertorio_id=id, lyric_id=lyric_id).first()
544
+ if exists:
545
+ return {"message": "La letra ya está en el repertorio"}
546
+
547
+ last_order = db.query(RepertorioLyric).filter_by(repertorio_id=id).count()
548
+
549
+ db_rep_lyric = RepertorioLyric(repertorio_id=id, lyric_id=lyric_id, order=last_order)
550
+ db.add(db_rep_lyric)
551
+ db.commit()
552
+
553
+ if db_rep.group_id is not None:
554
+ try:
555
+ from app.routers.alabanza import broadcast_to_group
556
+ broadcast_to_group(db_rep.group_id, {"type": "group_updated", "grupo_id": db_rep.group_id}, db)
557
+ except Exception:
558
+ pass
559
+ return {"message": "Letra agregada al repertorio"}
560
+
561
+ @router.get("/repertorios/{id}")
562
+ def get_repertoire(id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
563
+ rep = db.query(Repertorio).filter(Repertorio.id == id).first()
564
+ if not rep:
565
+ raise HTTPException(status_code=404, detail="Repertoire not found")
566
+
567
+ if rep.group_id is not None:
568
+ ug = db.query(UserGroup).filter(
569
+ UserGroup.user_id == current_user.id,
570
+ UserGroup.group_id == rep.group_id,
571
+ UserGroup.estado == "activo"
572
+ ).first()
573
+ if not ug:
574
+ raise HTTPException(status_code=403, detail="No tienes acceso a este repertorio")
575
+ if ug.role != "admin" and not ug.perm_ver_repertorios and rep.created_by != current_user.id:
576
+ raise HTTPException(status_code=403, detail="No tienes permisos para ver este repertorio")
577
+ else:
578
+ if rep.created_by != current_user.id:
579
+ raise HTTPException(status_code=403, detail="No tienes acceso a este repertorio")
580
+
581
+ # Ordenar letras por el campo 'order'
582
+ sorted_lyrics = sorted(rep.lyrics, key=lambda x: x.order if x.order is not None else 0)
583
+ lyrics = [rl.lyric for rl in sorted_lyrics if rl.lyric is not None]
584
+
585
+ return {
586
+ "id": rep.id,
587
+ "name": rep.name,
588
+ "group_id": rep.group_id,
589
+ "created_by": rep.created_by,
590
+ "created_at": rep.created_at,
591
+ "lyrics": lyrics
592
+ }
593
+
594
+ # --- INVITATIONS ENDPOINTS ---
595
+
596
+ @router.post("/groups/{group_id}/invite", response_model=InvitationOut)
597
+ def create_invitation(group_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
598
+ # Verificar que el usuario sea admin del grupo
599
+ ug = db.query(UserGroup).filter_by(user_id=current_user.id, group_id=group_id, role="admin").first()
600
+ if not ug:
601
+ raise HTTPException(status_code=403, detail="Solo los administradores pueden crear invitaciones")
602
+
603
+ code = str(uuid.uuid4())[:8] # Código corto
604
+ db_invite = Invitation(code=code, group_id=group_id, created_by=current_user.id)
605
+ db.add(db_invite)
606
+ db.commit()
607
+ db.refresh(db_invite)
608
+ return db_invite
609
+
610
+ @router.post("/join/{code}")
611
+ def join_group(code: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
612
+ invite = db.query(Invitation).filter_by(code=code).first()
613
+ if not invite:
614
+ raise HTTPException(status_code=404, detail="Invitación no encontrada")
615
+
616
+ # Verificar si pertenece a otro grupo (activo o pendiente)
617
+ other_groups = db.query(UserGroup).filter(
618
+ UserGroup.user_id == current_user.id,
619
+ UserGroup.group_id != invite.group_id,
620
+ UserGroup.estado.in_(["activo", "pendiente"])
621
+ ).all()
622
+
623
+ # Verificar si ya está en este grupo
624
+ ug = db.query(UserGroup).filter_by(user_id=current_user.id, group_id=invite.group_id).first()
625
+
626
+ # Cobrar 1 crédito si ya pertenece a otro grupo y está solicitando unirse (nuevo o reingreso)
627
+ if len(other_groups) >= 1 and (ug is None or ug.estado in ("expulsado", "salido")):
628
+ deduct_credits(db, current_user, 1, "unirse_grupo_extra", reference_id=code)
629
+ if ug:
630
+ if ug.estado == "expulsado":
631
+ ug.estado = "pendiente"
632
+ db.commit()
633
+ return {"message": "Solicitud enviada, esperando aprobación", "group_id": invite.group_id}
634
+ elif ug.estado == "pendiente":
635
+ return {"message": "Tu solicitud está esperando aprobación", "group_id": invite.group_id}
636
+ elif ug.estado == "activo":
637
+ raise HTTPException(status_code=400, detail="Ya eres miembro de este grupo")
638
+
639
+ # Unirse al grupo
640
+ db_ug = UserGroup(user_id=current_user.id, group_id=invite.group_id, role="member", estado="activo")
641
+ db.add(db_ug)
642
+ db.commit()
643
+
644
+ return {"message": "Te has unido al grupo exitosamente", "group_id": invite.group_id}
645
+
646
+ @router.delete("/groups/{group_id}/members/{user_id}")
647
+ def expulsar_miembro_grupo(group_id: int, user_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
648
+ ug_admin = db.query(UserGroup).filter_by(user_id=current_user.id, group_id=group_id, role="admin", estado="activo").first()
649
+ if not ug_admin:
650
+ raise HTTPException(status_code=403, detail="No autorizado")
651
+
652
+ ug_target = db.query(UserGroup).filter_by(user_id=user_id, group_id=group_id).first()
653
+ if not ug_target:
654
+ raise HTTPException(status_code=404, detail="Usuario no encontrado en el grupo")
655
+
656
+ ug_target.estado = "expulsado"
657
+ db.commit()
658
+ return {"message": "Usuario expulsado del grupo"}
659
+
660
+ @router.get("/groups/{group_id}/solicitudes")
661
+ def ver_solicitudes_grupo(group_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
662
+ ug_admin = db.query(UserGroup).filter_by(user_id=current_user.id, group_id=group_id, role="admin", estado="activo").first()
663
+ if not ug_admin:
664
+ raise HTTPException(status_code=403, detail="No autorizado")
665
+
666
+ pendientes = db.query(UserGroup).filter_by(group_id=group_id, estado="pendiente").all()
667
+ return [
668
+ {
669
+ "user_id": p.user_id,
670
+ "username": p.user.username if p.user else "—",
671
+ "requested_at": p.joined_at.isoformat() if p.joined_at else None,
672
+ }
673
+ for p in pendientes
674
+ ]
675
+
676
+ from pydantic import BaseModel
677
+ class ResolverSolicitudGrupo(BaseModel):
678
+ accion: str
679
+
680
+ @router.put("/groups/{group_id}/solicitudes/{user_id}")
681
+ def resolver_solicitud_grupo(group_id: int, user_id: int, data: ResolverSolicitudGrupo, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
682
+ ug_admin = db.query(UserGroup).filter_by(user_id=current_user.id, group_id=group_id, role="admin", estado="activo").first()
683
+ if not ug_admin:
684
+ raise HTTPException(status_code=403, detail="No autorizado")
685
+
686
+ ug_target = db.query(UserGroup).filter_by(user_id=user_id, group_id=group_id, estado="pendiente").first()
687
+ if not ug_target:
688
+ raise HTTPException(status_code=404, detail="Solicitud no encontrada")
689
+
690
+ if data.accion == "aprobar":
691
+ ug_target.estado = "activo"
692
+ elif data.accion == "rechazar":
693
+ ug_target.estado = "expulsado"
694
+ else:
695
+ raise HTTPException(status_code=400, detail="Acción inválida")
696
+
697
+ db.commit()
698
+ return {"message": f"Solicitud {data.accion}da exitosamente"}
699
+
700
+ class ShareLyricRequest(BaseModel):
701
+ group_id: int
702
+ action: str # "share" o "unshare"
703
+
704
+ @router.post("/{lyric_id}/share")
705
+ def share_lyric(
706
+ lyric_id: int,
707
+ data: ShareLyricRequest,
708
+ db: Session = Depends(get_db),
709
+ current_user: User = Depends(get_current_user)
710
+ ):
711
+ lyric = db.query(Lyric).filter(Lyric.id == lyric_id).first()
712
+ if not lyric:
713
+ raise HTTPException(status_code=404, detail="Letra no encontrada")
714
+
715
+ ug = db.query(UserGroup).filter(UserGroup.user_id == current_user.id, UserGroup.group_id == data.group_id, UserGroup.estado == "activo").first()
716
+ if not ug:
717
+ raise HTTPException(status_code=403, detail="No perteneces de forma activa a este grupo")
718
+
719
+ is_owner = lyric.created_by == current_user.id
720
+ is_group_admin = ug.role == "admin"
721
+
722
+ # Para "share": creador, admin, o miembro con perm_compartir_letras
723
+ # Para "unshare": sólo creador o admin (para no desvincular letras ajenas)
724
+ if data.action == "share":
725
+ if not is_owner and not is_group_admin and not ug.perm_compartir_letras:
726
+ raise HTTPException(status_code=403, detail="No tienes permisos para compartir letras con este grupo")
727
+ elif data.action == "unshare":
728
+ if not is_owner and not is_group_admin:
729
+ raise HTTPException(status_code=403, detail="Solo el creador o un administrador puede desvincular esta letra del grupo")
730
+
731
+ group = db.query(Group).filter(Group.id == data.group_id).first()
732
+ if not group:
733
+ raise HTTPException(status_code=404, detail="Grupo no encontrado")
734
+
735
+ if data.action == "share":
736
+ if ug.role != "admin" and not ug.perm_compartir_letras:
737
+ raise HTTPException(status_code=403, detail="No tienes permisos para compartir letras con este grupo")
738
+ if group not in lyric.groups_shared:
739
+ lyric.groups_shared.append(group)
740
+ if lyric.group_id is None:
741
+ lyric.group_id = data.group_id
742
+ lyric.created_at = datetime.utcnow()
743
+ elif data.action == "unshare":
744
+ if group in lyric.groups_shared:
745
+ lyric.groups_shared.remove(group)
746
+ if lyric.group_id == data.group_id:
747
+ lyric.group_id = lyric.groups_shared[0].id if lyric.groups_shared else None
748
+ else:
749
+ raise HTTPException(status_code=400, detail="Acción inválida")
750
+
751
+ db.commit()
752
+ try:
753
+ from app.routers.alabanza import broadcast_to_group
754
+ broadcast_to_group(data.group_id, {"type": "group_updated", "grupo_id": data.group_id}, db)
755
+ except Exception:
756
+ pass
757
+ return {"message": "Letra compartida con éxito" if data.action == "share" else "Letra desvinculada del grupo"}
app/routers/midi.py CHANGED
@@ -1,128 +1,128 @@
1
- from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
2
- from sqlalchemy.orm import Session
3
- from typing import List, Optional
4
- import os
5
- import uuid
6
- import shutil
7
- from app.database import get_db
8
- from app.models.midi import PartituraMidi
9
- from app.utils.midi_parser import parse_midi_file
10
- from datetime import datetime
11
-
12
- router = APIRouter(prefix="/midi", tags=["MIDI & Partituras"])
13
-
14
- BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15
- MIDI_UPLOAD_DIR = os.path.join(BASE_DIR, "uploads", "midi")
16
- os.makedirs(MIDI_UPLOAD_DIR, exist_ok=True)
17
-
18
- @router.post("/upload", status_code=status.HTTP_201_CREATED)
19
- async def upload_midi_file(
20
- file: UploadFile = File(...),
21
- title: Optional[str] = Form(None),
22
- artist: Optional[str] = Form("Artista desconocido"),
23
- device_id: Optional[str] = Form(None),
24
- db: Session = Depends(get_db)
25
- ):
26
- if not file.filename.lower().endswith((".mid", ".midi")):
27
- raise HTTPException(status_code=400, detail="Solo se permiten archivos en formato MIDI (.mid o .midi).")
28
-
29
- song_title = title if title else os.path.splitext(file.filename)[0]
30
- unique_filename = f"{uuid.uuid4().hex}_{file.filename}"
31
- filepath = os.path.join(MIDI_UPLOAD_DIR, unique_filename)
32
-
33
- with open(filepath, "wb") as buffer:
34
- shutil.copyfileobj(file.file, buffer)
35
-
36
- # Validar que sea parseable antes de guardarlo en DB
37
- parse_result = parse_midi_file(filepath)
38
- if not parse_result.get("success"):
39
- if os.path.exists(filepath):
40
- os.remove(filepath)
41
- raise HTTPException(status_code=400, detail=parse_result.get("error", "Error parseando archivo MIDI."))
42
-
43
- tempo = parse_result.get("tempo_bpm", 120.0)
44
-
45
- partitura = PartituraMidi(
46
- title=song_title,
47
- artist=artist,
48
- filename=file.filename,
49
- filepath=filepath,
50
- tempo_bpm=tempo,
51
- device_id=device_id,
52
- created_at=datetime.utcnow()
53
- )
54
-
55
- db.add(partitura)
56
- db.commit()
57
- db.refresh(partitura)
58
-
59
- return {
60
- "success": True,
61
- "message": "Archivo MIDI subido y verificado correctamente.",
62
- "partitura": {
63
- "id": partitura.id,
64
- "title": partitura.title,
65
- "artist": partitura.artist,
66
- "filename": partitura.filename,
67
- "tempo_bpm": partitura.tempo_bpm,
68
- "created_at": partitura.created_at
69
- }
70
- }
71
-
72
- @router.get("/list")
73
- def list_midi_files(device_id: Optional[str] = None, db: Session = Depends(get_db)):
74
- query = db.query(PartituraMidi)
75
- if device_id:
76
- query = query.filter((PartituraMidi.device_id == device_id) | (PartituraMidi.device_id == None))
77
- partituras = query.order_by(PartituraMidi.created_at.desc()).all()
78
-
79
- result = []
80
- for p in partituras:
81
- result.append({
82
- "id": p.id,
83
- "title": p.title,
84
- "artist": p.artist,
85
- "filename": p.filename,
86
- "tempo_bpm": p.tempo_bpm,
87
- "created_at": p.created_at
88
- })
89
- return result
90
-
91
- @router.get("/{partitura_id:int}/data")
92
- def get_midi_data(partitura_id: int, db: Session = Depends(get_db)):
93
- partitura = db.query(PartituraMidi).filter(PartituraMidi.id == partitura_id).first()
94
- if not partitura:
95
- raise HTTPException(status_code=404, detail="Partitura o archivo MIDI no encontrado.")
96
-
97
- if not os.path.exists(partitura.filepath):
98
- raise HTTPException(status_code=404, detail="El archivo físico MIDI no se encuentra en el servidor.")
99
-
100
- data = parse_midi_file(partitura.filepath)
101
- if not data.get("success"):
102
- raise HTTPException(status_code=500, detail=data.get("error", "Error interno parseando el archivo."))
103
-
104
- return {
105
- "id": partitura.id,
106
- "title": partitura.title,
107
- "artist": partitura.artist,
108
- "filename": partitura.filename,
109
- "tempo_bpm": partitura.tempo_bpm,
110
- "data": data
111
- }
112
-
113
- @router.delete("/{partitura_id:int}")
114
- def delete_midi_file(partitura_id: int, db: Session = Depends(get_db)):
115
- partitura = db.query(PartituraMidi).filter(PartituraMidi.id == partitura_id).first()
116
- if not partitura:
117
- raise HTTPException(status_code=404, detail="Partitura o archivo MIDI no encontrado.")
118
-
119
- if os.path.exists(partitura.filepath):
120
- try:
121
- os.remove(partitura.filepath)
122
- except Exception as e:
123
- pass
124
-
125
- db.delete(partitura)
126
- db.commit()
127
-
128
- return {"success": True, "message": "Partitura eliminada correctamente."}
 
1
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
2
+ from sqlalchemy.orm import Session
3
+ from typing import List, Optional
4
+ import os
5
+ import uuid
6
+ import shutil
7
+ from app.database import get_db
8
+ from app.models.midi import PartituraMidi
9
+ from app.utils.midi_parser import parse_midi_file
10
+ from datetime import datetime
11
+
12
+ router = APIRouter(prefix="/midi", tags=["MIDI & Partituras"])
13
+
14
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15
+ MIDI_UPLOAD_DIR = os.path.join(BASE_DIR, "uploads", "midi")
16
+ os.makedirs(MIDI_UPLOAD_DIR, exist_ok=True)
17
+
18
+ @router.post("/upload", status_code=status.HTTP_201_CREATED)
19
+ async def upload_midi_file(
20
+ file: UploadFile = File(...),
21
+ title: Optional[str] = Form(None),
22
+ artist: Optional[str] = Form("Artista desconocido"),
23
+ device_id: Optional[str] = Form(None),
24
+ db: Session = Depends(get_db)
25
+ ):
26
+ if not file.filename.lower().endswith((".mid", ".midi")):
27
+ raise HTTPException(status_code=400, detail="Solo se permiten archivos en formato MIDI (.mid o .midi).")
28
+
29
+ song_title = title if title else os.path.splitext(file.filename)[0]
30
+ unique_filename = f"{uuid.uuid4().hex}_{file.filename}"
31
+ filepath = os.path.join(MIDI_UPLOAD_DIR, unique_filename)
32
+
33
+ with open(filepath, "wb") as buffer:
34
+ shutil.copyfileobj(file.file, buffer)
35
+
36
+ # Validar que sea parseable antes de guardarlo en DB
37
+ parse_result = parse_midi_file(filepath)
38
+ if not parse_result.get("success"):
39
+ if os.path.exists(filepath):
40
+ os.remove(filepath)
41
+ raise HTTPException(status_code=400, detail=parse_result.get("error", "Error parseando archivo MIDI."))
42
+
43
+ tempo = parse_result.get("tempo_bpm", 120.0)
44
+
45
+ partitura = PartituraMidi(
46
+ title=song_title,
47
+ artist=artist,
48
+ filename=file.filename,
49
+ filepath=filepath,
50
+ tempo_bpm=tempo,
51
+ device_id=device_id,
52
+ created_at=datetime.utcnow()
53
+ )
54
+
55
+ db.add(partitura)
56
+ db.commit()
57
+ db.refresh(partitura)
58
+
59
+ return {
60
+ "success": True,
61
+ "message": "Archivo MIDI subido y verificado correctamente.",
62
+ "partitura": {
63
+ "id": partitura.id,
64
+ "title": partitura.title,
65
+ "artist": partitura.artist,
66
+ "filename": partitura.filename,
67
+ "tempo_bpm": partitura.tempo_bpm,
68
+ "created_at": partitura.created_at
69
+ }
70
+ }
71
+
72
+ @router.get("/list")
73
+ def list_midi_files(device_id: Optional[str] = None, db: Session = Depends(get_db)):
74
+ query = db.query(PartituraMidi)
75
+ if device_id:
76
+ query = query.filter((PartituraMidi.device_id == device_id) | (PartituraMidi.device_id == None))
77
+ partituras = query.order_by(PartituraMidi.created_at.desc()).all()
78
+
79
+ result = []
80
+ for p in partituras:
81
+ result.append({
82
+ "id": p.id,
83
+ "title": p.title,
84
+ "artist": p.artist,
85
+ "filename": p.filename,
86
+ "tempo_bpm": p.tempo_bpm,
87
+ "created_at": p.created_at
88
+ })
89
+ return result
90
+
91
+ @router.get("/{partitura_id:int}/data")
92
+ def get_midi_data(partitura_id: int, db: Session = Depends(get_db)):
93
+ partitura = db.query(PartituraMidi).filter(PartituraMidi.id == partitura_id).first()
94
+ if not partitura:
95
+ raise HTTPException(status_code=404, detail="Partitura o archivo MIDI no encontrado.")
96
+
97
+ if not os.path.exists(partitura.filepath):
98
+ raise HTTPException(status_code=404, detail="El archivo físico MIDI no se encuentra en el servidor.")
99
+
100
+ data = parse_midi_file(partitura.filepath)
101
+ if not data.get("success"):
102
+ raise HTTPException(status_code=500, detail=data.get("error", "Error interno parseando el archivo."))
103
+
104
+ return {
105
+ "id": partitura.id,
106
+ "title": partitura.title,
107
+ "artist": partitura.artist,
108
+ "filename": partitura.filename,
109
+ "tempo_bpm": partitura.tempo_bpm,
110
+ "data": data
111
+ }
112
+
113
+ @router.delete("/{partitura_id:int}")
114
+ def delete_midi_file(partitura_id: int, db: Session = Depends(get_db)):
115
+ partitura = db.query(PartituraMidi).filter(PartituraMidi.id == partitura_id).first()
116
+ if not partitura:
117
+ raise HTTPException(status_code=404, detail="Partitura o archivo MIDI no encontrado.")
118
+
119
+ if os.path.exists(partitura.filepath):
120
+ try:
121
+ os.remove(partitura.filepath)
122
+ except Exception as e:
123
+ pass
124
+
125
+ db.delete(partitura)
126
+ db.commit()
127
+
128
+ return {"success": True, "message": "Partitura eliminada correctamente."}
app/routers/migration.py CHANGED
@@ -1,305 +1,305 @@
1
- import os
2
- import shutil
3
- import logging
4
- from datetime import datetime, timedelta
5
- from typing import Dict, List, Optional
6
- from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
7
- from sqlalchemy.orm import Session
8
- from sqlalchemy.orm.attributes import flag_modified
9
- from pydantic import BaseModel
10
-
11
- from app.database import get_db
12
- from app.models.user import User
13
- from app.models.sync import MigrationBackup
14
- from app.routers.auth import get_current_user
15
- from app.utils.hf_storage import (
16
- upload_to_hf,
17
- delete_folder_from_hf,
18
- get_hf_download_url
19
- )
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
- router = APIRouter(prefix="/migration", tags=["Device Migration"])
24
-
25
- class BackupCreate(BaseModel):
26
- metadata_json: dict
27
-
28
- def cleanup_expired_backups(db: Session, user_id: int):
29
- """
30
- Busca y limpia respaldos que hayan pasado el límite de 7 días de validez.
31
- """
32
- now = datetime.utcnow()
33
- expired = db.query(MigrationBackup).filter(
34
- MigrationBackup.user_id == user_id,
35
- MigrationBackup.expires_at < now,
36
- MigrationBackup.status.in_(["pending", "completed"])
37
- ).all()
38
-
39
- for eb in expired:
40
- try:
41
- logger.info(f"Limpiando respaldo expirado {eb.id} para usuario {user_id}")
42
- delete_folder_from_hf(f"migrations/{user_id}")
43
- eb.status = "expired"
44
- except Exception as e:
45
- logger.error(f"Error limpiando respaldo expirado {eb.id}: {e}")
46
-
47
- if expired:
48
- db.commit()
49
-
50
- @router.post("/backup")
51
- def create_backup(
52
- data: BackupCreate,
53
- db: Session = Depends(get_db),
54
- current_user: User = Depends(get_current_user)
55
- ):
56
- """
57
- Inicia un proceso de respaldo. Elimina cualquier respaldo anterior en Hugging Face y BD.
58
- """
59
- # 1. Limpieza de expirados
60
- cleanup_expired_backups(db, current_user.id)
61
-
62
- # 2. Buscar si ya existe algún respaldo activo (pendiente o completado)
63
- active_backups = db.query(MigrationBackup).filter(
64
- MigrationBackup.user_id == current_user.id,
65
- MigrationBackup.status.in_(["pending", "completed"])
66
- ).all()
67
-
68
- # Eliminar archivos en Hugging Face y registros en BD de respaldos anteriores
69
- if active_backups:
70
- logger.info(f"Eliminando {len(active_backups)} respaldos activos previos para usuario {current_user.id}")
71
- delete_folder_from_hf(f"migrations/{current_user.id}")
72
- for ab in active_backups:
73
- db.delete(ab)
74
- db.commit()
75
-
76
- # 3. Crear el nuevo registro
77
- expires_at = datetime.utcnow() + timedelta(days=7)
78
- backup = MigrationBackup(
79
- user_id=current_user.id,
80
- expires_at=expires_at,
81
- status="pending",
82
- metadata_json=data.metadata_json,
83
- files_info=[]
84
- )
85
- db.add(backup)
86
- db.commit()
87
- db.refresh(backup)
88
-
89
- return {
90
- "success": True,
91
- "backup_id": backup.id,
92
- "expires_at": backup.expires_at.isoformat()
93
- }
94
-
95
- @router.post("/upload/{backup_id}/{task_id}/{stem_name}")
96
- def upload_backup_file(
97
- backup_id: str,
98
- task_id: str,
99
- stem_name: str,
100
- file: UploadFile = File(...),
101
- db: Session = Depends(get_db),
102
- current_user: User = Depends(get_current_user)
103
- ):
104
- """
105
- Recibe un stem individual en formato .flac o .json y lo sube a la ruta migrations/ de Hugging Face.
106
- """
107
- # Buscar y validar el respaldo
108
- backup = db.query(MigrationBackup).filter(
109
- MigrationBackup.id == backup_id,
110
- MigrationBackup.user_id == current_user.id
111
- ).first()
112
-
113
- if not backup:
114
- raise HTTPException(status_code=404, detail="Respaldo de migración no encontrado")
115
-
116
- if backup.status == "restored":
117
- raise HTTPException(status_code=400, detail="Este respaldo ya ha sido restaurado")
118
-
119
- if backup.expires_at < datetime.utcnow():
120
- backup.status = "expired"
121
- db.commit()
122
- raise HTTPException(status_code=400, detail="El respaldo de migración ha expirado")
123
-
124
- # Crear directorio temporal local para recibir el archivo
125
- temp_dir = os.path.join(os.getcwd(), "uploads", "migration_temp")
126
- os.makedirs(temp_dir, exist_ok=True)
127
- orig_ext = ""
128
- if file.filename:
129
- orig_ext = file.filename.split('.')[-1].lower()
130
-
131
- if stem_name.endswith("_pitch"):
132
- ext = "json"
133
- elif orig_ext in ["m4a", "flac"]:
134
- ext = orig_ext
135
- else:
136
- ext = "flac"
137
-
138
- temp_file_path = os.path.join(temp_dir, f"{backup_id}_{task_id}_{stem_name}.{ext}")
139
-
140
- try:
141
- # Guardar en disco local de forma temporal
142
- with open(temp_file_path, "wb") as buffer:
143
- shutil.copyfileobj(file.file, buffer)
144
-
145
- # Subir a Hugging Face Dataset
146
- object_key = f"migrations/{current_user.id}/{task_id}/{stem_name}.{ext}"
147
- public_url = upload_to_hf(temp_file_path, object_key)
148
-
149
- # Registrar el archivo en la BD
150
- files_list = list(backup.files_info) if backup.files_info else []
151
- files_list.append({
152
- "task_id": task_id,
153
- "stem_name": stem_name,
154
- "file_key": object_key
155
- })
156
- backup.files_info = files_list
157
- flag_modified(backup, "files_info")
158
- db.commit()
159
-
160
- except Exception as e:
161
- logger.error(f"Error subiendo archivo de migración {stem_name} para {task_id}: {e}")
162
- raise HTTPException(status_code=500, detail=f"Error en la subida a Hugging Face: {str(e)}")
163
- finally:
164
- if os.path.exists(temp_file_path):
165
- os.remove(temp_file_path)
166
-
167
- return {"success": True, "url": public_url}
168
-
169
- @router.post("/finalize/{backup_id}")
170
- def finalize_backup(
171
- backup_id: str,
172
- db: Session = Depends(get_db),
173
- current_user: User = Depends(get_current_user)
174
- ):
175
- """
176
- Marca el respaldo de migración como completado y listo para ser descargado.
177
- """
178
- backup = db.query(MigrationBackup).filter(
179
- MigrationBackup.id == backup_id,
180
- MigrationBackup.user_id == current_user.id
181
- ).first()
182
-
183
- if not backup:
184
- raise HTTPException(status_code=404, detail="Respaldo no encontrado")
185
-
186
- if backup.expires_at < datetime.utcnow():
187
- backup.status = "expired"
188
- db.commit()
189
- raise HTTPException(status_code=400, detail="El respaldo de migración ha expirado")
190
-
191
- backup.status = "completed"
192
- db.commit()
193
-
194
- return {"success": True}
195
-
196
- @router.get("/status")
197
- def get_backup_status(
198
- db: Session = Depends(get_db),
199
- current_user: User = Depends(get_current_user)
200
- ):
201
- """
202
- Verifica si existe un respaldo completado y vigente para el usuario actual.
203
- """
204
- cleanup_expired_backups(db, current_user.id)
205
-
206
- backup = db.query(MigrationBackup).filter(
207
- MigrationBackup.user_id == current_user.id,
208
- MigrationBackup.status == "completed"
209
- ).order_by(MigrationBackup.created_at.desc()).first()
210
-
211
- if not backup:
212
- return {"backup_available": False}
213
-
214
- return {
215
- "backup_available": True,
216
- "backup_id": backup.id,
217
- "created_at": backup.created_at.isoformat(),
218
- "expires_at": backup.expires_at.isoformat(),
219
- "files_count": len(backup.files_info) if backup.files_info else 0
220
- }
221
-
222
- @router.get("/restore")
223
- def restore_backup(
224
- db: Session = Depends(get_db),
225
- current_user: User = Depends(get_current_user)
226
- ):
227
- """
228
- Retorna el mapa de AsyncStorage y las URLs de descarga de Hugging Face para todos los stems respaldados.
229
- """
230
- cleanup_expired_backups(db, current_user.id)
231
-
232
- backup = db.query(MigrationBackup).filter(
233
- MigrationBackup.user_id == current_user.id,
234
- MigrationBackup.status == "completed"
235
- ).order_by(MigrationBackup.created_at.desc()).first()
236
-
237
- if not backup:
238
- raise HTTPException(status_code=404, detail="No tienes ningún respaldo activo listo para restaurar")
239
-
240
- # Generar URLs de descarga de Hugging Face
241
- restore_files = []
242
- files_list = backup.files_info or []
243
-
244
- for f in files_list:
245
- file_key = f["file_key"]
246
- download_url = get_hf_download_url(file_key)
247
- restore_files.append({
248
- "task_id": f["task_id"],
249
- "stem_name": f["stem_name"],
250
- "url": download_url,
251
- "file_key": file_key
252
- })
253
-
254
- return {
255
- "metadata_json": backup.metadata_json,
256
- "files": restore_files
257
- }
258
-
259
- @router.post("/complete")
260
- def complete_restore(
261
- db: Session = Depends(get_db),
262
- current_user: User = Depends(get_current_user)
263
- ):
264
- """
265
- Confirma que el nuevo dispositivo ha terminado de descargar los archivos y elimina los datos de Hugging Face.
266
- """
267
- backup = db.query(MigrationBackup).filter(
268
- MigrationBackup.user_id == current_user.id,
269
- MigrationBackup.status == "completed"
270
- ).order_by(MigrationBackup.created_at.desc()).first()
271
-
272
- if backup:
273
- # Eliminar archivos físicamente de Hugging Face
274
- logger.info(f"Limpiando carpeta de HF tras restauración exitosa de {backup.id}")
275
- delete_folder_from_hf(f"migrations/{current_user.id}")
276
- backup.status = "restored"
277
- db.commit()
278
-
279
- return {"success": True}
280
-
281
- @router.delete("/backup")
282
- def delete_backup(
283
- db: Session = Depends(get_db),
284
- current_user: User = Depends(get_current_user)
285
- ):
286
- """
287
- Elimina permanentemente cualquier respaldo activo de migración del usuario en Hugging Face y la base de datos.
288
- """
289
- active_backups = db.query(MigrationBackup).filter(
290
- MigrationBackup.user_id == current_user.id,
291
- MigrationBackup.status.in_(["pending", "completed"])
292
- ).all()
293
-
294
- if active_backups:
295
- logger.info(f"Eliminando {len(active_backups)} respaldos activos por petición de limpieza para usuario {current_user.id}")
296
- try:
297
- delete_folder_from_hf(f"migrations/{current_user.id}")
298
- except Exception as e:
299
- logger.error(f"Error borrando carpeta de HF para migración del usuario {current_user.id}: {e}")
300
- for ab in active_backups:
301
- db.delete(ab)
302
- db.commit()
303
-
304
- return {"success": True}
305
-
 
1
+ import os
2
+ import shutil
3
+ import logging
4
+ from datetime import datetime, timedelta
5
+ from typing import Dict, List, Optional
6
+ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
7
+ from sqlalchemy.orm import Session
8
+ from sqlalchemy.orm.attributes import flag_modified
9
+ from pydantic import BaseModel
10
+
11
+ from app.database import get_db
12
+ from app.models.user import User
13
+ from app.models.sync import MigrationBackup
14
+ from app.routers.auth import get_current_user
15
+ from app.utils.hf_storage import (
16
+ upload_to_hf,
17
+ delete_folder_from_hf,
18
+ get_hf_download_url
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ router = APIRouter(prefix="/migration", tags=["Device Migration"])
24
+
25
+ class BackupCreate(BaseModel):
26
+ metadata_json: dict
27
+
28
+ def cleanup_expired_backups(db: Session, user_id: int):
29
+ """
30
+ Busca y limpia respaldos que hayan pasado el límite de 7 días de validez.
31
+ """
32
+ now = datetime.utcnow()
33
+ expired = db.query(MigrationBackup).filter(
34
+ MigrationBackup.user_id == user_id,
35
+ MigrationBackup.expires_at < now,
36
+ MigrationBackup.status.in_(["pending", "completed"])
37
+ ).all()
38
+
39
+ for eb in expired:
40
+ try:
41
+ logger.info(f"Limpiando respaldo expirado {eb.id} para usuario {user_id}")
42
+ delete_folder_from_hf(f"migrations/{user_id}")
43
+ eb.status = "expired"
44
+ except Exception as e:
45
+ logger.error(f"Error limpiando respaldo expirado {eb.id}: {e}")
46
+
47
+ if expired:
48
+ db.commit()
49
+
50
+ @router.post("/backup")
51
+ def create_backup(
52
+ data: BackupCreate,
53
+ db: Session = Depends(get_db),
54
+ current_user: User = Depends(get_current_user)
55
+ ):
56
+ """
57
+ Inicia un proceso de respaldo. Elimina cualquier respaldo anterior en Hugging Face y BD.
58
+ """
59
+ # 1. Limpieza de expirados
60
+ cleanup_expired_backups(db, current_user.id)
61
+
62
+ # 2. Buscar si ya existe algún respaldo activo (pendiente o completado)
63
+ active_backups = db.query(MigrationBackup).filter(
64
+ MigrationBackup.user_id == current_user.id,
65
+ MigrationBackup.status.in_(["pending", "completed"])
66
+ ).all()
67
+
68
+ # Eliminar archivos en Hugging Face y registros en BD de respaldos anteriores
69
+ if active_backups:
70
+ logger.info(f"Eliminando {len(active_backups)} respaldos activos previos para usuario {current_user.id}")
71
+ delete_folder_from_hf(f"migrations/{current_user.id}")
72
+ for ab in active_backups:
73
+ db.delete(ab)
74
+ db.commit()
75
+
76
+ # 3. Crear el nuevo registro
77
+ expires_at = datetime.utcnow() + timedelta(days=7)
78
+ backup = MigrationBackup(
79
+ user_id=current_user.id,
80
+ expires_at=expires_at,
81
+ status="pending",
82
+ metadata_json=data.metadata_json,
83
+ files_info=[]
84
+ )
85
+ db.add(backup)
86
+ db.commit()
87
+ db.refresh(backup)
88
+
89
+ return {
90
+ "success": True,
91
+ "backup_id": backup.id,
92
+ "expires_at": backup.expires_at.isoformat()
93
+ }
94
+
95
+ @router.post("/upload/{backup_id}/{task_id}/{stem_name}")
96
+ def upload_backup_file(
97
+ backup_id: str,
98
+ task_id: str,
99
+ stem_name: str,
100
+ file: UploadFile = File(...),
101
+ db: Session = Depends(get_db),
102
+ current_user: User = Depends(get_current_user)
103
+ ):
104
+ """
105
+ Recibe un stem individual en formato .flac o .json y lo sube a la ruta migrations/ de Hugging Face.
106
+ """
107
+ # Buscar y validar el respaldo
108
+ backup = db.query(MigrationBackup).filter(
109
+ MigrationBackup.id == backup_id,
110
+ MigrationBackup.user_id == current_user.id
111
+ ).first()
112
+
113
+ if not backup:
114
+ raise HTTPException(status_code=404, detail="Respaldo de migración no encontrado")
115
+
116
+ if backup.status == "restored":
117
+ raise HTTPException(status_code=400, detail="Este respaldo ya ha sido restaurado")
118
+
119
+ if backup.expires_at < datetime.utcnow():
120
+ backup.status = "expired"
121
+ db.commit()
122
+ raise HTTPException(status_code=400, detail="El respaldo de migración ha expirado")
123
+
124
+ # Crear directorio temporal local para recibir el archivo
125
+ temp_dir = os.path.join(os.getcwd(), "uploads", "migration_temp")
126
+ os.makedirs(temp_dir, exist_ok=True)
127
+ orig_ext = ""
128
+ if file.filename:
129
+ orig_ext = file.filename.split('.')[-1].lower()
130
+
131
+ if stem_name.endswith("_pitch"):
132
+ ext = "json"
133
+ elif orig_ext in ["m4a", "flac"]:
134
+ ext = orig_ext
135
+ else:
136
+ ext = "flac"
137
+
138
+ temp_file_path = os.path.join(temp_dir, f"{backup_id}_{task_id}_{stem_name}.{ext}")
139
+
140
+ try:
141
+ # Guardar en disco local de forma temporal
142
+ with open(temp_file_path, "wb") as buffer:
143
+ shutil.copyfileobj(file.file, buffer)
144
+
145
+ # Subir a Hugging Face Dataset
146
+ object_key = f"migrations/{current_user.id}/{task_id}/{stem_name}.{ext}"
147
+ public_url = upload_to_hf(temp_file_path, object_key)
148
+
149
+ # Registrar el archivo en la BD
150
+ files_list = list(backup.files_info) if backup.files_info else []
151
+ files_list.append({
152
+ "task_id": task_id,
153
+ "stem_name": stem_name,
154
+ "file_key": object_key
155
+ })
156
+ backup.files_info = files_list
157
+ flag_modified(backup, "files_info")
158
+ db.commit()
159
+
160
+ except Exception as e:
161
+ logger.error(f"Error subiendo archivo de migración {stem_name} para {task_id}: {e}")
162
+ raise HTTPException(status_code=500, detail=f"Error en la subida a Hugging Face: {str(e)}")
163
+ finally:
164
+ if os.path.exists(temp_file_path):
165
+ os.remove(temp_file_path)
166
+
167
+ return {"success": True, "url": public_url}
168
+
169
+ @router.post("/finalize/{backup_id}")
170
+ def finalize_backup(
171
+ backup_id: str,
172
+ db: Session = Depends(get_db),
173
+ current_user: User = Depends(get_current_user)
174
+ ):
175
+ """
176
+ Marca el respaldo de migración como completado y listo para ser descargado.
177
+ """
178
+ backup = db.query(MigrationBackup).filter(
179
+ MigrationBackup.id == backup_id,
180
+ MigrationBackup.user_id == current_user.id
181
+ ).first()
182
+
183
+ if not backup:
184
+ raise HTTPException(status_code=404, detail="Respaldo no encontrado")
185
+
186
+ if backup.expires_at < datetime.utcnow():
187
+ backup.status = "expired"
188
+ db.commit()
189
+ raise HTTPException(status_code=400, detail="El respaldo de migración ha expirado")
190
+
191
+ backup.status = "completed"
192
+ db.commit()
193
+
194
+ return {"success": True}
195
+
196
+ @router.get("/status")
197
+ def get_backup_status(
198
+ db: Session = Depends(get_db),
199
+ current_user: User = Depends(get_current_user)
200
+ ):
201
+ """
202
+ Verifica si existe un respaldo completado y vigente para el usuario actual.
203
+ """
204
+ cleanup_expired_backups(db, current_user.id)
205
+
206
+ backup = db.query(MigrationBackup).filter(
207
+ MigrationBackup.user_id == current_user.id,
208
+ MigrationBackup.status == "completed"
209
+ ).order_by(MigrationBackup.created_at.desc()).first()
210
+
211
+ if not backup:
212
+ return {"backup_available": False}
213
+
214
+ return {
215
+ "backup_available": True,
216
+ "backup_id": backup.id,
217
+ "created_at": backup.created_at.isoformat(),
218
+ "expires_at": backup.expires_at.isoformat(),
219
+ "files_count": len(backup.files_info) if backup.files_info else 0
220
+ }
221
+
222
+ @router.get("/restore")
223
+ def restore_backup(
224
+ db: Session = Depends(get_db),
225
+ current_user: User = Depends(get_current_user)
226
+ ):
227
+ """
228
+ Retorna el mapa de AsyncStorage y las URLs de descarga de Hugging Face para todos los stems respaldados.
229
+ """
230
+ cleanup_expired_backups(db, current_user.id)
231
+
232
+ backup = db.query(MigrationBackup).filter(
233
+ MigrationBackup.user_id == current_user.id,
234
+ MigrationBackup.status == "completed"
235
+ ).order_by(MigrationBackup.created_at.desc()).first()
236
+
237
+ if not backup:
238
+ raise HTTPException(status_code=404, detail="No tienes ningún respaldo activo listo para restaurar")
239
+
240
+ # Generar URLs de descarga de Hugging Face
241
+ restore_files = []
242
+ files_list = backup.files_info or []
243
+
244
+ for f in files_list:
245
+ file_key = f["file_key"]
246
+ download_url = get_hf_download_url(file_key)
247
+ restore_files.append({
248
+ "task_id": f["task_id"],
249
+ "stem_name": f["stem_name"],
250
+ "url": download_url,
251
+ "file_key": file_key
252
+ })
253
+
254
+ return {
255
+ "metadata_json": backup.metadata_json,
256
+ "files": restore_files
257
+ }
258
+
259
+ @router.post("/complete")
260
+ def complete_restore(
261
+ db: Session = Depends(get_db),
262
+ current_user: User = Depends(get_current_user)
263
+ ):
264
+ """
265
+ Confirma que el nuevo dispositivo ha terminado de descargar los archivos y elimina los datos de Hugging Face.
266
+ """
267
+ backup = db.query(MigrationBackup).filter(
268
+ MigrationBackup.user_id == current_user.id,
269
+ MigrationBackup.status == "completed"
270
+ ).order_by(MigrationBackup.created_at.desc()).first()
271
+
272
+ if backup:
273
+ # Eliminar archivos físicamente de Hugging Face
274
+ logger.info(f"Limpiando carpeta de HF tras restauración exitosa de {backup.id}")
275
+ delete_folder_from_hf(f"migrations/{current_user.id}")
276
+ backup.status = "restored"
277
+ db.commit()
278
+
279
+ return {"success": True}
280
+
281
+ @router.delete("/backup")
282
+ def delete_backup(
283
+ db: Session = Depends(get_db),
284
+ current_user: User = Depends(get_current_user)
285
+ ):
286
+ """
287
+ Elimina permanentemente cualquier respaldo activo de migración del usuario en Hugging Face y la base de datos.
288
+ """
289
+ active_backups = db.query(MigrationBackup).filter(
290
+ MigrationBackup.user_id == current_user.id,
291
+ MigrationBackup.status.in_(["pending", "completed"])
292
+ ).all()
293
+
294
+ if active_backups:
295
+ logger.info(f"Eliminando {len(active_backups)} respaldos activos por petición de limpieza para usuario {current_user.id}")
296
+ try:
297
+ delete_folder_from_hf(f"migrations/{current_user.id}")
298
+ except Exception as e:
299
+ logger.error(f"Error borrando carpeta de HF para migración del usuario {current_user.id}: {e}")
300
+ for ab in active_backups:
301
+ db.delete(ab)
302
+ db.commit()
303
+
304
+ return {"success": True}
305
+
app/routers/songs.py CHANGED
The diff for this file is too large to render. See raw diff
 
app/routers/workers.py CHANGED
@@ -1,474 +1,474 @@
1
- from fastapi import APIRouter, Depends, HTTPException
2
- from sqlalchemy.orm import Session
3
- from app.database import get_db
4
- from app.models.process import Worker
5
- from app.models.user import AdminUser, User
6
- from app.routers.auth import get_current_user
7
- from app.routers.admin import get_current_admin
8
- from app.utils.notifications import create_admin_notification
9
- from pydantic import BaseModel
10
- from typing import List, Optional
11
- from datetime import datetime
12
- import uuid
13
-
14
- from app.websocket import ws_manager
15
-
16
- router = APIRouter(prefix="/workers", tags=["Workers"])
17
-
18
- class WorkerRegister(BaseModel):
19
- hostname: str
20
- name: Optional[str] = None
21
- status: Optional[str] = "online"
22
-
23
- class WorkerResponse(BaseModel):
24
- id: int
25
- hostname: str
26
- name: Optional[str]
27
- status: str
28
- last_seen: datetime
29
- user_id: Optional[int] = None
30
- is_public: Optional[bool] = False
31
- is_active: Optional[bool] = False
32
- owner_username: Optional[str] = None
33
-
34
- class Config:
35
- orm_mode = True
36
-
37
- class WorkerUpdate(BaseModel):
38
- name: Optional[str] = None
39
- status: Optional[str] = None
40
- is_public: Optional[bool] = None
41
-
42
- @router.post("/register", response_model=WorkerResponse)
43
- def register_worker(data: WorkerRegister, db: Session = Depends(get_db)):
44
- worker = db.query(Worker).filter(Worker.hostname == data.hostname).first()
45
- is_new = worker is None
46
- previous_status = None if is_new else worker.status
47
- if is_new:
48
- worker = Worker(
49
- hostname=data.hostname,
50
- name=data.name or data.hostname,
51
- status=data.status,
52
- last_seen=datetime.utcnow()
53
- )
54
- db.add(worker)
55
- else:
56
- worker.status = data.status
57
- worker.last_seen = datetime.utcnow()
58
- if data.name:
59
- worker.name = data.name
60
-
61
- db.commit()
62
- db.refresh(worker)
63
-
64
- if is_new:
65
- create_admin_notification(
66
- db,
67
- "Worker Registrado",
68
- f"El equipo worker '{worker.hostname}' se ha registrado en la red."
69
- )
70
- elif previous_status == "offline" and worker.status in ["online", "busy"]:
71
- create_admin_notification(
72
- db,
73
- "Worker Conectado",
74
- f"El equipo worker '{worker.name or worker.hostname}' se ha conectado (estado: {worker.status})."
75
- )
76
-
77
- ws_manager.broadcast_sync({"type": "refresh", "tab": "workers"})
78
- return {
79
- "id": worker.id,
80
- "hostname": worker.hostname,
81
- "name": worker.name,
82
- "status": worker.status,
83
- "last_seen": worker.last_seen,
84
- "user_id": worker.user_id,
85
- "is_public": getattr(worker, "is_public", False),
86
- "is_active": getattr(worker, "is_active", False),
87
- "owner_username": worker.owner.username if worker.owner else None
88
- }
89
-
90
- @router.get("", response_model=List[WorkerResponse])
91
- def get_workers(db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)):
92
- if not current_admin.perm_workers:
93
- raise HTTPException(status_code=403, detail="No tiene permisos para gestionar workers")
94
- # Marcar automáticamente como 'offline' los que no han reportado en 2 minutos
95
- workers = db.query(Worker).order_by(Worker.id).all()
96
- now = datetime.utcnow()
97
- updated = False
98
- for w in workers:
99
- if w.status in ["online", "busy"] and (now - w.last_seen).total_seconds() > 120:
100
- w.status = "offline"
101
- updated = True
102
- if updated:
103
- db.commit()
104
- workers = db.query(Worker).order_by(Worker.id).all()
105
- return [
106
- {
107
- "id": w.id,
108
- "hostname": w.hostname,
109
- "name": w.name,
110
- "status": w.status,
111
- "last_seen": w.last_seen,
112
- "user_id": w.user_id,
113
- "is_public": getattr(w, "is_public", False),
114
- "is_active": getattr(w, "is_active", False),
115
- "owner_username": w.owner.username if w.owner else None
116
- }
117
- for w in workers
118
- ]
119
-
120
- @router.put("/{worker_id}", response_model=WorkerResponse)
121
- def update_worker(worker_id: int, data: WorkerUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)):
122
- if not current_admin.perm_workers:
123
- raise HTTPException(status_code=403, detail="No tiene permisos para gestionar workers")
124
- worker = db.query(Worker).filter(Worker.id == worker_id).first()
125
- if not worker:
126
- raise HTTPException(status_code=404, detail="Worker no encontrado")
127
- if data.name is not None:
128
- worker.name = data.name
129
- if data.is_public is not None:
130
- worker.is_public = data.is_public
131
- if data.status is not None:
132
- worker.status = data.status
133
- if data.status == "suspended":
134
- worker.is_active = False
135
- worker.command = "stop"
136
- try:
137
- from tasks import celery_app
138
- celery_app.control.cancel_consumer('celery', destination=[f'celery@{worker.hostname}'])
139
- except Exception as e:
140
- print(f"[Worker] Error cancelando consumidor: {e}")
141
- elif data.status == "online":
142
- worker.is_active = True
143
- worker.command = "start"
144
- try:
145
- from tasks import celery_app
146
- celery_app.control.add_consumer('celery', destination=[f'celery@{worker.hostname}'])
147
- except Exception as e:
148
- print(f"[Worker] Error agregando consumidor: {e}")
149
- db.commit()
150
- db.refresh(worker)
151
-
152
- # Registrar en notificaciones de admin
153
- action_desc = f"actualizado (estado: {worker.status})"
154
- if data.status == "suspended":
155
- action_desc = "suspendido"
156
- elif data.status == "online":
157
- action_desc = "activado"
158
-
159
- create_admin_notification(
160
- db,
161
- title=f"Worker {action_desc}",
162
- message=f"El Worker '{worker.name}' (ID: {worker.id}, Host: {worker.hostname}) ha sido {action_desc} por el administrador '{current_admin.username}'.",
163
- category="worker"
164
- )
165
-
166
- ws_manager.broadcast_sync({"type": "refresh", "tab": "workers"})
167
- return worker
168
-
169
- class BulkDeleteWorkers(BaseModel):
170
- worker_ids: List[int]
171
-
172
- @router.post("/bulk-delete")
173
- def bulk_delete_workers(data: BulkDeleteWorkers, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)):
174
- if not current_admin.perm_workers:
175
- raise HTTPException(status_code=403, detail="No tiene permisos para gestionar workers")
176
-
177
- if not data.worker_ids:
178
- return {"message": "No se seleccionaron workers", "deleted_count": 0}
179
-
180
- deleted_count = 0
181
- deleted_names = []
182
- for worker_id in data.worker_ids:
183
- worker = db.query(Worker).filter(Worker.id == worker_id).first()
184
- if worker:
185
- deleted_names.append(worker.name or worker.hostname)
186
- db.delete(worker)
187
- deleted_count += 1
188
-
189
- db.commit()
190
-
191
- if deleted_count > 0:
192
- create_admin_notification(
193
- db,
194
- title="Workers eliminados en lote",
195
- message=f"Se han eliminado {deleted_count} worker(s) ({', '.join(deleted_names[:5])}{'...' if len(deleted_names) > 5 else ''}) por el administrador '{current_admin.username}'.",
196
- category="worker"
197
- )
198
- ws_manager.broadcast_sync({"type": "refresh", "tab": "workers"})
199
-
200
- return {"message": f"{deleted_count} worker(s) eliminados exitosamente", "deleted_count": deleted_count}
201
-
202
- @router.delete("/{worker_id}")
203
- def delete_worker(worker_id: int, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)):
204
- if not current_admin.perm_workers:
205
- raise HTTPException(status_code=403, detail="No tiene permisos para gestionar workers")
206
- worker = db.query(Worker).filter(Worker.id == worker_id).first()
207
- if not worker:
208
- raise HTTPException(status_code=404, detail="Worker no encontrado")
209
-
210
- db.delete(worker)
211
- db.commit()
212
-
213
- create_admin_notification(
214
- db,
215
- title="Worker eliminado",
216
- message=f"El Worker '{worker.name}' (ID: {worker_id}, Host: {worker.hostname}) ha sido eliminado permanentemente por el administrador '{current_admin.username}'.",
217
- category="worker"
218
- )
219
-
220
- ws_manager.broadcast_sync({"type": "refresh", "tab": "workers"})
221
- return {"message": "Worker eliminado exitosamente"}
222
-
223
- @router.post("/wake")
224
- def wake_workers():
225
- """
226
- Endpoint para despertar manualmente los workers de Hugging Face y la red.
227
- """
228
- from app.utils.hf_wake import wake_hf_workers
229
- wake_hf_workers()
230
- return {"message": "Peticiones de despertar enviadas a los workers de Hugging Face."}
231
-
232
- # ==========================================
233
- # BYOK / LOCAL WORKERS ENDPOINTS
234
- # ==========================================
235
-
236
- class WorkerModeUpdate(BaseModel):
237
- is_public: bool
238
-
239
- @router.get("/my-worker")
240
- def get_my_worker(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
241
- from app.models.process import Task
242
- from app.models.user import CreditTransaction
243
-
244
- worker = db.query(Worker).filter(Worker.user_id == current_user.id).first()
245
- if not worker:
246
- # Generar token único de worker y crearlo con hostname único garantizado
247
- token = f"melodix_wk_{uuid.uuid4().hex}"
248
- unique_hostname = f"user_{current_user.id}_{uuid.uuid4().hex[:8]}"
249
- worker = Worker(
250
- hostname=unique_hostname,
251
- name=f"Worker de {current_user.username}",
252
- status="offline",
253
- last_seen=datetime.utcnow(),
254
- user_id=current_user.id,
255
- token=token,
256
- command="none",
257
- is_active=False,
258
- is_public=False
259
- )
260
- db.add(worker)
261
- db.commit()
262
- db.refresh(worker)
263
- elif not worker.token:
264
- # Si el worker ya existía pero no tenía token asignado previamente
265
- worker.token = f"melodix_wk_{uuid.uuid4().hex}"
266
- if not worker.hostname or worker.hostname == "unregistered":
267
- worker.hostname = f"user_{current_user.id}_{uuid.uuid4().hex[:8]}"
268
- db.commit()
269
- db.refresh(worker)
270
-
271
- # Calcular estadísticas de procesamiento y recompensas
272
- processed_count = db.query(Task).filter(
273
- Task.processed_by_worker_id == worker.id,
274
- Task.status == "completed"
275
- ).count()
276
-
277
- reward_txs = db.query(CreditTransaction).filter(
278
- CreditTransaction.user_id == current_user.id,
279
- CreditTransaction.concept.like("recompensa_worker_publico%")
280
- ).all()
281
- total_rewards = sum(tx.amount for tx in reward_txs) if reward_txs else 0
282
-
283
- return {
284
- "id": worker.id,
285
- "name": worker.name,
286
- "status": worker.status,
287
- "last_seen": worker.last_seen,
288
- "token": worker.token,
289
- "is_active": worker.is_active,
290
- "is_public": bool(getattr(worker, "is_public", False)),
291
- "command": worker.command,
292
- "hostname": worker.hostname,
293
- "processed_count": processed_count,
294
- "reward_credits_earned": total_rewards
295
- }
296
-
297
- @router.post("/my-worker/mode")
298
- def update_my_worker_mode(data: WorkerModeUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
299
- worker = db.query(Worker).filter(Worker.user_id == current_user.id).first()
300
- if not worker:
301
- raise HTTPException(status_code=404, detail="Worker no encontrado")
302
-
303
- # Validación estricta: No se puede cambiar de modo mientras el worker esté ocupado (busy)
304
- if worker.status == "busy":
305
- raise HTTPException(
306
- status_code=400,
307
- detail="El equipo está procesando una pista actualmente. Espera a que termine para cambiar de modo."
308
- )
309
-
310
- worker.is_public = data.is_public
311
- db.commit()
312
- db.refresh(worker)
313
-
314
- mode_str = "Público (Comunitario)" if worker.is_public else "Privado (BYOW)"
315
-
316
- ws_manager.broadcast_sync({"type": "refresh", "tab": "workers"})
317
- return {
318
- "is_public": worker.is_public,
319
- "status": worker.status,
320
- "message": f"Modo cambiado a {mode_str} correctamente."
321
- }
322
-
323
- @router.post("/my-worker/toggle")
324
- def toggle_my_worker(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
325
- worker = db.query(Worker).filter(Worker.user_id == current_user.id).first()
326
- if not worker:
327
- raise HTTPException(status_code=404, detail="Worker no encontrado")
328
- worker.is_active = not worker.is_active
329
- db.commit()
330
- db.refresh(worker)
331
- return {"is_active": worker.is_active, "status": worker.status, "is_public": worker.is_public}
332
-
333
- @router.post("/my-worker/command")
334
- def send_worker_command(command: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
335
- if command not in ["start", "stop"]:
336
- raise HTTPException(status_code=400, detail="Comando invalido. Use 'start' o 'stop'")
337
- worker = db.query(Worker).filter(Worker.user_id == current_user.id).first()
338
- if not worker:
339
- raise HTTPException(status_code=404, detail="Worker no encontrado")
340
- worker.command = command
341
- db.commit()
342
- return {"message": f"Comando '{command}' enviado al worker"}
343
-
344
- @router.get("/agent/poll")
345
- def agent_poll(
346
- token: str,
347
- status: str,
348
- hostname: str,
349
- is_public: Optional[bool] = None,
350
- db: Session = Depends(get_db)
351
- ):
352
- worker = db.query(Worker).filter(Worker.token == token).first()
353
- if not worker:
354
- raise HTTPException(status_code=404, detail="Token de Worker no registrado")
355
-
356
- worker.last_seen = datetime.utcnow()
357
-
358
- # Si el agente envió solicitud de cambio de is_public y no está ocupado
359
- if is_public is not None and worker.status != "busy":
360
- worker.is_public = is_public
361
-
362
- # Si el administrador suspendió el worker, mantener el estado "suspended" y detener el engine
363
- if worker.status == "suspended":
364
- current_command = "stop" if status == "busy" else worker.command
365
- if worker.command != "none":
366
- worker.command = "none"
367
- db.commit()
368
- return {
369
- "command": current_command,
370
- "is_active": False,
371
- "is_public": bool(getattr(worker, "is_public", False)),
372
- "user_id": worker.user_id,
373
- "status": "suspended"
374
- }
375
-
376
- # De lo contrario, actualizar estado según el reporte del agente
377
- previous_status = worker.status
378
- if status == "offline":
379
- worker.status = "offline"
380
- elif status == "online":
381
- if worker.status != "busy":
382
- worker.status = "online"
383
- else:
384
- worker.status = status
385
- if hostname:
386
- worker.hostname = hostname
387
-
388
- current_command = worker.command
389
- if worker.command != "none":
390
- worker.command = "none"
391
-
392
- db.commit()
393
-
394
- if previous_status == "offline" and worker.status in ["online", "busy"]:
395
- create_admin_notification(
396
- db,
397
- "Worker Conectado",
398
- f"El worker local/BYOK '{worker.name or worker.hostname}' se ha conectado."
399
- )
400
-
401
- return {
402
- "command": current_command,
403
- "is_active": worker.is_active,
404
- "user_id": worker.user_id,
405
- "status": worker.status
406
- }
407
-
408
- @router.get("/agent/poll-public")
409
- def agent_poll_public(hostname: str, status: str, db: Session = Depends(get_db)):
410
- worker = db.query(Worker).filter(Worker.hostname == hostname, Worker.user_id == None).first()
411
- is_new = worker is None
412
- if not worker:
413
- # Registrar de forma automática si no existe en la base de datos
414
- worker = Worker(
415
- hostname=hostname,
416
- name=hostname,
417
- status="online",
418
- last_seen=datetime.utcnow(),
419
- command="none",
420
- is_active=True,
421
- is_public=True
422
- )
423
- db.add(worker)
424
- db.commit()
425
- db.refresh(worker)
426
-
427
- # Enviar notificación de nuevo worker
428
- create_admin_notification(
429
- db,
430
- "Worker Registrado",
431
- f"El equipo worker '{worker.hostname}' se ha autoregistrado vía poll público."
432
- )
433
- elif worker.user_id is None and not worker.is_public:
434
- worker.is_public = True
435
-
436
- worker.last_seen = datetime.utcnow()
437
-
438
- # Si el administrador suspendió el worker, mantener el estado "suspended"
439
- if worker.status == "suspended":
440
- current_command = "stop" if status == "busy" else worker.command
441
- if worker.command != "none":
442
- worker.command = "none"
443
- db.commit()
444
- return {
445
- "command": current_command,
446
- "status": "suspended"
447
- }
448
-
449
- # De lo contrario, actualizar estado según el reporte del agente
450
- previous_status = worker.status
451
- if status == "offline":
452
- worker.status = "offline"
453
- elif status == "online":
454
- if worker.status != "busy":
455
- worker.status = "online"
456
- else:
457
- worker.status = status
458
- current_command = worker.command
459
- if worker.command != "none":
460
- worker.command = "none"
461
-
462
- db.commit()
463
-
464
- if not is_new and previous_status == "offline" and worker.status in ["online", "busy"]:
465
- create_admin_notification(
466
- db,
467
- "Worker Conectado",
468
- f"El equipo worker '{worker.name or worker.hostname}' se ha conectado."
469
- )
470
-
471
- return {
472
- "command": current_command,
473
- "status": worker.status
474
- }
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.orm import Session
3
+ from app.database import get_db
4
+ from app.models.process import Worker
5
+ from app.models.user import AdminUser, User
6
+ from app.routers.auth import get_current_user
7
+ from app.routers.admin import get_current_admin
8
+ from app.utils.notifications import create_admin_notification
9
+ from pydantic import BaseModel
10
+ from typing import List, Optional
11
+ from datetime import datetime
12
+ import uuid
13
+
14
+ from app.websocket import ws_manager
15
+
16
+ router = APIRouter(prefix="/workers", tags=["Workers"])
17
+
18
+ class WorkerRegister(BaseModel):
19
+ hostname: str
20
+ name: Optional[str] = None
21
+ status: Optional[str] = "online"
22
+
23
+ class WorkerResponse(BaseModel):
24
+ id: int
25
+ hostname: str
26
+ name: Optional[str]
27
+ status: str
28
+ last_seen: datetime
29
+ user_id: Optional[int] = None
30
+ is_public: Optional[bool] = False
31
+ is_active: Optional[bool] = False
32
+ owner_username: Optional[str] = None
33
+
34
+ class Config:
35
+ orm_mode = True
36
+
37
+ class WorkerUpdate(BaseModel):
38
+ name: Optional[str] = None
39
+ status: Optional[str] = None
40
+ is_public: Optional[bool] = None
41
+
42
+ @router.post("/register", response_model=WorkerResponse)
43
+ def register_worker(data: WorkerRegister, db: Session = Depends(get_db)):
44
+ worker = db.query(Worker).filter(Worker.hostname == data.hostname).first()
45
+ is_new = worker is None
46
+ previous_status = None if is_new else worker.status
47
+ if is_new:
48
+ worker = Worker(
49
+ hostname=data.hostname,
50
+ name=data.name or data.hostname,
51
+ status=data.status,
52
+ last_seen=datetime.utcnow()
53
+ )
54
+ db.add(worker)
55
+ else:
56
+ worker.status = data.status
57
+ worker.last_seen = datetime.utcnow()
58
+ if data.name:
59
+ worker.name = data.name
60
+
61
+ db.commit()
62
+ db.refresh(worker)
63
+
64
+ if is_new:
65
+ create_admin_notification(
66
+ db,
67
+ "Worker Registrado",
68
+ f"El equipo worker '{worker.hostname}' se ha registrado en la red."
69
+ )
70
+ elif previous_status == "offline" and worker.status in ["online", "busy"]:
71
+ create_admin_notification(
72
+ db,
73
+ "Worker Conectado",
74
+ f"El equipo worker '{worker.name or worker.hostname}' se ha conectado (estado: {worker.status})."
75
+ )
76
+
77
+ ws_manager.broadcast_sync({"type": "refresh", "tab": "workers"})
78
+ return {
79
+ "id": worker.id,
80
+ "hostname": worker.hostname,
81
+ "name": worker.name,
82
+ "status": worker.status,
83
+ "last_seen": worker.last_seen,
84
+ "user_id": worker.user_id,
85
+ "is_public": getattr(worker, "is_public", False),
86
+ "is_active": getattr(worker, "is_active", False),
87
+ "owner_username": worker.owner.username if worker.owner else None
88
+ }
89
+
90
+ @router.get("", response_model=List[WorkerResponse])
91
+ def get_workers(db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)):
92
+ if not current_admin.perm_workers:
93
+ raise HTTPException(status_code=403, detail="No tiene permisos para gestionar workers")
94
+ # Marcar automáticamente como 'offline' los que no han reportado en 2 minutos
95
+ workers = db.query(Worker).order_by(Worker.id).all()
96
+ now = datetime.utcnow()
97
+ updated = False
98
+ for w in workers:
99
+ if w.status in ["online", "busy"] and (now - w.last_seen).total_seconds() > 120:
100
+ w.status = "offline"
101
+ updated = True
102
+ if updated:
103
+ db.commit()
104
+ workers = db.query(Worker).order_by(Worker.id).all()
105
+ return [
106
+ {
107
+ "id": w.id,
108
+ "hostname": w.hostname,
109
+ "name": w.name,
110
+ "status": w.status,
111
+ "last_seen": w.last_seen,
112
+ "user_id": w.user_id,
113
+ "is_public": getattr(w, "is_public", False),
114
+ "is_active": getattr(w, "is_active", False),
115
+ "owner_username": w.owner.username if w.owner else None
116
+ }
117
+ for w in workers
118
+ ]
119
+
120
+ @router.put("/{worker_id}", response_model=WorkerResponse)
121
+ def update_worker(worker_id: int, data: WorkerUpdate, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)):
122
+ if not current_admin.perm_workers:
123
+ raise HTTPException(status_code=403, detail="No tiene permisos para gestionar workers")
124
+ worker = db.query(Worker).filter(Worker.id == worker_id).first()
125
+ if not worker:
126
+ raise HTTPException(status_code=404, detail="Worker no encontrado")
127
+ if data.name is not None:
128
+ worker.name = data.name
129
+ if data.is_public is not None:
130
+ worker.is_public = data.is_public
131
+ if data.status is not None:
132
+ worker.status = data.status
133
+ if data.status == "suspended":
134
+ worker.is_active = False
135
+ worker.command = "stop"
136
+ try:
137
+ from tasks import celery_app
138
+ celery_app.control.cancel_consumer('celery', destination=[f'celery@{worker.hostname}'])
139
+ except Exception as e:
140
+ print(f"[Worker] Error cancelando consumidor: {e}")
141
+ elif data.status == "online":
142
+ worker.is_active = True
143
+ worker.command = "start"
144
+ try:
145
+ from tasks import celery_app
146
+ celery_app.control.add_consumer('celery', destination=[f'celery@{worker.hostname}'])
147
+ except Exception as e:
148
+ print(f"[Worker] Error agregando consumidor: {e}")
149
+ db.commit()
150
+ db.refresh(worker)
151
+
152
+ # Registrar en notificaciones de admin
153
+ action_desc = f"actualizado (estado: {worker.status})"
154
+ if data.status == "suspended":
155
+ action_desc = "suspendido"
156
+ elif data.status == "online":
157
+ action_desc = "activado"
158
+
159
+ create_admin_notification(
160
+ db,
161
+ title=f"Worker {action_desc}",
162
+ message=f"El Worker '{worker.name}' (ID: {worker.id}, Host: {worker.hostname}) ha sido {action_desc} por el administrador '{current_admin.username}'.",
163
+ category="worker"
164
+ )
165
+
166
+ ws_manager.broadcast_sync({"type": "refresh", "tab": "workers"})
167
+ return worker
168
+
169
+ class BulkDeleteWorkers(BaseModel):
170
+ worker_ids: List[int]
171
+
172
+ @router.post("/bulk-delete")
173
+ def bulk_delete_workers(data: BulkDeleteWorkers, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)):
174
+ if not current_admin.perm_workers:
175
+ raise HTTPException(status_code=403, detail="No tiene permisos para gestionar workers")
176
+
177
+ if not data.worker_ids:
178
+ return {"message": "No se seleccionaron workers", "deleted_count": 0}
179
+
180
+ deleted_count = 0
181
+ deleted_names = []
182
+ for worker_id in data.worker_ids:
183
+ worker = db.query(Worker).filter(Worker.id == worker_id).first()
184
+ if worker:
185
+ deleted_names.append(worker.name or worker.hostname)
186
+ db.delete(worker)
187
+ deleted_count += 1
188
+
189
+ db.commit()
190
+
191
+ if deleted_count > 0:
192
+ create_admin_notification(
193
+ db,
194
+ title="Workers eliminados en lote",
195
+ message=f"Se han eliminado {deleted_count} worker(s) ({', '.join(deleted_names[:5])}{'...' if len(deleted_names) > 5 else ''}) por el administrador '{current_admin.username}'.",
196
+ category="worker"
197
+ )
198
+ ws_manager.broadcast_sync({"type": "refresh", "tab": "workers"})
199
+
200
+ return {"message": f"{deleted_count} worker(s) eliminados exitosamente", "deleted_count": deleted_count}
201
+
202
+ @router.delete("/{worker_id}")
203
+ def delete_worker(worker_id: int, db: Session = Depends(get_db), current_admin: AdminUser = Depends(get_current_admin)):
204
+ if not current_admin.perm_workers:
205
+ raise HTTPException(status_code=403, detail="No tiene permisos para gestionar workers")
206
+ worker = db.query(Worker).filter(Worker.id == worker_id).first()
207
+ if not worker:
208
+ raise HTTPException(status_code=404, detail="Worker no encontrado")
209
+
210
+ db.delete(worker)
211
+ db.commit()
212
+
213
+ create_admin_notification(
214
+ db,
215
+ title="Worker eliminado",
216
+ message=f"El Worker '{worker.name}' (ID: {worker_id}, Host: {worker.hostname}) ha sido eliminado permanentemente por el administrador '{current_admin.username}'.",
217
+ category="worker"
218
+ )
219
+
220
+ ws_manager.broadcast_sync({"type": "refresh", "tab": "workers"})
221
+ return {"message": "Worker eliminado exitosamente"}
222
+
223
+ @router.post("/wake")
224
+ def wake_workers():
225
+ """
226
+ Endpoint para despertar manualmente los workers de Hugging Face y la red.
227
+ """
228
+ from app.utils.hf_wake import wake_hf_workers
229
+ wake_hf_workers()
230
+ return {"message": "Peticiones de despertar enviadas a los workers de Hugging Face."}
231
+
232
+ # ==========================================
233
+ # BYOK / LOCAL WORKERS ENDPOINTS
234
+ # ==========================================
235
+
236
+ class WorkerModeUpdate(BaseModel):
237
+ is_public: bool
238
+
239
+ @router.get("/my-worker")
240
+ def get_my_worker(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
241
+ from app.models.process import Task
242
+ from app.models.user import CreditTransaction
243
+
244
+ worker = db.query(Worker).filter(Worker.user_id == current_user.id).first()
245
+ if not worker:
246
+ # Generar token único de worker y crearlo con hostname único garantizado
247
+ token = f"melodix_wk_{uuid.uuid4().hex}"
248
+ unique_hostname = f"user_{current_user.id}_{uuid.uuid4().hex[:8]}"
249
+ worker = Worker(
250
+ hostname=unique_hostname,
251
+ name=f"Worker de {current_user.username}",
252
+ status="offline",
253
+ last_seen=datetime.utcnow(),
254
+ user_id=current_user.id,
255
+ token=token,
256
+ command="none",
257
+ is_active=False,
258
+ is_public=False
259
+ )
260
+ db.add(worker)
261
+ db.commit()
262
+ db.refresh(worker)
263
+ elif not worker.token:
264
+ # Si el worker ya existía pero no tenía token asignado previamente
265
+ worker.token = f"melodix_wk_{uuid.uuid4().hex}"
266
+ if not worker.hostname or worker.hostname == "unregistered":
267
+ worker.hostname = f"user_{current_user.id}_{uuid.uuid4().hex[:8]}"
268
+ db.commit()
269
+ db.refresh(worker)
270
+
271
+ # Calcular estadísticas de procesamiento y recompensas
272
+ processed_count = db.query(Task).filter(
273
+ Task.processed_by_worker_id == worker.id,
274
+ Task.status == "completed"
275
+ ).count()
276
+
277
+ reward_txs = db.query(CreditTransaction).filter(
278
+ CreditTransaction.user_id == current_user.id,
279
+ CreditTransaction.concept.like("recompensa_worker_publico%")
280
+ ).all()
281
+ total_rewards = sum(tx.amount for tx in reward_txs) if reward_txs else 0
282
+
283
+ return {
284
+ "id": worker.id,
285
+ "name": worker.name,
286
+ "status": worker.status,
287
+ "last_seen": worker.last_seen,
288
+ "token": worker.token,
289
+ "is_active": worker.is_active,
290
+ "is_public": bool(getattr(worker, "is_public", False)),
291
+ "command": worker.command,
292
+ "hostname": worker.hostname,
293
+ "processed_count": processed_count,
294
+ "reward_credits_earned": total_rewards
295
+ }
296
+
297
+ @router.post("/my-worker/mode")
298
+ def update_my_worker_mode(data: WorkerModeUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
299
+ worker = db.query(Worker).filter(Worker.user_id == current_user.id).first()
300
+ if not worker:
301
+ raise HTTPException(status_code=404, detail="Worker no encontrado")
302
+
303
+ # Validación estricta: No se puede cambiar de modo mientras el worker esté ocupado (busy)
304
+ if worker.status == "busy":
305
+ raise HTTPException(
306
+ status_code=400,
307
+ detail="El equipo está procesando una pista actualmente. Espera a que termine para cambiar de modo."
308
+ )
309
+
310
+ worker.is_public = data.is_public
311
+ db.commit()
312
+ db.refresh(worker)
313
+
314
+ mode_str = "Público (Comunitario)" if worker.is_public else "Privado (BYOW)"
315
+
316
+ ws_manager.broadcast_sync({"type": "refresh", "tab": "workers"})
317
+ return {
318
+ "is_public": worker.is_public,
319
+ "status": worker.status,
320
+ "message": f"Modo cambiado a {mode_str} correctamente."
321
+ }
322
+
323
+ @router.post("/my-worker/toggle")
324
+ def toggle_my_worker(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
325
+ worker = db.query(Worker).filter(Worker.user_id == current_user.id).first()
326
+ if not worker:
327
+ raise HTTPException(status_code=404, detail="Worker no encontrado")
328
+ worker.is_active = not worker.is_active
329
+ db.commit()
330
+ db.refresh(worker)
331
+ return {"is_active": worker.is_active, "status": worker.status, "is_public": worker.is_public}
332
+
333
+ @router.post("/my-worker/command")
334
+ def send_worker_command(command: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
335
+ if command not in ["start", "stop"]:
336
+ raise HTTPException(status_code=400, detail="Comando invalido. Use 'start' o 'stop'")
337
+ worker = db.query(Worker).filter(Worker.user_id == current_user.id).first()
338
+ if not worker:
339
+ raise HTTPException(status_code=404, detail="Worker no encontrado")
340
+ worker.command = command
341
+ db.commit()
342
+ return {"message": f"Comando '{command}' enviado al worker"}
343
+
344
+ @router.get("/agent/poll")
345
+ def agent_poll(
346
+ token: str,
347
+ status: str,
348
+ hostname: str,
349
+ is_public: Optional[bool] = None,
350
+ db: Session = Depends(get_db)
351
+ ):
352
+ worker = db.query(Worker).filter(Worker.token == token).first()
353
+ if not worker:
354
+ raise HTTPException(status_code=404, detail="Token de Worker no registrado")
355
+
356
+ worker.last_seen = datetime.utcnow()
357
+
358
+ # Si el agente envió solicitud de cambio de is_public y no está ocupado
359
+ if is_public is not None and worker.status != "busy":
360
+ worker.is_public = is_public
361
+
362
+ # Si el administrador suspendió el worker, mantener el estado "suspended" y detener el engine
363
+ if worker.status == "suspended":
364
+ current_command = "stop" if status == "busy" else worker.command
365
+ if worker.command != "none":
366
+ worker.command = "none"
367
+ db.commit()
368
+ return {
369
+ "command": current_command,
370
+ "is_active": False,
371
+ "is_public": bool(getattr(worker, "is_public", False)),
372
+ "user_id": worker.user_id,
373
+ "status": "suspended"
374
+ }
375
+
376
+ # De lo contrario, actualizar estado según el reporte del agente
377
+ previous_status = worker.status
378
+ if status == "offline":
379
+ worker.status = "offline"
380
+ elif status == "online":
381
+ if worker.status != "busy":
382
+ worker.status = "online"
383
+ else:
384
+ worker.status = status
385
+ if hostname:
386
+ worker.hostname = hostname
387
+
388
+ current_command = worker.command
389
+ if worker.command != "none":
390
+ worker.command = "none"
391
+
392
+ db.commit()
393
+
394
+ if previous_status == "offline" and worker.status in ["online", "busy"]:
395
+ create_admin_notification(
396
+ db,
397
+ "Worker Conectado",
398
+ f"El worker local/BYOK '{worker.name or worker.hostname}' se ha conectado."
399
+ )
400
+
401
+ return {
402
+ "command": current_command,
403
+ "is_active": worker.is_active,
404
+ "user_id": worker.user_id,
405
+ "status": worker.status
406
+ }
407
+
408
+ @router.get("/agent/poll-public")
409
+ def agent_poll_public(hostname: str, status: str, db: Session = Depends(get_db)):
410
+ worker = db.query(Worker).filter(Worker.hostname == hostname, Worker.user_id == None).first()
411
+ is_new = worker is None
412
+ if not worker:
413
+ # Registrar de forma automática si no existe en la base de datos
414
+ worker = Worker(
415
+ hostname=hostname,
416
+ name=hostname,
417
+ status="online",
418
+ last_seen=datetime.utcnow(),
419
+ command="none",
420
+ is_active=True,
421
+ is_public=True
422
+ )
423
+ db.add(worker)
424
+ db.commit()
425
+ db.refresh(worker)
426
+
427
+ # Enviar notificación de nuevo worker
428
+ create_admin_notification(
429
+ db,
430
+ "Worker Registrado",
431
+ f"El equipo worker '{worker.hostname}' se ha autoregistrado vía poll público."
432
+ )
433
+ elif worker.user_id is None and not worker.is_public:
434
+ worker.is_public = True
435
+
436
+ worker.last_seen = datetime.utcnow()
437
+
438
+ # Si el administrador suspendió el worker, mantener el estado "suspended"
439
+ if worker.status == "suspended":
440
+ current_command = "stop" if status == "busy" else worker.command
441
+ if worker.command != "none":
442
+ worker.command = "none"
443
+ db.commit()
444
+ return {
445
+ "command": current_command,
446
+ "status": "suspended"
447
+ }
448
+
449
+ # De lo contrario, actualizar estado según el reporte del agente
450
+ previous_status = worker.status
451
+ if status == "offline":
452
+ worker.status = "offline"
453
+ elif status == "online":
454
+ if worker.status != "busy":
455
+ worker.status = "online"
456
+ else:
457
+ worker.status = status
458
+ current_command = worker.command
459
+ if worker.command != "none":
460
+ worker.command = "none"
461
+
462
+ db.commit()
463
+
464
+ if not is_new and previous_status == "offline" and worker.status in ["online", "busy"]:
465
+ create_admin_notification(
466
+ db,
467
+ "Worker Conectado",
468
+ f"El equipo worker '{worker.name or worker.hostname}' se ha conectado."
469
+ )
470
+
471
+ return {
472
+ "command": current_command,
473
+ "status": worker.status
474
+ }
app/schemas/lyrics.py CHANGED
@@ -1,91 +1,91 @@
1
- from pydantic import BaseModel, model_validator
2
- from typing import Optional, List
3
- from datetime import datetime
4
-
5
- # --- LYRIC SCHEMAS ---
6
- class LyricBase(BaseModel):
7
- title: str
8
- artist: Optional[str] = None
9
- content: str
10
- key: Optional[str] = None
11
- bpm: Optional[int] = None
12
- group_id: Optional[int] = None
13
- transpose_offset: Optional[int] = 0
14
- is_repertoire_only: Optional[bool] = False
15
- parent_lyric_id: Optional[int] = None
16
-
17
- class LyricCreate(LyricBase):
18
- pass
19
-
20
- class LyricUpdate(BaseModel):
21
- title: Optional[str] = None
22
- artist: Optional[str] = None
23
- content: Optional[str] = None
24
- key: Optional[str] = None
25
- bpm: Optional[int] = None
26
- transpose_offset: Optional[int] = None
27
-
28
- class LyricOut(LyricBase):
29
- id: int
30
- created_by: int
31
- created_at: datetime
32
- updated_at: datetime
33
- group_ids: List[int] = []
34
-
35
- @model_validator(mode="after")
36
- def populate_fallback_group_id(self) -> "LyricOut":
37
- if self.group_id is None and self.group_ids:
38
- self.group_id = self.group_ids[0]
39
- return self
40
-
41
- class Config:
42
- from_attributes = True
43
-
44
- # --- GROUP SCHEMAS ---
45
- class GroupBase(BaseModel):
46
- name: str
47
-
48
- class GroupCreate(GroupBase):
49
- pass
50
-
51
- class GroupOut(GroupBase):
52
- id: int
53
- created_at: datetime
54
-
55
- class Config:
56
- from_attributes = True
57
-
58
- # --- REPERTORIO SCHEMAS ---
59
- class RepertorioBase(BaseModel):
60
- name: str
61
- group_id: Optional[int] = None
62
- color: Optional[str] = '#448AFF'
63
-
64
- class RepertorioCreate(RepertorioBase):
65
- lyrics_ids: List[int] = []
66
-
67
- class RepertorioOut(RepertorioBase):
68
- id: int
69
- created_by: int
70
- created_at: Optional[datetime] = None
71
-
72
- class Config:
73
- from_attributes = True
74
-
75
- # --- INVITATION SCHEMAS ---
76
- class InvitationBase(BaseModel):
77
- group_id: int
78
- expires_at: Optional[datetime] = None
79
-
80
- class InvitationCreate(InvitationBase):
81
- pass
82
-
83
- class InvitationOut(InvitationBase):
84
- id: int
85
- code: str
86
- created_by: int
87
- created_at: datetime
88
- used_at: Optional[datetime] = None
89
-
90
- class Config:
91
- from_attributes = True
 
1
+ from pydantic import BaseModel, model_validator
2
+ from typing import Optional, List
3
+ from datetime import datetime
4
+
5
+ # --- LYRIC SCHEMAS ---
6
+ class LyricBase(BaseModel):
7
+ title: str
8
+ artist: Optional[str] = None
9
+ content: str
10
+ key: Optional[str] = None
11
+ bpm: Optional[int] = None
12
+ group_id: Optional[int] = None
13
+ transpose_offset: Optional[int] = 0
14
+ is_repertoire_only: Optional[bool] = False
15
+ parent_lyric_id: Optional[int] = None
16
+
17
+ class LyricCreate(LyricBase):
18
+ pass
19
+
20
+ class LyricUpdate(BaseModel):
21
+ title: Optional[str] = None
22
+ artist: Optional[str] = None
23
+ content: Optional[str] = None
24
+ key: Optional[str] = None
25
+ bpm: Optional[int] = None
26
+ transpose_offset: Optional[int] = None
27
+
28
+ class LyricOut(LyricBase):
29
+ id: int
30
+ created_by: int
31
+ created_at: datetime
32
+ updated_at: datetime
33
+ group_ids: List[int] = []
34
+
35
+ @model_validator(mode="after")
36
+ def populate_fallback_group_id(self) -> "LyricOut":
37
+ if self.group_id is None and self.group_ids:
38
+ self.group_id = self.group_ids[0]
39
+ return self
40
+
41
+ class Config:
42
+ from_attributes = True
43
+
44
+ # --- GROUP SCHEMAS ---
45
+ class GroupBase(BaseModel):
46
+ name: str
47
+
48
+ class GroupCreate(GroupBase):
49
+ pass
50
+
51
+ class GroupOut(GroupBase):
52
+ id: int
53
+ created_at: datetime
54
+
55
+ class Config:
56
+ from_attributes = True
57
+
58
+ # --- REPERTORIO SCHEMAS ---
59
+ class RepertorioBase(BaseModel):
60
+ name: str
61
+ group_id: Optional[int] = None
62
+ color: Optional[str] = '#448AFF'
63
+
64
+ class RepertorioCreate(RepertorioBase):
65
+ lyrics_ids: List[int] = []
66
+
67
+ class RepertorioOut(RepertorioBase):
68
+ id: int
69
+ created_by: int
70
+ created_at: Optional[datetime] = None
71
+
72
+ class Config:
73
+ from_attributes = True
74
+
75
+ # --- INVITATION SCHEMAS ---
76
+ class InvitationBase(BaseModel):
77
+ group_id: int
78
+ expires_at: Optional[datetime] = None
79
+
80
+ class InvitationCreate(InvitationBase):
81
+ pass
82
+
83
+ class InvitationOut(InvitationBase):
84
+ id: int
85
+ code: str
86
+ created_by: int
87
+ created_at: datetime
88
+ used_at: Optional[datetime] = None
89
+
90
+ class Config:
91
+ from_attributes = True
app/schemas/process.py CHANGED
@@ -1,20 +1,20 @@
1
- from pydantic import BaseModel
2
- from typing import Optional, List, Any
3
-
4
- class AcordeInfo(BaseModel):
5
- t: float
6
- ch: str
7
-
8
- class DetalleResultado(BaseModel):
9
- task_id: str
10
- bpm: float
11
- time_signature: str
12
- acordes: List[AcordeInfo]
13
- stems_procesados: int
14
-
15
- class ProcesoResultado(BaseModel):
16
- estado: str
17
- resultado: Optional[Any] = None
18
- folder: Optional[str] = ""
19
- progreso: Optional[int] = 0
20
- mensaje: Optional[str] = ""
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List, Any
3
+
4
+ class AcordeInfo(BaseModel):
5
+ t: float
6
+ ch: str
7
+
8
+ class DetalleResultado(BaseModel):
9
+ task_id: str
10
+ bpm: float
11
+ time_signature: str
12
+ acordes: List[AcordeInfo]
13
+ stems_procesados: int
14
+
15
+ class ProcesoResultado(BaseModel):
16
+ estado: str
17
+ resultado: Optional[Any] = None
18
+ folder: Optional[str] = ""
19
+ progreso: Optional[int] = 0
20
+ mensaje: Optional[str] = ""
app/schemas/user.py CHANGED
@@ -1,60 +1,60 @@
1
- from pydantic import BaseModel, EmailStr
2
- from typing import Optional
3
- from datetime import datetime
4
-
5
- class UserBase(BaseModel):
6
- username: str
7
- email: EmailStr
8
-
9
- class UserCreate(UserBase):
10
- password: str
11
-
12
- class UserOut(UserBase):
13
- id: int
14
- credits: int
15
- bonus_credits: Optional[int] = 0
16
- total_credits: Optional[int] = 0
17
- is_premium: bool = False
18
- avatar_url: Optional[str] = None
19
- username_changes_this_month: Optional[int] = 0
20
- last_username_change_at: Optional[datetime] = None
21
- is_forum_banned: bool = False
22
- class Config:
23
- from_attributes = True
24
-
25
- class AvatarUpdate(BaseModel):
26
- avatar_url: str
27
-
28
- class UsernameUpdate(BaseModel):
29
- username: str
30
-
31
-
32
- class Token(BaseModel):
33
- access_token: str
34
- token_type: str
35
- expires_in: Optional[int] = None
36
- user: Optional[dict] = None
37
-
38
- class TokenData(BaseModel):
39
- username: Optional[str] = None
40
-
41
- class DeviceRegister(BaseModel):
42
- device_id: str
43
- device_name: str
44
- confirm_override: Optional[bool] = False
45
-
46
-
47
- class ForgotPasswordRequest(BaseModel):
48
- email: EmailStr
49
-
50
-
51
- class ResetPasswordRequest(BaseModel):
52
- email: EmailStr
53
- code: str
54
- password: str
55
-
56
-
57
- class ChangePasswordRequest(BaseModel):
58
- old_password: str
59
- new_password: str
60
-
 
1
+ from pydantic import BaseModel, EmailStr
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+ class UserBase(BaseModel):
6
+ username: str
7
+ email: EmailStr
8
+
9
+ class UserCreate(UserBase):
10
+ password: str
11
+
12
+ class UserOut(UserBase):
13
+ id: int
14
+ credits: int
15
+ bonus_credits: Optional[int] = 0
16
+ total_credits: Optional[int] = 0
17
+ is_premium: bool = False
18
+ avatar_url: Optional[str] = None
19
+ username_changes_this_month: Optional[int] = 0
20
+ last_username_change_at: Optional[datetime] = None
21
+ is_forum_banned: bool = False
22
+ class Config:
23
+ from_attributes = True
24
+
25
+ class AvatarUpdate(BaseModel):
26
+ avatar_url: str
27
+
28
+ class UsernameUpdate(BaseModel):
29
+ username: str
30
+
31
+
32
+ class Token(BaseModel):
33
+ access_token: str
34
+ token_type: str
35
+ expires_in: Optional[int] = None
36
+ user: Optional[dict] = None
37
+
38
+ class TokenData(BaseModel):
39
+ username: Optional[str] = None
40
+
41
+ class DeviceRegister(BaseModel):
42
+ device_id: str
43
+ device_name: str
44
+ confirm_override: Optional[bool] = False
45
+
46
+
47
+ class ForgotPasswordRequest(BaseModel):
48
+ email: EmailStr
49
+
50
+
51
+ class ResetPasswordRequest(BaseModel):
52
+ email: EmailStr
53
+ code: str
54
+ password: str
55
+
56
+
57
+ class ChangePasswordRequest(BaseModel):
58
+ old_password: str
59
+ new_password: str
60
+
app/utils/email.py CHANGED
@@ -1,197 +1,197 @@
1
- import os
2
- import smtplib
3
- import logging
4
- from email.mime.text import MIMEText
5
- from email.mime.multipart import MIMEMultipart
6
-
7
- logger = logging.getLogger(__name__)
8
-
9
- def send_recovery_email(to_email: str, code: str, raise_on_error: bool = False):
10
- """
11
- Envía un correo electrónico con el código de recuperación de contraseña de 6 dígitos.
12
- Soporta Brevo API (ideal para enviar a cualquier remitente gratis sin dominio),
13
- Resend API (requiere dominio o enviar solo al creador), o SMTP tradicional como fallback.
14
- """
15
- brevo_api_key = os.getenv("BREVO_API_KEY")
16
- resend_api_key = os.getenv("RESEND_API_KEY")
17
- smtp_from = os.getenv("SMTP_FROM")
18
- smtp_user = os.getenv("SMTP_USER")
19
-
20
- if smtp_from: smtp_from = smtp_from.strip("'\" ")
21
- if smtp_user: smtp_user = smtp_user.strip("'\" ")
22
-
23
- # HTML estilizado y profesional
24
- html_content = f"""
25
- <html>
26
- <body style="font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #0d0e12; color: #f8f8ff; padding: 20px; margin: 0;">
27
- <div style="max-width: 500px; margin: 0 auto; background-color: #15171e; border: 1px solid #232733; border-radius: 16px; padding: 30px; box-shadow: 0 8px 30px rgba(0,0,0,0.5);">
28
- <div style="text-align: center; margin-bottom: 25px;">
29
- <h2 style="color: #9d5cf6; margin: 0; font-size: 26px; letter-spacing: 4px; font-weight: 800;">MELODIX</h2>
30
- <p style="color: #a0a0b8; margin: 5px 0 0 0; font-size: 13px;">Código de Restablecimiento</p>
31
- </div>
32
- <hr style="border: 0; border-top: 1px solid #232733; margin-bottom: 25px;">
33
- <p style="font-size: 15px; line-height: 22px; color: #e2e2e9;">Hola,</p>
34
- <p style="font-size: 15px; line-height: 22px; color: #e2e2e9;">Has solicitado restablecer la contraseña de tu cuenta de Melodix. Utiliza el siguiente código de verificación de 6 dígitos:</p>
35
-
36
- <div style="text-align: center; margin: 30px 0;">
37
- <span style="font-family: 'Courier New', Courier, monospace; font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #00f5d4; background-color: rgba(0, 245, 212, 0.08); padding: 12px 24px; border-radius: 12px; border: 1px solid rgba(0, 245, 212, 0.25); display: inline-block;">
38
- {code}
39
- </span>
40
- </div>
41
-
42
- <p style="font-size: 13px; line-height: 18px; color: #a0a0b8;">Este código es válido por 15 minutos. Si no solicitaste este cambio, puedes ignorar este correo de forma segura.</p>
43
- <hr style="border: 0; border-top: 1px solid #232733; margin-top: 30px; margin-bottom: 20px;">
44
- <p style="font-size: 11px; text-align: center; color: #6c757d; margin: 0;">&copy; {datetime_year()} Melodix App. Todos los derechos reservados.</p>
45
- </div>
46
- </body>
47
- </html>
48
- """
49
-
50
- # 1. ENVIAR POR BREVO API (SI BREVO_API_KEY ESTÁ CONFIGURADA)
51
- if brevo_api_key:
52
- brevo_api_key = brevo_api_key.strip("'\" ")
53
- try:
54
- import requests
55
- url = "https://api.brevo.com/v3/smtp/email"
56
- headers = {
57
- "api-key": brevo_api_key,
58
- "Content-Type": "application/json"
59
- }
60
-
61
- # El remitente debe ser el verificado en Brevo (típicamente smtp_user)
62
- sender_email = smtp_user or "mifemmnic@gmail.com"
63
- sender_name = "Melodix"
64
- if smtp_from:
65
- if "<" in smtp_from and ">" in smtp_from:
66
- parts = smtp_from.split("<")
67
- sender_name = parts[0].strip()
68
- sender_email = parts[1].replace(">", "").strip()
69
- elif "@" in smtp_from:
70
- sender_email = smtp_from.strip()
71
-
72
- payload = {
73
- "sender": {
74
- "name": sender_name,
75
- "email": sender_email
76
- },
77
- "to": [
78
- {
79
- "email": to_email
80
- }
81
- ],
82
- "subject": "Recuperación de Contraseña - Melodix",
83
- "htmlContent": html_content
84
- }
85
-
86
- res = requests.post(url, json=payload, headers=headers, timeout=15)
87
- if res.status_code in (200, 201, 202):
88
- logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía Brevo API")
89
- return True
90
- else:
91
- err_msg = f"Brevo API retornó error {res.status_code}: {res.text}"
92
- logger.error(err_msg)
93
- if raise_on_error:
94
- raise Exception(err_msg)
95
- return False
96
- except Exception as e:
97
- logger.error(f"Error enviando correo vía Brevo a {to_email}: {e}", exc_info=True)
98
- if raise_on_error:
99
- raise e
100
- return False
101
-
102
- # 2. ENVIAR POR RESEND API (SI RESEND_API_KEY ESTÁ CONFIGURADA)
103
- if resend_api_key:
104
- resend_api_key = resend_api_key.strip("'\" ")
105
- try:
106
- import requests
107
- url = "https://api.resend.com/emails"
108
- headers = {
109
- "Authorization": f"Bearer {resend_api_key}",
110
- "Content-Type": "application/json"
111
- }
112
-
113
- from_addr = "onboarding@resend.dev"
114
- if smtp_from and "@" in smtp_from:
115
- domain = smtp_from.split("@")[-1].replace(">", "").strip()
116
- if domain not in ["gmail.com", "outlook.com", "hotmail.com", "yahoo.com"]:
117
- from_addr = smtp_from
118
-
119
- if not from_addr.startswith("Melodix"):
120
- from_addr = f"Melodix <{from_addr}>"
121
-
122
- payload = {
123
- "from": from_addr,
124
- "to": [to_email],
125
- "subject": "Recuperación de Contraseña - Melodix",
126
- "html": html_content
127
- }
128
-
129
- res = requests.post(url, json=payload, headers=headers, timeout=15)
130
- if res.status_code in (200, 201):
131
- logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía Resend API")
132
- return True
133
- else:
134
- err_msg = f"Resend API retornó error {res.status_code}: {res.text}"
135
- logger.error(err_msg)
136
- if raise_on_error:
137
- raise Exception(err_msg)
138
- return False
139
- except Exception as e:
140
- logger.error(f"Error enviando correo vía Resend a {to_email}: {e}", exc_info=True)
141
- if raise_on_error:
142
- raise e
143
- return False
144
-
145
- # 2. ENVIAR POR SMTP TRADICIONAL (FALLBACK)
146
- smtp_host = os.getenv("SMTP_HOST")
147
- smtp_port = os.getenv("SMTP_PORT")
148
- smtp_user = os.getenv("SMTP_USER")
149
- smtp_password = os.getenv("SMTP_PASSWORD")
150
-
151
- # Limpiar comillas iniciales/finales y espacios (común al pegar variables en Render)
152
- if smtp_host: smtp_host = smtp_host.strip("'\" ")
153
- if smtp_port: smtp_port = smtp_port.strip("'\" ")
154
- if smtp_user: smtp_user = smtp_user.strip("'\" ")
155
- if smtp_password: smtp_password = smtp_password.strip("'\" ")
156
-
157
- if not smtp_from:
158
- smtp_from = f"Melodix <{smtp_user}>"
159
-
160
- if not smtp_host or not smtp_port or not smtp_user or not smtp_password:
161
- logger.warning(f"Configuración SMTP incompleta. host={smtp_host}, port={smtp_port}, user={smtp_user}. No se puede enviar correo.")
162
- logger.info(f"[SIMULADO] Código de recuperación para {to_email}: {code}")
163
- return False
164
-
165
- try:
166
- # Configurar el mensaje
167
- msg = MIMEMultipart("alternative")
168
- msg["Subject"] = "Recuperación de Contraseña - Melodix"
169
- msg["From"] = smtp_from
170
- msg["To"] = to_email
171
- msg.attach(MIMEText(html_content, "html"))
172
-
173
- # Conectar al servidor SMTP
174
- port = int(smtp_port)
175
- if port == 465:
176
- server = smtplib.SMTP_SSL(smtp_host, port, timeout=15)
177
- else:
178
- server = smtplib.SMTP(smtp_host, port, timeout=15)
179
- server.ehlo()
180
- server.starttls()
181
- server.ehlo()
182
-
183
- server.login(smtp_user, smtp_password)
184
- server.sendmail(smtp_user, [to_email], msg.as_string())
185
- server.quit()
186
-
187
- logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía SMTP")
188
- return True
189
- except Exception as e:
190
- logger.error(f"Error enviando correo SMTP a {to_email}: {e}", exc_info=True)
191
- if raise_on_error:
192
- raise e
193
- return False
194
-
195
- def datetime_year():
196
- from datetime import datetime
197
- return datetime.utcnow().year
 
1
+ import os
2
+ import smtplib
3
+ import logging
4
+ from email.mime.text import MIMEText
5
+ from email.mime.multipart import MIMEMultipart
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ def send_recovery_email(to_email: str, code: str, raise_on_error: bool = False):
10
+ """
11
+ Envía un correo electrónico con el código de recuperación de contraseña de 6 dígitos.
12
+ Soporta Brevo API (ideal para enviar a cualquier remitente gratis sin dominio),
13
+ Resend API (requiere dominio o enviar solo al creador), o SMTP tradicional como fallback.
14
+ """
15
+ brevo_api_key = os.getenv("BREVO_API_KEY")
16
+ resend_api_key = os.getenv("RESEND_API_KEY")
17
+ smtp_from = os.getenv("SMTP_FROM")
18
+ smtp_user = os.getenv("SMTP_USER")
19
+
20
+ if smtp_from: smtp_from = smtp_from.strip("'\" ")
21
+ if smtp_user: smtp_user = smtp_user.strip("'\" ")
22
+
23
+ # HTML estilizado y profesional
24
+ html_content = f"""
25
+ <html>
26
+ <body style="font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #0d0e12; color: #f8f8ff; padding: 20px; margin: 0;">
27
+ <div style="max-width: 500px; margin: 0 auto; background-color: #15171e; border: 1px solid #232733; border-radius: 16px; padding: 30px; box-shadow: 0 8px 30px rgba(0,0,0,0.5);">
28
+ <div style="text-align: center; margin-bottom: 25px;">
29
+ <h2 style="color: #9d5cf6; margin: 0; font-size: 26px; letter-spacing: 4px; font-weight: 800;">MELODIX</h2>
30
+ <p style="color: #a0a0b8; margin: 5px 0 0 0; font-size: 13px;">Código de Restablecimiento</p>
31
+ </div>
32
+ <hr style="border: 0; border-top: 1px solid #232733; margin-bottom: 25px;">
33
+ <p style="font-size: 15px; line-height: 22px; color: #e2e2e9;">Hola,</p>
34
+ <p style="font-size: 15px; line-height: 22px; color: #e2e2e9;">Has solicitado restablecer la contraseña de tu cuenta de Melodix. Utiliza el siguiente código de verificación de 6 dígitos:</p>
35
+
36
+ <div style="text-align: center; margin: 30px 0;">
37
+ <span style="font-family: 'Courier New', Courier, monospace; font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #00f5d4; background-color: rgba(0, 245, 212, 0.08); padding: 12px 24px; border-radius: 12px; border: 1px solid rgba(0, 245, 212, 0.25); display: inline-block;">
38
+ {code}
39
+ </span>
40
+ </div>
41
+
42
+ <p style="font-size: 13px; line-height: 18px; color: #a0a0b8;">Este código es válido por 15 minutos. Si no solicitaste este cambio, puedes ignorar este correo de forma segura.</p>
43
+ <hr style="border: 0; border-top: 1px solid #232733; margin-top: 30px; margin-bottom: 20px;">
44
+ <p style="font-size: 11px; text-align: center; color: #6c757d; margin: 0;">&copy; {datetime_year()} Melodix App. Todos los derechos reservados.</p>
45
+ </div>
46
+ </body>
47
+ </html>
48
+ """
49
+
50
+ # 1. ENVIAR POR BREVO API (SI BREVO_API_KEY ESTÁ CONFIGURADA)
51
+ if brevo_api_key:
52
+ brevo_api_key = brevo_api_key.strip("'\" ")
53
+ try:
54
+ import requests
55
+ url = "https://api.brevo.com/v3/smtp/email"
56
+ headers = {
57
+ "api-key": brevo_api_key,
58
+ "Content-Type": "application/json"
59
+ }
60
+
61
+ # El remitente debe ser el verificado en Brevo (típicamente smtp_user)
62
+ sender_email = smtp_user or "mifemmnic@gmail.com"
63
+ sender_name = "Melodix"
64
+ if smtp_from:
65
+ if "<" in smtp_from and ">" in smtp_from:
66
+ parts = smtp_from.split("<")
67
+ sender_name = parts[0].strip()
68
+ sender_email = parts[1].replace(">", "").strip()
69
+ elif "@" in smtp_from:
70
+ sender_email = smtp_from.strip()
71
+
72
+ payload = {
73
+ "sender": {
74
+ "name": sender_name,
75
+ "email": sender_email
76
+ },
77
+ "to": [
78
+ {
79
+ "email": to_email
80
+ }
81
+ ],
82
+ "subject": "Recuperación de Contraseña - Melodix",
83
+ "htmlContent": html_content
84
+ }
85
+
86
+ res = requests.post(url, json=payload, headers=headers, timeout=15)
87
+ if res.status_code in (200, 201, 202):
88
+ logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía Brevo API")
89
+ return True
90
+ else:
91
+ err_msg = f"Brevo API retornó error {res.status_code}: {res.text}"
92
+ logger.error(err_msg)
93
+ if raise_on_error:
94
+ raise Exception(err_msg)
95
+ return False
96
+ except Exception as e:
97
+ logger.error(f"Error enviando correo vía Brevo a {to_email}: {e}", exc_info=True)
98
+ if raise_on_error:
99
+ raise e
100
+ return False
101
+
102
+ # 2. ENVIAR POR RESEND API (SI RESEND_API_KEY ESTÁ CONFIGURADA)
103
+ if resend_api_key:
104
+ resend_api_key = resend_api_key.strip("'\" ")
105
+ try:
106
+ import requests
107
+ url = "https://api.resend.com/emails"
108
+ headers = {
109
+ "Authorization": f"Bearer {resend_api_key}",
110
+ "Content-Type": "application/json"
111
+ }
112
+
113
+ from_addr = "onboarding@resend.dev"
114
+ if smtp_from and "@" in smtp_from:
115
+ domain = smtp_from.split("@")[-1].replace(">", "").strip()
116
+ if domain not in ["gmail.com", "outlook.com", "hotmail.com", "yahoo.com"]:
117
+ from_addr = smtp_from
118
+
119
+ if not from_addr.startswith("Melodix"):
120
+ from_addr = f"Melodix <{from_addr}>"
121
+
122
+ payload = {
123
+ "from": from_addr,
124
+ "to": [to_email],
125
+ "subject": "Recuperación de Contraseña - Melodix",
126
+ "html": html_content
127
+ }
128
+
129
+ res = requests.post(url, json=payload, headers=headers, timeout=15)
130
+ if res.status_code in (200, 201):
131
+ logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía Resend API")
132
+ return True
133
+ else:
134
+ err_msg = f"Resend API retornó error {res.status_code}: {res.text}"
135
+ logger.error(err_msg)
136
+ if raise_on_error:
137
+ raise Exception(err_msg)
138
+ return False
139
+ except Exception as e:
140
+ logger.error(f"Error enviando correo vía Resend a {to_email}: {e}", exc_info=True)
141
+ if raise_on_error:
142
+ raise e
143
+ return False
144
+
145
+ # 2. ENVIAR POR SMTP TRADICIONAL (FALLBACK)
146
+ smtp_host = os.getenv("SMTP_HOST")
147
+ smtp_port = os.getenv("SMTP_PORT")
148
+ smtp_user = os.getenv("SMTP_USER")
149
+ smtp_password = os.getenv("SMTP_PASSWORD")
150
+
151
+ # Limpiar comillas iniciales/finales y espacios (común al pegar variables en Render)
152
+ if smtp_host: smtp_host = smtp_host.strip("'\" ")
153
+ if smtp_port: smtp_port = smtp_port.strip("'\" ")
154
+ if smtp_user: smtp_user = smtp_user.strip("'\" ")
155
+ if smtp_password: smtp_password = smtp_password.strip("'\" ")
156
+
157
+ if not smtp_from:
158
+ smtp_from = f"Melodix <{smtp_user}>"
159
+
160
+ if not smtp_host or not smtp_port or not smtp_user or not smtp_password:
161
+ logger.warning(f"Configuración SMTP incompleta. host={smtp_host}, port={smtp_port}, user={smtp_user}. No se puede enviar correo.")
162
+ logger.info(f"[SIMULADO] Código de recuperación para {to_email}: {code}")
163
+ return False
164
+
165
+ try:
166
+ # Configurar el mensaje
167
+ msg = MIMEMultipart("alternative")
168
+ msg["Subject"] = "Recuperación de Contraseña - Melodix"
169
+ msg["From"] = smtp_from
170
+ msg["To"] = to_email
171
+ msg.attach(MIMEText(html_content, "html"))
172
+
173
+ # Conectar al servidor SMTP
174
+ port = int(smtp_port)
175
+ if port == 465:
176
+ server = smtplib.SMTP_SSL(smtp_host, port, timeout=15)
177
+ else:
178
+ server = smtplib.SMTP(smtp_host, port, timeout=15)
179
+ server.ehlo()
180
+ server.starttls()
181
+ server.ehlo()
182
+
183
+ server.login(smtp_user, smtp_password)
184
+ server.sendmail(smtp_user, [to_email], msg.as_string())
185
+ server.quit()
186
+
187
+ logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía SMTP")
188
+ return True
189
+ except Exception as e:
190
+ logger.error(f"Error enviando correo SMTP a {to_email}: {e}", exc_info=True)
191
+ if raise_on_error:
192
+ raise e
193
+ return False
194
+
195
+ def datetime_year():
196
+ from datetime import datetime
197
+ return datetime.utcnow().year
app/utils/imgbb.py CHANGED
@@ -1,76 +1,76 @@
1
- import requests
2
- import logging
3
-
4
- logger = logging.getLogger(__name__)
5
-
6
- IMGBB_UPLOAD_URL = "https://api.imgbb.com/1/upload"
7
-
8
- def upload_image_to_imgbb(base64_str: str, api_key: str = None) -> str:
9
- """
10
- Subir una imagen en formato base64 a ImgBB usando la clave de la iglesia y retornar la URL.
11
- Si ya es una URL válida, se retorna directamente.
12
- """
13
- if not base64_str:
14
- return base64_str
15
-
16
- if base64_str.startswith("http://") or base64_str.startswith("https://"):
17
- return base64_str
18
-
19
- if not api_key or not api_key.strip():
20
- logger.error("Intento de subida de imagen sin clave de ImgBB configurada.")
21
- raise ValueError("La iglesia no tiene configurada una clave de almacenamiento de imágenes (ImgBB). Por favor, pide al administrador que la configure en Conexiones.")
22
-
23
- try:
24
- # Remover prefijos como 'data:image/jpeg;base64,' si existen
25
- clean_base64 = base64_str
26
- if "," in base64_str:
27
- clean_base64 = base64_str.split(",", 1)[1]
28
-
29
- payload = {
30
- "key": api_key.strip(),
31
- "image": clean_base64
32
- }
33
-
34
- response = requests.post(IMGBB_UPLOAD_URL, data=payload, timeout=20)
35
-
36
- if response.status_code in [400, 403]:
37
- logger.error(f"Clave ImgBB inválida o rechazada: {response.text}")
38
- raise ValueError("La clave de ImgBB configurada en esta iglesia ya no es válida o ha caducado.")
39
-
40
- response.raise_for_status()
41
- data = response.json()
42
- if data.get("success"):
43
- return data["data"]["url"]
44
- else:
45
- logger.error(f"Error subiendo a ImgBB: {data}")
46
- raise ValueError("No se pudo completar la subida de la imagen a ImgBB.")
47
-
48
- except ValueError as ve:
49
- raise ve
50
- except Exception as e:
51
- logger.error(f"Excepción subiendo a ImgBB: {e}")
52
- raise ValueError("Ocurrió un error de conexión al subir la imagen.")
53
-
54
- def validate_imgbb_key(api_key: str) -> bool:
55
- """
56
- Valida una clave de ImgBB subiendo un pixel PNG 1x1 estándar.
57
- """
58
- if not api_key or not api_key.strip():
59
- return False
60
-
61
- # Pixel PNG 1x1 en Base64
62
- tiny_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
63
-
64
- payload = {
65
- "key": api_key.strip(),
66
- "image": tiny_png
67
- }
68
-
69
- try:
70
- response = requests.post(IMGBB_UPLOAD_URL, data=payload, timeout=10)
71
- if response.status_code == 200 and response.json().get("success"):
72
- return True
73
- return False
74
- except Exception as e:
75
- logger.warning(f"Fallo en validación de clave ImgBB: {e}")
76
- return False
 
1
+ import requests
2
+ import logging
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+ IMGBB_UPLOAD_URL = "https://api.imgbb.com/1/upload"
7
+
8
+ def upload_image_to_imgbb(base64_str: str, api_key: str = None) -> str:
9
+ """
10
+ Subir una imagen en formato base64 a ImgBB usando la clave de la iglesia y retornar la URL.
11
+ Si ya es una URL válida, se retorna directamente.
12
+ """
13
+ if not base64_str:
14
+ return base64_str
15
+
16
+ if base64_str.startswith("http://") or base64_str.startswith("https://"):
17
+ return base64_str
18
+
19
+ if not api_key or not api_key.strip():
20
+ logger.error("Intento de subida de imagen sin clave de ImgBB configurada.")
21
+ raise ValueError("La iglesia no tiene configurada una clave de almacenamiento de imágenes (ImgBB). Por favor, pide al administrador que la configure en Conexiones.")
22
+
23
+ try:
24
+ # Remover prefijos como 'data:image/jpeg;base64,' si existen
25
+ clean_base64 = base64_str
26
+ if "," in base64_str:
27
+ clean_base64 = base64_str.split(",", 1)[1]
28
+
29
+ payload = {
30
+ "key": api_key.strip(),
31
+ "image": clean_base64
32
+ }
33
+
34
+ response = requests.post(IMGBB_UPLOAD_URL, data=payload, timeout=20)
35
+
36
+ if response.status_code in [400, 403]:
37
+ logger.error(f"Clave ImgBB inválida o rechazada: {response.text}")
38
+ raise ValueError("La clave de ImgBB configurada en esta iglesia ya no es válida o ha caducado.")
39
+
40
+ response.raise_for_status()
41
+ data = response.json()
42
+ if data.get("success"):
43
+ return data["data"]["url"]
44
+ else:
45
+ logger.error(f"Error subiendo a ImgBB: {data}")
46
+ raise ValueError("No se pudo completar la subida de la imagen a ImgBB.")
47
+
48
+ except ValueError as ve:
49
+ raise ve
50
+ except Exception as e:
51
+ logger.error(f"Excepción subiendo a ImgBB: {e}")
52
+ raise ValueError("Ocurrió un error de conexión al subir la imagen.")
53
+
54
+ def validate_imgbb_key(api_key: str) -> bool:
55
+ """
56
+ Valida una clave de ImgBB subiendo un pixel PNG 1x1 estándar.
57
+ """
58
+ if not api_key or not api_key.strip():
59
+ return False
60
+
61
+ # Pixel PNG 1x1 en Base64
62
+ tiny_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
63
+
64
+ payload = {
65
+ "key": api_key.strip(),
66
+ "image": tiny_png
67
+ }
68
+
69
+ try:
70
+ response = requests.post(IMGBB_UPLOAD_URL, data=payload, timeout=10)
71
+ if response.status_code == 200 and response.json().get("success"):
72
+ return True
73
+ return False
74
+ except Exception as e:
75
+ logger.warning(f"Fallo en validación de clave ImgBB: {e}")
76
+ return False
app/utils/midi_parser.py CHANGED
@@ -1,84 +1,84 @@
1
- import pretty_midi
2
- import os
3
- import logging
4
-
5
- logger = logging.getLogger(__name__)
6
-
7
- def parse_midi_file(filepath: str) -> dict:
8
- """
9
- Parsea un archivo MIDI (.mid o .midi) y extrae todas sus pistas, notas, tempo y clasificación GM.
10
- """
11
- if not os.path.exists(filepath):
12
- raise FileNotFoundError(f"No se encontró el archivo MIDI en: {filepath}")
13
-
14
- try:
15
- pm = pretty_midi.PrettyMIDI(filepath)
16
- tempo = pm.estimate_tempo()
17
- duration = pm.get_end_time()
18
-
19
- tracks = []
20
- for idx, inst in enumerate(pm.instruments):
21
- notes = []
22
- for note in inst.notes:
23
- notes.append({
24
- "note": pretty_midi.note_number_to_name(note.pitch),
25
- "midi": note.pitch,
26
- "start": round(note.start, 3),
27
- "duration": round(note.end - note.start, 3),
28
- "velocity": note.velocity
29
- })
30
-
31
- # Si no tiene notas, la ignoramos para mantener el payload limpio
32
- if not notes:
33
- continue
34
-
35
- if inst.is_drum:
36
- inst_type = "drums"
37
- name = "Batería / Percusión"
38
- else:
39
- prog = inst.program
40
- if 0 <= prog <= 7:
41
- inst_type = "piano"
42
- name = f"Piano (GM {prog})"
43
- elif 24 <= prog <= 31:
44
- inst_type = "guitar"
45
- name = f"Guitarra (GM {prog})"
46
- elif 32 <= prog <= 39:
47
- inst_type = "bass"
48
- name = f"Bajo (GM {prog})"
49
- elif 16 <= prog <= 23:
50
- inst_type = "organ"
51
- name = f"Órgano (GM {prog})"
52
- elif 40 <= prog <= 55:
53
- inst_type = "strings"
54
- name = f"Cuerdas/Orquesta (GM {prog})"
55
- elif 56 <= prog <= 71:
56
- inst_type = "brass"
57
- name = f"Metales/Vientos (GM {prog})"
58
- else:
59
- inst_type = "synth"
60
- name = f"Sintetizador (GM {prog})"
61
-
62
- tracks.append({
63
- "id": idx,
64
- "nombre": name,
65
- "program": inst.program,
66
- "is_drum": inst.is_drum,
67
- "tipo_instrumento": inst_type,
68
- "total_notas": len(notes),
69
- "notas": notes
70
- })
71
-
72
- return {
73
- "success": True,
74
- "tempo_bpm": round(tempo, 1) if tempo > 0 else 120.0,
75
- "duracion_segundos": round(duration, 2),
76
- "total_pistas": len(tracks),
77
- "pistas": tracks
78
- }
79
- except Exception as e:
80
- logger.error(f"Error parseando archivo MIDI {filepath}: {e}")
81
- return {
82
- "success": False,
83
- "error": f"El archivo no es un MIDI válido o está dañado: {str(e)}"
84
- }
 
1
+ import pretty_midi
2
+ import os
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ def parse_midi_file(filepath: str) -> dict:
8
+ """
9
+ Parsea un archivo MIDI (.mid o .midi) y extrae todas sus pistas, notas, tempo y clasificación GM.
10
+ """
11
+ if not os.path.exists(filepath):
12
+ raise FileNotFoundError(f"No se encontró el archivo MIDI en: {filepath}")
13
+
14
+ try:
15
+ pm = pretty_midi.PrettyMIDI(filepath)
16
+ tempo = pm.estimate_tempo()
17
+ duration = pm.get_end_time()
18
+
19
+ tracks = []
20
+ for idx, inst in enumerate(pm.instruments):
21
+ notes = []
22
+ for note in inst.notes:
23
+ notes.append({
24
+ "note": pretty_midi.note_number_to_name(note.pitch),
25
+ "midi": note.pitch,
26
+ "start": round(note.start, 3),
27
+ "duration": round(note.end - note.start, 3),
28
+ "velocity": note.velocity
29
+ })
30
+
31
+ # Si no tiene notas, la ignoramos para mantener el payload limpio
32
+ if not notes:
33
+ continue
34
+
35
+ if inst.is_drum:
36
+ inst_type = "drums"
37
+ name = "Batería / Percusión"
38
+ else:
39
+ prog = inst.program
40
+ if 0 <= prog <= 7:
41
+ inst_type = "piano"
42
+ name = f"Piano (GM {prog})"
43
+ elif 24 <= prog <= 31:
44
+ inst_type = "guitar"
45
+ name = f"Guitarra (GM {prog})"
46
+ elif 32 <= prog <= 39:
47
+ inst_type = "bass"
48
+ name = f"Bajo (GM {prog})"
49
+ elif 16 <= prog <= 23:
50
+ inst_type = "organ"
51
+ name = f"Órgano (GM {prog})"
52
+ elif 40 <= prog <= 55:
53
+ inst_type = "strings"
54
+ name = f"Cuerdas/Orquesta (GM {prog})"
55
+ elif 56 <= prog <= 71:
56
+ inst_type = "brass"
57
+ name = f"Metales/Vientos (GM {prog})"
58
+ else:
59
+ inst_type = "synth"
60
+ name = f"Sintetizador (GM {prog})"
61
+
62
+ tracks.append({
63
+ "id": idx,
64
+ "nombre": name,
65
+ "program": inst.program,
66
+ "is_drum": inst.is_drum,
67
+ "tipo_instrumento": inst_type,
68
+ "total_notas": len(notes),
69
+ "notas": notes
70
+ })
71
+
72
+ return {
73
+ "success": True,
74
+ "tempo_bpm": round(tempo, 1) if tempo > 0 else 120.0,
75
+ "duracion_segundos": round(duration, 2),
76
+ "total_pistas": len(tracks),
77
+ "pistas": tracks
78
+ }
79
+ except Exception as e:
80
+ logger.error(f"Error parseando archivo MIDI {filepath}: {e}")
81
+ return {
82
+ "success": False,
83
+ "error": f"El archivo no es un MIDI válido o está dañado: {str(e)}"
84
+ }
app/utils/notifications.py CHANGED
@@ -1,27 +1,27 @@
1
- from sqlalchemy.orm import Session
2
- from app.models.user import AdminNotification
3
- from datetime import datetime
4
- import logging
5
-
6
- logger = logging.getLogger(__name__)
7
-
8
- def create_admin_notification(db: Session, title: str, message: str, category: str = "system"):
9
- """
10
- Registra de forma segura una notificación para el administrador en la DB.
11
- Maneja excepciones y realiza rollback en caso de fallo para no romper la transacción principal.
12
- Categorías disponibles: system | user | church | group | worker
13
- """
14
- try:
15
- notif = AdminNotification(
16
- title=title,
17
- message=message,
18
- category=category,
19
- created_at=datetime.utcnow(),
20
- read=False
21
- )
22
- db.add(notif)
23
- db.commit()
24
- logger.info(f"[AdminNotif] Notificación registrada: {title} - {message}")
25
- except Exception as e:
26
- db.rollback()
27
- logger.warning(f"[AdminNotif] Error al guardar notificación de admin: {e}")
 
1
+ from sqlalchemy.orm import Session
2
+ from app.models.user import AdminNotification
3
+ from datetime import datetime
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ def create_admin_notification(db: Session, title: str, message: str, category: str = "system"):
9
+ """
10
+ Registra de forma segura una notificación para el administrador en la DB.
11
+ Maneja excepciones y realiza rollback en caso de fallo para no romper la transacción principal.
12
+ Categorías disponibles: system | user | church | group | worker
13
+ """
14
+ try:
15
+ notif = AdminNotification(
16
+ title=title,
17
+ message=message,
18
+ category=category,
19
+ created_at=datetime.utcnow(),
20
+ read=False
21
+ )
22
+ db.add(notif)
23
+ db.commit()
24
+ logger.info(f"[AdminNotif] Notificación registrada: {title} - {message}")
25
+ except Exception as e:
26
+ db.rollback()
27
+ logger.warning(f"[AdminNotif] Error al guardar notificación de admin: {e}")
app/utils/redis_client.py CHANGED
@@ -1,29 +1,29 @@
1
- import os
2
- import redis
3
- import logging
4
-
5
- logger = logging.getLogger(__name__)
6
-
7
- def get_redis_client():
8
- redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
9
-
10
- # Handle TLS for Upstash Redis
11
- _is_tls = redis_url.startswith("rediss://")
12
- if _is_tls:
13
- # Normalize ssl_cert_reqs to 'none' if it is 'CERT_NONE' (case-insensitive) to prevent redis-py errors
14
- if "ssl_cert_reqs=" in redis_url:
15
- redis_url = redis_url.replace("ssl_cert_reqs=CERT_NONE", "ssl_cert_reqs=none")
16
- redis_url = redis_url.replace("ssl_cert_reqs=cert_none", "ssl_cert_reqs=none")
17
- else:
18
- if "?" in redis_url:
19
- redis_url += "&ssl_cert_reqs=none"
20
- else:
21
- redis_url += "?ssl_cert_reqs=none"
22
-
23
- try:
24
- # If it's rediss://, redis.from_url handles ssl parameters
25
- client = redis.Redis.from_url(redis_url, decode_responses=True)
26
- return client
27
- except Exception as e:
28
- logger.error(f"Error creating Redis client: {e}")
29
- return None
 
1
+ import os
2
+ import redis
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ def get_redis_client():
8
+ redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
9
+
10
+ # Handle TLS for Upstash Redis
11
+ _is_tls = redis_url.startswith("rediss://")
12
+ if _is_tls:
13
+ # Normalize ssl_cert_reqs to 'none' if it is 'CERT_NONE' (case-insensitive) to prevent redis-py errors
14
+ if "ssl_cert_reqs=" in redis_url:
15
+ redis_url = redis_url.replace("ssl_cert_reqs=CERT_NONE", "ssl_cert_reqs=none")
16
+ redis_url = redis_url.replace("ssl_cert_reqs=cert_none", "ssl_cert_reqs=none")
17
+ else:
18
+ if "?" in redis_url:
19
+ redis_url += "&ssl_cert_reqs=none"
20
+ else:
21
+ redis_url += "?ssl_cert_reqs=none"
22
+
23
+ try:
24
+ # If it's rediss://, redis.from_url handles ssl parameters
25
+ client = redis.Redis.from_url(redis_url, decode_responses=True)
26
+ return client
27
+ except Exception as e:
28
+ logger.error(f"Error creating Redis client: {e}")
29
+ return None
app/utils/supabase_storage.py CHANGED
@@ -1,56 +1,56 @@
1
- import re
2
- import logging
3
- from supabase import create_client, Client
4
-
5
- logger = logging.getLogger("uvicorn.error")
6
-
7
- SUPABASE_URL = "https://egkydqberhebzkqtltic.supabase.co"
8
- SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVna3lkcWJlcmhlYnprcXRsdGljIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkzNDA4NTMsImV4cCI6MjA5NDkxNjg1M30.KfLrhTHrC2W2g6IDuGuMA5Gd_wnj4YOuRdV6hhcSCqM"
9
- BUCKET_NAME = "melodix-audio"
10
-
11
- def get_supabase_client() -> Client:
12
- return create_client(SUPABASE_URL, SUPABASE_KEY)
13
-
14
- def parse_supabase_path(url: str) -> str:
15
- """
16
- Extrae el path del archivo dentro del bucket a partir de la URL pública de Supabase.
17
- Ej: https://.../public/melodix-audio/originals/xxx.mp3 -> originals/xxx.mp3
18
- """
19
- if not url:
20
- return None
21
- pattern = rf"/storage/v1/object/public/{BUCKET_NAME}/(.+)$"
22
- match = re.search(pattern, url)
23
- if match:
24
- return match.group(1)
25
- return None
26
-
27
- def delete_supabase_file(path_in_bucket: str):
28
- """
29
- Elimina un archivo específico del bucket de Supabase.
30
- """
31
- if not path_in_bucket:
32
- return
33
- try:
34
- client = get_supabase_client()
35
- res = client.storage.from_(BUCKET_NAME).remove([path_in_bucket])
36
- logger.info(f"Archivo eliminado de Supabase: {path_in_bucket}. Resultado: {res}")
37
- except Exception as e:
38
- logger.warning(f"No se pudo eliminar archivo de Supabase ({path_in_bucket}): {e}")
39
-
40
- def delete_supabase_folder(folder_path: str):
41
- """
42
- Elimina todos los archivos dentro de una carpeta del bucket.
43
- """
44
- if not folder_path:
45
- return
46
- try:
47
- client = get_supabase_client()
48
- # Listar archivos en la carpeta
49
- files = client.storage.from_(BUCKET_NAME).list(folder_path)
50
- if files:
51
- paths_to_remove = [f"{folder_path}/{f['name']}" for f in files if f.get('name') and f['name'] != '.emptyFolderPlaceholder']
52
- if paths_to_remove:
53
- res = client.storage.from_(BUCKET_NAME).remove(paths_to_remove)
54
- logger.info(f"Carpeta eliminada de Supabase: {folder_path}. Archivos eliminados: {paths_to_remove}. Resultado: {res}")
55
- except Exception as e:
56
- logger.warning(f"No se pudo eliminar carpeta de Supabase ({folder_path}): {e}")
 
1
+ import re
2
+ import logging
3
+ from supabase import create_client, Client
4
+
5
+ logger = logging.getLogger("uvicorn.error")
6
+
7
+ SUPABASE_URL = "https://egkydqberhebzkqtltic.supabase.co"
8
+ SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVna3lkcWJlcmhlYnprcXRsdGljIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkzNDA4NTMsImV4cCI6MjA5NDkxNjg1M30.KfLrhTHrC2W2g6IDuGuMA5Gd_wnj4YOuRdV6hhcSCqM"
9
+ BUCKET_NAME = "melodix-audio"
10
+
11
+ def get_supabase_client() -> Client:
12
+ return create_client(SUPABASE_URL, SUPABASE_KEY)
13
+
14
+ def parse_supabase_path(url: str) -> str:
15
+ """
16
+ Extrae el path del archivo dentro del bucket a partir de la URL pública de Supabase.
17
+ Ej: https://.../public/melodix-audio/originals/xxx.mp3 -> originals/xxx.mp3
18
+ """
19
+ if not url:
20
+ return None
21
+ pattern = rf"/storage/v1/object/public/{BUCKET_NAME}/(.+)$"
22
+ match = re.search(pattern, url)
23
+ if match:
24
+ return match.group(1)
25
+ return None
26
+
27
+ def delete_supabase_file(path_in_bucket: str):
28
+ """
29
+ Elimina un archivo específico del bucket de Supabase.
30
+ """
31
+ if not path_in_bucket:
32
+ return
33
+ try:
34
+ client = get_supabase_client()
35
+ res = client.storage.from_(BUCKET_NAME).remove([path_in_bucket])
36
+ logger.info(f"Archivo eliminado de Supabase: {path_in_bucket}. Resultado: {res}")
37
+ except Exception as e:
38
+ logger.warning(f"No se pudo eliminar archivo de Supabase ({path_in_bucket}): {e}")
39
+
40
+ def delete_supabase_folder(folder_path: str):
41
+ """
42
+ Elimina todos los archivos dentro de una carpeta del bucket.
43
+ """
44
+ if not folder_path:
45
+ return
46
+ try:
47
+ client = get_supabase_client()
48
+ # Listar archivos en la carpeta
49
+ files = client.storage.from_(BUCKET_NAME).list(folder_path)
50
+ if files:
51
+ paths_to_remove = [f"{folder_path}/{f['name']}" for f in files if f.get('name') and f['name'] != '.emptyFolderPlaceholder']
52
+ if paths_to_remove:
53
+ res = client.storage.from_(BUCKET_NAME).remove(paths_to_remove)
54
+ logger.info(f"Carpeta eliminada de Supabase: {folder_path}. Archivos eliminados: {paths_to_remove}. Resultado: {res}")
55
+ except Exception as e:
56
+ logger.warning(f"No se pudo eliminar carpeta de Supabase ({folder_path}): {e}")
app/utils/telegram_storage.py CHANGED
@@ -1,161 +1,161 @@
1
- # ==========================================
2
- # MELODIX - Telegram Storage Utility
3
- # Gestiona subida/descarga de stems al canal de Telegram desde el backend.
4
- # El Bot Token NUNCA se expone al cliente móvil.
5
- # ==========================================
6
-
7
- import os
8
- import logging
9
- import requests
10
- from dotenv import load_dotenv
11
-
12
- load_dotenv()
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
- TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
17
- TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
18
-
19
- TELEGRAM_API = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}"
20
- TELEGRAM_FILE_API = f"https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}"
21
-
22
-
23
- def _check_config():
24
- if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
25
- raise RuntimeError(
26
- "TELEGRAM_BOT_TOKEN y TELEGRAM_CHAT_ID deben estar configurados en .env"
27
- )
28
-
29
-
30
- def upload_stem_to_telegram(file_path: str, stem_name: str, task_id: str) -> dict:
31
- """
32
- Sube un archivo FLAC al canal de Telegram con reintentos y tolerancia a límite de peticiones (429).
33
-
34
- Returns:
35
- {"file_id": str, "message_id": int}
36
-
37
- Raises:
38
- RuntimeError si falla la subida después de todos los reintentos.
39
- """
40
- import time as _time
41
- _check_config()
42
-
43
- if not os.path.exists(file_path):
44
- raise FileNotFoundError(f"Stem no encontrado para subir: {file_path}")
45
-
46
- file_size_mb = os.path.getsize(file_path) / (1024 * 1024)
47
- logger.info(f"[Telegram] Subiendo stem '{stem_name}' de tarea '{task_id}' ({file_size_mb:.1f} MB)...")
48
-
49
- caption = f"🎵 task:{task_id} | stem:{stem_name}"
50
- max_retries = 3
51
-
52
- for attempt in range(1, max_retries + 1):
53
- try:
54
- with open(file_path, "rb") as f:
55
- response = requests.post(
56
- f"{TELEGRAM_API}/sendDocument",
57
- data={
58
- "chat_id": TELEGRAM_CHAT_ID,
59
- "caption": caption,
60
- },
61
- files={
62
- "document": (f"{task_id}_{stem_name}.flac", f, "audio/flac"),
63
- },
64
- timeout=300, # 5 minutos por archivo
65
- )
66
-
67
- # Manejar límite de peticiones de Telegram
68
- if response.status_code == 429:
69
- retry_after = 5
70
- try:
71
- retry_after = int(response.headers.get("Retry-After", 5))
72
- except:
73
- pass
74
- logger.warning(f"[Telegram] Límite de peticiones (429) alcanzado para '{stem_name}'. Reintentando en {retry_after}s... (Intento {attempt}/{max_retries})")
75
- _time.sleep(retry_after)
76
- continue
77
-
78
- if not response.ok:
79
- raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
80
-
81
- data = response.json()
82
- if not data.get("ok"):
83
- raise RuntimeError(f"Error API: {data.get('description', 'desconocido')}")
84
-
85
- document = data["result"].get("document") or data["result"].get("audio")
86
- if not document:
87
- raise RuntimeError("La respuesta no contiene 'document'")
88
-
89
- file_id = document["file_id"]
90
- message_id = data["result"]["message_id"]
91
-
92
- logger.info(f"[Telegram] ✅ '{stem_name}' subido con éxito en el intento {attempt}. message_id={message_id}")
93
- return {"file_id": file_id, "message_id": message_id}
94
-
95
- except Exception as e:
96
- logger.warning(f"[Telegram] Error en intento {attempt}/{max_retries} para '{stem_name}': {e}")
97
- if attempt == max_retries:
98
- raise RuntimeError(f"No se pudo subir a Telegram después de {max_retries} intentos. Error: {e}")
99
- _time.sleep(3) # Espera corta antes de reintentar
100
-
101
-
102
-
103
- def get_telegram_file_url(file_id: str) -> str:
104
- """
105
- Obtiene la URL de descarga directa (temporal ~1 hora) de un archivo en Telegram.
106
-
107
- Returns:
108
- URL de descarga directa (str)
109
-
110
- Raises:
111
- RuntimeError si falla.
112
- """
113
- _check_config()
114
-
115
- response = requests.get(
116
- f"{TELEGRAM_API}/getFile",
117
- params={"file_id": file_id},
118
- timeout=30,
119
- )
120
-
121
- if not response.ok:
122
- try:
123
- err_desc = response.json().get("description", response.text)
124
- except Exception:
125
- err_desc = response.text
126
- raise RuntimeError(f"[Telegram] getFile HTTP {response.status_code}: {err_desc}")
127
-
128
- data = response.json()
129
- if not data.get("ok"):
130
- raise RuntimeError(f"[Telegram] getFile error: {data.get('description')}")
131
-
132
- file_path = data["result"]["file_path"]
133
- return f"{TELEGRAM_FILE_API}/{file_path}"
134
-
135
-
136
- def delete_telegram_message(message_id: int) -> bool:
137
- """
138
- Elimina un mensaje (y por ende su archivo) del canal de Telegram.
139
- Llamar cuando el usuario borra una pista desde la app.
140
-
141
- Returns:
142
- True si se eliminó, False si hubo error no crítico.
143
- """
144
- _check_config()
145
-
146
- try:
147
- response = requests.post(
148
- f"{TELEGRAM_API}/deleteMessage",
149
- json={"chat_id": TELEGRAM_CHAT_ID, "message_id": message_id},
150
- timeout=15,
151
- )
152
- data = response.json()
153
- if data.get("ok"):
154
- logger.info(f"[Telegram] Mensaje {message_id} eliminado del canal.")
155
- return True
156
- else:
157
- logger.warning(f"[Telegram] No se pudo eliminar mensaje {message_id}: {data.get('description')}")
158
- return False
159
- except Exception as e:
160
- logger.warning(f"[Telegram] Error eliminando mensaje {message_id}: {e}")
161
- return False
 
1
+ # ==========================================
2
+ # MELODIX - Telegram Storage Utility
3
+ # Gestiona subida/descarga de stems al canal de Telegram desde el backend.
4
+ # El Bot Token NUNCA se expone al cliente móvil.
5
+ # ==========================================
6
+
7
+ import os
8
+ import logging
9
+ import requests
10
+ from dotenv import load_dotenv
11
+
12
+ load_dotenv()
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
17
+ TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
18
+
19
+ TELEGRAM_API = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}"
20
+ TELEGRAM_FILE_API = f"https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}"
21
+
22
+
23
+ def _check_config():
24
+ if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
25
+ raise RuntimeError(
26
+ "TELEGRAM_BOT_TOKEN y TELEGRAM_CHAT_ID deben estar configurados en .env"
27
+ )
28
+
29
+
30
+ def upload_stem_to_telegram(file_path: str, stem_name: str, task_id: str) -> dict:
31
+ """
32
+ Sube un archivo FLAC al canal de Telegram con reintentos y tolerancia a límite de peticiones (429).
33
+
34
+ Returns:
35
+ {"file_id": str, "message_id": int}
36
+
37
+ Raises:
38
+ RuntimeError si falla la subida después de todos los reintentos.
39
+ """
40
+ import time as _time
41
+ _check_config()
42
+
43
+ if not os.path.exists(file_path):
44
+ raise FileNotFoundError(f"Stem no encontrado para subir: {file_path}")
45
+
46
+ file_size_mb = os.path.getsize(file_path) / (1024 * 1024)
47
+ logger.info(f"[Telegram] Subiendo stem '{stem_name}' de tarea '{task_id}' ({file_size_mb:.1f} MB)...")
48
+
49
+ caption = f"🎵 task:{task_id} | stem:{stem_name}"
50
+ max_retries = 3
51
+
52
+ for attempt in range(1, max_retries + 1):
53
+ try:
54
+ with open(file_path, "rb") as f:
55
+ response = requests.post(
56
+ f"{TELEGRAM_API}/sendDocument",
57
+ data={
58
+ "chat_id": TELEGRAM_CHAT_ID,
59
+ "caption": caption,
60
+ },
61
+ files={
62
+ "document": (f"{task_id}_{stem_name}.flac", f, "audio/flac"),
63
+ },
64
+ timeout=300, # 5 minutos por archivo
65
+ )
66
+
67
+ # Manejar límite de peticiones de Telegram
68
+ if response.status_code == 429:
69
+ retry_after = 5
70
+ try:
71
+ retry_after = int(response.headers.get("Retry-After", 5))
72
+ except:
73
+ pass
74
+ logger.warning(f"[Telegram] Límite de peticiones (429) alcanzado para '{stem_name}'. Reintentando en {retry_after}s... (Intento {attempt}/{max_retries})")
75
+ _time.sleep(retry_after)
76
+ continue
77
+
78
+ if not response.ok:
79
+ raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
80
+
81
+ data = response.json()
82
+ if not data.get("ok"):
83
+ raise RuntimeError(f"Error API: {data.get('description', 'desconocido')}")
84
+
85
+ document = data["result"].get("document") or data["result"].get("audio")
86
+ if not document:
87
+ raise RuntimeError("La respuesta no contiene 'document'")
88
+
89
+ file_id = document["file_id"]
90
+ message_id = data["result"]["message_id"]
91
+
92
+ logger.info(f"[Telegram] ✅ '{stem_name}' subido con éxito en el intento {attempt}. message_id={message_id}")
93
+ return {"file_id": file_id, "message_id": message_id}
94
+
95
+ except Exception as e:
96
+ logger.warning(f"[Telegram] Error en intento {attempt}/{max_retries} para '{stem_name}': {e}")
97
+ if attempt == max_retries:
98
+ raise RuntimeError(f"No se pudo subir a Telegram después de {max_retries} intentos. Error: {e}")
99
+ _time.sleep(3) # Espera corta antes de reintentar
100
+
101
+
102
+
103
+ def get_telegram_file_url(file_id: str) -> str:
104
+ """
105
+ Obtiene la URL de descarga directa (temporal ~1 hora) de un archivo en Telegram.
106
+
107
+ Returns:
108
+ URL de descarga directa (str)
109
+
110
+ Raises:
111
+ RuntimeError si falla.
112
+ """
113
+ _check_config()
114
+
115
+ response = requests.get(
116
+ f"{TELEGRAM_API}/getFile",
117
+ params={"file_id": file_id},
118
+ timeout=30,
119
+ )
120
+
121
+ if not response.ok:
122
+ try:
123
+ err_desc = response.json().get("description", response.text)
124
+ except Exception:
125
+ err_desc = response.text
126
+ raise RuntimeError(f"[Telegram] getFile HTTP {response.status_code}: {err_desc}")
127
+
128
+ data = response.json()
129
+ if not data.get("ok"):
130
+ raise RuntimeError(f"[Telegram] getFile error: {data.get('description')}")
131
+
132
+ file_path = data["result"]["file_path"]
133
+ return f"{TELEGRAM_FILE_API}/{file_path}"
134
+
135
+
136
+ def delete_telegram_message(message_id: int) -> bool:
137
+ """
138
+ Elimina un mensaje (y por ende su archivo) del canal de Telegram.
139
+ Llamar cuando el usuario borra una pista desde la app.
140
+
141
+ Returns:
142
+ True si se eliminó, False si hubo error no crítico.
143
+ """
144
+ _check_config()
145
+
146
+ try:
147
+ response = requests.post(
148
+ f"{TELEGRAM_API}/deleteMessage",
149
+ json={"chat_id": TELEGRAM_CHAT_ID, "message_id": message_id},
150
+ timeout=15,
151
+ )
152
+ data = response.json()
153
+ if data.get("ok"):
154
+ logger.info(f"[Telegram] Mensaje {message_id} eliminado del canal.")
155
+ return True
156
+ else:
157
+ logger.warning(f"[Telegram] No se pudo eliminar mensaje {message_id}: {data.get('description')}")
158
+ return False
159
+ except Exception as e:
160
+ logger.warning(f"[Telegram] Error eliminando mensaje {message_id}: {e}")
161
+ return False
app/websocket_manager.py CHANGED
@@ -1,65 +1,65 @@
1
- # ==========================================
2
- # MELODIX - WebSocket Connection Manager
3
- # ==========================================
4
- import asyncio
5
- from fastapi import WebSocket
6
-
7
- class ConnectionManager:
8
- def __init__(self):
9
- # Maps user_id (int) to their active WebSocket connection
10
- self.active_connections: dict[int, WebSocket] = {}
11
- self.loop = None
12
-
13
- async def connect(self, user_id: int, websocket: WebSocket):
14
- await websocket.accept()
15
- self.active_connections[user_id] = websocket
16
- self.loop = asyncio.get_running_loop()
17
- print(f"[WebSocket] User {user_id} connected. Active connections: {list(self.active_connections.keys())}")
18
-
19
- def disconnect(self, user_id: int):
20
- if user_id in self.active_connections:
21
- del self.active_connections[user_id]
22
- print(f"[WebSocket] User {user_id} disconnected.")
23
-
24
- async def send_personal_message(self, message: dict, user_id: int):
25
- if user_id in self.active_connections:
26
- try:
27
- await self.active_connections[user_id].send_json(message)
28
- print(f"[WebSocket] Sent message to user {user_id}: {message}")
29
- except Exception as e:
30
- print(f"[WebSocket] Error sending message to user {user_id}: {e}")
31
- self.disconnect(user_id)
32
-
33
- async def broadcast(self, message: dict):
34
- for user_id, connection in list(self.active_connections.items()):
35
- try:
36
- await connection.send_json(message)
37
- except Exception as e:
38
- print(f"[WebSocket] Error broadcasting to user {user_id}: {e}")
39
- self.disconnect(user_id)
40
-
41
- manager = ConnectionManager()
42
-
43
- def send_sync_message(message: dict, user_id: int):
44
- """Synchronous helper to send WebSocket messages from synchronous route threads."""
45
- try:
46
- if manager.loop is not None and manager.loop.is_running():
47
- # Call from synchronous route thread pool to the main running event loop in a thread-safe way
48
- asyncio.run_coroutine_threadsafe(manager.send_personal_message(message, user_id), manager.loop)
49
- else:
50
- # Fallback to current thread event loop
51
- try:
52
- loop = asyncio.get_event_loop()
53
- except RuntimeError:
54
- loop = asyncio.new_event_loop()
55
- asyncio.set_event_loop(loop)
56
-
57
- if loop.is_running():
58
- # Call from running event loop (e.g. main thread)
59
- loop.create_task(manager.send_personal_message(message, user_id))
60
- else:
61
- # Call from synchronous route thread pool
62
- loop.run_until_complete(manager.send_personal_message(message, user_id))
63
- except Exception as e:
64
- print(f"[WebSocket] Error in send_sync_message for user {user_id}: {e}")
65
-
 
1
+ # ==========================================
2
+ # MELODIX - WebSocket Connection Manager
3
+ # ==========================================
4
+ import asyncio
5
+ from fastapi import WebSocket
6
+
7
+ class ConnectionManager:
8
+ def __init__(self):
9
+ # Maps user_id (int) to their active WebSocket connection
10
+ self.active_connections: dict[int, WebSocket] = {}
11
+ self.loop = None
12
+
13
+ async def connect(self, user_id: int, websocket: WebSocket):
14
+ await websocket.accept()
15
+ self.active_connections[user_id] = websocket
16
+ self.loop = asyncio.get_running_loop()
17
+ print(f"[WebSocket] User {user_id} connected. Active connections: {list(self.active_connections.keys())}")
18
+
19
+ def disconnect(self, user_id: int):
20
+ if user_id in self.active_connections:
21
+ del self.active_connections[user_id]
22
+ print(f"[WebSocket] User {user_id} disconnected.")
23
+
24
+ async def send_personal_message(self, message: dict, user_id: int):
25
+ if user_id in self.active_connections:
26
+ try:
27
+ await self.active_connections[user_id].send_json(message)
28
+ print(f"[WebSocket] Sent message to user {user_id}: {message}")
29
+ except Exception as e:
30
+ print(f"[WebSocket] Error sending message to user {user_id}: {e}")
31
+ self.disconnect(user_id)
32
+
33
+ async def broadcast(self, message: dict):
34
+ for user_id, connection in list(self.active_connections.items()):
35
+ try:
36
+ await connection.send_json(message)
37
+ except Exception as e:
38
+ print(f"[WebSocket] Error broadcasting to user {user_id}: {e}")
39
+ self.disconnect(user_id)
40
+
41
+ manager = ConnectionManager()
42
+
43
+ def send_sync_message(message: dict, user_id: int):
44
+ """Synchronous helper to send WebSocket messages from synchronous route threads."""
45
+ try:
46
+ if manager.loop is not None and manager.loop.is_running():
47
+ # Call from synchronous route thread pool to the main running event loop in a thread-safe way
48
+ asyncio.run_coroutine_threadsafe(manager.send_personal_message(message, user_id), manager.loop)
49
+ else:
50
+ # Fallback to current thread event loop
51
+ try:
52
+ loop = asyncio.get_event_loop()
53
+ except RuntimeError:
54
+ loop = asyncio.new_event_loop()
55
+ asyncio.set_event_loop(loop)
56
+
57
+ if loop.is_running():
58
+ # Call from running event loop (e.g. main thread)
59
+ loop.create_task(manager.send_personal_message(message, user_id))
60
+ else:
61
+ # Call from synchronous route thread pool
62
+ loop.run_until_complete(manager.send_personal_message(message, user_id))
63
+ except Exception as e:
64
+ print(f"[WebSocket] Error in send_sync_message for user {user_id}: {e}")
65
+
btc_toolkit/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- # BTC Toolkit - Módulo de Reconocimiento de Acordes con Transformer Bidireccional
2
- from .inference import BTCChordRecognizer
3
-
4
- __all__ = ["BTCChordRecognizer"]
 
1
+ # BTC Toolkit - Módulo de Reconocimiento de Acordes con Transformer Bidireccional
2
+ from .inference import BTCChordRecognizer
3
+
4
+ __all__ = ["BTCChordRecognizer"]
btc_toolkit/inference.py CHANGED
@@ -1,301 +1,301 @@
1
- """
2
- BTC Toolkit - Motor de Inferencia de Acordes
3
- Carga el modelo BTC, preprocesa el audio y retorna los acordes detectados.
4
-
5
- Flujo:
6
- 1. Cargar audio con librosa (extracción de CQT).
7
- 2. Pasar por el modelo Transformer.
8
- 3. Decodificar predicciones a nombres de acordes Mayor/Menor.
9
- 4. Agrupar acordes consecutivos iguales en segmentos de tiempo.
10
- """
11
-
12
- import os
13
- import logging
14
- import numpy as np
15
- import torch
16
- import torch.nn.functional as F
17
-
18
- from .model import BTCModel, CHORD_VOCAB
19
- from .weights_manager import ensure_weights_available
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
- # ==========================================
24
- # PARÁMETROS DE AUDIO
25
- # ==========================================
26
-
27
- SAMPLE_RATE = 22050 # Hz
28
- HOP_LENGTH = 512 # Frames CQT
29
- N_BINS = 144 # Bins CQT (12 semitones x 12 octavas)
30
- BINS_PER_OCTAVE = 36 # Resolución octava
31
- SEGMENT_SECONDS = 30.0 # Longitud máxima por bloque (para VRAM limitada)
32
- MIN_CHORD_DURATION = 0.5 # Duración mínima de un acorde para mostrarlo (segundos)
33
-
34
-
35
- # ==========================================
36
- # CLASE PRINCIPAL DE INFERENCIA
37
- # ==========================================
38
-
39
- class BTCChordRecognizer:
40
- """
41
- Motor de reconocimiento de acordes usando BTC Transformer.
42
-
43
- Diseñado para bajo uso de VRAM:
44
- - Procesa el audio en bloques de 30 segundos.
45
- - Libera la VRAM al finalizar con `unload()`.
46
- """
47
-
48
- def __init__(self, device: str = "cuda"):
49
- self._model: BTCModel | None = None
50
- self._device_str = device
51
- self._device: torch.device | None = None
52
-
53
- def _resolve_device(self) -> torch.device:
54
- """Selecciona GPU si está disponible, sino CPU."""
55
- if self._device_str == "cuda" and torch.cuda.is_available():
56
- dev = torch.device("cuda")
57
- vram_gb = torch.cuda.get_device_properties(0).total_memory / (1024**3)
58
- logger.info(f"[BTC] GPU detectada: {torch.cuda.get_device_name(0)} ({vram_gb:.1f} GB VRAM)")
59
- else:
60
- dev = torch.device("cpu")
61
- logger.info("[BTC] Usando CPU para inferencia de acordes.")
62
- return dev
63
-
64
- def load_model(self) -> None:
65
- """Carga el modelo en memoria. Descarga pesos si no están disponibles."""
66
- if self._model is not None:
67
- return
68
-
69
- self._device = self._resolve_device()
70
-
71
- # Asegurar que los pesos estén disponibles (descarga si falta)
72
- weights_path = ensure_weights_available()
73
-
74
- # Instanciar arquitectura
75
- self._model = BTCModel(n_freq=N_BINS)
76
- self._model.to(self._device)
77
-
78
- # Cargar pesos
79
- try:
80
- checkpoint = torch.load(weights_path, map_location=self._device, weights_only=False)
81
-
82
- # Soporte para checkpoints con y sin wrapper 'state_dict'
83
- if isinstance(checkpoint, dict) and "state_dict" in checkpoint:
84
- state = checkpoint["state_dict"]
85
- elif isinstance(checkpoint, dict) and "model_state_dict" in checkpoint:
86
- state = checkpoint["model_state_dict"]
87
- elif isinstance(checkpoint, dict) and "model" in checkpoint:
88
- state = checkpoint["model"]
89
- else:
90
- state = checkpoint
91
-
92
- # Carga flexible: ignora claves que no coinciden
93
- missing, unexpected = self._model.load_state_dict(state, strict=False)
94
- if missing:
95
- logger.warning(f"[BTC] Parámetros faltantes al cargar pesos: {len(missing)} parámetros faltantes")
96
- if unexpected:
97
- logger.debug(f"[BTC] Parámetros inesperados ignorados: {len(unexpected)} parámetros inesperados")
98
-
99
- self._model.eval()
100
- logger.info("[BTC] Modelo BTC cargado y listo para inferencia.")
101
-
102
- except Exception as e:
103
- self._model = None
104
- raise RuntimeError(f"[BTC] Error al cargar los pesos del modelo: {e}") from e
105
-
106
- def unload(self) -> None:
107
- """Libera el modelo de la memoria GPU para dejar espacio a otros procesos."""
108
- if self._model is not None:
109
- self._model.cpu()
110
- del self._model
111
- self._model = None
112
-
113
- if torch.cuda.is_available():
114
- torch.cuda.empty_cache()
115
- logger.info("[BTC] VRAM liberada correctamente.")
116
-
117
- def recognize(self, audio_path: str, bpm: float = None, beats: list = None) -> list[dict]:
118
- """
119
- Detecta acordes en un archivo de audio.
120
-
121
- Args:
122
- audio_path: Ruta al archivo de audio (.flac, .wav, .mp3).
123
-
124
- Returns:
125
- Lista de diccionarios con formato:
126
- [{"t": tiempo_inicio_segundos, "ch": "NombreAcorde"}, ...]
127
- """
128
- try:
129
- import librosa
130
- except ImportError:
131
- raise ImportError("librosa es requerido para la extracción de características CQT.")
132
-
133
- if not os.path.exists(audio_path):
134
- logger.error(f"[BTC] Archivo no encontrado: {audio_path}")
135
- return []
136
-
137
- # Asegurar que el modelo esté cargado
138
- if self._model is None:
139
- self.load_model()
140
-
141
- logger.info(f"[BTC] Analizando acordes en: {audio_path}")
142
-
143
- try:
144
- # ==========================================
145
- # EXTRACCIÓN DE CARACTERÍSTICAS CQT
146
- # ==========================================
147
- try:
148
- y, sr = librosa.load(audio_path, sr=SAMPLE_RATE, mono=True)
149
- except Exception as load_err:
150
- logger.warning(f"[BTC] librosa.load falló ({load_err}), usando fallback soundfile/ffmpeg...")
151
- try:
152
- import soundfile as sf
153
- y, orig_sr = sf.read(audio_path, dtype='float32')
154
- if y.ndim > 1:
155
- y = np.mean(y, axis=1)
156
- if orig_sr != SAMPLE_RATE:
157
- try:
158
- import soxr
159
- y = soxr.resample(y, orig_sr, SAMPLE_RATE)
160
- except Exception:
161
- y = librosa.resample(y, orig_sr=orig_sr, target_sr=SAMPLE_RATE)
162
- sr = SAMPLE_RATE
163
- except Exception:
164
- from app.utils.harmony import _load_audio_via_ffmpeg
165
- y, sr = _load_audio_via_ffmpeg(audio_path, sr=SAMPLE_RATE)
166
-
167
- # Transformada de Q constante (CQT) - representación frecuencial de alta resolución
168
- # NOTA: Se ha omitido librosa.effects.harmonic por su altísimo costo en CPU
169
- cqt = np.abs(librosa.cqt(
170
- y,
171
- sr=sr,
172
- hop_length=HOP_LENGTH,
173
- n_bins=N_BINS,
174
- bins_per_octave=BINS_PER_OCTAVE,
175
- fmin=librosa.note_to_hz('C1')
176
- ))
177
-
178
- # Normalización logarítmica (aumenta contraste de frecuencias armónicas)
179
- cqt = librosa.amplitude_to_db(cqt, ref=np.max)
180
- cqt = (cqt - cqt.mean()) / (cqt.std() + 1e-8)
181
-
182
- # Transponer: (freq, time) -> (time, freq)
183
- cqt = cqt.T # shape: (T, N_BINS)
184
-
185
- # ==========================================
186
- # INFERENCIA POR BLOQUES (30s para VRAM 4GB)
187
- # ==========================================
188
- frames_per_second = sr / HOP_LENGTH
189
- block_size = int(SEGMENT_SECONDS * frames_per_second)
190
- all_probs = []
191
-
192
- with torch.no_grad():
193
- for start in range(0, len(cqt), block_size):
194
- block = cqt[start: start + block_size]
195
- block_tensor = torch.tensor(block, dtype=torch.float32).unsqueeze(0)
196
- block_tensor = block_tensor.to(self._device)
197
-
198
- logits = self._model(block_tensor)
199
- probs = F.softmax(logits, dim=-1).squeeze(0).cpu() # (N_frames, 171)
200
- all_probs.append(probs)
201
-
202
- # Concatenar probabilidades de toda la canción
203
- full_probs = torch.cat(all_probs, dim=0).numpy()
204
-
205
- # ==========================================
206
- # DECODIFICACIÓN, POOLING Y AGRUPACIÓN
207
- # ==========================================
208
- acordes_agrupados = _decode_and_group_chords(full_probs, frames_per_second, bpm, beats)
209
-
210
- logger.info(f"[BTC] ✅ Reconocimiento completado: {len(acordes_agrupados)} segmentos de acordes detectados.")
211
- return acordes_agrupados
212
-
213
- except Exception as e:
214
- logger.error(f"[BTC] Error durante la inferencia: {e}", exc_info=True)
215
- return []
216
- finally:
217
- # Siempre liberar VRAM al terminar, incluso si hubo error
218
- self.unload()
219
-
220
-
221
- # ==========================================
222
- # FUNCIÓN DE AGRUPACIÓN Y POOLING RÍTMICO
223
- # ==========================================
224
-
225
- def _decode_and_group_chords(probs: np.ndarray, fps: float, bpm: float = None, beats: list = None) -> list[dict]:
226
- """
227
- Toma un tensor de probabilidades y extrae los acordes.
228
-
229
- Estrategia: Emite un acorde por cada beat donde se detecta un cambio.
230
- El frontend hereda el acorde del beat anterior para beats sin cambio,
231
- garantizando que cada compás tenga un acorde visible.
232
-
233
- Cuando el modelo predice 'N' (no-chord), se toma el mejor acorde no-N
234
- para evitar compases vacíos.
235
- """
236
- total_frames = len(probs)
237
- N_IDX = 0 # 'N' es siempre el índice 0 en CHORD_VOCAB
238
-
239
- # ── Determinar las fronteras de cada beat ──
240
- if beats is not None and len(beats) > 1:
241
- beat_times = list(beats)
242
- elif bpm is not None and bpm > 40:
243
- beat_duration = 60.0 / bpm
244
- total_duration = total_frames / fps
245
- beat_times = [i * beat_duration for i in range(int(total_duration / beat_duration) + 1)]
246
- else:
247
- # Sin BPM conocido, usar beats de 0.5s
248
- total_duration = total_frames / fps
249
- beat_times = [i * 0.5 for i in range(int(total_duration / 0.5) + 1)]
250
-
251
- if not beat_times:
252
- return []
253
-
254
- # ── Poolear probabilidades por beat y obtener el mejor acorde no-N ──
255
- beat_chords = []
256
- for i, bt in enumerate(beat_times):
257
- # Calcular rango de frames para este beat
258
- next_bt = beat_times[i + 1] if i + 1 < len(beat_times) else total_frames / fps
259
- start_frame = int(bt * fps)
260
- end_frame = min(int(next_bt * fps), total_frames)
261
-
262
- if start_frame >= total_frames or start_frame >= end_frame:
263
- continue
264
-
265
- # Promediar probabilidades en el rango del beat
266
- beat_probs = np.mean(probs[start_frame:end_frame], axis=0)
267
-
268
- # Obtener el mejor acorde, evitando 'N'
269
- best_idx = np.argmax(beat_probs)
270
- if best_idx == N_IDX:
271
- # Buscar el mejor no-N
272
- beat_probs_no_n = beat_probs.copy()
273
- beat_probs_no_n[N_IDX] = -1
274
- best_idx = np.argmax(beat_probs_no_n)
275
- # Si el mejor no-N tiene probabilidad muy baja, usar N
276
- if beat_probs[best_idx] < 0.02:
277
- best_idx = N_IDX
278
-
279
- chord_name = CHORD_VOCAB[best_idx]
280
- beat_chords.append((round(bt, 3), chord_name))
281
-
282
- # ── Emitir un acorde solo cuando cambia (optimización) ──
283
- # El frontend hereda el acorde anterior para beats sin cambio,
284
- # así que solo necesitamos emitir en los puntos de cambio.
285
- result = []
286
- prev_chord = None
287
-
288
- for bt, chord in beat_chords:
289
- if chord == 'N':
290
- continue # Saltar los pocos frames que aún predicen N
291
- if chord != prev_chord:
292
- result.append({"t": round(bt, 2), "ch": chord})
293
- prev_chord = chord
294
-
295
- # Asegurar que siempre hay un acorde al inicio (t=0)
296
- if result and result[0]["t"] > 0.5:
297
- # Si el primer acorde está muy lejos del inicio, insertar uno al inicio
298
- result.insert(0, {"t": 0.0, "ch": result[0]["ch"]})
299
-
300
- return result
301
-
 
1
+ """
2
+ BTC Toolkit - Motor de Inferencia de Acordes
3
+ Carga el modelo BTC, preprocesa el audio y retorna los acordes detectados.
4
+
5
+ Flujo:
6
+ 1. Cargar audio con librosa (extracción de CQT).
7
+ 2. Pasar por el modelo Transformer.
8
+ 3. Decodificar predicciones a nombres de acordes Mayor/Menor.
9
+ 4. Agrupar acordes consecutivos iguales en segmentos de tiempo.
10
+ """
11
+
12
+ import os
13
+ import logging
14
+ import numpy as np
15
+ import torch
16
+ import torch.nn.functional as F
17
+
18
+ from .model import BTCModel, CHORD_VOCAB
19
+ from .weights_manager import ensure_weights_available
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # ==========================================
24
+ # PARÁMETROS DE AUDIO
25
+ # ==========================================
26
+
27
+ SAMPLE_RATE = 22050 # Hz
28
+ HOP_LENGTH = 512 # Frames CQT
29
+ N_BINS = 144 # Bins CQT (12 semitones x 12 octavas)
30
+ BINS_PER_OCTAVE = 36 # Resolución octava
31
+ SEGMENT_SECONDS = 30.0 # Longitud máxima por bloque (para VRAM limitada)
32
+ MIN_CHORD_DURATION = 0.5 # Duración mínima de un acorde para mostrarlo (segundos)
33
+
34
+
35
+ # ==========================================
36
+ # CLASE PRINCIPAL DE INFERENCIA
37
+ # ==========================================
38
+
39
+ class BTCChordRecognizer:
40
+ """
41
+ Motor de reconocimiento de acordes usando BTC Transformer.
42
+
43
+ Diseñado para bajo uso de VRAM:
44
+ - Procesa el audio en bloques de 30 segundos.
45
+ - Libera la VRAM al finalizar con `unload()`.
46
+ """
47
+
48
+ def __init__(self, device: str = "cuda"):
49
+ self._model: BTCModel | None = None
50
+ self._device_str = device
51
+ self._device: torch.device | None = None
52
+
53
+ def _resolve_device(self) -> torch.device:
54
+ """Selecciona GPU si está disponible, sino CPU."""
55
+ if self._device_str == "cuda" and torch.cuda.is_available():
56
+ dev = torch.device("cuda")
57
+ vram_gb = torch.cuda.get_device_properties(0).total_memory / (1024**3)
58
+ logger.info(f"[BTC] GPU detectada: {torch.cuda.get_device_name(0)} ({vram_gb:.1f} GB VRAM)")
59
+ else:
60
+ dev = torch.device("cpu")
61
+ logger.info("[BTC] Usando CPU para inferencia de acordes.")
62
+ return dev
63
+
64
+ def load_model(self) -> None:
65
+ """Carga el modelo en memoria. Descarga pesos si no están disponibles."""
66
+ if self._model is not None:
67
+ return
68
+
69
+ self._device = self._resolve_device()
70
+
71
+ # Asegurar que los pesos estén disponibles (descarga si falta)
72
+ weights_path = ensure_weights_available()
73
+
74
+ # Instanciar arquitectura
75
+ self._model = BTCModel(n_freq=N_BINS)
76
+ self._model.to(self._device)
77
+
78
+ # Cargar pesos
79
+ try:
80
+ checkpoint = torch.load(weights_path, map_location=self._device, weights_only=False)
81
+
82
+ # Soporte para checkpoints con y sin wrapper 'state_dict'
83
+ if isinstance(checkpoint, dict) and "state_dict" in checkpoint:
84
+ state = checkpoint["state_dict"]
85
+ elif isinstance(checkpoint, dict) and "model_state_dict" in checkpoint:
86
+ state = checkpoint["model_state_dict"]
87
+ elif isinstance(checkpoint, dict) and "model" in checkpoint:
88
+ state = checkpoint["model"]
89
+ else:
90
+ state = checkpoint
91
+
92
+ # Carga flexible: ignora claves que no coinciden
93
+ missing, unexpected = self._model.load_state_dict(state, strict=False)
94
+ if missing:
95
+ logger.warning(f"[BTC] Parámetros faltantes al cargar pesos: {len(missing)} parámetros faltantes")
96
+ if unexpected:
97
+ logger.debug(f"[BTC] Parámetros inesperados ignorados: {len(unexpected)} parámetros inesperados")
98
+
99
+ self._model.eval()
100
+ logger.info("[BTC] Modelo BTC cargado y listo para inferencia.")
101
+
102
+ except Exception as e:
103
+ self._model = None
104
+ raise RuntimeError(f"[BTC] Error al cargar los pesos del modelo: {e}") from e
105
+
106
+ def unload(self) -> None:
107
+ """Libera el modelo de la memoria GPU para dejar espacio a otros procesos."""
108
+ if self._model is not None:
109
+ self._model.cpu()
110
+ del self._model
111
+ self._model = None
112
+
113
+ if torch.cuda.is_available():
114
+ torch.cuda.empty_cache()
115
+ logger.info("[BTC] VRAM liberada correctamente.")
116
+
117
+ def recognize(self, audio_path: str, bpm: float = None, beats: list = None) -> list[dict]:
118
+ """
119
+ Detecta acordes en un archivo de audio.
120
+
121
+ Args:
122
+ audio_path: Ruta al archivo de audio (.flac, .wav, .mp3).
123
+
124
+ Returns:
125
+ Lista de diccionarios con formato:
126
+ [{"t": tiempo_inicio_segundos, "ch": "NombreAcorde"}, ...]
127
+ """
128
+ try:
129
+ import librosa
130
+ except ImportError:
131
+ raise ImportError("librosa es requerido para la extracción de características CQT.")
132
+
133
+ if not os.path.exists(audio_path):
134
+ logger.error(f"[BTC] Archivo no encontrado: {audio_path}")
135
+ return []
136
+
137
+ # Asegurar que el modelo esté cargado
138
+ if self._model is None:
139
+ self.load_model()
140
+
141
+ logger.info(f"[BTC] Analizando acordes en: {audio_path}")
142
+
143
+ try:
144
+ # ==========================================
145
+ # EXTRACCIÓN DE CARACTERÍSTICAS CQT
146
+ # ==========================================
147
+ try:
148
+ y, sr = librosa.load(audio_path, sr=SAMPLE_RATE, mono=True)
149
+ except Exception as load_err:
150
+ logger.warning(f"[BTC] librosa.load falló ({load_err}), usando fallback soundfile/ffmpeg...")
151
+ try:
152
+ import soundfile as sf
153
+ y, orig_sr = sf.read(audio_path, dtype='float32')
154
+ if y.ndim > 1:
155
+ y = np.mean(y, axis=1)
156
+ if orig_sr != SAMPLE_RATE:
157
+ try:
158
+ import soxr
159
+ y = soxr.resample(y, orig_sr, SAMPLE_RATE)
160
+ except Exception:
161
+ y = librosa.resample(y, orig_sr=orig_sr, target_sr=SAMPLE_RATE)
162
+ sr = SAMPLE_RATE
163
+ except Exception:
164
+ from app.utils.harmony import _load_audio_via_ffmpeg
165
+ y, sr = _load_audio_via_ffmpeg(audio_path, sr=SAMPLE_RATE)
166
+
167
+ # Transformada de Q constante (CQT) - representación frecuencial de alta resolución
168
+ # NOTA: Se ha omitido librosa.effects.harmonic por su altísimo costo en CPU
169
+ cqt = np.abs(librosa.cqt(
170
+ y,
171
+ sr=sr,
172
+ hop_length=HOP_LENGTH,
173
+ n_bins=N_BINS,
174
+ bins_per_octave=BINS_PER_OCTAVE,
175
+ fmin=librosa.note_to_hz('C1')
176
+ ))
177
+
178
+ # Normalización logarítmica (aumenta contraste de frecuencias armónicas)
179
+ cqt = librosa.amplitude_to_db(cqt, ref=np.max)
180
+ cqt = (cqt - cqt.mean()) / (cqt.std() + 1e-8)
181
+
182
+ # Transponer: (freq, time) -> (time, freq)
183
+ cqt = cqt.T # shape: (T, N_BINS)
184
+
185
+ # ==========================================
186
+ # INFERENCIA POR BLOQUES (30s para VRAM 4GB)
187
+ # ==========================================
188
+ frames_per_second = sr / HOP_LENGTH
189
+ block_size = int(SEGMENT_SECONDS * frames_per_second)
190
+ all_probs = []
191
+
192
+ with torch.no_grad():
193
+ for start in range(0, len(cqt), block_size):
194
+ block = cqt[start: start + block_size]
195
+ block_tensor = torch.tensor(block, dtype=torch.float32).unsqueeze(0)
196
+ block_tensor = block_tensor.to(self._device)
197
+
198
+ logits = self._model(block_tensor)
199
+ probs = F.softmax(logits, dim=-1).squeeze(0).cpu() # (N_frames, 171)
200
+ all_probs.append(probs)
201
+
202
+ # Concatenar probabilidades de toda la canción
203
+ full_probs = torch.cat(all_probs, dim=0).numpy()
204
+
205
+ # ==========================================
206
+ # DECODIFICACIÓN, POOLING Y AGRUPACIÓN
207
+ # ==========================================
208
+ acordes_agrupados = _decode_and_group_chords(full_probs, frames_per_second, bpm, beats)
209
+
210
+ logger.info(f"[BTC] ✅ Reconocimiento completado: {len(acordes_agrupados)} segmentos de acordes detectados.")
211
+ return acordes_agrupados
212
+
213
+ except Exception as e:
214
+ logger.error(f"[BTC] Error durante la inferencia: {e}", exc_info=True)
215
+ return []
216
+ finally:
217
+ # Siempre liberar VRAM al terminar, incluso si hubo error
218
+ self.unload()
219
+
220
+
221
+ # ==========================================
222
+ # FUNCIÓN DE AGRUPACIÓN Y POOLING RÍTMICO
223
+ # ==========================================
224
+
225
+ def _decode_and_group_chords(probs: np.ndarray, fps: float, bpm: float = None, beats: list = None) -> list[dict]:
226
+ """
227
+ Toma un tensor de probabilidades y extrae los acordes.
228
+
229
+ Estrategia: Emite un acorde por cada beat donde se detecta un cambio.
230
+ El frontend hereda el acorde del beat anterior para beats sin cambio,
231
+ garantizando que cada compás tenga un acorde visible.
232
+
233
+ Cuando el modelo predice 'N' (no-chord), se toma el mejor acorde no-N
234
+ para evitar compases vacíos.
235
+ """
236
+ total_frames = len(probs)
237
+ N_IDX = 0 # 'N' es siempre el índice 0 en CHORD_VOCAB
238
+
239
+ # ── Determinar las fronteras de cada beat ──
240
+ if beats is not None and len(beats) > 1:
241
+ beat_times = list(beats)
242
+ elif bpm is not None and bpm > 40:
243
+ beat_duration = 60.0 / bpm
244
+ total_duration = total_frames / fps
245
+ beat_times = [i * beat_duration for i in range(int(total_duration / beat_duration) + 1)]
246
+ else:
247
+ # Sin BPM conocido, usar beats de 0.5s
248
+ total_duration = total_frames / fps
249
+ beat_times = [i * 0.5 for i in range(int(total_duration / 0.5) + 1)]
250
+
251
+ if not beat_times:
252
+ return []
253
+
254
+ # ── Poolear probabilidades por beat y obtener el mejor acorde no-N ──
255
+ beat_chords = []
256
+ for i, bt in enumerate(beat_times):
257
+ # Calcular rango de frames para este beat
258
+ next_bt = beat_times[i + 1] if i + 1 < len(beat_times) else total_frames / fps
259
+ start_frame = int(bt * fps)
260
+ end_frame = min(int(next_bt * fps), total_frames)
261
+
262
+ if start_frame >= total_frames or start_frame >= end_frame:
263
+ continue
264
+
265
+ # Promediar probabilidades en el rango del beat
266
+ beat_probs = np.mean(probs[start_frame:end_frame], axis=0)
267
+
268
+ # Obtener el mejor acorde, evitando 'N'
269
+ best_idx = np.argmax(beat_probs)
270
+ if best_idx == N_IDX:
271
+ # Buscar el mejor no-N
272
+ beat_probs_no_n = beat_probs.copy()
273
+ beat_probs_no_n[N_IDX] = -1
274
+ best_idx = np.argmax(beat_probs_no_n)
275
+ # Si el mejor no-N tiene probabilidad muy baja, usar N
276
+ if beat_probs[best_idx] < 0.02:
277
+ best_idx = N_IDX
278
+
279
+ chord_name = CHORD_VOCAB[best_idx]
280
+ beat_chords.append((round(bt, 3), chord_name))
281
+
282
+ # ── Emitir un acorde solo cuando cambia (optimización) ──
283
+ # El frontend hereda el acorde anterior para beats sin cambio,
284
+ # así que solo necesitamos emitir en los puntos de cambio.
285
+ result = []
286
+ prev_chord = None
287
+
288
+ for bt, chord in beat_chords:
289
+ if chord == 'N':
290
+ continue # Saltar los pocos frames que aún predicen N
291
+ if chord != prev_chord:
292
+ result.append({"t": round(bt, 2), "ch": chord})
293
+ prev_chord = chord
294
+
295
+ # Asegurar que siempre hay un acorde al inicio (t=0)
296
+ if result and result[0]["t"] > 0.5:
297
+ # Si el primer acorde está muy lejos del inicio, insertar uno al inicio
298
+ result.insert(0, {"t": 0.0, "ch": result[0]["ch"]})
299
+
300
+ return result
301
+
btc_toolkit/weights_manager.py CHANGED
@@ -1,132 +1,132 @@
1
- """
2
- BTC Toolkit - Descarga Automática de Pesos del Modelo
3
- Gestiona la descarga de los pesos pre-entrenados del modelo BTC desde GitHub.
4
- Incluye:
5
- - Descarga automática solo si no existen localmente.
6
- - Reporte de éxito/fallo al logger.
7
- - Verificación de integridad con SHA256.
8
- """
9
-
10
- import os
11
- import hashlib
12
- import logging
13
- import urllib.request
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
- # ==========================================
18
- # CONFIGURACIÓN DE PESOS
19
- # ==========================================
20
-
21
- # Directorio donde se guardan los pesos (relativo a este archivo)
22
- _MODELS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "models")
23
-
24
- # URL pública del checkpoint pre-entrenado oficial.
25
- # Apunta al checkpoint completo de 12.2 MB alojado en Hugging Face (amaai-lab/music2emo)
26
- BTC_WEIGHTS_URL = "https://huggingface.co/amaai-lab/music2emo/resolve/main/inference/data/btc_model.pt"
27
-
28
- # Nombre local del archivo de pesos
29
- BTC_WEIGHTS_FILENAME = "btc_chord.pth"
30
-
31
- # Hash SHA256 del archivo esperado (para verificar integridad)
32
- BTC_WEIGHTS_SHA256 = "71c2c5db17e8c43b8a9a9da5db36ef2d667158c07a214eba16344c154c00bf54"
33
-
34
-
35
- def get_weights_path() -> str:
36
- """Retorna la ruta absoluta donde deben estar los pesos."""
37
- return os.path.join(_MODELS_DIR, BTC_WEIGHTS_FILENAME)
38
-
39
-
40
- def _verify_sha256(filepath: str, expected_hash: str) -> bool:
41
- """Verifica la integridad del archivo descargado."""
42
- sha256 = hashlib.sha256()
43
- with open(filepath, "rb") as f:
44
- for chunk in iter(lambda: f.read(8192), b""):
45
- sha256.update(chunk)
46
- return sha256.hexdigest() == expected_hash
47
-
48
-
49
- def ensure_weights_available() -> str:
50
- """
51
- Verifica si los pesos del modelo están disponibles localmente.
52
- Si no existen, los descarga automáticamente.
53
-
54
- Returns:
55
- str: Ruta absoluta al archivo de pesos.
56
-
57
- Raises:
58
- RuntimeError: Si la descarga falla.
59
- """
60
- weights_path = get_weights_path()
61
-
62
- # Crear directorio si no existe
63
- os.makedirs(_MODELS_DIR, exist_ok=True)
64
-
65
- # Si ya existen, no hace nada
66
- if os.path.exists(weights_path):
67
- logger.debug(f"[BTC] Pesos del modelo encontrados localmente: {weights_path}")
68
- return weights_path
69
-
70
- # --- Descarga automática ---
71
- logger.info(f"[BTC] Pesos no encontrados. Iniciando descarga automática...")
72
- logger.info(f"[BTC] URL: {BTC_WEIGHTS_URL}")
73
- logger.info(f"[BTC] Destino: {weights_path}")
74
-
75
- try:
76
- headers = {
77
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
78
- }
79
- hf_token = os.getenv("HF_TOKEN")
80
- if hf_token:
81
- headers["Authorization"] = f"Bearer {hf_token}"
82
-
83
- req = urllib.request.Request(BTC_WEIGHTS_URL, headers=headers)
84
- with urllib.request.urlopen(req, timeout=60) as response, open(weights_path, 'wb') as out_file:
85
- total_size = int(response.headers.get('content-length', 0))
86
- downloaded = 0
87
- block_size = 65536
88
- while True:
89
- chunk = response.read(block_size)
90
- if not chunk:
91
- break
92
- out_file.write(chunk)
93
- downloaded += len(chunk)
94
- if total_size > 0:
95
- percent = min(100, int(downloaded * 100 / total_size))
96
- if percent % 25 == 0:
97
- logger.debug(f"[BTC] Descargando: {percent}% ({downloaded / (1024*1024):.1f} MB / {total_size / (1024*1024):.1f} MB)")
98
-
99
- # Verificar que el archivo se descargó correctamente
100
- if not os.path.exists(weights_path) or os.path.getsize(weights_path) == 0:
101
- raise RuntimeError("El archivo descargado está vacío o no existe.")
102
-
103
- file_size_mb = os.path.getsize(weights_path) / (1024 * 1024)
104
-
105
- # Verificar integridad si hay hash disponible
106
- if BTC_WEIGHTS_SHA256:
107
- if _verify_sha256(weights_path, BTC_WEIGHTS_SHA256):
108
- logger.info(f"[BTC] ✅ Pesos descargados y verificados correctamente ({file_size_mb:.1f} MB)")
109
- else:
110
- os.remove(weights_path)
111
- raise RuntimeError("La verificación de integridad del archivo falló (SHA256 no coincide). El archivo fue eliminado.")
112
- else:
113
- logger.info(f"[BTC] ✅ Pesos descargados correctamente ({file_size_mb:.1f} MB) → {weights_path}")
114
-
115
- return weights_path
116
-
117
- except Exception as e:
118
- # Si falló la descarga, limpiar archivo parcial
119
- if os.path.exists(weights_path):
120
- try:
121
- os.remove(weights_path)
122
- except:
123
- pass
124
-
125
- error_msg = (
126
- f"[BTC] ❌ ERROR: No se pudieron descargar los pesos del modelo.\n"
127
- f" Razón: {str(e)}\n"
128
- f" URL intentada: {BTC_WEIGHTS_URL}\n"
129
- f" Verifica tu conexión a internet o descarga el archivo manualmente en: {weights_path}"
130
- )
131
- logger.error(error_msg)
132
- raise RuntimeError(error_msg) from e
 
1
+ """
2
+ BTC Toolkit - Descarga Automática de Pesos del Modelo
3
+ Gestiona la descarga de los pesos pre-entrenados del modelo BTC desde GitHub.
4
+ Incluye:
5
+ - Descarga automática solo si no existen localmente.
6
+ - Reporte de éxito/fallo al logger.
7
+ - Verificación de integridad con SHA256.
8
+ """
9
+
10
+ import os
11
+ import hashlib
12
+ import logging
13
+ import urllib.request
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # ==========================================
18
+ # CONFIGURACIÓN DE PESOS
19
+ # ==========================================
20
+
21
+ # Directorio donde se guardan los pesos (relativo a este archivo)
22
+ _MODELS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "models")
23
+
24
+ # URL pública del checkpoint pre-entrenado oficial.
25
+ # Apunta al checkpoint completo de 12.2 MB alojado en Hugging Face (amaai-lab/music2emo)
26
+ BTC_WEIGHTS_URL = "https://huggingface.co/amaai-lab/music2emo/resolve/main/inference/data/btc_model.pt"
27
+
28
+ # Nombre local del archivo de pesos
29
+ BTC_WEIGHTS_FILENAME = "btc_chord.pth"
30
+
31
+ # Hash SHA256 del archivo esperado (para verificar integridad)
32
+ BTC_WEIGHTS_SHA256 = "71c2c5db17e8c43b8a9a9da5db36ef2d667158c07a214eba16344c154c00bf54"
33
+
34
+
35
+ def get_weights_path() -> str:
36
+ """Retorna la ruta absoluta donde deben estar los pesos."""
37
+ return os.path.join(_MODELS_DIR, BTC_WEIGHTS_FILENAME)
38
+
39
+
40
+ def _verify_sha256(filepath: str, expected_hash: str) -> bool:
41
+ """Verifica la integridad del archivo descargado."""
42
+ sha256 = hashlib.sha256()
43
+ with open(filepath, "rb") as f:
44
+ for chunk in iter(lambda: f.read(8192), b""):
45
+ sha256.update(chunk)
46
+ return sha256.hexdigest() == expected_hash
47
+
48
+
49
+ def ensure_weights_available() -> str:
50
+ """
51
+ Verifica si los pesos del modelo están disponibles localmente.
52
+ Si no existen, los descarga automáticamente.
53
+
54
+ Returns:
55
+ str: Ruta absoluta al archivo de pesos.
56
+
57
+ Raises:
58
+ RuntimeError: Si la descarga falla.
59
+ """
60
+ weights_path = get_weights_path()
61
+
62
+ # Crear directorio si no existe
63
+ os.makedirs(_MODELS_DIR, exist_ok=True)
64
+
65
+ # Si ya existen, no hace nada
66
+ if os.path.exists(weights_path):
67
+ logger.debug(f"[BTC] Pesos del modelo encontrados localmente: {weights_path}")
68
+ return weights_path
69
+
70
+ # --- Descarga automática ---
71
+ logger.info(f"[BTC] Pesos no encontrados. Iniciando descarga automática...")
72
+ logger.info(f"[BTC] URL: {BTC_WEIGHTS_URL}")
73
+ logger.info(f"[BTC] Destino: {weights_path}")
74
+
75
+ try:
76
+ headers = {
77
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
78
+ }
79
+ hf_token = os.getenv("HF_TOKEN")
80
+ if hf_token:
81
+ headers["Authorization"] = f"Bearer {hf_token}"
82
+
83
+ req = urllib.request.Request(BTC_WEIGHTS_URL, headers=headers)
84
+ with urllib.request.urlopen(req, timeout=60) as response, open(weights_path, 'wb') as out_file:
85
+ total_size = int(response.headers.get('content-length', 0))
86
+ downloaded = 0
87
+ block_size = 65536
88
+ while True:
89
+ chunk = response.read(block_size)
90
+ if not chunk:
91
+ break
92
+ out_file.write(chunk)
93
+ downloaded += len(chunk)
94
+ if total_size > 0:
95
+ percent = min(100, int(downloaded * 100 / total_size))
96
+ if percent % 25 == 0:
97
+ logger.debug(f"[BTC] Descargando: {percent}% ({downloaded / (1024*1024):.1f} MB / {total_size / (1024*1024):.1f} MB)")
98
+
99
+ # Verificar que el archivo se descargó correctamente
100
+ if not os.path.exists(weights_path) or os.path.getsize(weights_path) == 0:
101
+ raise RuntimeError("El archivo descargado está vacío o no existe.")
102
+
103
+ file_size_mb = os.path.getsize(weights_path) / (1024 * 1024)
104
+
105
+ # Verificar integridad si hay hash disponible
106
+ if BTC_WEIGHTS_SHA256:
107
+ if _verify_sha256(weights_path, BTC_WEIGHTS_SHA256):
108
+ logger.info(f"[BTC] ✅ Pesos descargados y verificados correctamente ({file_size_mb:.1f} MB)")
109
+ else:
110
+ os.remove(weights_path)
111
+ raise RuntimeError("La verificación de integridad del archivo falló (SHA256 no coincide). El archivo fue eliminado.")
112
+ else:
113
+ logger.info(f"[BTC] ✅ Pesos descargados correctamente ({file_size_mb:.1f} MB) → {weights_path}")
114
+
115
+ return weights_path
116
+
117
+ except Exception as e:
118
+ # Si falló la descarga, limpiar archivo parcial
119
+ if os.path.exists(weights_path):
120
+ try:
121
+ os.remove(weights_path)
122
+ except:
123
+ pass
124
+
125
+ error_msg = (
126
+ f"[BTC] ❌ ERROR: No se pudieron descargar los pesos del modelo.\n"
127
+ f" Razón: {str(e)}\n"
128
+ f" URL intentada: {BTC_WEIGHTS_URL}\n"
129
+ f" Verifica tu conexión a internet o descarga el archivo manualmente en: {weights_path}"
130
+ )
131
+ logger.error(error_msg)
132
+ raise RuntimeError(error_msg) from e
check_requirements.py CHANGED
@@ -1,322 +1,322 @@
1
- """
2
- MELODIX - Verificador de Prerrequisitos
3
-
4
- Verifica que todos los componentes necesarios estén instalados y configurados.
5
-
6
- Uso: python check_requirements.py
7
- """
8
-
9
- import sys
10
- import os
11
- import subprocess
12
- import shutil
13
- from dotenv import load_dotenv
14
-
15
- # ==========================================
16
- # CONFIGURACIÓN
17
- # ==========================================
18
-
19
- load_dotenv()
20
-
21
- COLORS = {
22
- 'GREEN': '\033[92m',
23
- 'RED': '\033[91m',
24
- 'YELLOW': '\033[93m',
25
- 'BLUE': '\033[94m',
26
- 'END': '\033[0m'
27
- }
28
-
29
- def c(text, color):
30
- """Colorea el texto"""
31
- return f"{COLORS.get(color, '')}{text}{COLORS['END']}"
32
-
33
- def check_mark(passed):
34
- """Retorna ✓ o ✗"""
35
- return c("✓", "GREEN") if passed else c("✗", "RED")
36
-
37
- # ==========================================
38
- # VERIFICACIONES
39
- # ==========================================
40
-
41
- def check_python():
42
- """Verifica la versión de Python"""
43
- print("\n" + "=" * 50)
44
- print(" PYTHON")
45
- print("=" * 50)
46
-
47
- version = sys.version_info
48
- passed = version.major == 3 and version.minor >= 10
49
-
50
- print(f" {check_mark(passed)} Versión: {version.major}.{version.minor}.{version.micro}")
51
- print(f" {'→ Requiere Python 3.10+' if not passed else ''}")
52
-
53
- return passed
54
-
55
- def check_packages():
56
- """Verifica los paquetes Python instalados"""
57
- print("\n" + "=" * 50)
58
- print(" PAQUETES PYTHON")
59
- print("=" * 50)
60
-
61
- required_packages = {
62
- 'fastapi': 'FastAPI',
63
- 'uvicorn': 'Uvicorn',
64
- 'sqlalchemy': 'SQLAlchemy',
65
- 'celery': 'Celery',
66
- 'redis': 'Redis',
67
- 'librosa': 'Librosa',
68
- 'passlib': 'Passlib',
69
- 'jose': 'Python-JOSE',
70
- 'dotenv': 'python-dotenv',
71
- 'pydantic': 'Pydantic',
72
- }
73
-
74
- all_installed = True
75
-
76
- for package, name in required_packages.items():
77
- try:
78
- __import__(package)
79
- pkg = __import__(package)
80
- version = getattr(pkg, '__version__', 'N/A')
81
- print(f" {check_mark(True)} {name} ({version})")
82
- except ImportError:
83
- print(f" {check_mark(False)} {name} - NO INSTALADO")
84
- all_installed = False
85
-
86
- # Verificar demucs
87
- try:
88
- import demucs
89
- print(f" {check_mark(True)} Demucs ({demucs.__version__ if hasattr(demucs, '__version__') else 'N/A'})")
90
- except ImportError:
91
- print(f" {check_mark(False)} Demucs - NO INSTALADO")
92
- all_installed = False
93
-
94
- return all_installed
95
-
96
- def check_redis():
97
- """Verifica la conexión a Redis"""
98
- print("\n" + "=" * 50)
99
- print(" REDIS")
100
- print("=" * 50)
101
-
102
- try:
103
- import redis
104
- redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
105
-
106
- # Normalize ssl_cert_reqs to 'none' if it is 'CERT_NONE' (case-insensitive) to prevent redis-py errors
107
- if redis_url.startswith("rediss://"):
108
- if "ssl_cert_reqs=" in redis_url:
109
- redis_url = redis_url.replace("ssl_cert_reqs=CERT_NONE", "ssl_cert_reqs=none")
110
- redis_url = redis_url.replace("ssl_cert_reqs=cert_none", "ssl_cert_reqs=none")
111
- else:
112
- if "?" in redis_url:
113
- redis_url += "&ssl_cert_reqs=none"
114
- else:
115
- redis_url += "?ssl_cert_reqs=none"
116
-
117
- r = redis.from_url(redis_url)
118
- r.ping()
119
-
120
- print(f" {check_mark(True)} Conexión: {redis_url}")
121
- print(f" {check_mark(True)} Redis respondiendo: PONG")
122
-
123
- return True
124
-
125
- except ImportError:
126
- print(f" {check_mark(False)} Paquete redis NO INSTALADO")
127
- return False
128
- except Exception as e:
129
- print(f" {check_mark(False)} Conexión fallida: {e}")
130
- print(f" → ¿Está Redis corriendo? Ejecuta: redis-server")
131
- return False
132
-
133
- def check_ffmpeg():
134
- """Verifica FFmpeg"""
135
- print("\n" + "=" * 50)
136
- print(" FFMPEG")
137
- print("=" * 50)
138
-
139
- ffmpeg_path = shutil.which("ffmpeg")
140
-
141
- if ffmpeg_path:
142
- try:
143
- result = subprocess.run(
144
- ["ffmpeg", "-version"],
145
- capture_output=True,
146
- text=True,
147
- timeout=5
148
- )
149
- version_line = result.stdout.split('\n')[0]
150
- print(f" {check_mark(True)} FFmpeg encontrado: {ffmpeg_path}")
151
- print(f" {check_mark(True)} {version_line}")
152
- return True
153
- except Exception as e:
154
- print(f" {check_mark(False)} Error verificando versión: {e}")
155
- return False
156
- else:
157
- print(f" {check_mark(False)} FFmpeg NO ENCONTRADO en PATH")
158
- print(f" → Instala FFmpeg: https://ffmpeg.org/download.html")
159
- print(f" → O usa Chocolatey: choco install ffmpeg")
160
- return False
161
-
162
- def check_demucs_models():
163
- """Verifica los modelos de Demucs"""
164
- print("\n" + "=" * 50)
165
- print(" MODELOS DEMUCS")
166
- print("=" * 50)
167
-
168
- # Los modelos se descargan automáticamente la primera vez
169
- # Solo verificamos si existe el directorio de caché
170
- cache_dir = os.path.expanduser("~/.cache/huggingface/hub")
171
-
172
- if os.path.exists(cache_dir):
173
- models = [d for d in os.listdir(cache_dir) if d.startswith('models--facebook--demucs')]
174
- if models:
175
- print(f" {check_mark(True)} Modelos en caché: {len(models)} encontrado(s)")
176
- for m in models:
177
- print(f" - {m}")
178
- return True
179
- else:
180
- print(f" {check_mark(True)} Caché listo (modelos se descargarán la primera vez)")
181
- return True
182
- else:
183
- print(f" {check_mark(True)} Los modelos se descargarán automáticamente")
184
- return True
185
-
186
- def check_directories():
187
- """Verifica los directorios necesarios"""
188
- print("\n" + "=" * 50)
189
- print(" DIRECTORIOS")
190
- print("=" * 50)
191
-
192
- base_dir = os.getenv("BASE_DIR", os.path.dirname(os.path.abspath(__file__)))
193
- dirs_to_check = [
194
- ("BASE_DIR", base_dir),
195
- ("UPLOADS_DIR", os.getenv("UPLOADS_DIR", os.path.join(base_dir, "uploads"))),
196
- ("PROCESSED_DIR", os.getenv("PROCESSED_DIR", os.path.join(base_dir, "processed"))),
197
- ("LOGS_DIR", os.path.join(base_dir, "logs")),
198
- ]
199
-
200
- all_ok = True
201
-
202
- for name, path in dirs_to_check:
203
- exists = os.path.exists(path)
204
- writable = os.access(path, os.W_OK) if exists else False
205
-
206
- if exists and writable:
207
- print(f" {check_mark(True)} {name}: {path}")
208
- elif exists:
209
- print(f" {check_mark(False)} {name}: {path} (NO ESCRIBIBLE)")
210
- all_ok = False
211
- else:
212
- print(f" {check_mark(False)} {name}: {path} (NO EXISTE)")
213
- all_ok = False
214
-
215
- return all_ok
216
-
217
- def check_env_file():
218
- """Verifica el archivo .env"""
219
- print("\n" + "=" * 50)
220
- print(" VARIABLES DE ENTORNO")
221
- print("=" * 50)
222
-
223
- env_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
224
-
225
- if os.path.exists(env_file):
226
- print(f" {check_mark(True)} Archivo .env encontrado")
227
-
228
- # Verificar variables críticas
229
- critical_vars = ["SECRET_KEY", "REDIS_URL"]
230
- for var in critical_vars:
231
- value = os.getenv(var)
232
- if value:
233
- print(f" {check_mark(True)} {var}: configurado")
234
- else:
235
- print(f" {check_mark(False)} {var}: NO CONFIGURADO")
236
-
237
- return True
238
- else:
239
- print(f" {check_mark(False)} Archivo .env NO ENCONTRADO")
240
- print(f" → Copia .env.example a .env")
241
- return False
242
-
243
- def check_gpu():
244
- """Verifica disponibilidad de GPU CUDA"""
245
- print("\n" + "=" * 50)
246
- print(" GPU / CUDA")
247
- print("=" * 50)
248
-
249
- try:
250
- import torch
251
- cuda_available = torch.cuda.is_available()
252
-
253
- if cuda_available:
254
- gpu_name = torch.cuda.get_device_name(0)
255
- gpu_memory = torch.cuda.get_device_properties(0).total_memory / (1024**3)
256
- print(f" {check_mark(True)} CUDA disponible: {gpu_name}")
257
- print(f" {check_mark(True)} Memoria GPU: {gpu_memory:.1f} GB")
258
- return True
259
- else:
260
- print(f" {check_mark(False)} CUDA NO disponible")
261
- print(f" → Se usará CPU (más lento)")
262
- print(f" → Para GPU: instala PyTorch con CUDA")
263
- return False
264
-
265
- except ImportError:
266
- print(f" {check_mark(False)} PyTorch NO INSTALADO")
267
- print(f" → Demucs lo instalará como dependencia")
268
- return False
269
- except Exception as e:
270
- print(f" {check_mark(False)} Error verificando GPU: {e}")
271
- return False
272
-
273
- # ==========================================
274
- # MAIN
275
- # ==========================================
276
-
277
- def main():
278
- print(c("\n" + "=" * 60, "BLUE"))
279
- print(c(" MELODIX - VERIFICADOR DE PRERREQUISITOS", "BLUE"))
280
- print(c("=" * 60, "BLUE"))
281
-
282
- results = {
283
- "Python": check_python(),
284
- "Paquetes": check_packages(),
285
- "Redis": check_redis(),
286
- "FFmpeg": check_ffmpeg(),
287
- "Demucs": check_demucs_models(),
288
- "Directorios": check_directories(),
289
- ".env": check_env_file(),
290
- "GPU": check_gpu(),
291
- }
292
-
293
- # ==========================================
294
- # RESUMEN FINAL
295
- # ==========================================
296
- print("\n" + "=" * 60)
297
- print(c(" RESUMEN", "BLUE"))
298
- print("=" * 60)
299
-
300
- passed = sum(1 for v in results.values() if v)
301
- total = len(results)
302
-
303
- for name, result in results.items():
304
- status = c("✓ PASS", "GREEN") if result else c("✗ FAIL", "RED")
305
- print(f" {status} - {name}")
306
-
307
- print("\n" + "-" * 60)
308
-
309
- if passed == total:
310
- print(c(" ¡TODO LISTO! Puedes iniciar Melodix", "GREEN"))
311
- print(c(" Ejecuta: iniciar_melodix.bat", "GREEN"))
312
- else:
313
- print(c(f" ATENCIÓN: {total - passed} verificación(es) fallida(s)", "YELLOW"))
314
- print(c(" Revisa los errores arriba y corrígelos antes de iniciar", "YELLOW"))
315
-
316
- print("=" * 60 + "\n")
317
-
318
- return 0 if passed == total else 1
319
-
320
-
321
- if __name__ == "__main__":
322
- sys.exit(main())
 
1
+ """
2
+ MELODIX - Verificador de Prerrequisitos
3
+
4
+ Verifica que todos los componentes necesarios estén instalados y configurados.
5
+
6
+ Uso: python check_requirements.py
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ import subprocess
12
+ import shutil
13
+ from dotenv import load_dotenv
14
+
15
+ # ==========================================
16
+ # CONFIGURACIÓN
17
+ # ==========================================
18
+
19
+ load_dotenv()
20
+
21
+ COLORS = {
22
+ 'GREEN': '\033[92m',
23
+ 'RED': '\033[91m',
24
+ 'YELLOW': '\033[93m',
25
+ 'BLUE': '\033[94m',
26
+ 'END': '\033[0m'
27
+ }
28
+
29
+ def c(text, color):
30
+ """Colorea el texto"""
31
+ return f"{COLORS.get(color, '')}{text}{COLORS['END']}"
32
+
33
+ def check_mark(passed):
34
+ """Retorna ✓ o ✗"""
35
+ return c("✓", "GREEN") if passed else c("✗", "RED")
36
+
37
+ # ==========================================
38
+ # VERIFICACIONES
39
+ # ==========================================
40
+
41
+ def check_python():
42
+ """Verifica la versión de Python"""
43
+ print("\n" + "=" * 50)
44
+ print(" PYTHON")
45
+ print("=" * 50)
46
+
47
+ version = sys.version_info
48
+ passed = version.major == 3 and version.minor >= 10
49
+
50
+ print(f" {check_mark(passed)} Versión: {version.major}.{version.minor}.{version.micro}")
51
+ print(f" {'→ Requiere Python 3.10+' if not passed else ''}")
52
+
53
+ return passed
54
+
55
+ def check_packages():
56
+ """Verifica los paquetes Python instalados"""
57
+ print("\n" + "=" * 50)
58
+ print(" PAQUETES PYTHON")
59
+ print("=" * 50)
60
+
61
+ required_packages = {
62
+ 'fastapi': 'FastAPI',
63
+ 'uvicorn': 'Uvicorn',
64
+ 'sqlalchemy': 'SQLAlchemy',
65
+ 'celery': 'Celery',
66
+ 'redis': 'Redis',
67
+ 'librosa': 'Librosa',
68
+ 'passlib': 'Passlib',
69
+ 'jose': 'Python-JOSE',
70
+ 'dotenv': 'python-dotenv',
71
+ 'pydantic': 'Pydantic',
72
+ }
73
+
74
+ all_installed = True
75
+
76
+ for package, name in required_packages.items():
77
+ try:
78
+ __import__(package)
79
+ pkg = __import__(package)
80
+ version = getattr(pkg, '__version__', 'N/A')
81
+ print(f" {check_mark(True)} {name} ({version})")
82
+ except ImportError:
83
+ print(f" {check_mark(False)} {name} - NO INSTALADO")
84
+ all_installed = False
85
+
86
+ # Verificar demucs
87
+ try:
88
+ import demucs
89
+ print(f" {check_mark(True)} Demucs ({demucs.__version__ if hasattr(demucs, '__version__') else 'N/A'})")
90
+ except ImportError:
91
+ print(f" {check_mark(False)} Demucs - NO INSTALADO")
92
+ all_installed = False
93
+
94
+ return all_installed
95
+
96
+ def check_redis():
97
+ """Verifica la conexión a Redis"""
98
+ print("\n" + "=" * 50)
99
+ print(" REDIS")
100
+ print("=" * 50)
101
+
102
+ try:
103
+ import redis
104
+ redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
105
+
106
+ # Normalize ssl_cert_reqs to 'none' if it is 'CERT_NONE' (case-insensitive) to prevent redis-py errors
107
+ if redis_url.startswith("rediss://"):
108
+ if "ssl_cert_reqs=" in redis_url:
109
+ redis_url = redis_url.replace("ssl_cert_reqs=CERT_NONE", "ssl_cert_reqs=none")
110
+ redis_url = redis_url.replace("ssl_cert_reqs=cert_none", "ssl_cert_reqs=none")
111
+ else:
112
+ if "?" in redis_url:
113
+ redis_url += "&ssl_cert_reqs=none"
114
+ else:
115
+ redis_url += "?ssl_cert_reqs=none"
116
+
117
+ r = redis.from_url(redis_url)
118
+ r.ping()
119
+
120
+ print(f" {check_mark(True)} Conexión: {redis_url}")
121
+ print(f" {check_mark(True)} Redis respondiendo: PONG")
122
+
123
+ return True
124
+
125
+ except ImportError:
126
+ print(f" {check_mark(False)} Paquete redis NO INSTALADO")
127
+ return False
128
+ except Exception as e:
129
+ print(f" {check_mark(False)} Conexión fallida: {e}")
130
+ print(f" → ¿Está Redis corriendo? Ejecuta: redis-server")
131
+ return False
132
+
133
+ def check_ffmpeg():
134
+ """Verifica FFmpeg"""
135
+ print("\n" + "=" * 50)
136
+ print(" FFMPEG")
137
+ print("=" * 50)
138
+
139
+ ffmpeg_path = shutil.which("ffmpeg")
140
+
141
+ if ffmpeg_path:
142
+ try:
143
+ result = subprocess.run(
144
+ ["ffmpeg", "-version"],
145
+ capture_output=True,
146
+ text=True,
147
+ timeout=5
148
+ )
149
+ version_line = result.stdout.split('\n')[0]
150
+ print(f" {check_mark(True)} FFmpeg encontrado: {ffmpeg_path}")
151
+ print(f" {check_mark(True)} {version_line}")
152
+ return True
153
+ except Exception as e:
154
+ print(f" {check_mark(False)} Error verificando versión: {e}")
155
+ return False
156
+ else:
157
+ print(f" {check_mark(False)} FFmpeg NO ENCONTRADO en PATH")
158
+ print(f" → Instala FFmpeg: https://ffmpeg.org/download.html")
159
+ print(f" → O usa Chocolatey: choco install ffmpeg")
160
+ return False
161
+
162
+ def check_demucs_models():
163
+ """Verifica los modelos de Demucs"""
164
+ print("\n" + "=" * 50)
165
+ print(" MODELOS DEMUCS")
166
+ print("=" * 50)
167
+
168
+ # Los modelos se descargan automáticamente la primera vez
169
+ # Solo verificamos si existe el directorio de caché
170
+ cache_dir = os.path.expanduser("~/.cache/huggingface/hub")
171
+
172
+ if os.path.exists(cache_dir):
173
+ models = [d for d in os.listdir(cache_dir) if d.startswith('models--facebook--demucs')]
174
+ if models:
175
+ print(f" {check_mark(True)} Modelos en caché: {len(models)} encontrado(s)")
176
+ for m in models:
177
+ print(f" - {m}")
178
+ return True
179
+ else:
180
+ print(f" {check_mark(True)} Caché listo (modelos se descargarán la primera vez)")
181
+ return True
182
+ else:
183
+ print(f" {check_mark(True)} Los modelos se descargarán automáticamente")
184
+ return True
185
+
186
+ def check_directories():
187
+ """Verifica los directorios necesarios"""
188
+ print("\n" + "=" * 50)
189
+ print(" DIRECTORIOS")
190
+ print("=" * 50)
191
+
192
+ base_dir = os.getenv("BASE_DIR", os.path.dirname(os.path.abspath(__file__)))
193
+ dirs_to_check = [
194
+ ("BASE_DIR", base_dir),
195
+ ("UPLOADS_DIR", os.getenv("UPLOADS_DIR", os.path.join(base_dir, "uploads"))),
196
+ ("PROCESSED_DIR", os.getenv("PROCESSED_DIR", os.path.join(base_dir, "processed"))),
197
+ ("LOGS_DIR", os.path.join(base_dir, "logs")),
198
+ ]
199
+
200
+ all_ok = True
201
+
202
+ for name, path in dirs_to_check:
203
+ exists = os.path.exists(path)
204
+ writable = os.access(path, os.W_OK) if exists else False
205
+
206
+ if exists and writable:
207
+ print(f" {check_mark(True)} {name}: {path}")
208
+ elif exists:
209
+ print(f" {check_mark(False)} {name}: {path} (NO ESCRIBIBLE)")
210
+ all_ok = False
211
+ else:
212
+ print(f" {check_mark(False)} {name}: {path} (NO EXISTE)")
213
+ all_ok = False
214
+
215
+ return all_ok
216
+
217
+ def check_env_file():
218
+ """Verifica el archivo .env"""
219
+ print("\n" + "=" * 50)
220
+ print(" VARIABLES DE ENTORNO")
221
+ print("=" * 50)
222
+
223
+ env_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
224
+
225
+ if os.path.exists(env_file):
226
+ print(f" {check_mark(True)} Archivo .env encontrado")
227
+
228
+ # Verificar variables críticas
229
+ critical_vars = ["SECRET_KEY", "REDIS_URL"]
230
+ for var in critical_vars:
231
+ value = os.getenv(var)
232
+ if value:
233
+ print(f" {check_mark(True)} {var}: configurado")
234
+ else:
235
+ print(f" {check_mark(False)} {var}: NO CONFIGURADO")
236
+
237
+ return True
238
+ else:
239
+ print(f" {check_mark(False)} Archivo .env NO ENCONTRADO")
240
+ print(f" → Copia .env.example a .env")
241
+ return False
242
+
243
+ def check_gpu():
244
+ """Verifica disponibilidad de GPU CUDA"""
245
+ print("\n" + "=" * 50)
246
+ print(" GPU / CUDA")
247
+ print("=" * 50)
248
+
249
+ try:
250
+ import torch
251
+ cuda_available = torch.cuda.is_available()
252
+
253
+ if cuda_available:
254
+ gpu_name = torch.cuda.get_device_name(0)
255
+ gpu_memory = torch.cuda.get_device_properties(0).total_memory / (1024**3)
256
+ print(f" {check_mark(True)} CUDA disponible: {gpu_name}")
257
+ print(f" {check_mark(True)} Memoria GPU: {gpu_memory:.1f} GB")
258
+ return True
259
+ else:
260
+ print(f" {check_mark(False)} CUDA NO disponible")
261
+ print(f" → Se usará CPU (más lento)")
262
+ print(f" → Para GPU: instala PyTorch con CUDA")
263
+ return False
264
+
265
+ except ImportError:
266
+ print(f" {check_mark(False)} PyTorch NO INSTALADO")
267
+ print(f" → Demucs lo instalará como dependencia")
268
+ return False
269
+ except Exception as e:
270
+ print(f" {check_mark(False)} Error verificando GPU: {e}")
271
+ return False
272
+
273
+ # ==========================================
274
+ # MAIN
275
+ # ==========================================
276
+
277
+ def main():
278
+ print(c("\n" + "=" * 60, "BLUE"))
279
+ print(c(" MELODIX - VERIFICADOR DE PRERREQUISITOS", "BLUE"))
280
+ print(c("=" * 60, "BLUE"))
281
+
282
+ results = {
283
+ "Python": check_python(),
284
+ "Paquetes": check_packages(),
285
+ "Redis": check_redis(),
286
+ "FFmpeg": check_ffmpeg(),
287
+ "Demucs": check_demucs_models(),
288
+ "Directorios": check_directories(),
289
+ ".env": check_env_file(),
290
+ "GPU": check_gpu(),
291
+ }
292
+
293
+ # ==========================================
294
+ # RESUMEN FINAL
295
+ # ==========================================
296
+ print("\n" + "=" * 60)
297
+ print(c(" RESUMEN", "BLUE"))
298
+ print("=" * 60)
299
+
300
+ passed = sum(1 for v in results.values() if v)
301
+ total = len(results)
302
+
303
+ for name, result in results.items():
304
+ status = c("✓ PASS", "GREEN") if result else c("✗ FAIL", "RED")
305
+ print(f" {status} - {name}")
306
+
307
+ print("\n" + "-" * 60)
308
+
309
+ if passed == total:
310
+ print(c(" ¡TODO LISTO! Puedes iniciar Melodix", "GREEN"))
311
+ print(c(" Ejecuta: iniciar_melodix.bat", "GREEN"))
312
+ else:
313
+ print(c(f" ATENCIÓN: {total - passed} verificación(es) fallida(s)", "YELLOW"))
314
+ print(c(" Revisa los errores arriba y corrígelos antes de iniciar", "YELLOW"))
315
+
316
+ print("=" * 60 + "\n")
317
+
318
+ return 0 if passed == total else 1
319
+
320
+
321
+ if __name__ == "__main__":
322
+ sys.exit(main())
check_schema.py CHANGED
@@ -1,15 +1,15 @@
1
- import psycopg2
2
-
3
- DATABASE_URL = "postgresql://postgres.egkydqberhebzkqtltic:Isaac%23105*may%40@aws-1-us-east-1.pooler.supabase.com:5432/postgres"
4
-
5
- def check_schema():
6
- conn = psycopg2.connect(DATABASE_URL)
7
- cur = conn.cursor()
8
- cur.execute("SELECT column_name FROM information_schema.columns WHERE table_name='devices';")
9
- cols = [r[0] for r in cur.fetchall()]
10
- print("Columnas en devices:", cols)
11
- cur.close()
12
- conn.close()
13
-
14
- if __name__ == "__main__":
15
- check_schema()
 
1
+ import psycopg2
2
+
3
+ DATABASE_URL = "postgresql://postgres.egkydqberhebzkqtltic:Isaac%23105*may%40@aws-1-us-east-1.pooler.supabase.com:5432/postgres"
4
+
5
+ def check_schema():
6
+ conn = psycopg2.connect(DATABASE_URL)
7
+ cur = conn.cursor()
8
+ cur.execute("SELECT column_name FROM information_schema.columns WHERE table_name='devices';")
9
+ cols = [r[0] for r in cur.fetchall()]
10
+ print("Columnas en devices:", cols)
11
+ cur.close()
12
+ conn.close()
13
+
14
+ if __name__ == "__main__":
15
+ check_schema()