expenses_manager/expenses_manager/expenses/models.py

284 lines
7.8 KiB
Python

from datetime import date
from decimal import Decimal
from django.db import models
from django.conf import settings
from django.db.models import Sum
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",
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):
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)
active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
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"
)
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"))
)
expenses = (
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}
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),
}
)
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")
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)})
return data
def __str__(self):
return self.name
class Tag(models.Model):
name = models.CharField(max_length=50)
owner = models.ForeignKey(
settings.AUTH_USER_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)
account = models.ForeignKey(
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"]
def __str__(self):
return f"{self.name} - {self.amount}"
class FuelEntry(models.Model):
expense = models.OneToOneField(
Expense, on_delete=models.CASCADE, related_name="fuel_data"
)
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"]
def km_since_previous(self):
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