Isaac105 commited on
Commit
fc4cf2f
·
1 Parent(s): e388808

deploy from github actions

Browse files
app/models/user.py CHANGED
@@ -20,6 +20,7 @@ class User(Base):
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")
 
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
+ last_monthly_credit_month = Column(String, nullable=True, default=None) # Formato: "YYYY-MM"
24
 
25
  devices = relationship("Device", back_populates="user")
26
  tasks = relationship("Task", back_populates="user")
app/routers/auth.py CHANGED
@@ -159,6 +159,15 @@ async def get_current_user(
159
  detail="Session invalidated. Logged in from another device.",
160
  headers={"WWW-Authenticate": "Bearer"},
161
  )
 
 
 
 
 
 
 
 
 
162
 
163
  return user
164
 
@@ -201,11 +210,13 @@ def register_user(user: UserCreate, db: Session = Depends(get_db)):
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()
@@ -278,6 +289,15 @@ async def login_for_access_token(
278
  headers={"WWW-Authenticate": "Bearer"},
279
  )
280
 
 
 
 
 
 
 
 
 
 
281
  # ==========================================
282
  # GESTIÓN DE DISPOSITIVOS
283
  # ==========================================
 
159
  detail="Session invalidated. Logged in from another device.",
160
  headers={"WWW-Authenticate": "Bearer"},
161
  )
162
+
163
+ # Verificación quirúrgica de recarga mensual de 10 créditos (1 de cada mes / primer acceso del mes)
164
+ current_month = datetime.utcnow().strftime("%Y-%m")
165
+ if getattr(user, "last_monthly_credit_month", None) != current_month:
166
+ try:
167
+ from app.utils.credits import grant_monthly_credits_to_user
168
+ grant_monthly_credits_to_user(db, user, year_month=current_month, amount=10)
169
+ except Exception as e_month:
170
+ logger.warning(f"Error aplicando recarga mensual perezosa a usuario {user.id}: {e_month}")
171
 
172
  return user
173
 
 
210
 
211
  # Crear usuario
212
  hashed_password = get_password_hash(user.password)
213
+ current_month = datetime.utcnow().strftime("%Y-%m")
214
  new_user = User(
215
  username=reg_username,
216
  email=user.email,
217
  hashed_password=hashed_password,
218
+ credits=10, # Créditos iniciales de registro
219
+ last_monthly_credit_month=current_month
220
  )
221
  db.add(new_user)
222
  db.commit()
 
289
  headers={"WWW-Authenticate": "Bearer"},
290
  )
291
 
292
+ # Verificación quirúrgica de recarga mensual de 10 créditos al iniciar sesión
293
+ current_month = datetime.utcnow().strftime("%Y-%m")
294
+ if getattr(user, "last_monthly_credit_month", None) != current_month:
295
+ try:
296
+ from app.utils.credits import grant_monthly_credits_to_user
297
+ grant_monthly_credits_to_user(db, user, year_month=current_month, amount=10)
298
+ except Exception as e_month:
299
+ logger.warning(f"Error aplicando recarga mensual al login de usuario {user.id}: {e_month}")
300
+
301
  # ==========================================
302
  # GESTIÓN DE DISPOSITIVOS
303
  # ==========================================
app/routers/lyrics.py CHANGED
@@ -98,7 +98,7 @@ def list_groups(db: Session = Depends(get_db), current_user: User = Depends(get_
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)):
 
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, Repertorio.group_id == None).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)):
app/utils/credits.py CHANGED
@@ -236,6 +236,97 @@ def reward_public_worker_owner(
236
  return True
237
 
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  def _notify_credits_update(user: User):
240
  """Envía mensaje WebSocket al cliente con el desglose de créditos."""
241
  try:
 
236
  return True
237
 
238
 
239
+ def grant_monthly_credits_to_user(
240
+ db: Session,
241
+ user: User,
242
+ year_month: str = None,
243
+ amount: int = 10
244
+ ) -> bool:
245
+ """
246
+ Otorga de forma quirúrgica e idempotente los 10 créditos mensuales a una cuenta registrada.
247
+
248
+ Garantías de seguridad:
249
+ - Validación 100% en backend (imposible de manipular o inyectar desde el frontend).
250
+ - Doble capa de protección contra duplicados (campo last_monthly_credit_month + registro en CreditTransaction).
251
+ - Notificación en tiempo real por WebSocket.
252
+ """
253
+ if not user:
254
+ return False
255
+
256
+ if year_month is None:
257
+ year_month = datetime.utcnow().strftime("%Y-%m")
258
+
259
+ # 1. Comprobar si ya se le otorgó en el modelo User
260
+ if getattr(user, "last_monthly_credit_month", None) == year_month:
261
+ return False
262
+
263
+ # 2. Comprobar además en CreditTransaction por idempotencia a nivel de tabla
264
+ ref_id = f"monthly_grant_{year_month}"
265
+ existing_tx = db.query(CreditTransaction).filter(
266
+ CreditTransaction.user_id == user.id,
267
+ CreditTransaction.reference_id == ref_id
268
+ ).first()
269
+ if existing_tx:
270
+ user.last_monthly_credit_month = year_month
271
+ db.commit()
272
+ return False
273
+
274
+ if user.credits is None:
275
+ user.credits = 0
276
+
277
+ # 3. Aplicar los 10 créditos base mensuales
278
+ user.credits += amount
279
+ user.last_monthly_credit_month = year_month
280
+
281
+ # 4. Registrar la transacción en el historial
282
+ tx = CreditTransaction(
283
+ user_id=user.id,
284
+ amount=amount,
285
+ concept=f"recarga_mensual_{year_month}",
286
+ reference_id=ref_id,
287
+ credit_type="base",
288
+ created_at=datetime.utcnow()
289
+ )
290
+ db.add(tx)
291
+ db.commit()
292
+ db.refresh(user)
293
+
294
+ logger.info(f"[Monthly Credits] +{amount} créditos mensuales aplicados exitosamente al usuario '{user.username}' (ID: {user.id}) para el mes {year_month}.")
295
+ _notify_credits_update(user)
296
+ return True
297
+
298
+
299
+ def process_all_users_monthly_credits(db: Session, year_month: str = None, amount: int = 10) -> dict:
300
+ """
301
+ Recorre todas las cuentas registradas activas y aplica la recarga mensual de 10 créditos
302
+ con total idempotencia.
303
+ """
304
+ if year_month is None:
305
+ year_month = datetime.utcnow().strftime("%Y-%m")
306
+
307
+ users = db.query(User).filter(User.is_suspended == False).all()
308
+ granted = 0
309
+ skipped = 0
310
+
311
+ for u in users:
312
+ try:
313
+ if grant_monthly_credits_to_user(db, u, year_month=year_month, amount=amount):
314
+ granted += 1
315
+ else:
316
+ skipped += 1
317
+ except Exception as e_user:
318
+ logger.error(f"[Monthly Credits Bulk] Error procesando usuario {u.id}: {e_user}")
319
+ db.rollback()
320
+
321
+ logger.info(f"[Monthly Credits Bulk] Resumen mes {year_month}: {granted} cuentas recargadas con +{amount} créditos, {skipped} omitidas (ya tenían recarga).")
322
+ return {
323
+ "year_month": year_month,
324
+ "granted": granted,
325
+ "skipped": skipped,
326
+ "total_users": len(users)
327
+ }
328
+
329
+
330
  def _notify_credits_update(user: User):
331
  """Envía mensaje WebSocket al cliente con el desglose de créditos."""
332
  try:
cleanup.py CHANGED
@@ -237,6 +237,40 @@ def clean_expired_sync_queue(max_age_days=7, dry_run=False):
237
  return 0
238
 
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  # ==========================================
241
  # MAIN
242
  # ==========================================
@@ -268,6 +302,8 @@ def main():
268
  cleaned_logs = clean_logs(max_age_days=30, dry_run=args.dry_run) # Logs: 30 días
269
  logger.info("")
270
  cleaned_sync = clean_expired_sync_queue(max_age_days=args.days, dry_run=args.dry_run) # HF/Expira
 
 
271
 
272
  logger.info("")
273
  logger.info("=" * 50)
@@ -277,6 +313,7 @@ def main():
277
  logger.info(f"Procesados limpiados: {cleaned_processed}")
278
  logger.info(f"Logs limpiados: {cleaned_logs}")
279
  logger.info(f"HF/Nube limpiados: {cleaned_sync}")
 
280
  logger.info("=" * 50)
281
 
282
  if args.dry_run:
 
237
  return 0
238
 
239
 
240
+ # ==========================================
241
+ # RECARGA MENSUAL DE CRÉDITOS (DÍA 1 DE CADA MES)
242
+ # ==========================================
243
+
244
+ def apply_monthly_credits_maintenance(dry_run=False):
245
+ """
246
+ Aplica la recarga mensual de 10 créditos a todas las cuentas registradas el día 1 de cada mes.
247
+ Idempotente: no duplica si ya recibieron su cuota de este mes.
248
+ """
249
+ if datetime.utcnow().day != 1:
250
+ return {"status": "omitted_not_day_1"}
251
+
252
+ logger.info("Es el 1 de mes: Verificando recarga mensual de 10 créditos para todas las cuentas...")
253
+ if dry_run:
254
+ logger.info(" [DRY-RUN] Se otorgarían 10 créditos a las cuentas registradas.")
255
+ return {"dry_run": True}
256
+
257
+ current_dir = os.path.dirname(os.path.abspath(__file__))
258
+ if current_dir not in sys.path:
259
+ sys.path.insert(0, current_dir)
260
+
261
+ try:
262
+ from app.database import SessionLocal
263
+ from app.utils.credits import process_all_users_monthly_credits
264
+ db = SessionLocal()
265
+ res = process_all_users_monthly_credits(db, amount=10)
266
+ db.close()
267
+ logger.info(f"Recarga mensual completada: {res}")
268
+ return res
269
+ except Exception as e:
270
+ logger.error(f"Error ejecutando process_all_users_monthly_credits desde cleanup.py: {e}")
271
+ return {"error": str(e)}
272
+
273
+
274
  # ==========================================
275
  # MAIN
276
  # ==========================================
 
302
  cleaned_logs = clean_logs(max_age_days=30, dry_run=args.dry_run) # Logs: 30 días
303
  logger.info("")
304
  cleaned_sync = clean_expired_sync_queue(max_age_days=args.days, dry_run=args.dry_run) # HF/Expira
305
+ logger.info("")
306
+ monthly_credits_res = apply_monthly_credits_maintenance(dry_run=args.dry_run) # 10 créditos día 1
307
 
308
  logger.info("")
309
  logger.info("=" * 50)
 
313
  logger.info(f"Procesados limpiados: {cleaned_processed}")
314
  logger.info(f"Logs limpiados: {cleaned_logs}")
315
  logger.info(f"HF/Nube limpiados: {cleaned_sync}")
316
+ logger.info(f"Créditos mensuales: {monthly_credits_res}")
317
  logger.info("=" * 50)
318
 
319
  if args.dry_run:
main.py CHANGED
@@ -58,6 +58,7 @@ _migrations = [
58
  "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS tuning_info TEXT",
59
  "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chords TEXT",
60
  "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS cloud_url TEXT",
 
61
  ]
62
  from sqlalchemy import text as _sql_text
63
  for _mig_sql in _migrations:
 
58
  "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS tuning_info TEXT",
59
  "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chords TEXT",
60
  "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS cloud_url TEXT",
61
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS last_monthly_credit_month VARCHAR(20)",
62
  ]
63
  from sqlalchemy import text as _sql_text
64
  for _mig_sql in _migrations:
tasks.py CHANGED
@@ -155,6 +155,10 @@ else:
155
  'task': 'limpiar_stems_expirados_task',
156
  'schedule': 21600.0, # Cada 6 horas
157
  },
 
 
 
 
158
  },
159
  timezone='UTC',
160
  )
@@ -2214,6 +2218,31 @@ def limpiar_stems_expirados_task():
2214
  db.close()
2215
 
2216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2217
  def sync_local_task_to_cloud(task_id, stems_to_upload, acordes, stems, user_id, song_name, artist):
2218
  logger.info(f"[{task_id}] Iniciando sincronización de respaldo a la nube en background (desde backup)...")
2219
  try:
 
155
  'task': 'limpiar_stems_expirados_task',
156
  'schedule': 21600.0, # Cada 6 horas
157
  },
158
+ 'recarga-mensual-creditos-cada-6-horas': {
159
+ 'task': 'aplicar_recarga_mensual_creditos_task',
160
+ 'schedule': 21600.0, # Cada 6 horas (idempotente)
161
+ },
162
  },
163
  timezone='UTC',
164
  )
 
2218
  db.close()
2219
 
2220
 
2221
+ @celery_app.task(name="aplicar_recarga_mensual_creditos_task")
2222
+ def aplicar_recarga_mensual_creditos_task():
2223
+ """
2224
+ Aplica 10 créditos adicionales a todas las cuentas registradas en Melodix el día 1 de cada mes.
2225
+ Totalmente idempotente: si ya los recibieron para el mes en curso (YYYY-MM), se omite de forma segura.
2226
+ """
2227
+ from app.database import SessionLocal
2228
+ from app.utils.credits import process_all_users_monthly_credits
2229
+ from datetime import datetime
2230
+
2231
+ now = datetime.utcnow()
2232
+ # Ejecutar principalmente el 1 de cada mes o para sincronizar mes en curso
2233
+ logger.info(f"[Monthly Credits Task] Iniciando recarga mensual para {now.strftime('%Y-%m')} (Día actual: {now.day})...")
2234
+ db = SessionLocal()
2235
+ try:
2236
+ res = process_all_users_monthly_credits(db, amount=10)
2237
+ logger.info(f"[Monthly Credits Task] Resultado: {res}")
2238
+ return res
2239
+ except Exception as e:
2240
+ logger.error(f"[Monthly Credits Task] Error en aplicar_recarga_mensual_creditos_task: {e}", exc_info=True)
2241
+ return {"error": str(e)}
2242
+ finally:
2243
+ db.close()
2244
+
2245
+
2246
  def sync_local_task_to_cloud(task_id, stems_to_upload, acordes, stems, user_id, song_name, artist):
2247
  logger.info(f"[{task_id}] Iniciando sincronización de respaldo a la nube en background (desde backup)...")
2248
  try:
tests/test_monthly_credits_grant.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ import os
3
+ import sys
4
+ from datetime import datetime
5
+
6
+ # Add parent directory to path
7
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
8
+
9
+ from sqlalchemy import create_engine
10
+ from sqlalchemy.orm import sessionmaker
11
+ from app.database import Base
12
+ from app.models.user import User, CreditTransaction
13
+ from app.models.process import Task
14
+ from app.models.lyrics import UserGroup
15
+ from app.utils.credits import grant_monthly_credits_to_user, process_all_users_monthly_credits
16
+
17
+ TEST_DB_URL = "sqlite:///./test_monthly_credits.db"
18
+
19
+ class TestMonthlyCreditsGrant(unittest.TestCase):
20
+ @classmethod
21
+ def setUpClass(cls):
22
+ if os.path.exists("./test_monthly_credits.db"):
23
+ os.remove("./test_monthly_credits.db")
24
+ cls.engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
25
+ Base.metadata.create_all(bind=cls.engine)
26
+ cls.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=cls.engine)
27
+
28
+ @classmethod
29
+ def tearDownClass(cls):
30
+ if os.path.exists("./test_monthly_credits.db"):
31
+ try:
32
+ os.remove("./test_monthly_credits.db")
33
+ except Exception:
34
+ pass
35
+
36
+ def setUp(self):
37
+ self.db = self.SessionLocal()
38
+
39
+ def tearDown(self):
40
+ self.db.close()
41
+
42
+ def test_single_user_monthly_credits_grant_and_idempotency(self):
43
+ """
44
+ Prueba que un usuario recibe 10 créditos para el mes actual,
45
+ y que intentos subsecuentes en el mismo mes son rechazados sin duplicar créditos.
46
+ """
47
+ user = User(
48
+ username="monthly_user_1",
49
+ email="m1@test.com",
50
+ credits=5,
51
+ bonus_credits=2,
52
+ last_monthly_credit_month="2026-07" # Mes anterior
53
+ )
54
+ self.db.add(user)
55
+ self.db.commit()
56
+ self.db.refresh(user)
57
+
58
+ # 1. Aplicar recarga mensual para 2026-08 (mes en curso)
59
+ applied = grant_monthly_credits_to_user(self.db, user, year_month="2026-08", amount=10)
60
+ self.assertTrue(applied)
61
+ self.assertEqual(user.credits, 15) # 5 + 10
62
+ self.assertEqual(user.bonus_credits, 2)
63
+ self.assertEqual(user.total_credits, 17)
64
+ self.assertEqual(user.last_monthly_credit_month, "2026-08")
65
+
66
+ # 2. Verificar registro en CreditTransaction
67
+ tx = self.db.query(CreditTransaction).filter(
68
+ CreditTransaction.user_id == user.id,
69
+ CreditTransaction.reference_id == "monthly_grant_2026-08"
70
+ ).first()
71
+ self.assertIsNotNone(tx)
72
+ self.assertEqual(tx.amount, 10)
73
+ self.assertEqual(tx.concept, "recarga_mensual_2026-08")
74
+ self.assertEqual(tx.credit_type, "base")
75
+
76
+ # 3. Intentar aplicar nuevamente para el MISMO mes (Idempotencia / Protección contra duplicados)
77
+ applied_again = grant_monthly_credits_to_user(self.db, user, year_month="2026-08", amount=10)
78
+ self.assertFalse(applied_again)
79
+ self.assertEqual(user.credits, 15) # No debe haber cambiado
80
+
81
+ # Verificar que solo existe UNA transacción para ese mes
82
+ tx_count = self.db.query(CreditTransaction).filter(
83
+ CreditTransaction.user_id == user.id,
84
+ CreditTransaction.reference_id == "monthly_grant_2026-08"
85
+ ).count()
86
+ self.assertEqual(tx_count, 1)
87
+
88
+ # 4. Transición al siguiente mes (2026-09)
89
+ next_month_applied = grant_monthly_credits_to_user(self.db, user, year_month="2026-09", amount=10)
90
+ self.assertTrue(next_month_applied)
91
+ self.assertEqual(user.credits, 25) # 15 + 10
92
+ self.assertEqual(user.last_monthly_credit_month, "2026-09")
93
+
94
+ def test_bulk_process_all_users_monthly_credits(self):
95
+ """
96
+ Prueba que process_all_users_monthly_credits procesa todas las cuentas registradas
97
+ otorgando exactamente 10 créditos solo a quienes no los hayan recibido ese mes.
98
+ """
99
+ u1 = User(username="bulk_user_a", email="ua@test.com", credits=10, last_monthly_credit_month="2026-07")
100
+ u2 = User(username="bulk_user_b", email="ub@test.com", credits=20, last_monthly_credit_month="2026-08") # Ya tiene
101
+ u3 = User(username="bulk_user_c", email="uc@test.com", credits=0, last_monthly_credit_month=None)
102
+ self.db.add_all([u1, u2, u3])
103
+ self.db.commit()
104
+
105
+ res = process_all_users_monthly_credits(self.db, year_month="2026-08", amount=10)
106
+
107
+ self.assertEqual(res["year_month"], "2026-08")
108
+ # u1 y u3 deben recibir los 10 créditos. u2 (y los del test anterior) se omiten
109
+ self.assertGreaterEqual(res["granted"], 2)
110
+
111
+ self.db.refresh(u1)
112
+ self.db.refresh(u2)
113
+ self.db.refresh(u3)
114
+
115
+ self.assertEqual(u1.credits, 20) # 10 + 10
116
+ self.assertEqual(u2.credits, 20) # Sin cambios (ya tenía 2026-08)
117
+ self.assertEqual(u3.credits, 10) # 0 + 10
118
+
119
+ # Ejecutar inmediatamente por segunda vez: 0 otorgados, todos omitidos
120
+ res2 = process_all_users_monthly_credits(self.db, year_month="2026-08", amount=10)
121
+ self.assertEqual(res2["granted"], 0)
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()