diff options
author | Filipp Lepalaan <filipp@mac.com> | 2015-08-04 10:11:24 +0300 |
---|---|---|
committer | Filipp Lepalaan <filipp@mac.com> | 2015-08-04 10:11:24 +0300 |
commit | 63b0fc6269b38edf7234b9f151b80d81f614c0a3 (patch) | |
tree | 555de3068f33f8dddb4619349bbea7d9b7c822fd /servo/views | |
download | Servo-63b0fc6269b38edf7234b9f151b80d81f614c0a3.tar.gz Servo-63b0fc6269b38edf7234b9f151b80d81f614c0a3.tar.bz2 Servo-63b0fc6269b38edf7234b9f151b80d81f614c0a3.zip |
Initial commit
First public commit
Diffstat (limited to 'servo/views')
-rw-r--r-- | servo/views/__init__.py | 0 | ||||
-rw-r--r-- | servo/views/account.py | 450 | ||||
-rw-r--r-- | servo/views/admin.py | 778 | ||||
-rw-r--r-- | servo/views/api.py | 401 | ||||
-rw-r--r-- | servo/views/checkin.py | 418 | ||||
-rw-r--r-- | servo/views/customer.py | 505 | ||||
-rw-r--r-- | servo/views/device.py | 605 | ||||
-rw-r--r-- | servo/views/error.py | 53 | ||||
-rw-r--r-- | servo/views/events.py | 44 | ||||
-rw-r--r-- | servo/views/files.py | 52 | ||||
-rw-r--r-- | servo/views/gsx.py | 349 | ||||
-rw-r--r-- | servo/views/invoices.py | 199 | ||||
-rw-r--r-- | servo/views/note.py | 435 | ||||
-rw-r--r-- | servo/views/order.py | 990 | ||||
-rw-r--r-- | servo/views/product.py | 474 | ||||
-rw-r--r-- | servo/views/purchases.py | 242 | ||||
-rw-r--r-- | servo/views/queue.py | 40 | ||||
-rw-r--r-- | servo/views/rules.py | 101 | ||||
-rw-r--r-- | servo/views/search.py | 254 | ||||
-rw-r--r-- | servo/views/shipments.py | 392 | ||||
-rw-r--r-- | servo/views/stats.py | 443 | ||||
-rw-r--r-- | servo/views/tags.py | 37 |
22 files changed, 7262 insertions, 0 deletions
diff --git a/servo/views/__init__.py b/servo/views/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/servo/views/__init__.py diff --git a/servo/views/account.py b/servo/views/account.py new file mode 100644 index 0000000..39193b6 --- /dev/null +++ b/servo/views/account.py @@ -0,0 +1,450 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import csv +import pytz +from datetime import date + +from django.contrib import auth +from django.utils import timezone, translation + +from django.contrib import messages +from django.http import HttpResponse +from django.core.urlresolvers import reverse +from django.shortcuts import redirect, render +from dateutil.relativedelta import relativedelta +from django.utils.translation import ugettext as _ +from django.contrib.auth.decorators import permission_required +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from servo.views.order import prepare_list_view + +from servo.models import Order, User, Calendar, CalendarEvent +from servo.forms.account import ProfileForm, RegistrationForm, LoginForm + + +def settings(request, username): + """ + User editing their profile preferences + """ + title = _("Profile Settings") + form = ProfileForm(instance=request.user) + + if request.method == "POST": + + form = ProfileForm(request.POST, request.FILES, instance=request.user) + + if form.is_valid(): + user = form.save() + messages.success(request, _("Settings saved")) + User.refresh_nomail() + + if form.cleaned_data['password1']: + request.user.set_password(form.cleaned_data['password1']) + request.user.save() + + lang = user.activate_locale() + translation.activate(lang) + request.session[translation.LANGUAGE_SESSION_KEY] = lang + request.session['django_timezone'] = user.timezone + + return redirect(settings, username) + else: + print("Error in user settings: %s" % form.errors) + messages.error(request, _("Error in user details")) + + return render(request, "accounts/settings.html", locals()) + + +def orders(request, username): + """ + This is basically like orders/index, but limited to the user + First, filter by the provided search criteria, + then check if we have a saved search filter + then default to user id + Always update saved search filter + """ + args = request.GET.copy() + + if not args: + args = request.session.get("account_search_filter", args) + + if not args: + args.update({'state': 1}) # default to open cases + + # Filter by the user, no matter what + args.update({'followed_by': request.user.pk}) + request.session['account_search_filter'] = args + + data = prepare_list_view(request, args) + data['title'] = _("My Orders") + + del(data['form'].fields['assigned_to']) + + return render(request, "accounts/orders.html", data) + + +def login(request): + """ + User trying to log in + """ + title = _("Sign In") + form = LoginForm() + + if 'username' in request.POST: + + form = LoginForm(request.POST) + + if form.is_valid(): + user = auth.authenticate( + username=form.cleaned_data['username'], + password=form.cleaned_data['password'] + ) + + if user is None: + messages.error(request, _("Incorrect username or password")) + elif not user.is_active: + messages.error(request, _("Your account has been deactivated")) + else: + auth.login(request, user) + + if user.location: + lang = user.activate_locale() + request.session['django_language'] = lang + request.session['django_timezone'] = user.timezone + + messages.success(request, _(u"%s logged in") % user.get_full_name()) + + if request.GET.get('next'): + return redirect(request.GET['next']) + else: + return redirect(orders, username=user.username) + else: + messages.error(request, _("Invalid input for login")) + + return render(request, "accounts/login.html", locals()) + + +def logout(request): + if request.method == 'POST': + auth.logout(request) + messages.info(request, _("You have logged out")) + + return redirect(login) + + return render(request, "accounts/logout.html") + + +@permission_required("servo.add_calendar") +def calendars(request, username=None): + data = {'title': _('Calendars')} + data['calendars'] = Calendar.objects.filter(user=request.user) + + if data['calendars'].count() > 0: + cal = data['calendars'][0] + return redirect(view_calendar, username, cal.pk) + + return render(request, "accounts/calendars.html", data) + + +@permission_required("servo.add_calendar") +def prepare_calendar_view(request, pk, view, start_date): + """ + Prepares a calendar detail view for other views to use + """ + calendar = Calendar.objects.get(user=request.user, pk=pk) + + if start_date is not None: + year, month, day = start_date.split("-") + start_date = date(int(year), int(month), int(day)) + else: + start_date = timezone.now().date() + + start = start_date + finish = start_date + relativedelta(days=+1) + + if view == "week": + start = start_date + relativedelta(day=1) + finish = start_date + relativedelta(weeks=+1) + + if view == "month": + start = start_date + relativedelta(day=1) + finish = start_date + relativedelta(day=1, months=+1, days=-1) + + data = {'title': "%s %s - %s" % (calendar.title, start.strftime("%x"), + finish.strftime("%x"))} + + data['view'] = view + data['start'] = start + data['finish'] = finish + + data['next'] = finish + relativedelta(days=+1) + data['previous'] = start + relativedelta(days=-1) + + data['calendars'] = Calendar.objects.filter(user=request.user) + data['events'] = calendar.calendarevent_set.filter( + started_at__range=(start, finish) + ) + + data['calendar'] = calendar + data['subtitle'] = calendar.subtitle(start, finish) + + return data + + +@permission_required("servo.add_calendar") +def download_calendar(request, username, pk, view): + calendar = Calendar.objects.get(pk=pk) + + response = HttpResponse(content_type="text/csv") + response['Content-Disposition'] = 'attachment; filename="%s.csv"' % calendar.title + writer = csv.writer(response) + writer.writerow(['START', 'FINISH', 'HOURS', 'NOTES']) + + for e in calendar.calendarevent_set.all(): + writer.writerow([e.started_at, e.finished_at, e.get_hours(), e.notes]) + + return response + + +@permission_required("servo.add_calendar") +def print_calendar(request, username, pk, view, start_date): + data = prepare_calendar_view(request, pk, view, start_date) + calendar = data['calendar'] + + data['location'] = request.user.location + # Don't show unfinished events in the report + data['events'] = data['events'].exclude(finished_at=None) + data['subtitle'] = calendar.subtitle(data['start'], data['finish']) + return render(request, "accounts/print_calendar.html", data) + + +@permission_required("servo.add_calendar") +def view_calendar(request, username, pk, view, start_date=None): + data = prepare_calendar_view(request, pk, view, start_date) + data['base_url'] = reverse(view_calendar, args=[username, pk, view]) + + return render(request, "accounts/view_calendar.html", data) + + +@permission_required("servo.delete_calendar") +def delete_calendar(request, username, pk): + calendar = Calendar.objects.get(pk=pk) + + if calendar.user != request.user: + messages.error(request, _("Users can only delete their own calendars!")) + + return redirect(calendars, username=username) + + if request.method == "POST": + calendar.delete() + messages.success(request, _('Calendar deleted')) + return redirect(calendars, username=request.user.username) + + data = {'title': _("Really delete this calendar?")} + data['action'] = request.path + + return render(request, "accounts/delete_calendar.html", data) + + +@permission_required("servo.change_calendar") +def edit_calendar(request, username, pk=None, view="week"): + from servo.models.calendar import CalendarForm + calendar = Calendar(user=request.user) + + if pk is not None: + calendar = Calendar.objects.get(pk=pk) + + if request.method == "POST": + form = CalendarForm(request.POST, instance=calendar) + + if form.is_valid(): + calendar = form.save() + messages.success(request, _("Calendar saved")) + return redirect(view_calendar, username, calendar.pk, 'week') + + form = CalendarForm(instance=calendar) + + data = {'title': calendar.title} + data['form'] = form + data['action'] = request.path + + return render(request, "accounts/calendar_form.html", data) + + +@permission_required('servo.change_calendar') +def edit_calendar_event(request, username, cal_pk, pk=None): + from servo.models.calendar import CalendarEventForm + + calendar = Calendar.objects.get(pk=cal_pk) + event = CalendarEvent(calendar=calendar) + + if pk: + event = CalendarEvent.objects.get(pk=pk) + else: + event.save() + messages.success(request, _(u'Calendar event created')) + return redirect(event.calendar) + + form = CalendarEventForm(instance=event) + + if request.method == 'POST': + form = CalendarEventForm(request.POST, instance=event) + + if form.is_valid(): + event = form.save() + messages.success(request, _(u'Event saved')) + return redirect(event.calendar) + + data = {'title': _(u'Edit Event')} + data['form'] = form + data['calendars'] = Calendar.objects.filter(user=request.user) + + return render(request, 'accounts/edit_calendar_event.html', data) + + +@permission_required("servo.change_calendar") +def finish_calendar_event(request, username, cal_pk, pk): + event = CalendarEvent.objects.get(pk=pk) + event.set_finished() + messages.success(request, _(u'Calendar event updated')) + + return redirect(view_calendar, username, cal_pk, 'week') + + +def delete_calendar_event(request, username, cal_pk, pk): + if username != request.user.username: + messages.error(request, _(u'Users can only delete their own events!')) + + return redirect(calendars, username=request.user.username) + + event = CalendarEvent.objects.get(pk=pk) + + if request.method == 'POST': + event.delete() + messages.success(request, _('Calendar event deleted')) + return redirect(event.calendar) + + data = {'title': _(u'Really delete this event?')} + data['action'] = request.path + return render(request, 'accounts/delete_calendar_event.html', data) + + +def register(request): + """ + New user applying for access + """ + form = RegistrationForm() + data = {'title': _("Register")} + + if request.method == 'POST': + + form = RegistrationForm(request.POST) + + if form.is_valid(): + user = User(is_active=False) + user.email = form.cleaned_data['email'] + user.last_name = form.cleaned_data['last_name'] + user.first_name = form.cleaned_data['first_name'] + user.set_password(form.cleaned_data['password']) + user.save() + + messages.success(request, _(u'Your registration is now pending approval.')) + + return redirect(login) + + data['form'] = form + return render(request, 'accounts/register.html', data) + + +def clear_notifications(request, username): + from datetime import datetime + ts = [int(x) for x in request.GET.get('t').split('/')] + ts = datetime(*ts, tzinfo=timezone.get_current_timezone()) + notif = request.user.notifications.filter(handled_at=None) + notif.filter(triggered_at__lt=ts).update(handled_at=timezone.now()) + messages.success(request, _('All notifications cleared')) + return redirect(request.META['HTTP_REFERER']) + + +def search(request, username): + """ + User searching for something from their homepage + """ + query = request.GET.get("q") + + if not query or len(query) < 3: + messages.error(request, _('Search query is too short')) + return redirect('accounts-list_orders', username) + + request.session['search_query'] = query + + # Redirect Order ID:s to the order + try: + order = Order.objects.get(code__iexact=query) + return redirect(order) + except Order.DoesNotExist: + pass + + kwargs = request.GET.copy() + kwargs.update({'followed_by': request.user.pk}) + data = prepare_list_view(request, kwargs) + + data['title'] = _("Search results") + orders = data['queryset'] + data['orders'] = orders.filter(customer__fullname__icontains=query) + + return render(request, "accounts/orders.html", data) + + +def stats(request, username): + from servo.views.stats import prep_view, BasicStatsForm + data = prep_view(request) + form = BasicStatsForm(initial=data['initial']) + if request.method == 'POST': + form = BasicStatsForm(request.POST, initial=data['initial']) + if form.is_valid(): + request.session['stats_filter'] = form.cleaned_data + data['form'] = form + return render(request, "accounts/stats.html", data) + + +def updates(request, username): + title = _('Updates') + kind = request.GET.get('kind', 'note_added') + events = request.user.notifications.filter(action=kind) + + page = request.GET.get("page") + paginator = Paginator(events, 100) + + try: + events = paginator.page(page) + except PageNotAnInteger: + events = paginator.page(1) + except EmptyPage: + events = paginator.page(paginator.num_pages) + + return render(request, "accounts/updates.html", locals()) diff --git a/servo/views/admin.py b/servo/views/admin.py new file mode 100644 index 0000000..91f73a9 --- /dev/null +++ b/servo/views/admin.py @@ -0,0 +1,778 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import gsxws + +from django.db import IntegrityError, transaction + +from django.core.cache import cache +from django.contrib import messages +from django.shortcuts import render, redirect, get_object_or_404 + +from django.conf import settings as app_settings +from django.utils.translation import ugettext as _ + +from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth.models import Group + +from django.forms.models import (inlineformset_factory, + modelform_factory, + modelformset_factory,) + +from servo.forms.admin import * +from servo.models.common import * +from servo.models.repair import Checklist, ChecklistItem +from servo.models.account import User, Group +from servo.models.product import ShippingMethod + + +def prep_list_view(model): + title = model._meta.verbose_name_plural + object_list = model.objects.all() + return locals() + + +@staff_member_required +def list_gsx_accounts(request): + object_list = GsxAccount.objects.all() + title = GsxAccount._meta.verbose_name_plural + + if object_list.count() > 0: + return redirect(object_list[0].get_admin_url()) + + return render(request, 'admin/gsx/index.html', locals()) + + +@staff_member_required +def edit_gsx_account(request, pk=None): + object_list = GsxAccount.objects.all() + title = GsxAccount._meta.verbose_name_plural + + if pk is None: + act = GsxAccount() + else: + act = GsxAccount.objects.get(pk=pk) + + form = GsxAccountForm(instance=act) + + if request.method == 'POST': + form = GsxAccountForm(request.POST, instance=act) + if form.is_valid(): + try: + act = form.save() + cache.delete('gsx_session') + try: + act.test() + messages.success(request, _(u'%s saved') % act.title) + return redirect(list_gsx_accounts) + except gsxws.GsxError, e: + messages.warning(request, e) + except IntegrityError: + transaction.rollback() + msg = _('GSX account for this sold-to and environment already exists') + messages.error(request, msg) + + return render(request, 'admin/gsx/form.html', locals()) + + +@staff_member_required +def delete_gsx_account(request, pk=None): + act = GsxAccount.objects.get(pk=pk) + if request.method == 'POST': + try: + act.delete() + messages.success(request, _("GSX account deleted")) + except Exception, e: + messages.error(request, e) + + return redirect(list_gsx_accounts) + + return render(request, 'admin/gsx/remove.html', {'action': request.path}) + + +@staff_member_required +def checklists(request): + object_list = Checklist.objects.all() + title = Checklist._meta.verbose_name_plural + + if object_list.count() > 0: + return redirect(object_list[0].get_admin_url()) + + return render(request, 'admin/checklist/index.html', locals()) + + +@staff_member_required +def edit_checklist(request, pk=None): + object_list = Checklist.objects.all() + title = Checklist._meta.verbose_name_plural + ChecklistItemFormset = inlineformset_factory(Checklist, ChecklistItem, exclude=[]) + + if pk is None: + checklist = Checklist() + else: + checklist = Checklist.objects.get(pk=pk) + + form = ChecklistForm(instance=checklist) + formset = ChecklistItemFormset(instance=checklist) + + if request.method == 'POST': + form = ChecklistForm(request.POST, instance=checklist) + + if form.is_valid(): + checklist = form.save() + formset = ChecklistItemFormset(request.POST, instance=checklist) + + if formset.is_valid(): + formset.save() + messages.success(request, _('Checklist saved')) + return redirect(checklist.get_admin_url()) + + return render(request, 'admin/checklist/form.html', locals()) + + +@staff_member_required +def delete_checklist(request, pk): + checklist = Checklist.objects.get(pk=pk) + + if request.method == 'POST': + checklist.delete() + messages.success(request, _('Checklist deleted')) + return redirect(checklists) + + action = str(request.path) + title = _('Really delete this checklist?') + explanation = _('This will also delete all checklist values.') + + return render(request, 'generic/delete.html', locals()) + + +@staff_member_required +def tags(request, type=None): + if type is None: + type = Tag.TYPES[0][0] + + title = Checklist._meta.verbose_name_plural + object_list = Tag.objects.filter(type=type) + + if object_list.count() > 0: + return redirect(object_list[0].get_admin_url()) + + types = Tag.TYPES + + return render(request, 'admin/tags/index.html', locals()) + + +@staff_member_required +def edit_tag(request, type, pk=None): + if pk is None: + tag = Tag(type=type) + else: + tag = Tag.objects.get(pk=pk) + + TagForm = modelform_factory(Tag, exclude=[]) + form = TagForm(instance=tag) + + if request.method == 'POST': + form = TagForm(request.POST, instance=tag) + + if form.is_valid(): + tag = form.save() + messages.success(request, _(u'Tag %s saved') % tag.title) + return redirect(edit_tag, tag.type, tag.pk) + + types = Tag.TYPES + title = Tag._meta.verbose_name_plural + object_list = Tag.objects.filter(type=type) + return render(request, 'admin/tags/form.html', locals()) + + +@staff_member_required +def delete_tag(request, pk): + tag = Tag.objects.get(pk=pk) + + if request.method == 'POST': + tag.delete() + messages.success(request, _('Tag deleted')) + return redirect(tags, type=tag.type) + + title = _('Really delete this tag?') + action = str(request.path) + + return render(request, 'generic/delete.html', locals()) + + +@staff_member_required +def settings(request): + title = _('System Settings') + ShippingMethodFormset = modelformset_factory(ShippingMethod, + can_delete=True, + extra=0, + exclude=[]) + formset = ShippingMethodFormset(queryset=ShippingMethod.objects.all()) + + if request.method == 'POST': + form = SettingsForm(request.POST, request.FILES) + + if not form.is_valid(): + messages.error(request, _('Check your settings')) + return render(request, 'admin/settings.html', locals()) + + config = form.save() + + if request.POST.get('update_prices'): + from servo.models import Product + for p in Product.objects.filter(fixed_price=False): + p.set_stock_sales_price() + p.set_exchange_sales_price() + p.save() + + # formset = ShippingMethodFormset(request.POST) + + # if not formset.is_valid(): + # messages.error(request, _('Error in shipping method settings')) + # return render(request, 'admin/settings.html', locals()) + + # formset.save() + + messages.success(request, _('Settings saved')) + return redirect(settings) + + config = Configuration.conf() + form = SettingsForm(initial=config) + + return render(request, 'admin/settings.html', locals()) + + +@staff_member_required +def statuses(request): + object_list = Status.objects.all() + title = Status._meta.verbose_name_plural + if object_list.count() > 0: + return redirect(edit_status, object_list[0].pk) + + return render(request, 'admin/statuses/index.html', locals()) + + +@staff_member_required +def edit_status(request, pk=None): + if pk is None: + status = Status() + else: + status = Status.objects.get(pk=pk) + + header = _(u'Statuses') + object_list = Status.objects.all() + form = StatusForm(instance=status) + title = Status._meta.verbose_name_plural + + if request.method == 'POST': + form = StatusForm(request.POST, instance=status) + if form.is_valid(): + status = form.save() + messages.success(request, _(u'%s saved') % status.title) + return redirect(edit_status, status.pk) + + return render(request, 'admin/statuses/form.html', locals()) + + +@staff_member_required +def remove_status(request, pk): + status = Status.objects.get(pk=pk) + action = request.path + + if request.method == 'POST': + status.delete() + messages.success(request, _(u'%s deleted') % status.title) + return redirect(statuses) + + return render(request, 'admin/statuses/remove.html', locals()) + + +@staff_member_required +def fields(request, type='customer'): + data = prep_list_view(Property) + data['type'] = type + data['types'] = Property.TYPES + data['object_list'] = Property.objects.filter(type=type) + + if data['object_list'].count() > 0: + field = data['object_list'][0] + return redirect(edit_field, field.type, field.pk) + + return render(request, 'admin/fields/index.html', data) + + +@staff_member_required +def edit_field(request, type, pk=None): + if pk is None: + field = Property(type=type) + else: + field = Property.objects.get(pk=pk) + + FieldForm = modelform_factory(Property, exclude=[]) + + types = Property.TYPES + title = Property._meta.verbose_name_plural + object_list = Property.objects.filter(type=type) + form = FieldForm(instance=field) + + if request.method == 'POST': + form = FieldForm(request.POST, instance=field) + + if form.is_valid(): + field = form.save() + messages.success(request, _(u'Field saved')) + return redirect(field.get_admin_url()) + + return render(request, 'admin/fields/form.html', locals()) + + +@staff_member_required +def delete_field(request, pk=None): + field = Property.objects.get(pk=pk) + + if request.method == 'POST': + field.delete() + messages.success(request, _(u'Field deleted')) + return redirect(fields, type=field.type) + + data = {'title': _('Really delete this field?')} + data['action'] = request.path + + return render(request, 'generic/delete.html', data) + + +@staff_member_required +def list_templates(request): + object_list = Template.objects.all() + title = Template._meta.verbose_name_plural + if object_list.count() > 0: + return redirect(object_list[0].get_admin_url()) + return render(request, "admin/templates/list_templates.html", locals()) + + +@staff_member_required +def edit_template(request, pk=None): + + if pk is None: + template = Template() + else: + template = Template.objects.get(pk=pk) + + form = TemplateForm(instance=template) + + if request.method == 'POST': + form = TemplateForm(request.POST, instance=template) + + if form.is_valid(): + template = form.save() + messages.success(request, _(u'Template %s saved') % template.title) + # generic view... + return redirect(template.get_admin_url()) + + form = form + object_list = Template.objects.all() + title = Template._meta.verbose_name_plural + return render(request, 'admin/templates/form.html', locals()) + + +@staff_member_required +def delete_template(request, pk): + template = Template.objects.get(pk=pk) + + if request.method == 'POST': + template.delete() + messages.success(request, _(u'Template %s deleted') % template.title) + return redirect(list_templates) + + title = _('Really delete this template?') + action = str(request.path) + return render(request, 'generic/delete.html', locals()) + + +@staff_member_required +def list_users(request): + object_list = User.objects.filter(is_visible=True) + title = User._meta.verbose_name_plural + locations = Location.objects.all() + + if object_list.count() > 0: + return redirect(object_list[0].get_admin_url()) + + return render(request, 'admin/users/index.html', locals()) + + +@staff_member_required +def list_groups(request): + object_list = Group.objects.all() + title = _('Users & Groups') + + return render(request, 'admin/users/groups.html', locals()) + + +@staff_member_required +def edit_group(request, pk=None): + title = _(u'Edit Group') + object_list = Group.objects.all() + + if pk is None: + group = Group() + form = GroupForm(instance=group) + else: + group = Group.objects.get(pk=pk) + title = group.name + form = GroupForm(instance=group) + + if request.method == 'POST': + form = GroupForm(request.POST, instance=group) + if form.is_valid(): + form.save() + messages.success(request, _(u'Group saved')) + return redirect(list_groups) + + return render(request, 'admin/users/group_form.html', locals()) + + +@staff_member_required +def delete_group(request, pk): + group = Group.objects.get(pk=pk) + + if request.method == "POST": + group.delete() + messages.success(request, _("Group deleted")) + return redirect(list_groups) + + data = {'action': request.path} + + return render(request, "admin/users/delete_group.html", data) + + +@staff_member_required +def delete_user(request, user_id): + user = User.objects.get(pk=user_id) + + if request.method == "POST": + try: + user.delete() + messages.success(request, _("User deleted")) + except Exception, e: + messages.error(request, e) + + return redirect(list_users) + + return render(request, "admin/users/remove.html", locals()) + + +@staff_member_required +def delete_user_token(request, user_id): + user = User.objects.get(pk=user_id) + user.delete_tokens() + messages.success(request, _('API tokens deleted')) + return redirect(edit_user, user.pk) + + +@staff_member_required +def create_user_token(request, user_id): + user = User.objects.get(pk=user_id) + token = user.create_token() + messages.success(request, _('API token created')) + return redirect(edit_user, user.pk) + + +@staff_member_required +def edit_user(request, pk=None): + if pk is None: + user = User(site_id=app_settings.SITE_ID) + user.location = request.user.location + user.locale = request.user.locale + user.region = request.user.region + user.timezone = request.user.timezone + else: + user = User.objects.get(pk=pk) + + form = UserForm(instance=user) + + if request.method == "POST": + form = UserForm(request.POST, instance=user) + if form.is_valid(): + user = form.save() + User.refresh_nomail() + if request.POST.get('password1'): + user.set_password(request.POST['password1']) + user.save() + messages.success(request, _(u"User %s saved") % user.get_name()) + return redirect(edit_user, user.pk) + else: + messages.error(request, _("Error in user profile data")) + + object_list = User.objects.filter(is_visible=True) + + if request.GET.get('l'): + object_list = object_list.filter(locations__pk=request.GET['l']) + + title = User._meta.verbose_name_plural + locations = Location.objects.all() + + if len(object_list) > 0: + header = _(u'%d users') % len(object_list) + + return render(request, "admin/users/form.html", locals()) + + +@staff_member_required +def locations(request): + object_list = Location.objects.all() + title = Location._meta.verbose_name_plural + + if object_list.count() > 0: + return redirect(object_list[0].get_admin_url()) + + return render(request, 'admin/locations/index.html', locals()) + + +@staff_member_required +def edit_location(request, pk=None): + header = _('Locations') + object_list = Location.objects.all() + title = Location._meta.verbose_name_plural + + if pk is None: + location = Location() + location.timezone = request.user.timezone + else: + location = Location.objects.get(pk=pk) + + form = LocationForm(instance=location) + + if request.method == 'POST': + form = LocationForm(request.POST, request.FILES, instance=location) + if form.is_valid(): + try: + location = form.save() + messages.success(request, _(u'Location %s saved') % location.title) + return redirect(location.get_admin_url()) + except Exception: + pass # just show the form with the error + + return render(request, 'admin/locations/form.html', locals()) + + +@staff_member_required +def delete_location(request, pk): + location = Location.objects.get(pk=pk) + + if request.method == 'POST': + try: + location.delete() + messages.success(request, _(u'%s deleted') % location.title) + except Exception, e: + messages.error(request, e) + + return redirect(locations) + + title = _(u'Really delete this location?') + explanation = _(u'This will not delete the orders at this location') + action = request.path + + return render(request, 'generic/delete.html', locals()) + + +@staff_member_required +def queues(request): + data = prep_list_view(Queue) + if data['object_list'].count() > 0: + return redirect(data['object_list'][0].get_admin_url()) + data['subtitle'] = _('Create, edit and delete service queues') + return render(request, 'admin/queues/index.html', data) + + +@staff_member_required +def edit_queue(request, pk=None): + + StatusFormSet = inlineformset_factory(Queue, QueueStatus, extra=1, exclude=[]) + + if pk is None: + queue = Queue() + locations = request.user.locations.all() + form = QueueForm(initial={'locations': locations}) + else: + queue = Queue.objects.get(pk=pk) + form = QueueForm(instance=queue, initial={'users': queue.user_set.all()}) + + title = _(u'Queues') + object_list = Queue.objects.all() + formset = StatusFormSet(instance=queue) + + if request.method == 'POST': + form = QueueForm(request.POST, request.FILES, instance=queue) + + if form.is_valid(): + try: + queue = form.save() + queue.user_set = form.cleaned_data['users'] + queue.save() + except Exception as e: + messages.error(request, _('Failed to save queue')) + return render(request, 'admin/queues/form.html', locals()) + + formset = StatusFormSet(request.POST, instance=queue) + + if formset.is_valid(): + formset.save() + messages.success(request, _(u'%s queue saved') % queue.title) + return redirect(queue.get_admin_url()) + else: + messages.error(request, formset.errors) + else: + messages.error(request, form.errors) + + return render(request, 'admin/queues/form.html', locals()) + + +@staff_member_required +def delete_queue(request, pk=None): + queue = Queue.objects.get(pk=pk) + + if request.method == 'POST': + try: + queue.delete() + messages.success(request, _("Queue deleted")) + except Queue.ProtectedError: + messages.error(request, _("Cannot delete queue")) + + return redirect(queues) + + return render(request, 'admin/queues/remove.html', locals()) + + +@staff_member_required +def notifications(request): + data = {'title': _(u'Notifications')} + return render(request, 'admin/notifications/index.html', data) + + +@staff_member_required +def edit_notification(request, nid): + return render(request, 'admin/notifications/form.html') + + +def list_sites(request): + if not request.user.is_superuser: + messages.error(request, _(u"Access denied")) + return redirect('/login/') + + data = {'sites': Site.objects.all()} + data['title'] = _(u"Manage Sites") + + return render(request, "admin/sites/index.html", data) + + +def edit_site(request, pk=None): + if not request.user.is_superuser: + messages.add_message(request, messages.ERROR, _(u"Access denied")) + return redirect('/login/') + + site = Site() + data = {'title': _(u"New Site")} + + if pk is not None: + site = Site.objects.get(pk=pk) + data['title'] = site.name + + SiteForm = modelform_factory(Site, exclude=[]) + form = SiteForm(instance=site) + + if request.method == "POST": + + form = SiteForm(request.POST, instance=site) + + if form.is_valid(): + form.save() + messages.add_message(request, messages.SUCCESS, _(u"Site saved")) + return redirect(list_sites) + + data['form'] = form + data['sites'] = Site.objects.all() + + return render(request, "admin/sites/edit_site.html", data) + + +def upload_users(request): + """ + """ + action = request.path + form = UserUploadForm() + title = _('Upload Users') + + if request.method == 'POST': + form = UserUploadForm(request.POST, request.FILES) + if form.is_valid(): + try: + users = form.save() + messages.success(request, _('%d users imported') % len(users)) + except Exception, e: + messages.error(request, e) + else: + messages.error(request, form.errors) + + return redirect(list_users) + + return render(request, "admin/users/upload_users.html", locals()) + + +class Backup(object): + @classmethod + def all(cls): + from glob import glob + return [cls(s) for s in glob("backups/*.gz")] + + def __init__(self, path): + import os + self.path = path + self.filename = os.path.basename(path) + self.filesize = os.path.getsize(path) + + def get_wrapper(self): + from django.core.servers.basehttp import FileWrapper + return FileWrapper(file(self.path)) + + def get_response(self): + from django.http import HttpResponse + wrapper = self.get_wrapper() + response = HttpResponse(wrapper, content_type='application/force-download') + response['Content-Disposition'] = 'attachment; filename=%s' % self.filename + response['Content-Length'] = self.filesize + return response + +def backups(request): + + if request.GET.get('dl'): + backup = Backup("backups/%s" % request.GET['dl']) + return backup.get_response() + + title = _('Backups') + backups = Backup.all() + return render(request, "admin/backups.html", locals()) diff --git a/servo/views/api.py b/servo/views/api.py new file mode 100644 index 0000000..87234f3 --- /dev/null +++ b/servo/views/api.py @@ -0,0 +1,401 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import json +from django.utils import timezone +from django.http import HttpResponse +from django.core.exceptions import FieldError +from django.core.serializers import serialize +from django.shortcuts import get_object_or_404 +from django.views.generic.detail import DetailView + +from rest_framework.response import Response + +from rest_framework.permissions import IsAuthenticated +from rest_framework.authentication import TokenAuthentication, SessionAuthentication +from rest_framework.decorators import (api_view, authentication_classes, permission_classes) + +from servo.api.serializers import * + +from servo.models import * + + +def dumps(obj): + import datetime + data = {} + for f in obj.api_fields: + value = getattr(obj, f) + if type(value) in (datetime.datetime, datetime.date,): + value = value.isoformat() + data[f] = value + return json.dumps(data) + + +class OrderStatusView(DetailView): + + model = Order + + def get(self, *args): + args = self.request.GET + if not args.get('q'): + error = {'error': 'Need parameter for query'} + return HttpResponse(json.dumps(error), + status=400, + content_type='application/json') + + self.code = args.get('q') + self.object = get_object_or_404(Order, code=self.code) + context = self.get_context_data(object=self.object) + return self.render_to_response(context) + + def render_to_response(self, context, **response_kwargs): + out = { + 'order': self.object.code, + 'status': self.object.get_status_name(), + 'status_description': self.object.get_status_description(), + } + + if Configuration.conf('checkin_timeline'): + timeline = [] + for i in self.object.orderstatus_set.exclude(status=None): + status = {'badge': i.get_badge()} + status['status'] = i.status.title + status['started_at'] = i.started_at.isoformat() + status['description'] = i.status.description + timeline.append(status) + + out['timeline'] = timeline + + return HttpResponse(json.dumps(out), content_type='application/json') + + +def tags(request): + results = Tag.objects.filter(**request.GET.dict()) + data = results.distinct().values_list("title", flat=True) + return HttpResponse(json.dumps(list(data)), content_type='application/json') + + +def statuses(request): + from servo.models import Status + results = Status.objects.all() + data = serialize('json', results) + return HttpResponse(data, content_type='application/json') + + +def locations(request): + queryset = Location.objects.all() + serializer = 'json' + if request.META['HTTP_USER_AGENT'].startswith('curl'): + serializer = 'yaml' + data = serialize(serializer, queryset) + return HttpResponse(data) + + +@api_view(['GET']) +@authentication_classes((SessionAuthentication, TokenAuthentication,)) +@permission_classes((IsAuthenticated,)) +def users(request): + query = request.GET.dict() + queryset = User.active.filter(**query) + data = list(queryset.values_list("full_name", flat=True)) + return HttpResponse(json.dumps(data), content_type='application/json') + + +def places(request): + places = Order.objects.exclude(place=None) + places = places.order_by("place").distinct("place").values_list('place', flat=True) + return HttpResponse(json.dumps(list(places)), content_type='application/json') + + +def queues(request): + queryset = Queue.objects.all() + data = serialize('json', queryset, fields=('pk', 'title')) + return HttpResponse(data, content_type='application/json') + + +def json_response(data): + return HttpResponse(json.dumps(data), content_type='application/json') + + +def ok(message): + msg = json.dumps(dict(ok=message)) + return HttpResponse(msg, content_type='application/json') + + +def error(message): + msg = json.dumps(dict(error=str(message))) + return HttpResponse(msg, content_type='application/json') + + +def client_error(message): + msg = json.dumps(dict(error=str(message))) + return HttpResponse(msg, content_type='application/json', status=400) + + +def create_order(request): + try: + data = json.loads(request.body) + except ValueError as e: + return client_error('Malformed request: %s' % e) + + cdata = data.get('customer') + problem = data.get('problem') + + if not cdata: + return client_error('Cannot create order without customer info') + + if not problem: + return client_error('Cannot create order without problem description') + + try: + customer, created = Customer.objects.get_or_create( + name=cdata['name'], + email=cdata['email'] + ) + except Exception as e: + return client_error('Invalid customer details: %s' % e) + + if request.user.customer: + customer.parent = request.user.customer + + if cdata.get('city'): + customer.city = cdata.get('city') + + if cdata.get('phone'): + customer.phone = cdata.get('phone') + + if cdata.get('zip_code'): + customer.zip_code = cdata.get('zip_code') + + if cdata.get('street_address'): + customer.street_address = cdata.get('street_address') + + customer.save() + + order = Order(created_by=request.user, customer=customer) + order.save() + + note = Note(created_by=request.user, body=problem, is_reported=True) + note.order = order + note.save() + + if data.get('attachment'): + import base64 + from servo.models import Attachment + from django.core.files.base import ContentFile + + attachment = data.get('attachment') + + try: + filename = attachment.get('name') + content = base64.b64decode(attachment.get('data')) + except Exception as e: + return client_error('Invalid file data: %s' %e) + + content = ContentFile(content, filename) + attachment = Attachment(content=content, content_object=note) + attachment.save() + attachment.content.save(filename, content) + note.attachments.add(attachment) + + if data.get('device'): + + try: + GsxAccount.default(request.user) + except Exception as e: + pass + + ddata = data.get('device') + + try: + device = order.add_device_sn(ddata.get('sn'), request.user) + except Exception as e: + device = Device(sn=ddata.get('sn', '')) + device.description = ddata.get('description', '') + device.save() + order.add_device(device) + + for a in ddata.get('accessories', []): + a = Accessory(name=a, order=order, device=device) + a.save() + + return ok(order.code) + + +@api_view(['GET', 'POST', 'PUT']) +@authentication_classes((TokenAuthentication,)) +@permission_classes((IsAuthenticated,)) +def orders(request, code=None, pk=None): + """ + This is the orders API + """ + from servo.api.serializers import OrderSerializer + + if request.method == 'POST': + return create_order(request) + + if request.method == 'PUT': + return error('Method not yet implemented') + + if request.GET.get('q'): + results = Order.objects.filter(**request.GET) + + if pk: + order = Order.objects.get(pk=pk) + serializer = OrderSerializer(order, context={'request': request}) + return Response(serializer.data) + + if code: + order = Order.objects.get(code=code) + if order.status: + order.status_description = order.status.status.description + serializer = OrderSerializer(order, context={'request': request}) + return Response(serializer.data) + + orders = Order.objects.none() + serializer = OrderSerializer(orders, many=True, context={'request': request}) + return Response(serializer.data) + + +def messages(request): + """ + Responds to SMS status updates + """ + from servo.messaging.sms import SMSJazzProvider, HQSMSProvider + + if not request.GET.get('id'): + return HttpResponse('Thanks, but no thanks') + + m = get_object_or_404(Message, code=request.GET['id']) + gw = Configuration.conf('sms_gateway') + statusmap = HQSMSProvider.STATUSES + + if gw == 'jazz': + statusmap = SMSJazzProvider.STATUSES + + status = statusmap[request.GET['status']] + m.status = status[0] + m.error = status[1] + + if m.status == 'DELIVERED': + m.received_at = timezone.now() + + if m.status == 'FAILED': + if m.note.order: + uid = Configuration.conf('imap_act') + if uid: + user = User.objects.get(pk=uid) + m.note.order.notify('sms_failed', m.error, user) + + m.save() + + return HttpResponse('OK') + + +def device_models(request): + data = Device.objects.order_by("description").distinct("description") + return json_response(list(data.values_list("description", flat=True))) + + +@api_view(['GET']) +@authentication_classes((TokenAuthentication,)) +@permission_classes((IsAuthenticated,)) +def warranty(request): + from servo.api.serializers import DeviceSerializer + sn = request.GET.get('sn') + + if not sn: + return error('Need query parameter for warranty lookup') + + try: + GsxAccount.default(request.user) + except Exception as e: + return error('Cannot connect to GSX (check user name and password)') + + try: + result = Device.from_gsx(sn, cached=False) + serializer = DeviceSerializer(result, context={'request': request}) + return Response(serializer.data) + except Exception as e: + return error(e) + + +@api_view(['GET']) +def order_status(request): + from servo.api.serializers import OrderStatusSerializer + code = request.GET.get('q') + try: + result = Order.objects.get(code=code) + #serializer = OrderStatusSerializer(result) + return Response(serializer.data) + except Exception as e: + return (error(e)) + + +@api_view(['GET']) +@authentication_classes((TokenAuthentication,)) +@permission_classes((IsAuthenticated,)) +def notes(request, pk=None): + if pk: + note = Note.objects.get(pk=pk) + serializer = NoteSerializer(note, context={'request': request}) + return Response(serializer.data) + + +@api_view(['GET']) +@authentication_classes((TokenAuthentication,)) +@permission_classes((IsAuthenticated,)) +def order_items(request, pk): + item = ServiceOrderItem.objects.get(pk=pk) + serializer = ServiceOrderItemSerializer(item, context={'request': request}) + return Response(serializer.data) + + +@api_view(['GET']) +@authentication_classes((TokenAuthentication,)) +@permission_classes((IsAuthenticated,)) +def user_detail(request, pk): + user = User.objects.get(pk=pk) + serializer = UserSerializer(user, context={'request': request}) + return Response(serializer.data) + + +@api_view(['GET']) +@authentication_classes((TokenAuthentication,)) +@permission_classes((IsAuthenticated,)) +def customers(request, pk=None): + customer = Customer.objects.get(pk=pk) + serializer = CustomerSerializer(customer, context={'request': request}) + return Response(serializer.data) + + +@api_view(['GET']) +@authentication_classes((TokenAuthentication,)) +@permission_classes((IsAuthenticated,)) +def devices(request, pk=None): + device = Device.objects.get(pk=pk) + serializer = DeviceSerializer(device, context={'request': request}) + return Response(serializer.data) diff --git a/servo/views/checkin.py b/servo/views/checkin.py new file mode 100644 index 0000000..7b6787a --- /dev/null +++ b/servo/views/checkin.py @@ -0,0 +1,418 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import json +import locale + +from gsxws import products, GsxError + +from django.conf import settings +from django.http import HttpResponse +from django.contrib import messages +from django.core.cache import cache + +from django.utils import translation +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext as _ +from django.shortcuts import render, redirect, get_object_or_404 + +from servo.views.order import put_on_paper +from servo.validators import apple_sn_validator +from servo.models import (User, Device, GsxAccount, Order, + Customer, Location, Note, Attachment, + Configuration, ChecklistItem, Tag,) +from servo.forms import (SerialNumberForm, AppleSerialNumberForm, + DeviceForm, IssueForm, CustomerForm, + QuestionForm, AttachmentForm, StatusCheckForm,) + + +def init_locale(request): + lc = settings.INSTALL_LOCALE.split('.') + locale.setlocale(locale.LC_TIME, lc) + locale.setlocale(locale.LC_NUMERIC, lc) + locale.setlocale(locale.LC_MESSAGES, lc) + locale.setlocale(locale.LC_MONETARY, lc) + + translation.activate(settings.INSTALL_LANGUAGE) + request.session[translation.LANGUAGE_SESSION_KEY] = settings.INSTALL_LANGUAGE + + +def set_cache_device(device): + key = 'checkin-device-%s' % device.sn + cache.set(key, device) + + +def get_gsx_connection(request): + act = GsxAccount.get_default_account() + user = User.objects.get(pk=request.session['checkin_user']) + location = Location.objects.get(pk=request.session['checkin_location']) + return act.connect(user, location) + + +def get_remote_device(request, sn): + try: + apple_sn_validator(sn) + except ValidationError: + return Device(sn=sn, image_url='https://static.servoapp.com/images/na.gif') + + get_gsx_connection(request) + + return Device.from_gsx(sn) + + +def get_local_device(request, sn): + try: + device = Device.objects.filter(sn=sn)[0] + except IndexError: + device = get_remote_device(request, sn) + + return device + + +def get_device(request, sn): + if len(sn) < 1: + return Device(sn=sn) + + key = 'checkin-device-%s' % sn + device = cache.get(key, get_local_device(request, sn)) + set_cache_device(device) + return device + + +def reset_session(request): + + # initialize some basic vars + if not request.user.is_authenticated(): + request.session.flush() + + # initialize locale + init_locale(request) + + request.session['checkin_device'] = None + request.session['checkin_customer'] = None + + if not request.session.get('company_name'): + request.session['company_name'] = Configuration.conf('company_name') + + if request.user.is_authenticated(): + + if request.GET.get('u'): + user = User.objects.get(pk=request.GET['u']) + else: + user = request.user + + if request.GET.get('l'): + location = Location.objects.get(pk=request.GET['l']) + else: + location = user.location + + checkin_users = User.get_checkin_group() + request.session['checkin_users'] = User.get_checkin_group_list() + request.session['checkin_locations'] = request.user.get_location_list() + + queryset = checkin_users.filter(location=location) + request.session['checkin_users'] = User.serialize(queryset) + + else: + user = User.get_checkin_user() + location = user.location + + request.session['checkin_user'] = user.pk + request.session['checkin_location'] = location.pk + request.session['checkin_user_name'] = user.get_name() + request.session['checkin_location_name'] = location.title + + +def reset(request): + reset_session(request) + return redirect(index) + + +def thanks(request, order): + """ + Final step/confirmation + """ + title = _('Done!') + + try: + request.session.delete_test_cookie() + except KeyError: + pass # ignore spurious KeyError at /checkin/thanks/RJTPS/ + + try: + order = Order.objects.get(url_code=order) + except Order.DoesNotExist: + messages.error(request, _('Order does not exist')) + return redirect(reset) + + return render(request, "checkin/thanks.html", locals()) + + +def get_customer(request): + if not request.user.is_authenticated(): + return + + if not request.GET.get('c'): + return + + customer = Customer.objects.get(pk=request.GET['c']) + request.session['checkin_customer'] = customer.pk + + fdata = {'fname': customer.firstname} + fdata['lname'] = customer.lastname + fdata['email'] = customer.email + fdata['city'] = customer.city + fdata['phone'] = customer.phone + fdata['country'] = customer.country + fdata['address'] = customer.street_address + fdata['postal_code'] = customer.zip_code + + return HttpResponse(json.dumps(fdata), content_type='application/json') + + +def status(request): + """ + Status checking through the checkin + """ + title = _('Repair Status') + + if request.GET.get('code'): + timeline = [] + form = StatusCheckForm(request.GET) + if form.is_valid(): + code = form.cleaned_data['code'] + try: + order = Order.objects.get(code=code) + if Configuration.conf('checkin_timeline'): + timeline = order.orderstatus_set.all() + if order.status is None: + order.status_name = _(u'Waiting to be processed') + except Order.DoesNotExist: + messages.error(request, _(u'Order %s not found') % code) + return render(request, "checkin/status-show.html", locals()) + else: + form = StatusCheckForm() + + return render(request, "checkin/status.html", locals()) + + +def print_confirmation(request, code): + order = Order.objects.get(url_code=code) + return put_on_paper(request, order.pk) + + +def terms(request): + conf = Configuration.conf() + return render(request, 'checkin/terms.html', locals()) + + +def index(request): + + if request.method == 'GET': + reset_session(request) + + title = _('Service Order Check-In') + + dcat = request.GET.get('d', 'mac') + dmap = { + 'mac' : _('Mac'), + 'iphone' : _('iPhone'), + 'ipad' : _('iPad'), + 'ipod' : _('iPod'), + 'acc' : _('Apple Accessory'), + 'beats' : _('Beats Products'), + 'other' : _('Other Devices'), + } + + issue_form = IssueForm() + device = Device(description=dmap[dcat]) + + if dcat in ('mac', 'iphone', 'ipad', 'ipod'): + sn_form = AppleSerialNumberForm() + else: + sn_form = SerialNumberForm() + + tags = Tag.objects.filter(type="order") + device_form = DeviceForm(instance=device) + customer_form = CustomerForm(request) + + if request.method == 'POST': + + sn_form = SerialNumberForm(request.POST) + issue_form = IssueForm(request.POST, request.FILES) + customer_form = CustomerForm(request, request.POST) + device_form = DeviceForm(request.POST, request.FILES) + + if device_form.is_valid() and issue_form.is_valid() and customer_form.is_valid(): + + user = User.objects.get(pk=request.session['checkin_user']) + + idata = issue_form.cleaned_data + ddata = device_form.cleaned_data + cdata = customer_form.cleaned_data + + customer_id = request.session.get('checkin_customer') + if customer_id: + customer = Customer.objects.get(pk=customer_id) + else: + customer = Customer() + + name = u'{0} {1}'.format(cdata['fname'], cdata['lname']) + + if len(cdata['company']): + name += ', ' + cdata['company'] + + customer.name = name + customer.city = cdata['city'] + customer.phone = cdata['phone'] + customer.email = cdata['email'] + customer.phone = cdata['phone'] + customer.zip_code = cdata['postal_code'] + customer.street_address = cdata['address'] + customer.save() + + order = Order(customer=customer, created_by=user) + order.location_id = request.session['checkin_location'] + order.checkin_location = cdata['checkin_location'] + order.checkout_location = cdata['checkout_location'] + + order.save() + order.check_in(user) + + try: + device = get_device(request, ddata['sn']) + except GsxError as e: + pass + + device.username = ddata['username'] + device.password = ddata['password'] + device.description = ddata['description'] + device.purchased_on = ddata['purchased_on'] + device.purchase_country = ddata['purchase_country'] + device.save() + + order.add_device(device, user) + + note = Note(created_by=user, body=idata['issue_description']) + note.is_reported = True + note.order = order + note.save() + + # Proof of purchase was supplied + if ddata.get('pop'): + f = {'content_type': Attachment.get_content_type('note').pk} + f['object_id'] = note.pk + a = AttachmentForm(f, {'content': ddata['pop']}) + a.save() + + if request.POST.get('tags'): + order.set_tags(request.POST.getlist('tags'), request.user) + + # Check checklists early for validation + answers = [] + + # @FIXME: should try to move this to a formset... + for k, v in request.POST.items(): + if k.startswith('__cl__'): + answers.append('- **' + k[6:] + '**: ' + v) + + if len(answers) > 0: + note = Note(created_by=user, body="\r\n".join(answers)) + + if Configuration.true('checkin_report_checklist'): + note.is_reported = True + + note.order = order + note.save() + + # mark down internal notes (only if logged in) + if len(idata.get('notes')): + note = Note(created_by=user, body=idata['notes']) + note.is_reported = False + note.order = order + note.save() + + # mark down condition of device + if len(ddata.get('condition')): + note = Note(created_by=user, body=ddata['condition']) + note.is_reported = True + note.order = order + note.save() + + # mark down supplied accessories + if len(ddata.get('accessories')): + accs = ddata['accessories'].strip().split("\n") + order.set_accessories(accs, device) + + redirect_to = thanks + + """ + if request.user.is_authenticated(): + if request.user.autoprint: + redirect_to = print_confirmation + """ + return redirect(redirect_to, order.url_code) + + try: + pk = Configuration.conf('checkin_checklist') + questions = ChecklistItem.objects.filter(checklist_id=pk) + except ValueError: + # Checklists probably not configured + pass + + if request.GET.get('phone'): + + if not request.user.is_authenticated(): + return + + results = [] + + for c in Customer.objects.filter(phone=request.GET['phone']): + title = '%s - %s' % (c.phone, c.name) + results.append({'id': c.pk, 'name': c.name, 'title': title}) + + return HttpResponse(json.dumps(results), content_type='application/json') + + if request.GET.get('sn'): + + device = Device(sn=request.GET['sn']) + device.description = _('Other Device') + device_form = DeviceForm(instance=device) + + try: + apple_sn_validator(device.sn) + except Exception as e: # not an Apple serial number + return render(request, "checkin/device_form.html", locals()) + + try: + device = get_device(request, device.sn) + device_form = DeviceForm(instance=device) + except GsxError as e: + error = e + + return render(request, "checkin/device_form.html", locals()) + + return render(request, "checkin/newindex.html", locals()) diff --git a/servo/views/customer.py b/servo/views/customer.py new file mode 100644 index 0000000..455126e --- /dev/null +++ b/servo/views/customer.py @@ -0,0 +1,505 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +from __future__ import absolute_import + +from django.db.models import Q +from django.contrib import messages +from django.http import HttpResponse + +from django.forms.models import modelform_factory +from django.utils.translation import ugettext as _ +from django.contrib.auth.decorators import permission_required +from django.shortcuts import render, redirect, get_object_or_404 +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from servo.models.note import Note +from servo.models.order import Order +from servo.models.common import Property +from servo.models.customer import Customer, CustomerGroup, ContactInfo + +from servo.forms.customer import (CustomerForm, + CustomerSearchForm, + CustomerUploadForm) + +GroupForm = modelform_factory(CustomerGroup, exclude=[]) + + +def prepare_view(request, group='all'): + + title = _("Customers") + + customer_list = [] + all_customers = Customer.objects.all().order_by('name') + customer_count = all_customers.count() + + if request.session.get("return_to"): + del(request.session['return_to']) + + if request.method == 'POST': + q = request.POST.get('query') + if q is not None: + try: + (key, value) = q.split('=') + # allow searching customers by arbitrary key/values + customer_list = Customer.objects.filter(**{key: value.strip()}) + except Exception: + customer_list = Customer.objects.filter(name__icontains=q) + else: + if group == 'all': + customer_list = all_customers + else: + g = CustomerGroup.objects.get(slug=group) + customer_list = all_customers.filter(groups=g) + title = g.name + + page = request.GET.get('page') + paginator = Paginator(customer_list, 40) + + try: + customers = paginator.page(page) + except PageNotAnInteger: + customers = paginator.page(1) + except EmptyPage: + customers = paginator.page(paginator.num_pages) + + groups = CustomerGroup.objects.all() + + return locals() + + +def index(request, group='all'): + data = prepare_view(request, group) + request.session['customer_query'] = None + + if data['customer_list']: + customer = data['customer_list'][0] + return redirect(view, pk=customer.pk, group=group) + + return render(request, "customers/index.html", data) + + +@permission_required("servo.change_order") +def add_order(request, customer_id, order_id): + order = Order.objects.get(pk=order_id) + customer = Customer.objects.get(pk=customer_id) + order.customer = customer + order.save() + + for d in order.devices.all(): + customer.devices.add(d) + + customer.save() + messages.success(request, _('Customer added')) + return redirect(order) + + +def notes(request, pk, note_id=None): + from servo.forms.note import NoteForm + customer = Customer.objects.get(pk=pk) + form = NoteForm(initial={'recipient': customer.name}) + + return render(request, "notes/form.html", {'form': form}) + + +def view(request, pk, group='all'): + try: + c = Customer.objects.get(pk=pk) + except Customer.DoesNotExist: + messages.error(request, _('Customer not found')) + return redirect(index) + + data = prepare_view(request, group) + + data['title'] = c.name + data['orders'] = Order.objects.filter( + customer__lft__gte=c.lft, + customer__rght__lte=c.rght, + customer__tree_id=c.tree_id + ) + + if c.email: + data['notes'] = Note.objects.filter(recipient=c.email) + + data['customer'] = c + request.session['return_to'] = request.path + + return render(request, 'customers/view.html', data) + + +@permission_required("servo.change_customer") +def edit_group(request, group='all'): + if group == 'all': + group = CustomerGroup() + else: + group = CustomerGroup.objects.get(slug=group) + + title = group.name + form = GroupForm(instance=group) + + if request.method == "POST": + form = GroupForm(request.POST, instance=group) + if form.is_valid(): + group = form.save() + messages.success(request, _(u'%s saved') % group.name) + return redirect(index, group.slug) + messages.error(request, form.errors['name'][0]) + return redirect(index) + + return render(request, "customers/edit_group.html", locals()) + + +@permission_required("servo.change_customer") +def delete_group(request, group): + group = CustomerGroup.objects.get(slug=group) + + if request.method == "POST": + group.delete() + messages.success(request, _(u'%s deleted') % group.name) + return redirect(index) + + return render(request, "customers/delete_group.html", locals()) + + +@permission_required("servo.change_customer") +def edit(request, pk=None, parent_id=None, group='all'): + + data = prepare_view(request, group) + + customer = Customer() + form = CustomerForm(instance=customer) + + if group != 'all': + g = CustomerGroup.objects.get(slug=group) + form.initial = {'groups': [g]} + + name = request.GET.get('name') + + if name: + form = CustomerForm(initial={'name': name}) + + if pk is not None: + customer = Customer.objects.get(pk=pk) + form = CustomerForm(instance=customer) + + if parent_id is not None: + customer.parent = Customer.objects.get(pk=parent_id) + form = CustomerForm(initial={'parent': parent_id}) + + if request.method == 'POST': + props = dict() + keys = request.POST.getlist('keys') + values = request.POST.getlist('values') + + form = CustomerForm(request.POST, request.FILES, instance=customer) + + if form.is_valid(): + ContactInfo.objects.filter(customer=customer).delete() + + for k, v in enumerate(values): + if v != '': + key = keys[k] + props[key] = v + + if form.is_valid(): + try: + customer = form.save() + except Exception as e: + messages.error(request, e) + return redirect(edit, group, pk) + + for k, v in props.items(): + if v != '': + ContactInfo.objects.create(key=k, value=v, customer=customer) + + messages.success(request, _('Customer saved')) + + if request.session.get('return_to'): + return_to = request.session['return_to'] + if hasattr(return_to, 'set_customer'): + return_to.set_customer(customer) + del request.session['return_to'] + return redirect(return_to) + + return redirect(view, pk=customer.pk, group=group) + + data['form'] = form + data['customer'] = customer + data['title'] = customer.name + data['fields'] = Property.objects.filter(type='customer') + + return render(request, 'customers/form.html', data) + + +@permission_required("servo.delete_customer") +def delete(request, pk=None, group='all'): + + customer = Customer.objects.get(pk=pk) + + if request.method == "POST": + customer.delete() + messages.success(request, _("Customer deleted")) + return redirect(index, group=group) + else: + data = {'action': request.path, 'customer': customer} + return render(request, "customers/remove.html", data) + + +@permission_required("servo.change_customer") +def merge(request, pk, target=None): + """ + Merges customer PK with customer TARGET + Re-links everything from customer PK to TARGET: + - orders + - devices + - invoices + Deletes the source customer + """ + customer = Customer.objects.get(pk=pk) + title = _('Merge %s with') % customer.name + + if request.method == 'POST': + name = request.POST.get('name') + results = Customer.objects.filter(name__icontains=name) + return render(request, 'customers/results-merge.html', locals()) + + if pk and target: + target_customer = Customer.objects.get(pk=target) + target_customer.orders.add(*customer.orders.all()) + target_customer.devices.add(*customer.devices.all()) + target_customer.note_set.add(*customer.note_set.all()) + target_customer.invoice_set.add(*customer.invoice_set.all()) + target_customer.save() + customer.delete() + messages.success(request, _('Customer records merged succesfully')) + return redirect(target_customer) + + return render(request, "customers/merge.html", locals()) + + +@permission_required("servo.change_customer") +def move(request, pk, new_parent=None): + """ + Moves a customer under another customer + """ + customer = Customer.objects.get(pk=pk) + + if new_parent is not None: + if int(new_parent) == 0: + new_parent = None + msg = _(u"Customer %s moved to top level") % customer + else: + new_parent = Customer.objects.get(pk=new_parent) + d = {'customer': customer, 'target': new_parent} + msg = _(u"Customer %(customer)s moved to %(target)s") % d + + try: + customer.move_to(new_parent) + customer.save() # To update fullname + messages.success(request, msg) + except Exception, e: + messages.error(request, e) + + return redirect(customer) + + return render(request, "customers/move.html", locals()) + + +def search(request): + """ + Searches for customers from "spotlight" + """ + query = request.GET.get("q") + kind = request.GET.get('kind') + request.session['search_query'] = query + + customers = Customer.objects.filter( + Q(fullname__icontains=query) | Q(email__icontains=query) | Q(phone__contains=query) + ) + + if kind == 'company': + customers = customers.filter(is_company=True) + + if kind == 'contact': + customers = customers.filter(is_company=False) + + title = _('Search results for "%s"') % query + return render(request, "customers/search.html", locals()) + + +def filter(request): + """ + Search for customers by name + May return JSON for ajax requests + or a rendered list + """ + import json + from django.http import HttpResponse + + if request.method == "GET": + results = list() + query = request.GET.get("query") + customers = Customer.objects.filter(fullname__icontains=query) + + for c in customers: + results.append(u"%s <%s>" % (c.name, c.email)) + results.append(u"%s <%s>" % (c.name, c.phone)) + else: + query = request.POST.get("name") + results = Customer.objects.filter(fullname__icontains=query) + data = {'results': results, 'id': request.POST['id']} + + return render(request, "customers/search-results.html", data) + + return HttpResponse(json.dumps(results), content_type="application/json") + + +def find(request): + """ + Search from customer advanced search + """ + results = list() + request.session['customer_list'] = list() + + if request.method == 'POST': + form = CustomerSearchForm(request.POST) + + if form.is_valid(): + d = form.cleaned_data + checkin_start = d.pop('checked_in_start') + checkin_end = d.pop('checked_in_end') + + if checkin_start and checkin_end: + d['orders__created_at__range'] = [checkin_start.isoformat(), + checkin_end.isoformat()] + + results = Customer.objects.filter(**d).distinct() + request.session['customer_query'] = d + else: + form = CustomerSearchForm() + + title = _('Search for customers') + + page = request.GET.get('page') + paginator = Paginator(results, 50) + + try: + customers = paginator.page(page) + except PageNotAnInteger: + customers = paginator.page(1) + except EmptyPage: + customers = paginator.page(paginator.num_pages) + + return render(request, "customers/find.html", locals()) + + +def download(request, format='csv', group='all'): + """ + Downloads all customers or search results + """ + filename = 'customers' + results = Customer.objects.all() + query = request.session.get('customer_query') + + response = HttpResponse(content_type="text/plain; charset=utf-8") + response['Content-Disposition'] = 'attachment; filename="%s.txt"' % filename + response.write(u"ID\tNAME\tEMAIL\tPHONE\tADDRESS\tPOSTAL CODE\tCITY\tCOUNTRY\tNOTES\n") + + if group != 'all': + results = results.filter(groups__slug=group) + + if query: + results = Customer.objects.filter(**query).distinct() + + for c in results: + row = u"%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n" % (c.pk, + c.name, + c.email, + c.phone, + c.street_address, + c.zip_code, + c.city, + c.country, + c.notes,) + response.write(row) + + return response + + +def create_message(request, pk): + return redirect("servo.views.note.edit", customer=pk) + + +def upload(request, group='all'): + + action = request.path + form = CustomerUploadForm() + + if request.method == 'POST': + form = CustomerUploadForm(request.POST, request.FILES) + + if not form.is_valid(): + messages.error(request, form.errors) + return redirect(index) + + i, df = 0, form.cleaned_data['datafile'].read() + + for l in df.split("\r"): + row = force_decode(l).strip().split("\t") + + if len(row) < 5: + messages.error(request, _("Invalid upload data")) + return redirect(index) + + if form.cleaned_data.get('skip_dups'): + if Customer.objects.filter(email=row[1]).exists(): + continue + + c = Customer(name=row[0], email=row[1]) + c.street_address = row[2] + c.zip_code = row[3] + c.city = row[4] + c.notes = row[5] + c.save() + + if group != 'all': + g = CustomerGroup.objects.get(slug=group) + c.groups.add(g) + + i += 1 + + messages.success(request, _("%d customer(s) imported") % i) + return redirect(index, group=group) + + return render(request, "customers/upload.html", locals()) + + +def force_decode(s, codecs=['mac_roman', 'utf-8', 'latin-1']): + for i in codecs: + try: + return s.decode(i) + except UnicodeDecodeError: + pass diff --git a/servo/views/device.py b/servo/views/device.py new file mode 100644 index 0000000..f35bd99 --- /dev/null +++ b/servo/views/device.py @@ -0,0 +1,605 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import gsxws + +from django.db.models import Q +from django.contrib import messages + +from django.core.cache import cache +from django.shortcuts import render, redirect, get_object_or_404 + +from django.utils.translation import ugettext as _ +from django.template.defaultfilters import slugify +from django.views.decorators.cache import cache_page +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from servo.models import Device, Product, GsxAccount, ServiceOrderItem +from servo.forms.devices import DeviceForm, DeviceUploadForm, DeviceSearchForm + +class RepairDiagnosticResults: + pass + +class DiagnosticResults(object): + def __init__(self, diags): + if not diags.diagnosticTestData: + raise gsxws.GsxError('Missing diagnostic data') + + self.diags = dict(result={}, profile={}, report={}) + + for r in diags.diagnosticTestData.testResult.result: + self.diags['result'][r.name] = r.value + + for r in diags.diagnosticProfileData.profile.unit.key: + self.diags['profile'][r.name] = r.value + + for r in diags.diagnosticProfileData.report.reportData.key: + self.diags['report'][r.name] = r.value + + def __iter__(self): + return iter(self.diags) + + +def model_from_slug(product_line, model=None): + """ + Returns product description for model slug or models dict for + the specified product line + """ + if not cache.get("slugmap"): + slugmap = {} # Map model slug to corresponding product description + product_lines = gsxws.products.models() + + for k, v in product_lines.items(): + d = {} + for p in v['models']: + slug = slugify(p) + d[slug] = p + + slugmap[k] = d + + cache.set("slugmap", slugmap) + + models = cache.get("slugmap").get(product_line) + + if model is not None: + return models.get(model) + + return models + + +def prep_list_view(request, product_line=None, model=None): + title = _('Devices') + all_devices = Device.objects.all() + product_lines = gsxws.products.models() + + if product_line is None: + product_line = product_lines.keys()[0] + + models = model_from_slug(product_line) + + if model is None: + model = models.keys()[0] + title = product_lines[product_line]['name'] + else: + title = models.get(model) + + if product_line == "OTHER": + all_devices = all_devices.filter(product_line=product_line) + else: + all_devices = all_devices.filter(slug=model) + + page = request.GET.get('page') + paginator = Paginator(all_devices, 50) + + try: + devices = paginator.page(page) + except PageNotAnInteger: + devices = paginator.page(1) + except EmptyPage: + devices = paginator.page(paginator.num_pages) + + return locals() + + +def prep_detail_view(request, pk, product_line=None, model=None): + if pk is None: + device = Device() + else: + device = Device.objects.get(pk=pk) + + data = prep_list_view(request, product_line, model) + + data['device'] = device + data['title'] = device.description + + return data + + +def index(request, product_line=None, model=None): + if request.session.get('return_to'): + del(request.session['return_to']) + + data = prep_list_view(request, product_line, model) + + if data['all_devices'].count() > 0: + return redirect(data['all_devices'].latest()) + + return render(request, "devices/index.html", data) + + +def delete_device(request, product_line, model, pk): + dev = Device.objects.get(pk=pk) + + if request.method == 'POST': + from django.db.models import ProtectedError + try: + dev.delete() + messages.success(request, _("Device deleted")) + except ProtectedError: + messages.error(request, _("Cannot delete device with GSX repairs")) + return redirect(dev) + + return redirect(index) + + data = {'action': request.path} + data['device'] = dev + + return render(request, "devices/remove.html", data) + + +def edit_device(request, pk=None, product_line=None, model=None): + """ + Edits an existing device or adds a new one + """ + device = Device() + device.sn = request.GET.get('sn', '') + + if product_line is not None: + device.product_line = product_line + + if model is not None: + device.product_line = product_line + device.description = model_from_slug(product_line, model) + + if pk is not None: + device = Device.objects.get(pk=pk) + + form = DeviceForm(instance=device) + + if request.method == "POST": + + form = DeviceForm(request.POST, request.FILES, instance=device) + + if form.is_valid(): + device = form.save() + messages.success(request, _(u"%s saved") % device.description) + device.add_tags(request.POST.getlist('tag')) + + return redirect(view_device, + pk=device.pk, + product_line=device.product_line, + model=device.slug) + + data = prep_detail_view(request, pk, product_line, model) + data['form'] = form + + return render(request, 'devices/form.html', data) + + +def view_device(request, pk, product_line=None, model=None): + data = prep_detail_view(request, pk, product_line, model) + return render(request, "devices/view.html", data) + + +def diagnostics(request, pk): + """ + Fetches MRI diagnostics or initiates iOS diags from GSX + """ + device = get_object_or_404(Device, pk=pk) + + if request.GET.get('a') == 'init': + if request.method == 'POST': + from gsxws import diagnostics + order = request.POST.get('order') + order = device.order_set.get(pk=order) + email = request.POST.get('email') + diag = diagnostics.Diagnostics(serialNumber=device.sn) + diag.emailAddress = email + diag.shipTo = order.location.gsx_shipto + + try: + GsxAccount.default(request.user) + res = diag.initiate() + msg = _('Diagnostics initiated - diags://%s') % res + order.notify("init_diags", msg, request.user) + messages.success(request, msg) + except gsxws.GsxError, e: + messages.error(request, e) + + return redirect(order) + + order = request.GET.get('order') + order = device.order_set.get(pk=order) + customer = order.customer + url = request.path + return render(request, "devices/diagnostic_init.html", locals()) + + if request.GET.get('a') == 'get': + try: + diagnostics = device.get_diagnostics(request.user) + if device.is_ios(): + diagnostics = DiagnosticResults(diagnostics) + return render(request, "devices/diagnostic_ios.html", locals()) + return render(request, "devices/diagnostic_results.html", locals()) + except gsxws.GsxError, e: + return render(request, "devices/diagnostic_error.html", {'error': e}) + + return render(request, "devices/diagnostics.html", locals()) + + +def get_gsx_search_results(request, what, param, query): + """ + The second phase of a GSX search. + There should be an active GSX session open at this stage. + """ + data = {} + results = [] + query = query.upper() + device = Device(sn=query) + error_template = "search/results/gsx_error.html" + + # @TODO: this isn't a GSX search. Move it somewhere else. + if what == "orders": + try: + if param == 'serialNumber': + device = Device.objects.get(sn__exact=query) + if param == 'alternateDeviceId': + device = Device.objects.get(imei__exact=query) + except (Device.DoesNotExist, ValueError,): + return render(request, "search/results/gsx_notfound.html") + + orders = device.order_set.all() + return render(request, "orders/list.html", locals()) + + if what == "warranty": + # Update wty info if been here before + try: + device = Device.objects.get(sn__exact=query) + device.update_gsx_details() + except Exception: + try: + device = Device.from_gsx(query) + except Exception, e: + return render(request, error_template, {'message': e}) + + results.append(device) + + # maybe it's a device we've already replaced... + try: + soi = ServiceOrderItem.objects.get(sn__iexact=query) + results[0].repeat_service = soi.order + except ServiceOrderItem.DoesNotExist: + pass + + if what == "parts": + # looking for parts + if param == "partNumber": + # ... with a part number + part = gsxws.Part(partNumber=query) + + try: + partinfo = part.lookup() + except gsxws.GsxError, e: + return render(request, error_template, {'message': e}) + + product = Product.from_gsx(partinfo) + cache.set(query, product) + results.append(product) + + if param == "serialNumber": + # ... with a serial number + try: + results = device.get_parts() + data['device'] = device + except Exception, e: + return render(request, error_template, {'message': e}) + + if param == "productName": + product = gsxws.Product(productName=query) + parts = product.parts() + for p in parts: + results.append(Product.from_gsx(p)) + + if what == "repairs": + # Looking for GSX repairs + if param == "serialNumber": + # ... with a serial number + try: + device = gsxws.Product(query) + #results = device.repairs() + # @TODO: move the encoding hack to py-gsxws + for i, p in enumerate(device.repairs()): + d = {'purchaseOrderNumber': p.purchaseOrderNumber} + d['repairConfirmationNumber'] = p.repairConfirmationNumber + d['createdOn'] = p.createdOn + d['customerName'] = p.customerName.encode('utf-8') + d['repairStatus'] = p.repairStatus + results.append(d) + except gsxws.GsxError, e: + return render(request, "search/results/gsx_notfound.html") + + elif param == "dispatchId": + # ... with a repair confirmation number + repair = gsxws.Repair(number=query) + try: + results = repair.lookup() + except gsxws.GsxError, message: + return render(request, error_template, locals()) + + return render(request, "devices/search_gsx_%s.html" % what, locals()) + + +def search_gsx(request, what, param, query): + """ + The first phase of a GSX search + """ + title = _(u'Search results for "%s"') % query + + try: + act = request.session.get("gsx_account") + act = None + if act is None: + GsxAccount.default(user=request.user) + else: + act.connect(request.user) + except gsxws.GsxError, message: + return render(request, "devices/search_gsx_error.html", locals()) + + if request.is_ajax(): + if what == "parts": + try: + dev = Device.from_gsx(query) + products = dev.get_parts() + return render(request, "devices/parts.html", locals()) + except gsxws.GsxError, message: + return render(request, "search/results/gsx_error.html", locals()) + + return get_gsx_search_results(request, what, param, query) + + return render(request, "devices/search_gsx.html", locals()) + + +def search(request): + """ + Searching for devices from the main navbar + """ + query = request.GET.get("q", '').strip() + request.session['search_query'] = query + + query = query.upper() + valid_arg = gsxws.validate(query) + + if valid_arg in ('serialNumber', 'alternateDeviceId',): + return redirect(search_gsx, "warranty", valid_arg, query) + + devices = Device.objects.filter( + Q(sn__icontains=query) | Q(description__icontains=query) + ) + + title = _(u'Devices matching "%s"') % query + + return render(request, "devices/search.html", locals()) + + +def find(request): + """ + Searching for device from devices/find + """ + title = _("Device search") + form = DeviceSearchForm() + results = Device.objects.none() + + if request.method == 'POST': + form = DeviceSearchForm(request.POST) + if form.is_valid(): + fdata = form.cleaned_data + results = Device.objects.all() + + if fdata.get("product_line"): + results = results.filter(product_line__in=fdata['product_line']) + if fdata.get("warranty_status"): + results = results.filter(warranty_status__in=fdata['warranty_status']) + if fdata.get("description"): + results = results.filter(description__icontains=fdata['description']) + if fdata.get("sn"): + results = results.filter(sn__icontains=fdata['sn']) + if fdata.get("date_start"): + results = results.filter(created_at__range=[fdata['date_start'], + fdata['date_end']]) + + paginator = Paginator(results, 100) + page = request.GET.get("page") + + try: + devices = paginator.page(page) + except PageNotAnInteger: + devices = paginator.page(1) + except EmptyPage: + devices = paginator.page(paginator.num_pages) + + return render(request, "devices/find.html", locals()) + + +#@cache_page(60*5) +def parts(request, pk, order_id, queue_id): + """ + Lists available parts for this device/order + taking into account the order's queues GSX Sold-To + and the Location's corresponding GSX account + """ + from decimal import InvalidOperation + + device = Device.objects.get(pk=pk) + order = device.order_set.get(pk=order_id) + + try: + # remember the right GSX account + act = GsxAccount.default(request.user, order.queue) + request.session['gsx_account'] = act.pk + products = device.get_parts() + except gsxws.GsxError as message: + return render(request, "search/results/gsx_error.html", locals()) + except AttributeError: + message = _('Invalid serial number for parts lookup') + return render(request, "search/results/gsx_error.html", locals()) + except InvalidOperation: + message = _('Error calculating prices. Please check your system settings.') + return render(request, "search/results/gsx_error.html", locals()) + + return render(request, "devices/parts.html", locals()) + + +def model_parts(request, product_line=None, model=None): + """ + Shows parts for this device model + """ + data = prep_list_view(request, product_line, model) + + if cache.get("slugmap") and model: + models = cache.get("slugmap")[product_line] + data['what'] = "parts" + data['param'] = "productName" + data['query'] = models[model] + data['products'] = Product.objects.filter(tags__tag=data['query']) + + return render(request, "devices/index.html", data) + + +def choose(request, order_id): + """ + Choosing a device from within an SRO + Does GSX lookup in case device is not found locally + """ + context = {'order': order_id} + + if request.method == "POST": + + query = request.POST.get('q').upper() + results = Device.objects.filter(Q(sn__iexact=query) | Q(imei=query)) + + if len(results) < 1: + try: + current_order = request.session.get("current_order_id") + current_order = Order.objects.get(pk=current_order) + if current_order and current_order.queue: + GsxAccount.default(request.user, current_order.queue) + else: + GsxAccount.default(request.user) + results = [Device.from_gsx(query)] + except Exception as e: + context['error'] = e + return render(request, "devices/choose-error.html", context) + + context['results'] = results + return render(request, "devices/choose-list.html", context) + + return render(request, "devices/choose.html", context) + + +def upload_devices(request): + """ + User uploads device DB as tab-delimited CSV file + SN USERNAME PASSWORD NOTES + """ + gsx_account = None + form = DeviceUploadForm() + + if request.method == "POST": + form = DeviceUploadForm(request.POST, request.FILES) + + if form.is_valid(): + i = 0 + df = form.cleaned_data['datafile'].read() + + if form.cleaned_data.get('do_warranty_check'): + gsx_account = GsxAccount.default(request.user) + + for l in df.split("\r"): + l = l.decode("latin-1").encode("utf-8") + row = l.strip().split("\t") + + if gsx_account: + try: + device = Device.from_gsx(row[0]) + except Exception, e: + messages.error(request, e) + break + else: + device = Device.objects.get_or_create(sn=row[0])[0] + + try: + device.username = row[1] + device.password = row[2] + device.notes = row[3] + except IndexError: + pass + + device.save() + i += 1 + + if form.cleaned_data.get("customer"): + customer = form.cleaned_data['customer'] + customer.devices.add(device) + + messages.success(request, _("%d devices imported") % i) + + return redirect(index) + + data = {'form': form, 'action': request.path} + return render(request, "devices/upload_devices.html", data) + + +def update_gsx_details(request, pk): + """ + Updates devices GSX warranty details + """ + device = get_object_or_404(Device, pk=pk) + try: + GsxAccount.default(request.user) + device.update_gsx_details() + messages.success(request, _("Warranty status updated successfully")) + except Exception, e: + messages.error(request, e) + + if request.session.get('return_to'): + return redirect(request.session['return_to']) + + return redirect(device) + + +def get_info(request, pk): + device = get_object_or_404(Device, pk=pk) + return render(request, "devices/get_info.html", locals()) diff --git a/servo/views/error.py b/servo/views/error.py new file mode 100644 index 0000000..b05367d --- /dev/null +++ b/servo/views/error.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +from django import forms +from django.conf import settings +from django.core.mail import send_mail +from servo.lib.shorturl import from_time +from servo.forms.base import FullTextArea +from django.shortcuts import render +from django.utils.translation import ugettext as _ + + +class ErrorForm(forms.Form): + description = FullTextArea(max_length=512, min_length=10) + + +def report(request): + crashed = True + if request.method == 'POST': + form = ErrorForm(request.POST) + if form.is_valid(): + ref = 'Error %s' % from_time() + recipient = settings.ADMINS[0][1] + send_mail(ref, form.cleaned_data['description'], request.user.email, [recipient]) + crashed = False + else: + initial = _('Browser: %s') % request.META['HTTP_USER_AGENT'] + form = ErrorForm(initial={'description': initial}) + + return render(request, 'error.html', locals()) diff --git a/servo/views/events.py b/servo/views/events.py new file mode 100644 index 0000000..66ded84 --- /dev/null +++ b/servo/views/events.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +from django.utils import timezone +from django.shortcuts import redirect +from django.utils.translation import ugettext as _ + +from servo.models.common import Event + + +def acknowledge(request, pk): + e = Event.objects.get(pk=pk) + e.handled_at = timezone.now() + e.save() + + referer = request.META.get('HTTP_REFERER') + + if request.GET.get('return') == '0'and referer: + return redirect(referer) + + return redirect(e.content_object.get_absolute_url()) diff --git a/servo/views/files.py b/servo/views/files.py new file mode 100644 index 0000000..6c3002f --- /dev/null +++ b/servo/views/files.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import os +import mimetypes +from django.conf import settings +from django.http import HttpResponse, Http404 + +from servo.models.common import Attachment + + +def view_file(request, pk): + doc = Attachment.objects.get(pk=pk) + return HttpResponse(doc.content.read(), content_type=doc.mime_type) + + +def get_file(request, path): + """ + Returns a file from the upload directory + """ + try: + f = open(os.path.join(settings.MEDIA_ROOT, path), 'r') + except IOError: + raise Http404 + + mimetypes.init() + t, e = mimetypes.guess_type(f.name) + + return HttpResponse(f.read(), t) diff --git a/servo/views/gsx.py b/servo/views/gsx.py new file mode 100644 index 0000000..c9af1a1 --- /dev/null +++ b/servo/views/gsx.py @@ -0,0 +1,349 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import gsxws +import logging + +from django.contrib import messages +from django.http import HttpResponse +from django.utils.translation import ugettext as _ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth.decorators import permission_required + +from servo.models import Order, GsxAccount, Repair, ServicePart +from servo.forms import GsxCustomerForm, GsxRepairForm, GsxComponentForm + + +class RepairDetails(object): + def __init__(self, confirmation): + repair = gsxws.Repair(confirmation).details() + self.dispatch_id = repair.dispatchId + self.po_number = repair.purchaseOrderNumber + self.cs_code = repair.csCode + self.tracking_number = repair.deliveryTrackingNumber + self.notes = repair.notes + self.status = repair.repairStatus + self.status_description = repair.coverageStatusDescription + self.parts = repair.partsInfo + + +@permission_required("servo.change_order") +def register_return(request, part_id): + part = ServicePart.objects.get(pk=part_id) + try: + part.register_for_return(request.user) + messages.success(request, _(u"Part %s updated") % part.order_item.code) + except Exception, e: + messages.error(request, e) + + return redirect(part.repair.order) + + +@permission_required("servo.change_repair") +def import_repair(request, pk): + pass + + +@permission_required("servo.change_order") +def return_label(request, repair, part): + """ + Returns the return label PDF for this repair and part + """ + repair = Repair.objects.get(pk=repair) + + try: + repair.connect_gsx(request.user) + label_data = repair.get_return_label(part) + return HttpResponse(label_data, content_type="application/pdf") + except gsxws.GsxError, e: + messages.error(request, e) + return redirect(repair.order) + + +@permission_required("servo.change_repair") +def add_part(request, repair, part): + """ + Adds this part to this GSX repair + """ + rep = Repair.objects.get(pk=repair) + soi = rep.order.serviceorderitem_set.get(pk=part) + + if request.method == "POST": + try: + part = rep.add_part(soi, request.user) + data = {'part': part.part_number, 'repair': rep.confirmation} + msg = _("Part %(part)s added to repair %(repair)s") % data + messages.success(request, msg) + except gsxws.GsxError, e: + messages.error(request, e) + + return redirect(rep.order) + + context = {'item': soi} + context['repair'] = rep + context['action'] = request.path + + return render(request, "repairs/add_part.html", context) + + +def remove_part(request, repair, part): + rep = Repair.objects.get(pk=repair) + part = ServicePart.objects.get(pk=part) + + if request.method == "POST": + + rep.connect_gsx(request.user) + gsx_rep = rep.get_gsx_repair() + orderline = part.get_repair_order_line() + orderline.toDelete = True + orderline.orderLineNumber = part.line_number + + try: + gsx_rep.update({'orderLines': [orderline]}) + data = {'part': part.code, 'repair': rep.confirmation} + msg = _(u"Part %(part)s removed from %(repair)s") % data + messages.success(request, msg) + except gsxws.GsxError, e: + messages.error(request, e) + + return redirect(rep.order) + + data = {'action': request.path} + return render(request, "repairs/delete_part.html", data) + + +def delete_repair(request, repair_id): + repair = get_object_or_404(Repair, pk=repair_id) + if repair.submitted_at: + messages.error(request, _('Submitted repairs cannot be deleted')) + return redirect(repair.order) + + if request.method == 'POST': + order = repair.order + repair.delete() + messages.success(request, _('GSX repair deleted')) + return redirect(order) + + context = {'action': request.path} + return render(request, 'repairs/delete_repair.html', context) + + +def check_parts_warranty(request, repair): + """ + Checks this (new) repair warranty status + with the included device and parts + """ + repair = Repair.objects.get(pk=repair) + parts = repair.order.get_parts() + + try: + wty = repair.warranty_status() + wty_parts = wty.parts + except Exception, e: + return render(request, 'search/results/gsx_error.html', {'message': e}) + + try: + for k, v in enumerate(parts): + try: + parts[k].warranty_status = wty_parts[k].partWarranty + except TypeError: + parts[k].warranty_status = _('Unknown') + except KeyError: + parts[0].warranty_status = wty_parts.partWarranty + + context = {'parts': parts} + context['checked_parts'] = [p.pk for p in repair.parts.all()] + return render(request, 'repairs/check_parts.html', context) + + +def prep_edit_view(request, repair, order=None, device=None): + """ + Prepares edit view for GSX repair + """ + context = {'order': order} + + if repair.submitted_at: + raise ValueError(_("Submitted repairs cannot be edited")) + + if not order.has_parts: + raise ValueError(_("Please add some parts before creating repair")) + + if not order.customer: + raise ValueError(_("Cannot create GSX repair without valid customer data")) + + customer = order.customer.gsx_address(request.user.location) + customer_form = GsxCustomerForm(initial=customer) + + context['repair'] = repair + context['customer'] = customer + context['title'] = repair.get_number() + context['customer_form'] = customer_form + context['device'] = device or repair.device + context['repair_form'] = GsxRepairForm(instance=repair) + + if len(repair.component_data): + context['component_form'] = GsxComponentForm(components=repair.component_data) + + return context + + +def edit_repair(request, order_id, repair_id): + """ + Edits existing (non-submitted) GSX repair + """ + order = Order.objects.get(pk=order_id) + repair = Repair.objects.get(pk=repair_id) + repair.set_parts(order.get_parts()) + + try: + repair.connect_gsx(request.user) + repair.check_components() + data = prep_edit_view(request, repair, order) + except (ValueError, gsxws.GsxError) as e: + messages.error(request, e) + return redirect(order) + + if request.method == "POST": + try: + data = save_repair(request, data) + msg = _('GSX repair saved') + if 'confirm' in request.POST.keys(): + repair.submit(data['customer_data']) + msg = _(u"GSX repair %s created") % repair.confirmation + messages.success(request, msg) + return redirect("repairs-view_repair", order.pk, repair.pk) + messages.success(request, msg) + return redirect(order) + except Exception, e: + messages.error(request, e) + + return render(request, "orders/gsx_repair_form.html", data) + + +def save_repair(request, context): + """ + Saves this GSX repair + """ + repair = context['repair'] + customer = context['customer'] + + if len(repair.component_data): + component_form = GsxComponentForm(request.POST, components=repair.component_data) + if component_form.is_valid(): + repair.component_data = component_form.json_data + else: + raise ValueError(_("Invalid component data")) + + customer_form = GsxCustomerForm(request.POST, initial=customer) + repair_form = GsxRepairForm(request.POST, request.FILES, instance=repair) + + if customer_form.is_valid(): + context['customer_data'] = customer_form.cleaned_data + if repair_form.is_valid(): + parts = repair_form.cleaned_data['parts'] + repair.save() + repair.set_parts(parts) + else: + logging.debug(repair_form.errors) + raise ValueError(repair_form.errors) + else: + raise ValueError(_("Invalid customer info")) + + context['repair_form'] = repair_form + context['customer_form'] = customer_form + + return context + + +def create_repair(request, order_id, device_id, type): + """ + Creates a GSX repair for the specified SRO and device + and redirects to the repair's edit page. + """ + from datetime import timedelta + from django.utils import timezone + + order = Order.objects.get(pk=order_id) + device = order.devices.get(pk=device_id) + + repair = Repair(order=order, created_by=request.user, device=device) + timediff = timezone.now() - order.created_at + + if timediff.seconds <= 3600: + repair.unit_received_at = order.created_at - timedelta(hours=1) + else: + repair.unit_received_at = order.created_at + + repair.reference = request.user.gsx_poprefix + order.code + + try: + repair.gsx_account = GsxAccount.default(request.user, order.queue) + except Exception, e: + messages.error(request, e) + return redirect(order) + + repair.repair_type = type + repair.tech_id = request.user.tech_id + repair.save() + + return redirect(edit_repair, order.pk, repair.pk) + + +def repair_details(request, confirmation): + """ + Returns GSX repair details for confirmation number + """ + repair = RepairDetails(confirmation) + data = {'repair': repair} + if request.method == "POST": + data = save_repair(request, data) + return render(request, "repairs/get_details.html", data) + + +def copy_repair(request, pk): + """ + Duplicates a local GSX repair + """ + repair = Repair.objects.get(pk=pk) + new_repair = repair.duplicate(request.user) + return redirect(edit_repair, new_repair.order_id, new_repair.pk) + + +def update_sn(request, pk, part): + """ + Updates the parts serial number + """ + part = ServicePart.objects.get(pk=part) + + try: + part.repair.connect_gsx(request.user) + part.update_sn() + msg = _(u'%s serial numbers updated') % part.part_number + messages.success(request, msg) + except Exception, e: + messages.error(request, e) + + return redirect(part.repair.order) diff --git a/servo/views/invoices.py b/servo/views/invoices.py new file mode 100644 index 0000000..cc48c60 --- /dev/null +++ b/servo/views/invoices.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +from django import forms +from django.utils import timezone +from django.contrib import messages +from django.utils.translation import ugettext as _ +from django.forms.models import inlineformset_factory +from django.contrib.auth.decorators import permission_required +from django.shortcuts import render, redirect, get_object_or_404 +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from servo.forms.invoices import * +from servo.models import Order, Invoice, Payment, PurchaseOrder + + +def invoices(request): + """ + Lists invoices, optionally with a search filter + """ + from datetime import timedelta + from django.db.models import Sum + + data = {'title': _("Invoices")} + now = timezone.now() + + start_date, end_date = now - timedelta(days=30), now + initial = {'start_date': start_date, 'end_date': end_date} + + invoices = Invoice.objects.filter(created_at__range=(start_date, end_date)) + form = InvoiceSearchForm(initial=initial) + + if request.method == 'POST': + invoices = Invoice.objects.all() + form = InvoiceSearchForm(request.POST, initial=initial) + + if form.is_valid(): + fdata = form.cleaned_data + if fdata.get('state') == 'OPEN': + invoices = invoices.filter(paid_at=None) + if fdata.get('state') == 'PAID': + invoices = invoices.exclude(paid_at=None) + + payment_method = fdata.get('payment_method') + if len(payment_method): + invoices = invoices.filter(payment__method=payment_method) + + start_date = fdata.get('start_date', start_date) + end_date = fdata.get('end_date', end_date) + invoices = invoices.filter(created_at__range=(start_date, end_date)) + + if fdata.get('status_isnot'): + invoices = invoices.exclude(order__status__status=fdata['status_isnot']) + + if fdata.get('customer_name'): + invoices = invoices.filter(customer_name__icontains=fdata['customer_name']) + + if fdata.get('service_order'): + invoices = invoices.filter(order__code__exact=fdata['service_order']) + + page = request.GET.get('page') + data['total'] = invoices.aggregate(Sum('total_net')) + data['total_paid'] = invoices.exclude(paid_at=None).aggregate(Sum('total_net')) + pos = PurchaseOrder.objects.filter(created_at__range=[start_date, end_date]) + data['total_purchases'] = pos.aggregate(Sum('total')) + + paginator = Paginator(invoices, 50) + + try: + invoices = paginator.page(page) + except PageNotAnInteger: + invoices = paginator.page(1) + except EmptyPage: + invoices = paginator.page(paginator.num_pages) + + data['form'] = form + data['invoices'] = invoices + + return render(request, "invoices/index.html", data) + + +def gsx_invoices(request): + pass + + +def print_invoice(request, pk): + from servo.models import Configuration + + invoice = get_object_or_404(Invoice, pk=pk) + template = invoice.order.get_print_template("receipt") + + title = _("Receipt #%d") % invoice.pk + conf = Configuration.conf() + order = invoice.order + + return render(request, template, locals()) + + +def view_invoice(request, pk): + title = _("Invoice %s") % pk + invoice = get_object_or_404(Invoice, pk=pk) + return render(request, "invoices/view_invoice.html", locals()) + + +@permission_required('servo.change_order') +def create_invoice(request, order_id=None, numbers=None): + """ + Dispatches Sales Order + """ + order = get_object_or_404(Order, pk=order_id) + title = _(u'Dispatch Order %s') % order.code + products = order.products.filter(dispatched=False) + + initial = { + 'order': order, + 'products': products, + 'total_tax': order.total_tax(), + 'total_net': order.net_total(), + 'total_gross': order.gross_total(), + } + + total_margin = order.total_margin() + + invoice = Invoice(order=order) + invoice.created_by = request.user + invoice.customer = order.customer + invoice.total_margin = total_margin + + if order.customer: + customer = order.customer + initial['customer_name'] = customer.name + initial['customer_phone'] = customer.phone + initial['customer_email'] = customer.email + initial['customer_address'] = customer.street_address + else: + initial['customer_name'] = _(u'Walk-In Customer') + + form = InvoiceForm(initial=initial, instance=invoice, prefix='invoice') + + PaymentFormset = inlineformset_factory(Invoice, Payment, extra=1, form=PaymentForm, exclude=[]) + initial = [{'amount': order.gross_total, 'created_by': request.user}] + formset = PaymentFormset(initial=initial, prefix='payment') + + if request.method == 'POST': + form = InvoiceForm(request.POST, instance=invoice, prefix='invoice') + if form.is_valid(): + invoice = form.save() + formset = PaymentFormset(request.POST, instance=invoice, prefix='payment') + + if formset.is_valid(): + payments = formset.save() + else: + messages.error(request, formset.errors) + return render(request, "orders/dispatch.html", locals()) + + products = request.POST.getlist('items') + + try: + order.dispatch(invoice=invoice, products=products) + messages.success(request, _(u'Order %s dispatched') % order.code) + except Exception, e: + messages.error(request, e) + return redirect(order) + else: + messages.error(request, form.errors) + + return render(request, "orders/dispatch.html", locals()) + + +@permission_required('servo.change_order') +def add_payment(request, pk): + invoice = get_object_or_404(Invoice, pk=pk) + payment = Payment(invoice=invoice) + payment.created_by = request.user + payment.amount = request.POST.get('amount') + payment.save() diff --git a/servo/views/note.py b/servo/views/note.py new file mode 100644 index 0000000..29c74e1 --- /dev/null +++ b/servo/views/note.py @@ -0,0 +1,435 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import json +import StringIO +from gsxws import escalations + +from django import template +from django.contrib import messages +from django.http import HttpResponse +from django.utils.translation import ugettext as _ +from django.forms.models import modelformset_factory +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.cache import cache_page +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth.decorators import permission_required +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from reportlab.lib.units import mm +from reportlab.graphics.shapes import Drawing +from reportlab.graphics.barcode import createBarcodeDrawing + +from servo.models import Order, Template, Tag, Customer, Note, Attachment, Escalation +from servo.forms import NoteForm, NoteSearchForm, EscalationForm + + +class BarcodeDrawing(Drawing): + def __init__(self, text_value, *args, **kwargs): + barcode = createBarcodeDrawing( + "Code128", + value=text_value.encode("utf-8"), + barHeight=10*mm, + width=80*mm + ) + + Drawing.__init__(self, barcode.width, barcode.height, *args, **kwargs) + self.add(barcode, name="barcode") + + +def show_barcode(request, text): + """ + Returns text as a barcode + """ + if request.GET.get('f') == 'svg': + import barcode + output = StringIO.StringIO() + code = barcode.Code39(text, add_checksum=False) + code.write(output) + contents = output.getvalue() + output.close() + return HttpResponse(contents, content_type="image/svg+xml") + + d = BarcodeDrawing(text) + return HttpResponse(d.asString("png"), content_type="image/png") + + +def prep_list_view(request, kind): + """ + Prepares the view for listing notes/messages + """ + data = {'title': _("Messages")} + all_notes = Note.objects.all().order_by("-created_at") + + if kind == "inbox": + all_notes = all_notes.filter(order=None).order_by("is_read", "-created_at") + if kind == "sent": + all_notes = all_notes.filter(created_by=request.user) + if kind == "flagged": + all_notes = all_notes.filter(is_flagged=True) + if kind == "escalations": + all_notes = Note.objects.all().exclude(escalation=None) + + page = request.GET.get("page") + paginator = Paginator(all_notes, 20) + + try: + notes = paginator.page(page) + except PageNotAnInteger: + notes = paginator.page(1) + except EmptyPage: + notes = paginator.page(paginator.num_pages) + + data['kind'] = kind + data['notes'] = notes + data['inbox_count'] = Note.objects.filter(order=None).count() + + return data + + +@permission_required('servo.change_note') +def copy(request, pk): + """ + Copies a note with its attachments and labels + """ + from servo.lib.shorturl import from_time + note = get_object_or_404(Note, pk=pk) + + new_note = Note(created_by=request.user) + new_note.body = note.body + new_note.order = note.order + new_note.subject = note.subject + new_note.save() + + new_note.labels = note.labels.all() + + for a in note.attachments.all(): + a.pk = None + a.content_object = new_note + a.save() + new_note.attachments.add(a) + + return redirect(edit, pk=new_note.pk, order_id=note.order_id) + + +@permission_required('servo.change_note') +def edit(request, pk=None, order_id=None, parent=None, recipient=None, customer=None): + """ + Edits a note + """ + to = [] + order = None + note = Note(order_id=order_id) + excluded_emails = note.get_excluded_emails() + + if recipient is not None: + to.append(recipient) + + if order_id is not None: + order = get_object_or_404(Order, pk=order_id) + + if order.user and (order.user != request.user): + note.is_read = False + if order.user.email not in excluded_emails: + to.append(order.user.email) + + if order.customer is not None: + customer = order.customer_id + + if customer is not None: + customer = Customer.objects.get(pk=customer) + note.customer = customer + + if order_id is None: + to.append(customer.email) + + tpl = template.Template(note.subject) + note.subject = tpl.render(template.Context({'note': note})) + + note.recipient = ', '.join(to) + note.created_by = request.user + note.sender = note.get_default_sender() + + fields = escalations.CONTEXTS + + try: + note.escalation = Escalation(created_by=request.user) + except Exception, e: + messages.error(request, e) + return redirect(request.META['HTTP_REFERER']) + + AttachmentFormset = modelformset_factory(Attachment, + fields=('content',), + can_delete=True, + extra=3, + exclude=[]) + formset = AttachmentFormset(queryset=Attachment.objects.none()) + + if pk is not None: + note = get_object_or_404(Note, pk=pk) + formset = AttachmentFormset(queryset=note.attachments.all()) + + if parent is not None: + parent = Note.objects.get(pk=parent) + note.parent = parent + note.body = parent.quote() + + if parent.subject: + note.subject = _(u'Re: %s') % parent.clean_subject() + if parent.sender not in excluded_emails: + note.recipient = parent.sender + if parent.order: + order = parent.order + note.order = parent.order + + note.customer = parent.customer + note.escalation = parent.escalation + note.is_reported = parent.is_reported + + title = note.subject + form = NoteForm(instance=note) + + if note.escalation: + contexts = json.loads(note.escalation.contexts) + + escalation_form = EscalationForm(prefix='escalation', instance=note.escalation) + + if request.method == "POST": + escalation_form = EscalationForm(request.POST, + prefix='escalation', + instance=note.escalation) + + if escalation_form.is_valid(): + note.escalation = escalation_form.save() + + form = NoteForm(request.POST, instance=note) + + if form.is_valid(): + + note = form.save() + formset = AttachmentFormset(request.POST, request.FILES) + + if formset.is_valid(): + + files = formset.save(commit=False) + + for f in files: + f.content_object = note + try: + f.save() + except ValueError, e: + messages.error(request, e) + return redirect(note) + + note.attachments.add(*files) + note.save() + + try: + msg = note.send_and_save(request.user) + messages.success(request, msg) + except ValueError, e: + messages.error(request, e) + + return redirect(note) + + return render(request, "notes/form.html", locals()) + + +def delete_note(request, pk): + """ + Deletes a note + """ + note = get_object_or_404(Note, pk=pk) + + if request.method == 'POST': + note.delete() + messages.success(request, _("Note deleted")) + + if request.session.get('return_to'): + url = request.session.get('return_to') + del(request.session['return_to']) + elif note.order_id: + url = note.order.get_absolute_url() + + return redirect(url) + + return render(request, 'notes/remove.html', {'note': note}) + + +@csrf_exempt +def render_template(request): + """ + Renders the template with this title with the current + Service Order as the context + """ + content = '' + title = request.POST.get('title') + tpl = Template.objects.get(title=title) + + if request.session.get('current_order_id'): + tpl = template.Template(tpl.content) + order = Order.objects.get(pk=request.session['current_order_id']) + content = tpl.render(template.Context({'order': order})) + + return HttpResponse(content) + + +def templates(request, template_id=None): + if template_id is not None: + tpl = Template.objects.get(pk=template_id) + content = tpl.content + if request.session.get('current_order_id'): + tpl = template.Template(content) + order = Order.objects.get(pk=request.session['current_order_id']) + content = tpl.render(template.Context({'order': order})) + + return HttpResponse(content) + + templates = Template.objects.all() + return render(request, 'notes/templates.html', {'templates': templates}) + + +def toggle_flag(request, pk, flag): + field = 'is_%s' % flag + note = Note.objects.get(pk=pk) + attr = getattr(note, field) + setattr(note, field, not attr) + note.save() + + return HttpResponse(getattr(note, 'get_%s_title' % flag)()) + + +def toggle_tag(request, pk, tag_id): + note = Note.objects.get(pk=pk) + tag = Tag.objects.get(pk=tag_id) + + if tag in note.labels.all(): + note.labels.remove(tag) + else: + note.labels.add(tag) + + if note.order: + return redirect(note.order) + + return HttpResponse(_('OK')) + +def list_notes(request, kind="inbox"): + data = prep_list_view(request, kind) + request.session['return_to'] = request.path + return render(request, "notes/list_notes.html", data) + + +def view_note(request, kind, pk): + note = Note.objects.get(pk=pk) + data = prep_list_view(request, kind) + data['title'] = note.subject + data['note'] = note + + if kind == 'escalations': + return render(request, "notes/view_escalation.html", data) + else: + return render(request, "notes/view_note.html", data) + + +def search(request): + query = request.GET.get("q") + request.session['search_query'] = query + + title = _(u'Notes containing "%s"') % query + results = Note.objects.filter(body__icontains=query).order_by('-created_at') + paginator = Paginator(results, 10) + + page = request.GET.get("page") + + try: + notes = paginator.page(page) + except PageNotAnInteger: + notes = paginator.page(1) + except EmptyPage: + notes = paginator.page(paginator.num_pages) + + return render(request, "notes/search.html", locals()) + + +def find(request): + form = NoteSearchForm(request.GET) + results = Note.objects.none() + + if request.GET and form.is_valid(): + + fdata = form.cleaned_data + results = Note.objects.all() + + if fdata.get('body'): + results = results.filter(body__icontains=fdata['body']) + if fdata.get('recipient'): + results = results.filter(recipient__icontains=fdata['recipient']) + if fdata.get('sender'): + results = results.filter(sender__icontains=fdata['sender']) + if fdata.get('order_code'): + results = results.filter(order__code__icontains=fdata['order_code']) + + results = results.order_by('-created_at') + + paginator = Paginator(results, 10) + page = request.GET.get("page") + + try: + notes = paginator.page(page) + except PageNotAnInteger: + notes = paginator.page(1) + except EmptyPage: + notes = paginator.page(paginator.num_pages) + + title = _('Message search') + return render(request, "notes/find.html", locals()) + + +def edit_escalation(request): + pass + + +def create_escalation(request): + esc = Escalation() + form = EscalationForm() + title = _('Edit Escalation') + + if request.method == 'POST': + data = request.POST.copy() + data['created_by'] = request.user + form = EscalationForm(data, request.FILES, instance=esc) + if form.is_valid(): + note = form.save() + #esc.submit(request.user) + return redirect(view_note, 'escalations', note.pk) + + return render(request, 'notes/edit_escalation.html', locals()) + + +def list_messages(request, pk): + note = get_object_or_404(Note, pk=pk) + messages = note.message_set.all() + return render(request, "notes/messages.html", locals()) diff --git a/servo/views/order.py b/servo/views/order.py new file mode 100644 index 0000000..00767ab --- /dev/null +++ b/servo/views/order.py @@ -0,0 +1,990 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import json + +from gsxws.core import GsxError +from django.http import QueryDict + +from django.db.models import Q +from django.utils import timezone +from django.contrib import messages +from django.core.cache import cache +from django.http import HttpResponse + +from django.db import DatabaseError + +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ +from django.views.decorators.cache import cache_page +from django.shortcuts import render, redirect, get_object_or_404 + +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.decorators import permission_required +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from servo.models.order import * +from servo.forms.orders import * + +from servo.models import Note, User, Device, Customer +from servo.models.common import (Tag, + Configuration, + FlaggedItem, + GsxAccount,) +from servo.models.repair import (Checklist, + ChecklistItem, + Repair, + ChecklistItemValue,) + + +def prepare_list_view(request, args): + """ + Lists service orders matching specified criteria + """ + data = {'title': _("Orders")} + + form = OrderSearchForm(args) + form.fields['location'].queryset = request.user.locations + + if request.session.get("current_queue"): + del(request.session['current_queue']) + + if request.session.get("return_to"): + del(request.session['return_to']) + + if request.user.customer: + orders = Order.objects.filter(customer=request.user.customer) + else: + orders = Order.objects.filter(location__in=request.user.locations.all()) + + if args.get("state"): + orders = orders.filter(state__in=args.getlist("state")) + + start_date = args.get("start_date") + if start_date: + end_date = args.get('end_date') or timezone.now() + orders = orders.filter(created_at__range=[start_date, end_date]) + + if args.get("assigned_to"): + users = args.getlist("assigned_to") + orders = orders.filter(user__in=users) + + if args.get("followed_by"): + users = args.getlist("followed_by") + orders = orders.filter(followed_by__in=users) + + if args.get("created_by"): + users = args.getlist("created_by") + orders = orders.filter(created_by__in=users) + + if args.get("customer"): + customer = int(args['customer'][0]) + if customer == 0: + orders = orders.filter(customer__pk=None) + else: + orders = orders.filter(customer__tree_id=customer) + + if args.get("spec"): + spec = args['spec'][0] + if spec is "None": + orders = orders.filter(devices=None) + else: + orders = orders.filter(devices__slug=spec) + + if args.get("device"): + orders = orders.filter(devices__pk=args['device']) + + if args.get("queue"): + queue = args.getlist("queue") + orders = orders.filter(queue__in=queue) + + if args.get("checkin_location"): + ci_location = args.getlist("checkin_location") + orders = orders.filter(checkin_location__in=ci_location) + + if args.get("location"): + location = args.getlist("location") + orders = orders.filter(location__in=location) + + if args.get("label"): + orders = orders.filter(tags__in=args.getlist("label")) + + if args.get("status"): + status = args.getlist("status") + + if args['status'][0] == 'None': + orders = orders.filter(status__pk=None) + else: + orders = orders.filter(status__status__in=status) + + if args.get("color"): + color = args.getlist("color") + now = timezone.now() + + if "grey" in color: + orders = orders.filter(status=None) + if "green" in color: + orders = orders.filter(status_limit_green__gte=now) + if "yellow" in color: + orders = orders.filter(status_limit_yellow__gte=now, + status_limit_green__lte=now) + if "red" in color: + orders = orders.filter(status_limit_yellow__lte=now) + + page = request.GET.get("page") + paginator = Paginator(orders.distinct(), 100) + + try: + order_pages = paginator.page(page) + except PageNotAnInteger: + order_pages = paginator.page(1) + except EmptyPage: + order_pages = paginator.page(paginator.num_pages) + + data['form'] = form + data['queryset'] = orders + data['orders'] = order_pages + data['subtitle'] = _("%d search results") % orders.count() + + # @FIXME!!! how to handle this with jsonserializer??? + #request.session['order_queryset'] = orders + + return data + + +def prepare_detail_view(request, pk): + """ + Prepares the view for whenever we're dealing with a specific order + """ + order = get_object_or_404(Order, pk=pk) + + request.session['current_order_id'] = None + request.session['current_order_code'] = None + request.session['current_order_customer'] = None + + title = _(u'Order %s') % order.code + priorities = Queue.PRIORITIES + followers = order.followed_by.all() + locations = Location.objects.filter(enabled=True) + queues = request.user.queues.all() + users = order.get_available_users(request.user) + + # wrap the customer in a list for easier recursetree + if order.customer is not None: + customer = order.customer.get_ancestors(include_self=True) + title = u'%s | %s' % (title, order.customer.name) + else: + customer = [] + + statuses = [] + checklists = [] + + if order.queue is not None: + checklists = Checklist.objects.filter(queues=order.queue) + statuses = order.queue.queuestatus_set.all() + + if order.is_editable: + request.session['current_order_id'] = order.pk + request.session['current_order_code'] = order.code + request.session['return_to'] = order.get_absolute_url() + if order.customer: + request.session['current_order_customer'] = order.customer.pk + + return locals() + + +@permission_required("servo.change_order") +def close(request, pk): + """ + Closes this Service Order + """ + order = Order.objects.get(pk=pk) + + if request.method == 'POST': + try: + order.close(request.user) + except Exception as e: + messages.error(request, e) + return redirect(order) + + if request.session.get("current_order_id"): + del(request.session['current_order_id']) + del(request.session['current_order_code']) + del(request.session['current_order_customer']) + + messages.success(request, _('Order %s closed') % order.code) + + return redirect(order) + + data = {'order': order, 'action': request.path} + return render(request, "orders/close.html", data) + + +@permission_required("servo.delete_order") +def reopen_order(request, pk): + order = Order.objects.get(pk=pk) + msg = order.reopen(request.user) + messages.success(request, msg) + return redirect(order) + + +@permission_required("servo.add_order") +def create(request, sn=None, device_id=None, product_id=None, note_id=None, customer_id=None): + """ + Creates a new Service Order + """ + order = Order(created_by=request.user) + + if customer_id is not None: + order.customer_id = customer_id + + try: + order.save() + except Exception as e: + messages.error(request, e) + return redirect(list_orders) + + messages.success(request, _("Order %s created") % order.code) + + # create service order from a new device + if sn is not None: + order.add_device_sn(sn, request.user) + + if device_id is not None: + device = Device.objects.get(pk=device_id) + order.add_device(device, request.user) + + # creating an order from a product + if product_id is not None: + return redirect(add_product, order.pk, product_id) + + # creating an order from a note + if note_id is not None: + note = Note.objects.get(pk=note_id) + note.order = order + note.save() + # try to match a customer + if note.sender: + try: + customer = Customer.objects.get(email=note.sender) + order.customer = customer + order.save() + except Customer.DoesNotExist: + pass + + return redirect(order) + + +def list_orders(request): + """ + orders/index + """ + args = request.GET.copy() + default = QueryDict('state={0}'.format(Order.STATE_QUEUED)) + + if len(args) < 2: # search form not submitted + args = request.session.get("order_search_filter", default) + + request.session['order_search_filter'] = args + data = prepare_list_view(request, args) + + return render(request, "orders/index.html", data) + + +@permission_required("servo.change_order") +def toggle_tag(request, order_id, tag_id): + tag = Tag.objects.get(pk=tag_id) + order = Order.objects.get(pk=order_id) + + if tag not in order.tags.all(): + order.add_tag(tag) + else: + order.tags.remove(tag) + + return HttpResponse(tag.title) + + +@permission_required("servo.change_order") +def toggle_task(request, order_id, item_id): + """ + Toggles a given Check List item in this order + """ + checklist_item = ChecklistItem.objects.get(pk=item_id) + + try: + item = ChecklistItemValue.objects.get(order_id=order_id, item=checklist_item) + item.delete() + except ChecklistItemValue.DoesNotExist: + item = ChecklistItemValue() + item.item = checklist_item + item.order_id = order_id + item.checked_by = request.user + item.save() + + return HttpResponse(checklist_item.title) + + +def repair(request, order_id, repair_id): + """ + Show the corresponding GSX Repair for this Service Order + """ + repair = get_object_or_404(Repair, pk=repair_id) + data = prepare_detail_view(request, order_id) + data['repair'] = repair + + try: + repair.connect_gsx(request.user) + details = repair.get_details() + try: + data['notes'] = details.notes.encode('utf-8') + except AttributeError: + pass + data['status'] = repair.update_status(request.user) + except Exception as e: + messages.error(request, e) + + data['parts'] = repair.servicepart_set.all() + return render(request, "orders/repair.html", data) + + +@permission_required("servo.change_order") +def complete_repair(request, order_id, repair_id): + repair = Repair.objects.get(pk=repair_id) + if request.method == 'POST': + try: + repair.close(request.user) + msg = _(u"Repair %s marked complete.") % repair.confirmation + messages.success(request, msg) + except GsxError, e: + messages.error(request, e) + + return redirect(repair.order) + + return render(request, 'orders/close_repair.html', locals()) + + +@csrf_exempt +@permission_required("servo.change_order") +def accessories(request, pk, device_id): + from django.utils import safestring + + if request.POST.get('name'): + a = Accessory(name=request.POST['name']) + a.order_id = pk + a.device_id = device_id + a.save() + + choice_list = [] + choices = Accessory.objects.distinct('name') + + for c in choices: + choice_list.append(c.name) + + action = reverse('orders-accessories', args=[pk, device_id]) + selected = Accessory.objects.filter(order_id=pk, device_id=device_id) + choices_json = safestring.mark_safe(json.dumps(choice_list)) + + return render(request, 'devices/accessories_edit.html', locals()) + + +@permission_required('servo.change_order') +def delete_accessory(request, order_id, device_id, pk): + Accessory.objects.filter(pk=pk).delete() + return accessories(request, order_id, device_id) + + +@permission_required("servo.change_order") +def edit(request, pk): + data = prepare_detail_view(request, pk) + data['note_tags'] = Tag.objects.filter(type='note') + return render(request, "orders/edit.html", data) + + +@permission_required('servo.delete_order') +def delete(request, pk): + + order = get_object_or_404(Order, pk=pk) + + if request.method == "POST": + return_to = order.get_queue_url() + try: + order.delete() + del(request.session['current_order_id']) + del(request.session['current_order_code']) + del(request.session['current_order_customer']) + messages.success(request, _(u'Order %s deleted') % order.code) + return redirect(return_to) + except Exception as e: + ed = {'order': order.code, 'error': e} + messages.error(request, _(u'Cannot delete order %(order)s: %(error)s') % ed) + return redirect(order) + + action = request.path + return render(request, "orders/delete_order.html", locals()) + + +@permission_required('servo.change_order') +def toggle_follow(request, order_id): + order = Order.objects.get(pk=order_id) + data = {'icon': "open", 'action': _("Follow")} + + if request.user in order.followed_by.all(): + order.followed_by.remove(request.user) + else: + order.followed_by.add(request.user) + data = {'icon': "close", 'action': _("Unfollow")} + + if request.is_ajax(): + return render(request, "orders/toggle_follow.html", data) + + return redirect(order) + + +def toggle_flagged(request, pk): + order = Order.objects.get(pk=pk) + t = FlaggedItem(content_object=order, flagged_by=request.user) + t.save() + + +@permission_required("servo.change_order") +def remove_user(request, pk, user_id): + """ + Removes this user from the follower list, unsets assignee + """ + order = get_object_or_404(Order, pk=pk) + user = User.objects.get(pk=user_id) + + try: + order.remove_follower(user) + if user == order.user: + order.set_user(None, request.user) + order.notify("unset_user", _('User %s removed from followers') % user, request.user) + except Exception, e: + messages.error(request, e) + + return redirect(order) + + +@permission_required("servo.change_order") +def update_order(request, pk, what, what_id): + """ + Updates some things about an order + """ + order = get_object_or_404(Order, pk=pk) + what_id = int(what_id) + + if order.state is Order.STATE_CLOSED: + messages.error(request, _("Closed orders cannot be modified")) + return redirect(order) + + if what == "user": + if request.method == "POST": + fullname = request.POST.get("user") + try: + user = User.active.get(full_name=fullname) + if order.user is None: + order.set_user(user, request.user) + else: + order.add_follower(user) + order.save() + except User.DoesNotExist: + messages.error(request, _(u"User %s not found") % fullname) + elif what_id > 0: + user = User.objects.get(pk=what_id) + order.set_user(user, request.user) + + if what == "queue": + order.set_queue(what_id, request.user) + + if what == "status": + order.set_status(what_id, request.user) + + if what == "priority": + order.priority = what_id + order.save() + + if what == "place" and request.method == "POST": + place = request.POST.get("place") + order.notify("set_place", place, request.user) + order.place = place + order.save() + + if what == "label" and request.method == "POST": + label = request.POST.get("label") + + try: + tag = Tag.objects.get(title=label, type="order") + order.add_tag(tag, request.user) + except Tag.DoesNotExist: + messages.error(request, _(u"Label %s does not exist") % label) + + if what == "checkin": + location = Location.objects.get(pk=what_id) + order.checkin_location = location + messages.success(request, _('Order updated')) + order.save() + + if what == "checkout": + location = Location.objects.get(pk=what_id) + order.checkout_location = location + messages.success(request, _('Order updated')) + order.save() + + if what == "location": + location = Location.objects.get(pk=what_id) + msg = order.set_location(location, request.user) + messages.success(request, msg) + + request.session['current_order_id'] = order.pk + request.session['current_order_code'] = order.code + if order.queue: + request.session['current_order_queue'] = order.queue.pk + + return redirect(order) + + +def put_on_paper(request, pk, kind="confirmation"): + """ + 'Print' was taken? + """ + conf = Configuration.conf() + order = get_object_or_404(Order, pk=pk) + + title = _(u"Service Order #%s") % order.code + notes = order.note_set.filter(is_reported=True) + + template = order.get_print_template(kind) + + if kind == "receipt": + try: + invoice = order.invoice_set.latest() + except Exception as e: + pass + + return render(request, template, locals()) + + +@permission_required("servo.change_order") +def add_device(request, pk, device_id=None, sn=None): + """ + Adds a device to a service order + using device_id with existing devices or + sn for new devices (which should have gone through GSX search) + """ + order = get_object_or_404(Order, pk=pk) + + if device_id is not None: + device = Device.objects.get(pk=device_id) + + if sn is not None: + sn = sn.upper() + # not using get() since SNs are not unique + device = Device.objects.filter(sn=sn).first() + + if device is None: + try: + device = Device.from_gsx(sn) + device.save() + except Exception, e: + messages.error(request, e) + return redirect(order) + + try: + event = order.add_device(device, request.user) + messages.success(request, event) + except Exception, e: + messages.error(request, e) + return redirect(order) + + if order.customer: + order.customer.devices.add(device) + + return redirect(order) + + +@permission_required("servo.change_order") +def remove_device(request, order_id, device_id): + action = request.path + order = Order.objects.get(pk=order_id) + device = Device.objects.get(pk=device_id) + + if request.method == "POST": + msg = order.remove_device(device, request.user) + messages.info(request, msg) + return redirect(order) + + return render(request, "orders/remove_device.html", locals()) + + +def events(request, order_id): + data = prepare_detail_view(request, order_id) + return render(request, "orders/events.html", data) + + +def device_from_product(request, pk, item_id): + """ + Turns a SOI into a device and attaches it to this order + """ + order = Order.objects.get(pk=pk) + soi = ServiceOrderItem.objects.get(pk=item_id) + + try: + GsxAccount.default(request.user, order.queue) + device = Device.from_gsx(soi.sn) + device.save() + event = order.add_device(device, request.user) + messages.success(request, event) + except Exception, e: + messages.error(request, e) + + return redirect(order) + + +@permission_required('servo.change_order') +def reserve_products(request, pk): + order = Order.objects.get(pk=pk) + location = request.user.get_location() + + if request.method == 'POST': + + for p in order.serviceorderitem_set.all(): + p.reserve_product() + + msg = _(u"Products of order %s reserved") % order.code + order.notify("products_reserved", msg, request.user) + messages.info(request, msg) + + return redirect(order) + + data = {'order': order, 'action': request.path} + return render(request, "orders/reserve_products.html", data) + + +@permission_required("servo.change_order") +def edit_product(request, pk, item_id): + """ + Edits a product added to an order + """ + order = Order.objects.get(pk=pk) + item = ServiceOrderItem.objects.get(pk=item_id) + + if not item.kbb_sn and item.product.part_type == "REPLACEMENT": + try: + device = order.devices.all()[0] + item.kbb_sn = device.sn + except IndexError: + pass # Probably no device in the order + + if item.product.component_code: + try: + GsxAccount.default(request.user, order.queue) + except Exception, e: + return render(request, "snippets/error_modal.html", {'error': e}) + + form = OrderItemForm(instance=item) + + if request.method == "POST": + form = OrderItemForm(request.POST, instance=item) + if form.is_valid(): + try: + item = form.save() + # Set whoever set the KBB sn as the one who replaced the part + if item.kbb_sn and not item.replaced_by: + item.replaced_by = request.user + item.save() + + messages.success(request, _(u"Product %s saved") % item.code) + + return redirect(order) + except Exception, e: + messages.error(request, e) + + product = item.product + title = product.code + prices = json.dumps(item.product.get_price()) + + return render(request, "orders/edit_product.html", locals()) + + +@permission_required("servo.change_order") +def add_product(request, pk, product_id): + "Adds this product to this Sales Order" + order = Order.objects.get(pk=pk) + product = Product.objects.get(pk=product_id) + order.add_product(product, 1, request.user) + messages.success(request, _(u'Product %s added') % product.code) + + return redirect(order) + + +@permission_required("servo.change_order") +def add_part(request, pk, device, code): + """ + Adds a part for this device to this order + """ + gsx_product = cache.get(code) + order = Order.objects.get(pk=pk) + device = Device.objects.get(pk=device) + + try: + product = Product.objects.get(code=code) + if not product.fixed_price: + product.update_price(gsx_product) + except Product.DoesNotExist: + product = gsx_product + + product.save() + + try: + tag, created = TaggedItem.objects.get_or_create( + content_type__model="product", + object_id=product.pk, + tag=device.description + ) + tag.save() + except DatabaseError: + pass + + order.add_product(product, 1, request.user) + + return render(request, "orders/list_products.html", locals()) + + +def choose_product(request, order_id): + pass + + +@permission_required("servo.change_order") +def report_product(request, pk, item_id): + product = ServiceOrderItem.objects.get(pk=item_id) + product.should_report = not product.should_report + product.save() + + if product.should_report: + return HttpResponse('<i class="icon-ok"></i>') + + return HttpResponse('<i class="icon-ok icon-white"></i>') + + +@permission_required("servo.change_order") +def report_device(request, pk, device_id): + device = OrderDevice.objects.get(pk=device_id) + device.should_report = not device.should_report + device.save() + + if device.should_report: + return HttpResponse('<i class="icon-ok"></i>') + + return HttpResponse('<i class="icon-ok icon-white"></i>') + + +@permission_required('servo.change_order') +def remove_product(request, pk, item_id): + order = Order.objects.get(pk=pk) + + # The following is to help those who hit Back after removing a product + try: + item = ServiceOrderItem.objects.get(pk=item_id) + except ServiceOrderItem.DoesNotExist: + messages.error(request, _("Order item does not exist")) + return redirect(order) + + if request.method == 'POST': + msg = order.remove_product(item, request.user) + messages.info(request, msg) + return redirect(order) + + return render(request, 'orders/remove_product.html', locals()) + + +@permission_required('servo.change_order') +def products(request, pk, item_id=None, action='list'): + order = Order.objects.get(pk=pk) + if action == 'list': + return render(request, 'orders/products.html', {'order': order}) + + +@permission_required('servo.change_order') +def list_products(request, pk): + order = Order.objects.get(pk=pk) + return render(request, "orders/list_products.html", locals()) + + +def parts(request, order_id, device_id, queue_id): + """ + Selects parts for this device in this order + """ + order = Order.objects.get(pk=order_id) + device = Device.objects.get(pk=device_id) + title = device.description + url = reverse('devices-parts', args=[device_id, order_id, queue_id]) + + if order.queue is not None: + request.session['current_queue'] = order.queue.pk + + return render(request, "orders/parts.html", locals()) + + +@permission_required("servo.change_order") +def select_customer(request, pk, customer_id): + """ + Selects a specific customer for this order + """ + order = Order.objects.get(pk=pk) + order.customer_id = customer_id + order.save() + + return redirect(order) + + +@permission_required("servo.change_order") +def choose_customer(request, pk): + """ + Lets the user search for a customer for this order + """ + if request.method == "POST": + customers = Customer.objects.none() + kind = request.POST.get('kind') + query = request.POST.get('name') + + if len(query) > 2: + customers = Customer.objects.filter( + Q(fullname__icontains=query) + | Q(email__icontains=query) + | Q(phone__contains=query) + ) + + if kind == 'companies': + customers = customers.filter(is_company=True) + + if kind == 'contacts': + customers = customers.filter(is_company=False) + + data = {'customers': customers, 'order_id': pk} + return render(request, "customers/choose-list.html", data) + + data = {'action': request.path} + return render(request, 'customers/choose.html', data) + + +@permission_required("servo.change_order") +def remove_customer(request, pk, customer_id): + if request.method == "POST": + order = Order.objects.get(pk=pk) + customer = Customer.objects.get(pk=customer_id) + order.customer = None + order.save() + msg = _(u"Customer %s removed") % customer.name + order.notify("customer_removed", msg, request.user) + messages.success(request, msg) + return redirect(order) + + data = {'action': request.path} + return render(request, "orders/remove_customer.html", data) + + +def search(request): + query = request.GET.get("q") + + if not query or len(query) < 3: + messages.error(request, _('Search query is too short')) + return redirect(list_orders) + + request.session['search_query'] = query + + # Redirect Order ID:s to the order + try: + order = Order.objects.get(code__iexact=query) + return redirect(order) + except Order.DoesNotExist: + pass + + orders = Order.objects.filter( + Q(code=query) | Q(devices__sn__contains=query) | + Q(customer__fullname__icontains=query) | + Q(customer__phone__contains=query) | + Q(repair__confirmation=query) | + Q(repair__reference=query) + ) + + data = {'title': _(u'Search results for "%s"') % query} + data['orders'] = orders.distinct() + + return render(request, "orders/index.html", data) + + +@permission_required("servo.add_order") +def copy_order(request, pk): + order = Order.objects.get(pk=pk) + new_order = order.duplicate(request.user) + return redirect(new_order) + + +def history(request, pk, device): + device = get_object_or_404(Device, pk=device) + orders = device.order_set.exclude(pk=pk) + return render(request, "orders/history.html", locals()) + + +@permission_required("servo.batch_process") +def batch_process(request): + form = BatchProcessForm() + title = _('Batch Processing') + + if request.method == 'POST': + form = BatchProcessForm(request.POST) + if form.is_valid(): + from servo.tasks import batch_process + batch_process.delay(request.user, form.cleaned_data) + messages.success(request, _('Request accepted for batch processing')) + + return render(request, "orders/batch_process.html", locals()) + + +def download_results(request): + import csv + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = 'attachment; filename="orders.csv"' + + writer = csv.writer(response) + header = [ + 'CODE', + 'CUSTOMER', + 'CREATED_AT', + 'ASSIGNED_TO', + 'CHECKED_IN', + 'LOCATION' + ] + writer.writerow(header) + + for o in request.session['order_queryset']: + row = [o.code, o.customer, o.created_at, + o.user, o.checkin_location, o.location] + coded = [unicode(s).encode('utf-8') for s in row] + + writer.writerow(coded) + + return response diff --git a/servo/views/product.py b/servo/views/product.py new file mode 100644 index 0000000..3dd2202 --- /dev/null +++ b/servo/views/product.py @@ -0,0 +1,474 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import json +from decimal import * + +from django.db.models import Q +from django.db import IntegrityError + +from django.contrib import messages +from django.core.cache import cache +from django.http import HttpResponse +from django.shortcuts import render, redirect +from django.utils.translation import ugettext as _ +from django.forms.models import inlineformset_factory +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.decorators import permission_required + +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from servo.models import (Attachment, TaggedItem, + Product, ProductCategory, + Inventory, Location, inventory_totals, + GsxAccount) +from servo.forms.product import ProductForm, CategoryForm, ProductSearchForm + + +def prep_list_view(request, group='all'): + """ + Prepares the product list view + """ + title = _("Products") + all_products = Product.objects.all() + categories = ProductCategory.objects.all() + + if group == 'all': + group = ProductCategory(title=_('All'), slug='all') + else: + group = categories.get(slug=group) + all_products = group.get_products() + + if request.method == 'POST': + form = ProductSearchForm(request.POST) + if form.is_valid(): + fdata = form.cleaned_data + + description = fdata.get('description') + if description: + all_products = all_products.filter(description__icontains=description) + + title = fdata.get('title') + if title: + all_products = all_products.filter(title__icontains=title) + + code = fdata.get('code') + if code: + all_products = all_products.filter(code__icontains=code) + + tag = fdata.get('tag') + if tag: + tag = tag.tag + title += u" / %s" % tag + all_products = all_products.filter(tags__tag=tag) + else: + form = ProductSearchForm() + + title += u" / %s" % group.title + page = request.GET.get("page") + paginator = Paginator(all_products.distinct(), 25) + + try: + products = paginator.page(page) + except PageNotAnInteger: + products = paginator.page(1) + except EmptyPage: + products = paginator.page(paginator.num_pages) + + return locals() + + +def tags(request): + """ + Returns all product tags + """ + tags = TaggedItem.objects.filter(content_type__model="product") + tags = tags.distinct("tag").values_list("tag", flat=True) + return HttpResponse(json.dumps(list(tags)), content_type='application/json') + + +def list_products(request, group='all'): + data = prep_list_view(request, group) + p, s = inventory_totals() + data['total_sales_value'] = s + data['total_purchase_value'] = p + + return render(request, "products/index.html", data) + + +@permission_required("servo.change_product") +def upload_gsx_parts(request, group=None): + from servo.forms.product import PartsImportForm + form = PartsImportForm() + + data = {'action': request.path} + + if request.method == "POST": + + form = PartsImportForm(request.POST, request.FILES) + + if form.is_valid(): + data = form.cleaned_data + filename = "servo/uploads/products/partsdb.csv" + destination = open(filename, "wb+") + + for chunk in data['partsdb'].chunks(): + destination.write(chunk) + + messages.success(request, _("Parts database uploaded for processing")) + return redirect(list_products) + + data['form'] = form + return render(request, "products/upload_gsx_parts.html", data) + + +@permission_required("servo.change_product") +def download_products(request, group="all"): + filename = "products" + + if group == "all": + products = Product.objects.all() + else: + category = ProductCategory.objects.get(slug=group) + products = category.get_products() + filename = group + + response = HttpResponse(content_type="text/plain; charset=utf-8") + response['Content-Disposition'] = 'attachment; filename="%s.txt"' % filename + + response.write(u"ID\tCODE\tTITLE\tPURCHASE_PRICE\tSALES_PRICE\tSTOCKED\n") + + for p in products: + row = u"%s\t%s\t%s\t%s\t%s\t%s\n" % (p.pk, + p.code, + p.title, + p.price_purchase_stock, + p.price_sales_stock, 0) + response.write(row) + + return response + + +@permission_required("servo.change_product") +def upload_products(request, group=None): + """" + Format should be the same as from download_products + """ + import io + from servo.forms import ProductUploadForm + location = request.user.get_location() + form = ProductUploadForm() + + if request.method == "POST": + form = ProductUploadForm(request.POST, request.FILES) + + if form.is_valid(): + string = u'' + category = form.cleaned_data['category'] + data = form.cleaned_data['datafile'].read() + + for i in ('utf-8', 'latin-1',): + try: + string = data.decode(i) + except: + pass + + if not string: + raise ValueError(_('Unsupported file encoding')) + + i = 0 + sio = io.StringIO(string, newline=None) + + for l in sio.readlines(): + cols = l.strip().split("\t") + + if cols[0] == "ID": + continue # Skip header row + + if len(cols) < 2: + continue # Skip empty rows + + if len(cols) < 6: # No ID row, pad it + cols.insert(0, "") + + product, created = Product.objects.get_or_create(code=cols[1]) + + product.title = cols[2].strip(' "').replace('""', '"') # Remove Excel escapes + product.price_purchase_stock = cols[3].replace(',', '.') + product.price_sales_stock = cols[4].replace(',', '.') + product.save() + + if category: + product.categories.add(category) + + inventory, created = Inventory.objects.get_or_create( + product=product, location=location + ) + inventory.amount_stocked = cols[5] + inventory.save() + i += 1 + + messages.success(request, _(u"%d products imported") % i) + + return redirect(list_products) + + action = request.path + title = _("Upload products") + return render(request, "products/upload_products.html", locals()) + + +@permission_required("servo.change_product") +def edit_product(request, pk=None, code=None, group='all'): + + initial = {} + product = Product() + + data = prep_list_view(request, group) + + if pk is not None: + product = Product.objects.get(pk=pk) + form = ProductForm(instance=product) + + if not group == 'all': + cat = ProductCategory.objects.get(slug=group) + initial = {'categories': [cat]} + data['group'] = cat + + product.update_photo() + + if code is not None: + product = cache.get(code) + + form = ProductForm(instance=product, initial=initial) + InventoryFormset = inlineformset_factory( + Product, + Inventory, + extra=1, + max_num=1, + exclude=[] + ) + + formset = InventoryFormset( + instance=product, + initial=[{'location': request.user.location}] + ) + + if request.method == "POST": + + form = ProductForm(request.POST, request.FILES, instance=product) + + if form.is_valid(): + + product = form.save() + content_type = ContentType.objects.get(model="product") + + for a in request.POST.getlist("attachments"): + doc = Attachment.objects.get(pk=a) + product.attachments.add(doc) + + tags = [x for x in request.POST.getlist('tag') if x != ''] + + for t in tags: + tag, created = TaggedItem.objects.get_or_create( + content_type=content_type, + object_id=product.pk, + tag=t) + tag.save() + + formset = InventoryFormset(request.POST, instance=product) + + if formset.is_valid(): + formset.save() + messages.success(request, _(u"Product %s saved") % product.code) + return redirect(product) + else: + messages.error(request, _('Error in inventory details')) + else: + messages.error(request, _('Error in product info')) + + data['form'] = form + data['product'] = product + data['formset'] = formset + data['title'] = product.title + + return render(request, "products/form.html", data) + + +@permission_required("servo.delete_product") +def delete_product(request, pk, group): + from django.db.models import ProtectedError + + product = Product.objects.get(pk=pk) + + if request.method == 'POST': + try: + product.delete() + Inventory.objects.filter(product=product).delete() + messages.success(request, _("Product deleted")) + except ProtectedError: + messages.error(request, _('Cannot delete product')) + + return redirect(list_products, group) + + action = request.path + return render(request, 'products/remove.html', locals()) + + +def search(request): + + query = request.GET.get("q") + request.session['search_query'] = query + + results = Product.objects.filter( + Q(code__icontains=query) | Q(title__icontains=query) | Q(eee_code__icontains=query) + ) + + paginator = Paginator(results, 100) + page = request.GET.get("page") + + try: + products = paginator.page(page) + except PageNotAnInteger: + products = paginator.page(1) + except EmptyPage: + products = paginator.page(paginator.num_pages) + + title = _(u'Search results for "%s"') % query + group = ProductCategory(title=_('All'), slug='all') + + return render(request, 'products/search.html', locals()) + + +def view_product(request, pk=None, code=None, group=None): + + product = Product() + inventory = Inventory.objects.none() + + try: + product = Product.objects.get(pk=pk) + inventory = Inventory.objects.filter(product=product) + except Product.DoesNotExist: + product = cache.get(code) + + data = prep_list_view(request, group) + + data['product'] = product + data['title'] = product.title + data['inventory'] = inventory + + return render(request, "products/view.html", data) + + +@permission_required("servo.change_productcategory") +def edit_category(request, slug=None, parent_slug=None): + + form = CategoryForm() + category = ProductCategory() + + if slug is not None: + category = ProductCategory.objects.get(slug=slug) + form = CategoryForm(instance=category) + + if parent_slug is not None: + parent = ProductCategory.objects.get(slug=parent_slug) + form = CategoryForm(initial={'parent': parent.pk}) + + if request.method == "POST": + form = CategoryForm(request.POST, instance=category) + if form.is_valid(): + try: + category = form.save() + except IntegrityError: + messages.error(request, _(u'Category %s already exists') % category.title) + return redirect(list_products) + messages.success(request, _(u"Category %s saved") % category.title) + return redirect(category) + else: + messages.error(request, form.errors) + return redirect(list_products) + + return render(request, "products/category_form.html", locals()) + + +@permission_required("servo.delete_productcategory") +def delete_category(request, slug): + + category = ProductCategory.objects.get(slug=slug) + + if request.method == "POST": + category.delete() + messages.success(request, _("Category deleted")) + + return redirect(list_products) + + data = {'category': category} + data['action'] = request.path + return render(request, 'products/delete_category.html', data) + + +@permission_required("servo.change_order") +def choose_product(request, order_id, product_id=None, target_url="orders-add_product"): + """ + order_id can be either Service Order or Purchase Order + """ + data = {'order': order_id} + data['action'] = request.path + data['target_url'] = target_url + + if request.method == "POST": + query = request.POST.get('q') + + if len(query) > 2: + products = Product.objects.filter( + Q(code__icontains=query) | Q(title__icontains=query) + ) + data['products'] = products + + return render(request, 'products/choose-list.html', data) + + return render(request, 'products/choose.html', data) + + +def get_info(request, location, code): + try: + product = Product.objects.get(code=code) + inventory = Inventory.objects.filter(product=product) + except Product.DoesNotExist: + product = cache.get(code) + + return render(request, 'products/get_info.html', locals()) + + +def update_price(request, pk): + product = Product.objects.get(pk=pk) + try: + GsxAccount.default(request.user) + product.update_price() + messages.success(request, _('Price info updated from GSX')) + except Exception, e: + messages.error(request, _('Failed to update price from GSX')) + + return redirect(product) diff --git a/servo/views/purchases.py b/servo/views/purchases.py new file mode 100644 index 0000000..66e7075 --- /dev/null +++ b/servo/views/purchases.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import gsxws + +from django.forms.models import modelform_factory +from django.forms.models import inlineformset_factory + +from django.utils.translation import ugettext as _ + +from django.shortcuts import render, redirect +from servo.models.order import ServiceOrderItem +from django.contrib.auth.decorators import permission_required +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from django.contrib import messages + +from servo.models import Product, GsxAccount, PurchaseOrder, PurchaseOrderItem +from servo.forms import PurchaseOrderItemEditForm, PurchaseOrderSearchForm + + +@permission_required("servo.change_purchaseorder") +def list_pos(request): + from datetime import timedelta + from django.utils import timezone + from django.db.models import Sum + + now = timezone.now() + data = {'title': _("Purchase Orders")} + + initial = {'start_date': now - timedelta(days=30), 'end_date': now} + all_orders = PurchaseOrder.objects.filter( + created_at__range=(initial['start_date'], initial['end_date']) + ) + + form = PurchaseOrderSearchForm(initial=initial) + + if request.method == 'POST': + all_orders = PurchaseOrder.objects.all() + form = PurchaseOrderSearchForm(request.POST, initial=initial) + + if form.is_valid(): + fdata = form.cleaned_data + reference = fdata.get('reference') + if reference: + all_orders = all_orders.filter(reference__contains=reference) + if fdata.get('state') == 'open': + all_orders = all_orders.filter(submitted_at=None) + if fdata.get('state') == 'submitted': + all_orders = all_orders.exclude(submitted_at=None) + if fdata.get('state') == 'received': + all_orders = all_orders.exclude(has_arrived=True) + s, e = (fdata.get('start_date'), fdata.get('end_date')) + if s and e: + all_orders = all_orders.filter(created_at__range=(s, e)) + created_by = fdata.get('created_by') + if created_by: + all_orders = all_orders.filter(created_by=created_by) + + page = request.GET.get("page") + paginator = Paginator(all_orders, 50) + + try: + orders = paginator.page(page) + except PageNotAnInteger: + orders = paginator.page(1) + except EmptyPage: + orders = paginator.page(paginator.num_pages) + + data['orders'] = orders + data['form'] = form + data['total'] = all_orders.aggregate(Sum('total')) + return render(request, "purchases/list_pos.html", data) + + +@permission_required("servo.change_purchaseorder") +def delete_from_po(request, pk, item_id): + # @TODO - decrement amount_ordered? + po = PurchaseOrder.objects.get(pk=pk) + poi = PurchaseOrderItem.objects.get(pk=item_id) + poi.delete() + messages.success(request, _(u'Product %s removed' % poi.product.code)) + return redirect(po) + + +@permission_required("servo.change_purchaseorder") +def add_to_po(request, pk, product_id): + po = PurchaseOrder.objects.get(pk=pk) + product = Product.objects.get(pk=product_id) + po.add_product(product, 1, request.user) + messages.success(request, _(u"Product %s added" % product.code)) + return redirect(edit_po, po.pk) + + +def view_po(request, pk): + po = PurchaseOrder.objects.get(pk=pk) + title = _('Purchase Order %d' % po.pk) + return render(request, "purchases/view_po.html", locals()) + + +@permission_required("servo.change_purchaseorder") +def edit_po(request, pk, item_id=None): + + if pk is not None: + po = PurchaseOrder.objects.get(pk=pk) + else: + po = PurchaseOrder(created_by=request.user) + + PurchaseOrderForm = modelform_factory(PurchaseOrder, exclude=[]) + form = PurchaseOrderForm(instance=po) + + ItemFormset = inlineformset_factory( + PurchaseOrder, + PurchaseOrderItem, + extra=0, + form=PurchaseOrderItemEditForm, + exclude=[] + ) + + formset = ItemFormset(instance=po) + + if request.method == "POST": + + form = PurchaseOrderForm(request.POST, instance=po) + + if form.is_valid(): + + po = form.save() + formset = ItemFormset(request.POST, instance=po) + + if formset.is_valid(): + + formset.save() + msg = _("Purchase Order %d saved" % po.pk) + + if "confirm" in request.POST.keys(): + po.submit(request.user) + msg = _("Purchase Order %d submitted") % po.pk + + messages.success(request, msg) + return redirect(list_pos) + + request.session['current_po'] = po + data = {'order': po, 'form': form} + data['formset'] = formset + data['title'] = _('Purchase Order #%d' % po.pk) + + return render(request, "purchases/edit_po.html", data) + + +@permission_required("servo.change_purchaseorder") +def order_stock(request, po_id): + """ + Submits the PO as a GSX Stocking Order + Using the default GSX account. + """ + po = PurchaseOrder.objects.get(pk=po_id) + + if request.method == "POST": + if po.submitted_at: + messages.error(request, _(u'Purchase Order %s has already been submitted') % po.pk) + return list_pos(request) + + act = GsxAccount.default(request.user) + + stock_order = gsxws.StockingOrder( + shipToCode=act.ship_to, + purchaseOrderNumber=po.id + ) + + for i in po.purchaseorderitem_set.all(): + stock_order.add_part(i.code, i.amount) + + try: + result = stock_order.submit() + po.supplier = "Apple" + po.confirmation = result.confirmationNumber + po.submit(request.user) + msg = _("Products ordered with confirmation %s" % po.confirmation) + messages.success(request, msg) + except gsxws.GsxError, e: + messages.error(request, e) + + return redirect(list_pos) + + data = {'action': request.path} + return render(request, "purchases/order_stock.html", data) + + +@permission_required('servo.delete_purchaseorder') +def delete_po(request, po_id): + po = PurchaseOrder.objects.get(pk=po_id) + try: + po.delete() + messages.success(request, _("Purchase Order %s deleted" % po_id)) + except Exception, e: + messages.error(request, e) + return redirect(list_pos) + + +@permission_required('servo.add_purchaseorder') +def create_po(request, product_id=None, order_id=None): + po = PurchaseOrder(created_by=request.user) + location = request.user.get_location() + po.location = location + po.save() + + if order_id is not None: + po.sales_order_id = order_id + for i in ServiceOrderItem.objects.filter(order_id=order_id): + po.add_product(i, amount=1, user=request.user) + + if product_id is not None: + product = Product.objects.get(pk=product_id) + po.add_product(product, amount=1, user=request.user) + + messages.success(request, _("Purchase Order %d created" % po.pk)) + + return redirect(edit_po, po.pk) diff --git a/servo/views/queue.py b/servo/views/queue.py new file mode 100644 index 0000000..e7e0914 --- /dev/null +++ b/servo/views/queue.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +from django import forms +from servo.models import Queue +from django.http import HttpResponse + + +def statuses(request, queue_id): + """Lists available statuses for this queue""" + queue = Queue.objects.get(pk=queue_id) + + class StatusForm(forms.Form): + status = forms.ModelChoiceField(queryset=queue.queuestatus_set.all()) + + form = StatusForm() + return HttpResponse(str(form['status'])) diff --git a/servo/views/rules.py b/servo/views/rules.py new file mode 100644 index 0000000..ec193a8 --- /dev/null +++ b/servo/views/rules.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +from django.conf import settings +from django.contrib import messages +from django.utils.translation import ugettext as _ +from django.shortcuts import render, redirect, get_object_or_404 + +from servo.models.rules import * + + +def get_data(request): + pass + + +def list_rules(request): + title = _('Rules') + object_list = Rule.objects.all() + return render(request, "rules/list_rules.html", locals()) + + +def edit_rule(request, pk=None): + title = _('Rules') + object_list = Rule.objects.all() + + if pk: + rule = get_object_or_404(Rule, pk=pk) + + if request.method == 'POST': + if pk: + rule = Rule.objects.get(pk=pk) + else: + rule = Rule() + + rule.description = request.POST.get('description') + #rule.match = request.POST.get('description') + rule.save() + + rule.condition_set.all().delete() + rule.action_set.all().delete() + + keys = request.POST.getlist('condition-key') + values = request.POST.getlist('condition-value') + + for k, v in enumerate(keys): + cond = Condition(rule=rule) + cond.key = v + cond.value = values[k] + cond.save() + + keys = request.POST.getlist('action-key') + values = request.POST.getlist('action-value') + + for k, v in enumerate(keys): + action = Action(rule=rule) + action.key = v + action.value = values[k] + action.save() + + + return render(request, "rules/form.html", locals()) + + +def view_rule(request, pk): + pass + + +def delete_rule(request, pk): + action = request.path + title = _('Delete rule') + rule = get_object_or_404(Rule, pk=pk) + + if request.method == 'POST': + rule.delete() + messages.error(request, _('Rule deleted')) + return redirect(list_rules) + + return render(request, "generic/delete.html", locals()) diff --git a/servo/views/search.py b/servo/views/search.py new file mode 100644 index 0000000..c61eca6 --- /dev/null +++ b/servo/views/search.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import re +import gsxws + +from django.db.models import Q +from django.core.cache import cache +from django.shortcuts import render, redirect +from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse + +from django.http import QueryDict, HttpResponseRedirect + +from servo.models.note import Note +from servo.models.wiki import Article +from servo.models.device import Device +from servo.models.product import Product +from servo.models.common import GsxAccount +from servo.models.purchases import PurchaseOrder +from servo.models.order import Order, ServiceOrderItem + + +def results_redirect(view, data): + q = QueryDict('', mutable=True) + q['q'] = data['query'] + query_str = q.urlencode() + url = reverse(view, args=[data['what']]) + return HttpResponseRedirect("%s?%s" % (url, query_str)) + + +def prepare_result_view(request): + + query = request.GET.get('q') + + data = {'title': _('Search results for "%s"' % query)} + + data['gsx_type'] = gsxws.validate(query.upper()) + data['query'] = query + data['tag_id'] = None + data['cat_id'] = None # Product category + data['group'] = 'all' # customer group + + return data, query + + +def list_gsx(request, what="warranty"): + data, query = prepare_result_view(request) + data['what'] = what + return render(request, "search/results/gsx.html", data) + + +def search_gsx(request, what, arg, value): + if request.is_ajax(): + + if what == "parts" and value != "None": + results = [] + GsxAccount.default(user=request.user) + + try: + product = gsxws.Product(productName=value) + parts = product.parts() + for p in parts: + results.append(Product.from_gsx(p)) + except gsxws.GsxError, e: + data = {'message': e} + return render(request, "search/results/gsx_error.html", data) + + data = {'results': results} + + return render(request, "search/results/gsx_%s.html" % what, data) + + data = {arg: value} + return render(request, "search/gsx_results.html", data) + + +def view_gsx_results(request, what="warranty"): + """ + Searches for something from GSX. Defaults to warranty lookup. + GSX search strings are always UPPERCASE. + """ + results = list() + data, query = prepare_result_view(request) + query = query.upper() + + error_template = "search/results/gsx_error.html" + + if data['gsx_type'] == "dispatchId": + what = "repairs" + + if data['gsx_type'] == "partNumber": + what = "parts" + + data['what'] = what + gsx_type = data['gsx_type'] + + try: + if request.session.get("current_queue"): + queue = request.session['current_queue'] + GsxAccount.default(request.user, queue) + else: + GsxAccount.default(request.user) + except gsxws.GsxError, e: + error = {'message': e} + return render(request, error_template, error) + + if gsx_type == "serialNumber" or "alternateDeviceId": + try: + device = Device.objects.get(sn=query) + except Device.DoesNotExist: + device = Device(sn=query) + + if what == "warranty": + if cache.get(query): + result = cache.get(query) + else: + try: + result = Device.from_gsx(query) + except gsxws.GsxError, e: + error = {'message': e} + return render(request, error_template, error) + + if re.match(r'iPhone', result.description): + result.activation = device.get_activation() + + results.append(result) + + if what == "parts": + # looking for parts + if gsx_type == "partNumber": + # ... with a part number + part = gsxws.Part(partNumber=query) + + try: + partinfo = part.lookup() + except gsxws.GsxError, e: + error = {'message': e} + return render(request, error_template, error) + + product = Product.from_gsx(partinfo) + cache.set(query, product) + results.append(product) + else: + # ... with a serial number + try: + results = device.get_parts() + data['device'] = device + except Exception, e: + error = {'message': e} + return render(request, error_template, error) + + if what == "repairs": + # Looking for GSX repairs + if gsx_type == "serialNumber": + # ... with a serial number + try: + device = gsxws.Product(query) + results = device.repairs() + except gsxws.GsxError, e: + return render(request, "search/results/gsx_notfound.html") + + elif gsx_type == "dispatchId": + # ... with a repair confirmation number + repair = gsxws.Repair(number=query) + try: + results = repair.lookup() + except gsxws.GsxError, e: + error = {'message': e} + return render(request, error_template, error) + + if what == "repair_details": + repair = gsxws.Repair(number=query) + results = repair.details() + return render(request, "search/results/gsx_repair_details.html", results) + + # Cache the results for quicker access later + cache.set('%s-%s' % (what, query), results) + data['results'] = results + + return render(request, "search/results/gsx_%s.html" % what, data) + + +def list_products(request): + data, query = prepare_result_view(request) + data['products'] = Product.objects.filter( + Q(code__icontains=query) | Q(title__icontains=query) + ) + + return render(request, "search/results/products.html", data) + + +def list_notes(request): + data, query = prepare_result_view(request) + data['notes'] = Note.objects.filter(body__icontains=query) + return render(request, "search/results/notes.html", data) + + +def spotlight(request): + """ + Searches for anything and redirects to the "closest" result view. + GSX searches are done separately. + """ + data, query = prepare_result_view(request) + data['what'] = "warranty" + + if Order.objects.filter(customer__name__icontains=query).exists(): + return list_orders(request) + + if data['gsx_type'] == "serialNumber": + try: + device = Device.objects.get(sn=query) + return redirect(device) + except Device.DoesNotExist: + return results_redirect("search-gsx", data) + + data['parts'] = ServiceOrderItem.objects.filter(sn__icontains=query) + + if gsxws.validate(query, "dispatchId"): + try: + po = PurchaseOrder.objects.get(confirmation=query) + data['orders'] = [po.sales_order] + except PurchaseOrder.DoesNotExist: + pass + + data['products'] = Product.objects.filter( + Q(code__icontains=query) | Q(title__icontains=query) + ) + + data['articles'] = Article.objects.filter(content__contains=query) + + return render(request, "search/spotlight.html", data) diff --git a/servo/views/shipments.py b/servo/views/shipments.py new file mode 100644 index 0000000..9b31e93 --- /dev/null +++ b/servo/views/shipments.py @@ -0,0 +1,392 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import gsxws +from decimal import * + +from django.utils import timezone +from django.contrib import messages +from django.http import HttpResponse +from django.shortcuts import render, redirect +from django.utils.translation import ugettext as _ +from django.forms.models import inlineformset_factory +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from servo.models import GsxAccount, ServicePart, Shipment, PurchaseOrderItem +from servo.forms.product import PurchaseOrderItemForm, IncomingSearchForm +from servo.forms.returns import * + + +def prep_counts(): + incoming = PurchaseOrderItem.objects.filter(received_at=None) + incoming = incoming.exclude(purchase_order__submitted_at=None).count() + pending_return = '' + returns = Shipment.objects.exclude(dispatched_at=None).count() + return locals() + + +def prep_list_view(request): + + from datetime import timedelta + now = timezone.now() + + data = {'can_receive': True} + data['counts'] = prep_counts() + location = request.user.get_location() + + ordered_date_range = [now - timedelta(days=30), timezone.now()] + received_date_range = [now - timedelta(days=30), timezone.now()] + + initial = { + 'location': location, + 'ordered_start_date': ordered_date_range[0], + 'ordered_end_date': ordered_date_range[1], + } + + if request.method == 'POST': + data['form'] = IncomingSearchForm(request.POST, initial=initial) + else: + data['form'] = IncomingSearchForm(initial=initial) + + inventory = PurchaseOrderItem.objects.filter(received_at=None) + inventory = inventory.exclude(purchase_order__submitted_at=None) + + if request.method == 'POST': + fdata = request.POST + + loc = fdata.get('location') + if loc: + inventory = PurchaseOrderItem.objects.filter(purchase_order__location=loc) + + ordered_sd = fdata.get('ordered_start_date') + if ordered_sd: + ordered_date_range[0] = ordered_sd + inventory = inventory.filter(purchase_order__submitted_at__range=ordered_date_range) + + received_sd = fdata.get('received_start_date') + if received_sd: + received_date_range[0] = received_sd + inventory = inventory.filter(received_at__range=received_date_range) + + conf = fdata.get('confirmation') + if conf: + inventory = PurchaseOrderItem.objects.filter(purchase_order__confirmation=conf) + + service_order = fdata.get('service_order') + if service_order: + inventory = PurchaseOrderItem.objects.filter(purchase_order__sales_order__code=service_order) + + page = request.GET.get("page") + data['count'] = inventory.count() + inventory = inventory.order_by('-id') + + paginator = Paginator(inventory, 200) + data['title'] = _(u"%d incoming products") % data['count'] + + try: + inventory = paginator.page(page) + except PageNotAnInteger: + inventory = paginator.page(1) + except EmptyPage: + inventory = paginator.page(paginator.num_pages) + + data['inventory'] = inventory + + return data + + +def list_incoming(request, shipment=None, status=""): + """ + Lists purchase order items that have not arrived yet + """ + data = prep_list_view(request) + + if request.POST.getlist("id"): + count = len(request.POST.getlist("id")) + for i in request.POST.getlist("id"): + item = PurchaseOrderItem.objects.get(pk=i) + try: + item.receive(request.user) + except ValueError, e: + messages.error(request, e) + return redirect(list_incoming) + + messages.success(request, _("%d products received") % count) + + return redirect(list_incoming) + + return render(request, "shipments/list_incoming.html", data) + + +def view_incoming(request, pk): + """ + Shows an incoming part + """ + next = False + item = PurchaseOrderItem.objects.get(pk=pk) + + data = prep_list_view(request) + + data['next'] = "" + data['subtitle'] = item.code + + try: + next = item.get_next_by_created_at(received_at=None) + data['next'] = next.pk + except PurchaseOrderItem.DoesNotExist: + pass # That was the last of them... + + if request.method == "POST": + + item.received_by = request.user + item.received_at = timezone.now() + + form = PurchaseOrderItemForm(request.POST, instance=item) + + if form.is_valid(): + try: + item = form.save() + except gsxws.GsxError, e: + messages.error(request, e) + return redirect(view_incoming, date, pk) + + messages.success(request, _(u"Product %s received") % item.code) + + if next: + return redirect(view_incoming, next.pk) + else: + return redirect(list_incoming) + else: + form = PurchaseOrderItemForm(instance=item) + + data['form'] = form + data['item'] = item + data['url'] = request.path + + return render(request, "products/receive_item.html", data) + + +def list_returns(request, shipment=None, date=None): + return render(request, "shipments/list_returns.html", locals()) + + +def return_label(request, code, return_order): + + GsxAccount.default(request.user) + + try: + label = gsxws.Returns(return_order) + return HttpResponse(label.returnLabelFileData, content_type="application/pdf") + except Exception, e: + messages.add_message(request, messages.ERROR, e) + return redirect('products-list') + + +def list_bulk_returns(request): + from django.db.models import Count + title = _("Browse Bulk Returns") + returns = Shipment.objects.exclude(dispatched_at=None).annotate(num_parts=Count('servicepart')) + + page = request.GET.get("page") + paginator = Paginator(returns, 50) + + try: + returns = paginator.page(page) + except PageNotAnInteger: + returns = paginator.page(1) + except EmptyPage: + returns = paginator.page(paginator.num_pages) + + counts = prep_counts() + return render(request, "shipments/list_bulk_returns.html", locals()) + + +def view_packing_list(request, pk): + shipment = Shipment.objects.get(pk=pk) + pdf = shipment.packing_list.read() + return HttpResponse(pdf, content_type="application/pdf") + + +def view_bulk_return(request, pk): + title = _("View bulk return") + shipment = Shipment.objects.get(pk=pk) + return render(request, "shipments/view_bulk_return.html", locals()) + + +def edit_bulk_return(request, pk=None, ship_to=None): + """ + Edits the bulk return shipment before it's submitted + """ + location = request.user.get_location() + accounts = location.get_shipto_choices() + + if len(accounts) < 1: + messages.error(request, _(u'Location %s has no Ship-To') % location.title) + return redirect('products-list_products') + + if not ship_to: + ship_to = accounts[0][0] + return redirect(edit_bulk_return, ship_to=ship_to) + + shipment = Shipment.get_current(request.user, location, ship_to) + + part_count = shipment.servicepart_set.all().count() + PartFormSet = inlineformset_factory(Shipment, + ServicePart, + form=BulkReturnPartForm, + extra=0, + exclude=[]) + form = BulkReturnForm(instance=shipment) + formset = PartFormSet(instance=shipment) + + if request.method == "POST": + form = BulkReturnForm(request.POST, instance=shipment) + if form.is_valid(): + formset = PartFormSet(request.POST, instance=shipment) + if formset.is_valid(): + shipment = form.save() + msg = _("Bulk return saved") + formset.save() + if "confirm" in request.POST.keys(): + try: + shipment.register_bulk_return(request.user) + msg = _(u"Bulk return %s submitted") % shipment.return_id + messages.success(request, msg) + return redirect(view_bulk_return, shipment.pk) + except Exception, e: + messages.error(request, e) + return redirect(edit_bulk_return, ship_to=ship_to) + messages.success(request, msg) + return redirect(edit_bulk_return, ship_to=ship_to) + else: + messages.error(request, formset.errors) + else: + messages.error(request, form.errors) + + counts = prep_counts() + counts['pending_return'] = len(formset) + title = _(u"%d parts pending return") % part_count + return render(request, "shipments/edit_bulk_return.html", locals()) + + +def remove_from_return(request, pk, part_pk): + """ + Removes a part from a bulk return + """ + shipment = Shipment.objects.get(pk=pk) + part = ServicePart.objects.get(pk=part_pk) + + try: + shipment.toggle_part(part) + messages.success(request, _(u"Part %s removed from bulk return") % part.part_number) + except Exception, e: + messages.error(request, e) + + return redirect(edit_bulk_return) + + +def add_to_return(request, pk, part=None): + """ + Adds a part to a bulk return + """ + data = {'action': request.path} + + if pk and part: + shipment = Shipment.objects.get(pk=pk) + part = ServicePart.objects.get(pk=part) + shipment.servicepart_set.add(part) + messages.success(request, _(u"Part %s added to return") % part.part_number) + + return redirect(edit_bulk_return) + + if request.method == "POST": + query = request.POST.get('q') + results = ServicePart.objects.filter(return_order=query) + data = {'shipment': pk, 'results': results} + + return render(request, "shipments/add_to_return-results.html", data) + + return render(request, "shipments/add_to_return.html", data) + + +def update_part(request, part, return_type): + """ + Update part status to GSX + """ + return_type = int(return_type) + part = ServicePart.objects.get(pk=part) + + msg = "" + form = "" + title = "" + + if return_type == Shipment.RETURN_DOA: + title = _("Return DOA Part") + form = DoaPartReturnForm(part=part) + + if return_type == Shipment.RETURN_GPR: + title = _("Return Good Part") + form = GoodPartReturnForm() + + if return_type == Shipment.RETURN_CTS: + title = _("Convert to Stock") + msg = _("This part will be converted to regular inventory") + form = ConvertToStockForm(initial={'partNumber': part.part_number}) + + if request.method == "POST": + + if return_type == Shipment.RETURN_DOA: + form = DoaPartReturnForm(part=part, data=request.POST) + if return_type == Shipment.RETURN_GPR: + form = GoodPartReturnForm(request.POST) + if return_type == Shipment.RETURN_CTS: + form = ConvertToStockForm(request.POST) + + if form.is_valid(): + try: + part.update_part(form.cleaned_data, return_type, request.user) + messages.success(request, _("Part updated")) + except Exception, e: + messages.error(request, e) + else: + messages.error(request, form.errors) + + return redirect(part.order_item.order) + + action = request.path + return render(request, "shipments/update_part.html", locals()) + + +def parts_pending_return(request, ship_to): + """ + Returns the part pending return for this GSX Account + """ + pass + + +def verify(request, pk): + shipment = Shipment.objects.get(pk=pk) + return redirect(shipment) diff --git a/servo/views/stats.py b/servo/views/stats.py new file mode 100644 index 0000000..5ca66f8 --- /dev/null +++ b/servo/views/stats.py @@ -0,0 +1,443 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import json +import datetime +from datetime import timedelta +from django.utils import timezone + +from django import forms +from django.db import connection +from django.shortcuts import render +from django.http import HttpResponse +from django.utils.translation import ugettext as _ +from django.views.decorators.cache import cache_page + +from servo.stats.forms import * +from servo.stats.queries import * + +from servo.models import User, Order + + +class ServoTimeDelta: + def __init__(self, td): + self.td = td + + def days(self): + return self.td.days + + def workdays(self): + pass + + def hours(self): + return self.td.seconds//3600 + + def nonzero(self): + return self.hours() > 0 + + +def prep_view(request): + """ + Prepares the stats view + """ + title = _('Statistics') + profile = request.user + location = request.user.location + + initial = { + 'location' : location.pk, + 'end_date' : str(default_end_date), + 'start_date': str(default_start_date), + 'timescale' : default_timescale, + } + + group = request.user.get_group() + + if group: + initial['group'] = group.pk + + # Remember the previous stats filter + if request.session.get('stats_filter'): + initial.update(request.session['stats_filter']) + + request.session['stats_filter'] = initial + + return locals() + + +def index(request): + """ + /stats/ + """ + data = prep_view(request) + form = TechieStatsForm(initial=data['initial']) + + if request.method == 'POST': + form = TechieStatsForm(request.POST, initial=data['initial']) + if form.is_valid(): + request.session['stats_filter'] = form.cleaned_data + + data['form'] = form + return render(request, "stats/index.html", data) + + +#@cache_page(15*60) +def data(request, query): + result = [] + stats = StatsManager() + cursor = connection.cursor() + report, what = query.split('/') + + locations = request.user.locations + params = request.session['stats_filter'] + timescale = params.get('timescale', default_timescale) + location = params.get('location', request.user.location) + + if params.get('location'): + location = Location.objects.get(pk=params['location']) + else: + location = request.user.location + + try: + location_id = location.pk + except AttributeError: + location_id = 0 + + start_date = params.get('start_date', default_start_date) + end_date = params.get('end_date', default_end_date) + queues = request.user.queues.all() + + try: + users = params.get('group').user_set + except AttributeError: + users = User.objects.filter(location=location) + + if report == "sales": + if what == "invoices": + for i in queues: + data = stats.sales_invoices(timescale, i.pk, start_date, end_date) + result.append({'label': i.title, 'data': data}) + + if what == "purchases": + for i in queues: + data = stats.sales_purchases(timescale, i.pk, start_date, end_date) + result.append({'label': i.title, 'data': data}) + + if what == "parts": + i = 0 + data = [] + labels = [] + results = stats.sales_parts_per_labtier(start_date, end_date) + for r in results: + data.append([i, r[1]]) + labels.append([i, r[0]]) + i += 1 + + result.append({'label': labels, 'data': data}) + + if what == "personal": + location_id = request.user.get_location().id + users = User.objects.filter(pk=request.user.pk) + + for i in users.filter(is_active=True): + data = stats.order_runrate(timescale, location_id, i.pk, start_date, end_date) + result.append({'label': i.get_full_name(), 'data': data}) + + if what == "runrate": + for i in users.filter(is_active=True): + data = stats.order_runrate(timescale, location_id, i.pk, start_date, end_date) + result.append({'label': i.get_full_name(), 'data': data}) + + if report == "created": + if what == "user": + for i in location.user_set.all(): + data = stats.orders_created_by(timescale, + location_id, + i.pk, + start_date, + end_date) + result.append({'label': i.get_full_name(), 'data': data}) + + if what == "location": + for i in locations.all(): + data = stats.orders_created_at(timescale, i.pk, start_date, end_date) + result.append({'label': i.title, 'data': data}) + + if report == "closed": + if what == "location": + for i in locations.all(): + data = stats.orders_closed_at(timescale, i.pk, start_date, end_date) + result.append({'label': i.title, 'data': data}) + if what == "queue": + for i in queues: + data = stats.orders_closed_in( + timescale, + location.pk, + i.pk, + start_date, + end_date) + result.append({'label': i.title, 'data': data}) + + if what == "count": + for i in queues: + data = stats.order_count(timescale, location_id, i.pk, start_date, end_date) + result.append({'label': i.title, 'data': data}) + + if report == "status": + try: + status = params.get('status').title + except AttributeError: + return HttpResponse(json.dumps(result)) + + if what == "location": + for i in locations.all(): + data = stats.statuses_per_location( + timescale, + i.pk, + status, + start_date, + end_date) + result.append({'label': i.title, 'data': data}) + if what == "tech": + for i in User.objects.filter(location=location, is_active=True): + data = stats.statuses_per_user( + timescale, + i.pk, + status, + start_date, + end_date) + result.append({'label': i.get_name(), 'data': data}) + + if report == "turnaround": + if what == "location": + for i in locations.all(): + data = stats.turnaround_per_location( + timescale, + i.pk, + start_date, + end_date) + result.append({'label': i.title, 'data': data}) + + if report == "runrate": + if what == "location": + for i in locations.all(): + data = stats.runrate_per_location( + timescale, + i.pk, + start_date, + end_date) + result.append({'label': i.title, 'data': data}) + + if report == "distribution": + if what == "location": + result = stats.distribution_per_location(start_date, end_date) + + if what == "turnaround": + for i in queues: + data = stats.order_turnaround( + timescale, + location_id, + i.pk, + start_date, + end_date + ) + result.append({'label': i.title, 'data': data}) + + if what == "queues": + cursor.execute("""SELECT q.title, COUNT(*) + FROM servo_order o LEFT OUTER JOIN servo_queue q on (o.queue_id = q.id) + WHERE (o.created_at, o.created_at) OVERLAPS (%s, %s) + GROUP BY q.title""", [start_date, end_date]) + + for k, v in cursor.fetchall(): + k = k or _('No Queue') + result.append({'label': k, 'data': v}) + + if what == "techs": + for i in users.filter(is_active=True): + cursor.execute("""SELECT COUNT(*) as p + FROM servo_order o + WHERE user_id = %s + AND location_id = %s + AND (created_at, created_at) OVERLAPS (%s, %s) + GROUP BY user_id""", [i.pk, location_id, start_date, end_date]) + + for v in cursor.fetchall(): + result.append({'label': i.username, 'data': v}) + + return HttpResponse(json.dumps(result)) + + +def sales(request): + data = prep_view(request) + form = InvoiceStatsForm(initial=data['initial']) + + if request.method == 'POST': + form = InvoiceStatsForm(request.POST, initial=data['initial']) + if form.is_valid(): + request.session['stats_filter'] = form.cleaned_data + + data['form'] = form + return render(request, "stats/sales.html", data) + + +def queues(request): + data = prep_view(request) + form = OrderStatsForm(initial=data['initial']) + if request.method == 'POST': + form = OrderStatsForm(request.POST, initial=data['initial']) + if form.is_valid(): + request.session['stats_filter'] = form.cleaned_data + + data['form'] = form + return render(request, "stats/queues.html", data) + + +def locations(request): + data = prep_view(request) + form = BasicStatsForm(initial=data['initial']) + if request.method == 'POST': + form = BasicStatsForm(request.POST, initial=data['initial']) + if form.is_valid(): + request.session['stats_filter'] = form.cleaned_data + data['form'] = form + return render(request, "stats/locations.html", data) + + +def statuses(request): + data = prep_view(request) + form = StatusStatsForm(initial=data['initial']) + if request.method == 'POST': + form = StatusStatsForm(request.POST, initial=data['initial']) + if form.is_valid(): + request.session['stats_filter'] = form.cleaned_data + + data['form'] = form + return render(request, "stats/statuses.html", data) + + +def repairs(request): + title = _('Repair statistics') + form = NewStatsForm(initial={ + 'location': [request.user.location], + 'queue': request.user.queues.all() + }) + + if request.GET.get('location'): + results = [] + form = NewStatsForm(request.GET) + totals = { + 'created' : 0, + 'assigned' : 0, + 'repairs' : 0, + 'dispatched' : 0, + 'tmp_orders' : [], + 'turnaround' : timedelta(), + } + + if not form.is_valid(): + return render(request, "stats/newstats.html", locals()) + + cdata = form.cleaned_data + date_range = (cdata['start_date'], cdata['end_date']) + + for u in User.active.filter(location=cdata['location']): + r = {'name': u.get_full_name()} + + # Look at invoices first because that data may be different from + # assignment info (tech A startx, tech B finishes) + dispatched = u.invoice_set.filter( + order__queue=cdata['queue'], + order__location=cdata['location'], + created_at__range=date_range + ) + + if len(cdata.get('label')): + dispatched = dispatched.filter(order__tags=cdata['label']) + + # Count each case's dispatch only once + r['dispatched'] = dispatched.values('order_id').distinct().count() + + created = u.created_orders.filter( + queue=cdata['queue'], + location=cdata['location'], + created_at__range=date_range + ) + + if len(cdata.get('label')): + created = created.filter(tags=cdata['label']) + + r['created'] = created.count() + totals['created'] += r['created'] # add amount to totals + + assigned = u.order_set.filter( + queue=cdata['queue'], + location=cdata['location'], + started_at__range=date_range + ) + + if len(cdata.get('label')): + assigned = assigned.filter(tags=cdata['label']) + + r['assigned'] = assigned.count() + + if (r['assigned'] < 1) and (r['dispatched'] < 1): + continue # ... only continue with actual techs + + repairs = u.created_repairs.filter( + order__queue=cdata['queue'], + order__location=cdata['location'], + submitted_at__range=date_range + ) + + if len(cdata.get('label')): + repairs = repairs.filter(order__tags=cdata['label']) + + # Only count each case's GSX repair once + r['repairs'] = repairs.values('order_id').distinct().count() + + totals['repairs'] += r['repairs'] + totals['assigned'] += r['assigned'] + totals['dispatched'] += r['dispatched'] + + results.append(r) + turnaround = timedelta() + + # calculate turnaround time of dispatched cases + for o in dispatched: + totals['tmp_orders'].append(o.order) + for s in o.order.orderstatus_set.filter(status=cdata['status']): + if s.finished_at is None: + s.finished_at = s.order.closed_at or timezone.now() + + totals['turnaround'] += (s.finished_at - s.started_at) + + totals['diff'] = totals['dispatched'] - totals['assigned'] + + if totals['dispatched'] > 0: + totals['turnaround'] = ServoTimeDelta(totals['turnaround']/totals['dispatched']) + + return render(request, "stats/newstats.html", locals()) diff --git a/servo/views/tags.py b/servo/views/tags.py new file mode 100644 index 0000000..3dea6d4 --- /dev/null +++ b/servo/views/tags.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013, First Party Software +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +from django.http import HttpResponse +from servo.models import TaggedItem + + +def clear(request, pk): + TaggedItem.objects.get(pk=pk).delete() + return HttpResponse("") + + +def add(request, content_type, pk, tag): + pass |