Compare commits
14 Commits
a5c47d1380
...
790fa214e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 790fa214e3 | |||
| a456e903df | |||
| e621a6f9f0 | |||
| ed17d27457 | |||
| 06e61ed1bf | |||
| c7ba7a970f | |||
| 61047df4bf | |||
| a2eec67491 | |||
| 03016209b0 | |||
| 2267146d0d | |||
| 0dd8c5bb49 | |||
| c29ed0d8ae | |||
| e1b4a6eef7 | |||
| 8d1f477940 |
2
expenses_manager/Jenkinsfile
vendored
2
expenses_manager/Jenkinsfile
vendored
@ -2,6 +2,7 @@ pipeline{
|
||||
agent any
|
||||
|
||||
environment {
|
||||
DEBUG = 'TRUE'
|
||||
DJANGO_SECRET_KEY = 'test-secret-key-for-ci'
|
||||
DJANGO_SETTINGS_MODULE = "expenses_manager.settings"
|
||||
PYTHONUNBUFFERED = "1"
|
||||
@ -33,7 +34,6 @@ pipeline{
|
||||
cd expenses_manager
|
||||
|
||||
. venv/bin/activate
|
||||
export SECRET_KEY=$DJANGO_SECRET_KEY
|
||||
venv/bin/pytest --cov
|
||||
'''
|
||||
}
|
||||
|
||||
@ -92,20 +92,86 @@ class CategoryForm(forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if user:
|
||||
self.fields["parent"].queryset = Category.objects.filter(
|
||||
owner=user,
|
||||
)
|
||||
queryset = Category.objects.filter(owner=user)
|
||||
|
||||
if self.instance.pk:
|
||||
queryset = queryset.exclude(
|
||||
pk__in=self.instance.descendant_ids()
|
||||
)
|
||||
|
||||
self.fields["parent"].queryset = queryset
|
||||
|
||||
def clean_parent(self):
|
||||
parent = self.cleaned_data.get("parent")
|
||||
|
||||
if parent and self.instance.pk:
|
||||
if parent.pk in self.instance.descendant_ids():
|
||||
raise forms.ValidationError(
|
||||
"Una categoría no puede ser su propio padre ni depender "
|
||||
"de una de sus subcategorías."
|
||||
)
|
||||
|
||||
return parent
|
||||
|
||||
|
||||
class GoalForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Goal
|
||||
fields = ['name', 'target_amount', 'category', "show_on_home"]
|
||||
fields = [
|
||||
'name',
|
||||
'kind',
|
||||
'target_amount',
|
||||
'category',
|
||||
"include_subcategories",
|
||||
"account",
|
||||
"start_date",
|
||||
"period",
|
||||
"show_on_home"
|
||||
]
|
||||
widgets = {
|
||||
"start_date": forms.DateInput(
|
||||
format="%Y-%m-%d", attrs={"type": "date"}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['category'].queryset = (
|
||||
user.categories.all()
|
||||
)
|
||||
self.fields['category'].queryset = user.categories.all()
|
||||
self.fields['account'].queryset = user.accounts.filter(active=True)
|
||||
|
||||
self.fields['category'].required = False
|
||||
self.fields['account'].required = False
|
||||
self.fields['start_date'].input_formats = ["%Y-%m-%d"]
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
kind = cleaned.get("kind")
|
||||
|
||||
if kind in (Goal.KIND_PAYMENT, Goal.KIND_BUDGET):
|
||||
if not cleaned.get('category'):
|
||||
self.add_error(
|
||||
"category",
|
||||
"Selecciona una categoría para este tipo de objetivo.",
|
||||
)
|
||||
cleaned["account"] = None
|
||||
|
||||
if kind == Goal.KIND_SAVING:
|
||||
if not cleaned.get("account"):
|
||||
self.add_error(
|
||||
"account",
|
||||
"Selecciona la cuenta donde se acumula el ahorro.",
|
||||
)
|
||||
cleaned["category"] = None
|
||||
|
||||
if kind == Goal.KIND_BUDGET:
|
||||
if cleaned.get("period") == Goal.PERIOD_NONE:
|
||||
self.add_error(
|
||||
"period",
|
||||
"Un presupuesto necesita un periodo: mensual o anual",
|
||||
)
|
||||
else:
|
||||
cleaned["period"] = Goal.PERIOD_NONE
|
||||
|
||||
return cleaned
|
||||
@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.2.10 on 2026-07-23 14:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('expenses', '0009_goal_show_on_home'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='goal',
|
||||
name='account',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='goals', to='expenses.account'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='goal',
|
||||
name='include_subcategories',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='goal',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('payment', 'Pago / deuda'), ('budget', 'Presupuesto'), ('saving', 'Ahorro')], default='payment', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='goal',
|
||||
name='period',
|
||||
field=models.CharField(choices=[('none', 'Sin reinicio'), ('month', 'Mensual'), ('year', 'Anual')], default='none', max_length=6),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='goal',
|
||||
name='start_date',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='goal',
|
||||
name='category',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='expenses.category'),
|
||||
),
|
||||
]
|
||||
@ -41,6 +41,22 @@ class Category(models.Model):
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def descendant_ids(self, include_self=True):
|
||||
"""IDs de esta categoría y de toda su descendencia."""
|
||||
seen = {self.pk}
|
||||
pending = [self.pk]
|
||||
|
||||
while pending:
|
||||
children = list(
|
||||
Category.objects.filter(parent_id__in=pending)
|
||||
.exclude(pk__in=seen)
|
||||
.values_list("pk", flat=True)
|
||||
)
|
||||
seen.update(children)
|
||||
pending = children
|
||||
|
||||
return list(seen) if include_self else [i for i in seen if i != self.pk]
|
||||
|
||||
|
||||
class Account(models.Model):
|
||||
owner = models.ForeignKey(
|
||||
@ -135,19 +151,33 @@ class Account(models.Model):
|
||||
|
||||
def monthly_net(self, year=None):
|
||||
year = year or date.today().year
|
||||
data = []
|
||||
incomes = (
|
||||
self.incomes.filter(date__year=year)
|
||||
.annotate(month=ExtractMonth("date"))
|
||||
.values("month")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
|
||||
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")
|
||||
expenses = (
|
||||
self.expenses.filter(date__year=year)
|
||||
.annotate(month=ExtractMonth("date"))
|
||||
.values("month")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
|
||||
data.append({"month": month, "net": float(income - expense)})
|
||||
income_map = {i["month"]: Decimal(str(i["total"] or 0)) for i in incomes}
|
||||
expenses_map = {e["month"]: Decimal(str(e["total"] or 0)) for e in expenses}
|
||||
|
||||
return data
|
||||
return [
|
||||
{
|
||||
"month": month,
|
||||
"net": float(
|
||||
income_map.get(month, Decimal("0"))
|
||||
- expenses_map.get(month, Decimal("0"))
|
||||
),
|
||||
}
|
||||
for month in range(1, 13)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -264,41 +294,119 @@ class FuelEntry(models.Model):
|
||||
|
||||
|
||||
class Goal(models.Model):
|
||||
KIND_PAYMENT = "payment"
|
||||
KIND_BUDGET = "budget"
|
||||
KIND_SAVING = "saving"
|
||||
|
||||
KIND_CHOICES = [
|
||||
(KIND_PAYMENT, "Pago / deuda"),
|
||||
(KIND_BUDGET, "Presupuesto"),
|
||||
(KIND_SAVING, "Ahorro"),
|
||||
]
|
||||
|
||||
PERIOD_NONE = "none"
|
||||
PERIOD_MONTH = "month"
|
||||
PERIOD_YEAR = "year"
|
||||
|
||||
PERIOD_CHOICES = [
|
||||
(PERIOD_NONE, "Sin reinicio"),
|
||||
(PERIOD_MONTH, "Mensual"),
|
||||
(PERIOD_YEAR, "Anual"),
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
kind = models.CharField(
|
||||
max_length=10, choices=KIND_CHOICES, default=KIND_PAYMENT
|
||||
)
|
||||
|
||||
# Para pago y presupuesto
|
||||
category = models.ForeignKey(
|
||||
"Category", on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
include_subcategories = models.BooleanField(default=True)
|
||||
|
||||
# Para ahorro (de momento se mide con el saldo de la cuenta)
|
||||
account = models.ForeignKey(
|
||||
"Account",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="goals",
|
||||
)
|
||||
|
||||
# Desde cuándo cuenta (pago) / cada cuánto se reinicia (presupuesto)
|
||||
start_date = models.DateField(null=True, blank=True)
|
||||
period = models.CharField(
|
||||
max_length=6, choices=PERIOD_CHOICES, default=PERIOD_NONE
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
show_on_home = models.BooleanField(default=False)
|
||||
|
||||
def _period_start(self):
|
||||
"""Fecha desde la que se cuenta el progreso, o None si es todo."""
|
||||
if self.kind == self.KIND_BUDGET:
|
||||
today = date.today()
|
||||
if self.period == self.PERIOD_MONTH:
|
||||
return date(today.year, today.month, 1)
|
||||
if self.period == self.PERIOD_YEAR:
|
||||
return date(today.year, 1, 1)
|
||||
|
||||
return self.start_date
|
||||
|
||||
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.
|
||||
if self.kind == self.KIND_SAVING:
|
||||
# Provisional: saldo de la cuenta asociada. Cuando exista el
|
||||
# módulo de inversiones, esta rama es lo único que hay que tocar.
|
||||
return self.account.current_balance() if self.account else Decimal("0")
|
||||
|
||||
Returns:
|
||||
Decimal: Total amount spent in the goal category, or Decimal('0') when there is no spending.
|
||||
if not self.category:
|
||||
return Decimal("0")
|
||||
|
||||
"""
|
||||
total = Expense.objects.filter(
|
||||
owner=self.owner,
|
||||
category=self.category,
|
||||
).aggregate(total=Sum("amount"))["total"]
|
||||
if self.include_subcategories:
|
||||
category_ids = self.category.descendant_ids()
|
||||
else:
|
||||
category_ids = [self.category_id]
|
||||
|
||||
return total or 0
|
||||
expenses = Expense.objects.filter(
|
||||
owner=self.owner, category_id__in=category_ids
|
||||
)
|
||||
|
||||
start = self._period_start()
|
||||
if start:
|
||||
expenses = expenses.filter(date__gte=start)
|
||||
|
||||
return expenses.aggregate(total=Sum("amount"))["total"] or Decimal("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:
|
||||
if not self.target_amount:
|
||||
return 0
|
||||
return (self.progress() / self.target_amount) * 100
|
||||
|
||||
def bar_width(self):
|
||||
return min(float(self.percentage()), 100)
|
||||
|
||||
def progress_state(self):
|
||||
pct = self.percentage()
|
||||
|
||||
if self.kind == self.KIND_BUDGET:
|
||||
if pct > 100:
|
||||
return "danger"
|
||||
if pct >= 80:
|
||||
return "warning"
|
||||
return "ok"
|
||||
|
||||
return "complete" if pct >= 100 else "ok"
|
||||
|
||||
def is_exceeded(self):
|
||||
"""True si es un presupuesto que ya se ha pasado del límite."""
|
||||
return (
|
||||
self.kind == self.KIND_BUDGET
|
||||
and self.progress() > self.target_amount
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@ -519,3 +519,16 @@ tbody tr:hover {
|
||||
border-color: #1e7e34;
|
||||
color: #1e7e34;
|
||||
}
|
||||
|
||||
.progress-fill.ok {
|
||||
background-color: #1e88e5;
|
||||
}
|
||||
.progress-fill.complete {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
.progress-fill.warning {
|
||||
background-color: #f9a825;
|
||||
}
|
||||
.progress-fill.danger {
|
||||
background-color: #b71c1c;
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
{% extends "expenses/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Categorías
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Eliminar categoría</h2>
|
||||
|
||||
<p>¿Seguro que quieres eliminar la categoría <strong>{{ category.name }}</strong>?</p>
|
||||
|
||||
{% if expense_count %}
|
||||
<p class="form-errors">
|
||||
Esta categoría tiene {{ expense_count }} gasto{{ expense_count|pluralize }} asociado{{ expense_count|pluralize }}
|
||||
y no se puede eliminar. Reasigna esos gastos a otra categoría primero.
|
||||
</p>
|
||||
{% else %}
|
||||
{% 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 %}.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if goal_count %}
|
||||
<p class="form-errors">
|
||||
Atención: se eliminarán también {{ goal_count }} objetivo{{ goal_count|pluralize }} asociado{{ goal_count|pluralize }} a esta categoría.
|
||||
</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 %}
|
||||
16
expenses_manager/expenses/templates/categories/form.html
Normal file
16
expenses_manager/expenses/templates/categories/form.html
Normal file
@ -0,0 +1,16 @@
|
||||
{% extends "expenses/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Categorías
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Editar categoría</h2>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit">Guardar</button>
|
||||
<a class="btn" href="{% url 'category_list' %}">Cancelar</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@ -30,6 +30,10 @@
|
||||
<tr>
|
||||
<td>{{ category.name }}</td>
|
||||
<td>{% if category.parent %}{{ category.parent.name }}{% endif %}</td>
|
||||
<td class="table-actions">
|
||||
<a href="{% url 'category_edit' category.id %}">Editar</a>
|
||||
<a href="{% url 'category_delete' category.id %}" class="danger">Eliminar</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@ -128,12 +128,13 @@
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="table-actions">
|
||||
{% if expense.category.slug == 'gasolina' %}
|
||||
<a href="{% url 'fuel_edit' expense.id %}">Editar</a>
|
||||
{% if expense.fuel_data %}
|
||||
<a href="{% url 'fuel_edit' expense.id %}?next={{ request.get_full_path|urlencode }}">Editar</a>
|
||||
<a href="{% url 'fuel_delete' expense.id %}?next={{ request.get_full_path|urlencode }}" class="danger">Eliminar</a>
|
||||
{% else %}
|
||||
<a href="{% url 'expense_edit' expense.id %}">Editar</a>
|
||||
<a href="{% url 'expense_delete' expense.id %}" class="danger">Eliminar</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'expense_delete' expense.id %}" class="danger">Eliminar</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
@ -68,14 +68,13 @@
|
||||
{% for goal in goals %}
|
||||
<div class="goal-card">
|
||||
<strong>{{ goal.name }}</strong>
|
||||
{% if goal.is_exceeded %}
|
||||
<span class="badge badge-inactive">Excedido</span>
|
||||
{% endif %}
|
||||
|
||||
<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 class="progress-fill {{ goal.progress_state }}"
|
||||
style="width: {{ goal.bar_width|unlocalize }}%"></div>
|
||||
</div>
|
||||
|
||||
<span class="progress-label">
|
||||
|
||||
26
expenses_manager/expenses/templates/fuel/confirm_delete.html
Normal file
26
expenses_manager/expenses/templates/fuel/confirm_delete.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends "expenses/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Repostajes
|
||||
{% 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>
|
||||
|
||||
<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 %}
|
||||
@ -19,6 +19,7 @@
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit">
|
||||
{% if editing %}
|
||||
@ -29,9 +30,11 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if editing %}
|
||||
<a href="{% url 'expense_list' %}">Volver</a>
|
||||
{% if next %}
|
||||
<a class="btn" href="{{ next }}">Volver</a>
|
||||
{% elif editing %}
|
||||
<a class="btn" href="{% url 'expense_list' %}">Volver</a>
|
||||
{% else %}
|
||||
<a href="{% url 'fuel_list' %}">Volver</a>
|
||||
<a class="btn" href="{% url 'fuel_list' %}">Volver</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@ -21,6 +21,7 @@
|
||||
<th>Gasto</th>
|
||||
<th>€/L</th>
|
||||
<th>Km desde anterior</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -32,6 +33,10 @@
|
||||
<td>{{ fuel.expense.amount }}</td>
|
||||
<td>{{ fuel.price_per_liter|floatformat:2 }}</td>
|
||||
<td>{{ fuel.km_since_previous }}</td>
|
||||
<td class="table-actions">
|
||||
<a href="{% url 'fuel_edit' fuel.expense.id %}?next={{ request.get_full_path|urlencode }}">Editar</a>
|
||||
<a href="{% url 'fuel_delete' fuel.expense.id %}?next={{ request.get_full_path|urlencode }}" class="danger">Eliminar</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
<table>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Tipo</th>
|
||||
<th>Progreso</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@ -21,22 +22,27 @@
|
||||
{% for goal in goals %}
|
||||
<tr>
|
||||
<td>{{ goal.name }}</td>
|
||||
<td>
|
||||
{{ goal.get_kind_display }}
|
||||
{% if goal.kind == "budget" %}
|
||||
<small>({{ goal.get_period_display|lower }})</small>
|
||||
{% endif %}
|
||||
</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 class="progress-fill {{ goal.progress_state }}"
|
||||
style="width: {{ goal.bar_width|unlocalize }}%"></div>
|
||||
</div>
|
||||
<span class="progress-label">
|
||||
{{ goal.percentage|floatformat:1}}%
|
||||
{{ goal.percentage|floatformat:1 }}%
|
||||
</span>
|
||||
{% if goal.is_exceeded %}
|
||||
<span class="badge badge-inactive">Excedido</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
|
||||
31
expenses_manager/expenses/tests/conftest.py
Normal file
31
expenses_manager/expenses/tests/conftest.py
Normal file
@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from expenses.models import Account, Category
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db, django_user_model):
|
||||
return django_user_model.objects.create_user(
|
||||
username="tester", password="testpass123"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(client, user):
|
||||
client.login(username=user.username, password="testpass123")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account(user):
|
||||
return Account.objects.create(
|
||||
owner=user,
|
||||
name="Cuenta principal",
|
||||
initial_balance=Decimal("0"),
|
||||
active=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def category(user):
|
||||
return Category.objects.create(owner=user, name="General")
|
||||
86
expenses_manager/expenses/tests/test_accounts.py
Normal file
86
expenses_manager/expenses/tests/test_accounts.py
Normal file
@ -0,0 +1,86 @@
|
||||
import pytest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from expenses.models import Account, Expense, Income, Category
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_current_balance_reflects_income_and_expenses(user, category):
|
||||
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("100"))
|
||||
Income.objects.create(owner=user, account=acc, name="Nomina", amount=Decimal("50"), date=date.today())
|
||||
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("30"), date=date.today())
|
||||
|
||||
assert acc.current_balance() == Decimal("120")
|
||||
|
||||
|
||||
def test_monthly_balance_returns_12_rows_in_order(user):
|
||||
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("100"))
|
||||
|
||||
data = acc.monthly_balance(2020)
|
||||
|
||||
assert len(data) == 12
|
||||
assert [row["month"] for row in data] == list(range(1, 13))
|
||||
assert all(row["balance"] == 100.0 for row in data)
|
||||
|
||||
|
||||
def test_monthly_balance_is_cumulative_across_months(user, category):
|
||||
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
|
||||
year = date.today().year - 1 # avoid the current-month live-balance patch
|
||||
|
||||
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("40"), date=date(year, 3, 1))
|
||||
Income.objects.create(owner=user, account=acc, name="Extra", amount=Decimal("100"), date=date(year, 6, 1))
|
||||
|
||||
data = acc.monthly_balance(year)
|
||||
|
||||
assert data[2]["balance"] == -40.0 # month 3
|
||||
assert data[5]["balance"] == 60.0 # month 6, cumulative
|
||||
assert data[11]["balance"] == 60.0 # carries forward to December
|
||||
|
||||
|
||||
def test_monthly_balance_respects_year_param_and_excludes_other_years(user, category):
|
||||
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
|
||||
|
||||
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("50"), date=date(2023, 6, 15))
|
||||
Income.objects.create(owner=user, account=acc, name="Extra", amount=Decimal("30"), date=date(2024, 3, 10))
|
||||
|
||||
data_2023 = acc.monthly_balance(2023)
|
||||
assert data_2023[5]["balance"] == -50.0 # month 6
|
||||
assert data_2023[11]["balance"] == -50.0
|
||||
|
||||
data_2024 = acc.monthly_balance(2024)
|
||||
assert data_2024[1]["balance"] == -50.0 # month 2: prior year's expense folded into start
|
||||
assert data_2024[2]["balance"] == -20.0 # month 3: 2024 income applied
|
||||
|
||||
|
||||
def test_monthly_balance_current_month_patched_with_live_balance(user, category):
|
||||
today = date.today()
|
||||
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("100"))
|
||||
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("30"), date=today)
|
||||
|
||||
data = acc.monthly_balance(today.year)
|
||||
|
||||
assert data[today.month - 1]["balance"] == float(acc.current_balance())
|
||||
|
||||
|
||||
def test_monthly_net_returns_income_minus_expense_per_month(user, category):
|
||||
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
|
||||
|
||||
Income.objects.create(owner=user, account=acc, name="Nomina", amount=Decimal("100"), date=date(2024, 4, 5))
|
||||
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("40"), date=date(2024, 4, 20))
|
||||
|
||||
data = acc.monthly_net(2024)
|
||||
|
||||
assert len(data) == 12
|
||||
assert data[3]["net"] == 60.0 # month 4
|
||||
assert data[0]["net"] == 0.0
|
||||
|
||||
|
||||
def test_balance_until_only_counts_entries_on_or_before_date(user, category):
|
||||
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
|
||||
|
||||
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("20"), date=date(2024, 1, 10))
|
||||
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("5"), date=date(2024, 1, 15))
|
||||
Income.objects.create(owner=user, account=acc, name="Later", amount=Decimal("50"), date=date(2024, 1, 20))
|
||||
|
||||
assert acc.balance_until(date(2024, 1, 15)) == Decimal("-25")
|
||||
101
expenses_manager/expenses/tests/test_categories.py
Normal file
101
expenses_manager/expenses/tests/test_categories.py
Normal file
@ -0,0 +1,101 @@
|
||||
import pytest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from django.contrib.messages import get_messages
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from expenses.models import Category, Expense
|
||||
from expenses.forms import CategoryForm
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_slug_auto_generated_from_name_on_save(user):
|
||||
cat = Category.objects.create(name="Ropa y Calzado", owner=user)
|
||||
|
||||
assert cat.slug == slugify("Ropa y Calzado")
|
||||
|
||||
|
||||
def test_slug_not_overwritten_if_explicitly_set(user):
|
||||
cat = Category(name="Comida", slug="mi-slug-personalizado", owner=user)
|
||||
cat.save()
|
||||
|
||||
assert cat.slug == "mi-slug-personalizado"
|
||||
|
||||
|
||||
def test_descendant_ids_includes_grandchildren(user):
|
||||
parent = Category.objects.create(name="A", owner=user)
|
||||
child = Category.objects.create(name="B", owner=user, parent=parent)
|
||||
grandchild = Category.objects.create(name="C", owner=user, parent=child)
|
||||
|
||||
ids = set(parent.descendant_ids())
|
||||
|
||||
assert ids == {parent.pk, child.pk, grandchild.pk}
|
||||
|
||||
|
||||
def test_descendant_ids_can_exclude_self(user):
|
||||
parent = Category.objects.create(name="A", owner=user)
|
||||
child = Category.objects.create(name="B", owner=user, parent=parent)
|
||||
|
||||
ids = parent.descendant_ids(include_self=False)
|
||||
|
||||
assert parent.pk not in ids
|
||||
assert child.pk in ids
|
||||
|
||||
|
||||
def test_categoryform_parent_persists_on_save(auth_client, user):
|
||||
other = Category.objects.create(name="Otra", owner=user)
|
||||
|
||||
auth_client.post(reverse('category_list'), {'name': 'Nueva', 'parent': str(other.pk)})
|
||||
|
||||
created = Category.objects.get(name='Nueva', owner=user)
|
||||
assert created.parent_id == other.pk
|
||||
|
||||
|
||||
def test_categoryform_excludes_self_and_descendants_from_parent_queryset_when_editing(user):
|
||||
parent = Category.objects.create(name="P", owner=user)
|
||||
child = Category.objects.create(name="C", owner=user, parent=parent)
|
||||
|
||||
form = CategoryForm(instance=parent, user=user)
|
||||
queryset_ids = set(form.fields['parent'].queryset.values_list('pk', flat=True))
|
||||
|
||||
assert parent.pk not in queryset_ids
|
||||
assert child.pk not in queryset_ids
|
||||
|
||||
|
||||
def test_categoryform_clean_parent_rejects_self_as_parent(user):
|
||||
cat = Category.objects.create(name="X", owner=user)
|
||||
|
||||
form = CategoryForm(data={'name': 'X', 'parent': str(cat.pk)}, instance=cat, user=user)
|
||||
|
||||
assert not form.is_valid()
|
||||
assert 'parent' in form.errors
|
||||
|
||||
|
||||
def test_categoryform_rejects_descendant_as_parent(user):
|
||||
parent = Category.objects.create(name="P", owner=user)
|
||||
child = Category.objects.create(name="C", owner=user, parent=parent)
|
||||
|
||||
form = CategoryForm(data={'name': 'P', 'parent': str(child.pk)}, instance=parent, user=user)
|
||||
|
||||
assert not form.is_valid()
|
||||
|
||||
|
||||
def test_category_delete_with_expenses_is_protected(auth_client, user, account, category):
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
|
||||
|
||||
response = auth_client.post(reverse('category_delete', args=[category.pk]))
|
||||
|
||||
assert response.status_code == 302
|
||||
assert Category.objects.filter(pk=category.pk).exists()
|
||||
messages = [str(m) for m in get_messages(response.wsgi_request)]
|
||||
assert any("gastos asociados" in m for m in messages)
|
||||
|
||||
|
||||
def test_category_delete_without_expenses_succeeds(auth_client, user):
|
||||
cat = Category.objects.create(name="Vacia", owner=user)
|
||||
|
||||
response = auth_client.post(reverse('category_delete', args=[cat.pk]))
|
||||
|
||||
assert response.status_code == 302
|
||||
assert not Category.objects.filter(pk=cat.pk).exists()
|
||||
@ -2,7 +2,7 @@ import pytest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from django.urls import reverse
|
||||
from expenses.models import Account, Expense, Category
|
||||
from expenses.models import Expense, Category
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@ -11,53 +11,39 @@ def test_dashboard_requires_login(client):
|
||||
response = client.get(url)
|
||||
assert response.status_code == 302
|
||||
|
||||
def test_dashboard_logged_user_can_access(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(
|
||||
username='test',
|
||||
password='1234'
|
||||
)
|
||||
client.login(username='test', password='1234')
|
||||
|
||||
def test_dashboard_logged_user_can_access(auth_client):
|
||||
url = reverse('dashboard')
|
||||
response = client.get(url)
|
||||
response = auth_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_dashboard_groups_by_category(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(
|
||||
username='test',
|
||||
password='1234'
|
||||
)
|
||||
client.login(username='test', password='1234')
|
||||
|
||||
def test_dashboard_groups_by_category(auth_client, user, account):
|
||||
food = Category.objects.create(name='Food', owner=user)
|
||||
rent = Category.objects.create(name='Rent', owner=user)
|
||||
|
||||
general_account = Account.objects.create(name='General', owner=user, initial_balance=300, active=True)
|
||||
|
||||
Expense.objects.create(
|
||||
owner=user,
|
||||
category=food,
|
||||
amount=Decimal('10'),
|
||||
date=date(2024, 1, 1),
|
||||
account=general_account,
|
||||
account=account,
|
||||
)
|
||||
Expense.objects.create(
|
||||
owner=user,
|
||||
category=food,
|
||||
amount=Decimal('5'),
|
||||
date=date(2024, 1, 2),
|
||||
account=general_account,
|
||||
account=account,
|
||||
)
|
||||
Expense.objects.create(
|
||||
owner=user,
|
||||
category=rent,
|
||||
amount=Decimal('20'),
|
||||
date=date(2024, 1, 3),
|
||||
account=general_account,
|
||||
account=account,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
response = auth_client.get(
|
||||
reverse('dashboard'),
|
||||
{'year': 2024}
|
||||
)
|
||||
@ -67,39 +53,30 @@ def test_dashboard_groups_by_category(client, django_user_model):
|
||||
assert {'category__name': 'Food', 'total': Decimal('15')} in data
|
||||
assert {'category__name': 'Rent', 'total': Decimal('20')} in data
|
||||
|
||||
def test_dashboard_filters_by_year(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(
|
||||
username='test',
|
||||
password='1234'
|
||||
)
|
||||
client.login(username='test', password='1234')
|
||||
|
||||
cat = Category.objects.create(name='General', owner=user)
|
||||
general_account = Account.objects.create(name='General', owner=user, initial_balance=300, active=True)
|
||||
|
||||
def test_dashboard_filters_by_year(auth_client, user, account, category):
|
||||
Expense.objects.create(
|
||||
owner=user,
|
||||
category=cat,
|
||||
category=category,
|
||||
amount=10,
|
||||
date=date(2023, 5, 1),
|
||||
account=general_account,
|
||||
account=account,
|
||||
)
|
||||
|
||||
Expense.objects.create(
|
||||
owner=user,
|
||||
category=cat,
|
||||
category=category,
|
||||
amount=20,
|
||||
date=date(2024, 5, 1),
|
||||
account=general_account,
|
||||
account=account,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
response = auth_client.get(
|
||||
reverse('dashboard'),
|
||||
{'year': 2024},
|
||||
)
|
||||
|
||||
by_month = list(response.context['by_month'])
|
||||
chart_data = response.context['chart_data']
|
||||
|
||||
totals = [row['total'] for row in by_month]
|
||||
assert Decimal('20') in totals
|
||||
assert Decimal('10') not in totals
|
||||
assert len(chart_data) == 12
|
||||
assert chart_data[4] == 20.0 # May = month 5 -> index 4
|
||||
assert sum(chart_data) == 20.0 # only the 2024 expense contributes
|
||||
|
||||
61
expenses_manager/expenses/tests/test_deletes.py
Normal file
61
expenses_manager/expenses/tests/test_deletes.py
Normal file
@ -0,0 +1,61 @@
|
||||
import pytest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from django.urls import reverse
|
||||
from expenses.models import Goal, Tag, Income
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_goal_delete_get_does_not_delete(auth_client, user):
|
||||
goal = Goal.objects.create(owner=user, name="Meta", target_amount=Decimal("100"))
|
||||
|
||||
response = auth_client.get(reverse('goal_delete', args=[goal.pk]))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert Goal.objects.filter(pk=goal.pk).exists()
|
||||
|
||||
|
||||
def test_goal_delete_post_deletes(auth_client, user):
|
||||
goal = Goal.objects.create(owner=user, name="Meta", target_amount=Decimal("100"))
|
||||
|
||||
response = auth_client.post(reverse('goal_delete', args=[goal.pk]))
|
||||
|
||||
assert response.status_code == 302
|
||||
assert not Goal.objects.filter(pk=goal.pk).exists()
|
||||
|
||||
|
||||
def test_tag_delete_get_does_not_delete(auth_client, user):
|
||||
tag = Tag.objects.create(name="Food", owner=user)
|
||||
|
||||
response = auth_client.get(reverse('tag_delete', args=[tag.pk]))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert Tag.objects.filter(pk=tag.pk).exists()
|
||||
|
||||
|
||||
def test_tag_delete_post_deletes(auth_client, user):
|
||||
tag = Tag.objects.create(name="Food", owner=user)
|
||||
|
||||
response = auth_client.post(reverse('tag_delete', args=[tag.pk]))
|
||||
|
||||
assert response.status_code == 302
|
||||
assert not Tag.objects.filter(pk=tag.pk).exists()
|
||||
|
||||
|
||||
def test_income_delete_get_does_not_delete(auth_client, user, account):
|
||||
income = Income.objects.create(owner=user, account=account, name="Nomina", amount=Decimal("500"), date=date.today())
|
||||
|
||||
response = auth_client.get(reverse('income_delete', args=[income.pk]))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert Income.objects.filter(pk=income.pk).exists()
|
||||
|
||||
|
||||
def test_income_delete_post_deletes(auth_client, user, account):
|
||||
income = Income.objects.create(owner=user, account=account, name="Nomina", amount=Decimal("500"), date=date.today())
|
||||
|
||||
response = auth_client.post(reverse('income_delete', args=[income.pk]))
|
||||
|
||||
assert response.status_code == 302
|
||||
assert not Income.objects.filter(pk=income.pk).exists()
|
||||
42
expenses_manager/expenses/tests/test_expense_list.py
Normal file
42
expenses_manager/expenses/tests/test_expense_list.py
Normal file
@ -0,0 +1,42 @@
|
||||
import pytest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from django.urls import reverse
|
||||
from expenses.models import Expense, Tag
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_tag_filter_with_non_numeric_value_returns_200(auth_client, user, account, category):
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
|
||||
|
||||
response = auth_client.get(reverse('expense_list'), {'tag': 'abc'})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.context['page_obj']) == 1
|
||||
|
||||
|
||||
def test_tag_filter_with_valid_tag_returns_only_matching_expenses(auth_client, user, account, category):
|
||||
tag = Tag.objects.create(name="Food", owner=user)
|
||||
tagged = Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
|
||||
tagged.tags.add(tag)
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("20"), date=date.today())
|
||||
|
||||
response = auth_client.get(reverse('expense_list'), {'tag': str(tag.pk)})
|
||||
|
||||
results = list(response.context['page_obj'])
|
||||
assert results == [tagged]
|
||||
|
||||
|
||||
def test_tag_filter_multiple_tags_are_ORed(auth_client, user, account, category):
|
||||
tag1 = Tag.objects.create(name="Food", owner=user)
|
||||
tag2 = Tag.objects.create(name="Transport", owner=user)
|
||||
expense1 = Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
|
||||
expense1.tags.add(tag1)
|
||||
expense2 = Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("20"), date=date.today())
|
||||
expense2.tags.add(tag2)
|
||||
|
||||
response = auth_client.get(reverse('expense_list'), {'tag': [str(tag1.pk), str(tag2.pk)]})
|
||||
|
||||
results = set(response.context['page_obj'])
|
||||
assert results == {expense1, expense2}
|
||||
@ -1,39 +1,27 @@
|
||||
import pytest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from django.urls import reverse
|
||||
from expenses.models import Expense, Category, Tag, Account
|
||||
from expenses.models import Expense, Tag
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
def test_expense_can_have_tags(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username='test', password='1234')
|
||||
client.login(username='test', password='1234')
|
||||
|
||||
def test_expense_can_have_tags(user, category, account):
|
||||
tag = Tag.objects.create(name='Food', owner=user)
|
||||
category = Category.objects.create(name='General', owner=user)
|
||||
|
||||
general_account = Account.objects.create(name='General', owner=user, initial_balance=300, active=True)
|
||||
|
||||
expense = Expense.objects.create(
|
||||
owner=user,
|
||||
category=category,
|
||||
amount=10,
|
||||
date=date.today(),
|
||||
account=general_account,
|
||||
account=account,
|
||||
)
|
||||
expense.tags.add(tag)
|
||||
|
||||
assert expense.tags.count() == 1
|
||||
assert tag in expense.tags.all()
|
||||
|
||||
def test_user_can_create_tag(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(
|
||||
username='test', password='1234'
|
||||
)
|
||||
client.login(username='test', password='1234')
|
||||
|
||||
response = client.post(
|
||||
def test_user_can_create_tag(auth_client, user):
|
||||
response = auth_client.post(
|
||||
reverse('tag_create'),
|
||||
{'name': 'Food'}
|
||||
)
|
||||
|
||||
69
expenses_manager/expenses/tests/test_fuel.py
Normal file
69
expenses_manager/expenses/tests/test_fuel.py
Normal file
@ -0,0 +1,69 @@
|
||||
import pytest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from django.urls import reverse
|
||||
from expenses.models import Expense, FuelEntry
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _make_fuel_entry(user, account, category, odometer, liters, amount=Decimal("50")):
|
||||
expense = Expense.objects.create(
|
||||
owner=user, account=account, category=category, amount=amount, date=date.today()
|
||||
)
|
||||
return FuelEntry.objects.create(expense=expense, odometer=odometer, liters=liters)
|
||||
|
||||
|
||||
def test_price_per_liter_divides_amount_by_liters(user, account, category):
|
||||
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("25"), amount=Decimal("50"))
|
||||
|
||||
assert entry.price_per_liter() == Decimal("2")
|
||||
|
||||
|
||||
def test_price_per_liter_zero_when_no_liters(user, account, category):
|
||||
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("0"))
|
||||
|
||||
assert entry.price_per_liter() == 0
|
||||
|
||||
|
||||
def test_km_since_previous_none_on_first_fillup(user, account, category):
|
||||
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
|
||||
|
||||
assert entry.km_since_previous() is None
|
||||
|
||||
|
||||
def test_km_since_previous_computes_delta_from_prior_entry(user, account, category):
|
||||
_make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
|
||||
second = _make_fuel_entry(user, account, category, odometer=1300, liters=Decimal("24"))
|
||||
|
||||
assert second.km_since_previous() == 300
|
||||
|
||||
|
||||
def test_consumption_none_when_no_previous_entry(user, account, category):
|
||||
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
|
||||
|
||||
assert entry.consumption() is None
|
||||
|
||||
|
||||
def test_consumption_computes_liters_per_100km(user, account, category):
|
||||
_make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
|
||||
second = _make_fuel_entry(user, account, category, odometer=1300, liters=Decimal("24"))
|
||||
|
||||
assert second.consumption() == Decimal("8")
|
||||
|
||||
|
||||
def test_fuel_create_invalid_post_returns_200_not_500(auth_client):
|
||||
response = auth_client.post(reverse('fuel_create'), {})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert FuelEntry.objects.count() == 0
|
||||
|
||||
|
||||
def test_fuel_delete_post_cascades_to_fuelentry(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]))
|
||||
|
||||
assert not Expense.objects.filter(pk=expense.pk).exists()
|
||||
assert not FuelEntry.objects.filter(pk=entry.pk).exists()
|
||||
196
expenses_manager/expenses/tests/test_goals.py
Normal file
196
expenses_manager/expenses/tests/test_goals.py
Normal file
@ -0,0 +1,196 @@
|
||||
import pytest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from expenses.models import Account, Category, Expense, Goal
|
||||
from expenses.forms import GoalForm
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _last_month_date():
|
||||
today = date.today()
|
||||
if today.month == 1:
|
||||
return date(today.year - 1, 12, 15)
|
||||
return date(today.year, today.month - 1, 15)
|
||||
|
||||
|
||||
def test_payment_goal_sums_expenses_since_start_date(user, account, category):
|
||||
start = date(2024, 6, 1)
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date(2024, 5, 1))
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("20"), date=date(2024, 6, 15))
|
||||
|
||||
goal = Goal.objects.create(
|
||||
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category,
|
||||
include_subcategories=False, start_date=start, target_amount=Decimal("100"),
|
||||
)
|
||||
|
||||
assert goal.progress() == Decimal("20")
|
||||
|
||||
|
||||
def test_payment_goal_include_subcategories_true_includes_child_expenses(user, account, category):
|
||||
child = Category.objects.create(owner=user, name="Sub", parent=category)
|
||||
Expense.objects.create(owner=user, account=account, category=child, amount=Decimal("15"), date=date.today())
|
||||
|
||||
goal = Goal.objects.create(
|
||||
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category,
|
||||
include_subcategories=True, target_amount=Decimal("100"),
|
||||
)
|
||||
|
||||
assert goal.progress() == Decimal("15")
|
||||
|
||||
|
||||
def test_payment_goal_include_subcategories_false_excludes_child_expenses(user, account, category):
|
||||
child = Category.objects.create(owner=user, name="Sub", parent=category)
|
||||
Expense.objects.create(owner=user, account=account, category=child, amount=Decimal("15"), date=date.today())
|
||||
|
||||
goal = Goal.objects.create(
|
||||
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category,
|
||||
include_subcategories=False, target_amount=Decimal("100"),
|
||||
)
|
||||
|
||||
assert goal.progress() == Decimal("0")
|
||||
|
||||
|
||||
def test_budget_goal_period_month_counts_only_current_month(user, account, category):
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("30"), date=date.today())
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("999"), date=_last_month_date())
|
||||
|
||||
goal = Goal.objects.create(
|
||||
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category,
|
||||
period=Goal.PERIOD_MONTH, target_amount=Decimal("100"),
|
||||
)
|
||||
|
||||
assert goal.progress() == Decimal("30")
|
||||
|
||||
|
||||
def test_budget_goal_is_exceeded_true_over_target(user, account, category):
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("150"), date=date.today())
|
||||
|
||||
goal = Goal.objects.create(
|
||||
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category,
|
||||
period=Goal.PERIOD_MONTH, target_amount=Decimal("100"),
|
||||
)
|
||||
|
||||
assert goal.is_exceeded() is True
|
||||
|
||||
|
||||
def test_budget_goal_is_exceeded_false_under_target(user, account, category):
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("50"), date=date.today())
|
||||
|
||||
goal = Goal.objects.create(
|
||||
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category,
|
||||
period=Goal.PERIOD_MONTH, target_amount=Decimal("100"),
|
||||
)
|
||||
|
||||
assert goal.is_exceeded() is False
|
||||
|
||||
|
||||
def test_is_exceeded_always_false_for_non_budget_kind(user, account, category):
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("50"), date=date.today())
|
||||
|
||||
goal = Goal.objects.create(
|
||||
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category, target_amount=Decimal("10"),
|
||||
)
|
||||
|
||||
assert goal.progress() > goal.target_amount
|
||||
assert goal.is_exceeded() is False
|
||||
|
||||
|
||||
def test_saving_goal_progress_equals_account_balance(user):
|
||||
acc = Account.objects.create(owner=user, name="Ahorros", initial_balance=Decimal("500"))
|
||||
|
||||
goal = Goal.objects.create(
|
||||
owner=user, name="Ahorro", kind=Goal.KIND_SAVING, account=acc, target_amount=Decimal("1000"),
|
||||
)
|
||||
|
||||
assert goal.progress() == acc.current_balance()
|
||||
|
||||
|
||||
def test_bar_width_caps_at_100_above_target(user):
|
||||
acc = Account.objects.create(owner=user, name="Ahorros", initial_balance=Decimal("250"))
|
||||
goal = Goal.objects.create(owner=user, name="Ahorro", kind=Goal.KIND_SAVING, account=acc, target_amount=Decimal("100"))
|
||||
|
||||
assert goal.percentage() == Decimal("250")
|
||||
assert goal.bar_width() == 100.0
|
||||
|
||||
|
||||
def test_bar_width_uncapped_below_100(user):
|
||||
acc = Account.objects.create(owner=user, name="Ahorros", initial_balance=Decimal("40"))
|
||||
goal = Goal.objects.create(owner=user, name="Ahorro", kind=Goal.KIND_SAVING, account=acc, target_amount=Decimal("100"))
|
||||
|
||||
assert goal.bar_width() == 40.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount,expected_state", [
|
||||
(Decimal("50"), "ok"),
|
||||
(Decimal("90"), "warning"),
|
||||
(Decimal("150"), "danger"),
|
||||
])
|
||||
def test_progress_state_budget_thresholds(user, account, category, amount, expected_state):
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=amount, date=date.today())
|
||||
goal = Goal.objects.create(
|
||||
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category, target_amount=Decimal("100"),
|
||||
)
|
||||
|
||||
assert goal.progress_state() == expected_state
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount,expected_state", [
|
||||
(Decimal("50"), "ok"),
|
||||
(Decimal("100"), "complete"),
|
||||
(Decimal("150"), "complete"),
|
||||
])
|
||||
def test_progress_state_non_budget_thresholds(user, account, category, amount, expected_state):
|
||||
Expense.objects.create(owner=user, account=account, category=category, amount=amount, date=date.today())
|
||||
goal = Goal.objects.create(
|
||||
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category, target_amount=Decimal("100"),
|
||||
)
|
||||
|
||||
assert goal.progress_state() == expected_state
|
||||
|
||||
|
||||
def test_goalform_budget_requires_period(user, category):
|
||||
data = {"name": "Presupuesto", "kind": Goal.KIND_BUDGET, "target_amount": "100", "category": str(category.pk), "period": Goal.PERIOD_NONE}
|
||||
form = GoalForm(data=data, user=user)
|
||||
|
||||
assert not form.is_valid()
|
||||
assert "period" in form.errors
|
||||
|
||||
|
||||
def test_goalform_saving_requires_account(user):
|
||||
data = {"name": "Ahorro", "kind": Goal.KIND_SAVING, "target_amount": "500", "period": Goal.PERIOD_NONE}
|
||||
form = GoalForm(data=data, user=user)
|
||||
|
||||
assert not form.is_valid()
|
||||
assert "account" in form.errors
|
||||
|
||||
|
||||
def test_goalform_payment_requires_category(user):
|
||||
data = {"name": "Pago", "kind": Goal.KIND_PAYMENT, "target_amount": "50", "period": Goal.PERIOD_NONE}
|
||||
form = GoalForm(data=data, user=user)
|
||||
|
||||
assert not form.is_valid()
|
||||
assert "category" in form.errors
|
||||
|
||||
|
||||
def test_goalform_budget_requires_category(user):
|
||||
data = {"name": "Presupuesto", "kind": Goal.KIND_BUDGET, "target_amount": "50", "period": Goal.PERIOD_MONTH}
|
||||
form = GoalForm(data=data, user=user)
|
||||
|
||||
assert not form.is_valid()
|
||||
assert "category" in form.errors
|
||||
|
||||
|
||||
def test_goalform_valid_payment_saves_goal(user, category):
|
||||
data = {
|
||||
"name": "Ahorro viaje", "kind": Goal.KIND_PAYMENT, "target_amount": "200",
|
||||
"category": str(category.pk), "period": Goal.PERIOD_NONE,
|
||||
}
|
||||
form = GoalForm(data=data, user=user)
|
||||
|
||||
assert form.is_valid(), form.errors
|
||||
goal = form.save(commit=False)
|
||||
goal.owner = user
|
||||
goal.save()
|
||||
|
||||
assert Goal.objects.filter(pk=goal.pk, name="Ahorro viaje", kind=Goal.KIND_PAYMENT, category=category).exists()
|
||||
@ -1,18 +1,10 @@
|
||||
import pytest
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from django.urls import reverse
|
||||
from expenses.models import Income, Account
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
def test_income_increases_account_balance(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(
|
||||
username='test',
|
||||
password='1234'
|
||||
)
|
||||
client.login(username='test', password='1234')
|
||||
|
||||
def test_income_increases_account_balance(user):
|
||||
general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True)
|
||||
|
||||
Income.objects.create(
|
||||
|
||||
@ -12,10 +12,10 @@ urlpatterns = [
|
||||
path('tags/new/', views.tag_create, name='tag_create'),
|
||||
path('tags/<int:pk>/edit/', views.tag_edit, name='tag_edit'),
|
||||
path('tags/<int:pk>/delete/', views.tag_delete, name='tag_delete'),
|
||||
path('accounts/', views.account_list, name='account_list'),
|
||||
path('accounts/new/', views.account_create, name='account_create'),
|
||||
path('accounts/<int:pk>/edit/', views.account_edit, name='account_edit'),
|
||||
path('accounts/<int:pk>/delete/', views.account_delete, name='account_delete'),
|
||||
path('finance-accounts/', views.account_list, name='account_list'),
|
||||
path('finance-accounts/new/', views.account_create, name='account_create'),
|
||||
path('finance-accounts/<int:pk>/edit/', views.account_edit, name='account_edit'),
|
||||
path('finance-accounts/<int:pk>/delete/', views.account_delete, name='account_delete'),
|
||||
path('incomes/', views.income_list, name='income_list'),
|
||||
path('incomes/new/', views.income_create, name='income_create'),
|
||||
path('incomes/<int:pk>/edit/', views.income_edit, name='income_edit'),
|
||||
@ -23,7 +23,10 @@ urlpatterns = [
|
||||
path('fuel/', views.fuel_list, name='fuel_list'),
|
||||
path('fuel/create/', views.fuel_create, name='fuel_create'),
|
||||
path('fuel/<int:pk>/edit/', views.fuel_edit, name='fuel_edit'),
|
||||
path('fuel/<int:pk>/delete/', views.fuel_delete, name='fuel_delete'),
|
||||
path('categories/', views.category_list, name='category_list'),
|
||||
path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'),
|
||||
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
|
||||
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'),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from operator import truediv
|
||||
import logging
|
||||
import calendar
|
||||
from datetime import date, datetime
|
||||
from django.contrib import messages
|
||||
from django.template import context
|
||||
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
|
||||
from .forms import (
|
||||
ExpenseForm,
|
||||
@ -13,32 +13,15 @@ from .forms import (
|
||||
GoalForm,
|
||||
)
|
||||
|
||||
# from dateutli.relativedelta import relativedelta
|
||||
|
||||
from django.db.models import Sum
|
||||
from django.contrib.auth import login
|
||||
from django.core.paginator import Paginator
|
||||
from django.utils.ipv6 import is_valid_ipv6_address
|
||||
from django.db.models import Sum, ProtectedError
|
||||
from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.shortcuts import get_object_or_404, render, redirect
|
||||
|
||||
MONTHS = {
|
||||
1: "ENERO",
|
||||
2: "FEBRERO",
|
||||
3: "MARZO",
|
||||
4: "ABRIL",
|
||||
5: "MAYO",
|
||||
6: "JUNIO",
|
||||
7: "JULIO",
|
||||
8: "AGOSTO",
|
||||
9: "SEPTIEMBRE",
|
||||
10: "OCTUBRE",
|
||||
11: "NOVIEMBRE",
|
||||
12: "DICIEMBRE",
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _get_int(value):
|
||||
try:
|
||||
@ -47,6 +30,19 @@ def _get_int(value):
|
||||
return None
|
||||
|
||||
|
||||
def _redirect_back(request, fallback):
|
||||
next_url = request.POST.get("next") or request.GET.get("next")
|
||||
|
||||
if next_url and url_has_allowed_host_and_scheme(
|
||||
next_url,
|
||||
allowed_hosts={request.get_host()},
|
||||
require_https=request.is_secure(),
|
||||
):
|
||||
return redirect(next_url)
|
||||
|
||||
return redirect(fallback)
|
||||
|
||||
|
||||
def sub_months(year, month, n):
|
||||
month -= n
|
||||
while month <= 0:
|
||||
@ -127,8 +123,11 @@ def expense_list(request):
|
||||
category = _get_int(request.GET.get("category"))
|
||||
account_id = _get_int(request.GET.get("account"))
|
||||
|
||||
tag_ids = request.GET.getlist("tag")
|
||||
tag_ids = [int(t) for t in tag_ids]
|
||||
tag_ids = [
|
||||
t_id
|
||||
for t in request.GET.getlist("tag")
|
||||
if (t_id:= _get_int(t)) is not None
|
||||
]
|
||||
|
||||
if year:
|
||||
expenses = expenses.filter(date__year=year)
|
||||
@ -364,7 +363,6 @@ def dashboard(request):
|
||||
)
|
||||
day_totals = {row["day"]: float(row["total"]) for row in by_day_qs}
|
||||
|
||||
import calendar
|
||||
num_days = calendar.monthrange(selected_year, selected_month)[1]
|
||||
if selected_year == today.year and selected_month == today.month:
|
||||
days_passed = today.day
|
||||
@ -449,7 +447,11 @@ def dashboard(request):
|
||||
try:
|
||||
monthly_data = acc.monthly_balance(selected_year)
|
||||
m_balance = [float(row["balance"]) for row in monthly_data]
|
||||
except:
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error calculando monthly_balance para la cuenta %s (año %s)",
|
||||
acc.id, selected_year
|
||||
)
|
||||
m_balance = [0] * 12
|
||||
|
||||
if selected_year == today.year:
|
||||
@ -820,7 +822,7 @@ def fuel_edit(request, pk):
|
||||
fuel.liters = form.cleaned_data["liters"]
|
||||
fuel.save()
|
||||
|
||||
return redirect("expense_list")
|
||||
return _redirect_back(request, "expense_list")
|
||||
else:
|
||||
fuel = expense.fuel_data
|
||||
# Initialize manually
|
||||
@ -835,13 +837,39 @@ def fuel_edit(request, pk):
|
||||
user=request.user,
|
||||
)
|
||||
|
||||
next_url = request.POST.get("next") or request.GET.get("next", "")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"fuel/create.html",
|
||||
{
|
||||
"active_menu": "expenses",
|
||||
"active_menu": "fuel" if next_url.startswith("/fuel") else "expenses",
|
||||
"form": form,
|
||||
"editing": True,
|
||||
"next": next_url
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def fuel_delete(request, pk):
|
||||
expense = get_object_or_404(Expense, pk=pk, owner=request.user)
|
||||
fuel = get_object_or_404(FuelEntry, expense=expense)
|
||||
|
||||
if request.method == "POST":
|
||||
expense.delete()
|
||||
messages.success(request, "Repostaje eliminado.")
|
||||
return _redirect_back(request, "fuel_list")
|
||||
|
||||
next_url = request.POST.get("next") or request.GET.get("next", "")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"fuel/confirm_delete.html",
|
||||
{
|
||||
"active_menu":"fuel" if next_url.startswith("/fuel") else "expenses",
|
||||
"fuel": fuel,
|
||||
"next": next_url
|
||||
},
|
||||
)
|
||||
|
||||
@ -871,6 +899,60 @@ def category_list(request):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def category_edit(request, pk):
|
||||
category = get_object_or_404(Category, pk=pk, owner=request.user)
|
||||
|
||||
if request.method == "POST":
|
||||
form = CategoryForm(request.POST, instance=category, user=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, "Categoría actualizada")
|
||||
return redirect("category_list")
|
||||
else:
|
||||
form = CategoryForm(instance=category, user=request.user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"categories/form.html",
|
||||
{
|
||||
"active_menu": "settings",
|
||||
"form":form,
|
||||
"category": category,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def category_delete(request, pk):
|
||||
category = get_object_or_404(Category, pk=pk, owner=request.user)
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
category.delete()
|
||||
except ProtectedError:
|
||||
messages.error(
|
||||
request,
|
||||
f"No se puede eliminar «{category.name}»; tiene gastos asociados. "
|
||||
"Reasigna esos gastos a otra categoría antes de borrarla."
|
||||
)
|
||||
else:
|
||||
messages.success(request, "Categoría eliminada.")
|
||||
return redirect("category_list")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"categories/confirm_delete.html",
|
||||
{
|
||||
"active_menu": "settings",
|
||||
"category": category,
|
||||
"children": category.children.all(),
|
||||
"expense_count": category.expenses.count(),
|
||||
"goal_count": category.goal_set.count(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def goal_list(request):
|
||||
goals = Goal.objects.filter(owner=request.user)
|
||||
|
||||
@ -158,7 +158,8 @@ DATETIME_INPUT_FORMATS = ["%d/%m/%Y %H:%M"]
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
if not DEBUG:
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
@ -15,12 +15,10 @@ Including another URLconf
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import urls
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('accounts/', include(urls)),
|
||||
path('', include('expenses.urls')),
|
||||
path('accounts/', include('django.contrib.auth.urls')),
|
||||
path('', include('expenses.urls')),
|
||||
]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user