models.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import datetime
  2. from itertools import chain
  3. from django.conf import settings
  4. from django.core.exceptions import ValidationError
  5. from django.core.urlresolvers import reverse
  6. from django.db import models, transaction
  7. import markdown
  8. class Document(models.Model):
  9. """ A document is a scenario or a record from facts, on 1 month.
  10. """
  11. TYPE_FACT = 'fact'
  12. TYPE_PLAN = 'plan'
  13. name = models.CharField(max_length=130)
  14. comment = models.TextField(blank=True, help_text="Texte brut ou markdown")
  15. comment_html = models.TextField(blank=True, editable=False)
  16. date = models.DateField(default=datetime.datetime.now)
  17. type = models.CharField(max_length=10, choices=(
  18. (TYPE_FACT, 'relevé'),
  19. (TYPE_PLAN, 'scénario/estimation'),
  20. ))
  21. def __str__(self):
  22. return self.name
  23. def save(self, *args, **kwargs):
  24. self.comment_html = markdown.markdown(self.comment)
  25. super().save(*args, **kwargs)
  26. def get_absolute_url(self):
  27. return reverse('detail-document', kwargs={'pk': self.pk})
  28. def copy(self):
  29. """ Deep copy and saving of a document
  30. All related resources are copied.
  31. """
  32. with transaction.atomic():
  33. new_doc = Document.objects.get(pk=self.pk)
  34. new_doc.pk = None
  35. new_doc.name = 'COPIE DE {}'.format(new_doc.name)
  36. new_doc.save()
  37. new_services, new_costs, new_goods = {}, {}, {}
  38. to_copy = (
  39. (self.service_set, new_services),
  40. (self.good_set, new_goods),
  41. (self.cost_set, new_costs),
  42. )
  43. for qs, index in to_copy:
  44. for item in qs.all():
  45. old_pk = item.pk
  46. item.pk = None
  47. item.document = new_doc
  48. item.save()
  49. index[old_pk] = item
  50. to_reproduce = (
  51. (ServiceUse, new_services),
  52. (GoodUse, new_goods),
  53. (CostUse, new_costs),
  54. )
  55. for Klass, index in to_reproduce:
  56. src_qs = Klass.objects.filter(service__document=self)
  57. for use in src_qs.all():
  58. use.pk = None
  59. use.service = new_services[use.service.pk]
  60. use.resource = index[use.resource.pk]
  61. use.save()
  62. return new_doc
  63. class AbstractItem(models.Model):
  64. name = models.CharField(max_length=130)
  65. description = models.TextField(blank=True)
  66. description_html = models.TextField(blank=True)
  67. document = models.ForeignKey(Document)
  68. def __str__(self):
  69. return self.name
  70. def save(self, *args, **kwargs):
  71. self.description_html = markdown.markdown(self.description)
  72. super().save(*args, **kwargs)
  73. class Meta:
  74. abstract = True
  75. class AbstractResource(AbstractItem):
  76. UNIT_AMP = 'a'
  77. UNIT_MBPS = 'mbps'
  78. UNIT_U = 'u'
  79. UNIT_IPV4 = 'ipv4'
  80. UNIT_ETHERNET_PORT = 'eth'
  81. UNIT_SERVICE = 'services'
  82. capacity_unit = models.CharField(
  83. max_length=10,
  84. choices=(
  85. (UNIT_AMP, 'A'),
  86. (UNIT_MBPS, 'Mbps'),
  87. (UNIT_U, 'U'),
  88. (UNIT_IPV4, 'IPv4'),
  89. (UNIT_ETHERNET_PORT, 'ports'),
  90. (UNIT_SERVICE, 'abonnement'),
  91. ),
  92. blank=True,
  93. )
  94. total_capacity = models.FloatField(default=1)
  95. class Meta:
  96. abstract = True
  97. def get_use_class(self):
  98. raise NotImplemented
  99. def used(self, except_by=None):
  100. """ Return the used fraction of an item
  101. :type: Service
  102. :param except_by: exclude this service from the math
  103. :rtype: float
  104. """
  105. sharing_costs = self.get_use_class().objects.filter(resource=self)
  106. if except_by:
  107. sharing_costs = sharing_costs.exclude(service=except_by)
  108. existing_uses_sum = sum(
  109. sharing_costs.values_list('share', flat=True))
  110. return existing_uses_sum
  111. def used_fraction(self, *args, **kwargs):
  112. return self.used(*args, **kwargs)/self.total_capacity
  113. def unused(self):
  114. return self.total_capacity-self.used()
  115. def __str__(self):
  116. if self.capacity_unit == '':
  117. return self.name
  118. else:
  119. return '{} {:.0f} {}'.format(
  120. self.name, self.total_capacity,
  121. self.get_capacity_unit_display())
  122. class Cost(AbstractResource):
  123. """ A monthtly cost we have to pay
  124. """
  125. price = models.FloatField(help_text="Coût mensuel")
  126. def get_use_class(self):
  127. return CostUse
  128. class Meta:
  129. verbose_name = 'Coût'
  130. class Good(AbstractResource):
  131. """ A good, which replacement is provisioned
  132. """
  133. price = models.FloatField()
  134. provisioning_duration = models.DurationField(
  135. choices=settings.PROVISIONING_DURATIONS)
  136. def get_use_class(self):
  137. return GoodUse
  138. def monthly_provision(self):
  139. return self.price/self.provisioning_duration.days*(365.25/12)
  140. class Meta:
  141. verbose_name = 'Bien'
  142. class AbstractUse(models.Model):
  143. share = models.FloatField()
  144. service = models.ForeignKey('Service')
  145. class Meta:
  146. abstract = True
  147. def clean(self):
  148. if hasattr(self, 'resource'):
  149. usage = self.resource.used(except_by=self.service) + self.share
  150. if usage > self.resource.total_capacity:
  151. raise ValidationError(
  152. "Cannot use more than 100% of {})".format(self.resource))
  153. def real_share(self):
  154. """The share, + wasted space share
  155. Taking into account that the unused space is
  156. wasted and has to be divided among actual users
  157. """
  158. return (
  159. self.share +
  160. (self.share/self.resource.used())*self.resource.unused()
  161. )
  162. def unit_share(self):
  163. if self.service.subscriptions_count == 0:
  164. return 0
  165. else:
  166. return self.share/self.service.subscriptions_count
  167. return
  168. def unit_real_share(self):
  169. if self.service.subscriptions_count == 0:
  170. return 0
  171. else:
  172. return self.real_share()/self.service.subscriptions_count
  173. def value_share(self):
  174. return (
  175. self.resource.price
  176. * self.real_share()
  177. / self.resource.total_capacity
  178. )
  179. def unit_value_share(self):
  180. if self.service.subscriptions_count == 0:
  181. return 0
  182. else:
  183. return self.value_share()/self.service.subscriptions_count
  184. class CostUse(AbstractUse):
  185. resource = models.ForeignKey(Cost)
  186. def cost_share(self):
  187. return (
  188. self.real_share() / self.resource.total_capacity
  189. * self.resource.price
  190. )
  191. def unit_cost_share(self):
  192. subscriptions_count = self.service.subscriptions_count
  193. if subscriptions_count == 0:
  194. return 0
  195. else:
  196. return self.cost_share()/self.service.subscriptions_count
  197. class GoodUse(AbstractUse):
  198. resource = models.ForeignKey(Good)
  199. def monthly_provision_share(self):
  200. return (
  201. self.real_share()
  202. * self.resource.monthly_provision()
  203. / self.resource.total_capacity)
  204. def unit_monthly_provision_share(self):
  205. subscriptions_count = self.service.subscriptions_count
  206. monthly_share = self.monthly_provision_share()
  207. if subscriptions_count == 0:
  208. return 0
  209. else:
  210. return monthly_share/subscriptions_count
  211. class Service(AbstractResource):
  212. """ A service we sell
  213. (considered monthly)
  214. """
  215. costs = models.ManyToManyField(
  216. Cost,
  217. through=CostUse,
  218. related_name='using_services')
  219. goods = models.ManyToManyField(
  220. Good,
  221. through=GoodUse,
  222. related_name='using_services')
  223. subscriptions_count = models.PositiveIntegerField(default=0)
  224. reusable = models.BooleanField(
  225. default=False,
  226. help_text="Peut-être utilisé par d'autres services")
  227. @property
  228. def price(self):
  229. return self.get_prices()['total_recurring_price']
  230. def save(self, *args, **kwargs):
  231. if self.reusable:
  232. self.capacity_unit = self.UNIT_SERVICE
  233. self.total_capacity = self.subscriptions_count
  234. return super().save(*args, **kwargs)
  235. def get_absolute_url(self):
  236. return reverse('detail-service', kwargs={'pk': self.pk})
  237. def get_use_class(self):
  238. return ServiceUse
  239. def get_prices(self):
  240. costs_uses = CostUse.objects.filter(service=self)
  241. goods_uses = GoodUse.objects.filter(service=self)
  242. services_uses = ServiceUse.objects.filter(service=self)
  243. total_recurring_price = sum(chain(
  244. (i.monthly_provision_share() for i in goods_uses),
  245. (i.cost_share() for i in costs_uses),
  246. (i.cost_share() for i in services_uses)
  247. ))
  248. total_goods_value_share = sum(
  249. (i.value_share() for i in chain(goods_uses, services_uses)))
  250. if self.subscriptions_count == 0:
  251. unit_recurring_price = 0
  252. unit_goods_value_share = 0
  253. else:
  254. unit_recurring_price = total_recurring_price/self.subscriptions_count
  255. unit_goods_value_share = \
  256. total_goods_value_share/self.subscriptions_count
  257. return {
  258. 'total_recurring_price': total_recurring_price,
  259. 'unit_recurring_price': unit_recurring_price,
  260. 'total_goods_value_share': total_goods_value_share,
  261. 'unit_goods_value_share': unit_goods_value_share,
  262. }
  263. def validate_reusable_service(v):
  264. if not Service.objects.get(pk=v).reusable:
  265. raise ValidationError('{} is not a reusable service'.format(v))
  266. class ServiceUse(AbstractUse):
  267. resource = models.ForeignKey(
  268. Service, related_name='dependent_services',
  269. limit_choices_to={'reusable': True},
  270. validators=[validate_reusable_service])
  271. def cost_share(self):
  272. return (
  273. self.share / self.resource.total_capacity
  274. * self.resource.price
  275. )
  276. def unit_cost_share(self):
  277. subscriptions_count = self.service.subscriptions_count
  278. if subscriptions_count == 0:
  279. return 0
  280. else:
  281. return self.cost_share()/self.service.subscriptions_count
  282. def value_share(self):
  283. return self.share*self.resource.get_prices()['unit_goods_value_share']
  284. def clean(self):
  285. """ Checks for cycles in service using services
  286. """
  287. start_resource = self.resource
  288. def crawl(service):
  289. for use in service.dependent_services.all():
  290. if use.service == start_resource:
  291. raise ValidationError(
  292. 'Cycle detected in services using services')
  293. else:
  294. crawl(use.service)
  295. crawl(self.service)