views.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  1. from django.shortcuts import render, redirect, get_object_or_404
  2. from django.http import JsonResponse
  3. from django.views.decorators.csrf import csrf_exempt
  4. from decimal import Decimal, InvalidOperation
  5. from django.db import models as db_models
  6. from django.contrib.auth.decorators import login_required
  7. from django.contrib import messages
  8. from django.core.serializers import serialize
  9. from datetime import date, timedelta, datetime, time
  10. from django.conf import settings
  11. from django.utils.timezone import localtime, now, make_aware, is_naive
  12. from calendar import monthrange
  13. from django.utils import timezone
  14. from django.db.models import Q
  15. from collections import defaultdict
  16. from django.template.loader import get_template
  17. from django.http import HttpResponse
  18. from weasyprint import HTML
  19. import tempfile
  20. from django.utils.dateparse import parse_date
  21. from django.db.models import Sum
  22. import logging
  23. import re
  24. from accounts import models
  25. from .models import Movimiento, Cuenta, MovimientoFrecuente, Categoria, TransaccionRecurrente
  26. from .forms import MovimientoForm, FiltroForm, FiltroMesAnioForm, FiltroCuentaForm, InformeCuentaForm, FiltroInformeCuentaForm
  27. from .recurrentes import procesar_transacciones_recurrentes
  28. logger = logging.getLogger(__name__)
  29. @login_required
  30. def dashboard(request):
  31. # Copiamos GET para poder modificarlo
  32. request.GET = request.GET.copy()
  33. hoy = now()
  34. form_fecha = FiltroMesAnioForm(request.GET or None)
  35. mes = hoy.month
  36. anio = hoy.year
  37. if form_fecha.is_valid():
  38. mes = int(form_fecha.cleaned_data['mes'])
  39. anio = int(form_fecha.cleaned_data['anio'])
  40. # logger.warning(f"dashboard: mes: {mes} / año: {anio}")
  41. inicio_mes = timezone.make_aware(datetime(anio, mes, 1))
  42. ultimo_dia = monthrange(anio, mes)[1]
  43. fin_mes = timezone.make_aware(datetime(anio, mes, ultimo_dia, 23, 59, 59))
  44. movimientos = Movimiento.objects.filter(usuario=request.user, fecha__range=(inicio_mes, fin_mes)).order_by('-fecha')
  45. # logger.warning(f"dashboard: movimientos: {movimientos}")
  46. favoritas = Cuenta.objects.filter(favorita='True').order_by('nombre')
  47. frecuentes = MovimientoFrecuente.objects.filter(usuario=request.user).order_by('cuenta_destino')
  48. form_filtro = FiltroForm(request.GET or None)
  49. if form_filtro.is_valid():
  50. busqueda = form_filtro.cleaned_data.get('buscar')
  51. if busqueda:
  52. # logger.warning(f"dashboard: busqueda=true {busqueda}")
  53. movimientos = movimientos.filter(
  54. db_models.Q(descripcion__icontains=busqueda) | db_models.Q(cuenta_origen__nombre__icontains=busqueda) | db_models.Q(cuenta_destino__nombre__icontains=busqueda)
  55. )
  56. return render(request, 'dashboard.html', {
  57. 'movimientos': movimientos,
  58. 'user': request.user,
  59. 'form_filtro': form_filtro,
  60. 'form_fecha': form_fecha,
  61. 'favoritas': favoritas,
  62. 'frecuentes': frecuentes,
  63. 'mes': mes,
  64. 'anio': anio,
  65. })
  66. @login_required
  67. def buscatotal(request):
  68. # Copiamos GET para poder modificarlo
  69. request.GET = request.GET.copy()
  70. movimientos = Movimiento.objects.filter(usuario=request.user).order_by('-fecha')
  71. form_filtro = FiltroForm(request.GET or None)
  72. fecha_fin = timezone.localtime().strftime('%Y-%m-%d')
  73. fecha_inicio = timezone.localtime().date().replace(day=1).strftime('%Y-%m-%d')
  74. if form_filtro.is_valid():
  75. busqueda = form_filtro.cleaned_data.get('buscar')
  76. if busqueda:
  77. # logger.warning(f"dashboard: busqueda=true {busqueda}")
  78. movimientos = movimientos.filter(
  79. db_models.Q(descripcion__icontains=busqueda) | db_models.Q(cuenta_origen__nombre__icontains=busqueda) | db_models.Q(cuenta_destino__nombre__icontains=busqueda)
  80. )
  81. return render(request, 'buscatotal.html', {
  82. 'movimientos': movimientos,
  83. 'user': request.user,
  84. 'form_filtro': form_filtro,
  85. 'fecha_inicio': fecha_inicio,
  86. 'fecha_fin': fecha_fin,
  87. })
  88. # Movimientos
  89. @login_required
  90. def agregar_movimiento(request):
  91. if request.method == 'POST':
  92. form = MovimientoForm(request.POST)
  93. cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre')
  94. form.fields['cuenta_origen'].queryset = cuentas
  95. form.fields['cuenta_destino'].queryset = cuentas
  96. if form.is_valid():
  97. movimiento = form.save(commit=False)
  98. movimiento.usuario = request.user
  99. movimiento.save()
  100. # Ajustar saldos de cuentas
  101. movimiento.cuenta_origen.saldo -= movimiento.monto
  102. movimiento.cuenta_origen.save()
  103. movimiento.cuenta_destino.saldo += movimiento.monto
  104. movimiento.cuenta_destino.save()
  105. messages.success(request, 'Movimiento registrado con éxito.')
  106. return redirect('dashboard')
  107. else:
  108. datos_iniciales = {}
  109. origen = request.GET.get('origen')
  110. destino = request.GET.get('destino')
  111. categoria = request.GET.get('categoria')
  112. descripcion = request.GET.get('descripcion')
  113. monto_raw = request.GET.get('monto')
  114. # logger.warning(f"agregar_movimiento: origen: {origen}")
  115. # logger.warning(f"agregar_movimiento: destino: {destino}")
  116. # logger.warning(f"agregar_movimiento: categoria: {categoria}")
  117. # logger.warning(f"agregar_movimiento: descripcion: {descripcion}")
  118. # logger.warning(f"agregar_movimiento: monto_raw: {monto_raw}")
  119. try:
  120. if origen:
  121. datos_iniciales['cuenta_origen'] = Cuenta.objects.get(nombre=request.GET['origen'], usuario=request.user)
  122. if destino:
  123. datos_iniciales['cuenta_destino'] = Cuenta.objects.get(nombre=request.GET['destino'], usuario=request.user)
  124. if categoria:
  125. datos_iniciales['categoria'] = Categoria.objects.get(nombre=request.GET['categoria'])
  126. if descripcion:
  127. datos_iniciales['descripcion'] = descripcion
  128. if monto_raw:
  129. # Reemplaza coma por punto, elimina símbolos y espacios
  130. monto_normalizado = re.sub(r'[^\d,.-]', '', monto_raw).replace(',', '.')
  131. try:
  132. datos_iniciales['monto'] = str(Decimal(monto_normalizado).quantize(Decimal('0.01')))
  133. except InvalidOperation:
  134. pass # Ignorar monto malformado
  135. logger.warning(f"monto: mal formado {datos_iniciales['monto']}")
  136. except (Cuenta.DoesNotExist, InvalidOperation):
  137. pass
  138. # logger.warning(f"agregar_movimiento: datos iniciales: {datos_iniciales}")
  139. cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre')
  140. form = MovimientoForm(initial=datos_iniciales)
  141. form.fields['cuenta_origen'].queryset = cuentas
  142. form.fields['cuenta_destino'].queryset = cuentas
  143. return render(request, 'agregar_movimiento.html', {'form': form})
  144. @login_required
  145. def editar_movimiento(request, movimiento_id):
  146. movimiento = get_object_or_404(Movimiento, id=movimiento_id, usuario=request.user)
  147. cuenta_original_origen = movimiento.cuenta_origen
  148. cuenta_original_destino = movimiento.cuenta_destino
  149. monto_original = movimiento.monto
  150. if request.method == 'POST':
  151. form = MovimientoForm(request.POST, instance=movimiento)
  152. cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre')
  153. form.fields['cuenta_origen'].queryset = cuentas
  154. form.fields['cuenta_destino'].queryset = cuentas
  155. # Revertir efecto anterior
  156. # Cuenta origen
  157. cuenta_original_origen.saldo += monto_original
  158. cuenta_original_origen.save()
  159. # Cuenta destino
  160. cuenta_original_destino.saldo -= monto_original
  161. cuenta_original_destino.save()
  162. if form.is_valid():
  163. movimiento_nuevo = form.save(commit=False)
  164. # Aplicar nuevo efecto
  165. # con esta modificación, que lo deja como estaba originalmente, parece que está arreglado.
  166. # Si cambio el importe, se ajustan los saldos origen y destino apropiadamente.
  167. # Si cambia una de las cuentas, también queda bien reflelado.
  168. # Si intercambio las cuentas, es decir, la origen pasa a ser la destino y viceversa, también queda bien reflejado.
  169. movimiento_nuevo.usuario = request.user
  170. movimiento_nuevo.save()
  171. movimiento_nuevo.cuenta_origen.saldo -= movimiento_nuevo.monto # ...tomar como valor del saldo el de la nueva cuenta.
  172. movimiento_nuevo.cuenta_origen.save()
  173. movimiento_nuevo.cuenta_destino.saldo += movimiento_nuevo.monto # ...tomar como valor del saldo el de la nueva cuenta.
  174. movimiento_nuevo.cuenta_destino.save()
  175. return redirect('dashboard')
  176. else:
  177. form = MovimientoForm(instance=movimiento)
  178. cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre')
  179. form.fields['cuenta_origen'].queryset = cuentas
  180. form.fields['cuenta_destino'].queryset = cuentas
  181. return render(request, 'editar_movimiento.html', {'form': form})
  182. @login_required
  183. def eliminar_movimiento(request, movimiento_id):
  184. movimiento = get_object_or_404(Movimiento, id=movimiento_id, usuario=request.user)
  185. if request.method == 'POST':
  186. # Revertir efecto del movimiento antes de borrarlo
  187. movimiento.cuenta_origen.saldo += movimiento.monto
  188. movimiento.cuenta_origen.save()
  189. movimiento.cuenta_destino.saldo -= movimiento.monto
  190. movimiento.cuenta_destino.save()
  191. movimiento.delete()
  192. return redirect('dashboard')
  193. return render(request, 'eliminar_movimiento.html', {'movimiento': movimiento})
  194. @login_required
  195. def duplicar_movimiento(request, movimiento_id):
  196. original = get_object_or_404(Movimiento, id=movimiento_id, usuario=request.user)
  197. # Crear una copia del movimiento sin guardar
  198. movimiento_nuevo = Movimiento(
  199. cuenta_origen=original.cuenta_origen,
  200. cuenta_destino=original.cuenta_destino,
  201. monto=original.monto,
  202. fecha=now(),
  203. descripcion=original.descripcion,
  204. usuario=request.user,
  205. categoria=original.categoria,
  206. )
  207. if request.method == 'POST':
  208. form = MovimientoForm(request.POST, instance=movimiento_nuevo)
  209. cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre')
  210. form.fields['cuenta_origen'].queryset = cuentas
  211. form.fields['cuenta_destino'].queryset = cuentas
  212. if form.is_valid():
  213. nuevo = form.save(commit=False)
  214. nuevo.usuario = request.user
  215. nuevo.save()
  216. # Ajustar saldos
  217. nuevo.cuenta_origen.saldo -= nuevo.monto
  218. nuevo.cuenta_origen.save()
  219. nuevo.cuenta_destino.saldo += nuevo.monto
  220. nuevo.cuenta_destino.save()
  221. messages.success(request, 'Movimiento duplicado con éxito.')
  222. return redirect('dashboard')
  223. else:
  224. form = MovimientoForm(instance=movimiento_nuevo)
  225. cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre')
  226. form.fields['cuenta_origen'].queryset = cuentas
  227. form.fields['cuenta_destino'].queryset = cuentas
  228. return render(request, 'duplicar_movimiento.html', {'form': form})
  229. # Cuentas
  230. @login_required
  231. def listar_cuentas(request):
  232. cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre')
  233. # desde aquí....
  234. form_filtro = FiltroForm(request.GET or None)
  235. fecha_fin = timezone.localtime().strftime('%Y-%m-%d')
  236. fecha_inicio = timezone.localtime().date().replace(day=1).strftime('%Y-%m-%d')
  237. if form_filtro.is_valid():
  238. busqueda = form_filtro.cleaned_data.get('buscar')
  239. if busqueda:
  240. cuentas = cuentas.filter(
  241. db_models.Q(nombre__icontains=busqueda)
  242. )
  243. # hasta aquí, hay que quitarlo
  244. # en el render hay que pasar solo cuentas.
  245. return render(request, 'listar_cuentas.html',
  246. {'cuentas': cuentas,
  247. 'form_filtro': form_filtro,
  248. 'fecha_inicio': fecha_inicio,
  249. 'fecha_fin': fecha_fin
  250. })
  251. # API
  252. def api_datos_completos(request):
  253. cuentas = list(Cuenta.objects.values('id', 'nombre', 'tipo', 'saldo', 'usuario__nombre', 'favorita', 'saldo_inicial'))
  254. categorias = list(Categoria.objects.values('id', 'nombre'))
  255. movimientos = [
  256. {
  257. 'id': m.id,
  258. 'usuario': m.usuario.nombre,
  259. 'cuenta_origen': m.cuenta_origen.nombre,
  260. 'cuenta_destino': m.cuenta_destino.nombre,
  261. 'monto': float(m.monto),
  262. 'fecha': m.fecha.isoformat(),
  263. 'descripcion': m.descripcion,
  264. 'categoria': m.categoria.nombre if m.categoria else None,
  265. }
  266. for m in Movimiento.objects.select_related('cuenta_origen', 'cuenta_destino', 'usuario', 'categoria')
  267. ]
  268. movs_frecuentes = [
  269. {
  270. 'id': m.id,
  271. 'nombre': m.nombre,
  272. 'cuenta_origen': m.cuenta_origen.nombre,
  273. 'cuenta_destino': m.cuenta_destino.nombre,
  274. 'monto': float(m.monto),
  275. 'categoria': m.categoria.nombre if m.categoria else None,
  276. 'descripcion': m.descripcion,
  277. 'usuario': m.usuario.nombre,
  278. }
  279. for m in MovimientoFrecuente.objects.select_related('cuenta_origen', 'cuenta_destino', 'usuario', 'categoria')
  280. ]
  281. movs_recurrentes = [
  282. {
  283. 'id': m.id,
  284. 'usuario': m.usuario.nombre,
  285. 'cuenta_origen': m.cuenta_origen.nombre,
  286. 'cuenta_destino': m.cuenta_destino.nombre,
  287. 'monto': float(m.monto),
  288. 'categoria': m.categoria.nombre if m.categoria else None,
  289. 'descripcion': m.descripcion,
  290. 'frecuencia': m.frecuencia,
  291. 'dia_del_mes': m.dia_del_mes,
  292. 'dia_semana': m.dia_semana,
  293. 'semana': m.semana,
  294. 'activa': m.activa,
  295. }
  296. for m in TransaccionRecurrente.objects.select_related('cuenta_origen', 'cuenta_destino', 'usuario', 'categoria')
  297. ]
  298. return JsonResponse({
  299. 'cuentas': cuentas,
  300. 'categorias': categorias,
  301. 'movimientos': movimientos,
  302. 'movimientos_frecuentes': movs_frecuentes,
  303. 'transacciones_recurrentes': movs_recurrentes,
  304. }, safe=False)
  305. @login_required
  306. def ejecutar_recurrentes(request):
  307. procesar_transacciones_recurrentes()
  308. return redirect('dashboard')
  309. @csrf_exempt
  310. def api_ejecutar_recurrentes(request):
  311. token_enviado = request.headers.get('X-API-KEY') or request.GET.get('token')
  312. # Usar token dedicado para la API de informes
  313. if token_enviado != settings.REPORTS_API_TOKEN:
  314. logger.warning('api_informe_cuenta_pdf: intento no autorizado', extra={'ip': request.META.get('REMOTE_ADDR'), 'cuenta_id': request.GET.get('cuenta_id')})
  315. return JsonResponse({'error': 'No autorizado'}, status=403)
  316. procesar_transacciones_recurrentes(hoy=now().date())
  317. return JsonResponse({'ok': True, 'mensaje': 'Transacciones recurrentes ejecutadas'})
  318. @login_required
  319. def resumen_saldos_mensuales(request):
  320. cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre')
  321. movimientos = Movimiento.objects.filter(usuario=request.user).order_by('fecha')
  322. # Diccionario: {(cuenta_id, año, mes): saldo}
  323. saldos_mensuales = defaultdict(lambda: 0)
  324. # Diccionario para llevar el saldo acumulado
  325. saldos_acumulados = {cuenta.id: float(cuenta.saldo_inicial) for cuenta in cuentas}
  326. # Obtener todos los meses presentes en movimientos
  327. meses_presentes = set()
  328. for mov in movimientos:
  329. anio = mov.fecha.year
  330. mes = mov.fecha.month
  331. clave = (mov.cuenta_origen.id, anio, mes)
  332. meses_presentes.add((anio, mes))
  333. # Resta al origen
  334. saldos_acumulados[mov.cuenta_origen.id] -= float(mov.monto)
  335. saldos_mensuales[clave] = saldos_acumulados[mov.cuenta_origen.id]
  336. # Suma al destino
  337. clave_dest = (mov.cuenta_destino.id, anio, mes)
  338. saldos_acumulados[mov.cuenta_destino.id] += float(mov.monto)
  339. saldos_mensuales[clave_dest] = saldos_acumulados[mov.cuenta_destino.id]
  340. # Ordenar meses
  341. meses_ordenados = sorted(meses_presentes)
  342. return render(request, 'resumen_saldos_mensuales.html', {
  343. 'cuentas': cuentas,
  344. 'meses': meses_ordenados,
  345. 'saldos_mensuales': saldos_mensuales,
  346. })
  347. # Informes
  348. @login_required
  349. def informe_cuenta(request):
  350. cuenta_id = request.GET.get('cuenta_id')
  351. cuenta = get_object_or_404(Cuenta, id=cuenta_id)
  352. logger.info('api_informe_cuenta_pdf: petición aceptada', extra={'ip': request.META.get('REMOTE_ADDR'), 'cuenta_id': cuenta_id, 'usuario': getattr(request, 'user', None)})
  353. form = FiltroInformeCuentaForm(request.GET or None)
  354. if form.is_valid():
  355. # obtener fechas desde GET o establecer por defecto (mes actual)
  356. hoy = timezone.localtime()
  357. primer_dia = hoy.replace(day=1)
  358. fecha_inicio_str = request.GET.get('fecha_inicio')
  359. fecha_fin_str = request.GET.get('fecha_fin')
  360. if fecha_inicio_str and fecha_fin_str:
  361. fecha_inicio = datetime.strptime(fecha_inicio_str, '%Y-%m-%d').date()
  362. fecha_fin = datetime.strptime(fecha_fin_str, '%Y-%m-%d').date()
  363. else:
  364. fecha_inicio = primer_dia
  365. fecha_fin = hoy
  366. # Convertir a datetime aware para evitar warnings
  367. fecha_inicio_dt = timezone.make_aware(datetime.combine(fecha_inicio, time.min))
  368. fecha_fin_dt = timezone.make_aware(datetime.combine(fecha_fin, time.max))
  369. # Saldo antes del periodo
  370. movimientos_previos = Movimiento.objects.filter(
  371. fecha__lt=fecha_inicio_dt,
  372. ).filter(
  373. cuenta_origen=cuenta
  374. ).aggregate(total_origen=Sum('monto')) or {'total_origen': 0}
  375. entradas_previas = Movimiento.objects.filter(
  376. fecha__lt=fecha_inicio_dt,
  377. ).filter(
  378. cuenta_destino=cuenta
  379. ).aggregate(total_destino=Sum('monto')) or {'total_destino': 0}
  380. saldo_inicial = (entradas_previas['total_destino'] or 0) - (movimientos_previos['total_origen'] or 0)
  381. saldo_inicial = saldo_inicial + cuenta.saldo_inicial
  382. movimientos = Movimiento.objects.filter(
  383. Q(cuenta_origen=cuenta) | Q(cuenta_destino=cuenta),
  384. fecha__range=(fecha_inicio_dt, fecha_fin_dt)
  385. ).order_by('fecha')
  386. # Cálculo de saldos después de cada movimiento
  387. saldo = saldo_inicial
  388. movimientos_con_saldo = []
  389. for mov in movimientos:
  390. if mov.cuenta_origen == cuenta:
  391. saldo -= mov.monto
  392. tipo = 'Gasto'
  393. elif mov.cuenta_destino == cuenta:
  394. saldo += mov.monto
  395. tipo = 'Ingreso'
  396. movimientos_con_saldo.append({
  397. 'id': mov.id,
  398. 'fecha': mov.fecha,
  399. 'descripcion': mov.descripcion,
  400. 'monto': mov.monto,
  401. 'tipo': tipo,
  402. 'desde': mov.cuenta_origen.nombre if mov.cuenta_origen else 'N/A',
  403. 'hasta': mov.cuenta_destino.nombre if mov.cuenta_destino else 'N/A',
  404. 'saldo_despues': saldo,
  405. })
  406. # Renderizado del PDF o del HTML como prefieras
  407. context = {
  408. 'cuenta': cuenta,
  409. 'movimientos_con_saldo': movimientos_con_saldo,
  410. 'fecha_inicio': fecha_inicio_str,
  411. 'fecha_fin': fecha_fin_str,
  412. 'saldo_inicial': saldo_inicial,
  413. }
  414. return render(request, 'informe_cuenta.html', context)
  415. @login_required
  416. def informe_cuenta_pdf(request):
  417. cuenta_id = request.GET.get('cuenta_id')
  418. cuenta = get_object_or_404(Cuenta, id=cuenta_id, usuario=request.user)
  419. # obtener fechas desde GET o establecer por defecto (mes actual)
  420. hoy = timezone.localtime()
  421. primer_dia = hoy.replace(day=1)
  422. fecha_inicio_str = request.GET.get('fecha_inicio')
  423. fecha_fin_str = request.GET.get('fecha_fin')
  424. if fecha_inicio_str and fecha_fin_str:
  425. fecha_inicio = datetime.strptime(fecha_inicio_str, '%Y-%m-%d').date()
  426. fecha_fin = datetime.strptime(fecha_fin_str, '%Y-%m-%d').date()
  427. else:
  428. fecha_inicio = primer_dia
  429. fecha_fin = hoy
  430. # Convertir a datetime aware para evitar warnings
  431. fecha_inicio_dt = timezone.make_aware(datetime.combine(fecha_inicio, time.min))
  432. fecha_fin_dt = timezone.make_aware(datetime.combine(fecha_fin, time.max))
  433. # Saldo antes del periodo
  434. movimientos_previos = Movimiento.objects.filter(
  435. fecha__lt=fecha_inicio_dt,
  436. ).filter(
  437. cuenta_origen=cuenta
  438. ).aggregate(total_origen=Sum('monto')) or {'total_origen': 0}
  439. entradas_previas = Movimiento.objects.filter(
  440. fecha__lt=fecha_inicio_dt,
  441. ).filter(
  442. cuenta_destino=cuenta
  443. ).aggregate(total_destino=Sum('monto')) or {'total_destino': 0}
  444. saldo_inicial = (entradas_previas['total_destino'] or 0) - (movimientos_previos['total_origen'] or 0)
  445. saldo_inicial = saldo_inicial + cuenta.saldo_inicial
  446. movimientos = Movimiento.objects.filter(
  447. Q(cuenta_origen=cuenta) | Q(cuenta_destino=cuenta),
  448. fecha__range=(fecha_inicio_dt, fecha_fin_dt)
  449. ).order_by('fecha')
  450. saldo = saldo_inicial
  451. historial = []
  452. for mov in movimientos:
  453. if mov.cuenta_origen == cuenta:
  454. saldo -= mov.monto
  455. tipo = 'Gasto'
  456. elif mov.cuenta_destino == cuenta:
  457. saldo += mov.monto
  458. tipo = 'Ingreso'
  459. else:
  460. tipo = 'Otro'
  461. historial.append({
  462. 'fecha': mov.fecha,
  463. 'desde': mov.cuenta_origen.nombre if mov.cuenta_origen else 'N/A',
  464. 'hasta': mov.cuenta_destino.nombre if mov.cuenta_destino else 'N/A',
  465. 'descripcion': mov.descripcion,
  466. 'monto': mov.monto,
  467. 'tipo': tipo,
  468. 'saldo_despues': saldo,
  469. })
  470. template = get_template('informe_cuenta_pdf.html')
  471. html = template.render({'cuenta': cuenta, 'saldo_inicial': saldo_inicial, 'fecha_inicio': fecha_inicio_str, 'fecha_fin': fecha_fin_str, 'historial': historial})
  472. with tempfile.NamedTemporaryFile(delete=True) as output:
  473. HTML(string=html).write_pdf(output.name)
  474. output.seek(0)
  475. data = output.read()
  476. response = HttpResponse(data, content_type='application/pdf')
  477. filename = f'informe_{cuenta.nombre}.pdf'
  478. response['Content-Disposition'] = f'filename={filename}'
  479. logger.info('api_informe_cuenta_pdf: PDF generado', extra={'cuenta_id': cuenta_id, 'filename': filename, 'size_bytes': len(data)})
  480. return response
  481. @csrf_exempt
  482. def api_informe_cuenta_pdf(request):
  483. """API endpoint para generar PDF del informe de una cuenta.
  484. Parámetros GET esperados:
  485. - cuenta_id: id de la cuenta
  486. - fecha_inicio: YYYY-MM-DD (opcional)
  487. - fecha_fin: YYYY-MM-DD (opcional)
  488. También acepta header `X-API-KEY` o parámetro `token` para autenticación.
  489. """
  490. token_enviado = request.headers.get('X-API-KEY') or request.GET.get('token')
  491. if token_enviado != settings.RECURRENTES_API_TOKEN:
  492. return JsonResponse({'error': 'No autorizado'}, status=403)
  493. cuenta_id = request.GET.get('cuenta_id')
  494. if not cuenta_id:
  495. return JsonResponse({'error': 'Falta parámetro cuenta_id'}, status=400)
  496. cuenta = get_object_or_404(Cuenta, id=cuenta_id)
  497. # obtener fechas desde GET o establecer por defecto (mes actual)
  498. hoy = timezone.localtime()
  499. primer_dia = hoy.replace(day=1)
  500. fecha_inicio_str = request.GET.get('fecha_inicio')
  501. fecha_fin_str = request.GET.get('fecha_fin')
  502. if fecha_inicio_str and fecha_fin_str:
  503. fecha_inicio = datetime.strptime(fecha_inicio_str, '%Y-%m-%d').date()
  504. fecha_fin = datetime.strptime(fecha_fin_str, '%Y-%m-%d').date()
  505. else:
  506. fecha_inicio = primer_dia
  507. fecha_fin = hoy
  508. # Convertir a datetime aware para evitar warnings
  509. fecha_inicio_dt = timezone.make_aware(datetime.combine(fecha_inicio, time.min))
  510. fecha_fin_dt = timezone.make_aware(datetime.combine(fecha_fin, time.max))
  511. movimientos_previos = Movimiento.objects.filter(
  512. fecha__lt=fecha_inicio_dt,
  513. ).filter(
  514. cuenta_origen=cuenta
  515. ).aggregate(total_origen=Sum('monto')) or {'total_origen': 0}
  516. entradas_previas = Movimiento.objects.filter(
  517. fecha__lt=fecha_inicio_dt,
  518. ).filter(
  519. cuenta_destino=cuenta
  520. ).aggregate(total_destino=Sum('monto')) or {'total_destino': 0}
  521. saldo_inicial = (entradas_previas['total_destino'] or 0) - (movimientos_previos['total_origen'] or 0)
  522. saldo_inicial = saldo_inicial + cuenta.saldo_inicial
  523. movimientos = Movimiento.objects.filter(
  524. Q(cuenta_origen=cuenta) | Q(cuenta_destino=cuenta),
  525. fecha__range=(fecha_inicio_dt, fecha_fin_dt)
  526. ).order_by('fecha')
  527. saldo = saldo_inicial
  528. historial = []
  529. for mov in movimientos:
  530. if mov.cuenta_origen == cuenta:
  531. saldo -= mov.monto
  532. tipo = 'Gasto'
  533. elif mov.cuenta_destino == cuenta:
  534. saldo += mov.monto
  535. tipo = 'Ingreso'
  536. else:
  537. tipo = 'Otro'
  538. historial.append({
  539. 'fecha': mov.fecha,
  540. 'desde': mov.cuenta_origen.nombre if mov.cuenta_origen else 'N/A',
  541. 'hasta': mov.cuenta_destino.nombre if mov.cuenta_destino else 'N/A',
  542. 'descripcion': mov.descripcion,
  543. 'monto': mov.monto,
  544. 'tipo': tipo,
  545. 'saldo_despues': saldo,
  546. })
  547. template = get_template('informe_cuenta_pdf.html')
  548. html = template.render({'cuenta': cuenta, 'saldo_inicial': saldo_inicial, 'fecha_inicio': fecha_inicio_str, 'fecha_fin': fecha_fin_str, 'historial': historial})
  549. with tempfile.NamedTemporaryFile(delete=True) as output:
  550. HTML(string=html).write_pdf(output.name)
  551. output.seek(0)
  552. response = HttpResponse(output.read(), content_type='application/pdf')
  553. response['Content-Disposition'] = f'filename=informe_{cuenta.nombre}.pdf'
  554. return response