Browse Source

ressources IP : période d’allocation

Élie Bouttier 8 years ago
parent
commit
37bb519ef9

+ 20 - 0
accounts/migrations/0003_profile_notes.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.5 on 2017-02-11 00:30
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('accounts', '0002_auto_20170206_2358'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='profile',
+            name='notes',
+            field=models.TextField(blank=True, default=''),
+        ),
+    ]

+ 1 - 0
accounts/models.py

@@ -11,6 +11,7 @@ class Profile(models.Model):
     phone_number = models.CharField(max_length=16, blank=True, default='',
                                     verbose_name='Numéro de téléphone')
     address = models.TextField(blank=True, default='', verbose_name='Adresse')
+    notes = models.TextField(blank=True, default='')
 
     class Meta:
         verbose_name = 'profil'

+ 11 - 1
adhesions/models.py

@@ -3,6 +3,7 @@ from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 
+from djadhere.utils import get_active_filter
 from banking.models import Payment
 
 
@@ -14,7 +15,7 @@ class Adherent(models.Model):
                                       limit_choices_to=limit, verbose_name='Type d’adhérent')
     adherent_id = models.PositiveIntegerField(verbose_name='ID')
     adherent = GenericForeignKey('adherent_type', 'adherent_id')
-    contribution = GenericRelation(Payment,
+    contributions = GenericRelation(Payment,
                                    content_type_field='reason_type',
                                    object_id_field='reason_id',
                                    related_query_name='adherent')
@@ -24,6 +25,15 @@ class Adherent(models.Model):
         unique_together = ("adherent_type", "adherent_id")
 
     @property
+    def contribution(self):
+        try:
+            return self.contributions.filter(get_active_filter()).get()
+        except Payment.DoesNotExist:
+            return None
+        # MultipleObjectsReturned non catché volontairement, le filtrage par la méthode clean est censé
+        # empêcher cette possibilité, si cette exception est levé on veut recevoir un mail avec l’erreur !
+
+    @property
     def type(self):
         if self.adherent_type.app_label == 'auth' and self.adherent_type.model == 'user':
             return 'Personne physique'

+ 4 - 6
adhesions/templates/adhesions/adhesion.html

@@ -9,16 +9,14 @@
     <div class="panel-heading"><h4>À propos de votre adhésion</h4></div>
     <div class="panel-body">
         <p>Votre numéro d’adhérent : ADT{{ adhesion.id }}</p>
-        {% if adhesion.contribution.count == 0 %}
+        {% if adhesion.contribution %}
+        <p>Montant de votre cotisation : {{ adhesion.contribution }}</p>
+        {% else %}
         <p>Pas de cotisation.</p>
-        {% elif adhesion.contribution.count == 1 %}
-        {% with contribution=adhesion.contribution.first %}
-        <p>Montant de votre cotisation : {{ contribution }}</p>
+        {% endif %}
         {% if contribution.date %}
         <p>Date d’adhésion : {{ contribution.date }}</p>
         {% endif %}
-        {% endwith %}
-        {% endif %}
     </div>
 </div>
 

+ 1 - 1
banking/admin.py

@@ -10,7 +10,7 @@ from .models import Payment
 
 class PaymentMixin:
     def get_fields(self, request, obj=None):
-        fields = ('amount', 'period', 'payment_method', 'date',)
+        fields = ('amount', 'period', 'payment_method', 'start', 'end')
         if request.user.has_perm('banking.validate_payment'):
             fields += ('validated',)
         return fields

+ 31 - 0
banking/migrations/0005_auto_20170211_0130.py

@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.5 on 2017-02-11 00:30
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('banking', '0004_payment_validation'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='payment',
+            old_name='date',
+            new_name='start',
+        ),
+        migrations.AddField(
+            model_name='payment',
+            name='end',
+            field=models.DateField(blank=True, null=True, verbose_name='Date de fin de paiement (si récurrent)'),
+        ),
+        migrations.AlterField(
+            model_name='payment',
+            name='period',
+            field=models.PositiveIntegerField(validators=[django.core.validators.MaxValueValidator(12)], verbose_name='Période (mois) (0 si non récurrent)'),
+        ),
+    ]

+ 21 - 2
banking/models.py

@@ -3,6 +3,9 @@ from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.core.validators import MaxValueValidator
 from django.core.urlresolvers import reverse
+from django.core.exceptions import ValidationError
+
+from djadhere.utils import is_overlapping
 
 
 class Payment(models.Model):
@@ -20,10 +23,11 @@ class Payment(models.Model):
     reason = GenericForeignKey('reason_type', 'reason_id')
     amount = models.DecimalField(max_digits=9, decimal_places=2, verbose_name='Montant')
     period = models.PositiveIntegerField(validators=[MaxValueValidator(12)],
-                                         verbose_name='Période (mois)')
+                                         verbose_name='Période (mois) (0 si non récurrent)')
     payment_method = models.IntegerField(choices=PAYMENT_CHOICES,
                                          verbose_name='Méthode de paiement')
-    date = models.DateField(verbose_name='Date de paiement ou de début de paiement')
+    start = models.DateField(verbose_name='Date de paiement ou de début de paiement')
+    end = models.DateField(null=True, blank=True, verbose_name='Date de fin de paiement (si récurrent)')
     validated = models.BooleanField(default=False, verbose_name='Paiement validé')
 
     class Meta:
@@ -66,6 +70,21 @@ class Payment(models.Model):
             return self.reason.adherent
     get_adherent.short_description = 'Adhérent'
 
+    def clean(self):
+        super().clean()
+        # S’il s’agit d’un paiement non récurrent, le champ end doit rester blanc
+        if self.period == 0 and self.end:
+            raise ValidationError({'end': "Un paiement non récurrent ne doit pas avoir de date de fin."})
+        # Vérification de la cohérence des champs start et end
+        if self.end and self.start > self.end:
+            raise ValidationError("La date de début de paiement doit être antérieur à la date de fin de paiement.")
+        # Vérification de l’absence de chevauchement avec une période existante
+        if self.reason:
+            payments = Payment.objects.filter(reason_type=self.reason_type, reason_id=self.reason_id)
+            if is_overlapping(self, payments):
+                raise ValidationError("Les périodes de paiement ne doivent pas se chevaucher.")
+
+
     def __str__(self):
         s = str(self.amount) + '€'
         if self.period:

+ 44 - 0
djadhere/utils.py

@@ -0,0 +1,44 @@
+from django.db.models import Q
+from django.utils import timezone
+
+
+# Cette fonction permet d’obtenir un filtre s’appliquant aux paiements et aux allocations de ressources
+def get_active_filter():
+    now = timezone.now()
+    # Début antérieur et fin non spécifié ou postérieur
+    return Q(start__lte=now) & (Q(end__isnull=True) | Q(end__gte=now))
+
+
+# Cette fonction vérifie que l’object « instance » ne chevauche pas temporellement un objet présent dans « queryset »
+# Le model associé doit posséder deux attributs « start » et « end ».
+# L’attribut « start » doit être obligatoire.
+def is_overlapping(instance, queryset):
+    # Le champ « start » ne doit pas être None pour les comparaisons qui suivent
+    if not instance.start:
+        return False # Une erreur sera levé au niveau de ce field spécifiquement
+
+    # S’il s’agit d’une modification et non d’un ajout, il faut ignorer l’objet déjà présent en db
+    if instance.pk:
+        queryset = queryset.exclude(pk=instance.pk)
+
+    for existing in queryset.all():
+        if instance.end and existing.end:
+            # Les deux périodes sont terminées
+            latest_start = max(instance.start, existing.start)
+            earliest_end = min(instance.end, existing.end)
+            if earliest_end > latest_start:
+                return True
+        elif existing.end:
+            assert(not instance.end)
+            # La période existante est terminée et doit donc se terminer avant la modifiée
+            if existing.end > instance.start:
+                return True
+        elif instance.end:
+            assert(not existing.end)
+            # La période existante n’est terminée, la modifiée doit se terminer avant
+            if instance.end > existing.start:
+                return True
+        else:
+            assert(not instance.end and not existing.end)
+            # Aucune des périodes n’est terminées
+            return True

+ 23 - 34
services/admin.py

@@ -1,9 +1,17 @@
 from django.contrib import admin
+from django.db.models import Q
+from django.utils import timezone
 
-from .models import Service, ServiceType, IPResource
+from .models import Service, ServiceType, IPResource, ResourceAllocation
 from banking.admin import PaymentInline, ValidatedPaymentInline, PendingOrNewPaymentInline
 
 
+class AllocationInline(admin.TabularInline):
+    model = ResourceAllocation
+    extra = 1
+    can_delete = False
+
+
 class ServiceTypeFilter(admin.SimpleListFilter):
     title = 'type de service'
     parameter_name = 'type'
@@ -19,34 +27,9 @@ class ServiceTypeFilter(admin.SimpleListFilter):
             return queryset.filter(service_type__pk=self.value())
 
 
-class ServiceStatusFilter(admin.SimpleListFilter):
-    title = 'statut'
-    parameter_name = 'status'
-
-    def lookups(self, request, model_admin):
-        return (
-            ('forthcoming', 'Non commencé'),
-            ('ongoing', 'En cours'),
-            ('finished', 'Terminé'),
-        )
-
-    def queryset(self, request, queryset):
-        if self.value() == 'forthcoming':
-            return queryset.filter(Service.get_forthcoming_filter())
-        if self.value() == 'ongoing':
-            return queryset.filter(Service.get_ongoing_filter())
-        if self.value() == 'finished':
-            return queryset.filter(Service.get_finished_filter())
-
-
 class ServiceAdmin(admin.ModelAdmin):
-    list_display = ('id', 'adherent', 'service_type', 'label', 'start', 'end', 'status')
-    list_filter = (ServiceStatusFilter, ServiceTypeFilter,)
-
-    def status(self, obj):
-        return obj.is_ongoing
-    status.short_description = 'En cours'
-    status.boolean = True
+    list_display = ('id', 'adherent', 'service_type', 'label', 'active')
+    list_filter = ('active', ServiceTypeFilter,)
 
     def get_form(self, request, obj=None, **kwargs):
         # get_inlines does not exists :-(
@@ -54,6 +37,7 @@ class ServiceAdmin(admin.ModelAdmin):
             self.inlines = (PaymentInline,)
         else:
             self.inlines = (ValidatedPaymentInline, PendingOrNewPaymentInline,)
+        self.inlines = self.inlines + (AllocationInline,)
         return super().get_form(request, obj, **kwargs)
 
     def get_queryset(self, request):
@@ -80,16 +64,20 @@ class InUseFilter(admin.SimpleListFilter):
         )
 
     def queryset(self, request, queryset):
-        ongoing_filter = Service.get_ongoing_filter()
-        if self.value() == '0':
-            return queryset.filter(ongoing_filter)
-        if self.value() == '1':
-            return queryset.exclude(ongoing_filter)
+        # On utilise le même filtre que get_active_filter(), mais préfixé par « allocation__ » …
+        # Ce billet donne une technique avec une classe PrefixedQ pour éviter cette duplication de code :
+        # https://hackerluddite.wordpress.com/2012/07/07/making-django-orm-more-dry-with-prefixes-and-qs/
+        now = timezone.now()
+        if self.value() == '0': # non disponible
+            return queryset.filter(Q(allocation__start__lte=now) & (Q(allocation__end__isnull=True) | Q(allocation__end__gte=now)))
+        if self.value() == '1': # disponible
+            return queryset.exclude(Q(allocation__start__lte=now) & (Q(allocation__end__isnull=True) | Q(allocation__end__gte=now)))
 
 
 class IPResourceAdmin(admin.ModelAdmin):
     list_display = ('__str__', 'in_use_view')
     list_filter = (InUseFilter,)
+    inlines = (AllocationInline,)
 
     def in_use_view(self, obj):
         return not obj.in_use
@@ -97,6 +85,7 @@ class IPResourceAdmin(admin.ModelAdmin):
     in_use_view.boolean = True
 
 
-admin.site.register(Service, ServiceAdmin)
 admin.site.register(ServiceType)
+admin.site.register(Service, ServiceAdmin)
 admin.site.register(IPResource, IPResourceAdmin)
+admin.site.register(ResourceAllocation)

+ 58 - 0
services/migrations/0008_auto_20170211_0130.py

@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.5 on 2017-02-11 00:30
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('services', '0007_service_label'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ResourceAllocation',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('start', models.DateTimeField(verbose_name='Début de la période d’allocation')),
+                ('end', models.DateTimeField(blank=True, null=True, verbose_name='Fin de la période d’allocation')),
+                ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocations', related_query_name='allocation', to='services.IPResource', verbose_name='Ressource')),
+            ],
+            options={
+                'verbose_name': 'Allocation de ressource',
+                'verbose_name_plural': 'Allocations de ressources',
+            },
+        ),
+        migrations.RemoveField(
+            model_name='service',
+            name='end',
+        ),
+        migrations.RemoveField(
+            model_name='service',
+            name='ip_resources',
+        ),
+        migrations.RemoveField(
+            model_name='service',
+            name='start',
+        ),
+        migrations.AddField(
+            model_name='service',
+            name='active',
+            field=models.BooleanField(default=True, verbose_name='actif'),
+        ),
+        migrations.AddField(
+            model_name='service',
+            name='created',
+            field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='resourceallocation',
+            name='service',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocations', related_query_name='allocation', to='services.Service'),
+        ),
+    ]

+ 53 - 38
services/models.py

@@ -6,6 +6,7 @@ from django.contrib.auth.models import Group
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 
+from djadhere.utils import get_active_filter, is_overlapping
 from adhesions.models import Adherent
 from banking.models import Payment
 
@@ -17,8 +18,7 @@ class IPResource(models.Model):
 
     @property
     def in_use(self):
-        if Service.objects.filter(ip_resources=self) \
-                .filter(Service.get_ongoing_filter()).exists():
+        if self.allocations.filter(get_active_filter()).exists():
             return True
         else:
             return False
@@ -54,49 +54,64 @@ class Service(models.Model):
                                      verbose_name='Type de service')
     label = models.CharField(blank=True, default='', max_length=128)
     notes = models.TextField(blank=True, default='')
-    contribution = GenericRelation(Payment,
+    active = models.BooleanField(default=True, verbose_name='actif')
+    created = models.DateTimeField(auto_now_add=True)
+    contributions = GenericRelation(Payment,
                                    content_type_field='reason_type',
                                    object_id_field='reason_id',
                                    related_query_name='service')
-    ip_resources = models.ManyToManyField(IPResource, blank=True,
-                                          verbose_name='Ressources IP')
-    start = models.DateTimeField(null=True, blank=True, verbose_name='Début du service')
-    end = models.DateTimeField(null=True, blank=True, verbose_name='Fin du service')
-
-    # Designed with a Karnaugh map, should be correct...
-    @staticmethod
-    def get_forthcoming_filter():
-        now = timezone.now()
-        return Q(start__isnull=True, end__isnull=True) \
-            | Q(start__gt=now)
-
-    @staticmethod
-    def get_ongoing_filter():
-        now = timezone.now()
-        return Q(start__lte=now, end__isnull=True) \
-            | Q(start__lte=now, end__gt=now) \
-            | Q(start__isnull=True, end__gt=now)
-
-    @staticmethod
-    def get_finished_filter():
-        now = timezone.now()
-        return Q(end__lte=now)
 
     @property
-    def is_ongoing(self):
-        if (not self.start or self.start < timezone.now()) \
-                and (not self.end or self.end > timezone.now()) \
-                and (self.start or self.end):
-            return True
-        else:
-            return False
+    def contribution(self):
+        try:
+            return self.contributions.get(get_active_filter())
+        except Payment.DoesNotExist:
+            return None
+        # MultipleObjectsReturned non catché volontairement, cf remarque adhesions.Adherent.contribution
+
+    @property
+    def active_allocations(self):
+        return self.allocations.filter(get_active_filter())
+
+    @property
+    def inactive_allocations(self):
+        return self.allocations.exclude(get_active_filter())
 
     def clean(self):
-        if self.start and self.end and self.start > self.end:
-            raise ValidationError("La date de début du service doit être antérieur "
-                                  "à la date de fin du service.")
-        if self.label != '' and Service.objects.filter(service_type=self.service_type, label=self.label):
+        super().clean()
+        # Vérification de l’unicité par type de service du label
+        if self.label != '' and Service.objects.exclude(pk=self.pk).filter(service_type=self.service_type, label=self.label):
             raise ValidationError("Un service du même type existe déjà avec ce label.")
 
     def __str__(self):
-        return str(self.service_type) + ' ' + str(self.adherent)
+        s = str(self.service_type)
+        if self.label:
+            s += ' ' + self.label
+        s += ' (' + str(self.adherent) + ')'
+        return s
+
+
+class ResourceAllocation(models.Model):
+    resource = models.ForeignKey(IPResource, verbose_name='Ressource', related_name='allocations', related_query_name='allocation')
+    service = models.ForeignKey(Service, related_name='allocations', related_query_name='allocation')
+    start = models.DateTimeField(verbose_name='Début de la période d’allocation')
+    end = models.DateTimeField(null=True, blank=True, verbose_name='Fin de la période d’allocation')
+
+    def clean(self):
+        super().clean()
+        # Vérification de la cohérence des champs start et end
+        if self.end and self.start > self.end:
+            raise ValidationError("La date de début de l’allocation doit être antérieur "
+                                  "à la date de fin de l’allocation.")
+        # Vérification de l’abscence de chevauchement de la période d’allocation
+        allocations = ResourceAllocation.objects.filter(resource__pk=self.resource.pk)
+        if is_overlapping(self, allocations):
+            raise ValidationError("La période d’allocation de cette ressource chevauche "
+                                    "avec une période d’allocation précédente.")
+
+    class Meta:
+        verbose_name = 'Allocation de ressource'
+        verbose_name_plural = 'Allocations de ressources'
+
+    def __str__(self):
+        return str(self.resource) + ' pour ' + str(self.service)

+ 6 - 17
services/templates/services/service_detail.html

@@ -17,32 +17,21 @@
         {% endif %}
     </div>
     <div class="panel-body">
-        {% if service.contribution.count == 0 %}
+        {% if service.contribution %}
+        <p>Contribution : {{ service.contribution }}</p>
+        {% else %}
         <p>Contribution : pas de contribution</p>
-        {% elif service.contribution.count == 1 %}
-        <p>Contribution : {{ service.contribution.first }}</p>
+        {% endif %}
         <p>
             Ressources IP :
-            {% for ip in service.ip_resources.all %}
+            {% for allocation in service.active_allocations %}
             {% if forloop.first %}<ul>{% endif %}
-                <li>{{ ip }}</li>
+                <li>{{ allocation.resource }} (depuis le {{ allocation.start }})</li>
             {% if forloop.last %}</ul>{% endif %}
             {% empty %}
             aucune IP allouée
             {% endfor %}
         </p>
-        <p>
-            Période d’activation du service :
-            {% if not service.start and not service.end %}
-            indéfinie
-            {% else %}
-            {{ service.start|default:"depuis toujours" }}
-            –
-            {{ service.end|default:"pour toujours" }}
-            ({% if service.is_ongoing %}<span class="text-success">actuellement actif</span>{% else %}<span class="text-danger">actuellement inactif</span>{% endif %})
-            {% endif %}
-        </p>
-        {% endif %}
     </div>
 </div>
 {% endblock %}

+ 1 - 4
services/templates/services/service_list.html

@@ -19,13 +19,10 @@
 {% if forloop.first %}
 <div class="list-group">
 {% endif %}
-    <a href="{% url 'service-detail' service.pk %}" class="list-group-item {% if service.is_ongoing %}list-group-item-success{% else %}list-group-item-danger{% endif %}">
+    <a href="{% url 'service-detail' service.pk %}" class="list-group-item {% if service.active %}list-group-item-success{% else %}list-group-item-danger{% endif %}">
         <h4 class="list-group-item-heading">
             <b>{{ service.service_type }}</b> {{ service.label }}
         </h4>
-        <p class="list-group-item-text">
-            {{ service.start|default:"…" }} – {{ service.end|default:"…" }}
-        </p>
         {% if service.contribution.count == 1 %}
         <p class="list-group-item-text">
             {{ service.contribution.first }}

+ 2 - 2
services/templatetags/services.py

@@ -8,9 +8,9 @@ register = template.Library()
 
 @register.filter
 def active(services):
-    return services.filter(Service.get_ongoing_filter())
+    return services.filter(active=True)
 
 
 @register.filter
 def inactive(services):
-    return services.exclude(Service.get_ongoing_filter())
+    return services.filter(active=False)

+ 1 - 1
services/views.py

@@ -8,7 +8,7 @@ from .models import Service
 class ServiceMixin:
     def get_queryset(self):
         return Service.objects.filter(adherent__pk__in=self.request.user.profile.adhesions.values_list('pk')) \
-                    .order_by('-start')
+                    .order_by('-created')
 
 
 class ServiceList(LoginRequiredMixin, ServiceMixin, ListView):