Merge pull request 'dev' (#7) from dev into main

Reviewed-on: #7
This commit is contained in:
jkuijperm 2026-05-05 19:17:11 +00:00
commit 6003247880
12 changed files with 1052 additions and 670 deletions

View File

@ -1,90 +1,101 @@
from django import forms
from .models import Expense, Category, Income, Tag, Account, FuelEntry
from .models import Expense, Category, Income, Tag, Account, FuelEntry, Goal
class ExpenseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
# Get user
user = kwargs.pop('user', None)
user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
if user:
self.fields['category'].queryset = Category.objects.filter(owner=user)
self.fields['tags'].queryset = Tag.objects.filter(owner=user)
self.fields['account'].queryset = Account.objects.filter(owner=user, active=True)
self.fields['date'].input_formats = ['%Y-%m-%d']
self.fields["category"].queryset = Category.objects.filter(owner=user)
self.fields["tags"].queryset = Tag.objects.filter(owner=user)
self.fields["account"].queryset = Account.objects.filter(
owner=user, active=True
)
self.fields["date"].input_formats = ["%Y-%m-%d"]
class Meta:
model = Expense
fields = [
'date',
'amount',
'description',
'category',
'account',
'tags',
"date",
"amount",
"description",
"category",
"account",
"tags",
]
widgets = {
'date': forms.DateInput(
format='%Y-%m-%d',
attrs={'type': 'date'}),
'widget': forms.CheckboxSelectMultiple(),
"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
"widget": forms.CheckboxSelectMultiple(),
}
class TagForm(forms.ModelForm):
class Meta:
model = Tag
fields = ['name']
fields = ["name"]
class AccountForm(forms.ModelForm):
class Meta:
model = Account
fields = ['name', 'initial_balance', 'active']
fields = ["name", "initial_balance", "active"]
class IncomeForm(forms.ModelForm):
class Meta:
model = Income
fields = ['account', 'name', 'amount', 'date']
widgets = {
'date': forms.DateInput(
format='%Y-%m-%d',
attrs={'type': 'date'})
}
fields = ["account", "name", "amount", "date"]
widgets = {"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user')
user = kwargs.pop("user")
super().__init__(*args, **kwargs)
self.fields['account'].queryset = Account.objects.filter(
self.fields["account"].queryset = Account.objects.filter(
owner=user,
active=True,
)
class FuelEntryForm(forms.Form):
# Expense fields
date = forms.DateField(widget=forms.DateInput(
format='%Y-%m-%d',
attrs={'type': 'date'}))
date = forms.DateField(
widget=forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})
)
amount = forms.DecimalField(max_digits=10, decimal_places=2)
account = forms.ModelChoiceField(queryset=None)
# Specifics fuel fields
odometer = forms.DecimalField(label='Current kilometers')
odometer = forms.DecimalField(label="Current kilometers")
liters = forms.DecimalField(max_digits=8, decimal_places=2)
def __init__(self, *args, **kwargs):
user = kwargs.pop('user')
user = kwargs.pop("user")
super().__init__(*args, **kwargs)
if user:
self.fields['account'].queryset = (
user.accounts.filter(active=True)
)
self.fields["account"].queryset = user.accounts.filter(active=True)
class CategoryForm(forms.ModelForm):
class Meta:
model = Category
fields = ['name', 'parent']
fields = ["name", "parent"]
class GoalForm(forms.ModelForm):
class Meta:
model = Goal
fields = ['name', 'target_amount', 'category', "show_on_home"]
def __init__(self, *args, **kwargs):
user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['category'].queryset = (
user.categories.all()
)

View File

@ -0,0 +1,27 @@
# Generated by Django 5.2.10 on 2026-05-05 09:08
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0007_alter_category_options_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Goal',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('target_amount', models.DecimalField(decimal_places=2, max_digits=12)),
('created_at', models.DateTimeField(auto_now_add=True)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='expenses.category')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.10 on 2026-05-05 13:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0008_goal'),
]
operations = [
migrations.AddField(
model_name='goal',
name='show_on_home',
field=models.BooleanField(default=False),
),
]

View File

@ -7,141 +7,127 @@ from django.db.models.fields import related
from django.db.models.functions import ExtractMonth
from django.utils.text import slugify
class Category(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(blank=True)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="categories",
)
parent = models.ForeignKey(
'self',
"self",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="children",
)
class Meta:
unique_together = ("name", "parent", "owner", "slug")
verbose_name_plural = "categories"
ordering = ["name"]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
from django.utils.text import slugify
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Account(models.Model):
class Account(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="accounts",
)
name = models.CharField(max_length=100)
initial_balance = models.DecimalField(
max_digits=12,
decimal_places=2,
default=0
)
name = models.CharField(max_length=100)
initial_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0)
active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
ordering = ["name"]
def current_balance(self):
expenses_total = (
self.expenses.aggregate(total=Sum('amount'))['total']
or Decimal('0')
)
income_total = (
self.incomes.aggregate(total=Sum('amount'))['total']
or Decimal('0')
expenses_total = self.expenses.aggregate(total=Sum("amount"))[
"total"
] or Decimal("0")
income_total = self.incomes.aggregate(total=Sum("amount"))["total"] or Decimal(
"0"
)
return self.initial_balance + income_total - expenses_total
def monthly_balance(self, year=None):
year = year or date.today().year
incomes = (
self.incomes
.filter(date__year=year)
.annotate(month=ExtractMonth('date'))
.values('month')
.annotate(total=Sum('amount'))
self.incomes.filter(date__year=year)
.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
expenses = (
self.expenses
.filter(date__year=year)
.annotate(month=ExtractMonth('date'))
.values('month')
.annotate(total=Sum('amount'))
self.expenses.filter(date__year=year)
.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
income_map = {i['month']: i['total'] for i in incomes}
expenses_map = {e['month']: e['total'] for e in expenses}
income_map = {i["month"]: i["total"] for i in incomes}
expenses_map = {e["month"]: e["total"] for e in expenses}
balance = self.initial_balance
data = []
for month in range(1, 13):
balance += income_map.get(month, Decimal('0'))
balance -= expenses_map.get(month, Decimal('0'))
data.append({
'month': month,
'balance': float(balance),
})
balance += income_map.get(month, Decimal("0"))
balance -= expenses_map.get(month, Decimal("0"))
data.append(
{
"month": month,
"balance": float(balance),
}
)
return data
def balance_until(self, date):
incomes_total = (
self.incomes
.filter(date__lte=date)
.aggregate(total=Sum('amount'))['total'] or Decimal('0')
)
expenses_total = (
self.expenses
.filter(date__lte=date)
.aggregate(total=Sum('amount'))['total'] or Decimal('0')
)
incomes_total = self.incomes.filter(date__lte=date).aggregate(
total=Sum("amount")
)["total"] or Decimal("0")
expenses_total = self.expenses.filter(date__lte=date).aggregate(
total=Sum("amount")
)["total"] or Decimal("0")
return self.initial_balance + incomes_total - expenses_total
def monthly_net(self, year=None):
year = year or date.today().year
data = []
for month in range(1, 13):
income = (
self.incomes
.filter(date__year=year, date__month=month)
.aggregate(total=Sum('amount'))['total'] or Decimal('0')
)
expense = (
self.expenses
.filter(date__year=year, date__month=month)
.aggregate(total=Sum('amount'))['total'] or Decimal('0')
)
data.append({
'month': month,
'net': float(income - expense)
})
income = self.incomes.filter(date__year=year, date__month=month).aggregate(
total=Sum("amount")
)["total"] or Decimal("0")
expense = self.expenses.filter(
date__year=year, date__month=month
).aggregate(total=Sum("amount"))["total"] or Decimal("0")
data.append({"month": month, "net": float(income - expense)})
return data
def __str__(self):
return self.name
@ -153,108 +139,145 @@ class Tag(models.Model):
on_delete=models.CASCADE,
related_name="tags",
)
class Meta:
unique_together = ("name", "owner")
def __str__(self):
return self.name
class Expense(models.Model):
date = models.DateField()
amount = models.DecimalField(max_digits=10, decimal_places=2)
description = models.TextField(blank=True)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="expenses",
)
category = models.ForeignKey(
Category,
on_delete=models.PROTECT,
related_name="expenses",
)
account = models.ForeignKey(
Account,
on_delete=models.PROTECT,
related_name="expenses",
)
tags = models.ManyToManyField(
Tag,
blank=True,
related_name="expenses",
)
created_at = models.DateField(auto_now_add=True)
class Meta:
ordering = ["-date"]
def __str__(self):
return "{} - {}".format(self.date, self.amount)
class Income(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE
)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
account = models.ForeignKey(
Account,
on_delete=models.CASCADE,
related_name='incomes'
Account, on_delete=models.CASCADE, related_name="incomes"
)
name = models.CharField(max_length=150)
amount = models.DecimalField(max_digits=12, decimal_places=2)
date = models.DateField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-date']
ordering = ["-date"]
def __str__(self):
return f'{self.name} - {self.amount}'
return f"{self.name} - {self.amount}"
class FuelEntry(models.Model):
expense = models.OneToOneField(
Expense,
on_delete=models.CASCADE,
related_name="fuel_data"
Expense, on_delete=models.CASCADE, related_name="fuel_data"
)
odometer = models.PositiveIntegerField() # kilometers
odometer = models.PositiveIntegerField() # kilometers
liters = models.DecimalField(max_digits=8, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['odometer']
ordering = ["odometer"]
def km_since_previous(self):
previous = FuelEntry.objects.filter(
expense__owner=self.expense.owner,
odometer__lt=self.odometer
).order_by('-odometer').first()
previous = (
FuelEntry.objects.filter(
expense__owner=self.expense.owner, odometer__lt=self.odometer
)
.order_by("-odometer")
.first()
)
if previous:
return self.odometer - previous.odometer
return None
def price_per_liter(self):
if self.liters:
return self.expense.amount / self.liters
return 0
def consumption(self):
km = self.km_since_previous()
if km and km > 0:
return (self.liters / km) * 100
return None
class Goal(models.Model):
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
name = models.CharField(max_length=100)
target_amount = models.DecimalField(max_digits=12, decimal_places=2)
category = models.ForeignKey("Category", on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
show_on_home = models.BooleanField(default=False)
def progress(self):
"""
Calculate the accumulated spending for the goal category.
This method returns the sum of all expenses of the owner in the goal's category.
Returns:
Decimal: Total amount spent in the goal category, or Decimal('0') when there is no spending.
"""
total = Expense.objects.filter(
owner=self.owner,
category=self.category,
).aggregate(total=Sum("amount"))["total"]
return total or 0
def percentage(self):
"""
Calculate the completion percentage of the goal.
This method returns how much of the target amount has been reached as a percentage.
Returns:
Decimal: Percentage of the goal that has been reached, or Decimal('0') when the target amount is zero.
"""
if self.target_amount == 0:
return 0
return (self.progress() / self.target_amount) * 100
def __str__(self):
return self.name

View File

@ -80,7 +80,7 @@ a.danger {
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-template-columns: repeat(2, 1fr);
gap: 20px
}
@ -89,15 +89,14 @@ a.danger {
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-chart {
max-width: 300px;
}
.card-chart canvas {
width: 100% !important;
height: 220px !important;
height: 500px;
position: relative;
}
.chart-box {
@ -105,6 +104,19 @@ a.danger {
margin-bottom: 2rem;
}
.canvas-wrapper {
flex-grow: 1;
min-height: 0;
position: relative;
width: 100%;
}
.card-chart canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@ -310,4 +322,96 @@ tbody tr:hover {
.tag:hover {
background: #dbe3ee;
}
.dropdown {
position: relative;
cursor: pointer;
color: #e5e7eb;
}
.dropdown.active {
font-weight: bold;
border-bottom: 2px solid white;
}
.dropdown-toggle {
padding: 10px;
display: inline-block;
}
.dropdown-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
min-width: 150px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.dropdown-menu a {
display: block;
padding: 10px;
text-decoration: none;
color: #333;
}
.dropdown-menu a:hover {
background-color: #f5f5f5;
}
.dropdown:hover .dropdown-menu {
display: block;
}
.progress-container {
display: flex;
align-items: center;
gap: 10px;
}
.progress-label {
min-width: 50px;
font-size: 12px;
color: #666;
}
.progress-bar {
width: 100%;
background: #e0e0e0;
border-radius: 6px;
height: 14px;
overflow: hidden;
}
.progress-fill {
height: 100%;
transition: width 0.6s ease-in-out;
}
.progress-fill.low {
background: #e74c3c;
}
.progress-fill.medium {
background: #f1c40f;
}
.progress-fill.high {
background: #2ecc71;
}
.goals-widget {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.goal-card {
padding: 10px;
border-radius: 8px;
background: #f9f9f9;
}

View File

@ -18,9 +18,18 @@
<a href="{% url 'expense_list' %}" class="nav-item {% if active_menu == 'expenses' %}active{% endif %} ">Gastos</a>
<a href="{% url 'account_list' %}" class="nav-item {% if active_menu == 'accounts' %}active{% endif %} ">Cuentas</a>
<a href="{% url 'income_list' %}" class="nav-item {% if active_menu == 'incomes' %}active{% endif %} ">Ingresos</a>
<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 '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 %}">
<span class="dropdown-toggle">Configuraciones ▼</span>
<div class="dropdown-menu">
<a href="{% url 'category_list' %}">Categorías</a>
<a href="{% url 'tag_list' %}">Etiquetas</a>
<a href="{% url 'goal_list' %}">Objetivos</a>
</div>
</div>
<span class="spacer"></span>

View File

@ -1,5 +1,7 @@
{% extends "expenses/base.html" %}
{% load l10n %}
{% block title %}Dashboard{% endblock %}
{% block content %}
@ -147,8 +149,10 @@
<div class="card card-chart">
<h3>{{ acc.name }}</h3>
<p><strong>Saldo actual:</strong> {{ acc.current_balance|floatformat:2 }} €</p>
<canvas id="accountChart{{ acc.id }}"></canvas>
<div class="canvas-wrapper">
<canvas id="accountChart{{ acc.id }}"></canvas>
</div>
</div>
{% endfor %}
</div>
@ -214,6 +218,39 @@
</section>
{% endif %}
<!-- ========================= -->
<!-- Goals -->
<!-- ========================= -->
<h3>Objetivos</h3>
{% if goals %}
<div class="goals-widget">
{% for goal in goals %}
<div class="goal-card">
<strong>{{ goal.name }}</strong>
<div class="progress-bar">
<div class="progress-fill
{% if goal.percentage < 50 %} low
{% elif goal.percentage < 80 %} medium
{% else %} high
{% endif %}"
style="width: {{ goal.percentage|unlocalize }}%"></div>
</div>
<span class="progress-label">
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)
</span>
</div>
{% endfor %}
</div>
{% else %}
<p>No hay objetivos.</p>
{% endif %}
<!-- ========================= -->
<!-- Categories -->
<!-- ========================= -->
<table>
<thead>
<tr>

View File

@ -1,5 +1,7 @@
{% extends "expenses/base.html" %}
{% load l10n %}
{% block title %}Home{% endblock %}
{% block content %}
@ -60,4 +62,30 @@
</section>
<h3>Objetivos</h3>
{% if goals %}
<div class="goals-widget">
{% for goal in goals %}
<div class="goal-card">
<strong>{{ goal.name }}</strong>
<div class="progress-bar">
<div class="progress-fill
{% if goal.percentage < 50 %} low
{% elif goal.percentage < 80 %} medium
{% else %} high
{% endif %}"
style="width: {{ goal.percentage|unlocalize }}%"></div>
</div>
<span class="progress-label">
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)
</span>
</div>
{% endfor %}
</div>
{% else %}
<p>No tienes objetivos aún.</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "expenses/base.html" %}
{% block title %}
Nuevo objetivo
{% endblock %}
{% block content %}
<h1>
Nuevo objetivo
</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">
Guardar
</button>
</form>
<a class="btn secondary" href="{% url 'goal_list' %}">Volver</a>
{% endblock %}

View File

@ -0,0 +1,51 @@
{% extends "expenses/base.html" %}
{% load l10n %}
{% block title %}
Objetivos
{% endblock %}
{% block content %}
<h2>Objetivos</h2>
<a class=btn href="{% url 'goal_create' %}"> Nuevo objetivo</a>
<table>
<tr>
<th>Nombre</th>
<th>Progreso</th>
<th></th>
</tr>
{% for goal in goals %}
<tr>
<td>{{ goal.name }}</td>
<td>
<div class="progress-container">
<span class="progress-label">
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€
</span>
<div class="progress-bar">
<div class="progress-fill
{% if goal.percentage < 50 %} low
{% elif goal.percentage < 80 %} medium
{% else %} high
{% endif %}"
style="width: {{ goal.percentage|unlocalize }}%"></div>
</div>
<span class="progress-label">
{{ goal.percentage|floatformat:1}}%
</span>
</div>
</td>
<td>
<a href="{% url 'goal_edit' goal.id %}">Editar</a>
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@ -24,4 +24,9 @@ urlpatterns = [
path('fuel/create/', views.fuel_create, name='fuel_create'),
path('fuel/<int:pk>/edit/', views.fuel_edit, name='fuel_edit'),
path('categories/', views.category_list, name='category_list'),
path('settings/', views.settings_index, name='settings_index'),
path('goals/', views.goal_list, name='goal_list'),
path('goals/new/', views.goal_create, name='goal_create'),
path('goals/<int:pk>/edit/', views.goal_edit, name='goal_edit'),
path('goals/<int:pk>/delete/', views.goal_delete, name='goal_delete'),
]

File diff suppressed because it is too large Load Diff