Compare commits

..

No commits in common. "d88276556a829265577ec539c89a5512af1b3a80" and "e18b1f48d982c3655277169c4bd1cf37ea3fa6d6" have entirely different histories.

36 changed files with 550 additions and 1378 deletions

View File

@ -1,17 +0,0 @@
# Django
DEBUG=True
SECRET_KEY=genera-una-clave-aqui
# Hosts (separados por comas). En producción, tu dominio real.
ALLOWED_HOSTS=localhost,127.0.0.1
CSRF_TRUSTED_ORIGINS=
# Base de datos.
# En local puedes dejar DB_ENGINE vacío o en sqlite3 para usar SQLite sin más.
# Para PostgreSQL, pon DB_ENGINE=postgresql y rellena el resto.
DB_ENGINE=sqlite3
DB_NAME=
DB_USER=
DB_PASSWORD=
DB_HOST=localhost
DB_PORT=5432

View File

@ -122,15 +122,15 @@ class GoalForm(forms.ModelForm):
class Meta: class Meta:
model = Goal model = Goal
fields = [ fields = [
"name", 'name',
"kind", 'kind',
"target_amount", 'target_amount',
"category", 'category',
"include_subcategories", "include_subcategories",
"account", "account",
"start_date", "start_date",
"period", "period",
"show_on_home", "show_on_home"
] ]
widgets = { widgets = {
"start_date": forms.DateInput( "start_date": forms.DateInput(

View File

@ -5,6 +5,7 @@ from django.conf import settings
from django.db.models import Sum from django.db.models import Sum
from functools import cached_property from functools import cached_property
from django.utils.text import slugify from django.utils.text import slugify
from django.db.models.fields import related
from django.db.models.functions import ExtractMonth from django.db.models.functions import ExtractMonth

File diff suppressed because it is too large Load Diff

View File

@ -1,88 +0,0 @@
(function () {
function getActiveTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
function getChartColors() {
var styles = getComputedStyle(document.documentElement);
var theme = getActiveTheme();
return {
theme: theme,
gridColor: styles.getPropertyValue('--chart-grid-color').trim() || 'rgba(0, 0, 0, 0.1)',
tickColor: styles.getPropertyValue('--color-text-muted').trim() || '#6b6560',
primaryColor: styles.getPropertyValue('--color-primary').trim() || '#0f766e',
primaryFill: styles.getPropertyValue('--chart-fill-color').trim() || 'rgba(15, 118, 110, 0.2)',
palette: ['#ff6384', '#36a2eb', '#cc65fe', '#ffce56', '#4bc0c0', '#ffa1b5']
};
}
// Aplica los colores de grid/ticks a las escalas cartesianas (x/y) de un gráfico.
// Se usa tanto al crear el gráfico (colores embebidos en las opciones iniciales)
// como al refrescar el tema en caliente sobre un gráfico ya existente.
function applyCartesianColors(chart, colors) {
var scales = chart.options.scales;
if (!scales) {
return;
}
Object.keys(scales).forEach(function (axis) {
var scale = scales[axis];
if (!scale) {
return;
}
// Object.assign crea un objeto grid/ticks nuevo en vez de mutar en el
// sitio (scale.grid.color = ...). Chart.js reconstruye internamente los
// objetos de opciones de un gráfico 'line' ya creado, y mutar una
// propiedad suelta sobre esa estructura reactiva provoca
// "TypeError: t.startsWith is not a function" al redibujar. Reemplazar
// el objeto entero evita tocar esas referencias internas.
if (typeof colors.gridColor === 'string' && colors.gridColor) {
scale.grid = Object.assign({}, scale.grid, { color: colors.gridColor });
}
if (typeof colors.tickColor === 'string' && colors.tickColor) {
scale.ticks = Object.assign({}, scale.ticks, { color: colors.tickColor });
}
});
}
// Los colores de tema se fijan en las opciones al CREAR cada gráfico (evita el
// bug de mutar un gráfico 'line' recién construido). Para que el gráfico
// reaccione a un cambio de tema posterior sin recrearlo, cada plantilla registra
// aquí su instancia junto con un callback que sabe cómo reaplicar sus colores.
var registeredCharts = [];
function registerThemedChart(chart, applyColors) {
registeredCharts.push({ chart: chart, applyColors: applyColors });
}
function refreshThemedCharts() {
var colors = getChartColors();
registeredCharts.forEach(function (entry) {
entry.applyColors(entry.chart, colors);
// 'none' desactiva la animación de la actualización: solo repinta con
// los nuevos colores, sin la transición de entrada de datos.
entry.chart.update('none');
});
}
// Observa el atributo data-theme del <html> (lo cambia el toggle de tema) para
// refrescar todos los gráficos registrados en cuanto el usuario cambia de tema,
// sin necesidad de recargar la página.
new MutationObserver(function (mutations) {
var themeChanged = mutations.some(function (m) { return m.attributeName === 'data-theme'; });
if (themeChanged) {
refreshThemedCharts();
}
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
window.getChartColors = getChartColors;
window.applyCartesianColors = applyCartesianColors;
window.registerThemedChart = registerThemedChart;
})();

View File

@ -1,30 +0,0 @@
document.addEventListener('DOMContentLoaded', function () {
var toggle = document.getElementById('navToggle');
var nav = document.getElementById('mainNav');
if (!toggle || !nav) {
return;
}
toggle.addEventListener('click', function () {
var isOpen = nav.classList.toggle('nav--open');
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
var dropdownToggle = document.getElementById('dropdownToggle');
var dropdown = document.getElementById('settingsDropdown');
if (dropdownToggle && dropdown) {
dropdownToggle.addEventListener('click', function () {
var isOpen = dropdown.classList.toggle('dropdown--open');
dropdownToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
document.addEventListener('click', function (event) {
if (!dropdown.contains(event.target)) {
dropdown.classList.remove('dropdown--open');
dropdownToggle.setAttribute('aria-expanded', 'false');
}
});
}
});

View File

@ -1,20 +0,0 @@
document.addEventListener('DOMContentLoaded', function () {
var toggle = document.getElementById('themeToggle');
if (!toggle) {
return;
}
function applyIcon(theme) {
toggle.textContent = theme === 'dark' ? '☀️' : '🌙';
}
applyIcon(document.documentElement.getAttribute('data-theme'));
toggle.addEventListener('click', function () {
var next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
applyIcon(next);
});
});

View File

@ -1,12 +1,14 @@
{% extends "expenses/_confirm_delete.html" %} {% extends "expenses/base.html" %}
{% block title %}Categorías{% endblock %} {% block title %}
Categorías
{% endblock %}
{% block delete_heading %}Eliminar categoría{% endblock %} {% block content %}
<h2>Eliminar categoría</h2>
{% block delete_question %}¿Seguro que quieres eliminar la categoría <strong>{{ category.name }}</strong>?{% endblock %} <p>¿Seguro que quieres eliminar la categoría <strong>{{ category.name }}</strong>?</p>
{% block delete_warnings %}
{% if expense_count %} {% if expense_count %}
<p class="form-errors"> <p class="form-errors">
Esta categoría tiene {{ expense_count }} gasto{{ expense_count|pluralize }} asociado{{ expense_count|pluralize }} Esta categoría tiene {{ expense_count }} gasto{{ expense_count|pluralize }} asociado{{ expense_count|pluralize }}
@ -16,7 +18,7 @@
{% if children %} {% if children %}
<p class="form-errors"> <p class="form-errors">
Atención: también se eliminarán sus subcategorías: Atención: también se eliminarán sus subcategorías:
{% for child in children %}<strong>{{ child.name }}</strong>{% if not forloop.last %}, {% endif %}{% endfor %}. {% for child in children %}<strong>{{child.name}}</strong>{% if not forloop.last %}, {% endif %}{% endfor %}.
</p> </p>
{% endif %} {% endif %}
@ -26,6 +28,11 @@
</p> </p>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endblock %}
{% block cancel_url %}{% url 'category_list' %}{% endblock %} <form method="post">
{% csrf_token %}
{{ form.as_p }}
<button class="btn danger">Eliminar</button>
<a class="btn" href="{% url 'category_list' %}">Cancelar</a>
</form>
{% endblock %}

View File

@ -10,9 +10,7 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<div class="form-actions"> <button type="submit">Guardar</button>
<button type="submit" class="btn btn-primary">Guardar</button> <a class="btn" href="{% url 'category_list' %}">Cancelar</a>
<a class="btn btn-secondary" href="{% url 'category_list' %}">Cancelar</a>
</div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -12,13 +12,12 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="btn btn-primary">Crear</button> <button type="submit">Crear</button>
</form> </form>
<hr> <hr>
<h3>Listado</h3> <h3>Listado</h3>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -39,6 +38,5 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% endblock %}

View File

@ -1,20 +0,0 @@
{% extends "expenses/base.html" %}
{% block title %}Eliminar{% endblock %}
{% block content %}
<h1>{% block delete_heading %}Eliminar{% endblock %}</h1>
<p>{% block delete_question %}¿Seguro que quieres eliminar este elemento?{% endblock %}</p>
{% block delete_warnings %}{% endblock %}
<form method="post">
{% csrf_token %}
{% block delete_extra_fields %}{% endblock %}
<div class="form-actions">
<button class="btn btn-danger">{% block delete_button %}Eliminar{% endblock %}</button>
<a class="btn btn-secondary" href="{% block cancel_url %}{% endblock %}">Cancelar</a>
</div>
</form>
{% endblock %}

View File

@ -1,11 +1,18 @@
{% extends "expenses/_confirm_delete.html" %} {% extends "expenses/base.html" %}
{% block title %}Eliminar{% endblock %} {% block title %}Eliminar{% endblock %}
{% block content %}
{% block delete_heading %}Eliminar cuenta{% endblock %} <h1>Eliminar cuenta</h1>
{% block delete_question %}¿Seguro que quieres eliminar la cuenta <strong>{{ account.name }}</strong>?{% endblock %} <p>
¿Seguro que quieres eliminar la cuenta
<strong>{{ account.name }}</strong>
</p>
{% block delete_button %}Sí, eliminar{% endblock %} <form method="post">
{% csrf_token %}
<button class="btn">Sí, eliminar</button>
<a class="btn secondary" href="{% url 'account_list' %}">Cancelar</a>
</form>
{% block cancel_url %}{% url 'account_list' %}{% endblock %} {% endblock %}

View File

@ -14,10 +14,8 @@
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<div class="form-actions"> <button type="submit">Guardar</button>
<button type="submit" class="btn btn-primary">Guardar</button> <a class="btn secondary" href="{% url 'account_list' %}">Cancelar</a>
<a class="btn btn-secondary" href="{% url 'account_list' %}">Cancelar</a>
</div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -6,7 +6,6 @@
<a class="btn" href="{% url 'account_create' %}"> Nueva cuenta</a> <a class="btn" href="{% url 'account_create' %}"> Nueva cuenta</a>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -47,5 +46,4 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% endblock %}

View File

@ -1,34 +1,18 @@
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE>
<html lang="es"> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Expenses manager{% endblock %}</title> <title>{% block title %}Expenses manager{% endblock %}</title>
<script>
(function() {
var theme = localStorage.getItem('theme');
if (!theme) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<link rel="stylesheet" href="{% static 'expenses/css/base.css' %}"> <link rel="stylesheet" href="{% static 'expenses/css/base.css' %}">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="{% static 'expenses/js/charts.js' %}"></script>
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
</head> </head>
<body> <body>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<header class="topbar"> <header class="topbar">
<button type="button" class="nav-toggle" id="navToggle" aria-label="Abrir menú" aria-expanded="false" aria-controls="mainNav"> <nav class="nav">
<span class="nav-toggle-bar"></span>
<span class="nav-toggle-bar"></span>
<span class="nav-toggle-bar"></span>
</button>
<nav class="nav" id="mainNav">
<a href="{% url 'home' %}" class="nav-item {% if active_menu == 'home' %}active{% endif %} ">Home</a> <a href="{% url 'home' %}" class="nav-item {% if active_menu == 'home' %}active{% endif %} ">Home</a>
<a href="{% url 'dashboard' %}" class="nav-item {% if active_menu == 'dashboard' %}active{% endif %} ">Dashboard</a> <a href="{% url 'dashboard' %}" class="nav-item {% if active_menu == 'dashboard' %}active{% endif %} ">Dashboard</a>
<a href="{% url 'expense_list' %}" class="nav-item {% if active_menu == 'expenses' %}active{% endif %} ">Gastos</a> <a href="{% url 'expense_list' %}" class="nav-item {% if active_menu == 'expenses' %}active{% endif %} ">Gastos</a>
@ -37,10 +21,10 @@
<!-- <a href="{% url 'tag_list' %}" class="nav-item {% if active_menu == 'tags' %}active{% endif %} ">Etiquetas</a> --> <!-- <a href="{% url 'tag_list' %}" class="nav-item {% if active_menu == 'tags' %}active{% endif %} ">Etiquetas</a> -->
<a href="{% url 'fuel_list' %}" class="nav-item {% if active_menu == 'fuel' %}active{% endif %} ">Repostajes</a> <a href="{% url 'fuel_list' %}" class="nav-item {% if active_menu == 'fuel' %}active{% endif %} ">Repostajes</a>
<!-- <a href="{% url 'category_list' %}" class="nav-item {% if active_menu == 'categories' %}active{% endif %} ">Categorías</a> --> <!-- <a href="{% url 'category_list' %}" class="nav-item {% if active_menu == 'categories' %}active{% endif %} ">Categorías</a> -->
<div class="nav-item dropdown {% if active_menu == 'settings' %}active{% endif %}" id="settingsDropdown"> <div class="nav-item dropdown {% if active_menu == 'settings' %}active{% endif %}">
<button type="button" class="dropdown-toggle" id="dropdownToggle" aria-haspopup="true" aria-expanded="false" aria-controls="dropdownMenu">Configuraciones ▼</button> <span class="dropdown-toggle">Configuraciones ▼</span>
<div class="dropdown-menu" id="dropdownMenu"> <div class="dropdown-menu">
<a href="{% url 'category_list' %}">Categorías</a> <a href="{% url 'category_list' %}">Categorías</a>
<a href="{% url 'tag_list' %}">Etiquetas</a> <a href="{% url 'tag_list' %}">Etiquetas</a>
<a href="{% url 'goal_list' %}">Objetivos</a> <a href="{% url 'goal_list' %}">Objetivos</a>
@ -49,8 +33,6 @@
<span class="spacer"></span> <span class="spacer"></span>
<button type="button" class="theme-toggle" id="themeToggle" aria-label="Cambiar tema" title="Cambiar tema"></button>
<a href="{% url 'password_change' %}">{{ request.user.username }}</a> <a href="{% url 'password_change' %}">{{ request.user.username }}</a>
<form method="post" action="{% url 'logout' %}" class="logout-form"> <form method="post" action="{% url 'logout' %}" class="logout-form">
@ -76,7 +58,5 @@
</main> </main>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
<script src="{% static 'expenses/js/nav.js' %}" defer></script>
<script src="{% static 'expenses/js/theme.js' %}" defer></script>
</body> </body>
</html> </html>

View File

@ -1,19 +1,9 @@
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Login{% endblock %}</title> <title>{% block title %}Login{% endblock %}</title>
<script>
(function() {
var theme = localStorage.getItem('theme');
if (!theme) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<link rel="stylesheet" href="{% static 'expenses/css/base.css' %}"> <link rel="stylesheet" href="{% static 'expenses/css/base.css' %}">
</head> </head>
<body> <body>

View File

@ -41,13 +41,10 @@
<a href="{% url 'dashboard' %}?period=this_year" class="preset {% if period == 'this_year' %}active{% endif %}"> <a href="{% url 'dashboard' %}?period=this_year" class="preset {% if period == 'this_year' %}active{% endif %}">
Este año Este año
</a> </a>
</div>
{% if period %} {% if period %}
<div class="clear-filters-row"> <a href="{% url 'dashboard' %}" style="color: red; margin-left: 10px;">❌ Quitar filtros temporales</a>
<a href="{% url 'dashboard' %}" class="clear-filters">❌ Quitar filtros temporales</a>
</div>
{% endif %} {% endif %}
</div>
<br> <br>
@ -89,7 +86,7 @@
Comparar periodo anterior Comparar periodo anterior
</label> </label>
<button type="submit" class="btn btn-primary">Aplicar Filtros</button> <button type="submit">Aplicar Filtros</button>
</form> </form>
</section> </section>
<script> <script>
@ -152,10 +149,10 @@
</div> </div>
{% if selected_month == today.month and selected_year == today.year %} {% if selected_month == today.month and selected_year == today.year %}
<div class="kpi-card" style="border-left: 4px solid var(--color-warning-accent);"> <div class="kpi-card" style="border-left: 4px solid #ff9f43;">
<span class="kpi-label">Proyección fin de mes</span> <span class="kpi-label">Proyección fin de mes</span>
<span class="kpi-value">{{ projected_end_of_month|floatformat:2 }}€</span> <span class="kpi-value">{{ projected_end_of_month|floatformat:2 }}€</span>
<small style="color: var(--color-text-muted); display:block;">Basado en tu ritmo de gasto actual</small> <small style="color: gray; display:block;">Basado en tu ritmo de gasto actual</small>
</div> </div>
{% endif %} {% endif %}
</section> </section>
@ -167,16 +164,15 @@
Gastos periodo actual: <strong>{{ kpi_total|floatformat:2 }} €</strong><br> Gastos periodo actual: <strong>{{ kpi_total|floatformat:2 }} €</strong><br>
Gastos periodo anterior: <strong>{{ kpi_previous_total|floatformat:2 }} €</strong><br> Gastos periodo anterior: <strong>{{ kpi_previous_total|floatformat:2 }} €</strong><br>
Diferencia: Diferencia:
<strong style="color: {% if kpi_trend == 'up' %}var(--color-danger-accent){% else %}var(--color-success-accent){% endif %};"> <strong style="color: {% if kpi_trend == 'up' %}#d9534f{% else %}#5cb85c{% endif %};">
{% if kpi_trend == "up" %}+{% endif %}{{ kpi_difference|floatformat:2 }}€ ({{ kpi_percentage|floatformat:1 }}%) {% if kpi_trend == "up" %}+{% endif %}{{ kpi_difference|floatformat:2 }}€ ({{ kpi_percentage|floatformat:1 }}%)
</strong> </strong>
<small style="display:block; color: var(--color-text-muted);"> <small style="display:block; color:gray;">
{% if kpi_trend == "up" %}⚠️ Has gastado más que el periodo pasado. {% else %} ¡Bien! Has reducido tus gastos. {% endif %} {% if kpi_trend == "up" %}⚠️ Has gastado más que el periodo pasado. {% else %} ¡Bien! Has reducido tus gastos. {% endif %}
</small> </small>
</p> </p>
<h3>Desglose de cambios por categoría</h3> <h3>Desglose de cambios por categoría</h3>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -192,14 +188,13 @@
<td>{{ cat.category }}</td> <td>{{ cat.category }}</td>
<td>{{ cat.previous|floatformat:2 }}€</td> <td>{{ cat.previous|floatformat:2 }}€</td>
<td>{{ cat.current|floatformat:2 }}€</td> <td>{{ cat.current|floatformat:2 }}€</td>
<td style="font-weight: bold; color: {% if cat.difference > 0 %}var(--color-danger-accent){% else %}var(--color-success-accent){% endif %};"> <td style="font-weight: bold; color: {% if cat.difference > 0 %}#d9534f{% else %}#5cb85c{% endif %};">
{% if cat.difference > 0 %}+{% endif %}{{ cat.difference|floatformat:2 }}€ {% if cat.difference > 0 %}+{% endif %}{{ cat.difference|floatformat:2 }}€
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</section> </section>
{% endif %} {% endif %}
@ -208,23 +203,22 @@
<!-- ========================= --> <!-- ========================= -->
<div class="charts-container"> <div class="charts-container">
<section> <section class="chart-box" style="width: 45%; min-width: 300px; max-width: 550px;">
<h3>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h3> <h3>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h3>
<div class="chart-box"> <div style="position: relative; width: 100%; height: 350px; padding-bottom: 20px;">
<canvas id="mainChart"></canvas> <canvas id="mainChart"></canvas>
</div> </div>
</section> </section>
<section> <section class="chart-box" style="width: 30%; min-width: 280px; max-width: 400px;">
<h3>Distribución por Categorías</h3> <h3>Distribución por Categorías</h3>
<div class="chart-box"> <div style="position: relative; width: 100%; height: 350px; display: flex; align-items: center; justify-content: center">
<canvas id="categoryChart"></canvas> <canvas id="categoryChart"></canvas>
</div> </div>
</section> </section>
<section> <section>
<h3>Gastos Recientes</h3> <h3>Gastos Recientes</h3>
<div class="table-wrap">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -242,14 +236,13 @@
<td>{{ exp.account.name }}</td> <td>{{ exp.account.name }}</td>
<td>{{ exp.category.name }}</td> <td>{{ exp.category.name }}</td>
<td>{{ exp.description|default:"-" }}</td> <td>{{ exp.description|default:"-" }}</td>
<td style="color: var(--color-danger-accent); font-weight: bold;">{{ exp.amount|floatformat:2 }}</td> <td style="color: #d9534f; font-weight: bold;">{{ exp.amount|floatformat:2 }}</td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="7">No hay gastos recientes registrados</td></tr> <tr><td colspan="7">No hay gastos recientes registrados</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</section> </section>
</div> </div>
@ -259,31 +252,24 @@
const catLabels = [{% for row in by_category_chart %}"{{ row.category__name }}",{% endfor %}]; const catLabels = [{% for row in by_category_chart %}"{{ row.category__name }}",{% endfor %}];
const catData = [{% for row in by_category_chart %}{{ row.total|unlocalize }},{% endfor %}]; const catData = [{% for row in by_category_chart %}{{ row.total|unlocalize }},{% endfor %}];
const categoryChartColors = getChartColors(); new Chart(document.getElementById('categoryChart'), {
const categoryChart = new Chart(document.getElementById('categoryChart'), {
type: 'pie', type: 'pie',
data: { data: {
labels: catLabels, labels: catLabels,
datasets: [{ datasets: [{
data: catData, data: catData,
backgroundColor: categoryChartColors.palette backgroundColor: [
'#ff6384', '#36a2eb', '#cc65fe', '#ffce56', '#4bc0c0', '#ffa1b5'
]
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { legend: { position: 'right' }
position: 'right',
labels: { color: categoryChartColors.tickColor }
} }
} }
}
});
registerThemedChart(categoryChart, function (chart, colors) {
chart.options.plugins.legend.labels.color = colors.tickColor;
}); });
</script> </script>
@ -304,39 +290,24 @@
const labels = {{ chart_labels|safe }}; const labels = {{ chart_labels|safe }};
const data = {{ chart_data|safe }}; const data = {{ chart_data|safe }};
const ctx = document.getElementById('mainChart'); const ctx = document.getElementById('mainChart');
const mainChartColors = getChartColors();
const mainChart = new Chart(ctx, { new Chart(ctx, {
type: 'bar', type: 'bar',
data: { data: {
labels: labels, labels: labels,
datasets:[{ datasets:[{
label: 'Gastos (€)', label: 'Gastos (€)',
data: data, data: data,
backgroundColor: mainChartColors.primaryColor, backgroundColor: 'rgba(54, 162, 235, 0.6)',
backgroundColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1 borderWidth: 1
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
scales: { scales: { y: { beginAtZero: true } }
x: {
grid: { color: mainChartColors.gridColor },
ticks: { color: mainChartColors.tickColor }
},
y: {
beginAtZero: true,
grid: { color: mainChartColors.gridColor },
ticks: { color: mainChartColors.tickColor }
} }
}
}
});
registerThemedChart(mainChart, function (chart, colors) {
chart.data.datasets[0].backgroundColor = colors.primaryColor;
applyCartesianColors(chart, colors);
}); });
const allMonths = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"]; const allMonths = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"];
@ -345,9 +316,8 @@
(function() { (function() {
const dataAccount = {{ acc.data|safe }}; const dataAccount = {{ acc.data|safe }};
const filteredLabels = allMonths.slice(0, dataAccount.length); const filteredLabels = allMonths.slice(0, dataAccount.length);
const accountChartColors = getChartColors();
const accountChart = new Chart( new Chart(
document.getElementById('accountChart{{ acc.id }}'), document.getElementById('accountChart{{ acc.id }}'),
{ {
type: 'line', type: 'line',
@ -357,8 +327,8 @@
label: 'Saldo Real (€)', label: 'Saldo Real (€)',
data: dataAccount, data: dataAccount,
fill: false, fill: false,
borderColor: accountChartColors.primaryColor, borderColor: 'rgba(54, 162, 235, 1)',
backgroundColor: accountChartColors.primaryFill, backgroundColor: 'rgba(54, 162, 235, 0.2)',
tension: 0.1 tension: 0.1
}] }]
}, },
@ -366,25 +336,13 @@
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
scales: { scales: {
x: {
grid: { color: accountChartColors.gridColor },
ticks: { color: accountChartColors.tickColor }
},
y: { y: {
beginAtZero: false, beginAtZero: false
grid: { color: accountChartColors.gridColor },
ticks: { color: accountChartColors.tickColor }
} }
} }
} }
} }
); );
registerThemedChart(accountChart, function (chart, colors) {
chart.data.datasets[0].borderColor = colors.primaryColor;
chart.data.datasets[0].backgroundColor = colors.primaryFill;
applyCartesianColors(chart, colors);
});
})(); })();
{% endfor %} {% endfor %}
</script> </script>
@ -440,7 +398,6 @@
<!-- ========================= --> <!-- ========================= -->
<!-- Categories --> <!-- Categories -->
<!-- ========================= --> <!-- ========================= -->
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -457,6 +414,5 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% endblock %}

View File

@ -1,16 +1,21 @@
{% extends "expenses/_confirm_delete.html" %} {% extends "expenses/base.html" %}
{% block title %}Eliminar gasto{% endblock %} {% block title %}Eliminar gasto{% endblock %}
{% block delete_heading %}Eliminar gasto{% endblock %} {% block content %}
<h1>Eliminar gasto</h1>
{% block delete_question %} <p>
¿Seguro que quieres eliminar el gasto de ¿Seguro que quieres eliminar el gasto de
<strong>{{ expense.amount }}€</strong> <strong>{{ expense.amount }}€</strong>
del {{ expense.date }} perteneciente a del {{ expense.date }} perteneciente a
<strong>{{ expense.account }}</strong>? <strong>{{ expense.account }}</strong>?
</p>
<form method="post">
{% csrf_token %}
<button class="btn">Sí, eliminar</button>
<a class="btn secondary" href="{% url 'expense_list' %}">Cancelar</a>
</form>
{% endblock %} {% endblock %}
{% block delete_button %}Sí, eliminar{% endblock %}
{% block cancel_url %}{% url 'expense_list' %}{% endblock %}

View File

@ -46,15 +46,14 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div class="form-actions"> <button type="submit">
<button type="submit" class="btn btn-primary">
{% if form.instance.pk %} {% if form.instance.pk %}
Guardar gasto Guardar gasto
{% else %} {% else %}
Crear gasto Crear gasto
{% endif %} {% endif %}
</button> </button>
<a class="btn btn-secondary" href="{% url 'expense_list' %}">Volver</a>
</div>
</form> </form>
<a href="{% url 'expense_list' %}">Volver</a>
{% endblock %} {% endblock %}

View File

@ -44,8 +44,8 @@
{% endfor %} {% endfor %}
</select> </select>
<button type="submit" class="btn btn-primary">Filtrar</button> <button type="submit">Filtrar</button>
<a href="{% url 'expense_list' %}" class="btn btn-secondary">Limpiar</a> <a href="{% url 'expense_list' %}" class="btn-secondary">Limpiar</a>
</div> </div>
<br> <br>
@ -100,7 +100,6 @@
<strong>Categorías:</strong> {{ kpi_categories }} <strong>Categorías:</strong> {{ kpi_categories }}
</section> </section>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -148,7 +147,6 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
<div class="pagination"> <div class="pagination">
<span class="step-links"> <span class="step-links">

View File

@ -34,49 +34,29 @@
<div class="dashboard-grid"> <div class="dashboard-grid">
<div class="card card-chart"> <div class="card card-chart">
<h2>Últimos meses</h2> <h2>Últimos meses</h2>
<div class="canvas-wrapper">
<canvas id="miniChart"></canvas> <canvas id="miniChart"></canvas>
</div> </div>
</div> </div>
</div>
<script> <script>
const labels = {{ mini_chart_labels|safe }}; const labels = {{ mini_chart_labels|safe }};
const data = {{ mini_chart_data|safe }}; const data = {{ mini_chart_data|safe }};
const ctx = document.getElementById('miniChart'); const ctx = document.getElementById('miniChart');
const miniChartColors = getChartColors();
const miniChart = new Chart(ctx, { new Chart(ctx, {
type: 'bar', type: 'bar',
data: { data: {
labels: labels, labels: labels,
datasets:[{ datasets:[{
label: 'Gastos', label: 'Gastos',
data: data, data: data,
backgroundColor: miniChartColors.primaryColor,
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false
scales: {
x: {
grid: { color: miniChartColors.gridColor },
ticks: { color: miniChartColors.tickColor }
},
y: {
beginAtZero: true,
grid: { color: miniChartColors.gridColor },
ticks: { color: miniChartColors.tickColor }
} }
}
}
});
registerThemedChart(miniChart, function (chart, colors) {
chart.data.datasets[0].backgroundColor = colors.primaryColor;
applyCartesianColors(chart, colors);
}); });
</script> </script>

View File

@ -1,16 +1,20 @@
{% extends "expenses/_confirm_delete.html" %} {% extends "expenses/base.html" %}
{% block title %}Eliminar ingreso{% endblock %} {% block title %}Eliminar ingreso{% endblock %}
{% block delete_heading %}Eliminar ingreso{% endblock %} {% block content %}
<h1>Eliminar ingreso</h1>
{% block delete_question %} <p>
¿Seguro que quieres eliminar el ingreso de ¿Seguro que quieres eliminar el ingreso de
<strong>{{ income.amount }}€</strong> <strong>{{ income.amount }}€</strong>
del {{ income.date }} perteneciente a del {{ income.date }} perteneciente a
<strong>{{ income.account }}</strong>? <strong>{{ income.account }}</strong>?
</p>
<form method="post">
{% csrf_token %}
<button class="btn">Sí, eliminar</button>
<a class="btn secondary" href="{% url 'income_list' %}">Cancelar</a>
</form>
{% endblock %} {% endblock %}
{% block delete_button %}Sí, eliminar{% endblock %}
{% block cancel_url %}{% url 'income_list' %}{% endblock %}

View File

@ -19,15 +19,14 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<div class="form-actions"> <button type="submit">
<button type="submit" class="btn btn-primary">
{% if form.instance.pk %} {% if form.instance.pk %}
Guardar ingreso Guardar ingreso
{% else %} {% else %}
Crear ingreso Crear ingreso
{% endif %} {% endif %}
</button> </button>
<a class="btn btn-secondary" href="{% url 'income_list' %}">Volver</a>
</div>
</form> </form>
<a class="btn secondary" href="{% url 'income_list' %}">Volver</a>
{% endblock %} {% endblock %}

View File

@ -5,7 +5,6 @@
<a class="btn" href="{% url 'income_create' %}"> Nuevo ingreso</a> <a class="btn" href="{% url 'income_create' %}"> Nuevo ingreso</a>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -38,6 +37,5 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% endblock %}

View File

@ -1,9 +1,17 @@
{% extends "expenses/_confirm_delete.html" %} {% extends "expenses/base.html" %}
{% block title %}Etiquetas{% endblock %} {% block title %}Etiquetas{% endblock %}
{% block content %}
<h1>Eliminar etiqueta</h1>
{% block delete_heading %}Eliminar etiqueta{% endblock %} <p>
¿Seguro que quieres eliminar la etiqueta
<strong>{{ tag.name }}€</strong>?
</p>
{% block delete_question %}¿Seguro que quieres eliminar la etiqueta <strong>{{ tag.name }}</strong>?{% endblock %} <form method="post">
{% csrf_token %}
<button class="btn">Eliminar</button>
<a class="btn secondary" href="{% url 'tag_list' %}">Cancelar</a>
</form>
{% block cancel_url %}{% url 'tag_list' %}{% endblock %} {% endblock %}

View File

@ -6,9 +6,8 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<div class="form-actions"> <button type="submit">Guardar</button>
<button type="submit" class="btn btn-primary">Guardar</button>
<a class="btn btn-secondary" href="{% url 'tag_list' %}">Volver</a>
</div>
</form> </form>
<a class="btn secondary" href="{% url 'tag_list' %}">Volver</a>
{% endblock %} {% endblock %}

View File

@ -1,23 +1,26 @@
{% extends "expenses/_confirm_delete.html" %} {% extends "expenses/base.html" %}
{% block title %}Repostajes{% endblock %} {% block title %}
Repostajes
{% block delete_heading %}Eliminar repostaje.{% endblock %}
{% block delete_question %}
¿Seguro que quieres eliminar el repostaje del
<strong>{{ fuel.expense.date }}</strong>
({{ fuel.liters }}L por {{ fuel.expense.amount }}€)?
{% endblock %} {% endblock %}
{% block delete_warnings %} {% block content %}
<h2>Eliminar repostaje.</h2>
<p>
¿Seguro que quieres eliminar el repostaje del
<strong>{{ fuel.expense.date}}</strong>
({{ fuel.liters }}L por {{fuel.expense.amount}}€)?
</p>
<p class="form-errors"> <p class="form-errors">
Se eliminará también el gasto asociado en el listado de gastos. Se eliminará también el gasto asociado en el listado de gastos.
</p> </p>
{% endblock %}
{% block delete_extra_fields %} <form method="post">
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %} {% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<button class="btn danger">Eliminar</button>
<a class="btn" href="{% url 'fuel_list' %}">Cancelar</a>
</form>
{% endblock %} {% endblock %}
{% block cancel_url %}{% if next %}{{ next }}{% else %}{% url 'fuel_list' %}{% endif %}{% endblock %}

View File

@ -21,22 +21,20 @@
{% csrf_token %} {% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %} {% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
{{ form.as_p }} {{ form.as_p }}
<div class="form-actions"> <button type="submit">
<button type="submit" class="btn btn-primary">
{% if editing %} {% if editing %}
Guardar repostaje Guardar repostaje
{% else %} {% else %}
Crear repostaje Crear repostaje
{% endif %} {% endif %}
</button> </button>
</form>
{% if next %} {% if next %}
<a class="btn btn-secondary" href="{{ next }}">Volver</a> <a class="btn" href="{{ next }}">Volver</a>
{% elif editing %} {% elif editing %}
<a class="btn btn-secondary" href="{% url 'expense_list' %}">Volver</a> <a class="btn" href="{% url 'expense_list' %}">Volver</a>
{% else %} {% else %}
<a class="btn btn-secondary" href="{% url 'fuel_list' %}">Volver</a> <a class="btn" href="{% url 'fuel_list' %}">Volver</a>
{% endif %} {% endif %}
</div>
</form>
{% endblock %} {% endblock %}

View File

@ -12,7 +12,6 @@
</div> </div>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -42,7 +41,6 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
<form method="get"> <form method="get">
<select name="year" onchange="this.form.submit()"> <select name="year" onchange="this.form.submit()">
@ -59,72 +57,30 @@
<script> <script>
const monthlyData = {{ monthly_data|safe }}; const monthlyData = {{ monthly_data|safe }};
const monthlyChartColors = getChartColors();
const monthlyChart = new Chart(document.getElementById('monthlyChart'), { new Chart(document.getElementById('monthlyChart'), {
type: 'bar', type: 'bar',
data: { data: {
labels: ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'], labels: ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'],
datasets: [{ datasets: [{
label: 'Gasto mensual', label: 'Gasto mensual',
data: monthlyData, data: monthlyData
backgroundColor: monthlyChartColors.primaryColor
}] }]
},
options: {
scales: {
x: {
grid: { color: monthlyChartColors.gridColor },
ticks: { color: monthlyChartColors.tickColor }
},
y: {
beginAtZero: true,
grid: { color: monthlyChartColors.gridColor },
ticks: { color: monthlyChartColors.tickColor }
} }
}
}
});
registerThemedChart(monthlyChart, function (chart, colors) {
chart.data.datasets[0].backgroundColor = colors.primaryColor;
applyCartesianColors(chart, colors);
}); });
const kmData = {{ km_data|safe }}; const kmData = {{ km_data|safe }};
const kmDates = {{ km_dates|safe }}; const kmDates = {{ km_dates|safe }};
const kmChartColors = getChartColors();
const kmChart = new Chart(document.getElementById('kmChart'), { new Chart(document.getElementById('kmChart'), {
type: 'line', type: 'line',
data: { data: {
labels: kmDates, labels: kmDates,
datasets: [{ datasets: [{
label: 'Km entre repostajes', label: 'Km entre repostajes',
data: kmData, data: kmData
borderColor: kmChartColors.primaryColor,
backgroundColor: kmChartColors.primaryFill
}] }]
},
options: {
scales: {
x: {
grid: { color: kmChartColors.gridColor },
ticks: { color: kmChartColors.tickColor }
},
y: {
beginAtZero: true,
grid: { color: kmChartColors.gridColor },
ticks: { color: kmChartColors.tickColor }
} }
}
}
});
registerThemedChart(kmChart, function (chart, colors) {
chart.data.datasets[0].borderColor = colors.primaryColor;
chart.data.datasets[0].backgroundColor = colors.primaryFill;
applyCartesianColors(chart, colors);
}); });
</script> </script>

View File

@ -1,9 +1,18 @@
{% extends "expenses/_confirm_delete.html" %} {% extends "expenses/base.html" %}
{% block title %}Objetivos{% endblock %} {% block title %}Objetivos{% endblock %}
{% block content %}
{% block delete_heading %}Eliminar objetivo{% endblock %} <h1>Eliminar objetivo</h1>
{% block delete_question %}¿Seguro que quieres eliminar el objetivo <strong>{{ goal.name }}</strong>?{% endblock %} <p>
¿Seguro que quieres eliminar el objetivo
<strong>{{ goal.name }}</strong>?
</p>
{% block cancel_url %}{% url 'goal_list' %}{% endblock %} <form method="post">
{% csrf_token %}
<button class="btn">Eliminar</button>
<a class="btn secondary" href="{% url 'goal_list' %}">Cancelar</a>
</form>
{% endblock %}

View File

@ -11,11 +11,10 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<div class="form-actions"> <button type="submit">
<button type="submit" class="btn btn-primary">
Guardar Guardar
</button> </button>
<a class="btn btn-secondary" href="{% url 'goal_list' %}">Volver</a>
</div>
</form> </form>
<a class="btn secondary" href="{% url 'goal_list' %}">Volver</a>
{% endblock %} {% endblock %}

View File

@ -9,9 +9,8 @@
{% block content %} {% block content %}
<h2>Objetivos</h2> <h2>Objetivos</h2>
<a class="btn btn-primary" href="{% url 'goal_create' %}"> Nuevo objetivo</a> <a class=btn href="{% url 'goal_create' %}"> Nuevo objetivo</a>
<div class="table-wrap">
<table> <table>
<tr> <tr>
<th>Nombre</th> <th>Nombre</th>
@ -54,6 +53,5 @@
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</div>
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="btn btn-primary">Entrar</button> <button type="submit">Entrar</button>
</form> </form>
<p class="auth-help"> <p class="auth-help">

View File

@ -5,6 +5,6 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="btn btn-primary">Cambiar</button> <button type="submit">Cambiar</button>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -67,165 +67,3 @@ def test_fuel_delete_post_cascades_to_fuelentry(auth_client, user, account, cate
assert not Expense.objects.filter(pk=expense.pk).exists() assert not Expense.objects.filter(pk=expense.pk).exists()
assert not FuelEntry.objects.filter(pk=entry.pk).exists() assert not FuelEntry.objects.filter(pk=entry.pk).exists()
def _edit_payload(account, edit_date, amount, odometer, liters, next_url=None):
payload = {
"date": edit_date.strftime("%Y-%m-%d"),
"amount": str(amount),
"account": account.pk,
"odometer": str(odometer),
"liters": str(liters),
}
if next_url is not None:
payload["next"] = next_url
return payload
def test_fuel_edit_post_updates_expense_and_fuelentry(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"), amount=Decimal("50"))
expense = entry.expense
new_date = date(2024, 3, 1)
response = auth_client.post(
reverse('fuel_edit', args=[expense.pk]),
_edit_payload(account, new_date, Decimal("45.50"), 1200, Decimal("22.5")),
)
assert response.status_code == 302
expense.refresh_from_db()
entry.refresh_from_db()
assert expense.amount == Decimal("45.50")
assert expense.date == new_date
assert expense.account_id == account.pk
assert entry.odometer == 1200
assert entry.liters == Decimal("22.5")
def test_fuel_edit_invalid_post_returns_200_and_does_not_modify(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"), amount=Decimal("50"))
expense = entry.expense
original_date = expense.date
payload = _edit_payload(account, date(2024, 3, 1), Decimal("45.50"), 1200, Decimal("22.5"))
payload["liters"] = ""
response = auth_client.post(reverse('fuel_edit', args=[expense.pk]), payload)
assert response.status_code == 200
expense.refresh_from_db()
entry.refresh_from_db()
assert expense.amount == Decimal("50")
assert expense.date == original_date
assert entry.odometer == 1000
assert entry.liters == Decimal("20")
def test_fuel_edit_redirects_to_next_when_relative(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
expense = entry.expense
next_url = reverse('expense_list')
response = auth_client.post(
reverse('fuel_edit', args=[expense.pk]),
_edit_payload(account, date(2024, 3, 1), Decimal("45.50"), 1200, Decimal("22.5"), next_url=next_url),
)
assert response.status_code == 302
assert response.url == next_url
def test_fuel_edit_ignores_external_next(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
expense = entry.expense
response = auth_client.post(
reverse('fuel_edit', args=[expense.pk]),
_edit_payload(
account, date(2024, 3, 1), Decimal("45.50"), 1200, Decimal("22.5"),
next_url="https://evil.com",
),
)
assert response.status_code == 302
assert response.url == reverse('expense_list')
def test_fuel_delete_get_does_not_delete(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
expense = entry.expense
response = auth_client.get(reverse('fuel_delete', args=[expense.pk]))
assert response.status_code == 200
assert Expense.objects.filter(pk=expense.pk).exists()
assert FuelEntry.objects.filter(pk=entry.pk).exists()
def test_fuel_delete_get_cancel_link_uses_next_when_provided(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
expense = entry.expense
next_url = reverse('expense_list')
response = auth_client.get(reverse('fuel_delete', args=[expense.pk]), {"next": next_url})
assert response.status_code == 200
assert f'<a class="btn btn-secondary" href="{next_url}">Cancelar</a>'.encode() in response.content
def test_fuel_delete_get_cancel_link_falls_back_to_fuel_list_without_next(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
expense = entry.expense
fuel_list_url = reverse('fuel_list')
response = auth_client.get(reverse('fuel_delete', args=[expense.pk]))
assert response.status_code == 200
assert f'<a class="btn btn-secondary" href="{fuel_list_url}">Cancelar</a>'.encode() in response.content
def test_fuel_delete_post_redirects_to_next_when_relative(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
expense = entry.expense
next_url = reverse('expense_list')
response = auth_client.post(reverse('fuel_delete', args=[expense.pk]), {"next": next_url})
assert response.status_code == 302
assert response.url == next_url
def test_fuel_delete_post_ignores_external_next(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
expense = entry.expense
response = auth_client.post(reverse('fuel_delete', args=[expense.pk]), {"next": "https://evil.com"})
assert response.status_code == 302
assert response.url == reverse('fuel_list')
def test_fuel_delete_get_ignores_unsafe_next_in_cancel_link(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
expense = entry.expense
fuel_list_url = reverse('fuel_list')
response = auth_client.get(reverse('fuel_delete', args=[expense.pk]), {"next": "javascript:alert(1)"})
assert response.status_code == 200
assert b"javascript:" not in response.content
assert f'<a class="btn btn-secondary" href="{fuel_list_url}">Cancelar</a>'.encode() in response.content
def test_fuel_edit_get_ignores_unsafe_next_in_cancel_link(auth_client, user, account, category):
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
expense = entry.expense
response = auth_client.get(reverse('fuel_edit', args=[expense.pk]), {"next": "javascript:alert(1)"})
assert response.status_code == 200
assert b"javascript:" not in response.content

View File

@ -30,7 +30,7 @@ def _get_int(value):
return None return None
def _safe_next(request): def _redirect_back(request, fallback):
next_url = request.POST.get("next") or request.GET.get("next") next_url = request.POST.get("next") or request.GET.get("next")
if next_url and url_has_allowed_host_and_scheme( if next_url and url_has_allowed_host_and_scheme(
@ -38,14 +38,9 @@ def _safe_next(request):
allowed_hosts={request.get_host()}, allowed_hosts={request.get_host()},
require_https=request.is_secure(), require_https=request.is_secure(),
): ):
return next_url return redirect(next_url)
return "" return redirect(fallback)
def _redirect_back(request, fallback):
next_url = _safe_next(request)
return redirect(next_url or fallback)
def sub_months(year, month, n): def sub_months(year, month, n):
@ -242,6 +237,7 @@ def expense_create(request):
@login_required @login_required
def expense_edit(request, pk): def expense_edit(request, pk):
# sourcery skip: assign-if-exp, merge-else-if-into-elif
expense = get_object_or_404( expense = get_object_or_404(
Expense, Expense,
pk=pk, pk=pk,
@ -809,16 +805,20 @@ def fuel_list(request):
@login_required @login_required
def fuel_edit(request, pk): def fuel_edit(request, pk):
expense = get_object_or_404(Expense, pk=pk, owner=request.user) expense = get_object_or_404(
Expense,
pk=pk,
owner=request.user,
)
fuel = get_object_or_404(FuelEntry, expense=expense) fuel = get_object_or_404(FuelEntry, expense=expense)
if request.method == "POST": if request.method == "POST":
form = FuelEntryForm(request.POST, instance=expense, user=request.user) form = FuelEntryForm(request.POST, user=request.user)
if form.is_valid(): if form.is_valid():
# Update expense # Update expense
expense = form.save(commit=False) expense.date = form.save(commit=False)
expense.description = "Repostaje" expense.description = "Repostaje"
expense.save() expense.save()
@ -831,7 +831,7 @@ def fuel_edit(request, pk):
else: else:
form = FuelEntryForm(instance=expense, user=request.user) form = FuelEntryForm(instance=expense, user=request.user)
next_url = _safe_next(request) next_url = request.POST.get("next") or request.GET.get("next", "")
return render( return render(
request, request,
@ -855,7 +855,7 @@ def fuel_delete(request, pk):
messages.success(request, "Repostaje eliminado.") messages.success(request, "Repostaje eliminado.")
return _redirect_back(request, "fuel_list") return _redirect_back(request, "fuel_list")
next_url = _safe_next(request) next_url = request.POST.get("next") or request.GET.get("next", "")
return render( return render(
request, request,