Source code for richy.transactions.models
import functools
import shutil
from pathlib import Path
from django.conf import settings
from django.db import models
from django.template.defaultfilters import date
from django.utils.functional import cached_property
from ..core.math import calc_percentage_change
from ..core.models import (
Item,
UserItem,
UserRelatedManager,
UserRelatedModel,
UserRelatedQuerySet,
)
from ..etfs.models import Dividend as EtfDividend
from ..shares.models import Dividend as ShareDividend
def get_attachment_dir(instance, filename):
return Path("transactions") / str(instance.transaction.pk) / filename
def get_staking_attachment_dir(instance, filename):
return Path("stakings") / str(instance.staking.pk) / filename
[docs]
class Exchange(models.Model):
"""
Exchange model.
"""
title = models.CharField(max_length=50, unique=True)
def __str__(self):
return self.title
[docs]
class TransactionQuerySet(UserRelatedQuerySet):
def positive_balance(self):
"""
Returns only those transactions it's item balance
is higher than 0 - currently hold items.
Not closed (is_closed = False) transaction can
have 0 balance in case the item was bought and then
exchanged for another one (cryptocurrencies).
:return: Queryset filtering only open transactions.
:rtype: models.QuerySet
"""
return self.extra(
where=[
"(select sum(amount) from transactions_transaction tr where tr.item_id = transactions_transaction.item_id) > 0"
]
).all()
[docs]
class TransactionManager(UserRelatedManager):
"""
ORM manager for Transaction model.
"""
def get_queryset(self):
return TransactionQuerySet(self.model)
def positive_balance(self):
return self.get_queryset().positive_balance()
[docs]
class Transaction(UserRelatedModel):
"""
Transaction model.
"""
# TODO: item shouldnt be allowed to delete in case of related transactions.
parents = models.ManyToManyField("self", symmetrical=False, blank=True)
item = models.ForeignKey(Item, on_delete=models.CASCADE)
price = models.FloatField()
amount = models.FloatField()
fee = models.FloatField()
date = models.DateField()
exchange = models.ForeignKey(
Exchange, blank=True, null=True, on_delete=models.PROTECT
)
currency = models.CharField(max_length=50, default="USD")
target = models.CharField(max_length=50, blank=True)
is_closing = models.BooleanField(default=False, db_index=True)
is_closed = models.BooleanField(default=False, db_index=True)
is_deposit = models.BooleanField(default=False, db_index=True)
note = models.TextField(blank=True, null=True)
closed_on = models.DateField(blank=True, null=True)
objects = TransactionManager()
@functools.cache
def __str__(self):
base = f"{date(self.date)} {self.amount:+} {self.item.symbol}"
if self.exchange:
base += f" ({self.exchange})"
return base
[docs]
def get_market_value(self):
"""
Calculates current market price of assets gained (bought)
in this transaction (applies only for positive (buy) transactions).
:raises Exception: In case of negative transaction.
:return: Market price based on last known item price
:rtype: int or float
"""
if 0 < self.amount:
last_price = self.item.price_set.last()
if last_price:
return self.amount * last_price.price
return 0
raise Exception(
"Asking for market value for negative transaction doesn't make sense."
)
[docs]
def get_value(self):
"""
Calculates market price of assets bought or sold.
The price is negative for sell transactions.
:return: Price of whole transaction.
:rtype: int or float
"""
return self.amount * self.price
[docs]
def delete(self, *args, **kwargs):
"""
Removes whole attachments dir.
"""
shutil.rmtree(
Path(settings.MEDIA_ROOT) / "transactions" / str(self.pk),
ignore_errors=True,
)
return super().delete(*args, **kwargs)
@cached_property
def is_positive(self):
return 0 < self.amount
@cached_property
def target_as_absolute_value(self):
if not self.target:
return
if "%" in self.target:
return self.price * (1 + (float(self.target.strip("%")) / 100))
return float(self.target)
@cached_property
def target_as_percents(self):
if not self.target:
return
if "%" not in self.target:
return calc_percentage_change(float(self.target.strip("%")), self.price)
return float(self.target.strip("%"))
[docs]
def get_user_item(self):
return UserItem.objects.get(user=self.user, item=self.item)
[docs]
class BaseDividendTransaction(UserRelatedModel):
shares = models.PositiveIntegerField()
amount = models.FloatField()
transactions = models.ManyToManyField(Transaction)
class Meta:
abstract = True
[docs]
class ShareDividendTransaction(BaseDividendTransaction):
dividend = models.ForeignKey(ShareDividend, on_delete=models.CASCADE)
[docs]
class EtfDividendTransaction(BaseDividendTransaction):
dividend = models.ForeignKey(EtfDividend, on_delete=models.CASCADE)
[docs]
class AttachmentMixin:
def delete(self, *args, **kwargs):
self.file.delete()
return super().delete(*args, **kwargs)
[docs]
class Attachment(models.Model, AttachmentMixin):
transaction = models.ForeignKey(Transaction, on_delete=models.CASCADE)
file = models.FileField(upload_to=get_attachment_dir)