diff --git a/shop/context_processors.py b/shop/context_processors.py index b7ed3af2..c46c6bb2 100644 --- a/shop/context_processors.py +++ b/shop/context_processors.py @@ -1,7 +1,7 @@ def current_order(request): if request.user.is_authenticated(): order = None - orders = request.user.orders.filter(finalized=False) + orders = request.user.orders.filter(open__isnull=False) if orders: order = orders[0] diff --git a/shop/forms.py b/shop/forms.py index 5d2e98ec..ccdb9209 100644 --- a/shop/forms.py +++ b/shop/forms.py @@ -2,10 +2,6 @@ from django import forms from .models import Order -class CheckoutForm(forms.Form): - # no fields here, just three submit buttons - pass - class AddToOrderForm(forms.Form): quantity = forms.IntegerField() diff --git a/shop/migrations/0004_auto_20160515_1604.py b/shop/migrations/0004_auto_20160515_1604.py new file mode 100644 index 00000000..4ab76d62 --- /dev/null +++ b/shop/migrations/0004_auto_20160515_1604.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.6 on 2016-05-15 16:04 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0003_auto_20160513_0646'), + ] + + operations = [ + migrations.AddField( + model_name='productcategory', + name='slug', + field=models.SlugField(default=''), + preserve_default=False, + ), + migrations.AlterField( + model_name='product', + name='category', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='shop.ProductCategory'), + ), + ] diff --git a/shop/migrations/0005_product_slug.py b/shop/migrations/0005_product_slug.py new file mode 100644 index 00000000..27d01c8c --- /dev/null +++ b/shop/migrations/0005_product_slug.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.6 on 2016-05-15 16:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0004_auto_20160515_1604'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='slug', + field=models.SlugField(default=''), + preserve_default=False, + ), + ] diff --git a/shop/migrations/0006_ensure_slugs.py b/shop/migrations/0006_ensure_slugs.py new file mode 100644 index 00000000..d7bc09fb --- /dev/null +++ b/shop/migrations/0006_ensure_slugs.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.6 on 2016-05-15 16:15 +from __future__ import unicode_literals + +from django.db import migrations + + +def ensure_slugs(apps, schema_editor): + ProductCategory = apps.get_model('shop', 'ProductCategory') + Product = apps.get_model('shop', 'Product') + + for category in ProductCategory.objects.all(): + category.save() + + for product in Product.objects.all(): + product.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0005_product_slug'), + ] + + operations = [ + migrations.RunPython(ensure_slugs, migrations.RunPython.noop) + ] diff --git a/shop/migrations/0007_auto_20160515_2157.py b/shop/migrations/0007_auto_20160515_2157.py new file mode 100644 index 00000000..d6550bfb --- /dev/null +++ b/shop/migrations/0007_auto_20160515_2157.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.6 on 2016-05-15 21:57 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0006_ensure_slugs'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='open', + field=models.NullBooleanField(default=True, help_text='Whether this order is open or not. "None" means closed.', verbose_name='Open?'), + ), + migrations.RemoveField( + model_name='order', + name='finalized', + ), + migrations.AlterUniqueTogether( + name='order', + unique_together=set([('user', 'open')]), + ), + ] diff --git a/shop/models.py b/shop/models.py index 383935b6..e6af683c 100644 --- a/shop/models.py +++ b/shop/models.py @@ -1,6 +1,7 @@ from django.db import models from django.db.models.aggregates import Sum from django.contrib.postgres.fields import DateTimeRangeField, JSONField +from django.utils.text import slugify from django.utils.translation import ugettext_lazy as _ from django.utils import timezone from bornhack.utils import CreatedUpdatedModel, UUIDModel @@ -9,6 +10,9 @@ from .managers import ProductQuerySet class Order(CreatedUpdatedModel): + class Meta: + unique_together = ('user', 'open') + products = models.ManyToManyField( 'shop.Product', through='shop.OrderProductRelation' @@ -27,10 +31,10 @@ class Order(CreatedUpdatedModel): default=False, ) - finalized = models.BooleanField( - verbose_name=_('Finalized?'), - help_text=_('Whether this order has been finalized.'), - default=False, + open = models.NullBooleanField( + verbose_name=_('Open?'), + help_text=_('Whether this order is open or not. "None" means closed.'), + default=True, ) camp = models.ForeignKey( @@ -44,6 +48,12 @@ class Order(CreatedUpdatedModel): BANK_TRANSFER = 'bank_transfer' PAYMENT_METHODS = [ + CREDIT_CARD, + BLOCKCHAIN, + BANK_TRANSFER, + ] + + PAYMENT_METHOD_CHOICES = [ (CREDIT_CARD, 'Credit card'), (BLOCKCHAIN, 'Blockchain'), (BANK_TRANSFER, 'Bank transfer'), @@ -51,7 +61,7 @@ class Order(CreatedUpdatedModel): payment_method = models.CharField( max_length=50, - choices=PAYMENT_METHODS, + choices=PAYMENT_METHOD_CHOICES, default=BLOCKCHAIN ) @@ -67,10 +77,15 @@ class ProductCategory(CreatedUpdatedModel, UUIDModel): verbose_name_plural = 'Product categories' name = models.CharField(max_length=150) + slug = models.SlugField() def __str__(self): return self.name + def save(self, **kwargs): + self.slug = slugify(self.name) + super(ProductCategory, self).save(**kwargs) + class Product(CreatedUpdatedModel, UUIDModel): class Meta: @@ -78,9 +93,13 @@ class Product(CreatedUpdatedModel, UUIDModel): verbose_name_plural = 'Products' ordering = ['available_in'] - category = models.ForeignKey('shop.ProductCategory') + category = models.ForeignKey( + 'shop.ProductCategory', + related_name='products' + ) name = models.CharField(max_length=150) + slug = models.SlugField() price = models.IntegerField( help_text=_('Price of the product (in DKK).') @@ -103,6 +122,10 @@ class Product(CreatedUpdatedModel, UUIDModel): self.price, ) + def save(self, **kwargs): + self.slug = slugify(self.name) + super(Product, self).save(**kwargs) + def is_available(self): now = timezone.now() return now in self.available_in @@ -114,6 +137,10 @@ class OrderProductRelation(models.Model): quantity = models.PositiveIntegerField() handed_out = models.BooleanField(default=False) + @property + def total(self): + return self.product.price * self.quantity + class EpayCallback(CreatedUpdatedModel, UUIDModel): class Meta: diff --git a/shop/templates/order_detail.html b/shop/templates/order_detail.html index 384462be..d4df4508 100644 --- a/shop/templates/order_detail.html +++ b/shop/templates/order_detail.html @@ -1,7 +1,48 @@ {% extends 'base.html' %} +{% load bootstrap3 %} {% block content %} -details for order {{ order.id }} +

Order #{{ order.id }}

+ + + + + + +{% for order_product in order.orderproductrelation_set.all %} + +
+ Name + + Quantity + + Price + + Total + +
+ {{ order_product.product.name }} + + {{ order_product.quantity }} + + {{ order_product.product.price }} + + {{ order_product.total }} + +{% endfor %} + +{# TODO: Add total + VAT info #} + +
+ +{% if order.open %} +
+ {% csrf_token %} + {% bootstrap_button "Credit card" button_type="submit" button_class="btn-primary" name="payment_method" value="credit_card" %} + {% bootstrap_button "Blockchain" button_type="submit" button_class="btn-primary" name="payment_method" value="blockchain" %} + {% bootstrap_button "Bank transfer" button_type="submit" button_class="btn-primary" name="payment_method" value="bank_transfer" %} +
+{% endif %} {% endblock %} diff --git a/shop/templates/order_list.html b/shop/templates/order_list.html new file mode 100644 index 00000000..3dc46281 --- /dev/null +++ b/shop/templates/order_list.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} + +{% block content %} +

Orders

+ + + + + + + + + + + +{% for order in orders %} + + + + + + + +{% endfor %} + +
Order IDOpen?Paid?Delivered?Actions
{{ order.id }}{{ order.finalized }}{{ order.paid }}? + {% if order.finalized and not order.paid %} + {% url 'shop:order_detail' pk=order.pk as order_detail_url %} + {% url 'shop:order_cancel' pk=order.pk as order_cancel_url %} + {% bootstrap_button "Pay order" href=order_detail_url button_class="btn-primary" %} + {% bootstrap_button "Cancel order" href=order_cancel_url button_class="btn-primary" %} + {% endif %} +
+ +{% endblock %} diff --git a/shop/templates/product_detail.html b/shop/templates/product_detail.html index 7aa53ed3..7f6a7263 100644 --- a/shop/templates/product_detail.html +++ b/shop/templates/product_detail.html @@ -7,6 +7,7 @@
{% csrf_token %} + {% bootstrap_form form %} {% bootstrap_button "Add to order" button_type="submit" button_class="btn-primary" %}
{% endblock %} diff --git a/shop/templates/shop_index.html b/shop/templates/shop_index.html index 6418bcc4..77b890b0 100644 --- a/shop/templates/shop_index.html +++ b/shop/templates/shop_index.html @@ -1,73 +1,61 @@ {% extends 'base.html' %} {% load bootstrap3 %} + {% block content %} -

Shop

+
+
+ Categories: + {% for category in categories %} + + {{category}} +   + {% endfor %} +
+
+ +
+ +
+ + {% for product in products %} + + +
+
+ +
+
{{ product.category.name }}
+ +

+ {{ product.name }} +

+ +

+ Price: {{ product.price }} DKK +

+ +

+ {{ product.description }} +

+ + + Availability:
+ {{ product.available_in.lower|date:"Y-m-d H:i" }} + {% if product.available_in.upper %} + - {{ product.available_in.upper|date:"Y-m-d H:i" }} + {% endif %} +
+
+
+
+
+ + {% endfor %} + +
-

-Here you can see your orders and available products in the shop. -

-

Orders

- - - - - - - - - - - -{% for order in orders %} - - - - - - - -{% endfor %} - -
Order IDOpen?Paid?Delivered?Actions
{{ order.id }}{{ order.finalized }}{{ order.paid }}? - {% if order.finalized and not order.paid %} - {% url 'shop:order_detail' pk=order.id as order_detail_url %} - {% url 'shop:order_cancel' pk=order.id as order_cancel_url %} - {% bootstrap_button "Pay order" href=order_detail_url button_class="btn-primary" %} - {% bootstrap_button "Cancel order" href=order_cancel_url button_class="btn-primary" %} - {% endif %} -
-

Products

- - - - - - - - - - -{% for product in product_list %} - - - - - - - -{% endfor %}
DescriptionPriceAvailabilityBuy
{{ product.name }}{{ product.price }} DKK{{ product.available_in.lower }} - {% if product.available_in.upper %} - - {{ product.available_in.upper }} - {% endif %} - - {% if product.is_available %} - {% url 'shop:product_detail' pk=product.pk as product_detail_url %} - {% bootstrap_button "Add to order" href=product_detail_url button_class="btn-primary" %} - {% else %} - N/A - {% endif %} -
{% endblock %} diff --git a/shop/urls.py b/shop/urls.py index 23581cac..1d3fa374 100644 --- a/shop/urls.py +++ b/shop/urls.py @@ -13,7 +13,8 @@ urlpatterns = [ #name='epay_callback' #), url(r'^$', ShopIndexView.as_view(), name='index'), - url(r'products/(?P[a-zA-Z0-9\-]+)/$', ProductDetailView.as_view(), name='product_detail'), + url(r'products/(?P[-_\w+]+)/$', ProductDetailView.as_view(), name='product_detail'), + url(r'orders/$', OrderListView.as_view(), name='order_list'), url(r'orders/(?P[0-9]+)/$', OrderDetailView.as_view(), name='order_detail'), - url(r'orders/(?P[0-9]+)/checkout/$', CheckoutView.as_view(), name='checkout'), + # url(r'orders/(?P[0-9]+)/checkout/$', CheckoutView.as_view(), name='checkout'), ] diff --git a/shop/views.py b/shop/views.py index 2883d2e6..813c79cb 100644 --- a/shop/views.py +++ b/shop/views.py @@ -1,70 +1,60 @@ -import hashlib - -from django.http import HttpResponseRedirect, Http404 -from django.views.generic import CreateView, TemplateView, ListView, DetailView, View, FormView -from django.core.urlresolvers import reverse_lazy from django.conf import settings -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.urlresolvers import reverse_lazy +from django.db.models import Count, F +from django.http import HttpResponseRedirect, Http404 +from django.views.generic import ( + TemplateView, + ListView, + DetailView, + FormView, +) -from .models import Order, Product, EpayCallback, EpayPayment, OrderProductRelation -from .forms import CheckoutForm, AddToOrderForm +from shop.models import ( + Order, + Product, + OrderProductRelation, + ProductCategory, +) +from .forms import AddToOrderForm class ShopIndexView(ListView): model = Product template_name = "shop_index.html" + context_object_name = 'products' def get_context_data(self, **kwargs): context = super(ShopIndexView, self).get_context_data(**kwargs) + + if 'category' in self.request.GET: + category = self.request.GET.get('category') + context['products'] = context['products'].filter( + category__slug=category + ) + context['current_category'] = category + context['categories'] = ProductCategory.objects.annotate( + num_products=Count('products') + ).filter( + num_products__gt=0 + ) + return context + + +class OrderListView(LoginRequiredMixin, ListView): + model = Order + template_name = "order_list.html" + + def get_context_data(self, **kwargs): + context = super(OrderListView, self).get_context_data(**kwargs) context['orders'] = Order.objects.filter(user=self.request.user) return context -class ProductDetailView(LoginRequiredMixin, FormView): - model = Product - template_name = 'product_detail.html' - form_class = AddToOrderForm - context_object_name = 'product' - - def get(self, request, *args, **kwargs): - self.product = Product.objects.get(pk=kwargs.get('pk')) - return self.render_to_response(self.get_context_data()) - - def form_valid(self, form): - ### do we have an open order? - try: - order = Order.objects.get(user=self.request.user, finalized=False) - except Order.DoesNotExist: - ### no open order - open a new one - order = Order.objects.create(user=request.user) - - ### get product from kwargs - if self.product in order.products.all(): - ### this product is already added to this order, increase count by one - OrderProductRelation.objects.filter(product=self.product, order=order).update(quantity=F('quantity') + 1) - else: - order.products.add(self.product) - - ### done - return super(ProductDetailView, self).form_valid(form) - - class OrderDetailView(LoginRequiredMixin, DetailView): - model = Product - template_name = 'order_detail.html' - context_object_name = 'order' - - -class CheckoutView(LoginRequiredMixin, FormView): - """ - Shows a summary of all products contained in an order, - total price, VAT info, and a button to finalize order and go to payment - """ model = Order - template_name = 'checkout.html' - form_class = CheckoutForm + template_name = 'order_detail.html' context_object_name = 'order' def get(self, request, *args, **kwargs): @@ -79,44 +69,97 @@ class CheckoutView(LoginRequiredMixin, FormView): messages.error(request, 'This order contains no products!') return HttpResponseRedirect('shop:order_detail') - return self.render_to_response(self.get_context_data()) + return super(OrderDetailView, self).get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + # mark order as finalized and redirect user to payment + order = self.get_object() + payment_method = request.POST.get('payment_method') + + if payment_method in order.PAYMENT_METHODS: + order.payment_method = payment_method + else: + # unknown submit button + messages.error(self.request, 'Unknown submit button :(') + return reverse_lazy( + 'shop:checkout', + kwargs={'orderid': self.get_object.id} + ) + + order.finalized = True + + reverses = { + Order.CREDIT_CARD: reverse_lazy( + 'shop:epay_form', + kwargs={'orderid': order.id} + ), + Order.BLOCKCHAIN: reverse_lazy( + 'shop:coinify_pay', + kwargs={'orderid': order.id} + ), + Order.BANK_TRANSFER: reverse_lazy( + 'shop:bank_transfer', + kwargs={'orderid': order.id} + ) + } + + return HttpResponseRedirect(reverses[payment_method]) + + +class ProductDetailView(LoginRequiredMixin, FormView, DetailView): + model = Product + template_name = 'product_detail.html' + form_class = AddToOrderForm + context_object_name = 'product' def form_valid(self, form): - ### mark order as finalized and redirect user to payment - form.instance.finalized=True + product = self.get_object() + quantity = form.cleaned_data.get('quantity'), - ### set payment_method based on submit button used - if 'credit_card' in form.data: - form.instance.payment_method=='credit_card' - elif 'blockchain' in form.data: - form.instance.payment_method=='blockchain' - elif 'bank_transfer' in form.data: - form.instance.payment_method=='bank_transfer' + # do we have an open order? + try: + order = Order.objects.get( + user=self.request.user, + finalized=False + ) + except Order.DoesNotExist: + # no open order - open a new one + order = Order.objects.create(user=self.request.user) + + # get product from kwargs + if product in order.products.all(): + # this product is already added to this order, + # increase count by quantity + OrderProductRelation.objects.filter( + product=product, + order=order + ).update(quantity=F('quantity') + quantity) else: - ### unknown submit button - messages.error(request, 'Unknown submit button :(') - return reverse('shop:checkout', kwargs={'orderid': self.get_object.id}) + order.products.add(product) - return super(CheckoutView, self).form_valid(form) + messages.info( + self.request, + '{}x {} has been added to your order.'.format( + quantity, + product.name + ) + ) + + # done + return super(ProductDetailView, self).form_valid(form) def get_success_url(self): - if self.get_object.payment_method == 'credit_card': - return reverse('shop:epay_form', kwargs={'orderid': self.get_object.id}) - elif self.get_object.payment_method == 'blockchain': - return reverse('shop:coinify_pay', kwargs={'orderid': self.get_object.id}) - elif self.get_object.payment_method == 'bank_transfer': - return reverse('shop:bank_transfer', kwargs={'orderid': self.get_object.id}) - else: - ### unknown payment method - messages.error(request, 'Unknown payment method :(') - return reverse('shop:checkout', kwargs={'orderid': self.get_object.id}) + return reverse_lazy( + 'shop:product_detail', + kwargs={'slug': self.get_object().slug} + ) class CoinifyRedirectView(TemplateView): template_name = 'coinify_redirect.html' - + def get(self, request, *args, **kwargs): - ### validate a few things + # validate a few things self.order = Order.objects.get(pk=kwargs.get('order_id')) if self.order.user != request.user: raise Http404("Order not found") @@ -139,25 +182,37 @@ class CoinifyRedirectView(TemplateView): order = Order.objects.get(pk=kwargs.get('order_id')) context = super(CoinifyRedirectView, self).get_context_data(**kwargs) context['order'] = order - - ### Initiate coinify API and create invoice - coinifyapi = CoinifyAPI(settings.COINIFY_API_KEY, settings.COINIFY_API_SECRET) + + # Initiate coinify API and create invoice + coinifyapi = CoinifyAPI( + settings.COINIFY_API_KEY, + settings.COINIFY_API_SECRET + ) response = coinifyapi.invoice_create( amount, currency, plugin_name='BornHack 2016 webshop', plugin_version='1.0', description='BornHack 2016 order id #%s' % order.id, - callback_url=reverse('shop:coinfy_callback', kwargs={'orderid': order.id}), - return_url=reverse('shop:order_paid', kwargs={'orderid': order.id}), + callback_url=reverse( + 'shop:coinfy_callback', + kwargs={'orderid': order.id} + ), + return_url=reverse( + 'shop:order_paid', + kwargs={'orderid': order.id} + ), ) if not response['success']: api_error = response['error'] - print "API error: %s (%s)" % (api_error['message'], api_error['code'] ) + print "API error: %s (%s)" % ( + api_error['message'], + api_error['code'] + ) invoice = response['data'] - ### change this to pass only needed data when we get that far + # change this to pass only needed data when we get that far context['invoice'] = invoice return context