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_FACT = 'fact' TYPE_PLAN = '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_FACT, 'rapport de transparence'), (TYPE_PLAN, 'estimation ou étude'), )) class Meta: ordering = ['-date'] 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.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 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)