models.py 12 KB

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