from django.shortcuts import render, redirect, get_object_or_404 from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from decimal import Decimal, InvalidOperation from django.db import models as db_models from django.contrib.auth.decorators import login_required from django.contrib import messages from django.core.serializers import serialize from datetime import date, timedelta, datetime, time from django.conf import settings from django.utils.timezone import localtime, now, make_aware, is_naive from calendar import monthrange from django.utils import timezone from django.db.models import Q from collections import defaultdict from django.template.loader import get_template from django.http import HttpResponse from weasyprint import HTML import tempfile from django.utils.dateparse import parse_date from django.db.models import Sum import re from accounts import models from .models import Movimiento, Cuenta, MovimientoFrecuente, Categoria, TransaccionRecurrente from .forms import MovimientoForm, FiltroForm, FiltroMesAnioForm, FiltroCuentaForm, InformeCuentaForm, FiltroInformeCuentaForm from .recurrentes import procesar_transacciones_recurrentes import logging logger_info = logging.getLogger('finanzas') logger_debug = logging.getLogger('django') @login_required def dashboard(request): # Copiamos GET para poder modificarlo request.GET = request.GET.copy() hoy = now() form_fecha = FiltroMesAnioForm(request.GET or None) mes = hoy.month anio = hoy.year if form_fecha.is_valid(): mes = int(form_fecha.cleaned_data['mes']) anio = int(form_fecha.cleaned_data['anio']) # logger.warning(f"dashboard: mes: {mes} / año: {anio}") inicio_mes = timezone.make_aware(datetime(anio, mes, 1)) ultimo_dia = monthrange(anio, mes)[1] fin_mes = timezone.make_aware(datetime(anio, mes, ultimo_dia, 23, 59, 59)) logger_debug.debug("Inicio mes %s. Último día: %s. Fin_mes: %s", inicio_mes,ultimo_dia,fin_mes) movimientos = Movimiento.objects.filter(usuario=request.user, fecha__range=(inicio_mes, fin_mes)).order_by('-fecha') favoritas = Cuenta.objects.filter(favorita='True').order_by('nombre') frecuentes = MovimientoFrecuente.objects.filter(usuario=request.user).order_by('cuenta_destino') logger_debug.debug("Rendering dashboard view with %d movimientos and %d favoritas", movimientos.count(), favoritas.count()) logger_info.info("User %s accessed principal view", request.user.nombre if request.user.is_authenticated else "Anonymous") form_filtro = FiltroForm(request.GET or None) if form_filtro.is_valid(): busqueda = form_filtro.cleaned_data.get('buscar') if busqueda: # logger.warning(f"dashboard: busqueda=true {busqueda}") movimientos = movimientos.filter( db_models.Q(descripcion__icontains=busqueda) | db_models.Q(cuenta_origen__nombre__icontains=busqueda) | db_models.Q(cuenta_destino__nombre__icontains=busqueda) ) return render(request, 'dashboard.html', { 'movimientos': movimientos, 'user': request.user, 'form_filtro': form_filtro, 'form_fecha': form_fecha, 'favoritas': favoritas, 'frecuentes': frecuentes, 'mes': mes, 'anio': anio }) @login_required def buscatotal(request): # Copiamos GET para poder modificarlo request.GET = request.GET.copy() movimientos = Movimiento.objects.filter(usuario=request.user).order_by('-fecha') form_filtro = FiltroForm(request.GET or None) fecha_fin = timezone.localtime().strftime('%Y-%m-%d') fecha_inicio = timezone.localtime().date().replace(day=1).strftime('%Y-%m-%d') if form_filtro.is_valid(): busqueda = form_filtro.cleaned_data.get('buscar') if busqueda: # logger.warning(f"dashboard: busqueda=true {busqueda}") movimientos = movimientos.filter( db_models.Q(descripcion__icontains=busqueda) | db_models.Q(cuenta_origen__nombre__icontains=busqueda) | db_models.Q(cuenta_destino__nombre__icontains=busqueda) ) return render(request, 'buscatotal.html', { 'movimientos': movimientos, 'user': request.user, 'form_filtro': form_filtro, 'fecha_inicio': fecha_inicio, 'fecha_fin': fecha_fin, }) # Movimientos @login_required def agregar_movimiento(request): if request.method == 'POST': form = MovimientoForm(request.POST) cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre') form.fields['cuenta_origen'].queryset = cuentas form.fields['cuenta_destino'].queryset = cuentas if form.is_valid(): movimiento = form.save(commit=False) movimiento.usuario = request.user movimiento.save() # Ajustar saldos de cuentas movimiento.cuenta_origen.saldo -= movimiento.monto movimiento.cuenta_origen.save() movimiento.cuenta_destino.saldo += movimiento.monto movimiento.cuenta_destino.save() messages.success(request, 'Movimiento registrado con éxito.') return redirect('dashboard') else: datos_iniciales = {} origen = request.GET.get('origen') destino = request.GET.get('destino') categoria = request.GET.get('categoria') descripcion = request.GET.get('descripcion') monto_raw = request.GET.get('monto') # logger.warning(f"agregar_movimiento: origen: {origen}") # logger.warning(f"agregar_movimiento: destino: {destino}") # logger.warning(f"agregar_movimiento: categoria: {categoria}") # logger.warning(f"agregar_movimiento: descripcion: {descripcion}") # logger.warning(f"agregar_movimiento: monto_raw: {monto_raw}") try: if origen: datos_iniciales['cuenta_origen'] = Cuenta.objects.get(nombre=request.GET['origen'], usuario=request.user) if destino: datos_iniciales['cuenta_destino'] = Cuenta.objects.get(nombre=request.GET['destino'], usuario=request.user) if categoria: datos_iniciales['categoria'] = Categoria.objects.get(nombre=request.GET['categoria']) if descripcion: datos_iniciales['descripcion'] = descripcion if monto_raw: # Reemplaza coma por punto, elimina símbolos y espacios monto_normalizado = re.sub(r'[^\d,.-]', '', monto_raw).replace(',', '.') try: datos_iniciales['monto'] = str(Decimal(monto_normalizado).quantize(Decimal('0.01'))) except InvalidOperation: pass # Ignorar monto malformado logger_info.warning(f"monto: mal formado {datos_iniciales['monto']}") except (Cuenta.DoesNotExist, InvalidOperation): pass # logger.warning(f"agregar_movimiento: datos iniciales: {datos_iniciales}") cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre') form = MovimientoForm(initial=datos_iniciales) form.fields['cuenta_origen'].queryset = cuentas form.fields['cuenta_destino'].queryset = cuentas return render(request, 'agregar_movimiento.html', {'form': form}) @login_required def editar_movimiento(request, movimiento_id): movimiento = get_object_or_404(Movimiento, id=movimiento_id, usuario=request.user) cuenta_original_origen = movimiento.cuenta_origen cuenta_original_destino = movimiento.cuenta_destino monto_original = movimiento.monto if request.method == 'POST': form = MovimientoForm(request.POST, instance=movimiento) cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre') form.fields['cuenta_origen'].queryset = cuentas form.fields['cuenta_destino'].queryset = cuentas # Revertir efecto anterior # Cuenta origen cuenta_original_origen.saldo += monto_original cuenta_original_origen.save() # Cuenta destino cuenta_original_destino.saldo -= monto_original cuenta_original_destino.save() if form.is_valid(): movimiento_nuevo = form.save(commit=False) # Aplicar nuevo efecto # con esta modificación, que lo deja como estaba originalmente, parece que está arreglado. # Si cambio el importe, se ajustan los saldos origen y destino apropiadamente. # Si cambia una de las cuentas, también queda bien reflelado. # Si intercambio las cuentas, es decir, la origen pasa a ser la destino y viceversa, también queda bien reflejado. movimiento_nuevo.usuario = request.user movimiento_nuevo.save() movimiento_nuevo.cuenta_origen.saldo -= movimiento_nuevo.monto # ...tomar como valor del saldo el de la nueva cuenta. movimiento_nuevo.cuenta_origen.save() movimiento_nuevo.cuenta_destino.saldo += movimiento_nuevo.monto # ...tomar como valor del saldo el de la nueva cuenta. movimiento_nuevo.cuenta_destino.save() return redirect('dashboard') else: form = MovimientoForm(instance=movimiento) cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre') form.fields['cuenta_origen'].queryset = cuentas form.fields['cuenta_destino'].queryset = cuentas return render(request, 'editar_movimiento.html', {'form': form}) @login_required def eliminar_movimiento(request, movimiento_id): movimiento = get_object_or_404(Movimiento, id=movimiento_id, usuario=request.user) if request.method == 'POST': # Revertir efecto del movimiento antes de borrarlo movimiento.cuenta_origen.saldo += movimiento.monto movimiento.cuenta_origen.save() movimiento.cuenta_destino.saldo -= movimiento.monto movimiento.cuenta_destino.save() movimiento.delete() return redirect('dashboard') return render(request, 'eliminar_movimiento.html', {'movimiento': movimiento}) @login_required def duplicar_movimiento(request, movimiento_id): original = get_object_or_404(Movimiento, id=movimiento_id, usuario=request.user) # Crear una copia del movimiento sin guardar movimiento_nuevo = Movimiento( cuenta_origen=original.cuenta_origen, cuenta_destino=original.cuenta_destino, monto=original.monto, fecha=now(), descripcion=original.descripcion, usuario=request.user, categoria=original.categoria, ) if request.method == 'POST': form = MovimientoForm(request.POST, instance=movimiento_nuevo) cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre') form.fields['cuenta_origen'].queryset = cuentas form.fields['cuenta_destino'].queryset = cuentas if form.is_valid(): nuevo = form.save(commit=False) nuevo.usuario = request.user nuevo.save() # Ajustar saldos nuevo.cuenta_origen.saldo -= nuevo.monto nuevo.cuenta_origen.save() nuevo.cuenta_destino.saldo += nuevo.monto nuevo.cuenta_destino.save() messages.success(request, 'Movimiento duplicado con éxito.') return redirect('dashboard') else: form = MovimientoForm(instance=movimiento_nuevo) cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre') form.fields['cuenta_origen'].queryset = cuentas form.fields['cuenta_destino'].queryset = cuentas return render(request, 'duplicar_movimiento.html', {'form': form}) # Cuentas @login_required def listar_cuentas(request): cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre') # desde aquí.... form_filtro = FiltroForm(request.GET or None) fecha_fin = timezone.localtime().strftime('%Y-%m-%d') fecha_inicio = timezone.localtime().date().replace(day=1).strftime('%Y-%m-%d') if form_filtro.is_valid(): busqueda = form_filtro.cleaned_data.get('buscar') if busqueda: cuentas = cuentas.filter( db_models.Q(nombre__icontains=busqueda) ) # hasta aquí, hay que quitarlo # en el render hay que pasar solo cuentas. return render(request, 'listar_cuentas.html', {'cuentas': cuentas, 'form_filtro': form_filtro, 'fecha_inicio': fecha_inicio, 'fecha_fin': fecha_fin }) # API def api_datos_completos(request): cuentas = list(Cuenta.objects.values('id', 'nombre', 'tipo', 'saldo', 'usuario__nombre', 'favorita', 'saldo_inicial')) categorias = list(Categoria.objects.values('id', 'nombre')) movimientos = [ { 'id': m.id, 'usuario': m.usuario.nombre, 'cuenta_origen': m.cuenta_origen.nombre, 'cuenta_destino': m.cuenta_destino.nombre, 'monto': float(m.monto), 'fecha': m.fecha.isoformat(), 'descripcion': m.descripcion, 'categoria': m.categoria.nombre if m.categoria else None, } for m in Movimiento.objects.select_related('cuenta_origen', 'cuenta_destino', 'usuario', 'categoria') ] movs_frecuentes = [ { 'id': m.id, 'nombre': m.nombre, 'cuenta_origen': m.cuenta_origen.nombre, 'cuenta_destino': m.cuenta_destino.nombre, 'monto': float(m.monto), 'categoria': m.categoria.nombre if m.categoria else None, 'descripcion': m.descripcion, 'usuario': m.usuario.nombre, } for m in MovimientoFrecuente.objects.select_related('cuenta_origen', 'cuenta_destino', 'usuario', 'categoria') ] movs_recurrentes = [ { 'id': m.id, 'usuario': m.usuario.nombre, 'cuenta_origen': m.cuenta_origen.nombre, 'cuenta_destino': m.cuenta_destino.nombre, 'monto': float(m.monto), 'categoria': m.categoria.nombre if m.categoria else None, 'descripcion': m.descripcion, 'frecuencia': m.frecuencia, 'dia_del_mes': m.dia_del_mes, 'dia_semana': m.dia_semana, 'semana': m.semana, 'activa': m.activa, } for m in TransaccionRecurrente.objects.select_related('cuenta_origen', 'cuenta_destino', 'usuario', 'categoria') ] return JsonResponse({ 'cuentas': cuentas, 'categorias': categorias, 'movimientos': movimientos, 'movimientos_frecuentes': movs_frecuentes, 'transacciones_recurrentes': movs_recurrentes, }, safe=False) @login_required def ejecutar_recurrentes(request): procesar_transacciones_recurrentes() return redirect('dashboard') @csrf_exempt def api_ejecutar_recurrentes(request): token_enviado = request.headers.get('X-API-KEY') or request.GET.get('token') # Usar token dedicado para la API de informes if token_enviado != settings.RECURRENTES_API_TOKEN: logger_info.warning('api_ejecutar_recurrentes: intento no autorizado', extra={'ip': request.META.get('REMOTE_ADDR'), 'cuenta_id': request.GET.get('cuenta_id')}) return JsonResponse({'error': 'No autorizado'}, status=403) procesar_transacciones_recurrentes(hoy=now().date()) return JsonResponse({'ok': True, 'mensaje': 'Transacciones recurrentes ejecutadas'}) @login_required def resumen_saldos_mensuales(request): cuentas = Cuenta.objects.filter(usuario=request.user).order_by('nombre') movimientos = Movimiento.objects.filter(usuario=request.user).order_by('fecha') # Diccionario: {(cuenta_id, año, mes): saldo} saldos_mensuales = defaultdict(lambda: 0) # Diccionario para llevar el saldo acumulado saldos_acumulados = {cuenta.id: float(cuenta.saldo_inicial) for cuenta in cuentas} # Obtener todos los meses presentes en movimientos meses_presentes = set() for mov in movimientos: anio = mov.fecha.year mes = mov.fecha.month clave = (mov.cuenta_origen.id, anio, mes) meses_presentes.add((anio, mes)) # Resta al origen saldos_acumulados[mov.cuenta_origen.id] -= float(mov.monto) saldos_mensuales[clave] = saldos_acumulados[mov.cuenta_origen.id] # Suma al destino clave_dest = (mov.cuenta_destino.id, anio, mes) saldos_acumulados[mov.cuenta_destino.id] += float(mov.monto) saldos_mensuales[clave_dest] = saldos_acumulados[mov.cuenta_destino.id] # Ordenar meses meses_ordenados = sorted(meses_presentes) return render(request, 'resumen_saldos_mensuales.html', { 'cuentas': cuentas, 'meses': meses_ordenados, 'saldos_mensuales': saldos_mensuales, }) # Informes @login_required def informe_cuenta(request): cuenta_id = request.GET.get('cuenta_id') cuenta = get_object_or_404(Cuenta, id=cuenta_id) # 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)}) form = FiltroInformeCuentaForm(request.GET or None) if form.is_valid(): # obtener fechas desde GET o establecer por defecto (mes actual) hoy = timezone.localtime() primer_dia = hoy.replace(day=1) fecha_inicio_str = request.GET.get('fecha_inicio') fecha_fin_str = request.GET.get('fecha_fin') if fecha_inicio_str and fecha_fin_str: fecha_inicio = datetime.strptime(fecha_inicio_str, '%Y-%m-%d').date() fecha_fin = datetime.strptime(fecha_fin_str, '%Y-%m-%d').date() else: fecha_inicio = primer_dia fecha_fin = hoy # Convertir a datetime aware para evitar warnings fecha_inicio_dt = timezone.make_aware(datetime.combine(fecha_inicio, time.min)) fecha_fin_dt = timezone.make_aware(datetime.combine(fecha_fin, time.max)) # Saldo antes del periodo movimientos_previos = Movimiento.objects.filter( fecha__lt=fecha_inicio_dt, ).filter( cuenta_origen=cuenta ).aggregate(total_origen=Sum('monto')) or {'total_origen': 0} entradas_previas = Movimiento.objects.filter( fecha__lt=fecha_inicio_dt, ).filter( cuenta_destino=cuenta ).aggregate(total_destino=Sum('monto')) or {'total_destino': 0} saldo_inicial = (entradas_previas['total_destino'] or 0) - (movimientos_previos['total_origen'] or 0) saldo_inicial = saldo_inicial + cuenta.saldo_inicial movimientos = Movimiento.objects.filter( Q(cuenta_origen=cuenta) | Q(cuenta_destino=cuenta), fecha__range=(fecha_inicio_dt, fecha_fin_dt) ).order_by('fecha') # Cálculo de saldos después de cada movimiento saldo = saldo_inicial movimientos_con_saldo = [] for mov in movimientos: if mov.cuenta_origen == cuenta: saldo -= mov.monto tipo = 'Gasto' elif mov.cuenta_destino == cuenta: saldo += mov.monto tipo = 'Ingreso' movimientos_con_saldo.append({ 'id': mov.id, 'fecha': mov.fecha, 'descripcion': mov.descripcion, 'monto': mov.monto, 'tipo': tipo, 'desde': mov.cuenta_origen.nombre if mov.cuenta_origen else 'N/A', 'hasta': mov.cuenta_destino.nombre if mov.cuenta_destino else 'N/A', 'saldo_despues': saldo, }) # Renderizado del PDF o del HTML como prefieras context = { 'cuenta': cuenta, 'movimientos_con_saldo': movimientos_con_saldo, 'fecha_inicio': fecha_inicio_str, 'fecha_fin': fecha_fin_str, 'saldo_inicial': saldo_inicial, } return render(request, 'informe_cuenta.html', context) @login_required def informe_cuenta_pdf(request): cuenta_id = request.GET.get('cuenta_id') cuenta = get_object_or_404(Cuenta, id=cuenta_id, usuario=request.user) # obtener fechas desde GET o establecer por defecto (mes actual) hoy = timezone.localtime() primer_dia = hoy.replace(day=1) fecha_inicio_str = request.GET.get('fecha_inicio') fecha_fin_str = request.GET.get('fecha_fin') if fecha_inicio_str and fecha_fin_str: fecha_inicio = datetime.strptime(fecha_inicio_str, '%Y-%m-%d').date() fecha_fin = datetime.strptime(fecha_fin_str, '%Y-%m-%d').date() else: fecha_inicio = primer_dia fecha_fin = hoy # Convertir a datetime aware para evitar warnings fecha_inicio_dt = timezone.make_aware(datetime.combine(fecha_inicio, time.min)) fecha_fin_dt = timezone.make_aware(datetime.combine(fecha_fin, time.max)) # Saldo antes del periodo movimientos_previos = Movimiento.objects.filter( fecha__lt=fecha_inicio_dt, ).filter( cuenta_origen=cuenta ).aggregate(total_origen=Sum('monto')) or {'total_origen': 0} entradas_previas = Movimiento.objects.filter( fecha__lt=fecha_inicio_dt, ).filter( cuenta_destino=cuenta ).aggregate(total_destino=Sum('monto')) or {'total_destino': 0} saldo_inicial = (entradas_previas['total_destino'] or 0) - (movimientos_previos['total_origen'] or 0) saldo_inicial = saldo_inicial + cuenta.saldo_inicial movimientos = Movimiento.objects.filter( Q(cuenta_origen=cuenta) | Q(cuenta_destino=cuenta), fecha__range=(fecha_inicio_dt, fecha_fin_dt) ).order_by('fecha') saldo = saldo_inicial historial = [] for mov in movimientos: if mov.cuenta_origen == cuenta: saldo -= mov.monto tipo = 'Gasto' elif mov.cuenta_destino == cuenta: saldo += mov.monto tipo = 'Ingreso' else: tipo = 'Otro' historial.append({ 'fecha': mov.fecha, 'desde': mov.cuenta_origen.nombre if mov.cuenta_origen else 'N/A', 'hasta': mov.cuenta_destino.nombre if mov.cuenta_destino else 'N/A', 'descripcion': mov.descripcion, 'monto': mov.monto, 'tipo': tipo, 'saldo_despues': saldo, }) template = get_template('informe_cuenta_pdf.html') html = template.render({'cuenta': cuenta, 'saldo_inicial': saldo_inicial, 'fecha_inicio': fecha_inicio_str, 'fecha_fin': fecha_fin_str, 'historial': historial}) with tempfile.NamedTemporaryFile(delete=True) as output: HTML(string=html).write_pdf(output.name) output.seek(0) response = HttpResponse(output.read(), content_type='application/pdf') response['Content-Disposition'] = f'filename=informe_{cuenta.nombre}.pdf' return response