Compare commits

...

11 Commits

Author SHA1 Message Date
d88276556a Merge pull request 'Updated the web' (#31) from dev into main
Reviewed-on: #31
2026-08-28 11:28:25 +00:00
161ea36ff5 Theming de graficos Chart.js para modo claro/oscuro
- Anade charts.js con getChartColors, applyCartesianColors y
  registerThemedChart para centralizar los colores de los graficos segun
  el tema activo y refrescarlos en vivo al cambiar de tema (via
  MutationObserver sobre data-theme).
- Corrige el bug de renderizado en graficos de tipo 'line'
  (TypeError: t.startsWith is not a function): los colores de tema ahora
  se fijan en las opciones al crear cada grafico en lugar de mutarlas
  despues de new Chart(...).
- Anade variables --chart-grid-color y --chart-fill-color en base.css
  (light/dark) e incluye charts.js en base.html.
- Aplica el theming a categoryChart, mainChart y accountChart{id}
  (dashboard.html), miniChart (home.html) y monthlyChart/kmChart
  (fuel/list.html).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 17:43:59 +02:00
2fec314f4c Visual improvements 2026-08-05 12:44:56 +02:00
0135421655 Added other test to the navigation 2026-07-31 11:03:30 +02:00
255c035797 Unified the confirm/delete/back buttons 2026-07-31 11:03:06 +02:00
07b9b7c878 Added a night vision and modified the theme of the application 2026-07-30 08:26:48 +02:00
176f9d075c Improvements in the mobile view 2026-07-29 16:41:35 +02:00
bc1a957292 Make responsive 2026-07-29 15:33:10 +02:00
4ad2b59a1c Fixed minor typos 2026-07-29 15:14:14 +02:00
4e03c65c07 Created the .env.example 2026-07-29 13:29:28 +02:00
3e7e1f0aec Merge pull request 'main' (#30) from main into dev
Reviewed-on: #30
2026-07-29 11:21:49 +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:
model = Goal
fields = [
'name',
'kind',
'target_amount',
'category',
"name",
"kind",
"target_amount",
"category",
"include_subcategories",
"account",
"start_date",
"period",
"show_on_home"
"show_on_home",
]
widgets = {
"start_date": forms.DateInput(

View File

@ -5,7 +5,6 @@ from django.conf import settings
from django.db.models import Sum
from functools import cached_property
from django.utils.text import slugify
from django.db.models.fields import related
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 %}
Categorías
{% endblock %}
{% block title %}Categorías{% endblock %}
{% block content %}
<h2>Eliminar categoría</h2>
{% block delete_heading %}Eliminar categoría{% endblock %}
<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 %}
<p class="form-errors">
Esta categoría tiene {{ expense_count }} gasto{{ expense_count|pluralize }} asociado{{ expense_count|pluralize }}
@ -18,7 +16,7 @@
{% if children %}
<p class="form-errors">
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>
{% endif %}
@ -28,11 +26,6 @@
</p>
{% 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 %}
{% block cancel_url %}{% url 'category_list' %}{% endblock %}

View File

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

View File

@ -12,12 +12,13 @@
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Crear</button>
<button type="submit" class="btn btn-primary">Crear</button>
</form>
<hr>
<h3>Listado</h3>
<div class="table-wrap">
<table>
<thead>
<tr>
@ -38,5 +39,6 @@
{% endfor %}
</tbody>
</table>
</div>
{% 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 content %}
<h1>Eliminar cuenta</h1>
{% block delete_heading %}Eliminar cuenta{% endblock %}
<p>
¿Seguro que quieres eliminar la cuenta
<strong>{{ account.name }}</strong>
</p>
{% block delete_question %}¿Seguro que quieres eliminar la cuenta <strong>{{ account.name }}</strong>?{% endblock %}
<form method="post">
{% csrf_token %}
<button class="btn">Sí, eliminar</button>
<a class="btn secondary" href="{% url 'account_list' %}">Cancelar</a>
</form>
{% block delete_button %}Sí, eliminar{% endblock %}
{% endblock %}
{% block cancel_url %}{% url 'account_list' %}{% endblock %}

View File

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

View File

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

View File

@ -1,18 +1,34 @@
{% load static %}
<!DOCTYPE>
<html>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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' %}">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="{% static 'expenses/js/charts.js' %}"></script>
{% block extra_css %}{% endblock %}
</head>
<body>
{% if user.is_authenticated %}
<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 '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>
@ -21,10 +37,10 @@
<!-- <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 '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 %}">
<span class="dropdown-toggle">Configuraciones ▼</span>
<div class="nav-item dropdown {% if active_menu == 'settings' %}active{% endif %}" id="settingsDropdown">
<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 'tag_list' %}">Etiquetas</a>
<a href="{% url 'goal_list' %}">Objetivos</a>
@ -33,6 +49,8 @@
<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>
<form method="post" action="{% url 'logout' %}" class="logout-form">
@ -58,5 +76,7 @@
</main>
{% block extra_js %}{% endblock %}
<script src="{% static 'expenses/js/nav.js' %}" defer></script>
<script src="{% static 'expenses/js/theme.js' %}" defer></script>
</body>
</html>

View File

@ -1,9 +1,19 @@
{% load static %}
<!DOCTYPE html>
<html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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' %}">
</head>
<body>

View File

@ -41,11 +41,14 @@
<a href="{% url 'dashboard' %}?period=this_year" class="preset {% if period == 'this_year' %}active{% endif %}">
Este año
</a>
{% if period %}
<a href="{% url 'dashboard' %}" style="color: red; margin-left: 10px;">❌ Quitar filtros temporales</a>
{% endif %}
</div>
{% if period %}
<div class="clear-filters-row">
<a href="{% url 'dashboard' %}" class="clear-filters">❌ Quitar filtros temporales</a>
</div>
{% endif %}
<br>
<form method="get" action="{% url 'dashboard' %}" id="filtered_form">
@ -86,7 +89,7 @@
Comparar periodo anterior
</label>
<button type="submit">Aplicar Filtros</button>
<button type="submit" class="btn btn-primary">Aplicar Filtros</button>
</form>
</section>
<script>
@ -149,10 +152,10 @@
</div>
{% 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-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>
{% endif %}
</section>
@ -164,15 +167,16 @@
Gastos periodo actual: <strong>{{ kpi_total|floatformat:2 }} €</strong><br>
Gastos periodo anterior: <strong>{{ kpi_previous_total|floatformat:2 }} €</strong><br>
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 }}%)
</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 %}
</small>
</p>
<h3>Desglose de cambios por categoría</h3>
<div class="table-wrap">
<table>
<thead>
<tr>
@ -188,13 +192,14 @@
<td>{{ cat.category }}</td>
<td>{{ cat.previous|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 }}€
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
@ -203,22 +208,23 @@
<!-- ========================= -->
<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>
<div style="position: relative; width: 100%; height: 350px; padding-bottom: 20px;">
<div class="chart-box">
<canvas id="mainChart"></canvas>
</div>
</section>
<section class="chart-box" style="width: 30%; min-width: 280px; max-width: 400px;">
<section>
<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>
</div>
</section>
<section>
<h3>Gastos Recientes</h3>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
@ -236,13 +242,14 @@
<td>{{ exp.account.name }}</td>
<td>{{ exp.category.name }}</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>
{% empty %}
<tr><td colspan="7">No hay gastos recientes registrados</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
@ -252,25 +259,32 @@
const catLabels = [{% for row in by_category_chart %}"{{ row.category__name }}",{% 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',
data: {
labels: catLabels,
datasets: [{
data: catData,
backgroundColor: [
'#ff6384', '#36a2eb', '#cc65fe', '#ffce56', '#4bc0c0', '#ffa1b5'
]
backgroundColor: categoryChartColors.palette
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
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>
<h3>Evolución anual por cuenta ({{ selected_year }})</h3>
@ -290,34 +304,50 @@
const labels = {{ chart_labels|safe }};
const data = {{ chart_data|safe }};
const ctx = document.getElementById('mainChart');
const mainChartColors = getChartColors();
new Chart(ctx, {
const mainChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets:[{
label: 'Gastos (€)',
data: data,
backgroundColor: 'rgba(54, 162, 235, 0.6)',
backgroundColor: 'rgba(54, 162, 235, 1)',
backgroundColor: mainChartColors.primaryColor,
borderWidth: 1
}]
},
options: {
responsive: true,
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"];
{% for acc in accounts_charts %}
(function() {
const dataAccount = {{ acc.data|safe }};
const filteredLabels = allMonths.slice(0, dataAccount.length);
const accountChartColors = getChartColors();
new Chart(
const accountChart = new Chart(
document.getElementById('accountChart{{ acc.id }}'),
{
type: 'line',
@ -327,8 +357,8 @@
label: 'Saldo Real (€)',
data: dataAccount,
fill: false,
borderColor: 'rgba(54, 162, 235, 1)',
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: accountChartColors.primaryColor,
backgroundColor: accountChartColors.primaryFill,
tension: 0.1
}]
},
@ -336,13 +366,25 @@
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
grid: { color: accountChartColors.gridColor },
ticks: { color: accountChartColors.tickColor }
},
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 %}
</script>
@ -398,6 +440,7 @@
<!-- ========================= -->
<!-- Categories -->
<!-- ========================= -->
<div class="table-wrap">
<table>
<thead>
<tr>
@ -414,5 +457,6 @@
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,21 +1,16 @@
{% extends "expenses/base.html" %}
{% extends "expenses/_confirm_delete.html" %}
{% block title %}Eliminar gasto{% endblock %}
{% block content %}
<h1>Eliminar gasto</h1>
<p>
¿Seguro que quieres eliminar el gasto de
<strong>{{ expense.amount }}€</strong>
del {{ expense.date }} perteneciente a
<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>
{% block delete_heading %}Eliminar gasto{% endblock %}
{% block delete_question %}
¿Seguro que quieres eliminar el gasto de
<strong>{{ expense.amount }}€</strong>
del {{ expense.date }} perteneciente a
<strong>{{ expense.account }}</strong>?
{% endblock %}
{% block delete_button %}Sí, eliminar{% endblock %}
{% block cancel_url %}{% url 'expense_list' %}{% endblock %}

View File

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

View File

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

View File

@ -34,7 +34,9 @@
<div class="dashboard-grid">
<div class="card card-chart">
<h2>Últimos meses</h2>
<canvas id="miniChart"></canvas>
<div class="canvas-wrapper">
<canvas id="miniChart"></canvas>
</div>
</div>
</div>
@ -43,21 +45,39 @@
const data = {{ mini_chart_data|safe }};
const ctx = document.getElementById('miniChart');
const miniChartColors = getChartColors();
new Chart(ctx, {
const miniChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets:[{
label: 'Gastos',
data: data,
backgroundColor: miniChartColors.primaryColor,
}]
},
options: {
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>
</section>

View File

@ -1,20 +1,16 @@
{% extends "expenses/base.html" %}
{% extends "expenses/_confirm_delete.html" %}
{% block title %}Eliminar ingreso{% endblock %}
{% block content %}
<h1>Eliminar ingreso</h1>
<p>
¿Seguro que quieres eliminar el ingreso de
<strong>{{ income.amount }}€</strong>
del {{ income.date }} perteneciente a
<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>
{% block delete_heading %}Eliminar ingreso{% endblock %}
{% block delete_question %}
¿Seguro que quieres eliminar el ingreso de
<strong>{{ income.amount }}€</strong>
del {{ income.date }} perteneciente a
<strong>{{ income.account }}</strong>?
{% endblock %}
{% block delete_button %}Sí, eliminar{% endblock %}
{% block cancel_url %}{% url 'income_list' %}{% endblock %}

View File

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

View File

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

View File

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

View File

@ -6,8 +6,9 @@
<form method="post">
{% csrf_token %}
{{ 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>
<a class="btn secondary" href="{% url 'tag_list' %}">Volver</a>
{% endblock %}

View File

@ -1,26 +1,23 @@
{% extends "expenses/base.html" %}
{% extends "expenses/_confirm_delete.html" %}
{% block title %}
Repostajes
{% block title %}Repostajes{% endblock %}
{% 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 %}
{% 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>
{% block delete_warnings %}
<p class="form-errors">
Se eliminará también el gasto asociado en el listado de gastos.
</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 %}
{% 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 %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
{{ form.as_p }}
<button type="submit">
{% if editing %}
Guardar repostaje
{% else %}
Crear repostaje
{% endif %}
</button>
</form>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if editing %}
Guardar repostaje
{% else %}
Crear repostaje
{% endif %}
</button>
{% if next %}
<a class="btn" href="{{ next }}">Volver</a>
{% elif editing %}
<a class="btn" href="{% url 'expense_list' %}">Volver</a>
{% else %}
<a class="btn" href="{% url 'fuel_list' %}">Volver</a>
{% endif %}
{% if next %}
<a class="btn btn-secondary" href="{{ next }}">Volver</a>
{% elif editing %}
<a class="btn btn-secondary" href="{% url 'expense_list' %}">Volver</a>
{% else %}
<a class="btn btn-secondary" href="{% url 'fuel_list' %}">Volver</a>
{% endif %}
</div>
</form>
{% endblock %}

View File

@ -12,6 +12,7 @@
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
@ -41,6 +42,7 @@
{% endfor %}
</tbody>
</table>
</div>
<form method="get">
<select name="year" onchange="this.form.submit()">
@ -57,31 +59,73 @@
<script>
const monthlyData = {{ monthly_data|safe }};
const monthlyChartColors = getChartColors();
new Chart(document.getElementById('monthlyChart'), {
const monthlyChart = new Chart(document.getElementById('monthlyChart'), {
type: 'bar',
data: {
labels: ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'],
datasets: [{
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 kmDates = {{ km_dates|safe }};
const kmChartColors = getChartColors();
new Chart(document.getElementById('kmChart'), {
const kmChart = new Chart(document.getElementById('kmChart'), {
type: 'line',
data: {
labels: kmDates,
datasets: [{
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>
{% endblock %}

View File

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

View File

@ -11,10 +11,11 @@
<form method="post">
{% csrf_token %}
{{ 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 'goal_list' %}">Volver</a>
</div>
</form>
<a class="btn secondary" href="{% url 'goal_list' %}">Volver</a>
{% endblock %}

View File

@ -9,8 +9,9 @@
{% block content %}
<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>
<tr>
<th>Nombre</th>
@ -53,5 +54,6 @@
</tr>
{% endfor %}
</table>
</div>
{% endblock %}

View File

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

View File

@ -5,6 +5,6 @@
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Cambiar</button>
<button type="submit" class="btn btn-primary">Cambiar</button>
</form>
{% 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 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
def _redirect_back(request, fallback):
def _safe_next(request):
next_url = request.POST.get("next") or request.GET.get("next")
if next_url and url_has_allowed_host_and_scheme(
@ -38,9 +38,14 @@ def _redirect_back(request, fallback):
allowed_hosts={request.get_host()},
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):
@ -237,7 +242,6 @@ def expense_create(request):
@login_required
def expense_edit(request, pk):
# sourcery skip: assign-if-exp, merge-else-if-into-elif
expense = get_object_or_404(
Expense,
pk=pk,
@ -805,20 +809,16 @@ def fuel_list(request):
@login_required
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)
if request.method == "POST":
form = FuelEntryForm(request.POST, user=request.user)
form = FuelEntryForm(request.POST, instance=expense, user=request.user)
if form.is_valid():
# Update expense
expense.date = form.save(commit=False)
expense = form.save(commit=False)
expense.description = "Repostaje"
expense.save()
@ -831,7 +831,7 @@ def fuel_edit(request, pk):
else:
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(
request,
@ -855,7 +855,7 @@ def fuel_delete(request, pk):
messages.success(request, "Repostaje eliminado.")
return _redirect_back(request, "fuel_list")
next_url = request.POST.get("next") or request.GET.get("next", "")
next_url = _safe_next(request)
return render(
request,