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 26KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810
  1. from decimal import Decimal
  2. import zlib
  3. from django.db import models
  4. from django.db.models import query, Sum
  5. from django.conf import settings
  6. from django.utils.timezone import now
  7. from django.utils.translation import ugettext as _
  8. from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
  9. from oscar.apps.basket.managers import OpenBasketManager, SavedBasketManager
  10. from oscar.apps.offer import results
  11. from oscar.core.compat import AUTH_USER_MODEL
  12. from oscar.templatetags.currency_filters import currency
  13. class AbstractBasket(models.Model):
  14. """
  15. Basket object
  16. """
  17. # Baskets can be anonymously owned - hence this field is nullable. When a
  18. # anon user signs in, their two baskets are merged.
  19. owner = models.ForeignKey(
  20. AUTH_USER_MODEL, related_name='baskets', null=True,
  21. verbose_name=_("Owner"))
  22. # Basket statuses
  23. # - Frozen is for when a basket is in the process of being submitted
  24. # and we need to prevent any changes to it.
  25. OPEN, MERGED, SAVED, FROZEN, SUBMITTED = (
  26. "Open", "Merged", "Saved", "Frozen", "Submitted")
  27. STATUS_CHOICES = (
  28. (OPEN, _("Open - currently active")),
  29. (MERGED, _("Merged - superceded by another basket")),
  30. (SAVED, _("Saved - for items to be purchased later")),
  31. (FROZEN, _("Frozen - the basket cannot be modified")),
  32. (SUBMITTED, _("Submitted - has been ordered at the checkout")),
  33. )
  34. status = models.CharField(
  35. _("Status"), max_length=128, default=OPEN, choices=STATUS_CHOICES)
  36. # A basket can have many vouchers attached to it. However, it is common
  37. # for sites to only allow one voucher per basket - this will need to be
  38. # enforced in the project's codebase.
  39. vouchers = models.ManyToManyField(
  40. 'voucher.Voucher', null=True, verbose_name=_("Vouchers"), blank=True)
  41. date_created = models.DateTimeField(_("Date created"), auto_now_add=True)
  42. date_merged = models.DateTimeField(_("Date merged"), null=True, blank=True)
  43. date_submitted = models.DateTimeField(_("Date submitted"), null=True,
  44. blank=True)
  45. # Only if a basket is in one of these statuses can it be edited
  46. editable_statuses = (OPEN, SAVED)
  47. class Meta:
  48. abstract = True
  49. verbose_name = _('Basket')
  50. verbose_name_plural = _('Baskets')
  51. objects = models.Manager()
  52. open = OpenBasketManager()
  53. saved = SavedBasketManager()
  54. def __init__(self, *args, **kwargs):
  55. super(AbstractBasket, self).__init__(*args, **kwargs)
  56. # We keep a cached copy of the basket lines as we refer to them often
  57. # within the same request cycle. Also, applying offers will append
  58. # discount data to the basket lines which isn't persisted to the DB and
  59. # so we want to avoid reloading them as this would drop the discount
  60. # information.
  61. self._lines = None
  62. self.offer_applications = results.OfferApplications()
  63. def __unicode__(self):
  64. return _(
  65. u"%(status)s basket (owner: %(owner)s, lines: %(num_lines)d)") % {
  66. 'status': self.status,
  67. 'owner': self.owner,
  68. 'num_lines': self.num_lines}
  69. # ========
  70. # Strategy
  71. # ========
  72. @property
  73. def has_strategy(self):
  74. return hasattr(self, '_strategy')
  75. def _get_strategy(self):
  76. if not self.has_strategy:
  77. raise RuntimeError(
  78. "No strategy class has been assigned to this basket. "
  79. "This is normally assigned to the incoming request in "
  80. "oscar.apps.basket.middleware.BasketMiddleware. "
  81. "Since it is missing, you must be doing something different. "
  82. "Ensure that a strategy instance is assigne to the basket!"
  83. )
  84. return self._strategy
  85. def _set_strategy(self, strategy):
  86. self._strategy = strategy
  87. strategy = property(_get_strategy, _set_strategy)
  88. def all_lines(self):
  89. """
  90. Return a cached set of basket lines.
  91. This is important for offers as they alter the line models and you
  92. don't want to reload them from the DB as that information would be
  93. lost.
  94. """
  95. if self.id is None:
  96. return self.lines.none()
  97. if self._lines is None:
  98. self._lines = self.lines.select_related(
  99. 'product', 'product__stockrecord'
  100. ).all().prefetch_related('attributes', 'product__images')
  101. # Assign strategy to each line so it can use it to determine
  102. # prices. This is only needed for Django 1.4.5, where accessing
  103. # self.basket from within the line will create a new basket
  104. # instance (with no strategy assigned). In later version, the
  105. # original basket instance is cached and keeps its strategy
  106. # property.
  107. for line in self._lines:
  108. line.strategy = self.strategy
  109. return self._lines
  110. def is_quantity_allowed(self, qty):
  111. """
  112. Test whether the passed quantity of items can be added to the basket
  113. """
  114. # We enfore a max threshold to prevent a DOS attack via the offers
  115. # system.
  116. basket_threshold = settings.OSCAR_MAX_BASKET_QUANTITY_THRESHOLD
  117. if basket_threshold:
  118. total_basket_quantity = self.num_items
  119. max_allowed = basket_threshold - total_basket_quantity
  120. if qty > max_allowed:
  121. return False, _(
  122. "Due to technical limitations we are not able "
  123. "to ship more than %(threshold)d items in one order.") % {
  124. 'threshold': basket_threshold,
  125. }
  126. return True, None
  127. # ============
  128. # Manipulation
  129. # ============
  130. def flush(self):
  131. """
  132. Remove all lines from basket.
  133. """
  134. if self.status == self.FROZEN:
  135. raise PermissionDenied("A frozen basket cannot be flushed")
  136. self.lines.all().delete()
  137. self._lines = None
  138. def add_product(self, product, quantity=1, options=None):
  139. """
  140. Add a product to the basket
  141. 'stock_info' is the price and availability data returned from
  142. a partner strategy class.
  143. The 'options' list should contains dicts with keys 'option' and 'value'
  144. which link the relevant product.Option model and string value
  145. respectively.
  146. """
  147. if options is None:
  148. options = []
  149. if not self.id:
  150. self.save()
  151. # Ensure that all lines are the same currency
  152. price_currency = self.currency
  153. stock_info = self.strategy.fetch(product)
  154. if price_currency and stock_info.price.currency != price_currency:
  155. raise ValueError((
  156. "Basket lines must all have the same currency. Proposed "
  157. "line has currency %s, while basket has currency %s") % (
  158. price_currency, stock_info.price.currency))
  159. # Line reference is used to distinguish between variations of the same
  160. # product (eg T-shirts with different personalisations)
  161. line_ref = self._create_line_reference(
  162. product, stock_info.stockrecord, options)
  163. # Determine price to store (if one exists). It is only stored for
  164. # audit and sometimes caching.
  165. defaults = {
  166. 'quantity': quantity,
  167. 'price_excl_tax': stock_info.price.excl_tax,
  168. 'price_currency': stock_info.price.currency,
  169. }
  170. if stock_info.price.is_tax_known:
  171. defaults['price_incl_tax'] = stock_info.price.incl_tax
  172. line, created = self.lines.get_or_create(
  173. line_reference=line_ref,
  174. product=product,
  175. stockrecord=stock_info.stockrecord,
  176. defaults=defaults)
  177. if created:
  178. for option_dict in options:
  179. line.attributes.create(option=option_dict['option'],
  180. value=option_dict['value'])
  181. else:
  182. line.quantity += quantity
  183. line.save()
  184. self.reset_offer_applications()
  185. add_product.alters_data = True
  186. add = add_product
  187. def applied_offers(self):
  188. """
  189. Return a dict of offers successfully applied to the basket.
  190. This is used to compare offers before and after a basket change to see
  191. if there is a difference.
  192. """
  193. return self.offer_applications.offers
  194. def reset_offer_applications(self):
  195. """
  196. Remove any discounts so they get recalculated
  197. """
  198. self.offer_applications = results.OfferApplications()
  199. self._lines = None
  200. def merge_line(self, line, add_quantities=True):
  201. """
  202. For transferring a line from another basket to this one.
  203. This is used with the "Saved" basket functionality.
  204. """
  205. try:
  206. existing_line = self.lines.get(line_reference=line.line_reference)
  207. except ObjectDoesNotExist:
  208. # Line does not already exist - reassign its basket
  209. line.basket = self
  210. line.save()
  211. else:
  212. # Line already exists - assume the max quantity is correct and
  213. # delete the old
  214. if add_quantities:
  215. existing_line.quantity += line.quantity
  216. else:
  217. existing_line.quantity = max(existing_line.quantity,
  218. line.quantity)
  219. existing_line.save()
  220. line.delete()
  221. finally:
  222. self._lines = None
  223. merge_line.alters_data = True
  224. def merge(self, basket, add_quantities=True):
  225. """
  226. Merges another basket with this one.
  227. :basket: The basket to merge into this one.
  228. :add_quantities: Whether to add line quantities when they are merged.
  229. """
  230. # Use basket.lines.all instead of all_lines as this function is called
  231. # before a strategy has been assigned.
  232. for line_to_merge in basket.lines.all():
  233. self.merge_line(line_to_merge, add_quantities)
  234. basket.status = self.MERGED
  235. basket.date_merged = now()
  236. basket._lines = None
  237. basket.save()
  238. merge.alters_data = True
  239. def freeze(self):
  240. """
  241. Freezes the basket so it cannot be modified.
  242. """
  243. self.status = self.FROZEN
  244. self.save()
  245. freeze.alters_data = True
  246. def thaw(self):
  247. """
  248. Unfreezes a basket so it can be modified again
  249. """
  250. self.status = self.OPEN
  251. self.save()
  252. thaw.alters_data = True
  253. def submit(self):
  254. """
  255. Mark this basket as submitted
  256. """
  257. self.status = self.SUBMITTED
  258. self.date_submitted = now()
  259. self.save()
  260. submit.alters_data = True
  261. # Kept for backwards compatibility
  262. set_as_submitted = submit
  263. def is_shipping_required(self):
  264. """
  265. Test whether the basket contains physical products that require
  266. shipping.
  267. """
  268. for line in self.all_lines():
  269. if line.product.is_shipping_required:
  270. return True
  271. return False
  272. # =======
  273. # Helpers
  274. # =======
  275. def _create_line_reference(self, product, stockrecord, options):
  276. """
  277. Returns a reference string for a line based on the item
  278. and its options.
  279. """
  280. base = '%s_%s' % (product.id, stockrecord.id)
  281. if not options:
  282. return base
  283. return "%s_%s" % (base, zlib.crc32(str(options)))
  284. def _get_total(self, property):
  285. """
  286. For executing a named method on each line of the basket
  287. and returning the total.
  288. """
  289. total = Decimal('0.00')
  290. for line in self.all_lines():
  291. try:
  292. total += getattr(line, property)
  293. except ObjectDoesNotExist:
  294. # Handle situation where the product may have been deleted
  295. pass
  296. return total
  297. # ==========
  298. # Properties
  299. # ==========
  300. @property
  301. def is_empty(self):
  302. """
  303. Test if this basket is empty
  304. """
  305. return self.id is None or self.num_lines == 0
  306. @property
  307. def is_tax_known(self):
  308. """
  309. Test if tax values are known for this basket
  310. """
  311. return all([line.is_tax_known for line in self.all_lines()])
  312. @property
  313. def total_excl_tax(self):
  314. """
  315. Return total line price excluding tax
  316. """
  317. return self._get_total('line_price_excl_tax_and_discounts')
  318. @property
  319. def total_tax(self):
  320. """Return total tax for a line"""
  321. return self._get_total('line_tax')
  322. @property
  323. def total_incl_tax(self):
  324. """
  325. Return total price inclusive of tax and discounts
  326. """
  327. return self._get_total('line_price_incl_tax_and_discounts')
  328. @property
  329. def total_incl_tax_excl_discounts(self):
  330. """
  331. Return total price inclusive of tax but exclusive discounts
  332. """
  333. return self._get_total('line_price_incl_tax')
  334. @property
  335. def total_discount(self):
  336. return self._get_total('discount_value')
  337. @property
  338. def offer_discounts(self):
  339. """
  340. Return basket discounts from non-voucher sources. Does not include
  341. shipping discounts.
  342. """
  343. return self.offer_applications.offer_discounts
  344. @property
  345. def voucher_discounts(self):
  346. """
  347. Return discounts from vouchers
  348. """
  349. return self.offer_applications.voucher_discounts
  350. @property
  351. def shipping_discounts(self):
  352. """
  353. Return discounts from vouchers
  354. """
  355. return self.offer_applications.shipping_discounts
  356. @property
  357. def post_order_actions(self):
  358. """
  359. Return discounts from vouchers
  360. """
  361. return self.offer_applications.post_order_actions
  362. @property
  363. def grouped_voucher_discounts(self):
  364. """
  365. Return discounts from vouchers but grouped so that a voucher which
  366. links to multiple offers is aggregated into one object.
  367. """
  368. return self.offer_applications.grouped_voucher_discounts
  369. @property
  370. def total_excl_tax_excl_discounts(self):
  371. """
  372. Return total price excluding tax and discounts
  373. """
  374. return self._get_total('line_price_excl_tax')
  375. @property
  376. def num_lines(self):
  377. """Return number of lines"""
  378. return self.lines.all().count()
  379. @property
  380. def num_items(self):
  381. """Return number of items"""
  382. return reduce(
  383. lambda num, line: num + line.quantity, self.lines.all(), 0)
  384. @property
  385. def num_items_without_discount(self):
  386. num = 0
  387. for line in self.all_lines():
  388. num += line.quantity_without_discount
  389. return num
  390. @property
  391. def num_items_with_discount(self):
  392. num = 0
  393. for line in self.all_lines():
  394. num += line.quantity_with_discount
  395. return num
  396. @property
  397. def time_before_submit(self):
  398. if not self.date_submitted:
  399. return None
  400. return self.date_submitted - self.date_created
  401. @property
  402. def time_since_creation(self, test_datetime=None):
  403. if not test_datetime:
  404. test_datetime = now()
  405. return test_datetime - self.date_created
  406. @property
  407. def contains_a_voucher(self):
  408. if not self.id:
  409. return False
  410. return self.vouchers.all().count() > 0
  411. @property
  412. def is_submitted(self):
  413. return self.status == self.SUBMITTED
  414. @property
  415. def can_be_edited(self):
  416. """
  417. Test if a basket can be edited
  418. """
  419. return self.status in self.editable_statuses
  420. @property
  421. def currency(self):
  422. # Since all lines should have the same currency, return the currency of
  423. # the first one found.
  424. for line in self.all_lines():
  425. return line.price_currency
  426. # =============
  427. # Query methods
  428. # =============
  429. def contains_voucher(self, code):
  430. """
  431. Test whether the basket contains a voucher with a given code
  432. """
  433. if self.id is None:
  434. return False
  435. try:
  436. self.vouchers.get(code=code)
  437. except ObjectDoesNotExist:
  438. return False
  439. else:
  440. return True
  441. def product_quantity(self, product):
  442. """
  443. Return the quantity of a product in the basket
  444. The basket can contain multiple lines with the same product, but
  445. different options and stockrecords. Those quantities are summed up.
  446. """
  447. matching_lines = self.lines.filter(product=product)
  448. quantity = matching_lines.aggregate(Sum('quantity'))['quantity__sum']
  449. return quantity or 0
  450. def line_quantity(self, product, stockrecord, options=None):
  451. """
  452. Return the current quantity of a specific product and options
  453. """
  454. ref = self._create_line_reference(product, stockrecord, options)
  455. try:
  456. return self.lines.get(line_reference=ref).quantity
  457. except ObjectDoesNotExist:
  458. return 0
  459. class AbstractLine(models.Model):
  460. """
  461. A line of a basket (product and a quantity)
  462. """
  463. basket = models.ForeignKey('basket.Basket', related_name='lines',
  464. verbose_name=_("Basket"))
  465. # This is to determine which products belong to the same line
  466. # We can't just use product.id as you can have customised products
  467. # which should be treated as separate lines. Set as a
  468. # SlugField as it is included in the path for certain views.
  469. line_reference = models.SlugField(
  470. _("Line Reference"), max_length=128, db_index=True)
  471. product = models.ForeignKey(
  472. 'catalogue.Product', related_name='basket_lines',
  473. verbose_name=_("Product"))
  474. # We store the stockrecord that should be used to fulfil this line. This
  475. # shouldn't really be NULLable but we need to keep it so for backwards
  476. # compatibility.
  477. stockrecord = models.ForeignKey(
  478. 'partner.StockRecord', related_name='basket_lines',
  479. null=True, blank=True)
  480. quantity = models.PositiveIntegerField(_('Quantity'), default=1)
  481. # We store the unit price incl tax of the product when it is first added to
  482. # the basket. This allows us to tell if a product has changed price since
  483. # a person first added it to their basket.
  484. price_currency = models.CharField(
  485. _("Currency"), max_length=12, default=settings.OSCAR_DEFAULT_CURRENCY)
  486. price_excl_tax = models.DecimalField(
  487. _('Price excl. Tax'), decimal_places=2, max_digits=12,
  488. null=True)
  489. price_incl_tax = models.DecimalField(
  490. _('Price incl. Tax'), decimal_places=2, max_digits=12, null=True)
  491. # Track date of first addition
  492. date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
  493. def __init__(self, *args, **kwargs):
  494. super(AbstractLine, self).__init__(*args, **kwargs)
  495. # Instance variables used to persist discount information
  496. self._discount = Decimal('0.00')
  497. self._affected_quantity = 0
  498. class Meta:
  499. abstract = True
  500. unique_together = ("basket", "line_reference")
  501. verbose_name = _('Basket line')
  502. verbose_name_plural = _('Basket lines')
  503. def __unicode__(self):
  504. return _(
  505. u"Basket #%(basket_id)d, Product #%(product_id)d, quantity %(quantity)d") % {
  506. 'basket_id': self.basket.pk,
  507. 'product_id': self.product.pk,
  508. 'quantity': self.quantity}
  509. def save(self, *args, **kwargs):
  510. """
  511. Saves a line or deletes if the quantity is 0
  512. """
  513. if not self.basket.can_be_edited:
  514. raise PermissionDenied(
  515. _("You cannot modify a %s basket") % (
  516. self.basket.status.lower(),))
  517. if self.quantity == 0:
  518. return self.delete(*args, **kwargs)
  519. return super(AbstractLine, self).save(*args, **kwargs)
  520. # =============
  521. # Offer methods
  522. # =============
  523. def clear_discount(self):
  524. """
  525. Remove any discounts from this line.
  526. """
  527. self._discount = Decimal('0.00')
  528. self._affected_quantity = 0
  529. def discount(self, discount_value, affected_quantity):
  530. """
  531. Apply a discount
  532. """
  533. self._discount += discount_value
  534. self._affected_quantity += int(affected_quantity)
  535. def consume(self, quantity):
  536. """
  537. Mark all or part of the line as 'consumed'
  538. Consumed items are no longer available to be used in offers.
  539. """
  540. if quantity > self.quantity - self._affected_quantity:
  541. inc = self.quantity - self._affected_quantity
  542. else:
  543. inc = quantity
  544. self._affected_quantity += int(inc)
  545. def get_price_breakdown(self):
  546. """
  547. Return a breakdown of line prices after discounts have been applied.
  548. Returns a list of (unit_price_incl_tx, unit_price_excl_tax, quantity)
  549. tuples.
  550. """
  551. prices = []
  552. if not self.has_discount:
  553. prices.append((self.unit_price_incl_tax, self.unit_price_excl_tax,
  554. self.quantity))
  555. else:
  556. # Need to split the discount among the affected quantity
  557. # of products.
  558. item_incl_tax_discount = (
  559. self._discount / int(self._affected_quantity))
  560. item_excl_tax_discount = item_incl_tax_discount * self._tax_ratio
  561. prices.append((self.unit_price_incl_tax - item_incl_tax_discount,
  562. self.unit_price_excl_tax - item_excl_tax_discount,
  563. self._affected_quantity))
  564. if self.quantity_without_discount:
  565. prices.append((self.unit_price_incl_tax,
  566. self.unit_price_excl_tax,
  567. self.quantity_without_discount))
  568. return prices
  569. # =======
  570. # Helpers
  571. # =======
  572. @property
  573. def _tax_ratio(self):
  574. if not self.unit_price_incl_tax:
  575. return 0
  576. return self.unit_price_excl_tax / self.unit_price_incl_tax
  577. # ==========
  578. # Properties
  579. # ==========
  580. @property
  581. def has_discount(self):
  582. return self.quantity > self.quantity_without_discount
  583. @property
  584. def quantity_with_discount(self):
  585. return self._affected_quantity
  586. @property
  587. def quantity_without_discount(self):
  588. return int(self.quantity - self._affected_quantity)
  589. @property
  590. def is_available_for_discount(self):
  591. return self.quantity_without_discount > 0
  592. @property
  593. def discount_value(self):
  594. return self._discount
  595. @property
  596. def stockinfo(self):
  597. """
  598. Return the stock/price info
  599. """
  600. if not hasattr(self, '_info'):
  601. # Cache the stockinfo (note that a strategy instance is assigned to
  602. # each line by the basket in the all_lines method).
  603. self._info = self.strategy.fetch(
  604. self.product, self.stockrecord)
  605. return self._info
  606. @property
  607. def is_tax_known(self):
  608. if not hasattr(self, 'strategy'):
  609. return False
  610. return self.stockinfo.price.is_tax_known
  611. @property
  612. def unit_price_excl_tax(self):
  613. return self.stockinfo.price.excl_tax
  614. @property
  615. def unit_price_incl_tax(self):
  616. return self.stockinfo.price.incl_tax
  617. @property
  618. def unit_tax(self):
  619. return self.stockinfo.price.tax
  620. @property
  621. def line_price_excl_tax(self):
  622. return self.quantity * self.unit_price_excl_tax
  623. @property
  624. def line_price_excl_tax_and_discounts(self):
  625. if self.is_tax_known and self._discount:
  626. # Scale discount down appropriately
  627. discount_excl_tax = self._discount * self._tax_ratio
  628. return self.line_price_excl_tax - discount_excl_tax
  629. return self.line_price_excl_tax
  630. @property
  631. def line_tax(self):
  632. return self.quantity * self.unit_tax
  633. @property
  634. def line_price_incl_tax(self):
  635. return self.quantity * self.unit_price_incl_tax
  636. @property
  637. def line_price_incl_tax_and_discounts(self):
  638. # Discount is implicitly assumed to include tax
  639. return self.line_price_incl_tax - self._discount
  640. @property
  641. def description(self):
  642. d = str(self.product)
  643. ops = []
  644. for attribute in self.attributes.all():
  645. ops.append("%s = '%s'" % (attribute.option.name, attribute.value))
  646. if ops:
  647. d = "%s (%s)" % (d.decode('utf-8'), ", ".join(ops))
  648. return d
  649. def get_warning(self):
  650. """
  651. Return a warning message about this basket line if one is applicable
  652. This could be things like the price has changed
  653. """
  654. if not self.stockrecord:
  655. msg = u"'%(product)s' is no longer available"
  656. return _(msg) % {'product': self.product.get_title()}
  657. if not self.price_incl_tax:
  658. return
  659. if not self.stockinfo.price.is_tax_known:
  660. return
  661. # Compare current price to price when added to basket
  662. current_price_incl_tax = self.stockinfo.price.incl_tax
  663. if current_price_incl_tax > self.price_incl_tax:
  664. msg = ("The price of '%(product)s' has increased from "
  665. "%(old_price)s to %(new_price)s since you added it "
  666. "to your basket")
  667. return _(msg) % {
  668. 'product': self.product.get_title(),
  669. 'old_price': currency(self.price_incl_tax),
  670. 'new_price': currency(current_price_incl_tax)}
  671. if current_price_incl_tax < self.price_incl_tax:
  672. msg = ("The price of '%(product)s' has decreased from "
  673. "%(old_price)s to %(new_price)s since you added it "
  674. "to your basket")
  675. return _(msg) % {
  676. 'product': self.product.get_title(),
  677. 'old_price': currency(self.price_incl_tax),
  678. 'new_price': currency(current_price_incl_tax)}
  679. class AbstractLineAttribute(models.Model):
  680. """
  681. An attribute of a basket line
  682. """
  683. line = models.ForeignKey('basket.Line', related_name='attributes',
  684. verbose_name=_("Line"))
  685. option = models.ForeignKey('catalogue.Option', verbose_name=_("Option"))
  686. value = models.CharField(_("Value"), max_length=255)
  687. class Meta:
  688. abstract = True
  689. verbose_name = _('Line attribute')
  690. verbose_name_plural = _('Line attributes')