Updated the web #31
17
expenses_manager/.env.example
Normal file
17
expenses_manager/.env.example
Normal 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
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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
88
expenses_manager/expenses/static/expenses/js/charts.js
Normal file
88
expenses_manager/expenses/static/expenses/js/charts.js
Normal 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;
|
||||||
|
})();
|
||||||
30
expenses_manager/expenses/static/expenses/js/nav.js
Normal file
30
expenses_manager/expenses/static/expenses/js/nav.js
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
20
expenses_manager/expenses/static/expenses/js/theme.js
Normal file
20
expenses_manager/expenses/static/expenses/js/theme.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 }}
|
||||||
@ -18,7 +16,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 %}
|
||||||
|
|
||||||
@ -28,11 +26,6 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<form method="post">
|
{% block cancel_url %}{% url 'category_list' %}{% endblock %}
|
||||||
{% csrf_token %}
|
|
||||||
{{ form.as_p }}
|
|
||||||
<button class="btn danger">Eliminar</button>
|
|
||||||
<a class="btn" href="{% url 'category_list' %}">Cancelar</a>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@ -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 %}
|
||||||
@ -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>
|
||||||
@ -37,6 +38,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -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 %}
|
||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 %}
|
||||||
@ -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 %}
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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,25 +259,32 @@
|
|||||||
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>
|
||||||
|
|
||||||
<h3>Evolución anual por cuenta ({{ selected_year }})</h3>
|
<h3>Evolución anual por cuenta ({{ selected_year }})</h3>
|
||||||
@ -290,34 +304,50 @@
|
|||||||
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"];
|
||||||
|
|
||||||
{% for acc in accounts_charts %}
|
{% for acc in accounts_charts %}
|
||||||
(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,14 +366,26 @@
|
|||||||
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 %}
|
||||||
@ -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>
|
|
||||||
¿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_question %}
|
||||||
|
¿Seguro que quieres eliminar el gasto de
|
||||||
|
<strong>{{ expense.amount }}€</strong>
|
||||||
|
del {{ expense.date }} perteneciente a
|
||||||
|
<strong>{{ expense.account }}</strong>?
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block delete_button %}Sí, eliminar{% endblock %}
|
||||||
|
|
||||||
|
{% block cancel_url %}{% url 'expense_list' %}{% endblock %}
|
||||||
|
|||||||
@ -46,14 +46,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<button type="submit">
|
<div class="form-actions">
|
||||||
{% if form.instance.pk %}
|
<button type="submit" class="btn btn-primary">
|
||||||
Guardar gasto
|
{% if form.instance.pk %}
|
||||||
{% else %}
|
Guardar gasto
|
||||||
Crear gasto
|
{% else %}
|
||||||
{% endif %}
|
Crear gasto
|
||||||
</button>
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-secondary" href="{% url 'expense_list' %}">Volver</a>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<a href="{% url 'expense_list' %}">Volver</a>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -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,7 +148,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<span class="step-links">
|
<span class="step-links">
|
||||||
{% if page_obj.has_previous %}
|
{% if page_obj.has_previous %}
|
||||||
|
|||||||
@ -33,8 +33,10 @@
|
|||||||
<section>
|
<section>
|
||||||
<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>
|
||||||
<canvas id="miniChart"></canvas>
|
<div class="canvas-wrapper">
|
||||||
|
<canvas id="miniChart"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -43,22 +45,40 @@
|
|||||||
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
registerThemedChart(miniChart, function (chart, colors) {
|
||||||
|
chart.data.datasets[0].backgroundColor = colors.primaryColor;
|
||||||
|
applyCartesianColors(chart, colors);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
{% endblock %}
|
||||||
|
|
||||||
<form method="post">
|
{% block delete_button %}Sí, eliminar{% endblock %}
|
||||||
{% csrf_token %}
|
|
||||||
<button class="btn">Sí, eliminar</button>
|
|
||||||
<a class="btn secondary" href="{% url 'income_list' %}">Cancelar</a>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% block cancel_url %}{% url 'income_list' %}{% endblock %}
|
||||||
|
|||||||
@ -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">
|
||||||
{% if form.instance.pk %}
|
<button type="submit" class="btn btn-primary">
|
||||||
Guardar ingreso
|
{% if form.instance.pk %}
|
||||||
{% else %}
|
Guardar ingreso
|
||||||
Crear ingreso
|
{% else %}
|
||||||
{% endif %}
|
Crear ingreso
|
||||||
</button>
|
{% endif %}
|
||||||
|
</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 %}
|
||||||
@ -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 %}
|
||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 %}
|
||||||
@ -1,26 +1,23 @@
|
|||||||
{% extends "expenses/base.html" %}
|
{% extends "expenses/_confirm_delete.html" %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}Repostajes{% endblock %}
|
||||||
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 content %}
|
{% block delete_warnings %}
|
||||||
<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 %}
|
||||||
|
|
||||||
<form method="post">
|
{% block delete_extra_fields %}
|
||||||
{% csrf_token %}
|
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||||
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
{% endblock %}
|
||||||
<button class="btn danger">Eliminar</button>
|
|
||||||
<a class="btn" href="{% url 'fuel_list' %}">Cancelar</a>
|
{% block cancel_url %}{% if next %}{{ next }}{% else %}{% url 'fuel_list' %}{% endif %}{% endblock %}
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@ -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">
|
||||||
{% if editing %}
|
<button type="submit" class="btn btn-primary">
|
||||||
Guardar repostaje
|
{% if editing %}
|
||||||
|
Guardar repostaje
|
||||||
|
{% else %}
|
||||||
|
Crear repostaje
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% 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 %}
|
{% else %}
|
||||||
Crear repostaje
|
<a class="btn btn-secondary" href="{% url 'fuel_list' %}">Volver</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</button>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% 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 %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -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,31 +59,73 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -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 %}
|
|
||||||
|
|||||||
@ -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">
|
||||||
Guardar
|
<button type="submit" class="btn btn-primary">
|
||||||
</button>
|
Guardar
|
||||||
|
</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 %}
|
||||||
@ -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 %}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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 %}
|
||||||
@ -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
|
||||||
|
|||||||
@ -30,17 +30,22 @@ 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(
|
||||||
next_url,
|
next_url,
|
||||||
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,8 +831,8 @@ 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,
|
||||||
"fuel/create.html",
|
"fuel/create.html",
|
||||||
@ -855,8 +855,8 @@ 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,
|
||||||
"fuel/confirm_delete.html",
|
"fuel/confirm_delete.html",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user