You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

views.py 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. import csv
  2. import datetime
  3. from decimal import Decimal as D, InvalidOperation
  4. from django.contrib import messages
  5. from django.core.urlresolvers import reverse
  6. from django.core.exceptions import ObjectDoesNotExist
  7. from django.db.models.loading import get_model
  8. from django.db.models import Sum, Count, fields, Q
  9. from django.http import HttpResponse, HttpResponseRedirect, Http404
  10. from django.shortcuts import get_object_or_404
  11. from django.template.defaultfilters import date as format_date
  12. from django.utils.datastructures import SortedDict
  13. from django.views.generic import ListView, DetailView, UpdateView, FormView
  14. from oscar.core.loading import get_class
  15. from oscar.apps.dashboard.orders import forms
  16. from oscar.apps.dashboard.views import BulkEditMixin
  17. from oscar.apps.payment.exceptions import PaymentError
  18. Order = get_model('order', 'Order')
  19. OrderNote = get_model('order', 'OrderNote')
  20. ShippingAddress = get_model('order', 'ShippingAddress')
  21. Line = get_model('order', 'Line')
  22. ShippingEventType = get_model('order', 'ShippingEventType')
  23. PaymentEventType = get_model('order', 'PaymentEventType')
  24. EventHandler = get_class('order.processing', 'EventHandler')
  25. class OrderSummaryView(FormView):
  26. template_name = 'dashboard/orders/summary.html'
  27. form_class = forms.OrderSummaryForm
  28. def get(self, request, *args, **kwargs):
  29. if 'date_from' in request.GET or 'date_to' in request.GET:
  30. return self.post(request, *args, **kwargs)
  31. return super(OrderSummaryView, self).get(request, *args, **kwargs)
  32. def form_valid(self, form):
  33. ctx = self.get_context_data(form=form,
  34. filters=form.get_filters())
  35. return self.render_to_response(ctx)
  36. def get_form_kwargs(self):
  37. kwargs = super(OrderSummaryView, self).get_form_kwargs()
  38. kwargs['data'] = self.request.GET
  39. return kwargs
  40. def get_context_data(self, **kwargs):
  41. ctx = super(OrderSummaryView, self).get_context_data(**kwargs)
  42. filters = kwargs.get('filters', {})
  43. ctx.update(self.get_stats(filters))
  44. return ctx
  45. def get_stats(self, filters):
  46. orders = Order.objects.filter(**filters)
  47. stats = {
  48. 'total_orders': orders.count(),
  49. 'total_lines': Line.objects.filter(order__in=orders).count(),
  50. 'total_revenue': orders.aggregate(Sum('total_incl_tax'))['total_incl_tax__sum'] or D('0.00'),
  51. 'order_status_breakdown': orders.order_by('status').values('status').annotate(freq=Count('id'))
  52. }
  53. return stats
  54. class OrderListView(ListView, BulkEditMixin):
  55. model = Order
  56. context_object_name = 'orders'
  57. template_name = 'dashboard/orders/order_list.html'
  58. form_class = forms.OrderSearchForm
  59. base_description = 'All orders'
  60. paginate_by = 25
  61. description = ''
  62. actions = ('download_selected_orders',)
  63. current_view = 'dashboard:order-list'
  64. def get(self, request, *args, **kwargs):
  65. if 'order_number' in request.GET and request.GET.get('response_format', None) == 'html':
  66. try:
  67. order = Order.objects.get(number=request.GET['order_number'])
  68. except Order.DoesNotExist:
  69. pass
  70. else:
  71. return HttpResponseRedirect(reverse('dashboard:order-detail', kwargs={'number': order.number}))
  72. return super(OrderListView, self).get(request, *args, **kwargs)
  73. def get_queryset(self):
  74. """
  75. Build the queryset for this list and also update the title that
  76. describes the queryset
  77. """
  78. queryset = self.model.objects.all().order_by('-date_placed')
  79. self.description = self.base_description
  80. # Look for shortcut query filters
  81. if 'order_status' in self.request.GET:
  82. self.form = self.form_class()
  83. status = self.request.GET['order_status']
  84. if status.lower() == 'none':
  85. self.description = "Orders without an order status"
  86. status = None
  87. else:
  88. self.description = "Orders with status '%s'" % status
  89. return self.model.objects.filter(status=status)
  90. if 'order_number' not in self.request.GET:
  91. self.form = self.form_class()
  92. return queryset
  93. self.form = self.form_class(self.request.GET)
  94. if not self.form.is_valid():
  95. return queryset
  96. data = self.form.cleaned_data
  97. if data['order_number']:
  98. queryset = self.model.objects.filter(number__istartswith=data['order_number'])
  99. self.description = 'Orders with number starting with "%s"' % data['order_number']
  100. if data['name']:
  101. # If the value is two words, then assume they are first name and last name
  102. parts = data['name'].split()
  103. if len(parts) == 2:
  104. queryset = queryset.filter(Q(user__first_name__istartswith=parts[0]) |
  105. Q(user__last_name__istartswith=parts[1])).distinct()
  106. else:
  107. queryset = queryset.filter(Q(user__first_name__istartswith=data['name']) |
  108. Q(user__last_name__istartswith=data['name'])).distinct()
  109. self.description += " with customer name matching '%s'" % data['name']
  110. if data['product_title']:
  111. queryset = queryset.filter(lines__title__istartswith=data['product_title']).distinct()
  112. self.description += " including an item with title matching '%s'" % data['product_title']
  113. if data['product_id']:
  114. queryset = queryset.filter(Q(lines__upc=data['product_id']) |
  115. Q(lines__product_id=data['product_id'])).distinct()
  116. self.description += " including an item with ID '%s'" % data['product_id']
  117. if data['date_from'] and data['date_to']:
  118. # Add 24 hours to make search inclusive
  119. date_to = data['date_to'] + datetime.timedelta(days=1)
  120. queryset = queryset.filter(date_placed__gte=data['date_from']).filter(date_placed__lt=date_to)
  121. self.description += " placed between %s and %s" % (format_date(data['date_from']), format_date(data['date_to']))
  122. elif data['date_from']:
  123. queryset = queryset.filter(date_placed__gte=data['date_from'])
  124. self.description += " placed since %s" % format_date(data['date_from'])
  125. elif data['date_to']:
  126. date_to = data['date_to'] + datetime.timedelta(days=1)
  127. queryset = queryset.filter(date_placed__lt=date_to)
  128. self.description += " placed before %s" % format_date(data['date_to'])
  129. if data['voucher']:
  130. queryset = queryset.filter(discounts__voucher_code=data['voucher']).distinct()
  131. self.description += " using voucher '%s'" % data['voucher']
  132. if data['payment_method']:
  133. queryset = queryset.filter(sources__source_type__code=data['payment_method']).distinct()
  134. self.description += " paid for by %s" % data['payment_method']
  135. if data['status']:
  136. queryset = queryset.filter(status=data['status'])
  137. self.description += " with status %s" % data['status']
  138. return queryset
  139. def get_context_data(self, **kwargs):
  140. ctx = super(OrderListView, self).get_context_data(**kwargs)
  141. ctx['queryset_description'] = self.description
  142. ctx['form'] = self.form
  143. return ctx
  144. def is_csv_download(self):
  145. return self.request.GET.get('response_format', None) == 'csv'
  146. def get_paginate_by(self, queryset):
  147. return None if self.is_csv_download() else self.paginate_by
  148. def render_to_response(self, context):
  149. if self.is_csv_download():
  150. return self.download_selected_orders(self.request, context['object_list'])
  151. return super(OrderListView, self).render_to_response(context)
  152. def get_download_filename(self, request):
  153. return 'orders.csv'
  154. def download_selected_orders(self, request, orders):
  155. response = HttpResponse(mimetype='text/csv')
  156. response['Content-Disposition'] = 'attachment; filename=%s' % self.get_download_filename(request)
  157. writer = csv.writer(response, delimiter=',')
  158. meta_data = (('number', 'Order number'),
  159. ('value', 'Order value'),
  160. ('date', 'Date of purchase'),
  161. ('num_items', 'Number of items'),
  162. ('status', 'Order status'),
  163. ('shipping_address_name', 'Deliver to name'),
  164. ('billing_address_name', 'Bill to name'),
  165. )
  166. columns = SortedDict()
  167. for k, v in meta_data:
  168. columns[k] = v
  169. writer.writerow(columns.values())
  170. for order in orders:
  171. row = columns.copy()
  172. row['number'] = order.number
  173. row['value'] = order.total_incl_tax
  174. row['date'] = order.date_placed
  175. row['num_items'] = order.num_items
  176. row['status'] = order.status
  177. if order.shipping_address:
  178. row['shipping_address_name'] = order.shipping_address.name()
  179. else:
  180. row['shipping_address_name'] = ''
  181. if order.billing_address:
  182. row['billing_address_name'] = order.billing_address.name()
  183. else:
  184. row['billing_address_name'] = ''
  185. encoded_values = [unicode(value).encode('utf8') for value in row.values()]
  186. writer.writerow(encoded_values)
  187. return response
  188. class OrderDetailView(DetailView):
  189. model = Order
  190. context_object_name = 'order'
  191. template_name = 'dashboard/orders/order_detail.html'
  192. order_actions = ('save_note', 'delete_note', 'change_order_status',
  193. 'create_order_payment_event')
  194. line_actions = ('change_line_statuses', 'create_shipping_event',
  195. 'create_payment_event')
  196. def get_object(self):
  197. return get_object_or_404(self.model, number=self.kwargs['number'])
  198. def get_context_data(self, **kwargs):
  199. ctx = super(OrderDetailView, self).get_context_data(**kwargs)
  200. ctx['note_form'] = self.get_order_note_form()
  201. ctx['line_statuses'] = Line.all_statuses()
  202. ctx['shipping_event_types'] = ShippingEventType.objects.all()
  203. ctx['payment_event_types'] = PaymentEventType.objects.all()
  204. return ctx
  205. def get_order_note_form(self):
  206. post_data = None
  207. kwargs = {}
  208. if self.request.method == 'POST':
  209. post_data = self.request.POST
  210. note_id = self.kwargs.get('note_id', None)
  211. if note_id:
  212. note = get_object_or_404(OrderNote, order=self.object, id=note_id)
  213. kwargs['instance'] = note
  214. return forms.OrderNoteForm(post_data, **kwargs)
  215. def post(self, request, *args, **kwargs):
  216. self.object = self.get_object()
  217. order = self.object
  218. # Look for order-level action
  219. order_action = request.POST.get('order_action', '').lower()
  220. if order_action:
  221. if order_action not in self.order_actions:
  222. messages.error(self.request, "Invalid action")
  223. return self.reload_page_response()
  224. else:
  225. return getattr(self, order_action)(request, order)
  226. # Look for line-level action
  227. line_action = request.POST.get('line_action', '').lower()
  228. if line_action:
  229. if line_action not in self.line_actions:
  230. messages.error(self.request, "Invalid action")
  231. return self.reload_page_response()
  232. else:
  233. line_ids = request.POST.getlist('selected_line')
  234. line_quantities = [int(qty) for qty in request.POST.getlist('selected_line_qty')]
  235. lines = order.lines.filter(id__in=line_ids)
  236. if lines.count() == 0:
  237. messages.error(self.request, "You must select some lines to act on")
  238. return self.reload_page_response()
  239. return getattr(self, line_action)(request, order, lines, line_quantities)
  240. messages.error(request, "No valid action submitted")
  241. return self.reload_page_response()
  242. def reload_page_response(self):
  243. return HttpResponseRedirect(reverse('dashboard:order-detail', kwargs={'number': self.object.number}))
  244. def save_note(self, request, order):
  245. form = self.get_order_note_form()
  246. success_msg = "Note saved"
  247. if form.is_valid():
  248. note = form.save(commit=False)
  249. note.user = request.user
  250. note.order = order
  251. note.save()
  252. messages.success(self.request, success_msg)
  253. return self.reload_page_response()
  254. ctx = self.get_context_data(note_form=form)
  255. return self.render_to_response(ctx)
  256. def delete_note(self, request, order):
  257. try:
  258. note = order.notes.get(id=request.POST.get('note_id', None))
  259. except ObjectDoesNotExist:
  260. messages.error(request, "Note cannot be deleted")
  261. else:
  262. messages.info(request, "Note deleted")
  263. note.delete()
  264. return self.reload_page_response()
  265. def change_order_status(self, request, order):
  266. new_status = request.POST['new_status'].strip()
  267. if not new_status:
  268. messages.error(request, "The new status '%s' is not valid" % new_status)
  269. return self.reload_page_response()
  270. if not new_status in order.available_statuses():
  271. messages.error(request, "The new status '%s' is not valid for this order" % new_status)
  272. return self.reload_page_response()
  273. handler = EventHandler()
  274. try:
  275. handler.handle_order_status_change(order, new_status)
  276. except PaymentError, e:
  277. messages.error(request, "Unable to change order status due to payment error: %s" % e)
  278. else:
  279. msg = "Order status changed from '%s' to '%s'" % (order.status, new_status)
  280. messages.info(request, msg)
  281. order.notes.create(user=request.user, message=msg,
  282. note_type=OrderNote.SYSTEM)
  283. return self.reload_page_response()
  284. def change_line_statuses(self, request, order, lines, quantities):
  285. new_status = request.POST['new_status'].strip()
  286. if not new_status:
  287. messages.error(request, "The new status '%s' is not valid" % new_status)
  288. return self.reload_page_response()
  289. errors = []
  290. for line in lines:
  291. if new_status not in line.available_statuses():
  292. errors.append("'%s' is not a valid new status for line %d" % (
  293. new_status, line.id))
  294. if errors:
  295. messages.error(request, "\n".join(errors))
  296. return self.reload_page_response()
  297. msgs = []
  298. for line in lines:
  299. msg = "Status of line %d changed from '%s' to '%s'" % (
  300. line.id, line.status, new_status)
  301. msgs.append(msg)
  302. line.set_status(new_status)
  303. message = "\n".join(msgs)
  304. messages.info(request, message)
  305. order.notes.create(user=request.user, message=message,
  306. note_type=OrderNote.SYSTEM)
  307. return self.reload_page_response()
  308. def create_shipping_event(self, request, order, lines, quantities):
  309. code = request.POST['shipping_event_type']
  310. try:
  311. event_type = ShippingEventType._default_manager.get(code=code)
  312. except ShippingEventType.DoesNotExist:
  313. messages.error(request, "The event type '%s' is not valid" % code)
  314. return self.reload_page_response()
  315. reference = request.POST.get('reference', None)
  316. try:
  317. EventHandler().handle_shipping_event(order, event_type, lines,
  318. quantities,
  319. reference=reference)
  320. except PaymentError, e:
  321. messages.error(request, "Unable to change order status due to payment error: %s" % e)
  322. else:
  323. messages.info(request, "Shipping event created")
  324. return self.reload_page_response()
  325. def create_order_payment_event(self, request, order):
  326. amount_str = request.POST.get('amount', None)
  327. try:
  328. amount = D(amount_str)
  329. except InvalidOperation:
  330. messages.error(request, "Please choose a valid amount")
  331. return self.reload_page_response()
  332. return self._create_payment_event(request, order, amount)
  333. def _create_payment_event(self, request, order, amount, lines=None,
  334. quantities=None):
  335. code = request.POST['payment_event_type']
  336. try:
  337. event_type = PaymentEventType._default_manager.get(code=code)
  338. except PaymentEventType.DoesNotExist:
  339. messages.error(request, "The event type '%s' is not valid" % code)
  340. return self.reload_page_response()
  341. try:
  342. EventHandler().handle_payment_event(order, event_type, amount,
  343. lines, quantities)
  344. except PaymentError, e:
  345. messages.error(request, "Unable to change order status due to payment error: %s" % e)
  346. else:
  347. messages.info(request, "Payment event created")
  348. return self.reload_page_response()
  349. def create_payment_event(self, request, order, lines, quantities):
  350. amount_str = request.POST.get('amount', None)
  351. if not amount_str:
  352. amount = D('0.00')
  353. for line, quantity in zip(lines, quantities):
  354. amount += int(quantity) * line.line_price_incl_tax
  355. else:
  356. try:
  357. amount = D(amount_str)
  358. except InvalidOperation:
  359. messages.error(request, "Please choose a valid amount")
  360. return self.reload_page_response()
  361. return self._create_payment_event(request, order, amount, lines,
  362. quantities)
  363. class LineDetailView(DetailView):
  364. model = Line
  365. context_object_name = 'line'
  366. template_name = 'dashboard/orders/line_detail.html'
  367. def get_object(self, queryset=None):
  368. try:
  369. return self.model.objects.get(pk=self.kwargs['line_id'])
  370. except self.model.DoesNotExist:
  371. raise Http404()
  372. def get_context_data(self, **kwargs):
  373. ctx = super(LineDetailView, self).get_context_data(**kwargs)
  374. ctx['order'] = self.object.order
  375. return ctx
  376. def get_changes_between_models(model1, model2, excludes=[]):
  377. changes = {}
  378. for field in model1._meta.fields:
  379. if not (isinstance(field, (fields.AutoField, fields.related.RelatedField))
  380. or field.name in excludes):
  381. if field.value_from_object(model1) != field.value_from_object(model2):
  382. changes[field.verbose_name] = (field.value_from_object(model1),
  383. field.value_from_object(model2))
  384. return changes
  385. def get_change_summary(model1, model2):
  386. """
  387. Generate a summary of the changes between two address models
  388. """
  389. changes = get_changes_between_models(model1, model2, ['search_text'])
  390. change_descriptions = []
  391. for field, delta in changes.items():
  392. change_descriptions.append(u"%s changed from '%s' to '%s'" % (field, delta[0], delta[1]))
  393. return "\n".join(change_descriptions)
  394. class ShippingAddressUpdateView(UpdateView):
  395. model = ShippingAddress
  396. context_object_name = 'address'
  397. template_name = 'dashboard/orders/shippingaddress_form.html'
  398. form_class = forms.ShippingAddressForm
  399. def get_object(self):
  400. return get_object_or_404(self.model, order__number=self.kwargs['number'])
  401. def get_context_data(self, **kwargs):
  402. ctx = super(ShippingAddressUpdateView, self).get_context_data(**kwargs)
  403. ctx['order'] = self.object.order
  404. return ctx
  405. def form_valid(self, form):
  406. old_address = ShippingAddress.objects.get(id=self.object.id)
  407. response = super(ShippingAddressUpdateView, self).form_valid(form)
  408. changes = get_change_summary(old_address, self.object)
  409. if changes:
  410. msg = "Delivery address updated:\n%s" % changes
  411. self.object.order.notes.create(user=self.request.user, message=msg,
  412. note_type=OrderNote.SYSTEM)
  413. return response
  414. def get_success_url(self):
  415. messages.info(self.request, "Delivery address updated")
  416. return reverse('dashboard:order-detail', kwargs={'number': self.object.order.number, })