Browse Source

Merge branch 'services-using-services'

Jocelyn Delande 9 years ago
parent
commit
f62ec015cf

+ 19 - 2
costs/admin.py

@@ -1,6 +1,7 @@
 from django.contrib import admin
 
-from .models import Cost, Good, Service, CostUse, GoodUse, Document
+from .models import (
+    Document, Cost, Good, CostUse, GoodUse, Service, ServiceUse)
 
 
 @admin.register(Document)
@@ -18,6 +19,12 @@ class CostUseInline(admin.TabularInline):
     extra = 1
 
 
+class ServiceUseInline(admin.TabularInline):
+    model = ServiceUse
+    extra = 1
+    fk_name = 'service'
+
+
 class DirectDocumentFilter(admin.SimpleListFilter):
     title = 'Document'
 
@@ -53,5 +60,15 @@ class GoodAdmin(admin.ModelAdmin):
 @admin.register(Service)
 class ServiceAdmin(admin.ModelAdmin):
     list_display = ('name', 'subscriptions_count', 'document')
-    inlines = (CostUseInline, GoodUseInline)
+    inlines = (CostUseInline, GoodUseInline, ServiceUseInline)
     list_filter = [DirectDocumentFilter]
+
+    fieldsets = (
+        (None, {
+            'fields': (
+                ('name', 'document'), 'description', 'subscriptions_count'),
+        }),
+        ('Ré-utilisabilité', {
+            'fields': ('reusable',)
+        })
+    )

+ 60 - 0
costs/migrations/0009_auto_20160131_1208.py

@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import datetime
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('costs', '0008_auto_20160108_2331'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ServiceUse',
+            fields=[
+                ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
+                ('share', models.FloatField()),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.AddField(
+            model_name='service',
+            name='capacity_unit',
+            field=models.CharField(blank=True, choices=[('a', 'A'), ('mbps', 'Mbps'), ('u', 'U'), ('ipv4', 'IPv4'), ('eth', 'ports'), ('services', 'services')], max_length=10),
+        ),
+        migrations.AddField(
+            model_name='service',
+            name='total_capacity',
+            field=models.FloatField(default=1),
+        ),
+        migrations.AlterField(
+            model_name='cost',
+            name='capacity_unit',
+            field=models.CharField(blank=True, choices=[('a', 'A'), ('mbps', 'Mbps'), ('u', 'U'), ('ipv4', 'IPv4'), ('eth', 'ports'), ('services', 'services')], max_length=10),
+        ),
+        migrations.AlterField(
+            model_name='document',
+            name='date',
+            field=models.DateField(default=datetime.datetime.now),
+        ),
+        migrations.AlterField(
+            model_name='good',
+            name='capacity_unit',
+            field=models.CharField(blank=True, choices=[('a', 'A'), ('mbps', 'Mbps'), ('u', 'U'), ('ipv4', 'IPv4'), ('eth', 'ports'), ('services', 'services')], max_length=10),
+        ),
+        migrations.AddField(
+            model_name='serviceuse',
+            name='resource',
+            field=models.ForeignKey(to='costs.Service', related_name='dependent_services'),
+        ),
+        migrations.AddField(
+            model_name='serviceuse',
+            name='service',
+            field=models.ForeignKey(to='costs.Service'),
+        ),
+    ]

+ 19 - 0
costs/migrations/0010_service_reusable.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('costs', '0009_auto_20160131_1208'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='service',
+            name='reusable',
+            field=models.BooleanField(help_text="Peut-être utilisé par d'autres services", default=False, verbose_name='Ré-utilisable'),
+        ),
+    ]

+ 91 - 2
costs/models.py

@@ -1,4 +1,5 @@
 import datetime
+from itertools import chain
 
 from django.conf import settings
 from django.core.exceptions import ValidationError
@@ -45,6 +46,7 @@ class AbstractResource(AbstractItem):
     UNIT_U = 'u'
     UNIT_IPV4 = 'ipv4'
     UNIT_ETHERNET_PORT = 'eth'
+    UNIT_SERVICE = 'services'
 
     capacity_unit = models.CharField(
         max_length=10,
@@ -54,6 +56,7 @@ class AbstractResource(AbstractItem):
             (UNIT_U, 'U'),
             (UNIT_IPV4, 'IPv4'),
             (UNIT_ETHERNET_PORT, 'ports'),
+            (UNIT_SERVICE, 'abonnement'),
         ),
         blank=True,
     )
@@ -213,7 +216,7 @@ class GoodUse(AbstractUse):
             return monthly_share/subscriptions_count
 
 
-class Service(AbstractItem):
+class Service(AbstractResource):
     """ A service we sell
 
     (considered monthly)
@@ -226,9 +229,95 @@ class Service(AbstractItem):
         Good,
         through=GoodUse,
         related_name='using_services')
-    # services = models.ManyToMany('Service') #TODO
 
     subscriptions_count = models.PositiveIntegerField(default=0)
+    reusable = models.BooleanField(
+        default=False,
+        help_text="Peut-être utilisé par d'autres services")
+
+    @property
+    def price(self):
+        return self.get_prices()['total_recurring_price']
+
+    def save(self, *args, **kwargs):
+        if self.reusable:
+            self.capacity_unit = self.UNIT_SERVICE
+            self.total_capacity = self.subscriptions_count
+        return super().save(*args, **kwargs)
 
     def get_absolute_url(self):
         return reverse('detail-service', kwargs={'pk': self.pk})
+
+    def get_use_class(self):
+        return ServiceUse
+
+    def get_prices(self):
+        costs_uses = CostUse.objects.filter(service=self)
+        goods_uses = GoodUse.objects.filter(service=self)
+        services_uses = ServiceUse.objects.filter(service=self)
+
+        total_recurring_price = sum(chain(
+            (i.monthly_provision_share() for i in goods_uses),
+            (i.cost_share() for i in costs_uses),
+            (i.cost_share() for i in services_uses)
+        ))
+
+        total_goods_value_share = sum(
+            (i.value_share() for i in chain(goods_uses, services_uses)))
+
+        if self.subscriptions_count == 0:
+            unit_recurring_price = 0
+            unit_goods_value_share = 0
+        else:
+            unit_recurring_price = total_recurring_price/self.subscriptions_count
+            unit_goods_value_share = \
+                total_goods_value_share/self.subscriptions_count
+        return {
+            'total_recurring_price': total_recurring_price,
+            'unit_recurring_price': unit_recurring_price,
+            'total_goods_value_share': total_goods_value_share,
+            'unit_goods_value_share': unit_goods_value_share,
+        }
+
+
+def validate_reusable_service(v):
+    if not Service.objects.get(pk=v).reusable:
+        raise ValidationError('{} is not a reusable service'.format(v))
+
+
+class ServiceUse(AbstractUse):
+    resource = models.ForeignKey(
+        Service, related_name='dependent_services',
+        limit_choices_to={'reusable': True},
+        validators=[validate_reusable_service])
+
+    def cost_share(self):
+        return (
+            self.share / self.resource.total_capacity
+            * self.resource.price
+        )
+
+    def unit_cost_share(self):
+        subscriptions_count = self.service.subscriptions_count
+        if subscriptions_count == 0:
+            return 0
+        else:
+            return self.cost_share()/self.service.subscriptions_count
+
+    def value_share(self):
+        return self.share*self.resource.get_prices()['unit_goods_value_share']
+
+    def clean(self):
+        """ Checks for cycles in service using services
+        """
+        start_resource = self.resource
+
+        def crawl(service):
+            for use in service.dependent_services.all():
+                if use.service == start_resource:
+                    raise ValidationError(
+                        'Cycle detected in services using services')
+                else:
+                    crawl(use.service)
+
+        crawl(self.service)

+ 32 - 1
costs/templates/costs/service_detail.html

@@ -109,11 +109,30 @@ matériel et des investissements à l'échéance de leur durée de vie.
         <td class="right aligned">{{ usage.unit_cost_share|price }}</td>
       </tr>
   {% endfor %}
+  {% for usage in services_uses %}
+      <tr>
+      {% if forloop.first %}
+        <td rowspan="{{ costs_uses|length }}">Récurent (service)</td>
+      {% endif %}
+        <td>
+          <a href="{{ usage.resource.get_absolute_url }}">service {{ usage.resource.name }}</a>
+          <span class="bare-info">
+         {% if usage.resource.capacity_unit %}
+           ({{ usage.unit_share|human_round }} {{ usage.resource.get_capacity_unit_display }})
+         {% else %}
+           ({{ usage.unit_real_share|percent }})
+         {% endif %}
+          </span>
+        </td>
+        <td class="right aligned">{{ usage.unit_cost_share|price }}</td>
+      </tr>
+  {% endfor %}
+
     </tbody>
     <tfoot>
       <tr>
         <th colspan="2"><strong>Coût de revient par abonnement</strong></th>
-        <th class="right aligned"><strong>{{ unit_costs_price|price }}</strong></th>
+        <th class="right aligned"><strong>{{ unit_recurring_price|price }}</strong></th>
       </tr>
     </tfoot>
   </table>
@@ -152,6 +171,18 @@ d'avance de trésorerie)</li>
         <td>{{ good_use.unit_value_share|price }}</td>
       </tr>
 {% endfor %}
+{% for service_use in services_uses %}
+      <tr>
+        <td>
+          FAS service
+          <a href="{{ service_use.resource.get_absolute_url }}">
+            {{ service_use.resource.name }}
+          </a>
+        </td>
+        <td>{{ service_use.unit_value_share|price }}</td>
+      </tr>
+{% endfor %}
+
     </tbody>
     <tfoot>
       <tr>

+ 192 - 1
costs/tests/test_models.py

@@ -1,7 +1,83 @@
+import datetime
+
 from django.test import TestCase
 from django.core.exceptions import ValidationError
 
-from ..models import Service, Cost, CostUse, Document
+from ..models import (
+    Cost, CostUse, Document, Good, GoodUse, Service, ServiceUse)
+
+THREE_YEARS = datetime.timedelta(days=365*3)
+
+class ServiceTests(TestCase):
+    def setUp(self):
+        self.doc = Document.objects.create(name='budget')
+        self.electricity_cost = Cost.objects.create(
+            name='electricity',
+            price=10,
+            document=self.doc,
+        )
+        self.server = Good.objects.create(
+            name="Computer",
+            price=10,
+            document=self.doc,
+            provisioning_duration=THREE_YEARS,
+        )
+
+    def test_get_prices_zero(self):
+        s = Service.objects.create(name='Foo', document=self.doc)
+        self.assertEqual(s.get_prices(), {
+            'total_recurring_price': 0,
+            'unit_recurring_price': 0,
+            'unit_goods_value_share': 0,
+            'total_goods_value_share': 0,
+        })
+
+    def test_get_prices_w_costs(self):
+        s = Service.objects.create(name='Foo', document=self.doc)
+
+        CostUse.objects.create(
+            service=s, resource=self.electricity_cost, share=0.4)
+
+        self.assertEqual(s.get_prices(), {
+            'total_recurring_price': 10,
+            'unit_recurring_price': 0,
+            'unit_goods_value_share': 0,
+            'total_goods_value_share': 0,
+        })
+
+        s.subscriptions_count = 2
+        s.save()
+
+        self.assertEqual(s.get_prices(), {
+            'total_recurring_price': 10,
+            'unit_recurring_price': 5,
+            'unit_goods_value_share': 0,
+            'total_goods_value_share': 0,
+        })
+
+    def test_get_prices_w_goods(self):
+        s = Service.objects.create(
+            name='Foo', document=self.doc, subscriptions_count=0)
+
+        GoodUse.objects.create(
+            service=s, resource=self.server, share=0.4)
+
+        self.assertEqual(s.get_prices(), {
+            'total_recurring_price': 10/(365*3)*365.25/12,
+            'unit_recurring_price': 0,
+            'unit_goods_value_share': 0,
+            'total_goods_value_share': 10.0,
+        })
+
+        s.subscriptions_count = 2
+        s.save()
+
+        self.assertEqual(s.get_prices(), {
+            'total_recurring_price': 10/(365*3)*365.25/12,
+            'unit_recurring_price': 10/(365*3)*365.25/12/2,
+            'unit_goods_value_share': 5,
+            'total_goods_value_share': 10,
+        })
 
 
 class AbstractUseTests(TestCase):
@@ -22,6 +98,12 @@ class AbstractUseTests(TestCase):
             total_capacity=4,
         )
 
+        self.carrier_connection = Cost.objects.create(
+            name='carrier connection',
+            price=100,
+            document=self.doc,
+        )
+
     def test_can_add_service_share(self):
         use = CostUse(
             service=self.hosting_service,
@@ -152,3 +234,112 @@ class AbstractUseTests(TestCase):
 
         self.assertEqual(self.electricity_cost.used(), 0.5)
         self.assertEqual(self.electricity_cost.unused(), 3.5)
+
+    def test_service_using_service(self):
+        """
+        Wifi+VPN is a service, but using VPN access
+        So there is a service using another service
+        """
+
+        vpn_service = Service.objects.create(
+            name="VPN",
+            document=self.doc,
+            subscriptions_count=20,  # includes wifi+vpn subscribers
+            reusable=True,
+        )
+        # both should be auto-set
+        self.assertEqual(vpn_service.capacity_unit, 'services')
+        self.assertEqual(vpn_service.total_capacity, 20)
+
+        wifi_service = Service.objects.create(
+            name="Wifi, via VPN",
+            document=self.doc,
+            subscriptions_count=2,
+            reusable=True,
+        )
+        self.assertEqual(vpn_service.capacity_unit, 'services')
+
+        # To simplify, VPN is only using electricity
+        CostUse.objects.create(
+            service=vpn_service,
+            resource=self.electricity_cost,
+            share=0.5,  # Amp
+        )
+
+        # Wifi is using VPN + a carrier connection
+        wifi_vpn_use = ServiceUse.objects.create(
+            service=wifi_service,
+            resource=vpn_service,
+            share=2,
+        )
+        CostUse.objects.create(
+            service=wifi_service,
+            resource=self.carrier_connection,
+            share=1,  # 100%
+        )
+
+        self.assertEqual(wifi_vpn_use.share, 0.5*4*10/20*2)
+        self.assertEqual(wifi_vpn_use.unit_share(), 0.5*4*10/20)
+
+        # VPN this is the only service using electricity
+        self.assertEqual(wifi_vpn_use.unit_real_share(), 10)
+
+        # VPN is now using some gear, with deprecation provisioning
+        hosting_access_fee = Good.objects.create(
+            name='hosting access fee', price=360,
+            provisioning_duration=THREE_YEARS, document=self.doc)
+        GoodUse.objects.create(
+            service=vpn_service, resource=hosting_access_fee, share=2)
+        self.assertEqual(
+            wifi_service.get_prices()['total_goods_value_share'], 36)
+        self.assertEqual(
+            wifi_service.get_prices()['unit_goods_value_share'], 18)
+
+    def test_service_using_non_usable_service(self):
+        serva = Service.objects.create(
+            name='A', document=self.doc,
+            subscriptions_count=4,
+            reusable=False,
+        )
+
+        with self.assertRaises(ValidationError):
+            su = ServiceUse(
+                service=self.mailbox_service,
+                resource=serva,
+                share=1,
+            )
+            su.full_clean()
+            su.save()
+
+    def test_service_using_cyclic_service(self):
+        """ We should not save any service dependency building a cycle
+        """
+        a = Service.objects.create(
+            name='a', document=self.doc,
+            reusable=True,
+        )
+
+        b = Service.objects.create(
+            name='b', document=self.doc,
+            reusable=True,
+        )
+
+        c = Service.objects.create(
+            name='c', document=self.doc,
+            reusable=True,
+        )
+
+        def create_clean(user, used):
+            new_use = ServiceUse(service=user, resource=used, share=1)
+            new_use.full_clean()
+            new_use.save()
+
+        create_clean(a, b)
+
+        with self.assertRaises(ValidationError):
+            create_clean(b, a)
+
+        create_clean(b, c)
+
+        with self.assertRaises(ValidationError):
+            create_clean(c, a)

+ 8 - 24
costs/views.py

@@ -1,9 +1,7 @@
-from itertools import chain
-
 from django.core.urlresolvers import reverse
 from django.shortcuts import render, get_object_or_404
 
-from .models import Document, Service, CostUse, GoodUse
+from .models import Document, Service, ServiceUse, CostUse, GoodUse
 
 
 def index(request):
@@ -48,32 +46,18 @@ def detail_service(request, pk):
     )
     costs_uses = CostUse.objects.filter(service=service)
     goods_uses = GoodUse.objects.filter(service=service)
+    services_uses = ServiceUse.objects.filter(service=service)
 
-    total_costs_price = sum(chain(
-        (i.monthly_provision_share() for i in goods_uses),
-        (i.cost_share() for i in costs_uses),
-    ))
-
-    total_goods_value_share = sum(i.value_share() for i in goods_uses)
-
-    if service.subscriptions_count == 0:
-        unit_costs_price = 0
-        unit_goods_value_share = 0
-    else:
-        unit_costs_price = total_costs_price/service.subscriptions_count
-        unit_goods_value_share = total_goods_value_share/service.subscriptions_count
-
-    context = {
+    context = {}
+    context.update(service.get_prices())
+    context.update({
         'breadcrumbs': breadcrumbs,
         'document': doc,
         'service': service,
         'costs_uses': costs_uses,
         'goods_uses': goods_uses,
-        'total_costs_price': total_costs_price,
-        'unit_costs_price': unit_costs_price,
+        'services_uses': services_uses,
+        'monthly_fas': context['unit_goods_value_share']/12,
+    })
 
-        'total_goods_value_share': total_goods_value_share,
-        'unit_goods_value_share': unit_goods_value_share,
-        'monthly_fas': unit_goods_value_share/12,
-    }
     return render(request, 'costs/service_detail.html', context)