# -*- coding: utf-8 -*- from django.db import models from django.core.validators import RegexValidator from django.contrib.auth.models import User from gestioncof.models import CofProfile from django.urls import reverse from django.utils.six.moves import reduce from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from django.db import transaction from django.db.models import F from datetime import date import re from .auth import KFET_GENERIC_TRIGRAMME from .auth.models import GenericTeamToken # noqa from .config import kfet_config from .utils import to_ukf def choices_length(choices): return reduce(lambda m, choice: max(m, len(choice[0])), choices, 0) def default_promo(): now = date.today() return now.month <= 8 and now.year-1 or now.year 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_CHOICES = [(r,r) for r in range(1980, date.today().year+1)] promo = models.IntegerField( choices = PROMO_CHOICES, blank = True, null = True, default = default_promo()) 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"), ) 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 real_balance(self): if hasattr(self, 'negative') and self.negative.balance_offset: return self.balance - self.negative.balance_offset return self.balance @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 != 'GNR' @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 = Account.objects.get(trigramme=trigramme) except Account.DoesNotExist: data['is_free'] = True return data def perms_to_perform_operation(self, amount): overdraft_duration_max = kfet_config.overdraft_duration overdraft_amount_max = kfet_config.overdraft_amount perms = set() stop_ope = False # 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') # Checking is frozen account if self.is_frozen: perms.add('kfet.override_frozen_protection') new_balance = self.balance + amount if new_balance < 0 and amount < 0: # Retrieving overdraft amount limit if (hasattr(self, 'negative') and self.negative.authz_overdraft_amount is not None): overdraft_amount = - self.negative.authz_overdraft_amount else: overdraft_amount = - overdraft_amount_max # Retrieving overdraft datetime limit if (hasattr(self, 'negative') and self.negative.authz_overdraft_until is not None): overdraft_until = self.negative.authz_overdraft_until elif hasattr(self, 'negative'): overdraft_until = \ self.negative.start + overdraft_duration_max else: overdraft_until = timezone.now() + overdraft_duration_max # Checking it doesn't break 1 rule if new_balance < overdraft_amount or timezone.now() > overdraft_until: stop_ope = True perms.add('kfet.perform_negative_operations') return perms, stop_ope # 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(Account, self).save(*args, **kwargs) def change_pwd(self, clear_password): from .auth.utils import hash_password self.password = hash_password(clear_password) # Surcharge de delete # Pas de suppression possible # Cas à régler plus tard def delete(self, *args, **kwargs): pass def update_negative(self): if self.real_balance < 0: if hasattr(self, 'negative') and not self.negative.start: self.negative.start = timezone.now() self.negative.save() elif not hasattr(self, 'negative'): self.negative = ( AccountNegative.objects.create( account=self, start=timezone.now(), ) ) elif hasattr(self, 'negative'): # self.real_balance >= 0 balance_offset = self.negative.balance_offset if balance_offset: ( Account.objects .filter(pk=self.pk) .update(balance=F('balance')-balance_offset) ) self.refresh_from_db() self.negative.delete() class UserHasAccount(Exception): def __init__(self, trigramme): self.trigramme = 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.PROTECT, related_name="negative", ) start = models.DateTimeField(blank=True, null=True, default=None) balance_offset = models.DecimalField( "décalage de balance", help_text="Montant non compris dans l'autorisation de négatif", max_digits=6, decimal_places=2, blank=True, null=True, default=None, ) authz_overdraft_amount = models.DecimalField( "négatif autorisé", max_digits=6, decimal_places=2, blank=True, null=True, default=None, ) authz_overdraft_until = models.DateTimeField( "expiration du négatif", blank=True, null=True, default=None, ) comment = models.CharField("commentaire", max_length=255, blank=True) class Meta: permissions = ( ('view_negs', 'Voir la liste des négatifs'), ) @property def until_default(self): return self.start + kfet_config.overdraft_duration class Checkout(models.Model): created_by = models.ForeignKey( Account, on_delete = models.PROTECT, 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) 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 class CheckoutTransfer(models.Model): from_checkout = models.ForeignKey( Checkout, on_delete = models.PROTECT, related_name = "transfers_from") to_checkout = models.ForeignKey( Checkout, on_delete = models.PROTECT, related_name = "transfers_to") amount = models.DecimalField( max_digits = 6, decimal_places = 2) @python_2_unicode_compatible class CheckoutStatement(models.Model): by = models.ForeignKey( Account, on_delete = models.PROTECT, related_name = "+") checkout = models.ForeignKey( Checkout, on_delete = models.PROTECT, 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(CheckoutStatement, self).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(CheckoutStatement, self).save(*args, **kwargs) @python_2_unicode_compatible 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.") def __str__(self): return self.name @python_2_unicode_compatible 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 ArticleRule(models.Model): article_on = models.OneToOneField( Article, on_delete = models.PROTECT, related_name = "rule_on") article_to = models.OneToOneField( Article, on_delete = models.PROTECT, related_name = "rule_to") ratio = models.PositiveSmallIntegerField() class Inventory(models.Model): articles = models.ManyToManyField( Article, through = 'InventoryArticle', related_name = "inventories") by = models.ForeignKey( Account, on_delete = models.PROTECT, 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.PROTECT) article = models.ForeignKey( Article, on_delete = models.PROTECT) 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(InventoryArticle, self).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.PROTECT) article = models.ForeignKey( Article, on_delete = models.PROTECT) 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.PROTECT, 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.PROTECT) article = models.ForeignKey( Article, on_delete = models.PROTECT) 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.PROTECT, 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.PROTECT, related_name="transfers_from") to_acc = models.ForeignKey( Account, on_delete=models.PROTECT, related_name="transfers_to") amount = models.DecimalField(max_digits=6, decimal_places=2) # Optional canceled_by = models.ForeignKey( Account, on_delete=models.PROTECT, 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.PROTECT, 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.PROTECT, 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.PROTECT, 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.PROTECT, 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.PROTECT, 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'), ('override_frozen_protection', "Forcer le gel d'un compte"), ('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)