views.py 23 KB

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