import re from datetime import date from django.core.urlresolvers import reverse from django.core.validators import RegexValidator from django.contrib.auth.models import User from django.db import models from django.db import transaction from django.db.models import F from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from django.utils.six.moves import reduce from django.utils.translation import ugettext_lazy as _ from gestion.models import Profile 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('profile__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() profile = models.OneToOneField( Profile, related_name="account_kfet", on_delete=models.PROTECT ) 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 user et profile @property def user(self): return self.profile.user @property def username(self): return self.profile.user.username @property def first_name(self): return self.profile.user.first_name @property def last_name(self): return self.profile.user.last_name @property def email(self): return self.profile.user.email @property def departement(self): return self.profile.departement @property def is_cof(self): return self.profile.user.has_perm("cof.member") # 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 Profile # Args: # - data : datas pour User et Profile # Action: # - Enregistre User, Profile à 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 Profile with data profile = self.profile profile.departement = data.get("departement", profile.departement) profile.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 Profile instance (profile, _) = Profile.objects.get_or_create(user=user) if "login_clipper" in data: profile.login_clipper = data['login_clipper'] if "departement" in data: profile.departement = data['departement'] profile.save() if data: self.profile = profile 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__profile__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)