123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 |
- import datetime
- from itertools import chain
- from django.conf import settings
- from django.core.exceptions import ValidationError
- from django.core.urlresolvers import reverse
- from django.db import models, transaction
- from django.db.models import Sum
- import markdown
- class Document(models.Model):
- """ A document is a scenario or a record from facts, on 1 month.
- """
- TYPE_PUBLIC = 'fact'
- TYPE_DRAFT = 'plan'
- name = models.CharField('Nom', max_length=130)
- comment = models.TextField(
- 'commentaire',
- blank=True, help_text="Texte brut ou markdown")
- comment_html = models.TextField(blank=True, editable=False)
- date = models.DateField(
- default=datetime.datetime.now,
- help_text="Date de création du document")
- type = models.CharField(max_length=10, choices=(
- (TYPE_PUBLIC, 'Rapport public'),
- (TYPE_DRAFT, 'Rapport brouillon'),
- ), help_text="Un rapport brouillon n'est pas visible publiquement")
- class Meta:
- ordering = ['-date']
- verbose_name = 'Rapport'
- def __str__(self):
- return self.name
- def save(self, *args, **kwargs):
- self.comment_html = markdown.markdown(self.comment)
- super().save(*args, **kwargs)
- def get_absolute_url(self):
- return reverse('detail-document', kwargs={'pk': self.pk})
- def copy(self):
- """ Deep copy and saving of a document
- All related resources are copied.
- """
- with transaction.atomic():
- new_doc = Document.objects.get(pk=self.pk)
- new_doc.pk = None
- new_doc.name = 'COPIE DE {}'.format(new_doc.name)
- new_doc.type = Document.TYPE_DRAFT
- new_doc.save()
- new_services, new_costs, new_goods = {}, {}, {}
- to_copy = (
- (self.service_set, new_services),
- (self.good_set, new_goods),
- (self.cost_set, new_costs),
- )
- for qs, index in to_copy:
- for item in qs.all():
- old_pk = item.pk
- item.pk = None
- item.document = new_doc
- item.save()
- index[old_pk] = item
- to_reproduce = (
- (ServiceUse, new_services),
- (GoodUse, new_goods),
- (CostUse, new_costs),
- )
- for Klass, index in to_reproduce:
- src_qs = Klass.objects.filter(service__document=self)
- for use in src_qs.all():
- use.pk = None
- use.service = new_services[use.service.pk]
- use.resource = index[use.resource.pk]
- use.save()
- return new_doc
- def get_total_recuring_costs(self):
- return self.cost_set.aggregate(sum=Sum('price'))['sum'] or 0
- def get_total_goods(self):
- return self.good_set.aggregate(sum=Sum('price'))['sum'] or 0
- def compare(self, other_doc):
- """ Compare this document to another one
- :type other_doc: Document
- :rtype: dict
- """
- service_records = []
- for service in self.service_set.subscribables():
- other_service = Service.objects.similar_to(service).filter(
- document=other_doc).first()
- if other_service:
- deltas = service.compare(other_service)
- else:
- deltas = {}
- service_records.append({
- 'service': service,
- 'other_service': other_service,
- 'deltas': deltas,
- })
- comparison = {
- 'services': service_records,
- 'total_recuring_costs': self.get_total_recuring_costs() - other_doc.get_total_recuring_costs(),
- 'total_goods': self.get_total_goods() - other_doc.get_total_goods(),
- }
- return comparison
- class AbstractItem(models.Model):
- name = models.CharField('Nom', max_length=130)
- description = models.TextField('description', blank=True)
- description_html = models.TextField(blank=True)
- document = models.ForeignKey(Document)
- def __str__(self):
- return self.name
- def save(self, *args, **kwargs):
- self.description_html = markdown.markdown(self.description)
- super().save(*args, **kwargs)
- class Meta:
- abstract = True
- class AbstractResource(AbstractItem):
- UNIT_AMP = 'a'
- UNIT_MBPS = 'mbps'
- UNIT_U = 'u'
- UNIT_IPV4 = 'ipv4'
- UNIT_ETHERNET_PORT = 'eth'
- UNIT_SERVICE = 'services'
- capacity_unit = models.CharField(
- 'unité',
- max_length=10,
- choices=(
- (UNIT_AMP, 'A'),
- (UNIT_MBPS, 'Mbps'),
- (UNIT_U, 'U'),
- (UNIT_IPV4, 'IPv4'),
- (UNIT_ETHERNET_PORT, 'ports'),
- (UNIT_SERVICE, 'abonnement'),
- ),
- blank=True,
- help_text="unité de capacité (si applicable)",
- )
- total_capacity = models.FloatField(
- 'Capacité totale',
- default=1,
- help_text="Laisser à 1 si non divisible")
- class Meta:
- abstract = True
- def get_use_class(self):
- raise NotImplemented
- def used(self, except_by=None):
- """ Return the used fraction of an item
- :type: Service
- :param except_by: exclude this service from the math
- :rtype: float
- """
- sharing_costs = self.get_use_class().objects.filter(resource=self)
- if except_by:
- sharing_costs = sharing_costs.exclude(service=except_by)
- existing_uses_sum = sum(
- sharing_costs.values_list('share', flat=True))
- return existing_uses_sum
- def used_fraction(self, *args, **kwargs):
- return self.used(*args, **kwargs)/self.total_capacity
- def unused(self):
- return self.total_capacity-self.used()
- def __str__(self):
- if self.capacity_unit == '':
- return self.name
- else:
- return '{} {:.0f} {}'.format(
- self.name, self.total_capacity,
- self.get_capacity_unit_display())
- class Cost(AbstractResource):
- """ A monthtly cost we have to pay
- """
- price = models.FloatField("Coût mensuel")
- def get_use_class(self):
- return CostUse
- class Meta:
- verbose_name = 'Coût mensuel'
- class GoodQuerySet(models.QuerySet):
- def monthly_provision_total(self):
- # FIXME: could be optimized easily
- return sum([i.monthly_provision() for i in self.all()])
- class Good(AbstractResource):
- """ A good, which replacement is provisioned
- """
- price = models.FloatField("Prix d'achat")
- provisioning_duration = models.DurationField(
- "Durée d'amortissement",
- choices=settings.PROVISIONING_DURATIONS)
- objects = GoodQuerySet.as_manager()
- def get_use_class(self):
- return GoodUse
- def monthly_provision(self):
- return self.price/self.provisioning_duration.days*(365.25/12)
- class Meta:
- verbose_name = "Matériel ou Frais d'accès"
- verbose_name_plural = "Matériels ou Frais d'accès"
- class AbstractUse(models.Model):
- share = models.FloatField()
- service = models.ForeignKey('Service')
- class Meta:
- abstract = True
- def __str__(self):
- return str(self.resource)
- def clean(self):
- if hasattr(self, 'resource'):
- usage = self.resource.used(except_by=self.service) + self.share
- if usage > self.resource.total_capacity:
- raise ValidationError(
- "Cannot use more than 100% of {})".format(self.resource))
- def real_share(self):
- """The share, + wasted space share
- Taking into account that the unused space is
- wasted and has to be divided among actual users
- """
- return (
- self.share +
- (self.share/self.resource.used())*self.resource.unused()
- )
- def unit_share(self):
- if self.service.subscriptions_count == 0:
- return 0
- else:
- return self.share/self.service.subscriptions_count
- return
- def unit_real_share(self):
- if self.service.subscriptions_count == 0:
- return 0
- else:
- return self.real_share()/self.service.subscriptions_count
- def value_share(self):
- return (
- self.resource.price
- * self.real_share()
- / self.resource.total_capacity
- )
- def unit_value_share(self):
- if self.service.subscriptions_count == 0:
- return 0
- else:
- return self.value_share()/self.service.subscriptions_count
- class CostUse(AbstractUse):
- resource = models.ForeignKey(Cost)
- class Meta:
- verbose_name = 'Coût mensuel associé'
- verbose_name_plural = 'Coûts mensuels associés'
- def cost_share(self):
- return (
- self.real_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
- class GoodUse(AbstractUse):
- resource = models.ForeignKey(Good)
- class Meta:
- verbose_name = "Matériel ou frais d'accès utilisé"
- verbose_name_plural = "Matériels et frais d'accès utilisés"
- def monthly_provision_share(self):
- return (
- self.real_share()
- * self.resource.monthly_provision()
- / self.resource.total_capacity)
- def unit_monthly_provision_share(self):
- subscriptions_count = self.service.subscriptions_count
- monthly_share = self.monthly_provision_share()
- if subscriptions_count == 0:
- return 0
- else:
- return monthly_share/subscriptions_count
- class ServiceQuerySet(models.QuerySet):
- def subscribables(self):
- return self.exclude(internal=True)
- def similar_to(self, service):
- return self.filter(name=service.name).exclude(pk=service.pk)
- class Service(AbstractResource):
- """ A service we sell
- (considered monthly)
- """
- costs = models.ManyToManyField(
- Cost,
- through=CostUse,
- related_name='using_services',
- verbose_name='coûts mensuels associés')
- goods = models.ManyToManyField(
- Good,
- through=GoodUse,
- related_name='using_services',
- verbose_name="matériels et frais d'accès utilisés")
- subscriptions_count = models.PositiveIntegerField(
- "Nombre d'abonnements",
- default=0)
- reusable = models.BooleanField(
- "Ré-utilisable par d'autres services",
- default=False,
- help_text="Peut-être utilisé par d'autres services")
- internal = models.BooleanField(
- "Service interne",
- default=False,
- help_text="Ne peut être vendu tel quel, n'apparaît pas dans la liste des services"
- )
- objects = ServiceQuerySet.as_manager()
- @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
- unit_staggered_goods_share = \
- unit_goods_value_share/settings.SETUP_COST_STAGGERING_MONTHS
- return {
- 'total_recurring_price': total_recurring_price,
- 'total_goods_value_share': total_goods_value_share,
- 'unit_goods_value_share': unit_goods_value_share,
- 'unit_recurring_price': unit_recurring_price,
- 'unit_staggered_goods_share': unit_staggered_goods_share,
- 'unit_consolidated_cost': (
- unit_staggered_goods_share + unit_recurring_price),
- }
- def compare(self, other):
- """ Compares figures between two services
- Typically, same service type, but on two different reports.
- :type other: Service
- :rtype: dict
- :returns: dict of deltas (from other to self).
- """
- self_prices = self.get_prices()
- other_prices = other.get_prices()
- comparated_prices = (
- 'total_recurring_price', 'total_goods_value_share',
- 'unit_goods_value_share', 'unit_recurring_price',
- 'unit_staggered_goods_share', 'unit_consolidated_cost',
- )
- comparated_vals = {
- 'subscriptions_count': self.subscriptions_count - other.subscriptions_count,
- }
- for i in comparated_prices:
- comparated_vals[i] = self_prices[i] - other_prices[i]
- return comparated_vals
- 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):
- class Meta:
- verbose_name = 'service utilisé'
- verbose_name_plural = 'services utilisés'
- 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)
|