models.py 15 KB

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