Commit db2ea567 by Clinton Blackburn

Merge pull request #99 from edx/refund-api

Added refund creation endpoint
parents 23b8f7f3 4170fc00
"""Exceptions and error messages used by the ecommerce API."""
from django.utils.translation import ugettext_lazy as _
from rest_framework import status
from rest_framework.exceptions import APIException
PRODUCT_OBJECTS_MISSING_DEVELOPER_MESSAGE = u"No product objects could be found in the request body"
......@@ -23,3 +25,7 @@ class ApiError(Exception):
class ProductNotFoundError(ApiError):
"""Raised when the provided SKU does not correspond to a product in the catalog."""
pass
class BadRequestException(APIException):
status_code = status.HTTP_400_BAD_REQUEST
from rest_framework.permissions import BasePermission
class CanActForUser(BasePermission):
"""
Allows access only if the user has permission to perform operations for the user represented by the username field
in request.data.
"""
def has_permission(self, request, view):
username = request.data.get('username')
if not username:
return False
user = request.user
return user and (user.is_superuser or user.username == username)
from django.test import TestCase
from oscar.test.newfactories import UserFactory
from rest_framework.request import Request
from rest_framework.test import APIRequestFactory, force_authenticate
from ecommerce.extensions.api.permissions import CanActForUser
class CanActForUserTests(TestCase):
permissions_class = CanActForUser()
def _get_request(self, data=None, user=None):
request = APIRequestFactory().post('/', data)
if user:
force_authenticate(request, user=user)
return Request(request)
def test_has_permission_no_data(self):
""" If no username is supplied with the request data, return False. """
request = self._get_request()
self.assertFalse(self.permissions_class.has_permission(request, None))
def test_has_permission_superuser(self):
""" Return True if request.user is a superuser. """
user = UserFactory(is_superuser=True)
# Data is required, even if you're a superuser.
request = self._get_request(user=user)
self.assertFalse(self.permissions_class.has_permission(request, None))
# Superusers can create their own refunds
request = self._get_request(data={'username': user.username}, user=user)
self.assertTrue(self.permissions_class.has_permission(request, None))
# Superusers can create refunds for other users
request = self._get_request(data={'username': 'other_guy'}, user=user)
self.assertTrue(self.permissions_class.has_permission(request, None))
def test_has_permission_same_user(self):
""" If the request.data['username'] matches request.user, return True. """
user = UserFactory()
# Normal users can create their own refunds
request = self._get_request(data={'username': user.username}, user=user)
self.assertTrue(self.permissions_class.has_permission(request, None))
# Normal users CANNOT create refunds for other users
request = self._get_request(data={'username': 'other_guy'}, user=user)
self.assertFalse(self.permissions_class.has_permission(request, None))
......@@ -28,12 +28,16 @@ from ecommerce.extensions.fulfillment.status import LINE, ORDER
from ecommerce.extensions.payment import exceptions as payment_exceptions
from ecommerce.extensions.payment.processors import Cybersource
from ecommerce.extensions.payment.tests.processors import DummyProcessor, AnotherDummyProcessor
from ecommerce.tests.mixins import UserMixin, ThrottlingMixin, BasketCreationMixin
from ecommerce.extensions.refund.tests.factories import RefundLineFactory
from ecommerce.extensions.refund.tests.test_api import RefundTestMixin
from ecommerce.tests.mixins import UserMixin, ThrottlingMixin, BasketCreationMixin, JwtMixin
Basket = get_model('basket', 'Basket')
Order = get_model('order', 'Order')
ShippingEventType = get_model('order', 'ShippingEventType')
Refund = get_model('refund', 'Refund')
JSON_CONTENT_TYPE = 'application/json'
@ddt.ddt
......@@ -151,7 +155,7 @@ class BasketCreateViewTests(BasketCreationMixin, ThrottlingMixin, TestCase):
response = self.client.post(
self.PATH,
data=json.dumps(request_data),
content_type='application/json',
content_type=JSON_CONTENT_TYPE,
HTTP_AUTHORIZATION='JWT ' + self.generate_token(self.USER_DATA)
)
self.assertEqual(response.status_code, 400)
......@@ -347,7 +351,7 @@ class OrderListViewTests(AccessTokenMixin, ThrottlingMixin, UserMixin, TestCase)
class OrderFulfillViewTests(UserMixin, TestCase):
def setUp(self):
super(OrderFulfillViewTests, self).setUp()
ShippingEventType.objects.create(name=FulfillmentMixin.SHIPPING_EVENT_NAME)
ShippingEventType.objects.get_or_create(name=FulfillmentMixin.SHIPPING_EVENT_NAME)
self.user = self.create_user(is_superuser=True)
self.client.login(username=self.user.username, password=self.password)
......@@ -462,3 +466,161 @@ class PaymentProcessorListViewTests(TestCase, UserMixin):
def test_get_many(self):
"""Ensure multiple processors in settings are handled correctly."""
self.assert_processor_list_matches([DummyProcessor.NAME, AnotherDummyProcessor.NAME])
class RefundCreateViewTests(RefundTestMixin, AccessTokenMixin, JwtMixin, UserMixin, TestCase):
path = reverse('api:v2:refunds:create')
def setUp(self):
super(RefundCreateViewTests, self).setUp()
self.course_id = 'edX/DemoX/Demo_Course'
self.user = self.create_user()
self.client.login(username=self.user.username, password=self.password)
def assert_bad_request_response(self, response, detail):
""" Assert the response has status code 406 and the appropriate detail message. """
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
data = json.loads(response.content)
self.assertEqual(data, {'detail': detail})
def assert_ok_response(self, response):
""" Assert the response has HTTP status 200 and no data. """
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content), [])
def _get_data(self, username=None, course_id=None):
data = {}
if username:
data['username'] = username
if course_id:
data['course_id'] = course_id
return json.dumps(data)
def test_no_orders(self):
""" If the user has no orders, no refund IDs should be returned. HTTP status should be 200. """
self.assertFalse(self.user.orders.exists())
data = self._get_data(self.user.username, self.course_id)
response = self.client.post(self.path, data, JSON_CONTENT_TYPE)
self.assert_ok_response(response)
def test_missing_data(self):
"""
If course_id is missing from the POST body, return HTTP 400
"""
data = self._get_data(self.user.username)
response = self.client.post(self.path, data, JSON_CONTENT_TYPE)
self.assert_bad_request_response(response, 'No course_id specified.')
def test_user_not_found(self):
"""
If no user matching the username is found, return HTTP 400.
"""
superuser = self.create_user(is_superuser=True)
self.client.login(username=superuser.username, password=self.password)
username = 'fakey-userson'
data = self._get_data(username, self.course_id)
response = self.client.post(self.path, data, JSON_CONTENT_TYPE)
self.assert_bad_request_response(response, 'User "{}" does not exist.'.format(username))
def test_authentication_required(self):
""" Clients MUST be authenticated. """
self.client.logout()
data = self._get_data(self.user.username, self.course_id)
response = self.client.post(self.path, data, JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_jwt_authentication(self):
""" Client can authenticate with JWT. """
self.client.logout()
data = self._get_data(self.user.username, self.course_id)
auth_header = 'JWT ' + self.generate_token({'username': self.user.username})
response = self.client.post(self.path, data, JSON_CONTENT_TYPE, HTTP_AUTHORIZATION=auth_header)
self.assert_ok_response(response)
@httpretty.activate
@override_settings(OAUTH2_PROVIDER_URL=OAUTH2_PROVIDER_URL)
def test_oauth_authentication(self):
""" Client can authenticate with OAuth. """
self.client.logout()
data = self._get_data(self.user.username, self.course_id)
auth_header = 'Bearer ' + self.DEFAULT_TOKEN
self._mock_access_token_response(username=self.user.username)
response = self.client.post(self.path, data, JSON_CONTENT_TYPE, HTTP_AUTHORIZATION=auth_header)
self.assert_ok_response(response)
def test_session_authentication(self):
""" Client can authenticate with a Django session. """
self.client.logout()
self.client.login(username=self.user.username, password=self.password)
data = self._get_data(self.user.username, self.course_id)
response = self.client.post(self.path, data, JSON_CONTENT_TYPE)
self.assert_ok_response(response)
def test_authorization(self):
""" Client must be authenticated as the user matching the username field or a superuser. """
# A normal user CANNOT create refunds for other users.
self.client.login(username=self.user.username, password=self.password)
data = self._get_data('not-me', self.course_id)
response = self.client.post(self.path, data, JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# A superuser can create refunds for everyone.
superuser = self.create_user(is_superuser=True)
self.client.login(username=superuser.username, password=self.password)
data = self._get_data(self.user.username, self.course_id)
response = self.client.post(self.path, data, JSON_CONTENT_TYPE)
self.assert_ok_response(response)
def test_valid_order(self):
"""
View should create a refund if an order/line are found eligible for refund.
"""
order = self.create_order()
self.assertFalse(Refund.objects.exists())
data = self._get_data(self.user.username, self.course_id)
response = self.client.post(self.path, data, JSON_CONTENT_TYPE)
refund = Refund.objects.latest()
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content), [refund.id])
self.assert_refund_matches_order(refund, order)
# A second call should result in no additional refunds being created
response = self.client.post(self.path, data, JSON_CONTENT_TYPE)
self.assert_ok_response(response)
def test_refunded_line(self):
"""
View should NOT create a refund if an order/line is found, and has an existing refund.
"""
order = self.create_order()
Refund.objects.all().delete()
RefundLineFactory(order_line=order.lines.first())
self.assertEqual(Refund.objects.count(), 1)
data = self._get_data(self.user.username, self.course_id)
response = self.client.post(self.path, data, JSON_CONTENT_TYPE)
self.assert_ok_response(response)
self.assertEqual(Refund.objects.count(), 1)
def test_non_course_order(self):
""" Refunds should NOT be created for orders with no line items related to courses. """
Refund.objects.all().delete()
factories.create_order(user=self.user)
self.assertEqual(Refund.objects.count(), 0)
data = self._get_data(self.user.username, self.course_id)
response = self.client.post(self.path, data, JSON_CONTENT_TYPE)
self.assert_ok_response(response)
self.assertEqual(Refund.objects.count(), 0)
......@@ -37,9 +37,15 @@ PAYMENT_URLS = patterns(
url(r'^processors/$', cache_page(60 * 30)(views.PaymentProcessorListView.as_view()), name='list_processors'),
)
REFUND_URLS = patterns(
'',
url(r'^$', views.RefundCreateView.as_view(), name='create'),
)
urlpatterns = patterns(
'',
url(r'^baskets/', include(BASKET_URLS, namespace='baskets')),
url(r'^orders/', include(ORDER_URLS, namespace='orders')),
url(r'^payment/', include(PAYMENT_URLS, namespace='payment')),
url(r'^refunds/', include(REFUND_URLS, namespace='refunds')),
)
......@@ -2,6 +2,7 @@
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from oscar.core.loading import get_model
from rest_framework import status
from rest_framework.generics import CreateAPIView, RetrieveAPIView, ListAPIView, UpdateAPIView
......@@ -10,17 +11,20 @@ from rest_framework.response import Response
from ecommerce.extensions.api import data, exceptions as api_exceptions, serializers
from ecommerce.extensions.api.constants import APIConstants as AC
# noinspection PyUnresolvedReferences
from ecommerce.extensions.api.exceptions import BadRequestException
from ecommerce.extensions.api.permissions import CanActForUser
from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin
from ecommerce.extensions.fulfillment.mixins import FulfillmentMixin
from ecommerce.extensions.payment import exceptions as payment_exceptions
from ecommerce.extensions.payment.helpers import (get_processor_class, get_default_processor_class,
get_processor_class_by_name)
from ecommerce.extensions.refund.api import find_orders_associated_with_course, create_refunds
logger = logging.getLogger(__name__)
Order = get_model('order', 'Order')
User = get_user_model()
class BasketCreateView(EdxOrderPlacementMixin, CreateAPIView):
......@@ -376,3 +380,58 @@ class PaymentProcessorListView(ListAPIView):
def get_queryset(self):
"""Fetch the list of payment processor classes based on Django settings."""
return [get_processor_class(path) for path in settings.PAYMENT_PROCESSORS]
class RefundCreateView(CreateAPIView):
"""
Creates refunds.
Given a username and course ID, this view finds and creates a refund for each order
matching the following criteria:
* Order was placed by the User linked to username.
* Order is in the COMPLETE state.
* Order has at least one line item associated with the course ID.
Note that only the line items associated with the course ID will be refunded.
Items associated with a different course ID, or not associated with any course ID, will NOT be refunded.
With the exception of superusers, users may only create refunds for themselves.
Attempts to create refunds for other users will fail with HTTP 403.
If refunds are created, a list of the refund IDs will be returned along with HTTP 201.
If no refunds are created, HTTP 200 will be returned.
"""
permission_classes = (IsAuthenticated, CanActForUser)
def create(self, request, *args, **kwargs):
""" Creates refunds, if eligible orders exist. """
course_id = request.data.get('course_id')
username = request.data.get('username')
if not course_id:
raise BadRequestException('No course_id specified.')
# We should always have a username value as long as CanActForUser is in place.
if not username: # pragma: no cover
raise BadRequestException('No username specified.')
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
raise BadRequestException('User "{}" does not exist.'.format(username))
refunds = []
# We can only create refunds if the user has orders.
if user.orders.exists():
orders = find_orders_associated_with_course(user, course_id)
refunds = create_refunds(orders, course_id)
# Return HTTP 201 if we created refunds.
if refunds:
refund_ids = [refund.id for refund in refunds]
return Response(refund_ids, status=status.HTTP_201_CREATED)
# Return HTTP 200 if we did NOT create refunds.
return Response([], status=status.HTTP_200_OK)
from django.contrib import admin
from oscar.core.loading import get_model
Refund = get_model('refund', 'Refund')
class RefundAdmin(admin.ModelAdmin):
list_display = ('id', 'order', 'user', 'status', 'total_credit_excl_tax', 'currency')
list_filter = ('status',)
admin.site.register(Refund, RefundAdmin)
from django.conf import settings
from oscar.core.loading import get_model
from ecommerce.extensions.fulfillment.status import ORDER
from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
Refund = get_model('refund', 'Refund')
RefundLine = get_model('refund', 'RefundLine')
def find_orders_associated_with_course(user, course_id):
"""
Returns a list of orders associated with the given user and course.
Arguments:
user (User): user who purchased the order(s)
course_id (str): Identifier of the course associated with the order(s)
Raises:
ValueError if course_id is invalid.
Returns:
list: orders associated with the course
"""
# Validate the course_id
if not course_id or not course_id.strip():
raise ValueError('"{}" is not a valid course ID.'.format(course_id))
# If the user has no orders, we cannot possibly return a list of orders eligible for refund.
if not user.orders.exists():
return []
# Find all complete orders associated with the course.
orders = user.orders.filter(status=ORDER.COMPLETE,
lines__product__attribute_values__attribute__code='course_key',
lines__product__attribute_values__value_text=course_id)
return list(orders)
def create_refunds(orders, course_id):
"""
Creates refunds for the given list of orders.
Arguments:
orders (list): orders for which refunds should be created
course_id (str): Identifier of the course associated with the order line(s)
Returns:
list: refunds created
"""
refunds = []
for order in orders:
# Find lines associated with the course and not refunded.
lines = order.lines.filter(refund_lines__id__isnull=True,
product__attribute_values__attribute__code='course_key',
product__attribute_values__value_text=course_id)
# Only create a refund if there are line items to refund.
if lines:
total_credit_excl_tax = sum([line.line_price_excl_tax for line in lines])
status = getattr(settings, 'OSCAR_INITIAL_REFUND_STATUS', REFUND.OPEN)
refund = Refund.objects.create(order=order, user=order.user, status=status,
total_credit_excl_tax=total_credit_excl_tax)
status = getattr(settings, 'OSCAR_INITIAL_REFUND_LINE_STATUS', REFUND_LINE.OPEN)
for line in lines:
RefundLine.objects.create(refund=refund, order_line=line, line_credit_excl_tax=line.line_price_excl_tax,
quantity=line.quantity, status=status)
refunds.append(refund)
return refunds
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import oscar.core.utils
import django.utils.timezone
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
('refund', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='refund',
options={'ordering': ('-modified', '-created'), 'get_latest_by': 'modified'},
),
migrations.AlterModelOptions(
name='refundline',
options={'ordering': ('-modified', '-created'), 'get_latest_by': 'modified'},
),
migrations.AddField(
model_name='historicalrefund',
name='created',
field=django_extensions.db.fields.CreationDateTimeField(default=django.utils.timezone.now, verbose_name='created', editable=False, blank=True),
preserve_default=True,
),
migrations.AddField(
model_name='historicalrefund',
name='currency',
field=models.CharField(default=oscar.core.utils.get_default_currency, max_length=12, verbose_name='Currency'),
preserve_default=True,
),
migrations.AddField(
model_name='historicalrefund',
name='modified',
field=django_extensions.db.fields.ModificationDateTimeField(default=django.utils.timezone.now, verbose_name='modified', editable=False, blank=True),
preserve_default=True,
),
migrations.AddField(
model_name='historicalrefundline',
name='created',
field=django_extensions.db.fields.CreationDateTimeField(default=django.utils.timezone.now, verbose_name='created', editable=False, blank=True),
preserve_default=True,
),
migrations.AddField(
model_name='historicalrefundline',
name='modified',
field=django_extensions.db.fields.ModificationDateTimeField(default=django.utils.timezone.now, verbose_name='modified', editable=False, blank=True),
preserve_default=True,
),
migrations.AddField(
model_name='refund',
name='created',
field=django_extensions.db.fields.CreationDateTimeField(default=django.utils.timezone.now, verbose_name='created', editable=False, blank=True),
preserve_default=True,
),
migrations.AddField(
model_name='refund',
name='currency',
field=models.CharField(default=oscar.core.utils.get_default_currency, max_length=12, verbose_name='Currency'),
preserve_default=True,
),
migrations.AddField(
model_name='refund',
name='modified',
field=django_extensions.db.fields.ModificationDateTimeField(default=django.utils.timezone.now, verbose_name='modified', editable=False, blank=True),
preserve_default=True,
),
migrations.AddField(
model_name='refundline',
name='created',
field=django_extensions.db.fields.CreationDateTimeField(default=django.utils.timezone.now, verbose_name='created', editable=False, blank=True),
preserve_default=True,
),
migrations.AddField(
model_name='refundline',
name='modified',
field=django_extensions.db.fields.ModificationDateTimeField(default=django.utils.timezone.now, verbose_name='modified', editable=False, blank=True),
preserve_default=True,
),
migrations.AlterField(
model_name='refund',
name='order',
field=models.ForeignKey(related_name='refunds', verbose_name='Order', to='order.Order'),
preserve_default=True,
),
]
from django.conf import settings
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from oscar.core.utils import get_default_currency
from simple_history.models import HistoricalRecords
from ecommerce.extensions.refund.exceptions import InvalidStatus
......@@ -39,18 +41,19 @@ class StatusMixin(object):
self.save()
class Refund(StatusMixin, models.Model):
class Refund(StatusMixin, TimeStampedModel):
"""Main refund model, used to represent the state of a refund."""
order = models.ForeignKey('order.Order', related_name='refund', verbose_name=_('Order'))
order = models.ForeignKey('order.Order', related_name='refunds', verbose_name=_('Order'))
user = models.ForeignKey('user.User', related_name='refunds', verbose_name=_('User'))
total_credit_excl_tax = models.DecimalField(_('Total Credit (excl. tax)'), decimal_places=2, max_digits=12)
currency = models.CharField(_("Currency"), max_length=12, default=get_default_currency)
status = models.CharField(_('Status'), max_length=255)
history = HistoricalRecords()
pipeline_setting = 'OSCAR_REFUND_STATUS_PIPELINE'
class RefundLine(StatusMixin, models.Model):
class RefundLine(StatusMixin, TimeStampedModel):
"""A refund line, used to represent the state of a single item as part of a larger Refund."""
refund = models.ForeignKey('refund.Refund', related_name='lines', verbose_name=_('Refund'))
order_line = models.ForeignKey('order.Line', related_name='refund_lines', verbose_name=_('Order Line'))
......
from decimal import Decimal
from django.conf import settings
from django.conf import settings
from django.utils.text import slugify
import factory
from oscar.core.loading import get_model
from oscar.test import factories
......@@ -8,6 +9,12 @@ from oscar.test.newfactories import UserFactory
from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
Category = get_model("catalogue", "Category")
Partner = get_model('partner', 'Partner')
Product = get_model("catalogue", "Product")
ProductAttribute = get_model("catalogue", "ProductAttribute")
ProductClass = get_model("catalogue", "ProductClass")
class RefundFactory(factory.DjangoModelFactory):
status = getattr(settings, 'OSCAR_INITIAL_REFUND_STATUS', REFUND.OPEN)
......@@ -34,3 +41,55 @@ class RefundLineFactory(factory.DjangoModelFactory):
class Meta(object):
model = get_model('refund', 'RefundLine')
class CourseFactory(object):
def __init__(self, course_id, course_name):
self.course_name = course_name
self.course_id = course_id
self.modes = {}
self.partner, _created = Partner.objects.get_or_create(name='edX')
def _get_parent_seat_product(self):
seat, created = ProductClass.objects.get_or_create(slug='seat',
defaults={'track_stock': False, 'requires_shipping': False,
'name': 'Seat'})
if created:
ProductAttribute.objects.create(product_class=seat, name='course_key', code='course_key', type='text',
required=True)
ProductAttribute.objects.create(product_class=seat, name='id_verification_required',
code='id_verification_required', type='boolean', required=False)
ProductAttribute.objects.create(product_class=seat, name='certificate_type', code='certificate_type',
type='text', required=False)
slug = slugify(self.course_name)
title = u'Seat in {}'.format(self.course_name)
parent_product, created = Product.objects.get_or_create(product_class=seat, slug=slug, structure='parent',
defaults={'title': title})
if created:
parent_product.attr.course_key = self.course_id
parent_product.save()
return parent_product
def add_mode(self, name, price, id_verification_required=False):
parent_product = self._get_parent_seat_product()
title = u'{mode_name} Seat in {course_name}'.format(mode_name=name, course_name=self.course_name)
slug = slugify(u'{course_name}-seat-{mode_name}'.format(course_name=self.course_name, mode_name=name))
child_product, created = Product.objects.get_or_create(parent=parent_product, title=title, slug=slug,
structure='child')
if created:
child_product.attr.course_key = self.course_id
child_product.attr.certificate_type = name
child_product.attr.id_verification_required = id_verification_required
child_product.save()
child_product.stockrecords.create(partner=self.partner, partner_sku=slug, num_in_stock=None,
price_currency=settings.OSCAR_DEFAULT_CURRENCY, price_excl_tax=price)
self.modes[name] = child_product
return child_product
# coding=utf-8
from decimal import Decimal
from unittest import TestCase
import ddt
from django.conf import settings
from django.test import override_settings
from oscar.core.loading import get_model
from oscar.test.factories import create_order
from oscar.test.newfactories import UserFactory, BasketFactory
from ecommerce.extensions.fulfillment.status import ORDER
from ecommerce.extensions.refund.api import find_orders_associated_with_course, create_refunds
from ecommerce.extensions.refund.tests.factories import CourseFactory, RefundLineFactory
ProductAttribute = get_model("catalogue", "ProductAttribute")
ProductClass = get_model("catalogue", "ProductClass")
Refund = get_model('refund', 'Refund')
OSCAR_INITIAL_REFUND_STATUS = 'REFUND_OPEN'
OSCAR_INITIAL_REFUND_LINE_STATUS = 'REFUND_LINE_OPEN'
class RefundTestMixin(object):
def setUp(self):
self.course_id = u'edX/DemoX/Demo_Course'
self.course = CourseFactory(self.course_id, u'edX Demó Course')
self.honor_product = self.course.add_mode('honor', 0)
self.verified_product = self.course.add_mode('verified', Decimal(10.00), id_verification_required=True)
def create_order(self, user=None):
user = user or self.user
basket = BasketFactory(owner=user)
basket.add_product(self.verified_product)
order = create_order(basket=basket, user=user)
order.status = ORDER.COMPLETE
order.save()
return order
def assert_refund_matches_order(self, refund, order):
""" Verify the refund corresponds to the given order. """
self.assertEqual(refund.order, order)
self.assertEqual(refund.user, order.user)
self.assertEqual(refund.status, settings.OSCAR_INITIAL_REFUND_STATUS)
self.assertEqual(refund.total_credit_excl_tax, order.total_excl_tax)
self.assertEqual(refund.lines.count(), 1)
refund_line = refund.lines.first()
line = order.lines.first()
self.assertEqual(refund_line.status, settings.OSCAR_INITIAL_REFUND_LINE_STATUS)
self.assertEqual(refund_line.order_line, line)
self.assertEqual(refund_line.line_credit_excl_tax, line.line_price_excl_tax)
self.assertEqual(refund_line.quantity, 1)
@ddt.ddt
class ApiTests(RefundTestMixin, TestCase):
def setUp(self):
super(ApiTests, self).setUp()
self.user = UserFactory()
def test_find_orders_associated_with_course(self):
"""
Ideal scenario: user has completed orders related to the course, and the verification close date has not passed.
"""
order = self.create_order()
self.assertTrue(self.user.orders.exists())
actual = find_orders_associated_with_course(self.user, self.course_id)
self.assertEqual(actual, [order])
@ddt.data('', ' ', None)
def test_find_orders_associated_with_course_invalid_course_id(self, course_id):
""" ValueError should be raised if course_id is invalid. """
self.assertRaises(ValueError, find_orders_associated_with_course, self.user, course_id)
def test_find_orders_associated_with_course_no_orders(self):
""" An empty list should be returned if the user has never placed an order. """
self.assertFalse(self.user.orders.exists())
actual = find_orders_associated_with_course(self.user, self.course_id)
self.assertEqual(actual, [])
@ddt.data(ORDER.OPEN, ORDER.FULFILLMENT_ERROR)
def test_find_orders_associated_with_course_no_completed_orders(self, status):
""" An empty list should be returned if the user has no completed orders. """
order = self.create_order()
order.status = status
order.save()
actual = find_orders_associated_with_course(self.user, self.course_id)
self.assertEqual(actual, [])
# TODO Implement this when we begin storing the verification close date.
# def test_create_refunds_verification_closed(self):
# """ No refunds should be created if the verification close date has passed. """
# self.fail()
@override_settings(OSCAR_INITIAL_REFUND_STATUS=OSCAR_INITIAL_REFUND_STATUS,
OSCAR_INITIAL_REFUND_LINE_STATUS=OSCAR_INITIAL_REFUND_LINE_STATUS)
def test_create_refunds(self):
""" The method should create refunds for orders/lines that have not been refunded. """
order = self.create_order()
actual = create_refunds([order], self.course_id)
refund = Refund.objects.get(order=order)
self.assertEqual(actual, [refund])
self.assert_refund_matches_order(refund, order)
def test_create_refunds_with_existing_refund(self):
""" The method should NOT create refunds for lines that have already been refunded. """
order = self.create_order()
RefundLineFactory(order_line=order.lines.first())
actual = create_refunds([order], self.course_id)
self.assertEqual(actual, [])
......@@ -46,11 +46,21 @@ class ThrottlingMixin(object):
self.addCleanup(cache.clear)
class BasketCreationMixin(object):
class JwtMixin(object):
""" Mixin with JWT-related helper functions. """
JWT_SECRET_KEY = getattr(settings, 'JWT_AUTH')['JWT_SECRET_KEY']
def generate_token(self, payload, secret=None):
"""Generate a JWT token with the provided payload."""
secret = secret or self.JWT_SECRET_KEY
token = jwt.encode(payload, secret)
return token
class BasketCreationMixin(JwtMixin):
"""Provides utility methods for creating baskets in test cases."""
PATH = reverse('api:v2:baskets:create')
SHIPPING_EVENT_NAME = FulfillmentMixin.SHIPPING_EVENT_NAME
JWT_SECRET_KEY = getattr(settings, 'JWT_AUTH')['JWT_SECRET_KEY']
FREE_SKU = u'𝑭𝑹𝑬𝑬-𝑷𝑹𝑶𝑫𝑼𝑪𝑻'
USER_DATA = {
'username': 'sgoodman',
......@@ -79,12 +89,6 @@ class BasketCreationMixin(object):
stockrecords__price_excl_tax=D('0.00'),
)
def generate_token(self, payload, secret=None):
"""Generate a JWT token with the provided payload."""
secret = secret or self.JWT_SECRET_KEY
token = jwt.encode(payload, secret)
return token
def create_basket(self, skus=None, checkout=None, payment_processor_name=None, auth=True, token=None):
"""Issue a POST request to the basket creation endpoint."""
request_data = {}
......@@ -121,7 +125,7 @@ class BasketCreationMixin(object):
):
"""Verify that basket creation succeeded."""
# Ideally, we'd use Oscar's ShippingEventTypeFactory here, but it's not exposed/public.
ShippingEventType.objects.create(name=self.SHIPPING_EVENT_NAME)
ShippingEventType.objects.get_or_create(name=self.SHIPPING_EVENT_NAME)
response = self.create_basket(skus=skus, checkout=checkout, payment_processor_name=payment_processor_name)
......
......@@ -2,6 +2,7 @@ analytics-python==1.0.3
Django==1.7.7
django-appconf==0.6
django_compressor==1.4
django_extensions==1.5.5
django-libsass==0.2
django-model-utils==1.5.0
django-oscar==1.0.2
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment