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.

__init__.py 22KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. from decimal import Decimal as D
  2. from http import client as http_client
  3. from unittest import mock
  4. from django.urls import reverse
  5. from oscar.apps.shipping import methods
  6. from oscar.core.loading import get_class, get_classes, get_model
  7. from oscar.test import factories
  8. Basket = get_model('basket', 'Basket')
  9. ConditionalOffer = get_model('offer', 'ConditionalOffer')
  10. Order = get_model('order', 'Order')
  11. FailedPreCondition = get_class('checkout.exceptions', 'FailedPreCondition')
  12. GatewayForm = get_class('checkout.forms', 'GatewayForm')
  13. UnableToPlaceOrder = get_class('order.exceptions', 'UnableToPlaceOrder')
  14. RedirectRequired, UnableToTakePayment, PaymentError = get_classes(
  15. 'payment.exceptions', ['RedirectRequired', 'UnableToTakePayment', 'PaymentError'])
  16. NoShippingRequired = get_class('shipping.methods', 'NoShippingRequired')
  17. class CheckoutMixin(object):
  18. def create_digital_product(self):
  19. product_class = factories.ProductClassFactory(
  20. requires_shipping=False, track_stock=False)
  21. product = factories.ProductFactory(product_class=product_class)
  22. factories.StockRecordFactory(
  23. num_in_stock=None, price=D('12.00'), product=product)
  24. return product
  25. def add_product_to_basket(self, product=None, **kwargs):
  26. if product is None:
  27. product = factories.ProductFactory()
  28. factories.StockRecordFactory(
  29. num_in_stock=10, price=D('12.00'), product=product)
  30. detail_page = self.get(product.get_absolute_url(), user=kwargs.get('logged_in_user', self.user))
  31. form = detail_page.forms['add_to_basket_form']
  32. form.submit()
  33. def add_voucher_to_basket(self, voucher=None):
  34. if voucher is None:
  35. voucher = factories.create_voucher()
  36. basket_page = self.get(reverse('basket:summary'))
  37. form = basket_page.forms['voucher_form']
  38. form['code'] = voucher.code
  39. form.submit()
  40. def enter_guest_details(self, email='guest@example.com'):
  41. index_page = self.get(reverse('checkout:index'))
  42. if index_page.status_code == 200:
  43. index_page.form['username'] = email
  44. index_page.form.select('options', GatewayForm.GUEST)
  45. index_page.form.submit()
  46. def create_shipping_country(self):
  47. return factories.CountryFactory(
  48. iso_3166_1_a2='GB', is_shipping_country=True)
  49. def enter_shipping_address(self):
  50. self.create_shipping_country()
  51. address_page = self.get(reverse('checkout:shipping-address'))
  52. if address_page.status_code == 200:
  53. form = address_page.forms['new_shipping_address']
  54. form['first_name'] = 'John'
  55. form['last_name'] = 'Doe'
  56. form['line1'] = '1 Egg Road'
  57. form['line4'] = 'Shell City'
  58. form['postcode'] = 'N12 9RT'
  59. form.submit()
  60. def enter_shipping_method(self):
  61. self.get(reverse('checkout:shipping-method'))
  62. def place_order(self):
  63. payment_details = self.get(
  64. reverse('checkout:shipping-method')).follow().follow()
  65. preview = payment_details.click(linkid="view_preview")
  66. return preview.forms['place_order_form'].submit().follow()
  67. def reach_payment_details_page(self):
  68. self.add_product_to_basket()
  69. if self.is_anonymous:
  70. self.enter_guest_details('hello@egg.com')
  71. self.enter_shipping_address()
  72. return self.get(
  73. reverse('checkout:shipping-method')).follow().follow()
  74. def ready_to_place_an_order(self):
  75. payment_details = self.reach_payment_details_page()
  76. return payment_details.click(linkid="view_preview")
  77. class IndexViewPreConditionsMixin:
  78. view_name = None
  79. # Disable skip conditions, so that we do not first get redirected forwards
  80. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_payment_is_required')
  81. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_basket_requires_shipping')
  82. def test_check_basket_is_not_empty(
  83. self,
  84. mock_skip_unless_basket_requires_shipping,
  85. mock_skip_unless_payment_is_required,
  86. ):
  87. response = self.get(reverse(self.view_name))
  88. self.assertRedirectsTo(response, 'basket:summary')
  89. # Disable skip conditions, so that we do not first get redirected forwards
  90. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_payment_is_required')
  91. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_basket_requires_shipping')
  92. def test_check_basket_is_valid(
  93. self,
  94. mock_skip_unless_basket_requires_shipping,
  95. mock_skip_unless_payment_is_required,
  96. ):
  97. # Add product to basket but then remove its stock so it is not
  98. # purchasable.
  99. product = factories.ProductFactory()
  100. self.add_product_to_basket(product)
  101. product.stockrecords.all().update(num_in_stock=0)
  102. if self.is_anonymous:
  103. self.enter_guest_details()
  104. response = self.get(reverse(self.view_name))
  105. self.assertRedirectsTo(response, 'basket:summary')
  106. class ShippingAddressViewSkipConditionsMixin:
  107. view_name = None
  108. next_view_name = None
  109. def test_skip_unless_basket_requires_shipping(self):
  110. product = self.create_digital_product()
  111. self.add_product_to_basket(product)
  112. if self.is_anonymous:
  113. self.enter_guest_details()
  114. response = self.get(reverse(self.view_name))
  115. self.assertRedirectsTo(response, self.next_view_name)
  116. class ShippingAddressViewPreConditionsMixin(IndexViewPreConditionsMixin):
  117. view_name = None
  118. # Disable skip conditions, so that we do not first get redirected forwards
  119. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_payment_is_required')
  120. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_basket_requires_shipping')
  121. def test_check_user_email_is_captured(
  122. self,
  123. mock_skip_unless_basket_requires_shipping,
  124. mock_skip_unless_payment_is_required,
  125. ):
  126. if self.is_anonymous:
  127. self.add_product_to_basket()
  128. response = self.get(reverse(self.view_name))
  129. self.assertRedirectsTo(response, 'checkout:index')
  130. class ShippingAddressViewMixin(ShippingAddressViewSkipConditionsMixin, ShippingAddressViewPreConditionsMixin):
  131. def test_submitting_valid_form_adds_data_to_session(self):
  132. self.add_product_to_basket()
  133. if self.is_anonymous:
  134. self.enter_guest_details()
  135. self.create_shipping_country()
  136. page = self.get(reverse('checkout:shipping-address'))
  137. form = page.forms['new_shipping_address']
  138. form['first_name'] = 'Barry'
  139. form['last_name'] = 'Chuckle'
  140. form['line1'] = '1 King Street'
  141. form['line4'] = 'Gotham City'
  142. form['postcode'] = 'N1 7RR'
  143. response = form.submit()
  144. self.assertRedirectsTo(response, 'checkout:shipping-method')
  145. session_data = self.app.session['checkout_data']
  146. session_fields = session_data['shipping']['new_address_fields']
  147. self.assertEqual('Barry', session_fields['first_name'])
  148. self.assertEqual('Chuckle', session_fields['last_name'])
  149. self.assertEqual('1 King Street', session_fields['line1'])
  150. self.assertEqual('Gotham City', session_fields['line4'])
  151. self.assertEqual('N1 7RR', session_fields['postcode'])
  152. def test_shows_initial_data_if_the_form_has_already_been_submitted(self):
  153. self.add_product_to_basket()
  154. if self.is_anonymous:
  155. self.enter_guest_details()
  156. self.enter_shipping_address()
  157. page = self.get(reverse('checkout:shipping-address'), user=self.user)
  158. form = page.forms['new_shipping_address']
  159. self.assertEqual('John', form['first_name'].value)
  160. self.assertEqual('Doe', form['last_name'].value)
  161. self.assertEqual('1 Egg Road', form['line1'].value)
  162. self.assertEqual('Shell City', form['line4'].value)
  163. self.assertEqual('N12 9RT', form['postcode'].value)
  164. class ShippingMethodViewSkipConditionsMixin:
  165. view_name = None
  166. next_view_name = None
  167. def test_skip_unless_basket_requires_shipping(self):
  168. # This skip condition is not a "normal" one, but is implemented in the
  169. # view's "get" method
  170. product = self.create_digital_product()
  171. self.add_product_to_basket(product)
  172. if self.is_anonymous:
  173. self.enter_guest_details()
  174. response = self.get(reverse(self.view_name))
  175. self.assertRedirectsTo(response, self.next_view_name)
  176. self.assertEqual(self.app.session['checkout_data']['shipping']['method_code'], NoShippingRequired.code)
  177. @mock.patch('oscar.apps.checkout.views.Repository')
  178. def test_skip_if_single_shipping_method_is_available(self, mock_repo):
  179. # This skip condition is not a "normal" one, but is implemented in the
  180. # view's "get" method
  181. self.add_product_to_basket()
  182. if self.is_anonymous:
  183. self.enter_guest_details()
  184. self.enter_shipping_address()
  185. # Ensure one shipping method available
  186. instance = mock_repo.return_value
  187. instance.get_shipping_methods.return_value = [methods.Free()]
  188. response = self.get(reverse('checkout:shipping-method'))
  189. self.assertRedirectsTo(response, 'checkout:payment-method')
  190. class ShippingMethodViewPreConditionsMixin(ShippingAddressViewPreConditionsMixin):
  191. view_name = None
  192. # Disable skip conditions, so that we do not first get redirected forwards
  193. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_payment_is_required')
  194. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_basket_requires_shipping')
  195. @mock.patch('oscar.apps.checkout.views.Repository')
  196. def test_check_shipping_methods_are_available(
  197. self,
  198. mock_repo,
  199. mock_skip_unless_basket_requires_shipping,
  200. mock_skip_unless_payment_is_required,
  201. ):
  202. # This pre condition is not a "normal" one, but is implemented in the
  203. # view's "get" method
  204. self.add_product_to_basket()
  205. if self.is_anonymous:
  206. self.enter_guest_details()
  207. self.enter_shipping_address()
  208. # Ensure no shipping methods available
  209. instance = mock_repo.return_value
  210. instance.get_shipping_methods.return_value = []
  211. response = self.get(reverse('checkout:shipping-method'))
  212. self.assertRedirectsTo(response, 'checkout:shipping-address')
  213. # Disable skip conditions, so that we do not first get redirected forwards
  214. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_payment_is_required')
  215. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_basket_requires_shipping')
  216. def test_check_shipping_data_is_captured(
  217. self,
  218. mock_skip_unless_basket_requires_shipping,
  219. mock_skip_unless_payment_is_required,
  220. ):
  221. # This pre condition is not a "normal" one, but is implemented in the
  222. # view's "get" method
  223. self.add_product_to_basket()
  224. if self.is_anonymous:
  225. self.enter_guest_details()
  226. response = self.get(reverse(self.view_name))
  227. self.assertRedirectsTo(response, 'checkout:shipping-address')
  228. class ShippingMethodViewMixin(ShippingMethodViewSkipConditionsMixin, ShippingMethodViewPreConditionsMixin):
  229. @mock.patch('oscar.apps.checkout.views.Repository')
  230. def test_shows_form_when_multiple_shipping_methods_available(self, mock_repo):
  231. self.add_product_to_basket()
  232. if self.is_anonymous:
  233. self.enter_guest_details()
  234. self.enter_shipping_address()
  235. # Ensure multiple shipping methods available
  236. method = mock.MagicMock()
  237. method.code = 'm'
  238. instance = mock_repo.return_value
  239. instance.get_shipping_methods.return_value = [methods.Free(), method]
  240. form_page = self.get(reverse('checkout:shipping-method'))
  241. self.assertIsOk(form_page)
  242. response = form_page.forms[0].submit()
  243. self.assertRedirectsTo(response, 'checkout:payment-method')
  244. # Disable skip conditions, so that we do not first get redirected forwards
  245. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_payment_is_required')
  246. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_basket_requires_shipping')
  247. @mock.patch('oscar.apps.checkout.views.Repository')
  248. def test_check_user_can_submit_only_valid_shipping_method(
  249. self,
  250. mock_repo,
  251. mock_skip_unless_basket_requires_shipping,
  252. mock_skip_unless_payment_is_required,
  253. ):
  254. self.add_product_to_basket()
  255. if self.is_anonymous:
  256. self.enter_guest_details()
  257. self.enter_shipping_address()
  258. method = mock.MagicMock()
  259. method.code = 'm'
  260. instance = mock_repo.return_value
  261. instance.get_shipping_methods.return_value = [methods.Free(), method]
  262. form_page = self.get(reverse('checkout:shipping-method'))
  263. # a malicious attempt?
  264. form_page.forms[0]['method_code'].value = 'super-free-shipping'
  265. response = form_page.forms[0].submit()
  266. self.assertIsNotRedirect(response)
  267. response.mustcontain('Your submitted shipping method is not permitted')
  268. class PaymentMethodViewSkipConditionsMixin:
  269. @mock.patch('oscar.apps.checkout.session.SurchargeApplicator.get_surcharges')
  270. def test_skip_unless_payment_is_required(self, mock_get_surcharges):
  271. mock_get_surcharges.return_value = []
  272. product = factories.create_product(price=D('0.00'), num_in_stock=100)
  273. self.add_product_to_basket(product)
  274. if self.is_anonymous:
  275. self.enter_guest_details()
  276. self.enter_shipping_address()
  277. # The shipping method is set automatically, as there is only one (free)
  278. # available
  279. response = self.get(reverse('checkout:payment-method'))
  280. self.assertRedirectsTo(response, 'checkout:preview')
  281. class PaymentMethodViewPreConditionsMixin(ShippingMethodViewPreConditionsMixin):
  282. view_name = None
  283. # Disable skip conditions, so that we do not first get redirected forwards
  284. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_payment_is_required')
  285. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_basket_requires_shipping')
  286. def test_check_shipping_data_is_captured(
  287. self,
  288. mock_skip_unless_basket_requires_shipping,
  289. mock_skip_unless_payment_is_required,
  290. ):
  291. super().test_check_shipping_data_is_captured()
  292. self.enter_shipping_address()
  293. response = self.get(reverse(self.view_name))
  294. self.assertRedirectsTo(response, 'checkout:shipping-method')
  295. class PaymentMethodViewMixin(PaymentMethodViewSkipConditionsMixin, PaymentMethodViewPreConditionsMixin):
  296. pass
  297. class PaymentDetailsViewSkipConditionsMixin:
  298. @mock.patch('oscar.apps.checkout.session.SurchargeApplicator.get_surcharges')
  299. def test_skip_unless_payment_is_required(self, mock_get_surcharges):
  300. mock_get_surcharges.return_value = []
  301. product = factories.create_product(price=D('0.00'), num_in_stock=100)
  302. self.add_product_to_basket(product)
  303. if self.is_anonymous:
  304. self.enter_guest_details()
  305. self.enter_shipping_address()
  306. # The shipping method is set automatically, as there is only one (free)
  307. # available
  308. response = self.get(reverse('checkout:payment-details'))
  309. self.assertRedirectsTo(response, 'checkout:preview')
  310. class PaymentDetailsViewPreConditionsMixin(PaymentMethodViewPreConditionsMixin):
  311. """
  312. Does not add any new pre conditions.
  313. """
  314. class PaymentDetailsViewMixin(PaymentDetailsViewSkipConditionsMixin, PaymentDetailsViewPreConditionsMixin):
  315. @mock.patch('oscar.apps.checkout.views.PaymentDetailsView.handle_payment')
  316. def test_redirects_customers_when_using_bank_gateway(self, mock_method):
  317. bank_url = 'https://bank-website.com'
  318. e = RedirectRequired(url=bank_url)
  319. mock_method.side_effect = e
  320. preview = self.ready_to_place_an_order()
  321. bank_redirect = preview.forms['place_order_form'].submit()
  322. assert bank_redirect.status_code == 302
  323. assert bank_redirect.url == bank_url
  324. @mock.patch('oscar.apps.checkout.views.PaymentDetailsView.handle_payment')
  325. def test_handles_anticipated_payments_errors_gracefully(self, mock_method):
  326. msg = 'Submitted expiration date is wrong'
  327. e = UnableToTakePayment(msg)
  328. mock_method.side_effect = e
  329. preview = self.ready_to_place_an_order()
  330. response = preview.forms['place_order_form'].submit()
  331. self.assertIsOk(response)
  332. # check user is warned
  333. response.mustcontain(msg)
  334. # check basket is restored
  335. basket = Basket.objects.get()
  336. self.assertEqual(basket.status, Basket.OPEN)
  337. @mock.patch('oscar.apps.checkout.views.logger')
  338. @mock.patch('oscar.apps.checkout.views.PaymentDetailsView.handle_payment')
  339. def test_handles_unexpected_payment_errors_gracefully(
  340. self, mock_method, mock_logger):
  341. msg = 'This gateway is down for maintenance'
  342. e = PaymentError(msg)
  343. mock_method.side_effect = e
  344. preview = self.ready_to_place_an_order()
  345. response = preview.forms['place_order_form'].submit()
  346. self.assertIsOk(response)
  347. # check user is warned with a generic error
  348. response.mustcontain(
  349. 'A problem occurred while processing payment for this order',
  350. no=[msg])
  351. # admin should be warned
  352. self.assertTrue(mock_logger.error.called)
  353. # check basket is restored
  354. basket = Basket.objects.get()
  355. self.assertEqual(basket.status, Basket.OPEN)
  356. @mock.patch('oscar.apps.checkout.views.logger')
  357. @mock.patch('oscar.apps.checkout.views.PaymentDetailsView.handle_payment')
  358. def test_handles_bad_errors_during_payments(
  359. self, mock_method, mock_logger):
  360. e = Exception()
  361. mock_method.side_effect = e
  362. preview = self.ready_to_place_an_order()
  363. response = preview.forms['place_order_form'].submit()
  364. self.assertIsOk(response)
  365. self.assertTrue(mock_logger.exception.called)
  366. basket = Basket.objects.get()
  367. self.assertEqual(basket.status, Basket.OPEN)
  368. @mock.patch('oscar.apps.checkout.views.logger')
  369. @mock.patch('oscar.apps.checkout.views.PaymentDetailsView.handle_order_placement')
  370. def test_handles_unexpected_order_placement_errors_gracefully(
  371. self, mock_method, mock_logger):
  372. e = UnableToPlaceOrder()
  373. mock_method.side_effect = e
  374. preview = self.ready_to_place_an_order()
  375. response = preview.forms['place_order_form'].submit()
  376. self.assertIsOk(response)
  377. self.assertTrue(mock_logger.error.called)
  378. basket = Basket.objects.get()
  379. self.assertEqual(basket.status, Basket.OPEN)
  380. @mock.patch('oscar.apps.checkout.views.logger')
  381. @mock.patch('oscar.apps.checkout.views.PaymentDetailsView.handle_order_placement')
  382. def test_handles_all_other_exceptions_gracefully(self, mock_method, mock_logger):
  383. mock_method.side_effect = Exception()
  384. preview = self.ready_to_place_an_order()
  385. response = preview.forms['place_order_form'].submit()
  386. self.assertIsOk(response)
  387. self.assertTrue(mock_logger.exception.called)
  388. basket = Basket.objects.get()
  389. self.assertEqual(basket.status, Basket.OPEN)
  390. class PaymentDetailsPreviewViewPreConditionsMixin(PaymentDetailsViewPreConditionsMixin):
  391. # Disable skip conditions, so that we do not first get redirected forwards
  392. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_payment_is_required')
  393. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.skip_unless_basket_requires_shipping')
  394. @mock.patch('oscar.apps.checkout.session.CheckoutSessionMixin.check_payment_data_is_captured')
  395. def test_check_payment_data_is_captured(
  396. self,
  397. mock_check_payment_data_is_captured,
  398. mock_skip_unless_basket_requires_shipping,
  399. mock_skip_unless_payment_is_required,
  400. ):
  401. mock_check_payment_data_is_captured.side_effect = FailedPreCondition(url=reverse('checkout:payment-details'))
  402. response = self.ready_to_place_an_order()
  403. self.assertRedirectsTo(response, 'checkout:payment-details')
  404. class PaymentDetailsPreviewViewMixin(PaymentDetailsPreviewViewPreConditionsMixin):
  405. def test_allows_order_to_be_placed(self):
  406. self.add_product_to_basket()
  407. if self.is_anonymous:
  408. self.enter_guest_details()
  409. self.enter_shipping_address()
  410. payment_details = self.get(
  411. reverse('checkout:shipping-method')).follow().follow()
  412. preview = payment_details.click(linkid="view_preview")
  413. preview.forms['place_order_form'].submit().follow()
  414. self.assertEqual(1, Order.objects.all().count())
  415. def test_payment_form_being_submitted_from_payment_details_view(self):
  416. payment_details = self.reach_payment_details_page()
  417. preview = payment_details.forms['sensible_data'].submit()
  418. self.assertEqual(0, Order.objects.all().count())
  419. preview.form.submit().follow()
  420. self.assertEqual(1, Order.objects.all().count())
  421. def test_handles_invalid_payment_forms(self):
  422. payment_details = self.reach_payment_details_page()
  423. form = payment_details.forms['sensible_data']
  424. # payment forms should use the preview URL not the payment details URL
  425. form.action = reverse('checkout:payment-details')
  426. self.assertEqual(form.submit(status="*").status_code, http_client.BAD_REQUEST)
  427. def test_placing_an_order_using_a_voucher_records_use(self):
  428. self.add_product_to_basket()
  429. self.add_voucher_to_basket()
  430. if self.is_anonymous:
  431. self.enter_guest_details()
  432. self.enter_shipping_address()
  433. thankyou = self.place_order()
  434. order = thankyou.context['order']
  435. self.assertEqual(1, order.discounts.all().count())
  436. discount = order.discounts.all()[0]
  437. voucher = discount.voucher
  438. self.assertEqual(1, voucher.num_orders)
  439. def test_placing_an_order_using_an_offer_records_use(self):
  440. offer = factories.create_offer()
  441. self.add_product_to_basket()
  442. if self.is_anonymous:
  443. self.enter_guest_details()
  444. self.enter_shipping_address()
  445. self.place_order()
  446. # Reload offer
  447. offer = ConditionalOffer.objects.get(id=offer.id)
  448. self.assertEqual(1, offer.num_orders)
  449. self.assertEqual(1, offer.num_applications)