models.py 33 KB
Newer Older
1
# -*- mode: python; coding: utf-8 -*-
2 3 4 5 6 7 8
# Re2o est un logiciel d'administration développé initiallement au rezometz. Il
# se veut agnostique au réseau considéré, de manière à être installable en
# quelques clics.
#
# Copyright © 2017  Gabriel Détraz
# Copyright © 2017  Goulven Kermarec
# Copyright © 2017  Augustin Lemesle
9
# Copyright © 2018  Hugo Levy-Falk
10 11 12 13 14 15 16 17 18 19 20 21 22 23
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
chirac's avatar
chirac committed
24
"""
25 26 27 28
The database models for the 'cotisation' app of re2o.
The goal is to keep the main actions here, i.e. the 'clean' and 'save'
function are higly reposnsible for the changes, checking the coherence of the
data and the good behaviour in general for not breaking the database.
chirac's avatar
chirac committed
29

30 31
For further details on each of those models, see the documentation details for
each.
chirac's avatar
chirac committed
32
"""
33

34
from __future__ import unicode_literals
chirac's avatar
chirac committed
35
from dateutil.relativedelta import relativedelta
36

chirac's avatar
chirac committed
37
from django.db import models
moamoak's avatar
moamoak committed
38
from django.db.models import Q, Max
39 40
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
chibrac's avatar
chibrac committed
41
from django.forms import ValidationError
42
from django.core.validators import MinValueValidator
43
from django.utils import timezone
44
from django.utils.translation import ugettext_lazy as _
45 46 47
from django.urls import reverse
from django.shortcuts import redirect
from django.contrib import messages
48

49
from preferences.models import CotisationsOption
moamoak's avatar
moamoak committed
50
from machines.models import regen
51
from re2o.field_permissions import FieldPermissionModelMixin
52
from re2o.mixins import AclMixin, RevMixin
53

klafyvel's avatar
klafyvel committed
54 55 56
from cotisations.utils import (
    find_payment_method, send_mail_invoice, send_mail_voucher
)
57
from cotisations.validators import check_no_balance
58

59

klafyvel's avatar
klafyvel committed
60 61 62
class BaseInvoice(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model):
    date = models.DateTimeField(
        auto_now_add=True,
63
        verbose_name=_("Date")
klafyvel's avatar
klafyvel committed
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
    )

    # TODO : change prix to price
    def prix(self):
        """
        Returns: the raw price without the quantities.
        Deprecated, use :total_price instead.
        """
        price = Vente.objects.filter(
            facture=self
        ).aggregate(models.Sum('prix'))['prix__sum']
        return price

    # TODO : change prix to price
    def prix_total(self):
        """
        Returns: the total price for an invoice. Sum all the articles' prices
        and take the quantities into account.
        """
        # TODO : change Vente to somethingelse
        return Vente.objects.filter(
            facture=self
        ).aggregate(
            total=models.Sum(
                models.F('prix')*models.F('number'),
89
                output_field=models.DecimalField()
klafyvel's avatar
klafyvel committed
90 91 92 93 94 95 96 97 98 99 100 101 102 103
            )
        )['total'] or 0

    def name(self):
        """
        Returns : a string with the name of all the articles in the invoice.
        Used for reprensenting the invoice with a string.
        """
        name = ' - '.join(Vente.objects.filter(
            facture=self
        ).values_list('name', flat=True))
        return name


104
# TODO : change facture to invoice
klafyvel's avatar
klafyvel committed
105
class Facture(BaseInvoice):
106 107 108
    """
    The model for an invoice. It reprensents the fact that a user paid for
    something (it can be multiple article paid at once).
moamoak's avatar
moamoak committed
109

110 111 112 113 114 115 116 117 118 119 120 121 122 123
    An invoice is linked to :
        * one or more purchases (one for each article sold that time)
        * a user (the one who bought those articles)
        * a payment method (the one used by the user)
        * (if applicable) a bank
        * (if applicable) a cheque number.
    Every invoice is dated throught the 'date' value.
    An invoice has a 'controlled' value (default : False) which means that
    someone with high enough rights has controlled that invoice and taken it
    into account. It also has a 'valid' value (default : True) which means
    that someone with high enough rights has decided that this invoice was not
    valid (thus it's like the user never paid for his articles). It may be
    necessary in case of non-payment.
    """
124

chirac's avatar
chirac committed
125
    user = models.ForeignKey('users.User', on_delete=models.PROTECT)
126
    # TODO : change paiement to payment
chirac's avatar
chirac committed
127
    paiement = models.ForeignKey('Paiement', on_delete=models.PROTECT)
128
    # TODO : change banque to bank
chirac's avatar
chirac committed
129
    banque = models.ForeignKey(
chirac's avatar
chirac committed
130 131 132
        'Banque',
        on_delete=models.PROTECT,
        blank=True,
133 134 135 136 137 138
        null=True
    )
    # TODO : maybe change to cheque nummber because not evident
    cheque = models.CharField(
        max_length=255,
        blank=True,
139
        verbose_name=_("cheque number")
140 141 142
    )
    # TODO : change name to validity for clarity
    valid = models.BooleanField(
143
        default=False,
144
        verbose_name=_("validated")
145 146 147 148
    )
    # TODO : changed name to controlled for clarity
    control = models.BooleanField(
        default=False,
149
        verbose_name=_("controlled")
150
    )
151

152 153
    class Meta:
        abstract = False
154
        permissions = (
155
            # TODO : change facture to invoice
moamoak's avatar
moamoak committed
156
            ('change_facture_control',
157
             _("Can edit the \"controlled\" state")),
moamoak's avatar
moamoak committed
158
            ('view_facture',
159
             _("Can view an invoice object")),
moamoak's avatar
moamoak committed
160
            ('change_all_facture',
161
             _("Can edit all the previous invoices")),
162
        )
163 164
        verbose_name = _("invoice")
        verbose_name_plural = _("invoices")
165

166 167 168 169 170
    def linked_objects(self):
        """Return linked objects : machine and domain.
        Usefull in history display"""
        return self.vente_set.all()

171
    def can_edit(self, user_request, *args, **kwargs):
Gabriel Detraz's avatar
Gabriel Detraz committed
172
        if not user_request.has_perm('cotisations.change_facture'):
173
            return False, _("You don't have the right to edit an invoice.")
moamoak's avatar
moamoak committed
174 175 176 177 178 179 180 181
        elif not user_request.has_perm('cotisations.change_all_facture') and \
                not self.user.can_edit(user_request, *args, **kwargs)[0]:
            return False, _("You don't have the right to edit this user's "
                            "invoices.")
        elif not user_request.has_perm('cotisations.change_all_facture') and \
                (self.control or not self.valid):
            return False, _("You don't have the right to edit an invoice "
                            "already controlled or invalidated.")
182 183 184 185
        else:
            return True, None

    def can_delete(self, user_request, *args, **kwargs):
Gabriel Detraz's avatar
Gabriel Detraz committed
186
        if not user_request.has_perm('cotisations.delete_facture'):
187
            return False, _("You don't have the right to delete an invoice.")
188 189
        elif not user_request.has_perm('cotisations.change_all_facture') and \
                not self.user.can_edit(user_request, *args, **kwargs)[0]:
moamoak's avatar
moamoak committed
190 191
            return False, _("You don't have the right to delete this user's "
                            "invoices.")
192 193
        elif not user_request.has_perm('cotisations.change_all_facture') and \
                (self.control or not self.valid):
moamoak's avatar
moamoak committed
194 195
            return False, _("You don't have the right to delete an invoice "
                            "already controlled or invalidated.")
196 197 198
        else:
            return True, None

moamoak's avatar
moamoak committed
199
    def can_view(self, user_request, *_args, **_kwargs):
200 201 202 203 204 205 206 207
        if not user_request.has_perm('cotisations.view_facture'):
            if self.user != user_request:
                return False, _("You don't have the right to view someone else's "
                                "invoices history.")
            elif not self.valid:
                return False, _("The invoice has been invalidated.")
            else:
                return True, None
208 209 210
        else:
            return True, None

klafyvel's avatar
klafyvel committed
211
    @staticmethod
moamoak's avatar
moamoak committed
212 213 214
    def can_change_control(user_request, *_args, **_kwargs):
        """ Returns True if the user can change the 'controlled' status of
        this invoice """
moamoak's avatar
moamoak committed
215 216 217 218
        return (
            user_request.has_perm('cotisations.change_facture_control'),
            _("You don't have the right to edit the \"controlled\" state.")
        )
219

220 221
    @staticmethod
    def can_create(user_request, *_args, **_kwargs):
klafyvel's avatar
klafyvel committed
222
        """Check if a user can create an invoice.
223 224 225 226 227

        :param user_request: The user who wants to create an invoice.
        :return: a message and a boolean which is True if the user can create
            an invoice or if the `options.allow_self_subscription` is set.
        """
228 229 230
        if user_request.has_perm('cotisations.add_facture'):
            return True, None
        if len(Paiement.find_allowed_payments(user_request)) <= 0:
231
            return False, _("There are no payment method which you can use.")
232
        if len(Article.find_allowed_articles(user_request, user_request)) <= 0:
233
            return False, _("There are no article that you can buy.")
klafyvel's avatar
klafyvel committed
234
        return True, None
235

236 237 238
    def __init__(self, *args, **kwargs):
        super(Facture, self).__init__(*args, **kwargs)
        self.field_permissions = {
moamoak's avatar
moamoak committed
239
            'control': self.can_change_control,
240
        }
241
        self.__original_valid = self.valid
klafyvel's avatar
klafyvel committed
242 243
        self.__original_control = self.control

klafyvel's avatar
klafyvel committed
244
    def get_subscription(self):
klafyvel's avatar
klafyvel committed
245
        """Returns every subscription associated with this invoice."""
klafyvel's avatar
klafyvel committed
246 247 248
        return Cotisation.objects.filter(
            vente__in=self.vente_set.filter(
                Q(type_cotisation='All') |
klafyvel's avatar
klafyvel committed
249
                Q(type_cotisation='Adhesion')
klafyvel's avatar
klafyvel committed
250
            )
klafyvel's avatar
klafyvel committed
251 252
        )

klafyvel's avatar
klafyvel committed
253
    def is_subscription(self):
klafyvel's avatar
klafyvel committed
254
        """Returns True if this invoice contains at least one subscribtion."""
klafyvel's avatar
klafyvel committed
255
        return bool(self.get_subscription())
256

257 258 259 260
    def save(self, *args, **kwargs):
        super(Facture, self).save(*args, **kwargs)
        if not self.__original_valid and self.valid:
            send_mail_invoice(self)
261
        if self.is_subscription() \
klafyvel's avatar
klafyvel committed
262 263 264
                and not self.__original_control \
                and self.control \
                and CotisationsOption.get_cached_value('send_voucher_mail'):
klafyvel's avatar
klafyvel committed
265
            send_mail_voucher(self)
266

267
    def __str__(self):
Dalahro's avatar
Dalahro committed
268
        return str(self.user) + ' ' + str(self.date)
269

klafyvel's avatar
klafyvel committed
270

271
@receiver(post_save, sender=Facture)
moamoak's avatar
moamoak committed
272
def facture_post_save(**kwargs):
273 274 275
    """
    Synchronise the LDAP user after an invoice has been saved.
    """
276
    facture = kwargs['instance']
277 278
    if facture.valid:
        user = facture.user
279 280
        user.set_active()
        user.ldap_sync(base=False, access_refresh=True, mac_refresh=False)
281

chirac's avatar
chirac committed
282

283
@receiver(post_delete, sender=Facture)
moamoak's avatar
moamoak committed
284
def facture_post_delete(**kwargs):
285 286 287
    """
    Synchronise the LDAP user after an invoice has been deleted.
    """
288
    user = kwargs['instance'].user
289
    user.ldap_sync(base=False, access_refresh=True, mac_refresh=False)
290

chirac's avatar
chirac committed
291

klafyvel's avatar
klafyvel committed
292 293 294
class CustomInvoice(BaseInvoice):
    class Meta:
        permissions = (
295
            ('view_custominvoice', _("Can view a custom invoice object")),
klafyvel's avatar
klafyvel committed
296 297 298
        )
    recipient = models.CharField(
        max_length=255,
299
        verbose_name=_("Recipient")
klafyvel's avatar
klafyvel committed
300 301 302
    )
    payment = models.CharField(
        max_length=255,
303
        verbose_name=_("Payment type")
klafyvel's avatar
klafyvel committed
304 305 306
    )
    address = models.CharField(
        max_length=255,
307
        verbose_name=_("Address")
klafyvel's avatar
klafyvel committed
308 309
    )
    paid = models.BooleanField(
klafyvel's avatar
klafyvel committed
310 311
        verbose_name=_("Paid"),
        default=False
klafyvel's avatar
klafyvel committed
312
    )
klafyvel's avatar
klafyvel committed
313 314 315 316 317
    remark = models.TextField(
        verbose_name=_("Remark"),
        blank=True,
        null=True
    )
klafyvel's avatar
klafyvel committed
318 319


klafyvel's avatar
klafyvel committed
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366
class CostEstimate(CustomInvoice):
    class Meta:
        permissions = (
            ('view_costestimate', _("Can view a cost estimate object")),
        )
    validity = models.DurationField(
        verbose_name=_("Period of validity"),
        help_text="DD HH:MM:SS"
    )
    final_invoice = models.ForeignKey(
        CustomInvoice,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="origin_cost_estimate",
        primary_key=False
    )

    def create_invoice(self):
        """Create a CustomInvoice from the CostEstimate."""
        if self.final_invoice is not None:
            return self.final_invoice
        invoice = CustomInvoice()
        invoice.recipient = self.recipient
        invoice.payment = self.payment
        invoice.address = self.address
        invoice.paid = False
        invoice.remark = self.remark
        invoice.date = timezone.now()
        invoice.save()
        self.final_invoice = invoice
        self.save()
        for sale in self.vente_set.all():
            Vente.objects.create(
                facture=invoice,
                name=sale.name,
                prix=sale.prix,
                number=sale.number,
            )
        return invoice

    def can_delete(self, user_request, *args, **kwargs):
        if not user_request.has_perm('cotisations.delete_costestimate'):
            return False, _("You don't have the right "
                            "to delete a cost estimate.")
        if self.final_invoice is not None:
            return False, _("The cost estimate has an "
367
                            "invoice and can't be deleted.")
klafyvel's avatar
klafyvel committed
368 369 370
        return True, None


371
# TODO : change Vente to Purchase
372
class Vente(RevMixin, AclMixin, models.Model):
373 374 375
    """
    The model defining a purchase. It consist of one type of article being
    sold. In particular there may be multiple purchases in a single invoice.
moamoak's avatar
moamoak committed
376

377 378 379 380 381 382 383
    It's reprensentated by:
        * an amount (the number of items sold)
        * an invoice (whose the purchase is part of)
        * an article
        * (if applicable) a cotisation (which holds some informations about
            the effect of the purchase on the time agreed for this user)
    """
384

385
    # TODO : change this to English
386
    COTISATION_TYPE = (
387 388 389
        ('Connexion', _("Connection")),
        ('Adhesion', _("Membership")),
        ('All', _("Both of them")),
390 391
    )

392 393
    # TODO : change facture to invoice
    facture = models.ForeignKey(
klafyvel's avatar
klafyvel committed
394
        'BaseInvoice',
395
        on_delete=models.CASCADE,
396
        verbose_name=_("invoice")
397 398 399 400
    )
    # TODO : change number to amount for clarity
    number = models.IntegerField(
        validators=[MinValueValidator(1)],
401
        verbose_name=_("amount")
402 403 404 405
    )
    # TODO : change this field for a ForeinKey to Article
    name = models.CharField(
        max_length=255,
406
        verbose_name=_("article")
407 408 409 410 411 412
    )
    # TODO : change prix to price
    # TODO : this field is not needed if you use Article ForeignKey
    prix = models.DecimalField(
        max_digits=5,
        decimal_places=2,
413
        verbose_name=_("price"))
414
    # TODO : this field is not needed if you use Article ForeignKey
415
    duration = models.PositiveIntegerField(
chirac's avatar
chirac committed
416
        blank=True,
417
        null=True,
418
        verbose_name=_("duration (in months)")
419 420
    )
    # TODO : this field is not needed if you use Article ForeignKey
421 422 423 424
    type_cotisation = models.CharField(
        choices=COTISATION_TYPE,
        blank=True,
        null=True,
425
        max_length=255,
426
        verbose_name=_("subscription type")
427
    )
chirac's avatar
chirac committed
428

429 430
    class Meta:
        permissions = (
431 432
            ('view_vente', _("Can view a purchase object")),
            ('change_all_vente', _("Can edit all the previous purchases")),
433
        )
434 435
        verbose_name = _("purchase")
        verbose_name_plural = _("purchases")
436

437
    # TODO : change prix_total to total_price
438
    def prix_total(self):
439 440 441
        """
        Returns: the total of price for this amount of items.
        """
442 443
        return self.prix*self.number

444
    def update_cotisation(self):
445 446 447 448
        """
        Update the related object 'cotisation' if there is one. Based on the
        duration of the purchase.
        """
449 450
        if hasattr(self, 'cotisation'):
            cotisation = self.cotisation
chirac's avatar
chirac committed
451
            cotisation.date_end = cotisation.date_start + relativedelta(
chirac's avatar
chirac committed
452
                months=self.duration*self.number)
453 454 455
        return

    def create_cotis(self, date_start=False):
456 457 458 459 460
        """
        Update and create a 'cotisation' related object if there is a
        cotisation_type defined (which means the article sold represents
        a cotisation)
        """
klafyvel's avatar
klafyvel committed
461 462 463 464
        try:
            invoice = self.facture.facture
        except Facture.DoesNotExist:
            return
465
        if not hasattr(self, 'cotisation') and self.type_cotisation:
chirac's avatar
chirac committed
466
            cotisation = Cotisation(vente=self)
467
            cotisation.type_cotisation = self.type_cotisation
468
            if date_start:
Gabriel Detraz's avatar
Gabriel Detraz committed
469
                end_cotisation = Cotisation.objects.filter(
chirac's avatar
chirac committed
470 471
                    vente__in=Vente.objects.filter(
                        facture__in=Facture.objects.filter(
klafyvel's avatar
klafyvel committed
472
                            user=invoice.user
chirac's avatar
chirac committed
473
                        ).exclude(valid=False))
474 475 476 477 478 479
                ).filter(
                    Q(type_cotisation='All') |
                    Q(type_cotisation=self.type_cotisation)
                ).filter(
                    date_start__lt=date_start
                ).aggregate(Max('date_end'))['date_end__max']
moamoak's avatar
moamoak committed
480
            elif self.type_cotisation == "Adhesion":
klafyvel's avatar
klafyvel committed
481
                end_cotisation = invoice.user.end_adhesion()
482
            else:
klafyvel's avatar
klafyvel committed
483
                end_cotisation = invoice.user.end_connexion()
484
            date_start = date_start or timezone.now()
485 486
            end_cotisation = end_cotisation or date_start
            date_max = max(end_cotisation, date_start)
487
            cotisation.date_start = date_max
chirac's avatar
chirac committed
488
            cotisation.date_end = cotisation.date_start + relativedelta(
chirac's avatar
chirac committed
489
                months=self.duration*self.number
490
            )
491 492 493
        return

    def save(self, *args, **kwargs):
494 495 496 497 498 499
        """
        Save a purchase object and check if all the fields are coherents
        It also update the associated cotisation in the changes have some
        effect on the user's cotisation
        """
        # Checking that if a cotisation is specified, there is also a duration
500
        if self.type_cotisation and not self.duration:
501
            raise ValidationError(
502
                _("Duration must be specified for a subscription.")
503
            )
504 505
        self.update_cotisation()
        super(Vente, self).save(*args, **kwargs)
506

507
    def can_edit(self, user_request, *args, **kwargs):
Gabriel Detraz's avatar
Gabriel Detraz committed
508
        if not user_request.has_perm('cotisations.change_vente'):
509
            return False, _("You don't have the right to edit the purchases.")
moamoak's avatar
moamoak committed
510 511 512
        elif (not user_request.has_perm('cotisations.change_all_facture') and
              not self.facture.user.can_edit(
                  user_request, *args, **kwargs
513
        )[0]):
moamoak's avatar
moamoak committed
514 515
            return False, _("You don't have the right to edit this user's "
                            "purchases.")
moamoak's avatar
moamoak committed
516 517
        elif (not user_request.has_perm('cotisations.change_all_vente') and
              (self.facture.control or not self.facture.valid)):
moamoak's avatar
moamoak committed
518 519
            return False, _("You don't have the right to edit a purchase "
                            "already controlled or invalidated.")
520 521
        else:
            return True, None
522

523
    def can_delete(self, user_request, *args, **kwargs):
Gabriel Detraz's avatar
Gabriel Detraz committed
524
        if not user_request.has_perm('cotisations.delete_vente'):
525
            return False, _("You don't have the right to delete a purchase.")
526
        if not self.facture.user.can_edit(user_request, *args, **kwargs)[0]:
moamoak's avatar
moamoak committed
527 528
            return False, _("You don't have the right to delete this user's "
                            "purchases.")
529
        if self.facture.control or not self.facture.valid:
moamoak's avatar
moamoak committed
530 531
            return False, _("You don't have the right to delete a purchase "
                            "already controlled or invalidated.")
532 533
        else:
            return True, None
534

moamoak's avatar
moamoak committed
535 536 537
    def can_view(self, user_request, *_args, **_kwargs):
        if (not user_request.has_perm('cotisations.view_vente') and
                self.facture.user != user_request):
538
            return False, _("You don't have the right to view someone "
moamoak's avatar
moamoak committed
539
                            "else's purchase history.")
540 541
        else:
            return True, None
542

chirac's avatar
chirac committed
543
    def __str__(self):
544
        return str(self.name) + ' ' + str(self.facture)
chirac's avatar
chirac committed
545

chirac's avatar
chirac committed
546

547
# TODO : change vente to purchase
548
@receiver(post_save, sender=Vente)
moamoak's avatar
moamoak committed
549
def vente_post_save(**kwargs):
550 551 552 553 554
    """
    Creates a 'cotisation' related object if needed and synchronise the
    LDAP user when a purchase has been saved.
    """
    purchase = kwargs['instance']
klafyvel's avatar
klafyvel committed
555 556 557 558
    try:
        purchase.facture.facture
    except Facture.DoesNotExist:
        return
Gabriel Detraz's avatar
Gabriel Detraz committed
559
    if hasattr(purchase, 'cotisation'):
560 561 562 563 564
        purchase.cotisation.vente = purchase
        purchase.cotisation.save()
    if purchase.type_cotisation:
        purchase.create_cotis()
        purchase.cotisation.save()
565
        user = purchase.facture.facture.user
566 567
        user.set_active()
        user.ldap_sync(base=True, access_refresh=True, mac_refresh=False)
568

chirac's avatar
chirac committed
569

570
# TODO : change vente to purchase
571
@receiver(post_delete, sender=Vente)
moamoak's avatar
moamoak committed
572
def vente_post_delete(**kwargs):
573 574 575 576
    """
    Synchronise the LDAP user after a purchase has been deleted.
    """
    purchase = kwargs['instance']
klafyvel's avatar
klafyvel committed
577 578 579 580
    try:
        invoice = purchase.facture.facture
    except Facture.DoesNotExist:
        return
581
    if purchase.type_cotisation:
klafyvel's avatar
klafyvel committed
582
        user = invoice.user
583
        user.ldap_sync(base=False, access_refresh=True, mac_refresh=False)
584

chirac's avatar
chirac committed
585

586
class Article(RevMixin, AclMixin, models.Model):
587
    """
moamoak's avatar
moamoak committed
588 589 590
    The definition of an article model. It represents a type of object
    that can be sold to the user.

591 592 593
    It's represented by:
        * a name
        * a price
moamoak's avatar
moamoak committed
594 595
        * a cotisation type (indicating if this article reprensents a
            cotisation or not)
596 597 598
        * a duration (if it is a cotisation)
        * a type of user (indicating what kind of user can buy this article)
    """
599

600
    # TODO : Either use TYPE or TYPES in both choices but not both
601
    USER_TYPES = (
602 603 604
        ('Adherent', _("Member")),
        ('Club', _("Club")),
        ('All', _("Both of them")),
605 606 607
    )

    COTISATION_TYPE = (
608 609 610
        ('Connexion', _("Connection")),
        ('Adhesion', _("Membership")),
        ('All', _("Both of them")),
611 612
    )

613 614
    name = models.CharField(
        max_length=255,
615
        verbose_name=_("designation")
616 617 618 619 620
    )
    # TODO : change prix to price
    prix = models.DecimalField(
        max_digits=5,
        decimal_places=2,
621
        verbose_name=_("unit price")
622
    )
623
    duration = models.PositiveIntegerField(
David Sinquin's avatar
David Sinquin committed
624 625
        blank=True,
        null=True,
626
        validators=[MinValueValidator(0)],
627
        verbose_name=_("duration (in months)")
628
    )
629 630 631
    type_user = models.CharField(
        choices=USER_TYPES,
        default='All',
632
        max_length=255,
633
        verbose_name=_("type of users concerned")
634 635 636 637 638 639
    )
    type_cotisation = models.CharField(
        choices=COTISATION_TYPE,
        default=None,
        blank=True,
        null=True,
640
        max_length=255,
641
        verbose_name=_("subscription type")
642
    )
klafyvel's avatar
klafyvel committed
643
    available_for_everyone = models.BooleanField(
644
        default=False,
645
        verbose_name=_("is available for every user")
646
    )
chibrac's avatar
chibrac committed
647

648 649
    unique_together = ('name', 'type_user')

650 651
    class Meta:
        permissions = (
652 653
            ('view_article', _("Can view an article object")),
            ('buy_every_article', _("Can buy every article"))
654
        )
655 656
        verbose_name = "article"
        verbose_name_plural = "articles"
657

chibrac's avatar
chibrac committed
658
    def clean(self):
659 660
        if self.name.lower() == 'solde':
            raise ValidationError(
661
                _("Balance is a reserved article name.")
662
            )
663 664
        if self.type_cotisation and not self.duration:
            raise ValidationError(
665
                _("Duration must be specified for a subscription.")
666
            )
chibrac's avatar
chibrac committed
667

chirac's avatar
chirac committed
668 669 670
    def __str__(self):
        return self.name

klafyvel's avatar
klafyvel committed
671 672 673
    def can_buy_article(self, user, *_args, **_kwargs):
        """Check if a user can buy this article.

674 675 676 677 678 679
        Args:
            self: The article
            user: The user requesting buying

        Returns:
            A boolean stating if usage is granted and an explanation
klafyvel's avatar
klafyvel committed
680 681 682 683
            message if the boolean is `False`.
        """
        return (
            self.available_for_everyone
684 685
            or user.has_perm('cotisations.buy_every_article')
            or user.has_perm('cotisations.add_facture'),
686
            _("You can't buy this article.")
klafyvel's avatar
klafyvel committed
687 688 689
        )

    @classmethod
690 691
    def find_allowed_articles(cls, user, target_user):
        """Finds every allowed articles for an user, on a target user.
692

693 694
        Args:
            user: The user requesting articles.
695
            target_user: The user to sell articles
696
        """
697 698 699
        if target_user is None:
            objects_pool = cls.objects.filter(Q(type_user='All'))
        elif target_user.is_class_club:
700 701 702 703 704 705 706
            objects_pool = cls.objects.filter(
                Q(type_user='All') | Q(type_user='Club')
            )
        else:
            objects_pool = cls.objects.filter(
                Q(type_user='All') | Q(type_user='Adherent')
            )
klafyvel's avatar
klafyvel committed
707
        if target_user is not None and not target_user.is_adherent():
708 709 710
            objects_pool = objects_pool.filter(
                Q(type_cotisation='All') | Q(type_cotisation='Adhesion')
            )
711
        if user.has_perm('cotisations.buy_every_article'):
712 713
            return objects_pool
        return objects_pool.filter(available_for_everyone=True)
klafyvel's avatar
klafyvel committed
714

chirac's avatar
chirac committed
715

716
class Banque(RevMixin, AclMixin, models.Model):
717 718 719 720 721 722 723
    """
    The model defining a bank. It represents a user's bank. It's mainly used
    for statistics by regrouping the user under their bank's name and avoid
    the use of a simple name which leads (by experience) to duplicates that
    only differs by a capital letter, a space, a misspelling, ... That's why
    it's easier to use simple object for the banks.
    """
724

725 726 727
    name = models.CharField(
        max_length=255,
    )
chirac's avatar
chirac committed
728

729 730
    class Meta:
        permissions = (
731
            ('view_banque', _("Can view a bank object")),
732
        )
733 734
        verbose_name = _("bank")
        verbose_name_plural = _("banks")
735

chirac's avatar
chirac committed
736 737 738
    def __str__(self):
        return self.name

chirac's avatar
chirac committed
739

740
# TODO : change Paiement to Payment
741
class Paiement(RevMixin, AclMixin, models.Model):
742 743 744 745 746 747
    """
    The model defining a payment method. It is how the user is paying for the
    invoice. It's easier to know this information when doing the accouts.
    It is represented by:
        * a name
    """
moamoak's avatar
moamoak committed
748

749 750 751
    # TODO : change moyen to method
    moyen = models.CharField(
        max_length=255,
752
        verbose_name=_("method")
753
    )
klafyvel's avatar
klafyvel committed
754
    available_for_everyone = models.BooleanField(
755
        default=False,
756
        verbose_name=_("is available for every user")
757
    )
758 759 760
    is_balance = models.BooleanField(
        default=False,
        editable=False,
761 762
        verbose_name=_("is user balance"),
        help_text=_("There should be only one balance payment method."),
763 764
        validators=[check_no_balance]
    )
chirac's avatar
chirac committed
765

766 767
    class Meta:
        permissions = (
768 769
            ('view_paiement', _("Can view a payment method object")),
            ('use_every_payment', _("Can use every payment method")),
770
        )
771 772
        verbose_name = _("payment method")
        verbose_name_plural = _("payment methods")
773

chirac's avatar
chirac committed
774 775 776
    def __str__(self):
        return self.moyen

chibrac's avatar
chibrac committed
777
    def clean(self):
778
        """l
779 780
        Override of the herited clean function to get a correct name
        """
chibrac's avatar
chibrac committed
781 782
        self.moyen = self.moyen.title()

783
    def end_payment(self, invoice, request, use_payment_method=True):
784
        """
785 786
        The general way of ending a payment.

787 788 789 790 791 792
        Args:
            invoice: The invoice being created.
            request: Request sent by the user.
            use_payment_method: If this flag is set to True and`self` has
                an attribute `payment_method`, returns the result of
                `self.payment_method.end_payment(invoice, request)`
793

794 795
        Returns:
            An `HttpResponse`-like object.
796
        """
797 798 799
        payment_method = find_payment_method(self)
        if payment_method is not None and use_payment_method:
            return payment_method.end_payment(invoice, request)
800

klafyvel's avatar
klafyvel committed
801
        # So make this invoice valid, trigger send mail
802 803 804
        invoice.valid = True
        invoice.save()

805 806 807 808 809
        # In case a cotisation was bought, inform the user, the
        # cotisation time has been extended too
        if any(sell.type_cotisation for sell in invoice.vente_set.all()):
            messages.success(
                request,
810 811
                _("The subscription of %(member_name)s was extended to"
                  " %(end_date)s.") % {
812 813
                    'member_name': invoice.user.pseudo,
                    'end_date': invoice.user.end_adhesion()
814 815 816 817 818 819
                }
            )
        # Else, only tell the invoice was created
        else:
            messages.success(
                request,
820
                _("The invoice was created.")
821 822 823
            )
        return redirect(reverse(
            'users:profil',
824
            kwargs={'userid': invoice.user.pk}
825 826
        ))

klafyvel's avatar
klafyvel committed
827 828 829
    def can_use_payment(self, user, *_args, **_kwargs):
        """Check if a user can use this payment.

830 831 832 833 834
        Args:
            self: The payment
            user: The user requesting usage
        Returns:
            A boolean stating if usage is granted and an explanation
klafyvel's avatar
klafyvel committed
835 836 837 838
            message if the boolean is `False`.
        """
        return (
            self.available_for_everyone
839 840
            or user.has_perm('cotisations.use_every_payment')
            or user.has_perm('cotisations.add_facture'),
841
            _("You can't use this payment method.")
klafyvel's avatar
klafyvel committed
842 843 844 845
        )

    @classmethod
    def find_allowed_payments(cls, user):
846 847
        """Finds every allowed payments for an user.

848 849
        Args:
            user: The user requesting payment methods.
850 851 852 853
        """
        if user.has_perm('cotisations.use_every_payment'):
            return cls.objects.all()
        return cls.objects.filter(available_for_everyone=True)
klafyvel's avatar
klafyvel committed
854

855 856 857 858
    def get_payment_method_name(self):
        p = find_payment_method(self)
        if p is not None:
            return p._meta.verbose_name
859
        return _("No custom payment method.")
860

chirac's avatar
chirac committed
861

862
class Cotisation(RevMixin, AclMixin, models.Model):
863 864 865 866 867 868 869 870 871 872
    """
    The model defining a cotisation. It holds information about the time a user
    is allowed when he has paid something.
    It characterised by :
        * a date_start (the date when the cotisaiton begins/began
        * a date_end (the date when the cotisation ends/ended
        * a type of cotisation (which indicates the implication of such
            cotisation)
        * a purchase (the related objects this cotisation is linked to)
    """
873

874
    COTISATION_TYPE = (
875 876 877
        ('Connexion', _("Connection")),
        ('Adhesion', _("Membership")),
        ('All', _("Both of them")),
878 879
    )

880 881 882 883 884
    # TODO : change vente to purchase
    vente = models.OneToOneField(
        'Vente',
        on_delete=models.CASCADE,
        null=True,
885
        verbose_name=_("purchase")
886
    )
887 888 889
    type_cotisation = models.CharField(
        choices=COTISATION_TYPE,
        max_length=255,
890
        default='All',
891
        verbose_name=_("subscription type")
892 893
    )
    date_start = models.DateTimeField(
894
        verbose_name=_("start date")
895 896
    )
    date_end = models.DateTimeField(
897
        verbose_name=_("end date")
898
    )
899

900 901
    class Meta:
        permissions = (
902 903
            ('view_cotisation', _("Can view a subscription object")),
            ('change_all_cotisation', _("Can edit the previous subscriptions")),
904
        )
905 906
        verbose_name = _("subscription")
        verbose_name_plural = _("subscriptions")
907

moamoak's avatar
moamoak committed
908
    def can_edit(self, user_request, *_args, **_kwargs):
Gabriel Detraz's avatar
Gabriel Detraz committed
909
        if not user_request.has_perm('cotisations.change_cotisation'):
910
            return False, _("You don't have the right to edit a subscription.")
moamoak's avatar
moamoak committed
911 912 913
        elif not user_request.has_perm('cotisations.change_all_cotisation') \
                and (self.vente.facture.control or
                     not self.vente.facture.valid):
914
            return False, _("You don't have the right to edit a subscription "
moamoak's avatar
moamoak committed
915
                            "already controlled or invalidated.")
916 917
        else:
            return True, None
918

moamoak's avatar
moamoak committed
919
    def can_delete(self, user_request, *_args, **_kwargs):
Gabriel Detraz's avatar
Gabriel Detraz committed
920
        if not user_request.has_perm('cotisations.delete_cotisation'):
moamoak's avatar
moamoak committed
921
            return False, _("You don't have the right to delete a "
922
                            "subscription.")
923
        if self.vente.facture.control or not self.vente.facture.valid:
924
            return False, _("You don't have the right to delete a subscription "
moamoak's avatar
moamoak committed
925
                            "already controlled or invalidated.")
926 927
        else:
            return True, None
928

moamoak's avatar
moamoak committed
929
    def can_view(self, user_request, *_args, **_kwargs):
Gabriel Detraz's avatar
Gabriel Detraz committed
930
        if not user_request.has_perm('cotisations.view_cotisation') and\
moamoak's avatar
moamoak committed
931
                self.vente.facture.user != user_request:
932 933
            return False, _("You don't have the right to view someone else's "
                            "subscription history.")
934 935
        else:
            return True, None
936

937
    def __str__(self):
938
        return str(self.vente)
939

chirac's avatar
chirac committed
940

941
@receiver(post_save, sender=Cotisation)
moamoak's avatar
moamoak committed
942
def cotisation_post_save(**_kwargs):
943 944 945 946
    """
    Mark some services as needing a regeneration after the edition of a
    cotisation. Indeed the membership status may have changed.
    """
947 948 949
    regen('dns')
    regen('dhcp')
    regen('mac_ip_list')
950
    regen('mailing')
951

chirac's avatar
chirac committed
952

953
@receiver(post_delete, sender=Cotisation)
moamoak's avatar
moamoak committed
954
def cotisation_post_delete(**_kwargs):
955 956 957 958
    """
    Mark some services as needing a regeneration after the deletion of a
    cotisation. Indeed the membership status may have changed.
    """
root's avatar
root committed
959
    regen('mac_ip_list')
960
    regen('mailing')