Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

We’re showing branches in this repository, but you can also compare across forks.

base fork: django-oscar/django-oscar
...
head fork: django-oscar/django-oscar
  • 10 commits
  • 28 files changed
  • 1 commit comment
  • 1 contributor
Showing with 514 additions and 281 deletions.
  1. +8 −3 oscar/apps/catalogue/abstract_models.py
  2. +3 −88 oscar/apps/checkout/mixins.py
  3. +87 −0 oscar/apps/checkout/session.py
  4. +2 −2 oscar/apps/checkout/views.py
  5. +26 −1 oscar/apps/dashboard/catalogue/forms.py
  6. +32 −12 oscar/apps/dashboard/catalogue/views.py
  7. +1 −1  oscar/apps/dashboard/reviews/forms.py
  8. +2 −6 oscar/apps/offer/models.py
  9. +0 −12 oscar/apps/offer/receivers.py
  10. +1 −0  oscar/apps/offer/utils.py
  11. +15 −6 oscar/apps/order/utils.py
  12. +17 −6 oscar/apps/voucher/abstract_models.py
  13. +0 −2  oscar/apps/voucher/models.py
  14. +1 −5 oscar/templates/checkout/thank_you.html
  15. +1 −1  oscar/templates/dashboard/catalogue/category_list.html
  16. +7 −1 oscar/templates/dashboard/catalogue/product_update.html
  17. +1 −1  oscar/templates/dashboard/reports/partials/open_basket_report.html
  18. +1 −1  oscar/templates/dashboard/reports/partials/submitted_basket_report.html
  19. +1 −1  oscar/templates/dashboard/vouchers/voucher_detail.html
  20. +8 −5 oscar/test/__init__.py
  21. +9 −5 runtests.py
  22. +2 −2 tests/config.py
  23. +25 −17 tests/functional/checkout_tests.py
  24. +0 −96 tests/functional/dashboard/catalogue_tests.py
  25. +98 −0 tests/functional/dashboard/category_tests.py
  26. +103 −0 tests/functional/dashboard/product_tests.py
  27. 0  tests/unit/dashboard/{cataogue_form_tests.py → catalogue_form_tests.py}
  28. +63 −7 tests/unit/voucher_tests.py
11 oscar/apps/catalogue/abstract_models.py
View
@@ -152,7 +152,10 @@ class Meta:
verbose_name_plural = _('Categories')
def has_children(self):
- return self.get_children().count() > 0
+ return self.get_num_children() > 0
+
+ def get_num_children(self):
+ return self.get_children().count()
class AbstractProductCategory(models.Model):
@@ -302,8 +305,10 @@ def options(self):
@property
def is_top_level(self):
- u"""Return True if this is a parent product"""
- return self.parent_id == None
+ """
+ Test if this product is a parent (who may or may not have children)
+ """
+ return self.parent_id is None
@property
def is_group(self):
91 oscar/apps/checkout/mixins.py
View
@@ -6,108 +6,23 @@
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import get_model
-from oscar.apps.shipping.methods import Free
-from oscar.core.loading import get_class, get_classes
-ShippingAddressForm, GatewayForm = get_classes('checkout.forms', ['ShippingAddressForm', 'GatewayForm'])
-OrderTotalCalculator = get_class('checkout.calculators', 'OrderTotalCalculator')
-CheckoutSessionData = get_class('checkout.utils', 'CheckoutSessionData')
-pre_payment, post_payment = get_classes('checkout.signals', ['pre_payment', 'post_payment'])
-OrderNumberGenerator, OrderCreator = get_classes('order.utils', ['OrderNumberGenerator', 'OrderCreator'])
-UserAddressForm = get_class('address.forms', 'UserAddressForm')
-Repository = get_class('shipping.repository', 'Repository')
-AccountAuthView = get_class('customer.views', 'AccountAuthView')
+from oscar.core.loading import get_class
+OrderCreator = get_class('order.utils', 'OrderCreator')
Dispatcher = get_class('customer.utils', 'Dispatcher')
-RedirectRequired, UnableToTakePayment, PaymentError = get_classes(
- 'payment.exceptions', ['RedirectRequired', 'UnableToTakePayment', 'PaymentError'])
-UnableToPlaceOrder = get_class('order.exceptions', 'UnableToPlaceOrder')
+CheckoutSessionMixin = get_class('checkout.session', 'CheckoutSessionMixin')
-Order = get_model('order', 'Order')
ShippingAddress = get_model('order', 'ShippingAddress')
CommunicationEvent = get_model('order', 'CommunicationEvent')
PaymentEventType = get_model('order', 'PaymentEventType')
PaymentEvent = get_model('order', 'PaymentEvent')
UserAddress = get_model('address', 'UserAddress')
Basket = get_model('basket', 'Basket')
-Email = get_model('customer', 'Email')
CommunicationEventType = get_model('customer', 'CommunicationEventType')
# Standard logger for checkout events
logger = logging.getLogger('oscar.checkout')
-class CheckoutSessionMixin(object):
- """
- Mixin to provide common functionality shared between checkout views.
- """
-
- def dispatch(self, request, *args, **kwargs):
- self.checkout_session = CheckoutSessionData(request)
- return super(CheckoutSessionMixin, self).dispatch(request, *args, **kwargs)
-
- def get_shipping_address(self):
- """
- Return the current shipping address for this checkout session.
-
- This could either be a ShippingAddress model which has been
- pre-populated (not saved), or a UserAddress model which will
- need converting into a ShippingAddress model at submission
- """
- addr_data = self.checkout_session.new_shipping_address_fields()
- if addr_data:
- # Load address data into a blank address model
- return ShippingAddress(**addr_data)
- addr_id = self.checkout_session.user_address_id()
- if addr_id:
- try:
- return UserAddress._default_manager.get(pk=addr_id)
- except UserAddress.DoesNotExist:
- # This can happen if you reset all your tables and you still have
- # session data that refers to addresses that no longer exist
- pass
- return None
-
- def get_shipping_method(self, basket=None):
- method = self.checkout_session.shipping_method()
- if method:
- if not basket:
- basket = self.request.basket
- method.set_basket(basket)
- else:
- # We default to using free shipping
- method = Free()
- return method
-
- def get_order_totals(self, basket=None, shipping_method=None, **kwargs):
- """
- Returns the total for the order with and without tax (as a tuple)
- """
- calc = OrderTotalCalculator(self.request)
- if not basket:
- basket = self.request.basket
- if not shipping_method:
- shipping_method = self.get_shipping_method(basket)
- total_incl_tax = calc.order_total_incl_tax(basket, shipping_method, **kwargs)
- total_excl_tax = calc.order_total_excl_tax(basket, shipping_method, **kwargs)
- return total_incl_tax, total_excl_tax
-
- def get_context_data(self, **kwargs):
- """
- Assign common template variables to the context.
- """
- ctx = super(CheckoutSessionMixin, self).get_context_data(**kwargs)
- ctx['shipping_address'] = self.get_shipping_address()
-
- method = self.get_shipping_method()
- if method:
- ctx['shipping_method'] = method
- ctx['shipping_total_excl_tax'] = method.basket_charge_excl_tax()
- ctx['shipping_total_incl_tax'] = method.basket_charge_incl_tax()
-
- ctx['order_total_incl_tax'], ctx['order_total_excl_tax'] = self.get_order_totals()
-
- return ctx
-
-
class OrderPlacementMixin(CheckoutSessionMixin):
"""
Mixin which provides functionality for placing orders.
87 oscar/apps/checkout/session.py
View
@@ -0,0 +1,87 @@
+import logging
+
+from django.db.models import get_model
+
+from oscar.apps.shipping.methods import Free
+from oscar.core.loading import get_class
+OrderTotalCalculator = get_class('checkout.calculators', 'OrderTotalCalculator')
+CheckoutSessionData = get_class('checkout.utils', 'CheckoutSessionData')
+
+ShippingAddress = get_model('order', 'ShippingAddress')
+UserAddress = get_model('address', 'UserAddress')
+
+# Standard logger for checkout events
+logger = logging.getLogger('oscar.checkout')
+
+
+class CheckoutSessionMixin(object):
+ """
+ Mixin to provide common functionality shared between checkout views.
+ """
+
+ def dispatch(self, request, *args, **kwargs):
+ self.checkout_session = CheckoutSessionData(request)
+ return super(CheckoutSessionMixin, self).dispatch(request, *args, **kwargs)
+
+ def get_shipping_address(self):
+ """
+ Return the current shipping address for this checkout session.
+
+ This could either be a ShippingAddress model which has been
+ pre-populated (not saved), or a UserAddress model which will
+ need converting into a ShippingAddress model at submission
+ """
+ addr_data = self.checkout_session.new_shipping_address_fields()
+ if addr_data:
+ # Load address data into a blank address model
+ return ShippingAddress(**addr_data)
+ addr_id = self.checkout_session.user_address_id()
+ if addr_id:
+ try:
+ return UserAddress._default_manager.get(pk=addr_id)
+ except UserAddress.DoesNotExist:
+ # This can happen if you reset all your tables and you still have
+ # session data that refers to addresses that no longer exist
+ pass
+ return None
+
+ def get_shipping_method(self, basket=None):
+ method = self.checkout_session.shipping_method()
+ if method:
+ if not basket:
+ basket = self.request.basket
+ method.set_basket(basket)
+ else:
+ # We default to using free shipping
+ method = Free()
+ return method
+
+ def get_order_totals(self, basket=None, shipping_method=None, **kwargs):
+ """
+ Returns the total for the order with and without tax (as a tuple)
+ """
+ calc = OrderTotalCalculator(self.request)
+ if not basket:
+ basket = self.request.basket
+ if not shipping_method:
+ shipping_method = self.get_shipping_method(basket)
+ total_incl_tax = calc.order_total_incl_tax(basket, shipping_method, **kwargs)
+ total_excl_tax = calc.order_total_excl_tax(basket, shipping_method, **kwargs)
+ return total_incl_tax, total_excl_tax
+
+ def get_context_data(self, **kwargs):
+ """
+ Assign common template variables to the context.
+ """
+ ctx = super(CheckoutSessionMixin, self).get_context_data(**kwargs)
+ ctx['shipping_address'] = self.get_shipping_address()
+
+ method = self.get_shipping_method()
+ if method:
+ ctx['shipping_method'] = method
+ ctx['shipping_total_excl_tax'] = method.basket_charge_excl_tax()
+ ctx['shipping_total_incl_tax'] = method.basket_charge_incl_tax()
+
+ ctx['order_total_incl_tax'], ctx['order_total_excl_tax'] = self.get_order_totals()
+
+ return ctx
4 oscar/apps/checkout/views.py
View
@@ -23,8 +23,8 @@
RedirectRequired, UnableToTakePayment, PaymentError = get_classes(
'payment.exceptions', ['RedirectRequired', 'UnableToTakePayment', 'PaymentError'])
UnableToPlaceOrder = get_class('order.exceptions', 'UnableToPlaceOrder')
-CheckoutSessionMixin, OrderPlacementMixin = get_classes('checkout.mixins',
- ('CheckoutSessionMixin', 'OrderPlacementMixin'))
+OrderPlacementMixin = get_class('checkout.mixins', 'OrderPlacementMixin')
+CheckoutSessionMixin = get_class('checkout.session', 'CheckoutSessionMixin')
Order = get_model('order', 'Order')
ShippingAddress = get_model('order', 'ShippingAddress')
27 oscar/apps/dashboard/catalogue/forms.py
View
@@ -1,5 +1,5 @@
from django import forms
-from django.forms.models import inlineformset_factory
+from django.forms.models import inlineformset_factory, BaseInlineFormSet
from django.db.models import get_model
from django.template.defaultfilters import slugify
from django.utils.translation import ugettext_lazy as _
@@ -9,6 +9,7 @@
Product = get_model('catalogue', 'Product')
Category = get_model('catalogue', 'Category')
StockRecord = get_model('partner', 'StockRecord')
+Partner = get_model('partner', 'Partner')
ProductAttributeValue = get_model('catalogue', 'ProductAttributeValue')
ProductCategory = get_model('catalogue', 'ProductCategory')
ProductImage = get_model('catalogue', 'ProductImage')
@@ -63,6 +64,11 @@ class ProductSearchForm(forms.Form):
class StockRecordForm(forms.ModelForm):
+ partner = forms.ModelChoiceField(queryset=Partner.objects.all(),
+ required=False,
+ label=_("Partner"))
+ partner_sku = forms.CharField(required=False,
+ label=_("Partner SKU"))
class Meta:
model = StockRecord
@@ -196,8 +202,27 @@ class Meta:
model = ProductCategory
+class ProductCategoryFormSet(BaseInlineFormSet):
+
+ def clean(self):
+ if self.instance.is_top_level and self.get_num_categories() == 0:
+ raise forms.ValidationError(
+ _("A top-level product must have at least one category"))
+ if self.instance.is_variant and self.get_num_categories() > 0:
+ raise forms.ValidationError(
+ _("A variant product should not have at categories"))
+
+ def get_num_categories(self):
+ num_categories = 0
+ for i in range(0, self.total_form_count()):
+ form = self.forms[i]
+ if form.has_changed():
+ num_categories += 1
+ return num_categories
+
ProductCategoryFormSet = inlineformset_factory(Product, ProductCategory,
form=ProductCategoryForm,
+ formset=ProductCategoryFormSet,
fields=('category',), extra=1)
44 oscar/apps/dashboard/catalogue/views.py
View
@@ -68,7 +68,7 @@ class ProductCreateRedirectView(generic.RedirectView):
def get_redirect_url(self, **kwargs):
product_class_id = self.request.GET.get('product_class', None)
- if not product_class_id.isdigit():
+ if not product_class_id or not product_class_id.isdigit():
messages.error(self.request, _("Please choose a product class"))
return reverse('dashboard:catalogue-product-list')
try:
@@ -96,6 +96,7 @@ def get_context_data(self, **kwargs):
if 'image_formset' not in ctx:
ctx['image_formset'] = ProductImageFormSet()
ctx['title'] = _('Create new product')
+ ctx['product_class'] = self.get_product_class()
return ctx
def get_product_class(self):
@@ -106,10 +107,20 @@ def get_form_kwargs(self):
kwargs['product_class'] = self.get_product_class()
return kwargs
+ def is_stockrecord_submitted(self):
+ return len(self.request.POST.get('partner', '')) > 0
+
def form_invalid(self, form):
- stockrecord_form = StockRecordForm(self.request.POST)
+ if self.is_stockrecord_submitted():
+ stockrecord_form = StockRecordForm(self.request.POST)
+ else:
+ stockrecord_form = StockRecordForm()
category_formset = ProductCategoryFormSet(self.request.POST)
image_formset = ProductImageFormSet(self.request.POST, self.request.FILES)
+
+ messages.error(self.request,
+ _("Your submitted data was not valid - please "
+ "correct the below errors"))
ctx = self.get_context_data(form=form,
stockrecord_form=stockrecord_form,
category_formset=category_formset,
@@ -118,28 +129,37 @@ def form_invalid(self, form):
def form_valid(self, form):
product = form.save()
-
- stockrecord_form = StockRecordForm(self.request.POST)
category_formset = ProductCategoryFormSet(self.request.POST,
instance=product)
image_formset = ProductImageFormSet(self.request.POST,
self.request.FILES,
instance=product)
- if all([stockrecord_form.is_valid(), category_formset.is_valid(), image_formset.is_valid()]):
- # Save product
- product.save()
- # Save stock record
- stockrecord = stockrecord_form.save(commit=False)
- stockrecord.product = product
- stockrecord.save()
+ if self.is_stockrecord_submitted():
+ stockrecord_form = StockRecordForm(self.request.POST)
+ is_valid = all([stockrecord_form.is_valid(),
+ category_formset.is_valid(),
+ image_formset.is_valid()])
+ else:
+ stockrecord_form = StockRecordForm()
+ is_valid = all([category_formset.is_valid(),
+ image_formset.is_valid()])
+ if is_valid:
+ if self.is_stockrecord_submitted():
+ # Save stock record
+ stockrecord = stockrecord_form.save(commit=False)
+ stockrecord.product = product
+ stockrecord.save()
# Save formsets
category_formset.save()
image_formset.save()
return HttpResponseRedirect(self.get_success_url(product))
+ messages.error(self.request,
+ _("Your submitted data was not valid - please "
+ "correct the below errors"))
+
# Delete product as its relations were not valid
product.delete()
-
ctx = self.get_context_data(form=form,
stockrecord_form=stockrecord_form,
category_formset=category_formset,
2  oscar/apps/dashboard/reviews/forms.py
View
@@ -24,7 +24,7 @@ class ProductReviewSearchForm(forms.Form):
keyword = forms.CharField(required=False, label=_("Keyword"))
status = forms.ChoiceField(required=False, choices=STATUS_CHOICES,
label=_("Status"))
- date_from = forms.DateTimeField(required=False)
+ date_from = forms.DateTimeField(required=False, label=_("Date from"))
date_to = forms.DateTimeField(required=False, label=_('to'))
name = forms.CharField(required=False, label=_('Customer name'))
8 oscar/apps/offer/models.py
View
@@ -251,8 +251,8 @@ def __unicode__(self):
desc = _("%(value).2f discount on %(range)s") % {'value': float(self.value),
'range': unicode(self.range).lower()}
- max_item_str = ungettext(" (max %d item)", " (max %d items)", self.max_affected_items)
- desc += max_item_str % self.max_affected_items
+ if self.max_affected_items:
+ desc += ungettext(" (max 1 item)", " (max %d items)", self.max_affected_items) % self.max_affected_items
return desc
@@ -752,7 +752,3 @@ def apply(self, basket, condition=None):
else:
free_line.discount(discount, 0)
return self.round(discount)
-
-
-# We need to import receivers at the bottom of this script
-from oscar.apps.offer.receivers import receive_basket_voucher_change
12 oscar/apps/offer/receivers.py
View
@@ -16,15 +16,3 @@ def receive_basket_voucher_change(sender, **kwargs):
voucher = Voucher._default_manager.get(pk=voucher_id)
voucher.num_basket_additions += 1
voucher.save()
-
-
-@receiver(post_save, sender=OrderDiscount)
-def receive_order_discount_save(sender, instance, **kwargs):
- # Record the amount of discount against the appropriate offers
- # and vouchers
- discount = instance
- if discount.voucher:
- discount.voucher.total_discount += discount.amount
- discount.voucher.save()
- if discount.offer:
- discount.offer.record_usage(discount.amount)
1  oscar/apps/offer/utils.py
View
@@ -48,6 +48,7 @@ def apply(self, request, basket):
offers = self.get_offers(request, basket)
logger.debug("Found %d offers to apply to basket %d", len(offers), basket.id)
discounts = self.get_basket_discounts(basket, offers)
+
# Store this list of discounts with the basket so it can be
# rendered in templates
basket.set_discounts(list(discounts.values()))
21 oscar/apps/order/utils.py
View
@@ -69,8 +69,11 @@ def place_order(self, basket, total_incl_tax=None, total_excl_tax=None,
for line in basket.all_lines():
self.create_line_models(order, line)
self.update_stock_records(line)
+
for discount in basket.get_discounts():
self.create_discount_model(order, discount)
+ self.record_discount(discount)
+
for voucher in basket.vouchers.all():
self.record_voucher_usage(order, voucher, user)
@@ -78,6 +81,8 @@ def place_order(self, basket, total_incl_tax=None, total_excl_tax=None,
order_placed.send(sender=self, order=order, user=user)
return order
+
+
def create_order_model(self, user, basket, shipping_address, shipping_method,
billing_address, total_incl_tax, total_excl_tax,
@@ -200,17 +205,21 @@ def create_discount_model(self, order, discount):
Creates an order discount model for each discount attached to the basket.
"""
order_discount = OrderDiscount(order=order,
- offer_id=discount['offer'].id,
+ offer_id=discount['offer'].id,
amount=discount['discount'])
- if discount['voucher']:
- order_discount.voucher_id = discount['voucher'].id
- order_discount.voucher_code = discount['voucher'].code
+ voucher = discount.get('voucher', None)
+ if voucher:
+ order_discount.voucher_id = voucher.id
+ order_discount.voucher_code = voucher.code
order_discount.save()
+
+ def record_discount(self, discount):
+ discount['offer'].record_usage(discount['discount'])
+ if 'voucher' in discount:
+ discount['voucher'].record_discount(discount['discount'])
def record_voucher_usage(self, order, voucher, user):
"""
Updates the models that care about this voucher.
"""
voucher.record_usage(order, user)
- voucher.num_orders += 1
- voucher.save()
23 oscar/apps/voucher/abstract_models.py
View
@@ -8,7 +8,7 @@
class AbstractVoucher(models.Model):
"""
- A voucher. This is simply a link to a collection of offers
+ A voucher. This is simply a link to a collection of offers.
Note that there are three possible "usage" models:
(a) Single use
@@ -19,7 +19,7 @@ class AbstractVoucher(models.Model):
help_text=_("""This will be shown in the checkout and basket once the voucher is entered"""))
code = models.CharField(_("Code"), max_length=128, db_index=True, unique=True,
help_text=_("""Case insensitive / No spaces allowed"""))
- offers = models.ManyToManyField('offer.ConditionalOffer', related_name='vouchers',
+ offers = models.ManyToManyField('offer.ConditionalOffer', related_name='vouchers',
limit_choices_to={'offer_type': "Voucher"})
SINGLE_USE, MULTI_USE, ONCE_PER_CUSTOMER = ('Single use', 'Multi-use', 'Once per customer')
@@ -33,7 +33,7 @@ class AbstractVoucher(models.Model):
start_date = models.DateField(_('Start Date'))
end_date = models.DateField(_('End Date'))
- # Summary information
+ # Audit information
num_basket_additions = models.PositiveIntegerField(_('Times added to basket'), default=0)
num_orders = models.PositiveIntegerField(_('Times on orders'), default=0)
total_discount = models.DecimalField(_('Total discount'), decimal_places=2, max_digits=12, default=Decimal('0.00'))
@@ -59,7 +59,7 @@ def save(self, *args, **kwargs):
def is_active(self, test_date=None):
"""
- Tests whether this voucher is currently active.
+ Test whether this voucher is currently active.
"""
if not test_date:
test_date = datetime.date.today()
@@ -67,7 +67,7 @@ def is_active(self, test_date=None):
def is_available_to_user(self, user=None):
"""
- Tests whether this voucher is available to the passed user.
+ Test whether this voucher is available to the passed user.
Returns a tuple of a boolean for whether it is successulf, and a message
"""
@@ -96,6 +96,15 @@ def record_usage(self, order, user):
self.applications.create(voucher=self, order=order, user=user)
else:
self.applications.create(voucher=self, order=order)
+ self.num_orders += 1
+ self.save()
+
+ def record_discount(self, discount):
+ """
+ Record a discount that this offer has given
+ """
+ self.total_discount += discount
+ self.save()
@property
def benefit(self):
@@ -120,4 +129,6 @@ class Meta:
verbose_name_plural = _("Voucher Applications")
def __unicode__(self):
- return _("'%(voucher)s' used by '%(user)s'") % {'voucher': self.voucher, 'user': self.user}
+ return _("'%(voucher)s' used by '%(user)s'") % {
+ 'voucher': self.voucher,
+ 'user': self.user}
2  oscar/apps/voucher/models.py
View
@@ -7,5 +7,3 @@ class Voucher(AbstractVoucher):
class VoucherApplication(AbstractVoucherApplication):
pass
-
-
6 oscar/templates/checkout/thank_you.html
View
@@ -91,11 +91,7 @@ <h4 class="span11">{% trans "Basket total" %}</h4>
</div>
<div class="basket-items">
<div class="row-fluid">
- <h4 class="span11">
- {% blocktrans with order.shipping_method as shipping_method %}
- Shipping charge - {{ shipping_method }}
- {% endblocktrans %}
- </h4>
+ <h4 class="span11">{% blocktrans with method=order.shipping_method %}Shipping charge - {{ method }}{% endblocktrans %}</h4>
<div class="span1">{{ order.shipping_incl_tax|currency }}</div>
</div>
</div>
2  oscar/templates/dashboard/catalogue/category_list.html
View
@@ -48,7 +48,7 @@
<tr>
<td>{{ category.name }}</td>
<td>{{ category.description|default:""|truncatewords:6 }}</td>
- <td>{{ category.get_children_count }}</td>
+ <td>{{ category.get_num_children }}</td>
<td>
<a class="btn btn-primary" href="{% url dashboard:catalogue-category-update category.id %}">Edit category</a>
<a class="btn btn-primary {% if not category.has_children %}disabled{% endif %}" href="{% url dashboard:catalogue-category-detail-list pk=category.pk %}">Edit children</a>
8 oscar/templates/dashboard/catalogue/product_update.html
View
@@ -36,8 +36,9 @@
<h3 class="app-ico ico_expand icon">Product information</h3>
</div>
<div class="control-group fields-full">
- {% trans "Product class" %}: <strong>{{ product.product_class }}</strong>
+ {% trans "Product class" %}: <strong>{{ product_class.name }}</strong>
</div>
+ {{ form.non_field_errors }}
{% for field in form %}
{% if forloop.counter > 4 %}
<div class="form-inline pull-left">
@@ -87,7 +88,12 @@ <h3 class="app-ico ico_expand icon">Product information</h3>
<div class="sub-header">
<h3 class="app-ico ico_home icon">{% trans "Category information" %}</h3>
</div>
+ <p>{% blocktrans %}The first category in this list is the "primary" category for the product, and will be the one displayed
+ in the site breadcrumb trail.
+ {% endblocktrans %}
+ </p>
{{ category_formset.management_form }}
+ {{ category_formset.non_form_errors }}
{% for category_form in category_formset %}
{% include "partials/form_fields_inline.html" with form=category_form %}
<hr/>
2  oscar/templates/dashboard/reports/partials/open_basket_report.html
View
@@ -15,7 +15,7 @@
</tr>
{% for basket in objects %}
<tr>
- <td>{{ basket.owner.email }}</td>
+ <td>{{ basket.owner.email|default:"-" }}</td>
<td>{{ basket.owner.get_full_name|default:"-" }}</td>
<td>{{ basket.num_lines }}</td>
<td>{{ basket.num_items }}</td>
2  oscar/templates/dashboard/reports/partials/submitted_basket_report.html
View
@@ -15,7 +15,7 @@
</tr>
{% for basket in objects %}
<tr>
- <td>{{ basket.owner.email }}</td>
+ <td>{{ basket.owner.email|default:"-" }}</td>
<td>{{ basket.owner.get_full_name|default:"-" }}</td>
<td>{{ basket.num_lines }}</td>
<td>{{ basket.num_items }}</td>
2  oscar/templates/dashboard/vouchers/voucher_detail.html
View
@@ -45,7 +45,7 @@
<tbody>
<tr><th>{% trans "Number of basket additions" %}</th><td>{{ voucher.num_basket_additions }}</td></tr>
<tr><th>{% trans "Number of orders" %}</th><td>{{ voucher.num_orders }}</td></tr>
- <tr><th>{% trans "Total discount" %}</th><td>{{ voucher.total_discount }}</td></tr>
+ <tr><th>{% trans "Total discount" %}</th><td>{{ voucher.total_discount|currency }}</td></tr>
</tbody>
</table>
13 oscar/test/__init__.py
View
@@ -32,9 +32,12 @@ class ClientTestCase(TestCase):
def setUp(self):
self.client = Client()
if not self.is_anonymous:
- self.user = self.create_user()
- self.client.login(username=self.username,
- password=self.password)
+ self.login()
+
+ def login(self):
+ self.user = self.create_user()
+ self.client.login(username=self.username,
+ password=self.password)
def create_user(self):
user = User.objects.create_user(self.username,
@@ -52,10 +55,10 @@ def assertIsRedirect(self, response, expected_url=None):
location = URL.from_string(response['Location'])
self.assertEqual(expected_url, location.path())
- def assertRedirectUrlName(self, response, name):
+ def assertRedirectUrlName(self, response, name, kwargs=None):
self.assertIsRedirect(response)
location = response['Location'].replace('http://testserver', '')
- self.assertEqual(location, reverse(name))
+ self.assertEqual(location, reverse(name, kwargs=kwargs))
def assertIsOk(self, response):
self.assertEqual(httplib.OK, response.status_code)
14 runtests.py
View
@@ -4,18 +4,16 @@
from optparse import OptionParser
from coverage import coverage
-# This configures the settings
from tests.config import configure
-configure()
-
-from django_nose import NoseTestSuiteRunner
logging.disable(logging.CRITICAL)
def run_tests(options, *test_args):
+ from django_nose import NoseTestSuiteRunner
test_runner = NoseTestSuiteRunner(verbosity=options.verbosity,
- pdb=options.pdb)
+ pdb=options.pdb,
+ )
if not test_args:
test_args = ['tests']
num_failures = test_runner.run_tests(test_args)
@@ -33,6 +31,12 @@ def run_tests(options, *test_args):
action='store_true', help="Whether to drop into PDB on failure/error")
(options, args) = parser.parse_args()
+ # If no args, then use 'progressive' plugin to keep the screen real estate
+ # used down to a minimum. Otherwise, use the spec plugin
+ nose_args = ['-s', '-x',
+ '--with-progressive' if not args else '--with-spec']
+ configure(nose_args)
+
if options.use_coverage:
print 'Running tests with coverage'
c = coverage(source=['oscar'])
4 tests/config.py
View
@@ -4,7 +4,7 @@
from oscar import OSCAR_CORE_APPS
-def configure():
+def configure(nose_args):
if not settings.configured:
from oscar.defaults import OSCAR_SETTINGS
@@ -56,6 +56,6 @@ def configure():
HAYSTACK_SEARCH_ENGINE='dummy',
HAYSTACK_SITECONF = 'oscar.search_sites',
APPEND_SLASH=True,
- NOSE_ARGS=['-s', '-x', '--with-spec'],
+ NOSE_ARGS=nose_args,
**OSCAR_SETTINGS
)
42 tests/functional/checkout_tests.py
View
@@ -10,6 +10,7 @@
from oscar.apps.basket.models import Basket
from oscar.apps.order.models import Order
from oscar.apps.address.models import Country
+from oscar.apps.voucher.models import Voucher
class CheckoutMixin(object):
@@ -19,8 +20,9 @@ def add_product_to_basket(self):
self.client.post(reverse('basket:add'), {'product_id': product.id,
'quantity': 1})
- def add_voucher_to_basket(self):
- voucher = create_voucher()
+ def add_voucher_to_basket(self, voucher=None):
+ if voucher is None:
+ voucher = create_voucher()
self.client.post(reverse('basket:vouchers-add'),
{'code': voucher.code})
@@ -41,7 +43,7 @@ def complete_shipping_address(self):
'postcode': 'N1 9RT',
'country': 'GB',
})
- self.assertIsRedirect(response)
+ self.assertRedirectUrlName(response, 'checkout:shipping-method')
def complete_shipping_method(self):
self.client.get(reverse('checkout:shipping-method'))
@@ -205,7 +207,7 @@ def test_ok_response_if_previous_steps_complete(self):
self.assertIsOk(response)
-class PaymentDetailsViewTests(ClientTestCase, CheckoutMixin):
+class TestPaymentDetailsView(ClientTestCase, CheckoutMixin):
def test_user_must_have_a_nonempty_basket(self):
response = self.client.get(reverse('checkout:payment-details'))
@@ -230,12 +232,12 @@ def test_placing_order_with_empty_basket_redirects(self):
self.assertRedirectUrlName(response, 'basket:summary')
-class OrderPlacementTests(ClientTestCase, CheckoutMixin):
+class TestOrderPlacement(ClientTestCase, CheckoutMixin):
def setUp(self):
Order.objects.all().delete()
- super(OrderPlacementTests, self).setUp()
+ super(TestOrderPlacement, self).setUp()
self.basket = Basket.objects.create(owner=self.user)
self.basket.add_product(create_product(price=D('12.00')))
@@ -253,19 +255,25 @@ def test_order_is_created(self):
self.assertEqual(1, len(orders))
-class TestAnonUserOrderPlacementScenarios(ClientTestCase, CheckoutMixin):
+class TestPlacingOrderUsingAVoucher(ClientTestCase, CheckoutMixin):
- def test_basic_submission_gets_redirect_to_thankyou(self):
+ def setUp(self):
+ self.login()
self.add_product_to_basket()
+ voucher = create_voucher()
+ self.add_voucher_to_basket(voucher)
self.complete_shipping_address()
self.complete_shipping_method()
- response = self.submit()
- self.assertRedirectUrlName(response, 'checkout:thank-you')
+ self.response = self.submit()
- def test_submission_using_voucher(self):
- self.add_product_to_basket()
- self.add_voucher_to_basket()
- self.complete_shipping_address()
- self.complete_shipping_method()
- response = self.submit()
- self.assertRedirectUrlName(response, 'checkout:thank-you')
+ # Reload voucher
+ self.voucher = Voucher.objects.get(id=voucher.id)
+
+ def test_is_successful(self):
+ self.assertRedirectUrlName(self.response, 'checkout:thank-you')
+
+ def test_records_use(self):
+ self.assertEquals(1, self.voucher.num_orders)
+
+ def test_records_discount(self):
+ self.assertEquals(1, self.voucher.num_orders)
96 tests/functional/dashboard/catalogue_tests.py
View
@@ -1,10 +1,6 @@
from django.core.urlresolvers import reverse
-from django.test import TestCase
from oscar.test import ClientTestCase
-from oscar.apps.catalogue.models import Category
-from oscar.apps.dashboard.catalogue.forms import CategoryForm
-from oscar.apps.catalogue.categories import create_from_breadcrumbs
class TestCatalogueViews(ClientTestCase):
@@ -17,95 +13,3 @@ def test_exist(self):
]
for url in urls:
self.assertIsOk(self.client.get(url))
-
-
-def create_test_category_tree():
- trail = 'A > B > C'
- create_from_breadcrumbs(trail)
- trail = 'A > B > D'
- create_from_breadcrumbs(trail)
- trail = 'A > E > F'
- create_from_breadcrumbs(trail)
- trail = 'A > E > G'
- create_from_breadcrumbs(trail)
-
-
-class TestCategoryForm(TestCase):
-
- def setUp(self):
- Category.objects.all().delete()
-
- def test_conflicting_slugs_recognized(self):
- create_test_category_tree()
-
- f = CategoryForm()
-
- #root categories
- ref_node_pk = Category.objects.get(name='A').pk
- conflicting = f.is_slug_conflicting('A', ref_node_pk, 'right')
- self.assertEqual(conflicting, True)
-
- conflicting = f.is_slug_conflicting('A', None, 'left')
- self.assertEqual(conflicting, True)
-
- conflicting = f.is_slug_conflicting('A', None, 'first-child')
- self.assertEqual(conflicting, True)
-
- conflicting = f.is_slug_conflicting('B', None, 'left')
- self.assertEqual(conflicting, False)
-
- #subcategories
- ref_node_pk = Category.objects.get(name='C').pk
- conflicting = f.is_slug_conflicting('D', ref_node_pk, 'left')
- self.assertEqual(conflicting, True)
-
- ref_node_pk = Category.objects.get(name='B').pk
- conflicting = f.is_slug_conflicting('D', ref_node_pk, 'first-child')
- self.assertEqual(conflicting, True)
-
- ref_node_pk = Category.objects.get(name='F').pk
- conflicting = f.is_slug_conflicting('D', ref_node_pk, 'left')
- self.assertEqual(conflicting, False)
-
- ref_node_pk = Category.objects.get(name='E').pk
- conflicting = f.is_slug_conflicting('D', ref_node_pk, 'first-child')
- self.assertEqual(conflicting, False)
-
- #updating
- f.instance = Category.objects.get(name='E')
- ref_node_pk = Category.objects.get(name='A').pk
- conflicting = f.is_slug_conflicting('E', ref_node_pk, 'first-child')
- self.assertEqual(conflicting, False)
-
-
-class CategoryTests(ClientTestCase):
- is_staff = True
-
- def setUp(self):
- super(CategoryTests, self).setUp()
- create_test_category_tree()
-
- def test_category_create(self):
- a = Category.objects.get(name='A')
- b = Category.objects.get(name='B')
- c = Category.objects.get(name='C')
-
- # Redirect to subcategory list view
- response = self.client.post(reverse('dashboard:catalogue-category-create'),
- {'name': 'Testee',
- '_position': 'left',
- '_ref_node_id': c.id,})
-
- self.assertIsRedirect(response, reverse('dashboard:catalogue-category-detail-list',
- args=(b.pk,)))
-
- # Redirect to main category list view
- response = self.client.post(reverse('dashboard:catalogue-category-create'),
- {'name': 'Testee',
- '_position': 'right',
- '_ref_node_id': a.id,})
-
- self.assertIsRedirect(response, reverse('dashboard:catalogue-category-list'))
-
- self.assertEqual(Category.objects.all().count(), 9)
-
98 tests/functional/dashboard/category_tests.py
View
@@ -0,0 +1,98 @@
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+
+from oscar.test import ClientTestCase
+from oscar.apps.catalogue.models import Category
+from oscar.apps.dashboard.catalogue.forms import CategoryForm
+from oscar.apps.catalogue.categories import create_from_breadcrumbs
+
+
+def create_test_category_tree():
+ trail = 'A > B > C'
+ create_from_breadcrumbs(trail)
+ trail = 'A > B > D'
+ create_from_breadcrumbs(trail)
+ trail = 'A > E > F'
+ create_from_breadcrumbs(trail)
+ trail = 'A > E > G'
+ create_from_breadcrumbs(trail)
+
+
+class TestCategoryForm(TestCase):
+
+ def setUp(self):
+ Category.objects.all().delete()
+
+ def test_conflicting_slugs_recognized(self):
+ create_test_category_tree()
+
+ f = CategoryForm()
+
+ #root categories
+ ref_node_pk = Category.objects.get(name='A').pk
+ conflicting = f.is_slug_conflicting('A', ref_node_pk, 'right')
+ self.assertEqual(conflicting, True)
+
+ conflicting = f.is_slug_conflicting('A', None, 'left')
+ self.assertEqual(conflicting, True)
+
+ conflicting = f.is_slug_conflicting('A', None, 'first-child')
+ self.assertEqual(conflicting, True)
+
+ conflicting = f.is_slug_conflicting('B', None, 'left')
+ self.assertEqual(conflicting, False)
+
+ #subcategories
+ ref_node_pk = Category.objects.get(name='C').pk
+ conflicting = f.is_slug_conflicting('D', ref_node_pk, 'left')
+ self.assertEqual(conflicting, True)
+
+ ref_node_pk = Category.objects.get(name='B').pk
+ conflicting = f.is_slug_conflicting('D', ref_node_pk, 'first-child')
+ self.assertEqual(conflicting, True)
+
+ ref_node_pk = Category.objects.get(name='F').pk
+ conflicting = f.is_slug_conflicting('D', ref_node_pk, 'left')
+ self.assertEqual(conflicting, False)
+
+ ref_node_pk = Category.objects.get(name='E').pk
+ conflicting = f.is_slug_conflicting('D', ref_node_pk, 'first-child')
+ self.assertEqual(conflicting, False)
+
+ #updating
+ f.instance = Category.objects.get(name='E')
+ ref_node_pk = Category.objects.get(name='A').pk
+ conflicting = f.is_slug_conflicting('E', ref_node_pk, 'first-child')
+ self.assertEqual(conflicting, False)
+
+
+class CategoryTests(ClientTestCase):
+ is_staff = True
+
+ def setUp(self):
+ super(CategoryTests, self).setUp()
+ create_test_category_tree()
+
+ def test_category_create(self):
+ a = Category.objects.get(name='A')
+ b = Category.objects.get(name='B')
+ c = Category.objects.get(name='C')
+
+ # Redirect to subcategory list view
+ response = self.client.post(reverse('dashboard:catalogue-category-create'),
+ {'name': 'Testee',
+ '_position': 'left',
+ '_ref_node_id': c.id,})
+
+ self.assertIsRedirect(response, reverse('dashboard:catalogue-category-detail-list',
+ args=(b.pk,)))
+
+ # Redirect to main category list view
+ response = self.client.post(reverse('dashboard:catalogue-category-create'),
+ {'name': 'Testee',
+ '_position': 'right',
+ '_ref_node_id': a.id,})
+
+ self.assertIsRedirect(response, reverse('dashboard:catalogue-category-list'))
+
+ self.assertEqual(Category.objects.all().count(), 9)
103 tests/functional/dashboard/product_tests.py
View
@@ -0,0 +1,103 @@
+from django.core.urlresolvers import reverse
+from django_dynamic_fixture import G
+
+from oscar.test import ClientTestCase
+from oscar.apps.catalogue.models import ProductClass, Category, Product
+
+
+class TestGatewayPage(ClientTestCase):
+ is_staff = True
+
+ def test_redirects_to_list_page_when_no_query_param(self):
+ url = reverse('dashboard:catalogue-product-create')
+ response = self.client.get(url)
+ self.assertRedirectUrlName(response,
+ 'dashboard:catalogue-product-list')
+
+ def test_redirects_to_list_page_when_invalid_query_param(self):
+ url = reverse('dashboard:catalogue-product-create')
+ response = self.client.get(url + '?product_class=bad')
+ self.assertRedirectUrlName(response,
+ 'dashboard:catalogue-product-list')
+
+ def test_redirects_to_form_page_when_valid_query_param(self):
+ pclass = G(ProductClass)
+ url = reverse('dashboard:catalogue-product-create')
+ response = self.client.get(url + '?product_class=%d' % pclass.id)
+ self.assertRedirectUrlName(response,
+ 'dashboard:catalogue-product-create',
+ {'product_class_id': pclass.id})
+
+
+class TestCreateGroupProduct(ClientTestCase):
+ is_staff = True
+
+ def setUp(self):
+ self.pclass = G(ProductClass)
+ super(TestCreateGroupProduct, self).setUp()
+
+ def submit(self, **params):
+ data = {'title': 'Nice T-Shirt',
+ 'productcategory_set-TOTAL_FORMS': '1',
+ 'productcategory_set-INITIAL_FORMS': '0',
+ 'productcategory_set-MAX_NUM_FORMS': '',
+ 'images-TOTAL_FORMS': '2',
+ 'images-INITIAL_FORMS': '0',
+ 'images-MAX_NUM_FORMS': '',
+ }
+ data.update(params)
+ url = reverse('dashboard:catalogue-product-create',
+ kwargs={'product_class_id': self.pclass.id})
+ return self.client.post(url, data)
+
+ def test_title_is_required(self):
+ response = self.submit(title='')
+ self.assertIsOk(response)
+
+ def test_requires_a_category(self):
+ response = self.submit()
+ self.assertIsOk(response)
+
+ def test_doesnt_smoke(self):
+ category = G(Category)
+ data = {
+ 'productcategory_set-0-category': category.id,
+ 'productcategory_set-0-id': '',
+ 'productcategory_set-0-product': '',
+ }
+ response = self.submit(**data)
+ self.assertRedirectUrlName(response, 'dashboard:catalogue-product-list')
+
+
+class TestCreateChildProduct(ClientTestCase):
+ is_staff = True
+
+ def setUp(self):
+ self.pclass = G(ProductClass)
+ self.parent = G(Product)
+ super(TestCreateChildProduct, self).setUp()
+
+ def submit(self, **params):
+ data = {'title': 'Nice T-Shirt',
+ 'productcategory_set-TOTAL_FORMS': '1',
+ 'productcategory_set-INITIAL_FORMS': '0',
+ 'productcategory_set-MAX_NUM_FORMS': '',
+ 'images-TOTAL_FORMS': '2',
+ 'images-INITIAL_FORMS': '0',
+ 'images-MAX_NUM_FORMS': '',
+ }
+ data.update(params)
+ url = reverse('dashboard:catalogue-product-create',
+ kwargs={'product_class_id': self.pclass.id})
+ return self.client.post(url, data)
+
+ def test_categories_are_not_required(self):
+ category = G(Category)
+ data = {
+ 'parent': self.parent.id,
+ 'productcategory_set-0-category': category.id,
+ 'productcategory_set-0-id': '',
+ 'productcategory_set-0-product': '',
+ }
+ response = self.submit(**data)
+ self.assertIsOk(response)
0  tests/unit/dashboard/cataogue_form_tests.py → tests/unit/dashboard/catalogue_form_tests.py
View
File renamed without changes
70 tests/unit/voucher_tests.py
View
@@ -1,11 +1,36 @@
import datetime
+from decimal import Decimal as D
from django.test import TestCase
+from django.core import exceptions
+from django.contrib.auth.models import User
+from django_dynamic_fixture import G
from oscar.apps.voucher.models import Voucher
+from oscar.apps.order.models import Order
+
+START_DATE = datetime.date(2011, 01, 01)
+END_DATE = datetime.date(2012, 01, 01)
class TestVoucher(TestCase):
+
+ def test_saves_code_as_uppercase(self):
+ start = datetime.date(2011, 01, 01)
+ end = datetime.date(2012, 01, 01)
+ voucher = Voucher.objects.create(code='lower',
+ start_date=start,
+ end_date=end)
+ self.assertEqual('LOWER', voucher.code)
+
+ def test_checks_dates_are_sensible(self):
+ start = datetime.date(2011, 01, 01)
+ end = datetime.date(2012, 01, 01)
+ with self.assertRaises(exceptions.ValidationError):
+ voucher = Voucher.objects.create(code='lower',
+ start_date=end,
+ end_date=start)
+ voucher.clean()
def test_is_active_between_start_and_end_dates(self):
start = datetime.date(2011, 01, 01)
@@ -20,10 +45,41 @@ def test_is_inactive_outside_of_start_and_end_dates(self):
end = datetime.date(2011, 02, 01)
voucher = Voucher(start_date=start, end_date=end)
self.assertFalse(voucher.is_active(test))
-
- def test_codes_are_saved_as_uppercase(self):
- start = datetime.date(2011, 01, 01)
- end = datetime.date(2011, 02, 01)
- voucher = Voucher(name="Dummy voucher", code="lowercase", start_date=start, end_date=end)
- voucher.save()
- self.assertEquals("LOWERCASE", voucher.code)
+
+ def test_increments_total_discount_when_recording_usage(self):
+ voucher = G(Voucher)
+ voucher.record_discount(D('10.00'))
+ self.assertEqual(voucher.total_discount, D('10.00'))
+ voucher.record_discount(D('10.00'))
+ self.assertEqual(voucher.total_discount, D('20.00'))
+
+
+class TestMultiuseVoucher(TestCase):
+
+ def setUp(self):
+ self.voucher = G(Voucher, usage=Voucher.MULTI_USE)
+
+ def test_is_available_to_same_user_multiple_times(self):
+ user, order = G(User), G(Order)
+ for i in xrange(10):
+ self.voucher.record_usage(order, user)
+ self.assertTrue(self.voucher.is_available_to_user(user)[0])
+
+
+class TestOncePerCustomerVoucher(TestCase):
+
+ def setUp(self):
+ self.voucher = G(Voucher, usage=Voucher.ONCE_PER_CUSTOMER)
+
+ def test_is_available_to_a_user_once(self):
+ user, order = G(User), G(Order)
+ self.assertTrue(self.voucher.is_available_to_user(user)[0])
+ self.voucher.record_usage(order, user)
+ self.assertFalse(self.voucher.is_available_to_user(user)[0])
+
+ def test_is_available_to_different_users(self):
+ users, order = [G(User), G(User)], G(Order)
+ for user in users:
+ self.assertTrue(self.voucher.is_available_to_user(user)[0])
+ self.voucher.record_usage(order, user)
+ self.assertFalse(self.voucher.is_available_to_user(user)[0])

Showing you all comments on commits in this comparison.

Paweł Kowalski

Here in form value is not required while in models the attribute is not nullable: https://github.com/tangentlabs/django-oscar/blob/releases/0.3/oscar/apps/partner/abstract_models.py#L70
It leads to an error when you try to create product with partner select empty:
ValueError: Cannot assign None: "StockRecord.partner" does not allow null values.
Either model should allow null or form field should be required.

Something went wrong with that request. Please try again.