gestioCOF/kfet/models.py
2024-07-12 14:54:14 +02:00

778 lines
25 KiB
Python

import re
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.mail import EmailMessage
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()
self.negative.save()
elif timezone.now() > self.negative.end + kfet_config.cancel_duration:
# Idem: on supprime le négatif après une légère période
# Nécessaire pour se souvenir du négatif après une charge annulée
self.negative.delete()
class UserHasAccount(Exception):
def __init__(self, trigramme):
self.trigramme = trigramme
def send_creation_email(self):
"""
Envoie un mail à la création du trigramme.
"""
mail_data = settings.MAIL_DATA["kfet"]
email = EmailMessage(
subject="Création d'un trigramme",
body=loader.render_to_string(
"kfet/mails/creation_trigramme.txt",
context={
"account": self,
"site": Site.objects.get_current(),
"url_read": reverse("kfet.account.read", args=(self.trigramme)),
"url_update": reverse("kfet.account.update", args=(self.trigramme)),
"url_delete": reverse("kfet.account.delete", args=(self.trigramme))
},
),
from_email=mail_data["FROM"],
to=[self.email],
reply_to=[mail_data["REPLYTO"]],
)
# On envoie le mail
email.send()
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.
"""
mail_data = settings.MAIL_DATA["kfet"]
email = EmailMessage(
subject="Compte K-Psul négatif",
body=loader.render_to_string(
"kfet/mails/rappel.txt",
context={
"account": self.account,
"neg_amount": -self.account.balance,
"start_date": self.start,
},
),
from_email=mail_data["FROM"],
to=[self.account.email],
reply_to=[mail_data["REPLYTO"]],
)
# On envoie le mail
email.send()
# On enregistre le fait que l'envoi a bien eu lieu
self.last_rappel = timezone.now()
self.save()
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
)