Updated the web #31

Merged
jkuijperm merged 10 commits from dev into main 2026-08-28 11:28:27 +00:00
36 changed files with 1376 additions and 548 deletions

View File

@ -0,0 +1,17 @@
# 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,7 +5,6 @@ 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

@ -0,0 +1,88 @@
(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

@ -0,0 +1,30 @@
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

@ -0,0 +1,20 @@
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,14 +1,12 @@
{% extends "expenses/base.html" %} {% extends "expenses/_confirm_delete.html" %}
{% block title %} {% block title %}Categorías{% endblock %}
Categorías
{% endblock %}
{% block content %} {% block delete_heading %}Eliminar categoría{% endblock %}
<h2>Eliminar categoría</h2>
<p>¿Seguro que quieres eliminar la categoría <strong>{{ category.name }}</strong>?</p> {% block delete_question %}¿Seguro que quieres eliminar la categoría <strong>{{ category.name }}</strong>?{% endblock %}
{% 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 }}
@ -28,11 +26,6 @@
</p> </p>
{% endif %} {% endif %}
{% endif %} {% endif %}
<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 %} {% endblock %}
{% block cancel_url %}{% url 'category_list' %}{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@
<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>
@ -46,4 +47,5 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% endblock %}

View File

@ -1,18 +1,34 @@
{% load static %} {% load static %}
<!DOCTYPE> <!DOCTYPE html>
<html> <html lang="es">
<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">
<nav class="nav"> <button type="button" class="nav-toggle" id="navToggle" aria-label="Abrir menú" aria-expanded="false" aria-controls="mainNav">
<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>
@ -21,10 +37,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 %}"> <div class="nav-item dropdown {% if active_menu == 'settings' %}active{% endif %}" id="settingsDropdown">
<span class="dropdown-toggle">Configuraciones ▼</span> <button type="button" class="dropdown-toggle" id="dropdownToggle" aria-haspopup="true" aria-expanded="false" aria-controls="dropdownMenu">Configuraciones ▼</button>
<div class="dropdown-menu"> <div class="dropdown-menu" id="dropdownMenu">
<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>
@ -33,6 +49,8 @@
<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">
@ -58,5 +76,7 @@
</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,9 +1,19 @@
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="es">
<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,11 +41,14 @@
<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>
{% if period %}
<a href="{% url 'dashboard' %}" style="color: red; margin-left: 10px;">❌ Quitar filtros temporales</a>
{% endif %}
</div> </div>
{% if period %}
<div class="clear-filters-row">
<a href="{% url 'dashboard' %}" class="clear-filters">❌ Quitar filtros temporales</a>
</div>
{% endif %}
<br> <br>
<form method="get" action="{% url 'dashboard' %}" id="filtered_form"> <form method="get" action="{% url 'dashboard' %}" id="filtered_form">
@ -86,7 +89,7 @@
Comparar periodo anterior Comparar periodo anterior
</label> </label>
<button type="submit">Aplicar Filtros</button> <button type="submit" class="btn btn-primary">Aplicar Filtros</button>
</form> </form>
</section> </section>
<script> <script>
@ -149,10 +152,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 #ff9f43;"> <div class="kpi-card" style="border-left: 4px solid var(--color-warning-accent);">
<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: gray; display:block;">Basado en tu ritmo de gasto actual</small> <small style="color: var(--color-text-muted); display:block;">Basado en tu ritmo de gasto actual</small>
</div> </div>
{% endif %} {% endif %}
</section> </section>
@ -164,15 +167,16 @@
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' %}#d9534f{% else %}#5cb85c{% endif %};"> <strong style="color: {% if kpi_trend == 'up' %}var(--color-danger-accent){% else %}var(--color-success-accent){% 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:gray;"> <small style="display:block; color: var(--color-text-muted);">
{% 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>
@ -188,13 +192,14 @@
<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 %}#d9534f{% else %}#5cb85c{% endif %};"> <td style="font-weight: bold; color: {% if cat.difference > 0 %}var(--color-danger-accent){% else %}var(--color-success-accent){% 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 %}
@ -203,22 +208,23 @@
<!-- ========================= --> <!-- ========================= -->
<div class="charts-container"> <div class="charts-container">
<section class="chart-box" style="width: 45%; min-width: 300px; max-width: 550px;"> <section>
<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 style="position: relative; width: 100%; height: 350px; padding-bottom: 20px;"> <div class="chart-box">
<canvas id="mainChart"></canvas> <canvas id="mainChart"></canvas>
</div> </div>
</section> </section>
<section class="chart-box" style="width: 30%; min-width: 280px; max-width: 400px;"> <section>
<h3>Distribución por Categorías</h3> <h3>Distribución por Categorías</h3>
<div style="position: relative; width: 100%; height: 350px; display: flex; align-items: center; justify-content: center"> <div class="chart-box">
<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>
@ -236,13 +242,14 @@
<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: #d9534f; font-weight: bold;">{{ exp.amount|floatformat:2 }}</td> <td style="color: var(--color-danger-accent); 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>
@ -252,24 +259,31 @@
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 %}];
new Chart(document.getElementById('categoryChart'), { const categoryChartColors = getChartColors();
const categoryChart = new Chart(document.getElementById('categoryChart'), {
type: 'pie', type: 'pie',
data: { data: {
labels: catLabels, labels: catLabels,
datasets: [{ datasets: [{
data: catData, data: catData,
backgroundColor: [ backgroundColor: categoryChartColors.palette
'#ff6384', '#36a2eb', '#cc65fe', '#ffce56', '#4bc0c0', '#ffa1b5'
]
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { position: 'right' } legend: {
position: 'right',
labels: { color: categoryChartColors.tickColor }
} }
} }
}
});
registerThemedChart(categoryChart, function (chart, colors) {
chart.options.plugins.legend.labels.color = colors.tickColor;
}); });
</script> </script>
@ -290,24 +304,39 @@
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();
new Chart(ctx, { const mainChart = new Chart(ctx, {
type: 'bar', type: 'bar',
data: { data: {
labels: labels, labels: labels,
datasets:[{ datasets:[{
label: 'Gastos (€)', label: 'Gastos (€)',
data: data, data: data,
backgroundColor: 'rgba(54, 162, 235, 0.6)', backgroundColor: mainChartColors.primaryColor,
backgroundColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1 borderWidth: 1
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
scales: { y: { beginAtZero: true } } scales: {
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"];
@ -316,8 +345,9 @@
(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();
new Chart( const accountChart = new Chart(
document.getElementById('accountChart{{ acc.id }}'), document.getElementById('accountChart{{ acc.id }}'),
{ {
type: 'line', type: 'line',
@ -327,8 +357,8 @@
label: 'Saldo Real (€)', label: 'Saldo Real (€)',
data: dataAccount, data: dataAccount,
fill: false, fill: false,
borderColor: 'rgba(54, 162, 235, 1)', borderColor: accountChartColors.primaryColor,
backgroundColor: 'rgba(54, 162, 235, 0.2)', backgroundColor: accountChartColors.primaryFill,
tension: 0.1 tension: 0.1
}] }]
}, },
@ -336,13 +366,25 @@
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>
@ -398,6 +440,7 @@
<!-- ========================= --> <!-- ========================= -->
<!-- Categories --> <!-- Categories -->
<!-- ========================= --> <!-- ========================= -->
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -414,5 +457,6 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% endblock %}

View File

@ -1,21 +1,16 @@
{% extends "expenses/base.html" %} {% extends "expenses/_confirm_delete.html" %}
{% block title %}Eliminar gasto{% endblock %} {% block title %}Eliminar gasto{% endblock %}
{% block content %} {% block delete_heading %}Eliminar gasto{% endblock %}
<h1>Eliminar gasto</h1>
<p> {% block delete_question %}
¿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,14 +46,15 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<button type="submit"> <div class="form-actions">
<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">Filtrar</button> <button type="submit" class="btn btn-primary">Filtrar</button>
<a href="{% url 'expense_list' %}" class="btn-secondary">Limpiar</a> <a href="{% url 'expense_list' %}" class="btn btn-secondary">Limpiar</a>
</div> </div>
<br> <br>
@ -100,6 +100,7 @@
<strong>Categorías:</strong> {{ kpi_categories }} <strong>Categorías:</strong> {{ kpi_categories }}
</section> </section>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -147,6 +148,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
<div class="pagination"> <div class="pagination">
<span class="step-links"> <span class="step-links">

View File

@ -34,29 +34,49 @@
<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();
new Chart(ctx, { const miniChart = 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,20 +1,16 @@
{% extends "expenses/base.html" %} {% extends "expenses/_confirm_delete.html" %}
{% block title %}Eliminar ingreso{% endblock %} {% block title %}Eliminar ingreso{% endblock %}
{% block content %} {% block delete_heading %}Eliminar ingreso{% endblock %}
<h1>Eliminar ingreso</h1>
<p> {% block delete_question %}
¿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,14 +19,15 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit"> <div class="form-actions">
<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,6 +5,7 @@
<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>
@ -37,5 +38,6 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% endblock %}

View File

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

View File

@ -6,8 +6,9 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit">Guardar</button> <div class="form-actions">
<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,26 +1,23 @@
{% extends "expenses/base.html" %} {% extends "expenses/_confirm_delete.html" %}
{% block title %} {% block title %}Repostajes{% endblock %}
Repostajes
{% endblock %}
{% block content %} {% block delete_heading %}Eliminar repostaje.{% endblock %}
<h2>Eliminar repostaje.</h2>
<p> {% block delete_question %}
¿Seguro que quieres eliminar el repostaje del ¿Seguro que quieres eliminar el repostaje del
<strong>{{ fuel.expense.date }}</strong> <strong>{{ fuel.expense.date }}</strong>
({{ fuel.liters }}L por {{ fuel.expense.amount }}€)? ({{ fuel.liters }}L por {{ fuel.expense.amount }}€)?
</p> {% endblock %}
{% block delete_warnings %}
<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>
<form method="post">
{% csrf_token %}
{% 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 delete_extra_fields %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
{% endblock %}
{% block cancel_url %}{% if next %}{{ next }}{% else %}{% url 'fuel_list' %}{% endif %}{% endblock %}

View File

@ -21,20 +21,22 @@
{% 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 }}
<button type="submit"> <div class="form-actions">
<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" href="{{ next }}">Volver</a> <a class="btn btn-secondary" href="{{ next }}">Volver</a>
{% elif editing %} {% elif editing %}
<a class="btn" href="{% url 'expense_list' %}">Volver</a> <a class="btn btn-secondary" href="{% url 'expense_list' %}">Volver</a>
{% else %} {% else %}
<a class="btn" href="{% url 'fuel_list' %}">Volver</a> <a class="btn btn-secondary" href="{% url 'fuel_list' %}">Volver</a>
{% endif %} {% endif %}
</div>
</form>
{% endblock %} {% endblock %}

View File

@ -12,6 +12,7 @@
</div> </div>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -41,6 +42,7 @@
{% 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()">
@ -57,30 +59,72 @@
<script> <script>
const monthlyData = {{ monthly_data|safe }}; const monthlyData = {{ monthly_data|safe }};
const monthlyChartColors = getChartColors();
new Chart(document.getElementById('monthlyChart'), { const 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();
new Chart(document.getElementById('kmChart'), { const 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,18 +1,9 @@
{% extends "expenses/base.html" %} {% extends "expenses/_confirm_delete.html" %}
{% block title %}Objetivos{% endblock %} {% block title %}Objetivos{% endblock %}
{% block content %}
<h1>Eliminar objetivo</h1> {% block delete_heading %}Eliminar objetivo{% endblock %}
<p> {% block delete_question %}¿Seguro que quieres eliminar el objetivo <strong>{{ goal.name }}</strong>?{% endblock %}
¿Seguro que quieres eliminar el objetivo
<strong>{{ goal.name }}</strong>?
</p>
<form method="post"> {% block cancel_url %}{% url 'goal_list' %}{% endblock %}
{% csrf_token %}
<button class="btn">Eliminar</button>
<a class="btn secondary" href="{% url 'goal_list' %}">Cancelar</a>
</form>
{% endblock %}

View File

@ -11,10 +11,11 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit"> <div class="form-actions">
<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,8 +9,9 @@
{% block content %} {% block content %}
<h2>Objetivos</h2> <h2>Objetivos</h2>
<a class=btn href="{% url 'goal_create' %}"> Nuevo objetivo</a> <a class="btn btn-primary" href="{% url 'goal_create' %}"> Nuevo objetivo</a>
<div class="table-wrap">
<table> <table>
<tr> <tr>
<th>Nombre</th> <th>Nombre</th>
@ -53,5 +54,6 @@
</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">Entrar</button> <button type="submit" class="btn btn-primary">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">Cambiar</button> <button type="submit" class="btn btn-primary">Cambiar</button>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -67,3 +67,165 @@ 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 _redirect_back(request, fallback): def _safe_next(request):
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,9 +38,14 @@ def _redirect_back(request, fallback):
allowed_hosts={request.get_host()}, allowed_hosts={request.get_host()},
require_https=request.is_secure(), require_https=request.is_secure(),
): ):
return redirect(next_url) return next_url
return redirect(fallback) return ""
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):
@ -237,7 +242,6 @@ 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,
@ -805,20 +809,16 @@ def fuel_list(request):
@login_required @login_required
def fuel_edit(request, pk): def fuel_edit(request, pk):
expense = get_object_or_404( expense = get_object_or_404(Expense, pk=pk, owner=request.user)
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, user=request.user) form = FuelEntryForm(request.POST, instance=expense, user=request.user)
if form.is_valid(): if form.is_valid():
# Update expense # Update expense
expense.date = form.save(commit=False) expense = 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 = request.POST.get("next") or request.GET.get("next", "") next_url = _safe_next(request)
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 = request.POST.get("next") or request.GET.get("next", "") next_url = _safe_next(request)
return render( return render(
request, request,