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 )