You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

abstract_models.py 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. from itertools import chain
  2. from django.db import models
  3. from django.contrib.auth.models import User
  4. from django.template.defaultfilters import slugify
  5. from django.utils.translation import ugettext_lazy as _
  6. from django.db.models import Sum
  7. from django.template import Template, Context
  8. class AbstractOrder(models.Model):
  9. u"""An order"""
  10. number = models.CharField(_("Order number"), max_length=128, db_index=True)
  11. # We track the site that each order is placed within
  12. site = models.ForeignKey('sites.Site')
  13. basket = models.ForeignKey('basket.Basket', null=True, blank=True)
  14. # Orders can be anonymous so we don't always have a customer ID
  15. user = models.ForeignKey(User, related_name='orders', null=True, blank=True)
  16. # Billing address is not always required (eg paying by gift card)
  17. billing_address = models.ForeignKey('order.BillingAddress', null=True, blank=True)
  18. # Total price looks like it could be calculated by adding up the
  19. # prices of the associated lines, but in some circumstances extra
  20. # order-level charges are added and so we need to store it separately
  21. total_incl_tax = models.DecimalField(_("Order total (inc. tax)"), decimal_places=2, max_digits=12)
  22. total_excl_tax = models.DecimalField(_("Order total (excl. tax)"), decimal_places=2, max_digits=12)
  23. # Shipping charges
  24. shipping_incl_tax = models.DecimalField(_("Shipping charge (inc. tax)"), decimal_places=2, max_digits=12, default=0)
  25. shipping_excl_tax = models.DecimalField(_("Shipping charge (excl. tax)"), decimal_places=2, max_digits=12, default=0)
  26. # Not all lines are actually shipped (such as downloads), hence shipping address
  27. # is not mandatory.
  28. shipping_address = models.ForeignKey('order.ShippingAddress', null=True, blank=True)
  29. shipping_method = models.CharField(_("Shipping method"), max_length=128, null=True, blank=True)
  30. # Use this field to indicate that an order is on hold / awaiting payment
  31. status = models.CharField(_("Status"), max_length=100, null=True, blank=True)
  32. # Index added to this field for reporting
  33. date_placed = models.DateTimeField(auto_now_add=True, db_index=True)
  34. @property
  35. def basket_total_incl_tax(self):
  36. u"""Return basket total including tax"""
  37. return self.total_incl_tax - self.shipping_incl_tax
  38. @property
  39. def basket_total_excl_tax(self):
  40. u"""Return basket total excluding tax"""
  41. return self.total_excl_tax - self.shipping_excl_tax
  42. @property
  43. def num_lines(self):
  44. return self.lines.count()
  45. @property
  46. def num_items(self):
  47. u"""
  48. Returns the number of items in this order.
  49. """
  50. num_items = 0
  51. for line in self.lines.all():
  52. num_items += line.quantity
  53. return num_items
  54. @property
  55. def shipping_status(self):
  56. events = self.shipping_events.all()
  57. if not len(events):
  58. return ''
  59. # Collect all events by event-type
  60. map = {}
  61. for event in events:
  62. event_name = event.event_type.name
  63. if event_name not in map:
  64. map[event_name] = []
  65. map[event_name] = list(chain(map[event_name], event.line_quantities.all()))
  66. # Determine last complete event
  67. status = _("In progress")
  68. for event_name, event_line_quantities in map.items():
  69. if self._is_event_complete(event_line_quantities):
  70. status = event_name
  71. return status
  72. def _is_event_complete(self, event_quantites):
  73. # Form map of line to quantity
  74. map = {}
  75. for event_quantity in event_quantites:
  76. line_id = event_quantity.line_id
  77. map.setdefault(line_id, 0)
  78. map[line_id] += event_quantity.quantity
  79. for line in self.lines.all():
  80. if map[line.id] != line.quantity:
  81. return False
  82. return True
  83. class Meta:
  84. abstract = True
  85. ordering = ['-date_placed',]
  86. permissions = (
  87. ("can_view", "Can view orders (eg for reporting)"),
  88. )
  89. def __unicode__(self):
  90. return u"#%s" % (self.number,)
  91. class AbstractOrderNote(models.Model):
  92. u"""A note against an order."""
  93. order = models.ForeignKey('order.Order', related_name="notes")
  94. user = models.ForeignKey('auth.User')
  95. message = models.TextField()
  96. date = models.DateTimeField(auto_now_add=True)
  97. class Meta:
  98. abstract = True
  99. def __unicode__(self):
  100. return u"'%s' (%s)" % (self.message[0:50], self.user)
  101. class AbstractCommunicationEvent(models.Model):
  102. u"""
  103. An order-level event involving a communication to the customer, such
  104. as an confirmation email being sent."""
  105. order = models.ForeignKey('order.Order', related_name="communication_events")
  106. type = models.ForeignKey('order.CommunicationEventType')
  107. date = models.DateTimeField(auto_now_add=True)
  108. class Meta:
  109. abstract = True
  110. def __unicode__(self):
  111. return u"'%s' event for order #%s" % (self.type.name, self.order.number)
  112. class AbstractCommunicationEventType(models.Model):
  113. u"""Communication events are things like 'OrderConfirmationEmailSent'"""
  114. # Code is used in forms
  115. code = models.SlugField(max_length=128)
  116. # Name is the friendly description of an event
  117. name = models.CharField(max_length=255)
  118. # Template content for emails
  119. email_subject_template = models.CharField(max_length=255, blank=True)
  120. email_body_template = models.TextField(blank=True, null=True)
  121. def save(self, *args, **kwargs):
  122. if not self.code:
  123. self.code = slugify(self.name)
  124. super(AbstractCommunicationEventType, self).save(*args, **kwargs)
  125. class Meta:
  126. abstract = True
  127. verbose_name_plural = _("Communication event types")
  128. def __unicode__(self):
  129. return self.name
  130. def has_email_templates(self):
  131. return self.email_subject_template and self.email_body_template
  132. def get_email_subject_for_order(self, order, **kwargs):
  133. return self._merge_template_with_context(self.email_subject_template, order, **kwargs)
  134. def get_email_body_for_order(self, order, **kwargs):
  135. return self._merge_template_with_context(self.email_body_template, order, **kwargs)
  136. def _merge_template_with_context(self, template, order, **kwargs):
  137. ctx = {'order': order}
  138. ctx.update(**kwargs)
  139. return Template(template).render(Context(ctx))
  140. class AbstractLine(models.Model):
  141. u"""
  142. A order line (basically a product and a quantity)
  143. Not using a line model as it's difficult to capture and payment
  144. information when it splits across a line.
  145. """
  146. order = models.ForeignKey('order.Order', related_name='lines')
  147. # We store the partner, their SKU and the title for cases where the product has been
  148. # deleted from the catalogue. We also store the partner name in case the partner
  149. # gets deleted at a later date.
  150. partner = models.ForeignKey('partner.Partner', related_name='order_lines', blank=True, null=True, on_delete=models.SET_NULL)
  151. partner_name = models.CharField(_("Partner name"), max_length=128)
  152. partner_sku = models.CharField(_("Partner SKU"), max_length=128)
  153. title = models.CharField(_("Title"), max_length=255)
  154. # We don't want any hard links between orders and the products table
  155. product = models.ForeignKey('product.Item', on_delete=models.SET_NULL, null=True)
  156. quantity = models.PositiveIntegerField(default=1)
  157. # Price information (these fields are actually redundant as the information
  158. # can be calculated from the LinePrice models
  159. line_price_incl_tax = models.DecimalField(decimal_places=2, max_digits=12)
  160. line_price_excl_tax = models.DecimalField(decimal_places=2, max_digits=12)
  161. # Price information before discounts are applied
  162. line_price_before_discounts_incl_tax = models.DecimalField(decimal_places=2, max_digits=12)
  163. line_price_before_discounts_excl_tax = models.DecimalField(decimal_places=2, max_digits=12)
  164. # REPORTING FIELDS
  165. # Cost price (the price charged by the fulfilment partner for this product).
  166. unit_cost_price = models.DecimalField(decimal_places=2, max_digits=12, blank=True, null=True)
  167. # Normal site price for item (without discounts)
  168. unit_site_price = models.DecimalField(decimal_places=2, max_digits=12, blank=True, null=True)
  169. # Retail price at time of purchase
  170. unit_retail_price = models.DecimalField(decimal_places=2, max_digits=12, blank=True, null=True)
  171. # Partner information
  172. partner_line_reference = models.CharField(_("Partner reference"), max_length=128, blank=True, null=True,
  173. help_text=_("This is the item number that the partner uses within their system"))
  174. partner_line_notes = models.TextField(blank=True, null=True)
  175. # Estimated dispatch date - should be set at order time
  176. est_dispatch_date = models.DateField(blank=True, null=True)
  177. @property
  178. def description(self):
  179. u"""
  180. Returns a description of this line including details of any
  181. line attributes.
  182. """
  183. d = str(self.product)
  184. ops = []
  185. for attribute in self.attributes.all():
  186. ops.append("%s = '%s'" % (attribute.type, attribute.value))
  187. if ops:
  188. d = "%s (%s)" % (d, ", ".join(ops))
  189. return d
  190. @property
  191. def shipping_status(self):
  192. u"""Returns a string summary of the shipping status of this line"""
  193. status_map = self._shipping_event_history()
  194. if not status_map:
  195. return ''
  196. events = []
  197. last_complete_event_name = None
  198. for event_dict in status_map:
  199. if event_dict['quantity'] == self.quantity:
  200. events.append(event_dict['name'])
  201. last_complete_event_name = event_dict['name']
  202. else:
  203. events.append("%s (%d/%d items)" % (event_dict['name'],
  204. event_dict['quantity'], self.quantity))
  205. if last_complete_event_name == status_map[-1]['name']:
  206. return last_complete_event_name
  207. return ', '.join(events)
  208. def has_shipping_event_occurred(self, event_type):
  209. u"""Checks whether this line has passed a given shipping event"""
  210. for event_dict in self._shipping_event_history():
  211. if event_dict['name'] == event_type.name and event_dict['quantity'] == self.quantity:
  212. return True
  213. return False
  214. @property
  215. def is_product_deleted(self):
  216. return self.product == None
  217. def _shipping_event_history(self):
  218. u"""
  219. Returns a list of shipping events"""
  220. status_map = {}
  221. for event in self.shippingevent_set.all():
  222. event_name = event.event_type.name
  223. event_quantity = event.line_quantities.get(line=self).quantity
  224. if event_name in status_map:
  225. status_map[event_name]['quantity'] += event_quantity
  226. else:
  227. status_map[event_name] = {'name': event_name, 'quantity': event_quantity}
  228. return list(status_map.values())
  229. class Meta:
  230. abstract = True
  231. verbose_name_plural = _("Order lines")
  232. def __unicode__(self):
  233. return u"Product '%s', quantity '%s'" % (self.product, self.quantity)
  234. class AbstractLineAttribute(models.Model):
  235. u"""An attribute of a line."""
  236. line = models.ForeignKey('order.Line', related_name='attributes')
  237. option = models.ForeignKey('product.Option', null=True, on_delete=models.SET_NULL, related_name="line_attributes")
  238. type = models.CharField(_("Type"), max_length=128)
  239. value = models.CharField(_("Value"), max_length=255)
  240. class Meta:
  241. abstract = True
  242. def __unicode__(self):
  243. return "%s = %s" % (self.type, self.value)
  244. class AbstractLinePrice(models.Model):
  245. u"""
  246. For tracking the prices paid for each unit within a line.
  247. This is necessary as offers can lead to units within a line
  248. having different prices. For example, one product may be sold at
  249. 50% off as it's part of an offer while the remainder are full price.
  250. """
  251. order = models.ForeignKey('order.Order', related_name='line_prices')
  252. line = models.ForeignKey('order.Line', related_name='prices')
  253. quantity = models.PositiveIntegerField(default=1)
  254. price_incl_tax = models.DecimalField(decimal_places=2, max_digits=12)
  255. price_excl_tax = models.DecimalField(decimal_places=2, max_digits=12)
  256. shipping_incl_tax = models.DecimalField(decimal_places=2, max_digits=12, default=0)
  257. shipping_excl_tax = models.DecimalField(decimal_places=2, max_digits=12, default=0)
  258. class Meta:
  259. abstract = True
  260. def __unicode__(self):
  261. return u"Line '%s' (quantity %d) price %s" % (self.line, self.quantity, self.price_incl_tax)
  262. # PAYMENT EVENTS
  263. class AbstractPaymentEventType(models.Model):
  264. """
  265. Payment events are things like 'Paid', 'Failed', 'Refunded'
  266. """
  267. name = models.CharField(max_length=128)
  268. code = models.SlugField(max_length=128)
  269. sequence_number = models.PositiveIntegerField(default=0)
  270. def save(self, *args, **kwargs):
  271. if not self.code:
  272. self.code = slugify(self.name)
  273. super(AbstractPaymentEventType, self).save(*args, **kwargs)
  274. class Meta:
  275. abstract = True
  276. verbose_name_plural = _("Payment event types")
  277. ordering = ('sequence_number',)
  278. def __unicode__(self):
  279. return self.name
  280. class AbstractPaymentEvent(models.Model):
  281. u"""
  282. An event is something which happens to a line such as
  283. payment being taken for 2 items, or 1 item being dispatched.
  284. """
  285. order = models.ForeignKey('order.Order', related_name='payment_events')
  286. lines = models.ManyToManyField('order.Line', through='PaymentEventQuantity')
  287. event_type = models.ForeignKey('order.PaymentEventType')
  288. date = models.DateTimeField(auto_now_add=True)
  289. class Meta:
  290. abstract = True
  291. verbose_name_plural = _("Payment events")
  292. def __unicode__(self):
  293. return u"Order #%d, line %s: %d items %s" % (
  294. self.line.order.number, self.line.line_id, self.quantity, self.event_type)
  295. class PaymentEventQuantity(models.Model):
  296. u"""A "through" model linking lines to payment events"""
  297. event = models.ForeignKey('order.PaymentEvent', related_name='line_quantities')
  298. line = models.ForeignKey('order.Line')
  299. quantity = models.PositiveIntegerField()
  300. class AbstractShippingEvent(models.Model):
  301. u"""
  302. An event is something which happens to a group of lines such as
  303. 1 item being dispatched.
  304. """
  305. order = models.ForeignKey('order.Order', related_name='shipping_events')
  306. lines = models.ManyToManyField('order.Line', through='ShippingEventQuantity')
  307. event_type = models.ForeignKey('order.ShippingEventType')
  308. notes = models.TextField(_("Event notes"), blank=True, null=True,
  309. help_text="This could be the dispatch reference, or a tracking number")
  310. date = models.DateTimeField(auto_now_add=True)
  311. class Meta:
  312. abstract = True
  313. verbose_name_plural = _("Shipping events")
  314. ordering = ['-date']
  315. def __unicode__(self):
  316. return u"Order #%s, type %s" % (
  317. self.order.number, self.event_type)
  318. def num_affected_lines(self):
  319. return self.lines.count()
  320. class ShippingEventQuantity(models.Model):
  321. u"""A "through" model linking lines to shipping events"""
  322. event = models.ForeignKey('order.ShippingEvent', related_name='line_quantities')
  323. line = models.ForeignKey('order.Line')
  324. quantity = models.PositiveIntegerField()
  325. def _check_previous_events_are_complete(self):
  326. u"""Checks whether previous shipping events have passed"""
  327. previous_events = ShippingEventQuantity._default_manager.filter(line=self.line,
  328. event__event_type__sequence_number__lt=self.event.event_type.sequence_number)
  329. self.quantity = int(self.quantity)
  330. for event_quantities in previous_events:
  331. if event_quantities.quantity < self.quantity:
  332. raise ValueError("Invalid quantity (%d) for event type (a previous event has not been fully passed)" % self.quantity)
  333. def _check_new_quantity(self):
  334. quantity_row = ShippingEventQuantity._default_manager.filter(line=self.line,
  335. event__event_type=self.event.event_type).aggregate(Sum('quantity'))
  336. previous_quantity = quantity_row['quantity__sum']
  337. if previous_quantity == None:
  338. previous_quantity = 0
  339. if previous_quantity + self.quantity > self.line.quantity:
  340. raise ValueError("Invalid quantity (%d) for event type (total exceeds line total)" % self.quantity)
  341. def save(self, *args, **kwargs):
  342. # Default quantity to full quantity of line
  343. if not self.quantity:
  344. self.quantity = self.line.quantity
  345. self._check_previous_events_are_complete()
  346. self._check_new_quantity()
  347. super(ShippingEventQuantity, self).save(*args, **kwargs)
  348. def __unicode__(self):
  349. return "%s - quantity %d" % (self.line.product, self.quantity)
  350. class AbstractShippingEventType(models.Model):
  351. u"""Shipping events are things like 'OrderPlaced', 'Acknowledged', 'Dispatched', 'Refunded'"""
  352. # Code is used in forms
  353. code = models.CharField(max_length=128)
  354. # Name is the friendly description of an event
  355. name = models.CharField(max_length=255)
  356. # Code is used in forms
  357. code = models.SlugField(max_length=128)
  358. is_required = models.BooleanField(default=True, help_text="This event must be passed before the next shipping event can take place")
  359. # The normal order in which these shipping events take place
  360. sequence_number = models.PositiveIntegerField(default=0)
  361. def save(self, *args, **kwargs):
  362. if not self.code:
  363. self.code = slugify(self.name)
  364. super(AbstractShippingEventType, self).save(*args, **kwargs)
  365. class Meta:
  366. abstract = True
  367. verbose_name_plural = _("Shipping event types")
  368. ordering = ('sequence_number',)
  369. def __unicode__(self):
  370. return self.name
  371. class AbstractOrderDiscount(models.Model):
  372. order = models.ForeignKey('order.Order', related_name="discounts")
  373. offer = models.ForeignKey('offer.ConditionalOffer', null=True, on_delete=models.SET_NULL)
  374. voucher = models.ForeignKey('offer.Voucher', related_name="discount_vouchers", null=True, on_delete=models.SET_NULL)
  375. voucher_code = models.CharField(_("Code"), max_length=128, db_index=True)
  376. amount = models.DecimalField(decimal_places=2, max_digits=12, default=0)
  377. class Meta:
  378. abstract = True
  379. def description(self):
  380. if self.voucher_code:
  381. return self.voucher_code
  382. return self.offer.name