TODO — Revisión de código #13
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
TODO — Revisión de código (expenses_manager)
Revisión completa del repositorio
expenses_manager(Django). Incluye bugs reales, riesgos de seguridad, huecos funcionales y deuda técnica encontrados al leermodels.py,views.py,forms.py,urls.py,settings.py, el comandoseed_demo, las plantillas, los tests, elJenkinsfileyrequirements.txt.Los puntos marcados con 🔴 son bugs o riesgos reales (algo se rompe o es inseguro). Los marcados con 🟡 son mejoras de robustez/mantenibilidad, no bugs confirmados.
Progreso
Resueltos hasta ahora: 28 de 29 puntos.
fuel_createsinreturnen caso de POST inválidogoal_deletesin protección dePOSTfuel_createsin filtrar categoría "gasolina" porowneraccount_list.htmlsettings/index.html(enlace roto)ExpenseForm.Meta.widgets(checkboxes de tags no se aplicaban)seed_demono persisteis_staff/is_superuserSECRET_KEYcon fallback inseguro (ahora falla explícitamente en prod)STATIC_ROOT(ya existía en prod, ahora unificado en el repo)LOGGINGa stdout paradocker logsDB_ENGINE; prod ya usaba Postgres)requirements.txt(revisado: sí se usan)ValueErroren el filtro de tags deexpense_list(500 con?tag=abc)except:genérico en el dashboard (ahora logueado con traceback)views.pyurls.pyraíz/accounts/(cuentas financieras →/finance-accounts/)category_edit+category_delete)CategoryForm(una categoría ya no puede ser su propio padre)fuel_delete+ edición desde el listado de repostajes y vuelta al origenGoalreplanteado: tipos pago / presupuesto / ahorro, con periodo y fecha de iniciomonthly_balance()(ya estaba hecho) ymonthly_net()optimizadosFuelEntryFormrefactorizado aModelForm(deja de duplicar campos deExpense)psycopg2-binarysobrante derequirements.txtHito importante: el
settings.pyde producción del NAS y el del repo estaban divergidos (Postgres, whitenoise, CSRF_TRUSTED_ORIGINS solo existían en el NAS). Ahora hay un únicosettings.pyversionado controlado por variables de entorno (.env), desplegado y funcionando en producción.Único punto sin cerrar: la mejora del pipeline de Jenkins (build de la imagen Docker + despliegue automático desde
main), aplazada a propósito porque el despliegue se hace a mano de momento. La contraseña hardcodeada deseed_demoqueda como riesgo asumido (uso local, mitigada con la guarda deDEBUG).Bugs y riesgos con prioridad alta
🔴
fuel_createno responde si el formulario POST es inválido. Elreturn render(...)del caso GET está indentado dentro delelse, así que si llega un POST con datos inválidos la función no llega a ningúnreturny Django lanza un error (ValueError: didn't return an HttpResponse). Hay que sacar eserenderfuera delif/else, como en el resto de vistas. (Resuelto en ramadev.)🔴
goal_deleteborra el objetivo con una simple petición GET. A diferencia deexpense_delete,tag_delete,account_deleteeincome_delete, esta vista no compruebarequest.method == "POST". Cualquier enlace, precarga del navegador o bot puede borrar un objetivo sin confirmación. Añadir la comprobación de POST (y su plantilla de confirmación, como en el resto de recursos). (Resuelto en ramadev: se añadiógoals/confirm_delete.html.)🔴 Falta la plantilla
settings/index.html. Confirmado revisando el repo: no existe ningún directoriotemplates/settings/. La ruta/settings/(vistasettings_index) lanzaTemplateDoesNotExisten cuanto se visita. Es un enlace roto ahora mismo en producción. (Resuelto en ramadev: se creósettings/index.htmlcomo página hub con enlaces a Categorías/Etiquetas/Objetivos, ya que el menú de navegación no la usaba directamente.)🔴
fuel_createbusca la categoría "gasolina" sin filtrar por usuario. UsaCategory.objects.get(slug="gasolina")sinowner=request.user. Como el slug no es único entre usuarios (elunique_togetherdeCategoryincluyeowner), esto puede lanzarMultipleObjectsReturnedsi dos usuarios tienen esa categoría, o coger la categoría de otro usuario. (Resuelto en ramadevconget_or_create(slug="gasolina", owner=request.user, defaults={"name": "Gasolina"}).)🔴 Typo en
ExpenseForm.Meta.widgets: la clave es"widget"en vez de"tags". Como"widget"no es un campo del formulario, elCheckboxSelectMultiple()pensado para las tags nunca se aplica. El campotagsse renderiza con el select múltiple por defecto, no con checkboxes. (Resuelto en ramadev: se corrigió la clave y, de paso, se rediseñóexpense_form.htmlpara renderizar las tags como "chips" en fila con scroll, en vez de la lista vertical por defecto de Django.)🔴 Comando
seed_democrea un superusuario con contraseña débil hardcodeada (demo1234). No hay ninguna guarda que impida ejecutar este comando en producción. Si se ejecuta ahí por error, queda un superusuario con credenciales conocidas. (Aceptado como riesgo asumido: se usa solo en local. Mitigado parcialmente por la guarda deDEBUGañadida en el punto anterior, que impide ejecutar el comando siDEBUG=False.)🔴
seed_demono persisteis_staff/is_superusersi el usuario demo ya existe. En la ramaelse(usuario ya creado) se asignan esos atributos en el objeto en memoria pero nunca se llama a.save(), así que no se guardan en la base de datos. (Resuelto en ramadev: se movió la asignación deis_staff/is_superuserantes del.save()en ambas ramas — también afectaba a la ramacreated, que tampoco los persistía. Además se añadió una guardaif not settings.DEBUG: raise CommandError(...)para que el comando no pueda ejecutarse en producción.)Seguridad y configuración de despliegue (
settings.py)🔴
SECRET_KEYtiene un fallback inseguro hardcodeado ('fallback-secret-key-for-dev') que se usa silenciosamente si la variable de entorno no está definida. Si el.envfalla al desplegar en el NAS, la app arranca igualmente con una clave insegura y conocida, sin avisar. Mejor que falle explícitamente siSECRET_KEYno está definido en producción. (Resuelto: elsettings.pyunificado lanzaImproperlyConfiguredsi faltaSECRET_KEYconDEBUG=False; en dev usa un fallback explícitamente marcado como inseguro. Desplegado en producción.)🟡 No hay
STATIC_ROOTdefinido. Necesario paracollectstaticen un despliegue real vía Docker/gunicorn (sinrunserver). Revisar cómo se están sirviendo los estáticos ahora mismo en el contenedor. (Resuelto: ya existía en elsettings.pyde producción del NAS —divergido del repo— junto con whitenoise; ahora está unificado y versionado en el repo.)🟡 Faltan ajustes de hardening para producción:
CSRF_TRUSTED_ORIGINS,SESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE,SECURE_SSL_REDIRECT. Relevante porque el dominiofinanzas.kuijper.esprobablemente está detrás de un reverse proxy HTTPS. (Resuelto: elsettings.pyunificado activaSESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE,SECURE_SSL_REDIRECTySECURE_PROXY_SSL_HEADERcuandoDEBUG=False;CSRF_TRUSTED_ORIGINSyALLOWED_HOSTSvan por variable de entorno. Desplegado y verificado tras el reverse proxy nginx de Synology.)🟡 No hay configuración de
LOGGING. ConDEBUG=Falseen producción, los errores no quedan registrados en ningún sitio visible, dificultando el diagnóstico de fallos reales (como el desettings/index.htmlde arriba). (Resuelto:LOGGINGa consola/stdout, visible condocker-compose logs web.)🟡 SQLite como base de datos en un despliegue de uso continuo. Válido para uso personal, pero conviene documentar la estrategia de backups y tener en cuenta las limitaciones de concurrencia si el uso crece. (Punto revisado: producción usaba en realidad PostgreSQL en un
settings.pydivergido en el NAS. Ahora unificado:DB_ENGINE=postgresqlpor env en el NAS, SQLite por defecto en local. Queda como tarea aparte documentar la estrategia de backups de Postgres.)🟡 Falta el flujo de "olvidé mi contraseña". Solo está implementado el cambio de contraseña estando ya logueado (
password_change_*.html). No existen las plantillaspassword_reset_*.htmlque requieren las URLs dedjango.contrib.auth, y tampoco hayEMAIL_BACKENDconfigurado para poder enviar el email de recuperación. (Decisión consciente de diseño: la app la usan 2 personas y no se monta recuperación por email para no mantener infraestructura de correo. El reset lo hace el admin bajo petición condocker-compose exec web python manage.py changepassword <usuario>. En vez de dejar el hueco, se añadió una pantalla informativaregistration/password_help.html(rutapassword-help/víaTemplateView, enlazada desde el login) que indica al usuario que contacte con el administrador — evita cualquierTemplateDoesNotExisty da una salida clara.)urls.py(proyecto raíz)🟡 Colisión de prefijo
/accounts/. Se registrapath('accounts/', include(django.contrib.auth.urls))(login/logout/password cambio) y, a la vez, tu propia app usa/accounts/,/accounts/new/, etc. para el CRUD de cuentas financieras (montado víapath('', include('expenses.urls'))). Ahora mismo no colisionan literalmente porque las rutas de auth son más específicas, pero es un diseño frágil: dos conceptos distintos ("cuenta de usuario" y "cuenta financiera") comparten namespace de URL. (Resuelto en ramadev: las cuentas financieras pasan a/finance-accounts/.... Losname=de las rutas no cambiaron, así que ninguna plantilla necesitó tocarse.)🟡 Registro duplicado de las auth urls. Hay dos líneas que registran lo mismo:
path('accounts/', include(urls))(importando el módulodjango.contrib.auth.urlsdirectamente) ypath('accounts/', include('django.contrib.auth.urls'))(por string). Una de las dos sobra. (Resuelto en ramadev: se conserva solo la forma por string, antes deexpenses.urlspara mantener la precedencia actual, y se eliminó el importfrom django.contrib.auth import urlsque quedaba sin uso.)views.py— robustez🟡
except:genérico endashboard(bloque deaccounts_chartsal llamar aacc.monthly_balance(selected_year)). Silencia cualquier error real, dificultando el debug. Capturar la excepción concreta esperada, o al menos loguearla. (Resuelto:except Exception+logger.exception(...)con id de cuenta y año. El dashboard sigue sin romperse, pero el traceback aparece ahora endocker-compose logs web.)🟡
tag_ids = [int(t) for t in tag_ids]enexpense_listno manejaValueError. Un parámetrotagno numérico en la URL rompe la vista con un error 500, a diferencia del resto de filtros que usan el helper_get_int(que sí es seguro). (Resuelto: se filtran los valores no numéricos reutilizando_get_int— un valor inválido se ignora en vez de romper la vista, mismo comportamiento que el resto de filtros.)🟡 Inconsistencia en el borrado de recursos.
account_deletehace soft-delete (active=False), mientras queexpense_delete,tag_deleteeincome_deleteborran de verdad. Puede ser intencional (para no romper históricos de saldo), pero merece una decisión explícita y, si es así, documentarla en el código. (Resuelto en ramadev: decisión confirmada como intencional y documentada con un docstring enaccount_deleteque explica por qué las cuentas se desactivan en vez de borrarse —preservar los históricos de saldo de gastos/ingresos que la referencian—. La parte de UX (distinguir inactivas en el listado) ya se había resuelto por separado.)🟡 Las cuentas inactivas (
active=False) no se distinguían visualmente enaccount_list.html. Al "eliminar" una cuenta (soft-delete), esta seguía apareciendo en el listado sin ningún cambio visible más allá de unTrue/Falseen crudo en una columna poco visible — daba la sensación de que el borrado "no funcionaba". (Resuelto en ramadev: columna "Estado" con badge Activa/Inactiva, fila atenuada con CSS.row-inactive, y el botón "Eliminar" ya no se muestra para cuentas ya inactivas.)🟡 Imports sin usar / ruido en
views.py:truediv,django.template.context,is_valid_ipv6_address, y una línea comentada con un import mal escrito (dateutlien vez dedateutil). (Resuelto: eliminados esos cuatro, másdjango.contrib.auth.login(tampoco se usaba), y reordenado el bloque de imports al convenio stdlib → django → locales. En el mismo commit se limpiaron el dictMONTHSsin usar, la doble asignaciónby_category_qs = by_category, la variablechart_typeque nunca llegaba al contexto, y se subióimport calendaral principio del archivo.)🟡
category_listno tienecategory_editnicategory_delete, a diferencia detag/account/goal, que sí tienen el CRUD completo. Probablemente pendiente de implementar. (Resuelto en ramadev: añadidas ambas vistas con sus rutas y plantillas (categories/form.htmlycategories/confirm_delete.html), más la columna de acciones encategories/list.html. El borrado capturaProtectedError(categorías con gastos no se pueden borrar) y avisa en la confirmación de las subcategorías y objetivos que se eliminarían en cascada.)🟡 Falta
fuel_delete. El recursofueltienecreate/list/editpero no borrado, a diferencia del resto de recursos del proyecto. (Resuelto en ramadev: añadida la vistafuel_delete(recibe el pk delExpense, comofuel_edit) con su ruta yfuel/confirm_delete.html; borrar el gasto elimina elFuelEntryen cascada. Además se añadieron los enlaces Editar/Eliminar enfuel/list.html, que antes no existían, y un helper_redirect_backcon?next=validado (url_has_allowed_host_and_scheme) para volver a la pantalla de origen — gastos o repostajes — tras editar o borrar. Enexpense_list.htmllos enlaces se eligen segúnexpense.fuel_data(no por el slug de categoría, que puede quedar desfasado).)models.py🟡
Account.monthly_balance()recorre mes a mes con queries repetidas. Funciona, pero es optimizable con una sola agregación en vez de iterar los 12 meses. (Punto obsoleto: ya lo resolviste en el commit de junio "Fixed function monthly_balance" — el método actual hace 4 consultas y recorre los meses en memoria. En esta revisión se detectó quemonthly_net()sí conservaba el patrón viejo (24 consultas por cuenta y año) y se optimizó igual, quedando en 2.)🟡
Goal.progress()no filtra por cuenta ni por rango de fechas, solo por categoría. Si la intención es que la meta sea mensual o esté ligada a una cuenta concreta, el cálculo actual no lo refleja — habría que decidir el comportamiento esperado y ajustarlo. (Resuelto replanteando el modelo entero:Goalgana un campokindcon tres tipos —pago/deuda, presupuesto y ahorro—, másperiod(mensual/anual, para presupuestos),start_date,account(para ahorro) einclude_subcategories.progress()despacha según el tipo y acota por fecha; se añadieronbar_width()(evita que la barra desborde al pasar del 100%) yprogress_state()(colorea según el tipo: en un presupuesto acercarse al límite es alarma, no logro).GoalFormexpone los campos nuevos y valida qué es obligatorio según el tipo. Migración0010_...— soloAddField/AlterField, sin pérdida de datos. El ahorro se mide provisionalmente con el saldo de la cuenta asociada: es la única rama a tocar cuando llegue el módulo de inversiones.)forms.py🟡
CategoryFormno excluye la propia categoría del queryset deparental editar. Permite que una categoría se asigne a sí misma como padre, o crear ciclos (A → B → A), sin validación que lo impida. (Resuelto en ramadev: al editar, el desplegable excluye la propia categoría y toda su descendencia (a cualquier profundidad), más unclean_parentcomo validación de respaldo. Ojo: durante la implementación se produjo un fallo temporal en el queparentse guardaba siempre comoNone; el síntoma clásico es queclean_parentno devuelva el valor. Conviene un test que fije este comportamiento.)🟡
FuelEntryFormes unforms.Formplano que duplica a mano los campos deExpense(fecha, importe, cuenta). Funciona, pero cualquier cambio en esos modelos hay que replicarlo manualmente aquí. (Resuelto en ramadev: convertido enModelFormdeExpense(camposdate/amount/accountderivados del modelo) + los campos propios deFuelEntry(odometer/liters) añadidos aparte, con precarga al editar.fuel_create/fuel_editsimplificados conform.save(commit=False)en lugar de copiar campo por campo. Cubierto portest_fuel.py.)Tests
Expensebásico,Income,dashboardy un sanity check, pero ninguno paraAccount,FuelEntry,Goal,Tagni la lógica de slug deCategory. (Resuelto: suite ampliada a 64 tests en verde. Nuevoconftest.pycon fixtures (user,auth_client,account,category) y nuevos archivostest_accounts.py,test_fuel.py,test_goals.py,test_categories.py,test_expense_list.pyytest_deletes.py. Incluye tests de regresión para todos los bugs corregidos en esta revisión: POST inválido enfuel_create, borrados que exigen POST,?tag=no numérico, persistencia deparentenCategoryForm,PROTECTal borrar categorías con gastos y borrado en cascada deFuelEntry. Se arregló ademástest_dashboard_filters_by_year, que llevaba meses fallando: la clave de contextoby_monthse renombró achart_dataen el commit de junio "Added the comparative table" y el test se quedó desactualizado.)Dependencias y CI/CD
🟡
requirements.txtincluyepsycopg(v3) ypsycopg2-binary(v2) — drivers de PostgreSQL instalados pero sin usar, ya quesettings.pysigue en SQLite. O es preparación para migrar a Postgres (documentarlo), o son dependencias muertas que limpiar. (Resuelto en ramadev: Postgres sí se usa en producción, conpsycopgv3 (+psycopg-binary). Se eliminópsycopg2-binary==2.9.11, el driver v2 antiguo que no usaba nadie. De paso se había fijado anteswhitenoise==6.12.0.)🟡 El pipeline de Jenkins solo ejecuta los tests, no construye ni valida la imagen Docker con la que realmente se despliega en el NAS. Un
Dockerfileroto no lo detectaría el CI actual. (Aplazado a propósito: de momento el despliegue se hace a mano. Alcance deseado cuando se retome: (1) comprobar que el pipeline falla de verdad si fallan los tests — la suite llevaba meses en rojo sin que saltara ninguna alarma; (2) construir la imagen Docker en CI para detectar unDockerfileroto antes de desplegar; (3) despliegue automático en el NAS cuando haya cambios enmainy los tests pasen.)Resumen rápido
settings.py)urls.py(proyecto raíz)views.py— robustezmodels.pyforms.pyResueltos: 28 de 29.