models.py 13 KB

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