Skip to content

Commit

Permalink
add retire method to order class
Browse files Browse the repository at this point in the history
  • Loading branch information
adampalay committed Dec 4, 2014
1 parent 4a185eb commit 00999af
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 14 deletions.
8 changes: 8 additions & 0 deletions lms/djangoapps/shoppingcart/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,11 @@ class ReportException(Exception):

class ReportTypeDoesNotExistException(ReportException):
pass


class InvalidStatusToRetire(Exception):
pass


class UnexpectedOrderItemStatus(Exception):
pass
Empty file.
Empty file.
37 changes: 37 additions & 0 deletions lms/djangoapps/shoppingcart/management/commands/retire_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
Script for retiring order that went through cybersource but weren't
marked as "purchased" in the db
"""

from django.core.management.base import BaseCommand, CommandError
from shoppingcart.models import Order, OrderItem
from shoppingcart.exceptions import UnexpectedOrderItemStatus, InvalidStatusToRetire


class Command(BaseCommand):
"""
Retire orders that went through cybersource but weren't updated
appropriately in the db
"""
help = """
Retire orders that went through cybersource but weren't updated appropriately in the db.
Takes a file of orders to be retired, one order per line
"""

def handle(self, *args, **options):
"Execute the command"
if len(args) != 1:
raise CommandError("retire_order requires one argument: <orders file>")

with open(args[0]) as orders_file:
order_ids = [int(line.strip()) for line in orders_file.readlines()]

orders = Order.objects.filter(id__in=order_ids)

for order in orders:
try:
order.retire()
except (UnexpectedOrderItemStatus, InvalidStatusToRetire) as err:
print "Did not retire order {order}: {message}".format(
order = order.id, message = err.message
)
64 changes: 56 additions & 8 deletions lms/djangoapps/shoppingcart/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,17 @@
from verify_student.models import SoftwareSecurePhotoVerification

from .exceptions import (
InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException,
AlreadyEnrolledInCourseException, CourseDoesNotExistException,
MultipleCouponsNotAllowedException, RegCodeAlreadyExistException,
ItemDoesNotExistAgainstRegCodeException, ItemNotAllowedToRedeemRegCodeException
InvalidCartItem,
PurchasedCallbackException,
ItemAlreadyInCartException,
AlreadyEnrolledInCourseException,
CourseDoesNotExistException,
MultipleCouponsNotAllowedException,
RegCodeAlreadyExistException,
ItemDoesNotExistAgainstRegCodeException,
ItemNotAllowedToRedeemRegCodeException,
InvalidStatusToRetire,
UnexpectedOrderItemStatus,
)

from microsite_configuration import microsite
Expand All @@ -64,16 +71,20 @@
('refunded', 'refunded'),

# The user's order went through, but the order was erroneously left
# in 'cart'. This state can only be arrived at through manual database
# intervention.
# in 'cart'.
('defunct-cart', 'defunct-cart'),

# The user's order went through, but the order was erroneously left
# in 'paying'. This state can only be arrived at through manual database
# intervention.
# in 'paying'.
('defunct-paying', 'defunct-paying'),
)

# maps order statuses to their defunct states
ORDER_STATUS_MAP = {
'cart': 'defunct-cart',
'paying': 'defunct-paying',
}

# we need a tuple to represent the primary key of various OrderItem subclasses
OrderItemSubclassPK = namedtuple('OrderItemSubclassPK', ['cls', 'pk']) # pylint: disable=invalid-name

Expand Down Expand Up @@ -485,6 +496,39 @@ def generate_receipt_instructions(self):
instruction_set.update(set_of_html)
return instruction_dict, instruction_set

def retire(self):
"""
Method to "retire" orders that have gone through to the payment service
but have (erroneously) not had their statuses updated.
This method only works on orders that satisfy the following conditions:
1) the order status is either "cart" or "paying" (otherwise we raise
an InvalidStatusToRetire error)
2) the order's order item's statuses match the order's status (otherwise
we throw an UnexpectedOrderItemStatus error)
"""
# if an order is already retired, no-op:
if self.status in {"defunct-cart", "defunct-paying"}:
return

if self.status not in {"cart", "paying"}:
raise InvalidStatusToRetire(
"order status {order_status} is not 'paying' or 'cart'".format(
order_status = self.status
)
)

for item in self.orderitem_set.all():
if item.status != self.status:
raise UnexpectedOrderItemStatus(
"order_item status is different from order status"
)

self.status = ORDER_STATUS_MAP[self.status]
self.save()

for item in self.orderitem_set.all():
item.retire()


class OrderItem(TimeStampedModel):
"""
Expand Down Expand Up @@ -617,6 +661,10 @@ def analytics_data(self):
'category': 'N/A',
}

def retire(self):
self.status = ORDER_STATUS_MAP[self.status]
self.save()


class Invoice(models.Model):
"""
Expand Down
76 changes: 70 additions & 6 deletions lms/djangoapps/shoppingcart/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from mock import patch, MagicMock
import pytz
import ddt
from django.core import mail
from django.conf import settings
from django.db import DatabaseError
Expand All @@ -28,8 +29,14 @@
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from course_modes.models import CourseMode
from shoppingcart.exceptions import (PurchasedCallbackException, CourseDoesNotExistException,
ItemAlreadyInCartException, AlreadyEnrolledInCourseException)
from shoppingcart.exceptions import (
PurchasedCallbackException,
CourseDoesNotExistException,
ItemAlreadyInCartException,
AlreadyEnrolledInCourseException,
InvalidStatusToRetire,
UnexpectedOrderItemStatus,
)

from opaque_keys.edx.locator import CourseLocator

Expand All @@ -39,6 +46,7 @@


@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@ddt.ddt
class OrderTest(ModuleStoreTestCase):
def setUp(self):
self.user = UserFactory.create()
Expand Down Expand Up @@ -153,6 +161,62 @@ def test_start_purchase(self):
for item in cart.orderitem_set.all():
self.assertEqual(item.status, 'purchased')

def test_retire_order_cart(self):
"""Test that an order in cart can successfully be retired"""
cart = Order.get_cart_for_user(user=self.user)
item = CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor', currency='usd')

cart.retire()
self.assertEqual(cart.status, 'defunct-cart')
self.assertEqual(cart.orderitem_set.get().status, 'defunct-cart')

def test_retire_order_paying(self):
"""Test that an order in "paying" can successfully be retired"""
cart = Order.get_cart_for_user(user=self.user)
item = CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor', currency='usd')
cart.start_purchase()

cart.retire()
self.assertEqual(cart.status, 'defunct-paying')
self.assertEqual(cart.orderitem_set.get().status, 'defunct-paying')

@ddt.data(
("cart", "paying", UnexpectedOrderItemStatus),
("purchased", "purchased", InvalidStatusToRetire),
)
@ddt.unpack
def test_retire_order_error(self, order_status, item_status, exception):
"""
Test error cases for retiring an order:
1) Order item has a different status than the order
2) The order's status isn't in "cart" or "paying"
"""
cart = Order.get_cart_for_user(user=self.user)
item = CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor', currency='usd')

cart.status = order_status
cart.save()
item.status = item_status
item.save()

with self.assertRaises(exception):
cart.retire()

@ddt.data('defunct-paying', 'defunct-cart')
def test_retire_order_already_retired(self, status):
"""
Check that orders that have already been retired noop when the method
is called on them again.
"""
cart = Order.get_cart_for_user(user=self.user)
item = CertificateItem.add_to_order(cart, self.course_key, self.cost, 'honor', currency='usd')
cart.status = item.status = status
cart.save()
item.save()
cart.retire()
self.assertEqual(cart.status, status)
self.assertEqual(item.status, status)

@override_settings(
SEGMENT_IO_LMS_KEY="foobar",
FEATURES={
Expand Down Expand Up @@ -291,20 +355,20 @@ def test_billing_info_storage_off(self, render):
((_, context), _) = render.call_args
self.assertFalse(context['has_billing_info'])

mock_gen_inst = MagicMock(return_value=(OrderItemSubclassPK(OrderItem, 1), set([])))

def test_generate_receipt_instructions_callchain(self):
"""
This tests the generate_receipt_instructions call chain (ie calling the function on the
cart also calls it on items in the cart
"""
mock_gen_inst = MagicMock(return_value=(OrderItemSubclassPK(OrderItem, 1), set([])))

cart = Order.get_cart_for_user(self.user)
item = OrderItem(user=self.user, order=cart)
item.save()
self.assertTrue(cart.has_items())
with patch.object(OrderItem, 'generate_receipt_instructions', self.mock_gen_inst):
with patch.object(OrderItem, 'generate_receipt_instructions', mock_gen_inst):
cart.generate_receipt_instructions()
self.mock_gen_inst.assert_called_with()
mock_gen_inst.assert_called_with()


class OrderItemTest(TestCase):
Expand Down

0 comments on commit 00999af

Please sign in to comment.