kpsul/kfet/models.py
2022-10-03 10:55:33 +02:00

745 lines
24 KiB
Python

import re
from django.conf import settings
from django.contrib.auth.models import User
from django.core.mail import send_mail
from django.core.validators import RegexValidator
from django.db import models, transaction
from django.db.models import F
from django.template import loader
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from gestioncof.models import CofProfile
from shared.utils import choices_length
from . import KFET_DELETED_TRIGRAMME
from .auth import KFET_GENERIC_TRIGRAMME
from .auth.models import GenericTeamToken # noqa
from .config import kfet_config
from .utils import to_ukf
class AccountManager(models.Manager):
"""Manager for Account Model."""
def get_queryset(self):
"""Always append related data to this Account."""
return super().get_queryset().select_related("cofprofile__user", "negative")
def get_generic(self):
"""
Get the kfet generic account instance.
"""
return self.get(trigramme=KFET_GENERIC_TRIGRAMME)
def get_by_password(self, password):
"""
Get a kfet generic account by clear password.
Raises Account.DoesNotExist if no Account has this password.
"""
from .auth.utils import hash_password
if password is None:
raise self.model.DoesNotExist
return self.get(password=hash_password(password))
class Account(models.Model):
objects = AccountManager()
cofprofile = models.OneToOneField(
CofProfile, on_delete=models.PROTECT, related_name="account_kfet"
)
trigramme = models.CharField(
unique=True,
max_length=3,
validators=[RegexValidator(regex="^[^a-z]{3}$")],
db_index=True,
)
balance = models.DecimalField(max_digits=6, decimal_places=2, default=0)
is_frozen = models.BooleanField("est gelé", default=False)
created_at = models.DateTimeField(default=timezone.now)
# Optional
promo = models.IntegerField(null=True)
nickname = models.CharField("surnom(s)", max_length=255, blank=True, default="")
password = models.CharField(
max_length=255, unique=True, blank=True, null=True, default=None
)
class Meta:
permissions = (
("is_team", "Is part of the team"),
("manage_perms", "Gérer les permissions K-Fêt"),
("manage_addcosts", "Gérer les majorations"),
("edit_balance_account", "Modifier la balance d'un compte"),
(
"change_account_password",
"Modifier le mot de passe d'une personne de l'équipe",
),
("special_add_account", "Créer un compte avec une balance initiale"),
("can_force_close", "Fermer manuellement la K-Fêt"),
("see_config", "Voir la configuration K-Fêt"),
("change_config", "Modifier la configuration K-Fêt"),
("access_old_history", "Peut accéder à l'historique plus ancien"),
)
def __str__(self):
return "%s (%s)" % (self.trigramme, self.name)
# Propriétés pour accéder aux attributs de cofprofile et user
@property
def user(self):
return self.cofprofile.user
@property
def username(self):
return self.cofprofile.user.username
@property
def first_name(self):
return self.cofprofile.user.first_name
@property
def last_name(self):
return self.cofprofile.user.last_name
@property
def email(self):
return self.cofprofile.user.email
@property
def departement(self):
return self.cofprofile.departement
@property
def is_cof(self):
return self.cofprofile.is_cof
# Propriétés supplémentaires
@property
def balance_ukf(self):
return to_ukf(self.balance, is_cof=self.is_cof)
@property
def name(self):
return self.user.get_full_name()
@property
def is_cash(self):
return self.trigramme == "LIQ"
@property
def need_comment(self):
return self.trigramme == "#13"
@property
def readable(self):
return self.trigramme not in [KFET_DELETED_TRIGRAMME, KFET_GENERIC_TRIGRAMME]
@property
def editable(self):
return self.trigramme not in [
KFET_DELETED_TRIGRAMME,
KFET_GENERIC_TRIGRAMME,
"LIQ",
"#13",
]
@property
def is_team(self):
return self.has_perm("kfet.is_team")
@staticmethod
def is_validandfree(trigramme):
data = {"is_valid": False, "is_free": False}
pattern = re.compile("^[^a-z]{3}$")
data["is_valid"] = pattern.match(trigramme) and True or False
try:
Account.objects.get(trigramme=trigramme)
except Account.DoesNotExist:
data["is_free"] = True
return data
def perms_to_perform_operation(self, amount):
perms = set()
# Checking is cash account
if self.is_cash:
# Yes, so no perms and no stop
return set(), False
if self.need_comment:
perms.add("kfet.perform_commented_operations")
new_balance = self.balance + amount
if new_balance < -kfet_config.overdraft_amount:
return set(), True
if new_balance < 0 and amount < 0:
perms.add("kfet.perform_negative_operations")
return perms, False
# Surcharge Méthode save() avec gestions de User et CofProfile
# Args:
# - data : datas pour User et CofProfile
# Action:
# - Enregistre User, CofProfile à partir de "data"
# - Enregistre Account
def save(self, data={}, *args, **kwargs):
if self.pk and data:
# Account update
# Updating User with data
user = self.user
user.first_name = data.get("first_name", user.first_name)
user.last_name = data.get("last_name", user.last_name)
user.email = data.get("email", user.email)
user.save()
# Updating CofProfile with data
cof = self.cofprofile
cof.departement = data.get("departement", cof.departement)
cof.save()
elif data:
# New account
# Checking if user has already an account
username = data.get("username")
try:
user = User.objects.get(username=username)
if hasattr(user.profile, "account_kfet"):
trigramme = user.profile.account_kfet.trigramme
raise Account.UserHasAccount(trigramme)
except User.DoesNotExist:
pass
# Creating or updating User instance
(user, _) = User.objects.get_or_create(username=username)
if "first_name" in data:
user.first_name = data["first_name"]
if "last_name" in data:
user.last_name = data["last_name"]
if "email" in data:
user.email = data["email"]
user.save()
# Creating or updating CofProfile instance
(cof, _) = CofProfile.objects.get_or_create(user=user)
if "login_clipper" in data:
cof.login_clipper = data["login_clipper"]
if "departement" in data:
cof.departement = data["departement"]
cof.save()
if data:
self.cofprofile = cof
super().save(*args, **kwargs)
def change_pwd(self, clear_password):
from .auth.utils import hash_password
self.password = hash_password(clear_password)
def update_negative(self):
if self.balance < 0:
# On met à jour le début de négatif seulement si la fin du négatif précédent
# est "vieille"
if (
hasattr(self, "negative")
and self.negative.end is not None
and timezone.now() > self.negative.end + kfet_config.cancel_duration
):
self.negative.start = timezone.now()
self.negative.end = None
self.negative.save()
elif not hasattr(self, "negative"):
self.negative = AccountNegative.objects.create(
account=self, start=timezone.now()
)
elif hasattr(self, "negative"):
if self.negative.end is None:
self.negative.end = timezone.now()
elif timezone.now() > self.negative.end + kfet_config.cancel_duration:
# Idem: on supprime le négatif après une légère période
self.negative.delete()
class UserHasAccount(Exception):
def __init__(self, trigramme):
self.trigramme = trigramme
def get_deleted_account():
return Account.objects.get(trigramme=KFET_DELETED_TRIGRAMME)
class AccountNegativeManager(models.Manager):
"""Manager for AccountNegative model."""
def get_queryset(self):
return super().get_queryset().select_related("account__cofprofile__user")
class AccountNegative(models.Model):
objects = AccountNegativeManager()
account = models.OneToOneField(
Account, on_delete=models.CASCADE, related_name="negative"
)
start = models.DateTimeField(blank=True, null=True, default=None)
end = models.DateTimeField(blank=True, null=True, default=None)
last_rappel = models.DateTimeField("Mail de rappel envoyé", blank=True, null=True)
class Meta:
permissions = (("view_negs", "Voir la liste des négatifs"),)
def send_rappel(self):
"""
Envoie un mail de rappel signalant que la personne est en négatif.
"""
# On envoie le mail
send_mail(
"Compte K-Psul négatif",
loader.render_to_string(
"kfet/mails/rappel.txt",
context={
"account": self.account,
"neg_amount": -self.account.balance,
"start_date": self.start,
},
),
settings.MAIL_DATA["rappel_negatif"]["FROM"],
[self.account.email],
)
# On enregistre le fait que l'envoi a bien eu lieu
self.last_rappel = timezone.now()
self.save()
return
class CheckoutQuerySet(models.QuerySet):
def is_valid(self):
now = timezone.now()
return self.filter(valid_from__lte=now, valid_to__gte=now)
class Checkout(models.Model):
created_by = models.ForeignKey(
Account, on_delete=models.SET(get_deleted_account), related_name="+"
)
name = models.CharField(max_length=45)
valid_from = models.DateTimeField()
valid_to = models.DateTimeField()
balance = models.DecimalField(max_digits=6, decimal_places=2, default=0)
is_protected = models.BooleanField(default=False)
objects = CheckoutQuerySet.as_manager()
def get_absolute_url(self):
return reverse("kfet.checkout.read", kwargs={"pk": self.pk})
class Meta:
ordering = ["-valid_to"]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
created = self.pk is None
ret = super().save(*args, **kwargs)
if created:
self.statements.create(
amount_taken=0,
balance_old=self.balance,
balance_new=self.balance,
by=self.created_by,
)
return ret
class CheckoutStatement(models.Model):
by = models.ForeignKey(
Account, on_delete=models.SET(get_deleted_account), related_name="+"
)
checkout = models.ForeignKey(
Checkout, on_delete=models.CASCADE, related_name="statements"
)
balance_old = models.DecimalField(
"ancienne balance", max_digits=6, decimal_places=2
)
balance_new = models.DecimalField(
"nouvelle balance", max_digits=6, decimal_places=2
)
amount_taken = models.DecimalField("montant pris", max_digits=6, decimal_places=2)
amount_error = models.DecimalField(
"montant de l'erreur", max_digits=6, decimal_places=2
)
at = models.DateTimeField(auto_now_add=True)
not_count = models.BooleanField("caisse non comptée", default=False)
taken_001 = models.PositiveSmallIntegerField("pièces de 1¢", default=0)
taken_002 = models.PositiveSmallIntegerField("pièces de 2¢", default=0)
taken_005 = models.PositiveSmallIntegerField("pièces de 5¢", default=0)
taken_01 = models.PositiveSmallIntegerField("pièces de 10¢", default=0)
taken_02 = models.PositiveSmallIntegerField("pièces de 20¢", default=0)
taken_05 = models.PositiveSmallIntegerField("pièces de 50¢", default=0)
taken_1 = models.PositiveSmallIntegerField("pièces de 1€", default=0)
taken_2 = models.PositiveSmallIntegerField("pièces de 2€", default=0)
taken_5 = models.PositiveSmallIntegerField("billets de 5€", default=0)
taken_10 = models.PositiveSmallIntegerField("billets de 10€", default=0)
taken_20 = models.PositiveSmallIntegerField("billets de 20€", default=0)
taken_50 = models.PositiveSmallIntegerField("billets de 50€", default=0)
taken_100 = models.PositiveSmallIntegerField("billets de 100€", default=0)
taken_200 = models.PositiveSmallIntegerField("billets de 200€", default=0)
taken_500 = models.PositiveSmallIntegerField("billets de 500€", default=0)
taken_cheque = models.DecimalField(
"montant des chèques", default=0, max_digits=6, decimal_places=2
)
def __str__(self):
return "%s %s" % (self.checkout, self.at)
def save(self, *args, **kwargs):
if not self.pk:
checkout_id = self.checkout_id
self.balance_old = Checkout.objects.values_list("balance", flat=True).get(
pk=checkout_id
)
if self.not_count:
self.balance_new = self.balance_old - self.amount_taken
self.amount_error = self.balance_new + self.amount_taken - self.balance_old
with transaction.atomic():
Checkout.objects.filter(pk=checkout_id).update(balance=self.balance_new)
super().save(*args, **kwargs)
else:
self.amount_error = self.balance_new + self.amount_taken - self.balance_old
# Si on modifie le dernier relevé d'une caisse et que la nouvelle
# balance est modifiée alors on modifie la balance actuelle de la caisse
last_statement = (
CheckoutStatement.objects.filter(checkout=self.checkout)
.order_by("at")
.last()
)
if (
last_statement.pk == self.pk
and last_statement.balance_new != self.balance_new
):
Checkout.objects.filter(pk=self.checkout_id).update(
balance=F("balance") - last_statement.balance_new + self.balance_new
)
super().save(*args, **kwargs)
class ArticleCategory(models.Model):
name = models.CharField("nom", max_length=45)
has_addcost = models.BooleanField(
"majorée",
default=True,
help_text="Si oui et qu'une majoration "
"est active, celle-ci sera "
"appliquée aux articles de "
"cette catégorie.",
)
has_reduction = models.BooleanField(
"réduction COF",
default=True,
help_text="Si oui, la réduction COF s'applique"
" aux articles de cette catégorie",
)
def __str__(self):
return self.name
class Article(models.Model):
name = models.CharField("nom", max_length=45)
is_sold = models.BooleanField("en vente", default=True)
hidden = models.BooleanField(
"caché",
default=False,
help_text="Si oui, ne sera pas affiché "
"au public ; par exemple "
"sur la carte.",
)
price = models.DecimalField("prix", max_digits=6, decimal_places=2, default=0)
stock = models.IntegerField(default=0)
category = models.ForeignKey(
ArticleCategory,
on_delete=models.PROTECT,
related_name="articles",
verbose_name="catégorie",
)
BOX_TYPE_CHOICES = (
("caisse", "caisse"),
("carton", "carton"),
("palette", "palette"),
("fût", "fût"),
)
box_type = models.CharField(
"type de contenant",
choices=BOX_TYPE_CHOICES,
max_length=choices_length(BOX_TYPE_CHOICES),
blank=True,
null=True,
default=None,
)
box_capacity = models.PositiveSmallIntegerField(
"capacité du contenant", blank=True, null=True, default=None
)
def __str__(self):
return "%s - %s" % (self.category.name, self.name)
def get_absolute_url(self):
return reverse("kfet.article.read", kwargs={"pk": self.pk})
def price_ukf(self):
return to_ukf(self.price)
class Inventory(models.Model):
articles = models.ManyToManyField(
Article, through="InventoryArticle", related_name="inventories"
)
by = models.ForeignKey(
Account, on_delete=models.SET(get_deleted_account), related_name="+"
)
at = models.DateTimeField(auto_now_add=True)
# Optional
order = models.OneToOneField(
"Order",
on_delete=models.PROTECT,
related_name="inventory",
blank=True,
null=True,
default=None,
)
class Meta:
ordering = ["-at"]
permissions = (
("order_to_inventory", "Générer un inventaire à partir d'une commande"),
)
class InventoryArticle(models.Model):
inventory = models.ForeignKey(Inventory, on_delete=models.CASCADE)
article = models.ForeignKey(Article, on_delete=models.CASCADE)
stock_old = models.IntegerField()
stock_new = models.IntegerField()
stock_error = models.IntegerField(default=0)
def save(self, *args, **kwargs):
# S'il s'agit d'un inventaire provenant d'une livraison, il n'y a pas
# d'erreur
if not self.inventory.order:
self.stock_error = self.stock_new - self.stock_old
super().save(*args, **kwargs)
class Supplier(models.Model):
articles = models.ManyToManyField(
Article,
verbose_name=_("articles vendus"),
through="SupplierArticle",
related_name="suppliers",
)
name = models.CharField(_("nom"), max_length=45)
address = models.TextField(_("adresse"), blank=True)
email = models.EmailField(_("adresse mail"), blank=True)
phone = models.CharField(_("téléphone"), max_length=20, blank=True)
comment = models.TextField(_("commentaire"), blank=True)
def __str__(self):
return self.name
class SupplierArticle(models.Model):
supplier = models.ForeignKey(Supplier, on_delete=models.CASCADE)
article = models.ForeignKey(Article, on_delete=models.CASCADE)
at = models.DateTimeField(auto_now_add=True)
price_HT = models.DecimalField(
max_digits=7, decimal_places=4, blank=True, null=True, default=None
)
TVA = models.DecimalField(
max_digits=4, decimal_places=2, blank=True, null=True, default=None
)
rights = models.DecimalField(
max_digits=7, decimal_places=4, blank=True, null=True, default=None
)
class Order(models.Model):
supplier = models.ForeignKey(
Supplier, on_delete=models.CASCADE, related_name="orders"
)
articles = models.ManyToManyField(
Article, through="OrderArticle", related_name="orders"
)
at = models.DateTimeField(auto_now_add=True)
amount = models.DecimalField(max_digits=6, decimal_places=2, default=0)
class Meta:
ordering = ["-at"]
class OrderArticle(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE)
article = models.ForeignKey(Article, on_delete=models.CASCADE)
quantity_ordered = models.IntegerField()
quantity_received = models.IntegerField(default=0)
class TransferGroup(models.Model):
at = models.DateTimeField(default=timezone.now)
# Optional
comment = models.CharField(max_length=255, blank=True, default="")
valid_by = models.ForeignKey(
Account,
on_delete=models.SET(get_deleted_account),
related_name="+",
blank=True,
null=True,
default=None,
)
class Transfer(models.Model):
group = models.ForeignKey(
TransferGroup, on_delete=models.PROTECT, related_name="transfers"
)
from_acc = models.ForeignKey(
Account,
on_delete=models.SET(get_deleted_account),
related_name="transfers_from",
)
to_acc = models.ForeignKey(
Account, on_delete=models.SET(get_deleted_account), related_name="transfers_to"
)
amount = models.DecimalField(max_digits=6, decimal_places=2)
# Optional
canceled_by = models.ForeignKey(
Account,
on_delete=models.SET(get_deleted_account),
null=True,
blank=True,
default=None,
related_name="+",
)
canceled_at = models.DateTimeField(null=True, blank=True, default=None)
def __str__(self):
return "{} -> {}: {}".format(self.from_acc, self.to_acc, self.amount)
class OperationGroup(models.Model):
on_acc = models.ForeignKey(
Account, on_delete=models.SET(get_deleted_account), related_name="opesgroup"
)
checkout = models.ForeignKey(
Checkout, on_delete=models.PROTECT, related_name="opesgroup"
)
at = models.DateTimeField(default=timezone.now)
amount = models.DecimalField(max_digits=6, decimal_places=2, default=0)
is_cof = models.BooleanField(default=False)
# Optional
comment = models.CharField(max_length=255, blank=True, default="")
valid_by = models.ForeignKey(
Account,
on_delete=models.SET(get_deleted_account),
related_name="+",
blank=True,
null=True,
default=None,
)
def __str__(self):
return ", ".join(map(str, self.opes.all()))
class Operation(models.Model):
PURCHASE = "purchase"
DEPOSIT = "deposit"
WITHDRAW = "withdraw"
INITIAL = "initial"
EDIT = "edit"
TYPE_ORDER_CHOICES = (
(PURCHASE, "Achat"),
(DEPOSIT, "Charge"),
(WITHDRAW, "Retrait"),
(INITIAL, "Initial"),
(EDIT, "Édition"),
)
group = models.ForeignKey(
OperationGroup, on_delete=models.PROTECT, related_name="opes"
)
type = models.CharField(
choices=TYPE_ORDER_CHOICES, max_length=choices_length(TYPE_ORDER_CHOICES)
)
amount = models.DecimalField(max_digits=6, decimal_places=2, blank=True, default=0)
# Optional
article = models.ForeignKey(
Article,
on_delete=models.SET_NULL,
related_name="operations",
blank=True,
null=True,
default=None,
)
article_nb = models.PositiveSmallIntegerField(blank=True, null=True, default=None)
canceled_by = models.ForeignKey(
Account,
on_delete=models.SET(get_deleted_account),
related_name="+",
blank=True,
null=True,
default=None,
)
canceled_at = models.DateTimeField(blank=True, null=True, default=None)
addcost_for = models.ForeignKey(
Account,
on_delete=models.SET(get_deleted_account),
related_name="addcosts",
blank=True,
null=True,
default=None,
)
addcost_amount = models.DecimalField(
max_digits=6, decimal_places=2, blank=True, null=True, default=None
)
class Meta:
permissions = (
("perform_deposit", "Effectuer une charge"),
("perform_negative_operations", "Enregistrer des commandes en négatif"),
("cancel_old_operations", "Annuler des commandes non récentes"),
(
"perform_commented_operations",
"Enregistrer des commandes avec commentaires",
),
)
@property
def is_checkout(self):
return (
self.type == Operation.DEPOSIT
or self.type == Operation.WITHDRAW
or (self.type == Operation.PURCHASE and self.group.on_acc.is_cash)
)
def __str__(self):
templates = {
self.PURCHASE: "{nb} {article.name} ({amount}€)",
self.DEPOSIT: "charge ({amount}€)",
self.WITHDRAW: "retrait ({amount}€)",
self.INITIAL: "initial ({amount}€)",
self.EDIT: "édition ({amount}€)",
}
return templates[self.type].format(
nb=self.article_nb, article=self.article, amount=self.amount
)