From 63b0fc6269b38edf7234b9f151b80d81f614c0a3 Mon Sep 17 00:00:00 2001 From: Filipp Lepalaan Date: Tue, 4 Aug 2015 10:11:24 +0300 Subject: Initial commit First public commit --- servo/models/__init__.py | 42 ++ servo/models/account.py | 318 ++++++++++++ servo/models/calendar.py | 184 +++++++ servo/models/common.py | 823 ++++++++++++++++++++++++++++++ servo/models/customer.py | 314 ++++++++++++ servo/models/device.py | 523 ++++++++++++++++++++ servo/models/escalations.py | 125 +++++ servo/models/invoices.py | 226 +++++++++ servo/models/note.py | 617 +++++++++++++++++++++++ servo/models/order.py | 1156 +++++++++++++++++++++++++++++++++++++++++++ servo/models/parts.py | 407 +++++++++++++++ servo/models/product.py | 654 ++++++++++++++++++++++++ servo/models/purchases.py | 353 +++++++++++++ servo/models/queue.py | 296 +++++++++++ servo/models/repair.py | 641 ++++++++++++++++++++++++ servo/models/rules.py | 177 +++++++ servo/models/shipments.py | 212 ++++++++ 17 files changed, 7068 insertions(+) create mode 100644 servo/models/__init__.py create mode 100644 servo/models/account.py create mode 100644 servo/models/calendar.py create mode 100644 servo/models/common.py create mode 100644 servo/models/customer.py create mode 100644 servo/models/device.py create mode 100644 servo/models/escalations.py create mode 100644 servo/models/invoices.py create mode 100644 servo/models/note.py create mode 100644 servo/models/order.py create mode 100644 servo/models/parts.py create mode 100644 servo/models/product.py create mode 100644 servo/models/purchases.py create mode 100644 servo/models/queue.py create mode 100644 servo/models/repair.py create mode 100644 servo/models/rules.py create mode 100644 servo/models/shipments.py (limited to 'servo/models') diff --git a/servo/models/__init__.py b/servo/models/__init__.py new file mode 100644 index 0000000..1a109c8 --- /dev/null +++ b/servo/models/__init__.py @@ -0,0 +1,42 @@ +# -*- 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 common import * +from product import * +from account import * +from queue import * +from calendar import * +from customer import * +from device import * +from note import * +from order import * +from invoices import * +from purchases import * +from shipments import * +from parts import * +from repair import * +from escalations import * +from rules import * diff --git a/servo/models/account.py b/servo/models/account.py new file mode 100644 index 0000000..883a877 --- /dev/null +++ b/servo/models/account.py @@ -0,0 +1,318 @@ +# -*- 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 locale + +from django.db import models +from django.conf import settings + +from pytz import common_timezones +from django.core.cache import cache +from django.core.urlresolvers import reverse +from rest_framework.authtoken.models import Token + +from mptt.fields import TreeForeignKey +from django.contrib.sites.models import Site +from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth.models import AbstractUser, Group, UserManager + +from servo import defaults +from servo.models.common import Location, Configuration +from servo.models.queue import Queue +from servo.models.customer import Customer + + +class ActiveManager(UserManager): + def get_queryset(self): + r = super(ActiveManager, self).get_queryset().filter(is_visible=True) + return r.filter(is_active=True) + + +class TechieManager(UserManager): + def get_queryset(self): + return super(TechieManager, self).get_queryset().filter(tech_id__regex=r'\w{8}') + + def active(self): + return self.get_queryset().filter(is_active=True) + + +class User(AbstractUser): + site = models.ForeignKey(Site, editable=False, default=defaults.site_id) + customer = TreeForeignKey( + Customer, + null=True, + blank=True, + limit_choices_to={'is_company': True} + ) + + full_name = models.CharField( + max_length=128, + editable=False, + default=_('New User') + ) + + locations = models.ManyToManyField(Location, blank=True) + + # The location this user is currently in + location = models.ForeignKey( + Location, + null=True, + related_name='+', + on_delete=models.PROTECT, + verbose_name=_('Current Location'), + help_text=_(u'Orders you create will be registered to this location.') + ) + queues = models.ManyToManyField(Queue, blank=True, verbose_name=_('queues')) + LOCALES = ( + ('da_DK.UTF-8', _("Danish")), + ('nl_NL.UTF-8', _("Dutch")), + ('en_US.UTF-8', _("English")), + ('et_EE.UTF-8', _("Estonian")), + ('fi_FI.UTF-8', _("Finnish")), + ('sv_SE.UTF-8', _("Swedish")), + ) + locale = models.CharField( + max_length=32, + choices=LOCALES, + default=LOCALES[0][0], + verbose_name=_('language'), + help_text=_("Select which language you want to use Servo in.") + ) + + TIMEZONES = tuple((t, t) for t in common_timezones) + timezone = models.CharField( + max_length=128, + choices=TIMEZONES, + default=settings.TIMEZONE, + verbose_name=_('Time zone'), + help_text=_("Your current timezone") + ) + + REGIONS = ( + ('da_DK.UTF-8', _("Denmark")), + ('et_EE.UTF-8', _("Estonia")), + ('fi_FI.UTF-8', _("Finland")), + ('en_US.UTF-8', _("United States")), + ('nl_NL.UTF-8', _("Netherlands")), + ('sv_SE.UTF-8', _("Sweden")), + ) + region = models.CharField( + max_length=32, + choices=REGIONS, + default=defaults.locale, + verbose_name=_('region'), + help_text=_("Affects formatting of numbers, dates and currencies.") + ) + should_notify = models.BooleanField( + default=True, + verbose_name=_('Enable notifications'), + help_text=_("Enable notifications in the toolbar.") + ) + notify_by_email = models.BooleanField( + default=False, + verbose_name=_('email notifications'), + help_text=_("Event notifications will also be emailed to you.") + ) + autoprint = models.BooleanField( + default=True, + verbose_name=_('print automatically'), + help_text=_("Opens print dialog automatically.") + ) + tech_id = models.CharField( + blank=True, + default='', + max_length=16, + verbose_name=_("tech ID") + ) + gsx_userid = models.CharField( + blank=True, + default='', + max_length=128, + verbose_name=_("User ID") + ) + gsx_password = models.CharField( + blank=True, + default='', + max_length=256, + verbose_name=_("Password") + ) + gsx_poprefix = models.CharField( + blank=True, + default='', + max_length=8, + verbose_name=_("PO prefix"), + help_text=_("GSX repairs you create will be prefixed") + ) + + photo = models.ImageField( + null=True, + blank=True, + upload_to="avatars", + verbose_name=_('photo'), + help_text=_("Maximum avatar size is 1MB") + ) + + is_visible = models.BooleanField(default=True, editable=False) + + objects = UserManager() + techies = TechieManager() + active = ActiveManager() + + def get_location_list(self): + results = [] + for l in self.locations.all(): + results.append({'pk': l.pk, 'name': l.title}) + + return results + + @classmethod + def serialize(cls, queryset): + results = [] + for u in queryset: + results.append({'pk': u.pk, 'name': u.get_name()}) + + return results + + @classmethod + def refresh_nomail(cls): + users = cls.active.filter(notify_by_email=False) + nomail = [u.email for u in users] + cache.set('nomail', nomail) + + @classmethod + def get_checkin_group(cls): + """ + Returns all the active members of the check-in group + """ + group = Configuration.conf('checkin_group') + return cls.active.filter(groups__pk=group) + + @classmethod + def get_checkin_group_list(cls): + return cls.serialize(cls.get_checkin_group()) + + @classmethod + def get_checkin_user(cls): + return cls.objects.get(pk=Configuration.conf('checkin_user')) + + def create_token(self): + token = Token.objects.create(user=self) + return token.key + + def delete_tokens(self): + self.get_tokens().delete() + + def get_tokens(self): + return Token.objects.filter(user=self) + + def notify(self, msg): + pass + + def get_group(self): + """ + Returns the user's primary (first) group + """ + return self.groups.first() + + def get_icon(self): + return 'icon-star' if self.is_staff else 'icon-user' + + def get_name(self): + return self.full_name if len(self.full_name) > 1 else self.username + + def get_location(self): + return self.location + + def get_unread_message_count(self): + key = '%s_unread_message_count' % self.user.email + count = cache.get(key, 0) + return count if count > 0 else "" + + def get_order_count(self, max_state=2): + count = self.order_set.filter(state__lt=max_state).count() + return count if count > 0 else "" + + def order_count_in_queue(self, queue): + count = self.user.order_set.filter(queue=queue).count() + return count if count > 0 else "" + + def save(self, *args, **kwargs): + self.full_name = u"{0} {1}".format(self.first_name, self.last_name) + users = User.objects.filter(notify_by_email=False) + nomail = [u.email for u in users] + cache.set('nomail', nomail) + return super(User, self).save(*args, **kwargs) + + def activate_locale(self): + """ + Activates this user's locale + """ + try: + lc = self.locale.split('.') + region = self.region.split('.') + locale.setlocale(locale.LC_TIME, region) + locale.setlocale(locale.LC_MESSAGES, lc) + locale.setlocale(locale.LC_NUMERIC, region) + locale.setlocale(locale.LC_MONETARY, region) + except Exception as e: + locale.setlocale(locale.LC_ALL, None) + + # Return the language code + return self.locale.split('_', 1)[0] + + def get_avatar(self): + try: + return self.photo.url + except ValueError: + return "/static/images/avatar.png" + + def get_admin_url(self): + return reverse('admin-edit_user', args=[self.pk]) + + def __unicode__(self): + return self.get_name() or self.username + + class Meta: + app_label = "servo" + ordering = ("full_name",) + verbose_name = _('User') + verbose_name_plural = _('Users & Groups') + + +class UserGroup(Group): + site = models.ForeignKey(Site, editable=False, default=defaults.site_id) + + def members_as_list(self): + pass + + def get_name(self): + return self.name + + def get_admin_url(self): + return reverse('admin-edit_group', args=[self.pk]) + + class Meta: + app_label = 'servo' diff --git a/servo/models/calendar.py b/servo/models/calendar.py new file mode 100644 index 0000000..1a8b4e8 --- /dev/null +++ b/servo/models/calendar.py @@ -0,0 +1,184 @@ +# -*- 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 math + +from dateutil.rrule import DAILY, rrule + +from django import forms +from django.db import models +from django.conf import settings +from django.utils import timezone +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ +from django.db.models import Sum + + +class Calendar(models.Model): + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + editable=False + ) + + title = models.CharField( + max_length=128, + verbose_name=_('title'), + default=_('New Calendar') + ) + hours_per_day = models.FloatField( + null=True, + blank=True, + verbose_name=_("hours per day"), + help_text=_("How many hours per day should be in this calendar") + ) + + def min_hours(self): + return self.hours_per_day or 0 + + def get_overtime(self, total_hours, workdays): + overtime = total_hours - (self.min_hours() * workdays) + return overtime if overtime > 0 else 0 + + def subtitle(self, start_date, end_date): + workdays = self.get_workdays(start_date, end_date) + total_hours = self.get_total_hours(start_date, end_date) + overtime = self.get_overtime(total_hours, workdays) + + if overtime > 1: + d = {'hours': total_hours, 'workdays': workdays, 'overtime': overtime} + subtitle = _("%(hours)s hours total in %(workdays)s days (%(overtime)s hours overtime)." % d) + else: + d = {'hours': total_hours, 'workdays': workdays} + subtitle = _("%(hours)s hours total in %(workdays)s days." % d) + + return subtitle + + def get_workdays(self, start_date, end_date): + WORKDAYS = xrange(0, 5) + r = rrule(DAILY, dtstart=start_date, until=end_date, byweekday=WORKDAYS) + return r.count() + + def get_unfinished_count(self): + count = self.calendarevent_set.filter(finished_at=None).count() + return count or "" + + def get_total_hours(self, start=None, finish=None): + """ + Returns in hours, the total duration of events in this calendar within + a time period. + """ + events = self.calendarevent_set.all() + + if start and finish: + events = self.calendarevent_set.filter(started_at__range=(start, finish)) + + total = events.aggregate(total=Sum('seconds'))['total'] or 0 + + return math.ceil(total/3600.0) + + def get_absolute_url(self): + return reverse('calendars.view', args=[self.user.username, self.pk]) + + class Meta: + app_label = "servo" + + +class CalendarEvent(models.Model): + + calendar = models.ForeignKey( + Calendar, + editable=False + ) + + started_at = models.DateTimeField(default=timezone.now) + finished_at = models.DateTimeField(null=True, blank=True) + + # The duration of this event in seconds + seconds = models.PositiveIntegerField( + null=True, + editable=False + ) + + notes = models.TextField(null=True, blank=True) + + def get_start_date(self): + return self.started_at.strftime('%x') + + def get_start_time(self): + return self.started_at.strftime('%H:%M') + + def get_finish_time(self): + try: + return self.finished_at.strftime('%H:%M') + except AttributeError: + return '' + + def set_finished(self, ts=timezone.now): + self.finished_at = ts() + self.save() + + def get_hours(self): + return self.seconds/3600.0 + + def get_duration(self): + if self.finished_at is None: + return '' + + delta = (self.finished_at - self.started_at) + hours, remainder = divmod(delta.seconds, 3600) + minutes, seconds = divmod(remainder, 60) + + return '%d:%d' % (hours, minutes) + + def get_absolute_url(self): + return '/%s/calendars/%d/events/%d' % (self.calendar.user.username, self.calendar.pk, self.pk) + + def save(self, *args, **kwargs): + self.seconds = 0 + + if self.finished_at: + delta = self.finished_at - self.started_at + self.seconds = delta.seconds + + super(CalendarEvent, self).save(*args, **kwargs) + + class Meta: + app_label = 'servo' + ordering = ['-started_at'] + + +class CalendarForm(forms.ModelForm): + class Meta: + model = Calendar + exclude = [] + + +class CalendarEventForm(forms.ModelForm): + class Meta: + model = CalendarEvent + exclude = [] + \ No newline at end of file diff --git a/servo/models/common.py b/servo/models/common.py new file mode 100644 index 0000000..58b7427 --- /dev/null +++ b/servo/models/common.py @@ -0,0 +1,823 @@ +# -*- 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 +import os.path + +from decimal import Decimal +from django.core.urlresolvers import reverse +from django.template.defaultfilters import slugify +from pytz import common_timezones, country_timezones + +from django.db import models +from django.conf import settings +from django.contrib.sites.models import Site + +from mptt.managers import TreeManager +from django.contrib.sites.managers import CurrentSiteManager + +from mptt.models import MPTTModel, TreeForeignKey +from django.utils.translation import ugettext_lazy as _ + +from django.dispatch import receiver +from django.db.models.signals import post_save + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType + +from django.core.cache import cache + +from servo import defaults +from servo.validators import file_upload_validator + + +# Dict for mapping timezones to countries +TIMEZONE_COUNTRY = {} + +for cc in country_timezones: + timezones = country_timezones[cc] + for timezone in timezones: + TIMEZONE_COUNTRY[timezone] = cc + + +class CsvTable(object): + def __init__(self, colwidth=20): + self.rowcount = 0 + self.colwidth = colwidth + self.body = u'' + self.table = u'' + self.header = u'' + + def padrow(self, row): + r = [] + for c in row: + r.append(unicode(c).ljust(self.colwidth)) + + return r + + def addheader(self, new_header): + self.rowcount = self.rowcount + 1 + header = self.padrow(new_header) + self.header = ''.join(header) + + def addrow(self, new_row): + row = self.padrow(new_row) + self.body += ''.join(row) + "\n" + + def has_body(self): + return self.body != '' + + def __unicode__(self): + self.table = self.header + "\n" + self.body + return self.table + + def __str__(self): + return unicode(self).encode('utf-8') + + +class BaseItem(models.Model): + """ + Base class for a few generic relationships + """ + site = models.ForeignKey(Site, editable=False, default=defaults.site_id) + + object_id = models.PositiveIntegerField() + content_type = models.ForeignKey(ContentType) + content_object = GenericForeignKey("content_type", "object_id") + + objects = CurrentSiteManager() + + class Meta: + abstract = True + app_label = "servo" + + +class RatedItem(BaseItem): + rating = models.PositiveIntegerField() + + +class TimedItem(BaseItem): + status = models.CharField(max_length=128) + started_at = models.DateTimeField() + timeout_at = models.DateTimeField() + + +class TaggedItem(BaseItem): + """ + A generic tagged item + """ + tag = models.CharField(max_length=128) + slug = models.SlugField() + color = models.CharField(max_length=8, default="") + + def save(self, *args, **kwargs): + self.slug = slugify(self.tag) + super(TaggedItem, self).save(*args, **kwargs) + + def __unicode__(self): + return self.tag + + class Meta: + app_label = "servo" + unique_together = ("content_type", "object_id", "tag",) + + +class FlaggedItem(BaseItem): + flagged_by = models.ForeignKey(settings.AUTH_USER_MODEL) + + +class Event(BaseItem): + """ + Something that happens + """ + description = models.CharField(max_length=255) + + triggered_by = models.ForeignKey(settings.AUTH_USER_MODEL) + triggered_at = models.DateTimeField(auto_now_add=True) + handled_at = models.DateTimeField(null=True) + + action = models.CharField(max_length=32) + priority = models.SmallIntegerField(default=1) + + notify_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, + related_name="notifications" # request.user.notifications + ) + + def save(self, *args, **kwargs): + saved = super(Event, self).save(*args, **kwargs) + + if settings.ENABLE_RULES: + from servo.tasks import apply_rules + apply_rules.delay(self) + + def get_status(self): + from servo.models import Status + return Status.objects.get(title=self.description) + + def get_icon(self): + return "events/%s-%s" % (self.content_type, self.action) + + def get_link(self): + return self.content_object.get_absolute_url() + + def get_class(self): + return "disabled" if self.handled_at else "" + + def __unicode__(self): + return self.description + + class Meta: + ordering = ('priority', '-id',) + app_label = "servo" + + +class GsxAccount(models.Model): + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + + title = models.CharField(max_length=128, default=_("New GSX Account")) + sold_to = models.CharField(max_length=10, verbose_name=_("Sold-To")) + ship_to = models.CharField(max_length=10, verbose_name=_("Ship-To")) + + region = models.CharField( + max_length=3, + choices=gsxws.GSX_REGIONS, + verbose_name=_("Region") + ) + + user_id = models.CharField( + blank=True, + default='', + max_length=128, + verbose_name=_("User ID") + ) + + password = models.CharField( + blank=True, + default='', + max_length=256, + verbose_name=_("Password") + ) + + environment = models.CharField( + max_length=2, + verbose_name=_("Environment"), + choices=gsxws.ENVIRONMENTS, + default=gsxws.ENVIRONMENTS[0][0] + ) + + @classmethod + def get_soldto_choices(cls): + choices = [] + for i in cls.objects.all(): + choice = (i.sold_to, '%s (%s)' % (i.sold_to, i.title)) + choices.append(choice) + + choices = [('', '------------------'),] + choices + return choices + + @classmethod + def get_shipto_choices(cls): + return cls.objects.values_list('ship_to', 'ship_to') + + + @classmethod + def get_default_account(cls): + act_pk = Configuration.conf('gsx_account') + + if act_pk in ('', None,): + raise ValueError(_('Default GSX account not configured')) + + return GsxAccount.objects.get(pk=act_pk) + + @classmethod + def get_account(cls, location, queue=None): + """ + Returns the correct GSX account for the specified user/queue + """ + try: + act = location.gsx_accounts.get(sold_to=queue.gsx_soldto) + except Exception as e: + act = GsxAccount.get_default_account() + + return act + + @classmethod + def default(cls, user, queue=None): + """ + Returns the correct GSX account for + the specified user/queue and connects to it + """ + try: + act = GsxAccount.get_account(user.location, queue) + except ValueError: + raise gsxws.GsxError(_('Configuration error')) + + return act.connect(user) + + def connect(self, user, location=None): + """ + Connects to this GSX Account + """ + if user.gsx_userid: + self.user_id = user.gsx_userid + if user.gsx_password: + self.password = user.gsx_password + + if location is None: + timezone = user.location.gsx_tz + else: + timezone = location.gsx_tz + + gsxws.connect(user_id=self.user_id, + password=self.password, + sold_to=self.sold_to, + environment=self.environment, + timezone=timezone) + return self + + def test(self): + """ + Tests that the account details are correct + """ + if self.user_id and self.password: + gsxws.connect(sold_to=self.sold_to, + user_id=self.user_id, + password=self.password, + environment=self.environment) + + def get_admin_url(self): + return reverse('admin-edit_gsx_account', args=[self.pk]) + + def __unicode__(self): + return u"%s (%s)" % (self.title, self.get_environment_display()) + + class Meta: + app_label = 'servo' + get_latest_by = 'id' + ordering = ['title'] + verbose_name = _("GSX Account") + verbose_name_plural = _("GSX Accounts") + unique_together = ('sold_to', 'ship_to', 'environment', 'site',) + + +class Tag(MPTTModel): + """ + A tag is a simple one-word descriptor for something. + The type attribute is used to group tags to make them easier + to associate with different elements + """ + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + + title = models.CharField( + unique=True, + max_length=255, + default=_('New Tag'), + verbose_name=_('name') + ) + + TYPES = ( + ('device', _('Device')), + ('order', _('Order')), + ('note', _('Note')), + ('other', _('Other')), + ) + + type = models.CharField( + max_length=32, + choices=TYPES, + verbose_name=_(u'type') + ) + + parent = TreeForeignKey( + 'self', + null=True, + blank=True, + related_name='children' + ) + + times_used = models.IntegerField(default=0, editable=False) + + COLORS = ( + ('default', _('Default')), + ('success', _('Green')), + ('warning', _('Orange')), + ('important', _('Red')), + ('info', _('Blue')), + ) + + color = models.CharField( + max_length=16, + blank=True, + null=True, + choices=COLORS, + default='default' + ) + + def count_open_orders(self): + count = self.order_set.filter(state__lt=2).count() + return count if count > 0 else '' + + def get_admin_url(self): + return reverse('admin-edit_tag', args=[self.type, self.pk]) + + def __unicode__(self): + return self.title + + objects = TreeManager() + on_site = CurrentSiteManager() + + class Meta: + app_label = 'servo' + verbose_name = _('Tag') + verbose_name_plural = _('Tags') + + class MPTTMeta: + order_insertion_by = ['title'] + + +class Location(models.Model): + """ + A Service Location within a company + """ + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + + title = models.CharField( + max_length=255, + verbose_name=_(u'name'), + default=_('New Location'), + ) + phone = models.CharField( + blank=True, + default='', + max_length=32, + verbose_name=_('phone') + ) + email = models.EmailField(blank=True, default='', verbose_name=_('email')) + address = models.CharField( + blank=True, + default='', + max_length=32, + verbose_name=_(u'address') + ) + zip_code = models.CharField( + blank=True, + default='', + max_length=8, + verbose_name=_(u'ZIP Code') + ) + city = models.CharField( + blank=True, + default='', + max_length=16, + verbose_name=_(u'city') + ) + + TIMEZONES = tuple((t, t) for t in common_timezones) + + timezone = models.CharField( + default='UTC', + max_length=128, + choices=TIMEZONES, + verbose_name=_('Time zone') + ) + + # It would make more sense to just store the Ship-To + # per-location, but some location can have multiple Ship-Tos :-/ + gsx_accounts = models.ManyToManyField( + GsxAccount, + blank=True, + verbose_name=_('Accounts') + ) + + gsx_shipto = models.CharField( + max_length=10, + default='', + blank=True, + verbose_name=_('Ship-To') + ) + + gsx_tz = models.CharField( + max_length=4, + default='CEST', + verbose_name=_('Timezone'), + choices=gsxws.GSX_TIMEZONES + ) + + notes = models.TextField( + blank=True, + default='9:00 - 18:00', + verbose_name=_('Notes'), + help_text=_('Will be shown on print templates') + ) + + logo = models.FileField( + null=True, + blank=True, + upload_to='logos', + verbose_name=_('Logo') + ) + + enabled = models.BooleanField( + default=True, + verbose_name=_('Enabled') + ) + + def get_shipto_choices(self): + return self.gsx_accounts.values_list('ship_to', 'ship_to') + + def get_country(self): + try: + return TIMEZONE_COUNTRY[self.timezone] + except KeyError: + return 'FI' + + def ship_to_choices(self): + choices = [] + for i in self.gsx_accounts.all(): + choices.append((i.ship_to, i.ship_to)) + return choices + + def get_admin_url(self): + return reverse('admin-edit_location', args=[self.pk]) + + def gsx_address(self): + return { + 'city': self.city, + 'zipCode': self.zip_code, + 'country': self.get_country(), + 'primaryPhone': self.phone, + 'emailAddress': self.email, + } + + def __unicode__(self): + return self.title + + class Meta: + ordering = ('title',) + app_label = 'servo' + get_latest_by = 'id' + verbose_name = _('Location') + verbose_name_plural = _('Locations') + unique_together = ('title', 'site',) + + +class Configuration(models.Model): + site = models.ForeignKey(Site, editable=False, default=defaults.site_id) + key = models.CharField(max_length=255) + value = models.TextField(default='', blank=True) + + @classmethod + def true(cls, key): + return cls.conf(key) == 'True' + + @classmethod + def false(cls, key): + return not cls.true(key) + + @classmethod + def get_company_logo(cls): + return cls.conf('company_logo') + + @classmethod + def default_subject(cls): + return cls.conf('default_subject') + + @classmethod + def get_default_sender(cls, user): + conf = cls.conf() + sender = conf.get('default_sender') + + if sender == 'user': + return user.email + if sender == 'location': + return user.get_location().email + + return conf.get('default_sender_custom') + + @classmethod + def track_inventory(cls): + return cls.conf('track_inventory') == 'True' + + @classmethod + def notify_location(cls): + return cls.conf('notify_location') == 'True' + + @classmethod + def notify_email_address(cls): + """ + Returns the email address to send reports to + or None if it's invalid + """ + from django.core.validators import validate_email + try: + validate_email(conf['notify_address']) + return conf['notify_address'] + except Exception: + pass + + @classmethod + def autocomplete_repairs(cls): + return cls.conf('autocomplete_repairs') == 'True' + + @classmethod + def smtp_ssl(cls): + return cls.conf('smtp_ssl') == 'True' + + @classmethod + def get_imap_server(cls): + import imaplib + conf = cls.conf() + + if not conf.get('imap_host'): + raise ValueError("No IMAP server defined - check your configuration") + + if conf.get('imap_ssl'): + server = imaplib.IMAP4_SSL(conf['imap_host']) + else: + server = imaplib.IMAP4(conf['imap_host']) + + server.login(conf['imap_user'], conf['imap_password']) + server.select() + return server + + @classmethod + def conf(cls, key=None): + """ + Returns the admin-configurable config of the site + """ + config = cache.get('config') + if config is None: + config = dict() + for r in Configuration.objects.all(): + config[r.key] = r.value + + cache.set('config', config) + + return config.get(key) if key else config + + def save(self, *args, **kwargs): + config = super(Configuration, self).save(*args, **kwargs) + # Using cache instead of session since it's shared among + # all the users of the instance + cache.set('config', config, 60*60*24*1) + + class Meta: + app_label = 'servo' + unique_together = ('key', 'site',) + + +class Property(models.Model): + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + + TYPES = ( + ('customer', _('Customer')), + ('order', _('Order')), + ('product', _('Product')) + ) + + title = models.CharField( + max_length=255, + default=_('New Field'), + verbose_name=_('title') + ) + + type = models.CharField( + max_length=32, + choices=TYPES, + default=TYPES[0], + verbose_name=_('type') + ) + format = models.CharField( + blank=True, + default='', + max_length=32, + verbose_name=_('format') + ) + value = models.TextField(blank=True, default='', verbose_name=_('value')) + + def __unicode__(self): + return self.title + + def get_admin_url(self): + return reverse('admin-edit_field', args=[self.type, self.pk]) + + def values(self): + if self.value is None: + return [] + else: + return self.value.split(', ') + + class Meta: + app_label = 'servo' + ordering = ['title'] + verbose_name = _('Field') + verbose_name_plural = _('Fields') + + +class Search(models.Model): + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + query = models.TextField() + model = models.CharField(max_length=32) + title = models.CharField(max_length=128) + shared = models.BooleanField(default=True) + + class Meta: + app_label = 'servo' + + +class Notification(models.Model): + """ + A notification is a user-configurable response to an event + """ + KINDS = (('order', u'Tilaus'), ('note', u'Merkintä')) + ACTIONS = (('created', u'Luotu'), ('edited', u'Muokattu')) + + kind = models.CharField(max_length=16) + action = models.CharField(max_length=16) + message = models.TextField() + + class Meta: + app_label = 'servo' + + +class Template(models.Model): + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + + title = models.CharField( + blank=False, + unique=True, + max_length=128, + verbose_name=_('title'), + default=_('New Template') + ) + + content = models.TextField(blank=False, verbose_name=_('content')) + + @classmethod + def templates(self): + choices = Template.objects.all().values_list('title', flat=True) + return list(choices) + + def get_absolute_url(self): + return reverse('notes-template', args=[self.pk]) + + def get_admin_url(self): + return reverse('admin-edit_template', args=[self.pk]) + + def get_delete_url(self): + return reverse('admin-delete_template', args=[self.pk]) + + class Meta: + ordering = ['title'] + app_label = "servo" + verbose_name = _('Template') + verbose_name_plural = _('Templates') + + +class Attachment(BaseItem): + """ + A file attached to something + """ + mime_type = models.CharField(max_length=64, editable=False) + content = models.FileField( + upload_to='attachments', + verbose_name=_('file'), + validators=[file_upload_validator] + ) + + @classmethod + def get_content_type(cls, model): + return ContentType.objects.get(app_label='servo', model=model) + + @classmethod + def from_file(cls, file): + """ + Returns an attachment object from the file data + """ + attachment = cls(content=file) + attachment.save() + + def save(self, *args, **kwargs): + DENIED_EXTENSIONS = ('.htm', '.html', '.py', '.js',) + filename = self.content.name.lower() + ext = os.path.splitext(filename)[1] + + if ext in DENIED_EXTENSIONS: + raise ValueError(_(u'%s is not of an allowed file type') % filename) + + super(Attachment, self).save(*args, **kwargs) + + def __unicode__(self): + return os.path.basename(self.content.name) + + def __str__(self): + return unicode(self).encode('utf-8') + + def from_url(self, url): + pass + + def get_absolute_url(self): + return "/files/%d/view" % self.pk + + class Meta: + app_label = 'servo' + get_latest_by = "id" + + +@receiver(post_save, sender=Attachment) +def set_mimetype(sender, instance, created, **kwargs): + if created: + import subprocess + path = instance.content.path + mimetype = subprocess.check_output(['file', '-b', '--mime-type', path]).strip() + instance.mime_type = mimetype + instance.save() diff --git a/servo/models/customer.py b/servo/models/customer.py new file mode 100644 index 0000000..e89c3dd --- /dev/null +++ b/servo/models/customer.py @@ -0,0 +1,314 @@ +# -*- 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 phonenumbers +from django.db import models +from django.conf import settings + +from mptt.managers import TreeManager +from django.contrib.sites.models import Site +from django.template.defaultfilters import slugify +from mptt.models import MPTTModel, TreeForeignKey +from django.utils.translation import ugettext_lazy as _ +from django.contrib.sites.managers import CurrentSiteManager + +from pytz import country_names + +from servo import defaults +from servo.models import Tag +from servo.models.device import Device + + +class CustomerGroup(models.Model): + name = models.CharField( + unique=True, + max_length=255, + default=_('New Group'), + verbose_name=_('name') + ) + + slug = models.SlugField(editable=False) + + def save(self, *args, **kwargs): + self.slug = slugify(self.name) + super(CustomerGroup, self).save() + + def __unicode__(self): + return self.name + + class Meta: + get_latest_by = 'id' + app_label = "servo" + ordering = ('id',) + + +class Customer(MPTTModel): + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + parent = TreeForeignKey( + 'self', + null=True, + blank=True, + related_name='contacts', + verbose_name=_('company'), + limit_choices_to={'is_company': True} + ) + name = models.CharField( + max_length=255, + verbose_name=_('name'), + default=_('New Customer') + ) + fullname = models.CharField( + default='', + editable=False, + max_length=255 + ) + phone = models.CharField( + default='', + blank=True, + max_length=32, + verbose_name=_('phone') + ) + email = models.EmailField( + blank=True, + default='', + verbose_name=_('email') + ) + street_address = models.CharField( + blank=True, + default='', + max_length=128, + verbose_name=_('address') + ) + zip_code = models.CharField( + blank=True, + default='', + max_length=32, + verbose_name=_('ZIP Code') + ) + city = models.CharField( + blank=True, + default='', + max_length=32, + verbose_name=_('city') + ) + COUNTRY_CHOICES = [(k, country_names[k]) for k in sorted(country_names)] + country = models.CharField( + blank=True, + max_length=2, + verbose_name=_('Country'), + default=defaults.country, + choices=COUNTRY_CHOICES + ) + photo = models.ImageField( + null=True, + blank=True, + upload_to="photos", + verbose_name=_('photo') + ) + + groups = models.ManyToManyField( + CustomerGroup, + blank=True, + verbose_name=_('Groups') + ) + + tags = models.ManyToManyField( + Tag, + blank=True, + verbose_name=_('tags'), + limit_choices_to={'type': 'customer'} + ) + + notes = models.TextField( + blank=True, + default='', + verbose_name=_("notes") + ) + + devices = models.ManyToManyField( + Device, + blank=True, + editable=False, + verbose_name=_("devices") + ) + + created_at = models.DateTimeField(auto_now=True) + is_company = models.BooleanField( + default=False, + verbose_name=_("company"), + help_text=_('Companies can contain contacts') + ) + + objects = TreeManager() + on_site = CurrentSiteManager() + + def get_contacts(self): + return self.get_descendants(include_self=False) + + def get_phone(self): + return phonenumbers.parse(self.phone, self.country) + + def get_standard_phone(self): + n = self.get_phone() + fmt = phonenumbers.PhoneNumberFormat.E164 + return phonenumbers.format_number(n, fmt) + + def get_international_phone(self): + n = self.get_phone() + fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL + return phonenumbers.format_number(n, fmt) + + def get_national_phone(self): + n = self.get_phone() + fmt = phonenumbers.PhoneNumberFormat.NATIONAL + return phonenumbers.format_number(n, fmt) + + def get_email_address(self): + return '%s <%s>' % (self.name, self.email) + + def get_closest_prop(self, prop): + """ + Gets the 'closest' value of a property + """ + ancestors = self.get_ancestors(ascending=True, include_self=True) + for a in ancestors: + attr = getattr(a, prop) + if attr: + return attr + + def gsx_address(self, location): + """ + Returns a dictionary that's compatibly with GSX's Address datatype + """ + out = dict() + + out['country'] = location.get_country() + out['city'] = self.get_closest_prop('city') or location.city + out['zipCode'] = self.get_closest_prop('zip_code') or location.zip_code + out['primaryPhone'] = self.get_closest_prop('phone') or location.phone + out['emailAddress'] = self.get_closest_prop('email') or u'refused@apple.com' + out['addressLine1'] = self.get_closest_prop('street_address') or location.address + + try: + (out['firstName'], out['lastName']) = self.name.split(" ", 1) + except Exception: + out['firstName'], out['lastName'] = self.name, self.name + + return out + + def get_property(self, key): + """ + Returns the value of a specific property + """ + result = None + ci = ContactInfo.objects.filter(customer=self) + for i in ci: + if i.key == key: + result = i.value + + return result + + @property + def firstname(self): + return self.name.split(" ")[0] + + @property + def lastname(self): + return self.name.split(" ")[1].rstrip(',') + + def get_fullname(self): + """ + Gets the entire name tree for this customer + """ + title = list() + + for a in self.get_ancestors(): + title.append(a.name) + + if len(title) < 1: + return self.name + + return self.name + " - " + str(", ").join(title) + + def fullprops(self): + """ + Get the combined view of all the properties for this customer + """ + props = {} + for r in self.contactinfo_set.all(): + props[r.key] = r.value + + return props + + def get_group(self): + try: + return self.groups.latest('id').slug + except CustomerGroup.DoesNotExist: + return "all" + + def get_absolute_url(self): + return "/customers/%s/%d/" % (self.get_group(), self.pk) + + def get_icon(self): + return 'icon-briefcase' if self.is_company else 'icon-user' + + def save(self, *args, **kwargs): + self.zip_code = self.zip_code.replace(' ', '') + + super(Customer, self).save(*args, **kwargs) + fn = self.get_fullname() + + if self.fullname != fn: + self.fullname = fn + self.save() + + for o in self.orders.all(): + o.customer_name = fn + o.save() + + class Meta: + app_label = "servo" + + class MPTTMeta: + order_insertion_by = ['name'] + + def __unicode__(self): + return self.name + + +class ContactInfo(models.Model): + customer = models.ForeignKey(Customer) + key = models.CharField(max_length=255) + value = models.CharField(max_length=255) + + class Meta: + app_label = 'servo' + # Only allow a field once per customer + unique_together = ('customer', 'key',) diff --git a/servo/models/device.py b/servo/models/device.py new file mode 100644 index 0000000..ea719a3 --- /dev/null +++ b/servo/models/device.py @@ -0,0 +1,523 @@ +# -*- 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 os.path import basename +from django_countries import countries +from django.core.validators import RegexValidator + +from django.db import models +from django.conf import settings +from django.core.files import File +from django.core.cache import cache +from django.dispatch import receiver +from django.utils.text import slugify +from django.contrib.sites.models import Site +from django.core.urlresolvers import reverse +from django.db.models.signals import post_save + +from django.contrib.contenttypes.fields import GenericRelation + +from django.utils.translation import ugettext_lazy as _ +from django.contrib.contenttypes.models import ContentType +from django.contrib.sites.managers import CurrentSiteManager + +from servo import defaults +from servo.validators import sn_validator +from servo.models import GsxAccount, Product, DeviceGroup, TaggedItem + + +class Device(models.Model): + """ + The serviceable device + """ + site = models.ForeignKey(Site, editable=False, default=defaults.site_id) + + # @TODO: unique=True would be nice, but complicated... + sn = models.CharField( + blank=True, + default='', + max_length=32, + verbose_name=_("Serial Number"), + validators=[sn_validator] + ) + description = models.CharField( + max_length=128, + default=_("New Device"), + verbose_name=_("description") + ) + brand = models.CharField( + blank=True, + max_length=128, + default=_("Apple"), + verbose_name=_("Brand") + ) + reseller = models.CharField( + blank=True, + default='', + max_length=128, + verbose_name=_("Reseller") + ) + created_at = models.DateTimeField(auto_now_add=True, null=True) + imei = models.CharField( + blank=True, + default='', + max_length=15, + verbose_name=_("IMEI Number") + ) + initial_activation_policy = models.CharField( + default='', + editable=False, + max_length=128, + verbose_name=_("Initial Activation Policy") + ) + applied_activation_policy = models.CharField( + default='', + editable=False, + max_length=128, + verbose_name=_("Applied Activation Policy") + ) + next_tether_policy = models.CharField( + default='', + editable=False, + max_length=128, + verbose_name=_("Next Tether Policy") + ) + unlocked = models.NullBooleanField(default=None, editable=False) + slug = models.SlugField(null=True, editable=False, max_length=128) + PRODUCT_LINES = gsxws.products.models() + LINE_CHOICES = [(k, x['name']) for k, x in PRODUCT_LINES.items()] + product_line = models.CharField( + max_length=16, + default="OTHER", + choices=LINE_CHOICES, + verbose_name=_("Product Line") + ) + products = models.ManyToManyField( + Product, + editable=False, + help_text=_('Products that are compatible with this device instance') + ) + config_code = models.CharField(default='', max_length=8, editable=False) + configuration = models.CharField( + blank=True, + default='', + max_length=256, + verbose_name=_("configuration") + ) + + WARRANTY_CHOICES = ( + ('QP', _("Quality Program")), + ('CS', _("Customer Satisfaction")), + ('ALW', _("Apple Limited Warranty")), + ('APP', _("AppleCare Protection Plan")), + ('CC', _("Custom Bid Contracts")), + ('CBC', _("Custom Bid Contracts")), # sometimes CC, sometimes CBC? + ('WTY', _("3'rd Party Warranty")), + ('OOW', _("Out Of Warranty (No Coverage)")), + ('NA', _("Unknown")), + ) + + warranty_status = models.CharField( + max_length=3, + default="NA", + choices=WARRANTY_CHOICES, + verbose_name=_("Warranty Status") + ) + username = models.CharField( + blank=True, + default='', + max_length=32, + verbose_name=_("username") + ) + password = models.CharField( + blank=True, + default='', + max_length=32, + verbose_name=_("password") + ) + purchased_on = models.DateField( + null=True, + blank=True, + verbose_name=_("Date of Purchase") + ) + purchase_country = models.CharField( + blank=True, + editable=False, + max_length=128, + choices=countries, + default=defaults.country, + verbose_name=_("Purchase Country") + ) + + sla_description = models.TextField(null=True, editable=False) + has_onsite = models.BooleanField( + default=False, + help_text=_('Device is eligible for onsite repairs in GSX') + ) + contract_start_date = models.DateField(null=True, editable=False) + contract_end_date = models.DateField(null=True, editable=False) + onsite_start_date = models.DateField(null=True, editable=False) + onsite_end_date = models.DateField(null=True, editable=False) + + parts_and_labor_covered = models.BooleanField(default=False, editable=False) + + notes = models.TextField(blank=True, default="", verbose_name=_("notes")) + tags = GenericRelation(TaggedItem) + photo = models.ImageField( + null=True, + blank=True, + upload_to="devices", + verbose_name=_("photo") + ) + + image_url = models.URLField( + null=True, + blank=True, + verbose_name=_("Image URL") + ) + manual_url = models.URLField( + null=True, + blank=True, + verbose_name=_("Manual URL") + ) + exploded_view_url = models.URLField( + null=True, + blank=True, + verbose_name=_("Exploded View") + ) + + is_vintage = models.BooleanField( + default=False, + verbose_name='vintage', + help_text=_('Device is considered vintage in GSX') + ) + fmip_active = models.BooleanField(default=False, editable=False) + + objects = CurrentSiteManager() + + def is_apple_device(self): + """ + Checks if this is a valid Apple device SN + """ + valid_sn = gsxws.core.validate(self.sn, 'serialNumber') + valid_imei = gsxws.core.validate(self.imei, 'alternateDeviceId') + return valid_sn or valid_imei + + def get_sn(self): + return self.sn or self.imei + + @property + def has_warranty(self): + return self.warranty_status in ('ALW', 'APP', 'CBC') + + @property + def tag_choices(self): + return TaggedItem.objects.filter(content_type__model="device").distinct("tag") + + def add_tags(self, tags): + tags = [x for x in tags if x != ''] # Filter out empty tags + + if not tags: + return + + content_type = ContentType.objects.get_for_model(Device) + + for t in tags: + tag, created = TaggedItem.objects.get_or_create(content_type=content_type, + object_id=self.pk, + tag=t) + tag.save() + + def get_icon(self): + if re.match('iPad', self.description): + return "ipad" + if re.match('iPhone', self.description): + return "iphone" + if re.match('iPod shuffle', self.description): + return "ipod_shuffle" + if re.match('iPod', self.description): + return "ipod" + if re.match('MacBook', self.description): + return "macbook" + + return "imac" + + def set_wty_status(self, status): + """ + Translates a GSX warranty status description + to our internal representation + """ + if not isinstance(status, basestring): + return + if re.match(r"Apple Limited", status): + self.warranty_status = "ALW" + if re.match(r"AppleCare", status): + self.warranty_status = "APP" + if re.match(r"Customer Satisfaction", status): + self.warranty_status = "CSC" + if re.match(r"Custom Bid", status): + self.warranty_status = "CBC" + if re.match(r"Out Of", status): + self.warranty_status = "OOW" + + def to_dict(self): + result = {'sn': self.sn} + result['description'] = self.description + result['warranty_status'] = self.warranty_status + result['purchased_on'] = self.purchased_on + result['purchase_country'] = self.purchase_country + result['username'] = self.username + result['password'] = self.password + return result + + @classmethod + def from_dict(cls, d): + if d.get('_pk'): + return cls.objects.get(pk=d['_pk']) + + device = Device() + + for k, v in d: + if k.startswith('_'): + continue + setattr(device, k, v) + + return device + + def to_gsx(self): + if len(self.imei): + return gsxws.Product(self.imei) + return gsxws.Product(self.sn) + + @classmethod + def from_gsx(cls, sn, device=None, cached=True): + """ + Initialize new Device with warranty info from GSX + Or update existing one + """ + sn = sn.upper() + cache_key = 'device-%s' % sn + + # Only cache unsaved devices + if cached and device is None: + if cache.get(cache_key): + return cache.get(cache_key) + + arg = gsxws.validate(sn) + + if arg not in ("serialNumber", "alternateDeviceId",): + raise ValueError(_(u"Invalid input for warranty check: %s") % sn) + + product = gsxws.Product(sn) + wty = product.warranty() + model = product.model() + + if device is None: + # serialNumber may sometimes come back empty + serial_number = wty.serialNumber or sn + device = Device(sn=serial_number) + + if device.notes == '': + device.notes = wty.notes or '' + device.notes += wty.csNotes or '' + + device.has_onsite = product.has_onsite + device.is_vintage = product.is_vintage + device.description = product.description + device.fmip_active = product.fmip_is_active + + device.slug = slugify(device.description) + device.configuration = wty.configDescription or '' + device.purchase_country = wty.purchaseCountry or '' + + device.config_code = model.configCode + device.product_line = model.productLine.replace(" ", "") + device.parts_and_labor_covered = product.parts_and_labor_covered + + device.sla_description = wty.slaGroupDescription or '' + device.contract_start_date = wty.contractCoverageStartDate + device.contract_end_date = wty.contractCoverageEndDate + device.onsite_start_date = wty.onsiteStartDate + device.onsite_end_date = wty.onsiteEndDate + + if wty.estimatedPurchaseDate: + device.purchased_on = wty.estimatedPurchaseDate + + device.image_url = wty.imageURL or '' + device.manual_url = wty.manualURL or '' + device.exploded_view_url = wty.explodedViewURL or '' + + if wty.warrantyStatus: + device.set_wty_status(wty.warrantyStatus) + + if product.is_ios: + ad = device.get_activation() + device.imei = ad.imeiNumber or '' + device.unlocked = product.is_unlocked(ad) + device.applied_activation_policy = ad.appliedActivationDetails or '' + device.initial_activation_policy = ad.initialActivationPolicyDetails or '' + device.next_tether_policy = ad.nextTetherPolicyDetails or '' + + cache.set(cache_key, device) + + return device + + def is_mac(self): + """ + Returns True if this is a Mac + """ + p = gsxws.Product(self.sn) + p.description = self.description + return p.is_mac + + def is_ios(self): + """ + Returns True if this is an iOS device + """ + p = gsxws.Product(self.sn) + p.description = self.description + return p.is_ios + + def update_gsx_details(self): + Device.from_gsx(self.sn, self) + self.save() + + def get_image_url(self): + url = 'https://static.servoapp.com/images/products/%s.jpg' % self.slug + return self.image_url or url + + def get_photo(self): + try: + return self.photo.url + except ValueError: + return self.get_image_url() + + def get_fmip_status(self): + """ + Returns the translated FMiP status + """ + return _('Active') if self.fmip_active else _('Inactive') + + def get_coverage_details(self): + details = [] + if self.sla_description: + details.append(_(u'SLA Group: %s') % self.sla_description) + if self.has_onsite: + details.append(_('This unit is eligible for Onsite Service.')) + if self.parts_and_labor_covered: + details.append(_('Parts and Labor are covered.')) + + return details + + @property + def can_create_carryin(self): + if self.description == "Non-Serialized Products": + # Non-serialized products may have more than one repair + return True + + return self.repair_set.filter(completed_at=None).count() < 1 + + def get_accessories(self, order): + return self.accessory_set.filter(order=order).values_list('name', flat=True) + + def get_activation(self): + return gsxws.Product(self.sn).activation() + + def get_diagnostics(self, user): + """ + Fetch GSX iOS or Repair diagnostics based on device type + """ + GsxAccount.default(user) + return self.to_gsx().diagnostics() + + def get_warranty(self): + return gsxws.Product(self.sn).warranty() + + def get_repairs(self): + return gsxws.Product(self.sn).repairs() + + def get_parts(self): + """ + Returns GSX parts for a product with this device's serialNumber + """ + results = {} + cache_key = "%s_parts" % self.sn + + for p in gsxws.Product(self.sn).parts(): + product = Product.from_gsx(p) + results[product.code] = product + + cache.set_many(results) + cache.set(cache_key, results.values()) + + return results.values() + + def import_parts(self): + pass + + def save(self, *args, **kwargs): + if self.sn: + self.sn = self.sn.strip().upper() + + self.description = self.description.strip() + if self.slug is None: + self.slug = slugify(self.description) + + return super(Device, self).save(*args, **kwargs) + + def get_absolute_url(self): + return reverse('devices-view_device', args=[self.product_line, self.slug, self.pk]) + + def get_purchase_country(self): + # Return device's purchase country, can be 2-letter code (from checkin) or + # full country name (from GSX) + from django_countries import countries + + if len(self.purchase_country) > 2: + return self.purchase_country + + return countries.countries.get(self.purchase_country, '') + + def __unicode__(self): + return '%s (%s)' % (self.description, self.sn) + + class Meta: + app_label = "servo" + get_latest_by = "id" + + +@receiver(post_save, sender=Device) +def device_saved(sender, instance, created, **kwargs): + # make sure we have this tag and product category + if created: + DeviceGroup.objects.get_or_create(title=instance.description) + + # Update order descriptions + for o in instance.order_set.all(): + o.description = instance.description + o.save() diff --git a/servo/models/escalations.py b/servo/models/escalations.py new file mode 100644 index 0000000..f937946 --- /dev/null +++ b/servo/models/escalations.py @@ -0,0 +1,125 @@ +# -*- 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 gsxws +from gsxws.escalations import Context + +from django.db import models +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from servo import defaults +from servo.models import GsxAccount, User, Attachment + + +class Escalation(models.Model): + """ + Escalation/Note + """ + escalation_id = models.CharField( + default='', + max_length=22, + editable=False + ) + gsx_account = models.ForeignKey( + GsxAccount, + default=defaults.gsx_account, + verbose_name=_('GSX Account'), + ) + contexts = models.TextField(default='{}', blank=True) + issue_type = models.CharField( + default='', + blank=True, + max_length=4, + choices=gsxws.escalations.ISSUE_TYPES + ) + status = models.CharField( + max_length=1, + choices=gsxws.escalations.STATUSES, + default=gsxws.escalations.STATUS_OPEN + ) + submitted_at = models.DateTimeField(null=True) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey(User, editable=False, null=True) + + def is_submitted(self): + return self.submitted_at is not None + + def to_gsx(self): + self.gsx_account.connect(self.created_by) + esc = gsxws.escalations.Escalation() + + note = self.note_set.latest() + esc.notes = note.body + + try: + attachment = note.attachments.latest() + f = attachment.content.file.name + a = gsxws.escalations.FileAttachment(f) + esc.attachment = a + except Attachment.DoesNotExist: + pass + + return esc + + def get_escalation(self): + esc = gsxws.escalations.Escalation() + esc.escalationId = self.escalation_id + return esc + + def update(self, note): + esc = self.to_gsx() + esc.escalationId = self.escalation_id + esc.status = self.status + + return esc.update() + + def submit(self): + esc = self.to_gsx() + esc.shipTo = self.gsx_account.ship_to + esc.issueTypeCode = self.issue_type + + if len(self.contexts) > 2: + ec = [] + for k, v in json.loads(self.contexts).items(): + ec.append(Context(k, v)) + + esc.escalationContext = ec + + result = esc.create() + self.submitted_at = timezone.now() + self.escalation_id = result.escalationId + + self.save() + + @property + def subject(self): + return _(u'Escalation %s') % self.escalation_id + + class Meta: + app_label = "servo" + diff --git a/servo/models/invoices.py b/servo/models/invoices.py new file mode 100644 index 0000000..301753a --- /dev/null +++ b/servo/models/invoices.py @@ -0,0 +1,226 @@ +# -*- 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.db import models +from django.conf import settings +from django.utils import timezone +from django.contrib.sites.models import Site +from django.utils.translation import ugettext_lazy as _ + +from django.dispatch import receiver +from django.db.models.signals import post_save + +from django.contrib.sites.managers import CurrentSiteManager + +from servo import defaults +from servo.models import User, Customer, Order, ServiceOrderItem, AbstractOrderItem + + +class Invoice(models.Model): + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + created_at = models.DateTimeField(editable=False, auto_now_add=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, editable=False) + + PAYMENT_METHODS = ( + (0, _("No Charge")), + (1, _("Cash")), + (2, _("Invoice")), + (3, _("Credit Card")), + (4, _("Mail payment")), + (5, _("Online payment")) + ) + + payment_method = models.IntegerField( + editable=False, + choices=PAYMENT_METHODS, + default=PAYMENT_METHODS[0][0], + verbose_name=_("Payment Method") + ) + + is_paid = models.BooleanField(default=False, verbose_name=_("paid")) + paid_at = models.DateTimeField(null=True, editable=False) + order = models.ForeignKey(Order, editable=False) + customer = models.ForeignKey( + Customer, + null=True, + editable=False, + on_delete=models.SET_NULL + ) + + # We remember the following the following so that the customer info + # on the invoice doesn't change if the customer is modified or deleted + customer_name = models.CharField( + max_length=255, + default=_("Walk-in"), + verbose_name=_("Name") + ) + customer_phone = models.CharField( + null=True, + blank=True, + max_length=128, + verbose_name=_("Phone") + ) + customer_email = models.CharField( + null=True, + blank=True, + max_length=128, + verbose_name=_("Email") + ) + customer_address = models.CharField( + null=True, + blank=True, + max_length=255, + verbose_name=_("Address") + ) + reference = models.CharField( + null=True, + blank=True, + max_length=255, + verbose_name=_("Reference") + ) + + total_net = models.DecimalField(max_digits=8, decimal_places=2) # total w/o taxes + total_tax = models.DecimalField(max_digits=8, decimal_places=2) # total taxes + total_gross = models.DecimalField(max_digits=8, decimal_places=2) # total with taxes + + total_margin = models.DecimalField( + max_digits=8, + decimal_places=2, + editable=False + ) + + objects = CurrentSiteManager() + + def get_payment_total(self): + from django.db.models import Sum + result = self.payment_set.all().aggregate(Sum('amount')) + return result['amount__sum'] + + def get_payment_methods(self): + """ + Returns the different payment methods used in this invoice + """ + payments = self.payment_set.all() + return [x.get_method_display() for x in payments] + + def dispatch(self, products): + for p in products: + soi = ServiceOrderItem.objects.get(pk=p) + InvoiceItem.from_soi(soi, self) + + soi.product.sell(soi.amount, self.order.location) + soi.dispatched = True + soi.save() + + def get_absolute_url(self): + from django.core.urlresolvers import reverse + return reverse("invoices-view_invoice", args=[self.pk]) + + class Meta: + ordering = ('-id', ) + app_label = 'servo' + get_latest_by = "id" + + +class InvoiceItem(AbstractOrderItem): + invoice = models.ForeignKey(Invoice) + price = models.DecimalField( + max_digits=8, + decimal_places=2, + verbose_name=_("Sales Price") + ) + + @classmethod + def from_soi(cls, soi, invoice, invoice_item=None): + """ + Copies SalesOrderItem into an InvoiceItem + """ + if invoice_item: + i = invoice_item + else: + i = cls(invoice=invoice) + + i.sn = soi.sn + i.code = soi.code + i.title = soi.title + i.price = soi.price + i.amount = soi.amount + i.product = soi.product + i.description = soi.description + i.created_by = invoice.created_by + i.save() + return i + + class Meta: + app_label = "servo" + + +class Payment(models.Model): + invoice = models.ForeignKey(Invoice) + METHODS = ( + (0, _("No Charge")), + (1, _("Cash")), + (2, _("Invoice")), + (3, _("Credit Card")), + (4, _("Mail payment")), + (5, _("Online payment")) + ) + method = models.IntegerField( + choices=METHODS, + default=METHODS[0][0], + verbose_name=_("Payment Method") + ) + created_by = models.ForeignKey(User) + created_at = models.DateTimeField(auto_now_add=True) + amount = models.DecimalField(max_digits=8, decimal_places=2) + + class Meta: + app_label = "servo" + + +@receiver(post_save, sender=Invoice) +def trigger_order_dispatched(sender, instance, created, **kwargs): + if created: + description = _(u'Order %s dispatched') % instance.order.code + instance.order.notify('dispatched', description, instance.created_by) + +@receiver(post_save, sender=Payment) +def trigger_payment_received(sender, instance, created, **kwargs): + if created: + invoice = instance.invoice + + if instance.method > 0: + description = _(u'Payment for %0.2f received') % instance.amount + invoice.order.notify('paid', description, instance.created_by) + + if invoice.paid_at is None: + if invoice.get_payment_total() == invoice.total_gross: + invoice.paid_at = timezone.now() + invoice.save() diff --git a/servo/models/note.py b/servo/models/note.py new file mode 100644 index 0000000..cbdea2f --- /dev/null +++ b/servo/models/note.py @@ -0,0 +1,617 @@ +# -*- 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 base64 +import urllib + +from django.db import models, IntegrityError + +from django.conf import settings +from django.utils import timezone +from django.core.cache import cache +from django.dispatch import receiver +from django.utils.html import strip_tags +from django.core.files.base import ContentFile +from django.core.exceptions import ValidationError + +from django.utils.translation import ugettext_lazy as _ +from django.core.urlresolvers import reverse + +from django.core.mail import send_mail, EmailMessage + +from django.contrib.sites.models import Site + +from django.contrib.contenttypes.fields import GenericRelation + +from django.template.defaultfilters import truncatechars +from django.db.models.signals import pre_delete, post_save + +from mptt.managers import TreeManager +from django.contrib.sites.managers import CurrentSiteManager + +from mptt.models import MPTTModel, TreeForeignKey + +from servo import defaults +from servo.lib.shorturl import from_time + +from servo.models.order import Order +from servo.models.account import User +from servo.models.customer import Customer +from servo.models.escalations import Escalation +from servo.models.common import Configuration, Tag, Attachment, Event + + +SMS_ENCODING = 'ISO-8859-15' +COOKIE_REGEX = r'\(SRO#([\w/]+)\).*$' + + +class UnsavedForeignKey(models.ForeignKey): + # A ForeignKey which can point to an unsaved object + allow_unsaved_instance_assignment = True + + +def clean_phone_number(number): + return re.sub(r'[\+\s\-]', '', number).strip() + + +def validate_phone_number(number): + match = re.match(r'([\+\d]+$)', number) + if match: + return match.group(1).strip() + else: + raise ValidationError(_(u'%s is not a valid phone number') % number) + + +class Note(MPTTModel): + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + subject = models.CharField( + blank=True, + max_length=255, + default=defaults.subject, + verbose_name=_('Subject'), + ) + + body = models.TextField(verbose_name=_('Message')) + + code = models.CharField( + unique=True, + max_length=9, + editable=False, + default=from_time + ) + sender = models.CharField( + default='', + max_length=255, + verbose_name=_('From') + ) + recipient = models.CharField( + blank=True, + default='', + max_length=255, + verbose_name=_('To') + ) + customer = models.ForeignKey(Customer, null=True, blank=True) + escalation = UnsavedForeignKey(Escalation, null=True, editable=False) + labels = models.ManyToManyField(Tag, blank=True, limit_choices_to={'type': 'note'}) + + events = GenericRelation(Event) + attachments = GenericRelation(Attachment, null=True, blank=True) + parent = TreeForeignKey( + 'self', + null=True, + blank=True, + related_name='replies' + ) + + created_at = models.DateTimeField(auto_now_add=True, editable=False) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, editable=False) + sent_at = models.DateTimeField(null=True, editable=False) + order = models.ForeignKey(Order, null=True, blank=True) + + is_reported = models.BooleanField(default=False, verbose_name=_("report")) + is_read = models.BooleanField( + default=True, + editable=False, + verbose_name=_("read") + ) + is_flagged = models.BooleanField( + default=False, + editable=False, + verbose_name=_("flagged") + ) + + objects = TreeManager() + on_site = CurrentSiteManager() + + def __render__(self, tpl, ctx): + from django import template + tpl = template.Template(tpl) + return tpl.render(template.Context(ctx)) + + def render_subject(self, ctx): + """ + Renders this Markdown body + """ + self.subject = self.__render__(self.subject, ctx) + return self.subject + + def render_body(self, ctx): + """ + Renders this Markdown body + """ + self.body = self.__render__(self.body, ctx) + return self.body + + def add_reply(self, note): + note.parent = self + note.order = self.order + note.escalation = self.escalation + + def zip_attachments(self): + pass + + def get_default_sender(self): + return Configuration.get_default_sender(self.created_by) + + def get_sender_choices(self): + """ + Returns the options for this note's senders + """ + choices = [] + addresses = [] + user = self.created_by + loc = user.location + def_email = self.get_default_sender() + + if user.email: + user_choice = (user.email, u'%s <%s>' % (user.get_name(), user.email),) + choices.append(user_choice) + addresses.append(user.email) + + if loc.email and loc.email not in addresses: + loc_choice = (loc.email, u'%s <%s>' % (loc.title, loc.email),) + choices.append(loc_choice) + addresses.append(loc.email) + + if def_email and def_email not in addresses: + def_choice = (def_email, _(u'Default Address <%s>') % def_email,) + choices.append(def_choice) + + return choices + + def quote(self): + return "> " + self.body + + def unquote(self): + return re.sub(r'^>.*', '', self.body, flags=re.MULTILINE).strip() + + def clean_subject(self): + return re.sub(COOKIE_REGEX, '', self.subject) + + def get_excluded_emails(self): + """ + Returns a list of email addresses that should not be contacted + """ + if not cache.get('nomail'): + User.refresh_nomail() + + return cache.get('nomail') + + def get_classes(self): + """ + Returns the appropriate CSS classes for this note + """ + classes = list() + + if not self.is_read: + classes.append('info') + + if self.is_reported: + classes.append('success') + + if self.is_flagged: + classes.append('warning') + + return ' '.join(classes) + + def find_parent(self, txt): + cookie = re.search(r'\(SRO#([\w/]+)\)', txt) + + if not cookie: + return + + parent_code, order_code = cookie.group(1).split('/') + + try: + parent = Note.objects.get(code=parent_code) + self.parent = parent + self.recipient = parent.sender + self.order_id = parent.order_id + except Note.DoesNotExist: + # original note has been deleted + self.order = Order.objects.get(url_code=order_code) + + @classmethod + def from_email(cls, msg, user): + """ + Creates a new Note from an email message + """ + note = cls(sender=msg['From'], created_by=user) + + note.is_read = False + note.is_reported = False + note.recipient = msg['To'] + note.subject = msg['Subject'] + + note.find_parent(note.subject) + + for part in msg.walk(): + t, s = part.get_content_type().split('/', 1) + charset = part.get_content_charset() or "latin1" + + if t == "text": + payload = part.get_payload(decode=True) + note.body = unicode(payload, str(charset), "ignore") + if s == "html": + note.body = strip_tags(note.body) + else: + note.save() + if part.get_filename(): + filename = unicode(part.get_filename()) + content = base64.b64decode(part.get_payload()) + content = ContentFile(content, filename) + attachment = Attachment(content=content, content_object=note) + attachment.save() + attachment.content.save(filename, content) + note.attachments.add(attachment) + + if not note.parent: + # cookie not found in the subject, let's try the body... + note.find_parent(note.body) + + note.save() + + return note + + def get_sender_name(self): + name = self.created_by.get_full_name() + if not name: + name = self.created_by.username + + return name + + def get_flags(self): + return ['unread', 'flagged', 'reported'] + + def get_reported_title(self): + return _("As Unreported") if self.is_reported else _("As Reported") + + def get_read_title(self): + return _("As Unread") if self.is_read else _("As Read") + + def get_flagged_title(self): + return _("As Unflagged") if self.is_flagged else _("As Flagged") + + def mailto(self): + """ + Returns the email recipients of this note + Don't use validate_email because addresses may also be in + Name format (replies to emails) + """ + to = [] + recipients = [r.strip() for r in self.recipient.split(',')] + for r in recipients: + m = re.search(r'([\w\.\-_]+@[\w\.\-_]+)', r, re.IGNORECASE) + if m: + to.append(m.group(0)) + + return ','.join(to) + + def get_indent(self): + return (self.level*20)+10 + + def notify(self, action, message, user): + e = Event(content_object=self, action=action) + e.description = message + e.triggered_by = user + e.save() + + def get_edit_url(self): + if self.order: + return reverse('orders-edit_note', args=[self.order.pk, self.pk]) + + def has_sent_message(self, recipient): + r = self.message_set.filter(recipient=recipient) + return r.exclude(status='FAILED').exists() + + def send_mail(self, user): + """ + Sends this note as an email + """ + mailto = self.mailto() + + # Only send the same note once + if self.has_sent_message(mailto): + raise ValueError(_('Already sent message to %s') % mailto) + + config = Configuration.conf() + smtp_host = config.get('smtp_host').split(':') + settings.EMAIL_HOST = smtp_host[0] + + if len(smtp_host) > 1: + settings.EMAIL_PORT = int(smtp_host[1]) + + settings.EMAIL_USE_TLS = config.get('smtp_ssl') + settings.EMAIL_HOST_USER = str(config.get('smtp_user')) + settings.EMAIL_HOST_PASSWORD = str(config.get('smtp_password')) + + headers = {} + headers['Reply-To'] = self.sender + headers['References'] = '%s.%s' % (self.code, self.sender) + subject = u'%s (SRO#%s)' % (self.subject, self.code) + + if self.order: + # Encode the SO code so that we can match replies to the SO + # even if the original note has been deleted + subject = u'%s (SRO#%s/%s)' % (self.subject, + self.code, + self.order.url_code) + + recipients = mailto.split(',') + + msg = EmailMessage(subject, + self.body, + self.sender, + recipients, + headers=headers) + + for f in self.attachments.all(): + msg.attach_file(f.content.path) + + msg.send() + + for r in recipients: + msg = Message(note=self, recipient=r, created_by=user, body=self.body) + msg.sent_at = timezone.now() + msg.sender = self.sender + msg.status = 'SENT' + msg.save() + + message = _(u'Message sent to %s') % mailto + self.notify('email_sent', message, user) + return message + + def send_sms_smtp(self, config, recipient): + """ + Sends SMS through SMTP gateway + """ + recipient = recipient.replace(' ', '') + settings.EMAIL_HOST = config.get('smtp_host') + settings.EMAIL_USE_TLS = config.get('smtp_ssl') + settings.EMAIL_HOST_USER = config.get('smtp_user') + settings.EMAIL_HOST_PASSWORD = config.get('smtp_password') + + send_mail(recipient, self.body, self.sender, [config['sms_smtp_address']]) + + def send_sms_builtin(self, recipient, sender=None): + """ + Sends SMS through built-in gateway + """ + if not settings.SMS_HTTP_URL: + raise ValueError(_('System is not configured for built-in SMS support.')) + + if sender is None: + location = self.created_by.location + sender = location.title + + data = urllib.urlencode({ + 'username': settings.SMS_HTTP_USERNAME, + 'password': settings.SMS_HTTP_PASSWORD, + 'numberto': recipient.replace(' ', ''), + 'numberfrom': sender.encode(SMS_ENCODING), + 'message': self.body.encode(SMS_ENCODING), + }) + + from ssl import _create_unverified_context + f = urllib.urlopen(settings.SMS_HTTP_URL, data, context=_create_unverified_context()) + return f.read() + + def send_sms(self, number, user): + """ + Sends message as SMS + """ + number = validate_phone_number(number) + + if self.has_sent_message(number): + raise ValueError(_('Already sent message to %s') % number) + + conf = Configuration.conf() + sms_gw = conf.get('sms_gateway') + + if not sms_gw: + raise ValueError(_("SMS gateway not configured")) + + msg = Message(note=self, recipient=number, created_by=user, body=self.body) + + if sms_gw == 'hqsms': + from servo.messaging.sms import HQSMSProvider + HQSMSProvider(number, self, msg).send() + + if sms_gw == 'jazz': + from servo.messaging.sms import SMSJazzProvider + SMSJazzProvider(number, self, msg).send() + #self.send_sms_jazz(number, conf.get('sms_http_sender', ''), msg) + + if sms_gw == 'http': + from servo.messaging.sms import HttpProvider + HttpProvider(self, number).send() + + if sms_gw == 'smtp': + gw_address = conf.get('sms_smtp_address') + + if not gw_address: + raise ValueError('Missing SMTP SMS gateway address') + + self.send_sms_smtp(conf, number) + + if sms_gw == 'builtin': + self.send_sms_builtin(number) + + msg.method = 'SMS' + msg.status = 'SENT' + msg.sent_at = timezone.now() + msg.save() + + message = _('Message sent to %s') % number + self.notify('sms_sent', message, self.created_by) + return message + + def send_and_save(self, user): + """ + The main entry point to the sending logic + """ + from django.utils.encoding import force_text + messages = list() + recipients = [r.strip() for r in self.recipient.split(',')] + + for r in recipients: + try: + messages.append(self.send_sms(r, user)) + except (ValidationError, IntegrityError), e: + pass + + if self.mailto(): + messages.append(self.send_mail(user)) + + esc = self.escalation + + if esc and esc.pk and esc.issue_type: + if esc.submitted_at is None: + esc.submit() + messages.append(_('Escalation %s created') % esc.escalation_id) + else: + esc.update(self.body) + messages.append(_('Escalation %s updated') % esc.escalation_id) + + self.save() + + if len(messages) < 1: + messages = [_('Note saved')] + + return ', '.join([force_text(m) for m in messages]) + + def get_absolute_url(self): + if self.order: + return "%s#note-%d" % (self.order.get_absolute_url(), self.pk) + else: + return "/notes/saved/%d/view/" % self.pk + + def __unicode__(self): + return str(self.pk) + + class Meta: + app_label = "servo" + get_latest_by = "created_at" + + +class Message(models.Model): + """ + A note being sent by some method (SMS, email, escalation). + Only one sender and recipient per message + Keeping this separate from Note so that we can send and track + messages separately from Notes + """ + note = models.ForeignKey(Note) + code = models.CharField(unique=True, max_length=36, default=defaults.uid) + created_by = models.ForeignKey(User) + sender = models.CharField(max_length=128) + recipient = models.CharField(max_length=128) + body = models.TextField() + sent_at = models.DateTimeField(null=True) + received_at = models.DateTimeField(null=True) + STATUSES = ( + ('SENT', 'SENT'), + ('DELIVERED', 'DELIVERED'), + ('RECEIVED', 'RECEIVED'), + ('FAILED', 'FAILED'), + ) + status = models.CharField(max_length=16, choices=STATUSES) + METHODS = ( + ('EMAIL', 'EMAIL'), + ('SMS', 'SMS'), + ('GSX', 'GSX'), + ) + method = models.CharField( + max_length=16, + choices=METHODS, + default=METHODS[0][0] + ) + error = models.TextField() + + def send(self): + result = None + self.recipient = self.recipient.strip() + + try: + validate_phone_number(self.recipient) + result = self.send_sms() + except ValidationError: + pass + + try: + validate_email(self.recipient) + result = self.send_mail() + except ValidationError: + pass + + self.save() + return result + + class Meta: + app_label = "servo" + unique_together = ('note', 'recipient') + + +@receiver(pre_delete, sender=Note) +def clean_files(sender, instance, **kwargs): + instance.attachments.all().delete() + + +@receiver(post_save, sender=Note) +def note_saved(sender, instance, created, **kwargs): + if created and instance.order: + order = instance.order + user = instance.created_by + + if user is not order.user: + msg = truncatechars(instance.body, 75) + order.notify("note_added", msg, user) + diff --git a/servo/models/order.py b/servo/models/order.py new file mode 100644 index 0000000..d4f1fe3 --- /dev/null +++ b/servo/models/order.py @@ -0,0 +1,1156 @@ +# -*- 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 datetime import timedelta +from django.db import models, IntegrityError + +from django.conf import settings +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from django.contrib.sites.managers import CurrentSiteManager + +from django.contrib.contenttypes.fields import GenericRelation + +from django.dispatch import receiver +from django.core.urlresolvers import reverse +from django.db.models.signals import pre_save, post_save, post_delete + +from servo import defaults +from servo.lib.shorturl import encode_url + +from servo.models.common import Tag, Location, Event, Configuration, GsxAccount +from servo.models.product import * +from servo.models.customer import Customer +from servo.models.device import Device +from servo.models.queue import Queue, Status, QueueStatus + + +class Order(models.Model): + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + code = models.CharField(max_length=8, unique=True, null=True) + url_code = models.CharField(max_length=8, unique=True, null=True) + # Device description or something else + description = models.CharField(max_length=128, default="") + status_icon = models.CharField(max_length=16, default="undefined") + + priority = models.IntegerField( + default=Queue.PRIO_NORMAL, + choices=Queue.PRIORITIES, + verbose_name=_("priority") + ) + + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + related_name="created_orders", + on_delete=models.SET_NULL + ) + + started_at = models.DateTimeField(null=True) + started_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + related_name="started_orders", + on_delete=models.SET_NULL + ) + + closed_at = models.DateTimeField(null=True) + closed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + related_name='closed_orders', + on_delete=models.SET_NULL + ) + followed_by = models.ManyToManyField( + settings.AUTH_USER_MODEL, + related_name="followed_orders" + ) + + tags = models.ManyToManyField(Tag, verbose_name="tags") + events = GenericRelation(Event) + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + on_delete=models.PROTECT + ) + + checkin_location = models.ForeignKey( + Location, + null=True, + related_name='+', + on_delete=models.SET_NULL + ) + + location = models.ForeignKey(Location, on_delete=models.PROTECT) + checkout_location = models.ForeignKey( + Location, + null=True, + related_name='+', + on_delete=models.SET_NULL + ) + + place = models.CharField(default='', max_length=128) + customer = models.ForeignKey( + Customer, + null=True, + related_name='orders', + on_delete=models.SET_NULL + ) + customer_name = models.CharField(max_length=128, default='') + + devices = models.ManyToManyField(Device, through="OrderDevice") + products = models.ManyToManyField(Product, through="ServiceOrderItem") + + queue = models.ForeignKey( + Queue, + null=True, + verbose_name=_("queue"), + on_delete=models.SET_NULL + ) + status = models.ForeignKey( + QueueStatus, + null=True, + verbose_name=_("status"), + on_delete=models.SET_NULL + ) + + statuses = models.ManyToManyField( + Status, + through="OrderStatus", + related_name="orders" + ) + + STATE_QUEUED = 0 # order hasn't been started + STATE_OPEN = 1 # order is being worked on + STATE_CLOSED = 2 # order is closed + + STATES = ( + (STATE_QUEUED, _("Unassigned")), + (STATE_OPEN, _("Open")), + (STATE_CLOSED, _("Closed")) + ) + + state = models.IntegerField(default=0, choices=STATES) + + status_name = models.CharField(max_length=128, default="") + status_started_at = models.DateTimeField(null=True) + status_limit_green = models.DateTimeField(null=True) # turn yellow after this + status_limit_yellow = models.DateTimeField(null=True) # turn red after this + + objects = CurrentSiteManager() + api_fields = ('status_name', 'status_description',) + + def apply_rules(self): + pass + + def get_accessories(self): + return Accessory.objects.filter(order=self) + + def set_accessories(self, accs, device): + for a in accs: + a = Accessory(name=a, order=self, device=device) + a.save() + + def add_tag(self, tag, user): + from servo.tasks import apply_rules + + if not isinstance(tag, Tag): + tag = Tag.objects.get(pk=tag) + + self.tags.add(tag) + + event = Event(content_object=self) + event.description = str(tag.pk) + event.action = "set_tag" + event.triggered_by = user + + apply_rules(event) + + def set_tags(self, tags, user): + return [self.add_tag(t, user) for t in tags] + + def check_in(self, user): + """ + Checks this Order in through the check-in process + """ + queue_id = Configuration.conf('checkin_queue') + self.set_queue(queue_id, user) + + def can_order_products(self): + return self.products.count() > 0 and self.is_editable + + def duplicate(self, user): + new_order = Order(customer=self.customer, created_by=user) + new_order.save() + new_order.set_queue(self.queue_id, user) + + for d in self.devices.all(): + new_order.add_device(d, user) + + return new_order + + def get_print_template(self, kind="confirmation"): + template = "orders/print_%s.html" % kind + + if self.queue: + queue = self.queue + + if kind == "confirmation" and queue.order_template: + template = queue.order_template.name + if kind == "quote" and queue.quote_template: + template = queue.quote_template.name + if kind == "receipt" and queue.receipt_template: + template = queue.receipt_template.name + if kind == "dispatch" and queue.dispatch_template: + template = queue.dispatch_template.name + + return template + + def get_repairs(self): + # Returns the active GSX repairs for this SO + return self.repair_set.exclude(submitted_at=None) + + def get_repair(self): + # Returns the latest GSX repair for this SO + try: + return self.get_repairs().latest() + except Exception: + return + + def get_similar(self, status, state): + # Returns a queryset of "similar" cases + if self.user is None: + return Order.objects.filter(status=status) + + return Order.objects.filter(user=self.user) + + def get_footer(self): + footer = self.code + repair = self.get_repair() + if repair: + footer += ' (%s)' % repair.confirmation + return footer + + def add_device(self, device, user): + try: + OrderDevice.objects.create(order=self, device=device) + event = _(u'%s added') % device.description + self.notify('device_added', event, user) + return event + except IntegrityError: + raise ValueError(_("This device has already been added to this order")) + + def add_device_sn(self, sn, user): + """ + Adds device to order using serial number + """ + sn = sn.upper() + try: + device = Device.objects.get(sn=sn) + except Device.DoesNotExist: + device = Device.from_gsx(sn) + device.save() + + self.add_device(device, user) + return device + + def remove_device(self, device, user): + OrderDevice.objects.filter(order=self, device=device).delete() + msg = _(u'%s removed') % device.description + self.notify('device_removed', msg, user) + return msg + + def get_available_users(self, user): + """ + Returns a list of users available to work on this order + """ + if self.queue: + return self.queue.user_set.filter(is_active=True) + + return user.location.user_set.filter(is_active=True) + + def get_title(self): + """ + Returns a human-readable title for this order, based on various criteria + """ + from django.utils.timesince import timesince + now = timezone.now() + moment_seconds = 120 + + if self.closed_at: + if (now - self.closed_at).seconds < moment_seconds: + return _("Closed a moment ago") + return _(u"Closed for %(time)s") % {'time': timesince(self.closed_at)} + + if self.status and self.status_started_at is not None: + if (now - self.status_started_at).seconds < moment_seconds: + return _(u"%s a moment ago") % self.status.status.title + delta = timesince(self.status_started_at) + d = {'status': self.status.status.title, 'time': delta} + return _("%(status)s for %(time)s" % d) + + if self.user is None: + if (now - self.created_at).seconds < moment_seconds: + return _("Created a moment ago") + return _("Unassigned for %(delta)s") % {'delta': timesince(self.created_at)} + + if self.started_at and self.user is not None: + if (now - self.started_at).seconds < moment_seconds: + return _("Started a moment ago") + return _("Open for %(delta)s") % {'delta': timesince(self.started_at)} + + def get_place(self): + return self.place or _("Select place") + + def get_status(self): + return self.status or _("Select status") + + def get_user_name(self): + if self.user is not None: + return self.user.get_full_name() + + def get_user(self): + return self.user or _("Select user") + + def get_queue(self): + return self.queue or _("Select queue") + + def get_queue_url(self): + return reverse("orders-index") + + def get_queue_title(self): + if self.queue: + return self.queue.title + else: + return _("Orders") + + def is_item_complete(self, item): + try: + return self.checklistitemvalue_set.get(item=item) + except Exception: + return False + + def close(self, user): + self.notify("close_order", _(u"Order %s closed") % self.code, user) + self.closed_by = user + self.closed_at = timezone.now() + self.state = self.STATE_CLOSED + self.save() + + if Configuration.autocomplete_repairs(): + for r in self.repair_set.filter(completed_at=None): + r.close(user) + + if self.queue and self.queue.status_closed: + self.set_status(self.queue.status_closed, user) + + def reopen(self, user): + self.state = Order.STATE_OPEN + self.closed_at = None + self.save() + msg = _("Order %s reopened") % self.code + self.notify("reopen", msg, user) + return msg + + def notes(self): + return self.note_set.all() + + def reported_notes(self): + return self.note_set.filter(is_reported=True) + + @property + def can_create_carryin(self): + return self.customer and self.queue and self.is_editable + + def get_status_name(self): + try: + return self.status.status.title + except Exception: + pass + + def get_status_description(self): + try: + return self.status.status.description + except Exception: + pass + + def get_status_id(self): + """ + Returns "real" status ID of this order (regardless of queue) + """ + if self.status: + return self.status.status.id + + def get_next(self): + try: + result = Order.objects.filter(pk__gt=self.pk, queue=self.queue) + return result[0].pk + except Exception: + pass + + def get_prev(self): + try: + result = Order.objects.filter(pk__lt=self.pk, queue=self.queue) + return result[0].pk + except Exception: + pass + + def get_color(self): + color = "undefined" + if self.status: + now = timezone.now() + if now > self.status_limit_yellow: + color = "danger" + if now < self.status_limit_yellow: + color = "warning" + if now < self.status_limit_green: + color = "success" + + return color + + def get_status_img(self): + color = self.get_color() + return "images/status_%s_16.png" % color + + def set_property(self, key, value): + pass + + def set_location(self, new_location, user): + self.location = new_location + msg = _(u"Order %s moved to %s") % (self.code, new_location.title) + self.notify("set_location", msg, user) + self.save() + return msg + + def set_checkin_location(self, new_location, user): + pass + + def notify(self, action, message, user): + """ + Notifies this order of an event + This is also the hub for automation handling + """ + if self.is_closed: + return + + e = Event(content_object=self, action=action) + e.description = message + e.triggered_by = user + e.save() + + for f in self.followed_by.exclude(pk=user.pk).exclude(should_notify=False): + e.notify_users.add(f) + + if action == "product_arrived": + if self.queue and self.queue.status_products_received: + new_status = self.queue.status_products_received + self.set_status(new_status, user) + + def set_status(self, new_status, user): + """ + Sets status of this order to new_status + Status can only be set if order belongs to a queue! + """ + if self.is_closed: + return # fail silently + + if self.queue is None: + raise ValueError(_('Order must belong to a queue to set status')) + + if isinstance(new_status, QueueStatus): + status = new_status + else: + if int(new_status) == 0: + return self.unset_status(user) + + status = QueueStatus.objects.get(pk=new_status) + + self.status = status + self.status_name = status.status.title + self.status_started_at = timezone.now() + + self.status_limit_green = status.get_green_limit() + self.status_limit_yellow = status.get_yellow_limit() + self.save() + + # Set up the OrderStatus + OrderStatus.create(self, status, user) + + # trigger the notification + self.notify("set_status", self.status_name, user) + + def unset_status(self, user): + if self.is_closed: + return # fail silently + + self.status = None + self.status_started_at = None + self.status_limit_green = None + self.status_limit_yellow = None + self.save() + + self.notify("set_status", _('Status unassigned'), user) + + def set_queue(self, queue_id, user): + if self.is_closed: + return + + if queue_id in (None, ''): + queue_id = 0 + + if isinstance(queue_id, Queue): + queue = queue_id + else: + if int(queue_id) == 0: + return self.unset_queue(user) + + queue = Queue.objects.get(pk=queue_id) + + self.queue = queue + self.priority = queue.priority + self.notify('set_queue', queue.title, user) + + if queue.gsx_soldto: + gsx_account = GsxAccount.get_account(self.location, queue) + for i in self.repair_set.filter(completed_at=None): + i.gsx_account = gsx_account + i.save() + + if queue.status_created: + self.set_status(queue.status_created, user) + else: + self.save() + + def unset_queue(self, user): + self.queue = None + self.notify('set_queue', _('Removed from queue'), user) + self.save() + + def add_follower(self, follower): + if follower in self.followed_by.all(): + return + + self.followed_by.add(follower) + + def remove_follower(self, follower): + if self.state == self.STATE_CLOSED: + raise ValueError(_('Closed orders cannot be modified')) + + self.followed_by.remove(follower) + + def toggle_follower(self, follower): + if follower in order.followed_by.all(): + self.remove_follower(user) + else: + self.add_follower(follower) + + def set_user(self, new_user, current_user): + """ + Sets the assignee of this order to new_user + """ + if self.state == self.STATE_CLOSED: + raise ValueError(_('Closed orders cannot be modified')) + + state = self.STATE_OPEN + + if new_user is None: + state = self.STATE_QUEUED + event = _("Order unassigned") + self.remove_follower(self.user) + else: + data = {'order': self.code, 'user': new_user.get_full_name()} + event = _(u"Order %(order)s assigned to %(user)s") % data + # The assignee should also be a follower + self.add_follower(new_user) + + self.user = new_user + self.state = state + + self.notify("set_user", event, current_user) + + if self.user is not None: + self.location = new_user.location + if self.started_by is None: + self.started_by = new_user + self.started_at = timezone.now() + queue = self.queue + if queue and queue.status_assigned: + self.set_status(queue.status_assigned, current_user) + + self.save() + + def customer_id(self): + return self.customer.id + + def customer_list(self): + """ + Returns this order's customer wrapped in a list for easier + tree recursion + """ + return [self.customer] + + def customer_tree(self): + if self.customer is None: + return '0' + else: + return self.customer.tree_id + + def has_devices(self): + return self.devices.all().count() > 0 + + def device_name(self): + if self.devices.count(): + return self.devices.all()[0].description + + def set_customer(self, new_customer): + self.customer = new_customer + self.save() + + def get_customer_name(self): + try: + return self.customer.fullname + except AttributeError: + pass + + def device_slug(self): + try: + return self.devices.all()[0].slug + except Exception: + return None + + def net_total(self): + total = 0 + + for p in self.serviceorderitem_set.filter(should_report=True): + total += p.price_notax() * p.amount + + return total + + def gross_total(self): + total = 0 + + for p in self.serviceorderitem_set.filter(should_report=True): + total += p.price * p.amount + + return total + + def total_tax(self): + return self.gross_total() - self.net_total() + + def add_product(self, product, amount, user): + """ + Adds this product to the Service Order with stock price + """ + oi = ServiceOrderItem(order=self, created_by=user) + oi.product = product + oi.code = product.code + oi.title = product.title + oi.description = product.description + oi.amount = amount + + oi.price_category = 'stock' + oi.price = product.price_sales_stock + + oi.save() + + self.notify("product_added", _('Product %s added') % oi.title, user) + + return oi + + def remove_product(self, oi, user): + oi.delete() + msg = _('Product %s removed from order') % oi.title + self.notify("product_removed", msg, user) + return msg + + @property + def can_dispatch(self): + undispatched = self.products.filter(dispatched=False) + return undispatched.count() > 0 and self.is_editable + + @property + def can_close(self): + return self.is_editable and not self.can_dispatch + + def dispatch(self, invoice, products): + """ + Dispatch these products from the inventory with this invoice + """ + invoice.dispatch(products) + if self.queue and self.queue.status_dispatched: + self.set_status(self.queue.status_dispatched, invoice.created_by) + + def total_margin(self): + """ + Calculates the total margin for this Service Order + """ + total_purchase_price = 0 + for p in self.serviceorderitem_set.filter(should_report=True): + total_purchase_price += p.get_product_price('purchase') * p.amount + + return (self.net_total() - Decimal(total_purchase_price)) + + @property + def products(self): + return self.serviceorderitem_set.filter(should_report=True) + + @property + def serialized_products(self): + return self.products.filter(product__is_serialized=True) + + def get_parts(self): + """ + Returns the GSX parts that can be ordered for this SRO + """ + return [x for x in self.products.all() if x.product.is_apple_part] + + @property + def has_parts(self): + return len(self.get_parts()) > 0 + + def get_devices(self): + return self.devices.filter(orderdevice__should_report=True) + + def get_first_device(self): + try: + return self.get_devices().first() + except Exception: + pass + + @property + def is_editable(self): + return self.closed_at is None + + @property + def is_closed(self): + return self.closed_at is not None + + @property + def has_products(self): + return self.products.count() > 0 + + def has_accessories(self): + return self.accessory_set.all().count() > 0 + + def get_accessories(self): + return self.accessory_set.values_list('name', flat=True) + + class Meta: + app_label = 'servo' + ordering = ('-priority', 'id',) + + permissions = ( + ("change_user", _("Can set assignee")), + ("change_status", _("Can change status")), + ("follow_order", _("Can follow order")), + ("copy_order", _("Can copy order")), + ("batch_process", _("Can batch process")), + ) + + def get_absolute_url(self): + return reverse("orders-edit", args=[self.pk]) + + def __unicode__(self): + return self.code + + +class AbstractOrderItem(models.Model): + """ + The base class for order lines (purchase, sales) + """ + product = models.ForeignKey(Product, on_delete=models.PROTECT) + code = models.CharField(blank=True, default='', max_length=128) + title = models.CharField(max_length=128, verbose_name=_("title")) + description = models.TextField( + blank=True, + default='', + verbose_name=_("description") + ) + + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + editable=False, + on_delete=models.SET_NULL + ) + + amount = models.IntegerField(default=1, verbose_name=_("amount")) + sn = models.CharField( + blank=True, + default="", + max_length=32, + verbose_name=_("KGB Serial Number") + ) + + def price_notax(self): + """ + Returns the price of this OI w/o VAT + """ + from decimal import ROUND_05UP + vat_pct = self.product.pct_vat + return (self.price/Decimal((100+vat_pct)/100)).quantize(Decimal('1.00')) + + def total_gross(self): + return self.price * self.amount + + def total_net(self): + return self.price_notax() * self.amount + + def total_tax(self): + """ + Returns the amount of VAT paid for this POI + """ + return (self.price - self.price_notax()) * self.amount + + class Meta: + abstract = True + + +class ServiceOrderItem(AbstractOrderItem): + """ + A product that has been added to a Service Order + """ + order = models.ForeignKey(Order) + + dispatched = models.BooleanField( + default=False, + verbose_name=_("dispatched") + ) + should_report = models.BooleanField( + default=True, + verbose_name=_("report") + ) + price = models.DecimalField( + max_digits=8, + decimal_places=2, + verbose_name=_('sales price') + ) + + replaced_at = models.DateTimeField(null=True) + replaced_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + editable=False, + related_name='replaced_parts' + ) + + kbb_sn = models.CharField( + blank=True, + default="", + max_length=32, + verbose_name=_("KBB Serial Number") + ) + + imei = models.CharField( + blank=True, + default="", + max_length=35, + verbose_name=_("IMEI") + ) + + PRICE_CATEGORIES = ( + ('warranty', _("Warranty")), + ('exchange', _("Exchange Price")), + ('stock', _("Stock Price")), + ) + + price_category = models.CharField( + max_length=32, + choices=PRICE_CATEGORIES, + default=PRICE_CATEGORIES[0], + verbose_name=_("Price category") + ) + + comptia_code = models.CharField( + blank=True, + default="", + max_length=4, + verbose_name=_("symptom code") + ) + comptia_modifier = models.CharField( + blank=True, + default="", + max_length=1, + verbose_name=_("symptom modifier") + ) + + def can_create_device(self): + pt = self.product.part_type + return pt == 'REPLACEMENT' and self.sn + + def comptia_choices(self): + if self.product is not None: + from servo.models.parts import symptom_codes + return symptom_codes(self.product.component_code) + + def get_comptia_code_display(self): + for i in self.comptia_choices(): + if i[0] == self.comptia_code: + return i[1] + + def get_comptia_modifier_display(self): + if self.comptia_modifier: + from servo.models.parts import symptom_modifiers + for m in symptom_modifiers(): + if m[0] == self.comptia_modifier: + return m[1] + + def get_part(self): + return self.servicepart_set.latest() + + def get_poitem(self): + return self.purchaseorderitem_set.latest() + + def get_repair(self): + return self.order.repair_set.latest() + + def reserve_product(self): + location = self.order.location + inventory = Inventory.objects.get(location=location, product=self.product) + inventory.amount_reserved += self.amount + inventory.save() + + def get_purchase_price(self): + """ + Returns the purchase price of this SOIs Product + """ + return self.product.get_price(self.price_category, "purchase") + + def get_product_price(self, kind): + return self.product.get_price(category=self.price_category, kind=kind) + + def save(self, *args, **kwargs): + self.sn = self.sn.upper() + self.kbb_sn = self.kbb_sn.upper() + return super(ServiceOrderItem, self).save(*args, **kwargs) + + @property + def is_abused(self): + return self.price_category == 'stock' + + @property + def is_warranty(self): + return self.price_category == 'warranty' + + def __unicode__(self): + return self.code + + class Meta: + app_label = "servo" + ordering = ('id',) + get_latest_by = "id" + + +class OrderStatus(models.Model): + """ + The M/M statuses of an order + """ + order = models.ForeignKey(Order) + status = models.ForeignKey(Status) + + started_at = models.DateTimeField(auto_now_add=True) + finished_at = models.DateTimeField(null=True) + + started_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name='+' + ) + finished_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + related_name='+' + ) + + green_limit = models.DateTimeField(null=True) + yellow_limit = models.DateTimeField(null=True) + + BADGES = ( + ('undefined', 'undefined'), + ('success', 'success'), + ('warning', 'warning'), + ('danger', 'danger'), + ) + + badge = models.CharField(choices=BADGES, default=BADGES[0][0], max_length=16) + duration = models.IntegerField(default=0) + + @classmethod + def create(cls, order, queue_status, user): + """ + Set status or order to queue_status.status + """ + new_status = queue_status.status + os = cls(order=order, status=new_status) + os.started_by = user + #os.started_at = timezone.now() + + os.green_limit = queue_status.get_green_limit() + os.yellow_limit = queue_status.get_yellow_limit() + + os.save() + + prev = os.get_previous() + + if prev is None: + return + + # set color of previous OS + if prev.finished_by is None: + prev.finished_by = user + prev.finished_at = timezone.now() + prev.duration = (prev.finished_at - prev.started_at).total_seconds() + + prev.badge = prev.get_badge() + prev.save() + + def get_badge(self): + now = timezone.now() + badge = "undefined" + if self.yellow_limit and now > self.yellow_limit: + badge = "danger" + if self.yellow_limit and now < self.yellow_limit: + badge = "warning" + if self.green_limit and now < self.green_limit: + badge = "success" + + return badge + + def get_next(self): + statuses = self.order.orderstatus_set + return statuses.filter(started_at__gt=self.started_at).order_by('id').first() + + def get_previous(self): + statuses = self.order.orderstatus_set + return statuses.filter(started_at__lt=self.started_at).order_by('id').last() + + def __unicode__(self): + return self.status.title + + class Meta: + app_label = "servo" + ordering = ('-started_at',) + get_latest_by = "started_at" + + +class OrderDevice(models.Model): + """ + A device attached to a service order + """ + order = models.ForeignKey(Order) + device = models.ForeignKey(Device) + should_report = models.BooleanField(default=True) + + def is_repeat_service(self): + from django.utils import timezone + created_at = self.order.created_at + tlimit = timezone.now() - timedelta(days=30) + orders = Order.objects.filter(orderdevice__device=self.device, + created_at__lt=created_at, + created_at__gte=tlimit) + + return orders.exclude(pk=self.order.pk).count() > 0 + + class Meta: + app_label = "servo" + unique_together = ('order', 'device', ) # Can't add the same device more than once + + +class Accessory(models.Model): + """ + An accessory that came with the device in this Service Order + """ + name = models.TextField() + qty = models.IntegerField(default=1) + device = models.ForeignKey(Device) + order = models.ForeignKey(Order) + + def __unicode__(self): + return self.name + + class Meta: + app_label = "servo" + + +@receiver(pre_save, sender=Order) +def trigger_order_presave(sender, instance, **kwargs): + instance.customer_name = '' + if instance.customer is not None: + instance.customer_name = instance.customer.fullname + + location = instance.created_by.location + + if instance.checkin_location is None: + instance.checkin_location = location + + if instance.location_id is None: + instance.location = location + + if instance.checkout_location is None: + instance.checkout_location = location + +@receiver(post_save, sender=Order) +def trigger_order_created(sender, instance, created, **kwargs): + if created: + instance.url_code = encode_url(instance.id).upper() + instance.code = settings.INSTALL_ID + str(instance.id).rjust(6, '0') + description = _('Order %s created') % instance.code + instance.notify('created', description, instance.created_by) + instance.save() + + +@receiver(post_save, sender=OrderDevice) +def trigger_orderdevice_saved(sender, instance, created, **kwargs): + order = instance.order + device = instance.device + order.description = device.description + + if order.queue is None: + pass # @TODO try to autoasign case to right queue... + + order.save() + + +@receiver(post_delete, sender=OrderDevice) +def trigger_device_removed(sender, instance, **kwargs): + try: + order = instance.order + except Order.DoesNotExist: + return # Means the whole order was deleted, not just the device + devices = order.devices.all() + if devices.count() > 0: + order.description = devices[0].description + else: + order.description = '' + + order.save() diff --git a/servo/models/parts.py b/servo/models/parts.py new file mode 100644 index 0000000..c2d8e25 --- /dev/null +++ b/servo/models/parts.py @@ -0,0 +1,407 @@ +# -*- 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 yaml +import gsxws + +from django.db import models +from django.utils import timezone +from django.core.files import File +from django.utils.translation import ugettext_lazy as _ + +from servo.models.shipments import Shipment +from servo.models.order import ServiceOrderItem +from servo.models.purchases import PurchaseOrder, PurchaseOrderItem + + +def symptom_modifiers(): + return gsxws.MODIFIERS + + +def symptom_codes(group): + """ + Return symptom codes for component group + """ + if group == '': + return + + data = yaml.load(open("servo/fixtures/comptia.yaml", "r")) + symptoms = data[group]['symptoms'] + srted = sorted(symptoms) + codes = [(k, "%s - %s " % (k, symptoms[k])) for k in srted] + return codes + + +class ServicePart(models.Model): + """ + Stores the data necessary to connect our ServiceOrderItems + with the corresponding GSX parts + """ + repair = models.ForeignKey("Repair", editable=False) + order_item = models.ForeignKey(ServiceOrderItem, editable=False) + purchase_order = models.ForeignKey(PurchaseOrder, null=True, editable=False) + + comptia_code = models.CharField( + max_length=4, + editable=False, + verbose_name=_("Symptom Code") + ) + comptia_modifier = models.CharField( + max_length=1, + editable=False, + verbose_name=_("Symptom Modifier") + ) + + # maps to partsInfo/orderLineNumber + line_number = models.SmallIntegerField(null=True, editable=False) + registered_for_return = models.BooleanField(default=False) + returned_at = models.DateTimeField(null=True, editable=False) + + ship_to = models.CharField(max_length=18, editable=False) + part_title = models.CharField(max_length=128) + part_number = models.CharField(max_length=18) + service_order = models.CharField(max_length=10) + return_order = models.CharField(max_length=10, default='') + + # maps Return Status (Known Bad Board) + return_status = models.CharField(default='', max_length=128, editable=False) + # maps Return Code (KBB, NRET) + return_code = models.CharField(default='', max_length=4, editable=False) + # maps to GSX Order Status + order_status = models.CharField(default='', max_length=128, editable=False) + # maps to GSX Order Status Code + order_status_code = models.CharField(default='', max_length=4, editable=False) + + COVERAGE_STATUS_CHOICES = ( + ('CC', _('Custom Bid Contracts')), + ('CS', _('Customer Satisfaction')), + ('DO', _('DOA Coverage')), + ('LI', _('Apple Limited Warranty')), + ('MU', _('Missing Upon First Use')), + ('OO', _('Out of Warranty (No Coverage)')), + ('PA', _('AppleCare Parts Agreement')), + ('PP', _('AppleCare Protection Plan')), + ('QP', _('Quality Program')), + ('RA', _('AppleCare Repair Agreement')), + ('RE', _('Repeat Service')), + ('PT', _('Additional Part Coverage')), + ('EC', _('Additional Service Coverage')), + ('C1', _('NEW - AppleCare Protection Plan')), + ('VW', _('Consumer Law Coverage')), + ) + """ + coverage_status = models.CharField( + default='', + max_length=3, + choices=COVERAGE_STATUS_CHOICES + ) + """ + coverage_description = models.CharField( + default='', + max_length=128, + editable=False + ) + + shipment = models.ForeignKey(Shipment, null=True) + box_number = models.PositiveIntegerField(null=True) + return_label = models.FileField( + null=True, + editable=False, + upload_to="return_labels" + ) + carrier_url = models.CharField(default='', max_length=255, editable=False) + + def get_symptom_code_display(self): + codes = self.get_comptia_symptoms() or [] + try: + return [c[1] for c in codes if c[0] == self.comptia_code][0] + except IndexError: + return self.comptia_code + + def get_symptom_modifier_display(self): + mods = symptom_modifiers() + try: + return [m[1] for m in mods if m[0] == self.comptia_modifier][0] + except IndexError: + return self.comptia_modifier + + @property + def reference(self): + return self.repair.reference + + @classmethod + def from_soi(cls, repair, soi): + """ + Creates and returns a ServicePart from a repair and ServiceOrderItem + """ + part = cls(repair=repair, order_item=soi) + part.part_title = soi.title + part.part_number = soi.code + part.service_order = soi.order.code + part.ship_to = repair.gsx_account.ship_to + part.comptia_code = soi.comptia_code + part.comptia_modifier = soi.comptia_modifier + return part + + def order(self, user, po=None): + """ + Purchase this Service Part + """ + if po is None: + po = PurchaseOrder() + po.location = user.get_location() + po.sales_order = self.repair.order + po.reference = self.repair.reference + po.confirmation = self.repair.confirmation + po.created_by = user + po.supplier = "Apple" + po.save() + + self.purchase_order = po + poi = PurchaseOrderItem(purchase_order=po) + poi.code = self.part_number + poi.title = self.part_title + poi.order_item = self.order_item + poi.product = self.order_item.product + poi.price = self.order_item.get_purchase_price() + + poi.save() + + if po.submitted_at is None: + po.submit(user) + + self.save() + + def is_replacement(self): + return self.order_item.product.part_type == 'REPLACEMENT' + + def mark_doa(self): + """ + Marking a part DOA means we get a new part, so: + - make a copy of the old part + """ + # Update our PO so we know to expect the replacement for the DOA part + poi = PurchaseOrderItem(price=0, purchase_order=self.purchase_order) + poi.product = self.order_item.product + poi.order_item = self.order_item + poi.ordered_at = timezone.now() + poi.save() + + # Create a copy of this part and reset + new_part = self + new_part.pk = None + new_part.shipment = None + new_part.line_number = None + new_part.returned_at = None + + new_part.return_order = '' + new_part.order_status = '' + new_part.return_label = None + new_part.order_status_code = '' + new_part.coverage_description = '' + new_part.registered_for_return = False + + new_part.save() + + def set_part_details(self, gsx_part): + """ + Updates this part to match the info from gsx_part + """ + self.comptia_code = gsx_part.comptiaCode or '' + self.return_order = gsx_part.returnOrderNumber or '' + self.comptia_modifier = gsx_part.comptiaModifier or '' + + self.order_status = gsx_part.orderStatus or '' + self.order_status_code = gsx_part.orderStatusCode or '' + self.coverage_description = gsx_part.partCoverageDescription or '' + + self.return_code = gsx_part.returnCode or '' + self.return_status = gsx_part.returnStatus or '' + self.carrier_url = gsx_part.carrierURL or '' + self.line_number = gsx_part.orderLineNumber + + return self + + def update_part(self, return_data, return_type, user): + """ + gsx/returns/Parts Return Update + """ + self.repair.connect_gsx(user) + + p = {'partNumber': self.part_number, 'returnType': return_type} + p.update(return_data) + part = gsxws.RepairOrderLine(**p) + ret = gsxws.Return() + + ret.update_parts(self.repair.confirmation, [part]) + + if return_type == Shipment.RETURN_DOA: + self.mark_doa() + + def can_return(self): + return not self.return_order == '' + + def get_return_title(self): + if self.registered_for_return: + return _("Unregister from Return") + + return _("Register for Return") + + def register_for_return(self, user): + """ + Registers this part for the current bulk return + """ + location = user.get_location() + ship_to = self.repair.gsx_account.ship_to + shipment = Shipment.get_current(user, location, ship_to) + shipment.toggle_part(self) + + def to_gsx(self): + """ + Returns a GSX ServicePart entry for this part + """ + part = gsxws.ServicePart(self.part_number) + part.returnOrderNumber = self.return_order + if self.box_number > 0: + part.boxNumber = self.box_number + return part + + def needs_comptia_code(self): + """ + CompTIA not required for Replacement and Other category parts. + In practice this is here for Adjustment-type parts (#011-0663) + """ + return self.order_item.product.part_type != 'ADJUSTMENT' + + def get_repair_order_line(self): + """ + Returns GSX RepairOrderLine entry for this part + """ + ol = gsxws.RepairOrderLine() + ol.partNumber = self.part_number + + oi = self.order_item + ol.abused = oi.is_abused + + if self.needs_comptia_code(): + ol.comptiaCode = self.comptia_code + ol.comptiaModifier = self.comptia_modifier + + device = self.repair.device + + # Warranty only when warranty price (0) and no damage + # warranty means outOfWarrantyFlag=False + if device and not oi.is_abused: + if device.has_warranty: + ol.returnableDamage = not oi.is_warranty + + return ol + + def get_comptia_symptoms(self): + """ + Returns the appropriate CompTIA codes for this part + """ + product = self.order_item.product + return symptom_codes(product.component_code) + + def get_return_label(self): + """ + Returns the GSX return label for this part + """ + if self.return_label.name == "": + # Return label not yet set, get it... + label = gsxws.Return(self.return_order).get_label(self.part_number) + filename = "%s_%s.pdf" % (self.return_order, self.part_number) + + f = File(open(label.returnLabelFileData)) + self.return_label = f + self.save() + self.return_label.save(filename, f) + + return self.return_label.read() + + def update_module_sn(self): + """ + Updates the GSX module serial numbers + """ + part = gsxws.ServicePart(self.order_item.code) + part.oldSerialNumber = self.order_item.kbb_sn + part.serialNumber = self.order_item.sn + part.reasonCode = "OT" + part.isPartDOA = "N" + repair = self.repair.get_gsx_repair() + return repair.update_sn([part]) + + def update_replacement_sn(self): + """ + Updates the Whole-Unit swap KGB SN + With the user's own GSX credentials, falling back to the defaults + """ + repair = self.repair.get_gsx_repair() + return repair.update_kgb_sn(self.order_item.sn) + + def can_update_sn(self): + soi = self.order_item + return not soi.sn == '' + + def update_sn(self): + # CTS parts not eligible for SN update + if self.return_status == 'Convert To Stock': + return + + if not self.repair.confirmation: + raise ValueError(_('GSX repair has no dispatch ID')) + + product = self.order_item.product + + if not product.is_serialized: + return + + if product.part_type == "MODULE": + self.update_module_sn() + elif product.part_type == "REPLACEMENT": + self.update_replacement_sn() + + def lookup(self): + return gsxws.Part(partNumber=self.part_number).lookup() + + def save(self, *args, **kwargs): + if self.comptia_code is None: + oi = self.order_item + self.comptia_code = oi.comptia_code + self.comptia_modifier = oi.comptia_modifier + + super(ServicePart, self).save(*args, **kwargs) + + def __unicode__(self): + return u'ServicePart %s' % self.part_number + + class Meta: + app_label = "servo" + get_latest_by = "id" + ordering = ("order_item",) + # A part can only appear once per shipment + unique_together = ("id", "shipment",) diff --git a/servo/models/product.py b/servo/models/product.py new file mode 100644 index 0000000..b3bd787 --- /dev/null +++ b/servo/models/product.py @@ -0,0 +1,654 @@ +# -*- 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 +from os.path import basename + +from django.db import models +from django.db import connection +from django.conf import settings +from django.core.files import File +from django.core.cache import cache +from decimal import Decimal, ROUND_CEILING +from django.core.urlresolvers import reverse + +from django.contrib.contenttypes.fields import GenericRelation + +from django.contrib.sites.managers import CurrentSiteManager + +from django.contrib.sites.models import Site +from django.utils.translation import ugettext_lazy as _ + +from mptt.models import MPTTModel, TreeForeignKey +from mptt.managers import TreeManager +from gsxws import comptia, parts, validate + +from servo import defaults +from servo.lib.shorturl import from_time +from servo.models import Configuration, Location, TaggedItem + + +def get_margin(price=0.0): + """ + Returns the proper margin % for this price + """ + price = Decimal(price) + margin = defaults.margin() + + try: + return Decimal(margin) + except Exception: + ranges = margin.split(';') + for r in ranges: + m = re.search(r'(\d+)\-(\d+)=(\d+)', r) + p_min, p_max, margin = m.groups() + if Decimal(p_min) <= price <= Decimal(p_max): + return Decimal(margin) + + return Decimal(margin) + + +def default_vat(): + conf = Configuration.conf() + return conf.get("pct_vat", 0.0) + + +def inventory_totals(): + """ + Returns the total purchase and sales value + of our inventory + """ + cursor = connection.cursor() + sql = """SELECT SUM(price_purchase_stock*total_amount) AS a, + SUM(price_sales_stock*total_amount) AS b + FROM servo_product + WHERE part_type != 'SERVICE'""" + cursor.execute(sql) + + for k, v in cursor.fetchall(): + return (k, v) + + +class DeviceGroup(models.Model): + """ + This links products with devices. + The title should match a device's description field + """ + title = models.CharField(max_length=128, unique=True) + + class Meta: + app_label = "servo" + + +class AbstractBaseProduct(models.Model): + code = models.CharField( + unique=True, + max_length=32, + default=from_time, + verbose_name=_("code") + ) + + subst_code = models.CharField( + default='', + max_length=32, + editable=False, + verbose_name=_("Substituted (new) code of this part") + ) + + title = models.CharField( + max_length=255, + default=_("New Product"), + verbose_name=_("Title") + ) + description = models.TextField( + default='', + blank=True, + verbose_name=_("Description") + ) + pct_vat = models.DecimalField( + max_digits=4, + decimal_places=2, + default=default_vat, + verbose_name=_("VAT %") + ) + + fixed_price = models.BooleanField( + default=False, + help_text=_("Don't update price when recalculating prices or importing parts") + ) + + price_purchase_exchange = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Purchase price") + ) + + pct_margin_exchange = models.DecimalField( + max_digits=4, + decimal_places=2, + default=get_margin, + verbose_name=_("Margin %") + ) + price_notax_exchange = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Net price"), + help_text=_("Purchase price + margin %") + ) + price_sales_exchange = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Sales price"), + help_text=_("Purchase price + margin % + shipping + VAT %") + ) + + price_purchase_stock = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Purchase price") + ) + pct_margin_stock = models.DecimalField( + max_digits=4, + decimal_places=2, + default=get_margin, + verbose_name=_("Margin %") + ) + price_notax_stock = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Net price"), + help_text=_("Purchase price + margin %") + ) + price_sales_stock = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Sales price"), + help_text=_("Purchase price + margin % + shipping + VAT %") + ) + + is_serialized = models.BooleanField( + default=False, + verbose_name=_('is serialized'), + help_text=_("Product has a serial number") + ) + + class Meta: + app_label = 'servo' + abstract = True + + +class Product(AbstractBaseProduct): + + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + warranty_period = models.PositiveIntegerField( + default=0, + verbose_name=_("Warranty (months)") + ) + shelf = models.CharField( + default='', + blank=True, + max_length=8, + verbose_name=_("Shelf code") + ) + brand = models.CharField( + default='', + blank=True, + max_length=32, + verbose_name=_("Brand") + ) + categories = models.ManyToManyField( + "ProductCategory", + blank=True, + verbose_name=_("Categories") + ) + device_models = models.ManyToManyField( + "DeviceGroup", + blank=True, + verbose_name=_("device models") + ) + tags = GenericRelation(TaggedItem) + photo = models.ImageField( + null=True, + blank=True, + upload_to="products", + verbose_name=_("photo") + ) + + shipping = models.FloatField(default=0, verbose_name=_('shipping')) + + # component code is used to identify Apple parts + component_code = models.CharField( + blank=True, + default='', + max_length=1, + choices=comptia.GROUPS, + verbose_name=_("component group") + ) + + labour_tier = models.CharField(max_length=15, blank=True, default='') + + # We need this to call the correct GSX SN Update API + PART_TYPES = ( + ('ADJUSTMENT', _("Adjustment")), + ('MODULE', _("Module")), + ('REPLACEMENT', _("Replacement")), + ('SERVICE', _("Service")), + ('SERVICE CONTRACT', _("Service Contract")), + ('OTHER', _("Other")), + ) + + part_type = models.CharField( + max_length=18, + default='OTHER', + choices=PART_TYPES, + verbose_name=_("part type") + ) + + eee_code = models.CharField( + blank=True, + default='', + max_length=256, + verbose_name=_("EEE code") + ) + + total_amount = models.IntegerField(editable=False, default=0) + + def get_pick_url(self, order, device=None): + pk = self.pk or self.code + return '/orders/%d/devices/%d/' + + def is_service(self): + return self.part_type == 'SERVICE' + + def get_warranty_display(self): + if self.warranty_period: + return _("%d months") % self.warranty_period + + def can_order_from_gsx(self): + return self.component_code and self.part_type in ("MODULE", "REPLACEMENT", "OTHER",) + + def can_update_price(self): + return self.can_order_from_gsx() and not self.fixed_price + + def update_price(self, new_product=None): + """ + Updates part's price info from GSX or to match new_product + """ + if new_product is None: + part = parts.Part(partNumber=self.code).lookup() + new_product = Product.from_gsx(part) + + self.price_purchase_exchange = new_product.price_purchase_exchange + self.price_purchase_stock = new_product.price_purchase_stock + + self.title = new_product.title + + self.component_code = new_product.component_code + + self.price_notax_stock = new_product.price_notax_stock + self.price_notax_exchange = new_product.price_notax_exchange + + self.pct_margin_stock = new_product.pct_margin_stock + self.pct_margin_exchange = new_product.pct_margin_exchange + + self.price_sales_stock = new_product.price_sales_stock + self.price_sales_exchange = new_product.price_sales_exchange + + self.save() + + def calculate_price(self, price, shipping=0.0): + """ + Calculates price and returns it w/ and w/o tax + """ + conf = Configuration.conf() + shipping = shipping or 0.0 + + if not isinstance(shipping, Decimal): + shipping = Decimal(shipping) + + margin = get_margin(price) + vat = Decimal(conf.get("pct_vat", 0.0)) + + # TWOPLACES = Decimal(10) ** -2 # same as Decimal('0.01') + # @TODO: make rounding configurable! + wo_tax = ((price*100)/(100-margin)+shipping).to_integral_exact(rounding=ROUND_CEILING) + with_tax = (wo_tax*(vat+100)/100).to_integral_exact(rounding=ROUND_CEILING) + + return wo_tax, with_tax + + def set_stock_sales_price(self): + if not self.price_purchase_stock or self.fixed_price: + return + + purchase_sp = self.price_purchase_stock + sp, vat_sp = self.calculate_price(purchase_sp, self.shipping) + self.price_notax_stock = sp + self.price_sales_stock = vat_sp + + def set_exchange_sales_price(self): + if not self.price_purchase_exchange or self.fixed_price: + return + + purchase_ep = self.price_purchase_exchange + ep, vat_ep = self.calculate_price(purchase_ep, self.shipping) + self.price_notax_exhcange = ep + self.price_sales_exchange = vat_ep + + @property + def is_apple_part(self): + return validate(self.code, 'partNumber') + + @classmethod + def from_gsx(cls, part): + """ + Creates a Servo Product from GSX partDetail. + We don't do GSX lookups here since we can't + determine the correct GSX Account at this point. + """ + conf = Configuration.conf() + + try: + shipping = Decimal(conf.get("shipping_cost")) + except TypeError: + shipping = Decimal(0.0) + + part_number = part.originalPartNumber or part.partNumber + product = Product(code=part_number) + product.title = part.partDescription + + if part.originalPartNumber: + product.subst_code = part.partNumber + + if part.stockPrice and not product.fixed_price: + # calculate stock price + purchase_sp = part.stockPrice or 0.0 + purchase_sp = Decimal(purchase_sp) + sp, vat_sp = product.calculate_price(purchase_sp, shipping) + product.pct_margin_stock = get_margin(purchase_sp) + product.price_notax_stock = sp + product.price_sales_stock = vat_sp + # @TODO: make rounding configurable + product.price_purchase_stock = purchase_sp.to_integral_exact( + rounding=ROUND_CEILING + ) + + try: + # calculate exchange price + purchase_ep = part.exchangePrice or 0.0 + purchase_ep = Decimal(purchase_ep) + + if purchase_ep > 0 and not product.fixed_price: + ep, vat_ep = product.calculate_price(purchase_ep, shipping) + product.price_notax_exchange = ep + product.price_sales_exchange = vat_ep + product.pct_margin_exchange = Configuration.get_margin(purchase_ep) + # @TODO: make rounding configurable + product.price_purchase_exchange = purchase_ep.to_integral_exact( + rounding=ROUND_CEILING + ) + except AttributeError: + pass # Not all parts have exchange prices + + product.brand = "Apple" + product.shipping = shipping + product.warranty_period = 3 + + product.labour_tier = part.laborTier + product.part_type = part.partType.upper() + + # eee and componentCode are sometimes missing + if part.eeeCode: + product.eee_code = str(part.eeeCode).strip() + if part.componentCode: + product.component_code = str(part.componentCode).strip() + + product.is_serialized = part.isSerialized + return product + + @classmethod + def from_cache(cls, code): + data = cache.get(code) + return cls.from_gsx(data) + + def get_photo(self): + try: + return self.photo.url + except ValueError: + from django.conf import settings + return "%simages/na.gif" % settings.STATIC_URL + + def tax(self): + return self.price_sales - self.price_notax + + def latest_date_sold(self): + return '-' + + def latest_date_ordered(self): + return '-' + + def latest_date_arrived(self): + return '-' + + def sell(self, amount, location): + """ + Deduct product from inventory with specified location + """ + track_inventory = Configuration.track_inventory() + + if self.part_type == "SERVICE" or not track_inventory: + return + + try: + inventory = Inventory.objects.get(product=self, location=location) + inventory.amount_stocked = inventory.amount_stocked - amount + inventory.save() + except Inventory.DoesNotExist: + raise ValueError(_(u"Product %s not found in inventory.") % self.code) + + def get_relative_url(self): + if self.pk is None: + return "code/%s/" % self.code + + return self.pk + + def get_absolute_url(self): + if self.pk is None: + return reverse("products-view_product", kwargs={'code': self.code}) + return reverse("products-view_product", kwargs={'pk': self.pk}) + + def get_amount_stocked(self, user): + """ + Returns the amount of this product in the same location as the user. + Caches the result for faster access later. + """ + amount = 0 + track_inventory = Configuration.track_inventory() + + if not track_inventory: + return 0 + + if self.part_type == "SERVICE" or not self.pk: + return 0 + + cache_key = "product_%d_amount_stocked" % self.pk + + if cache.get(cache_key): + return cache.get(cache_key) + + location = user.get_location() + + try: + inventory = Inventory.objects.get(product=self, location=location) + amount = inventory.amount_stocked + except Inventory.DoesNotExist: + pass + + cache.set(cache_key, amount) + return amount + + def get_price(self, category=None, kind="sales"): + """ + Returns price of product in specific price category + of the specified kind (sales or purchase) + """ + prices = dict( + warranty=0.0, + exchange=float(getattr(self, "price_%s_exchange" % kind)), + stock=float(getattr(self, "price_%s_stock" % kind)) + ) + + return prices.get(category) if category else prices + + def get_pk(self): + return self.pk or Product.objects.get(code=self.code).pk + + def update_photo(self): + if self.component_code and not self.photo: + try: + part = parts.Part(partNumber=self.code) + result = part.fetch_image() + filename = basename(result) + self.photo.save(filename, File(open(result))) + except Exception, e: + print e + + def __unicode__(self): + return u'%s %s' % (self.code, self.title) + + class Meta: + ordering = ('-id',) + app_label = 'servo' + permissions = ( + ("change_amount", _("Can change product amount")), + ) + + +class ProductCategory(MPTTModel): + + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + + title = models.CharField(max_length=255, default=_("New Category")) + slug = models.SlugField(null=True, editable=False) + parent = TreeForeignKey( + 'self', + null=True, + blank=True, + related_name='children' + ) + + objects = TreeManager() + on_site = CurrentSiteManager() + + def get_products(self): + return Product.objects.filter( + categories__lft__gte=self.lft, + categories__rght__lte=self.rght, + categories__tree_id=self.tree_id + ) + + def get_product_count(self): + count = self.product_set.count() + return count if count > 0 else "" + + def get_absolute_url(self): + return "/sales/products/%s/" % self.slug + + def save(self, *args, **kwargs): + from django.utils.text import slugify + self.slug = slugify(self.title[:50]) + return super(ProductCategory, self).save(*args, **kwargs) + + def __unicode__(self): + return self.title + + class Meta: + app_label = "servo" + get_latest_by = "id" + ordering = ("-title",) + unique_together = ("title", "site", ) + + +class Inventory(models.Model): + """ + Inventory tracks how much of Product X is in Location Y + """ + product = models.ForeignKey(Product) + location = models.ForeignKey(Location) + + amount_minimum = models.PositiveIntegerField( + default=0, + verbose_name=_("minimum amount") + ) + amount_reserved = models.PositiveIntegerField( + default=0, + verbose_name=_("reserved amount") + ) + amount_stocked = models.IntegerField( + default=0, + verbose_name=_("stocked amount") + ) + amount_ordered = models.PositiveIntegerField( + default=0, + verbose_name=_("ordered amount") + ) + + def save(self, *args, **kwargs): + super(Inventory, self).save(*args, **kwargs) + total_amount = 0 + for i in self.product.inventory_set.all(): + total_amount += i.amount_stocked + self.product.total_amount = total_amount + self.product.save() + + class Meta: + app_label = "servo" + unique_together = ('product', 'location',) + + +class ShippingMethod(models.Model): + """ + How the contents of an order should be shipped + """ + title = models.CharField(max_length=128, default=_('New Shipping Method')) + description = models.TextField(default='', blank=True) + notify_email = models.EmailField(null=True, blank=True) + + class Meta: + app_label = "servo" diff --git a/servo/models/purchases.py b/servo/models/purchases.py new file mode 100644 index 0000000..4b9a886 --- /dev/null +++ b/servo/models/purchases.py @@ -0,0 +1,353 @@ +# -*- 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.db import models +from django.conf import settings +from django.utils import timezone +from django.contrib.sites.models import Site +from django.utils.translation import ugettext_lazy as _ + +from django.dispatch import receiver +from django.db.models.signals import post_save + +from servo import defaults +from servo.models.common import Location, Configuration +from servo.models.product import Product, Inventory +from servo.models.order import Order, AbstractOrderItem, ServiceOrderItem + + +class PurchaseOrder(models.Model): + """ + A purchase order(PO) consists of different purchase order items + all of which may reference individual Service Orders. + When a PO is submitted, the included items are registered + to the /products/incoming/ list (items that have not yet arrived). + A PO cannot be edited after it's been submitted. + + Creating a PO from an SO only creates the PO, it does not submit it. + """ + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + location = models.ForeignKey( + Location, + editable=False, + help_text=_('The location from which this PO was created') + ) + sales_order = models.ForeignKey(Order, null=True, editable=False) + reference = models.CharField( + blank=True, + default='', + max_length=32, + verbose_name=_("reference"), + ) + confirmation = models.CharField( + blank=True, + default='', + max_length=32, + verbose_name=_("confirmation"), + ) + + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, editable=False) + created_at = models.DateTimeField(auto_now_add=True, editable=False) + submitted_at = models.DateTimeField(null=True, editable=False) + + supplier = models.CharField( + blank=True, + max_length=32, + verbose_name=_("supplier") + ) + carrier = models.CharField( + blank=True, + max_length=32, + verbose_name=_("carrier") + ) + tracking_id = models.CharField( + blank=True, + max_length=128, + verbose_name=_("tracking ID") + ) + days_delivered = models.IntegerField( + blank=True, + default=1, + verbose_name=_("delivery Time") + ) + + has_arrived = models.BooleanField(default=False) + total = models.FloatField(null=True, editable=False) + invoice_id = models.CharField(default='', max_length=10, editable=False) + invoice = models.FileField( + null=True, + editable=False, + upload_to="gsx_invoices", + help_text="Apple's sales invoice for this PO" + ) + + def only_apple_parts(self): + for p in self.purchaseorderitem_set.all(): + if not p.product.is_apple_part: + return False + return True + + @property + def is_editable(self): + return self.submitted_at is None + + def can_create_gsx_stock(self): + return self.is_editable and self.confirmation == '' + + def order_items(self, items): + pass + + def get_absolute_url(self): + from django.core.urlresolvers import reverse + if self.submitted_at: + return reverse("purchases-view_po", args=[self.pk]) + return reverse("purchases-edit_po", args=[self.pk]) + + def sum(self): + total = 0 + for p in self.purchaseorderitem_set.all(): + total += float(p.price*p.amount) + + return total + + def amount(self): + amount = 0 + for p in self.purchaseorderitem_set.all(): + amount += p.amount + + return amount + + def submit(self, user): + "Submits this Purchase Order" + if self.submitted_at is not None: + raise ValueError(_("Purchase Order %d has already been submitted") % self.pk) + + location = user.get_location() + + for i in self.purchaseorderitem_set.all(): + inventory = Inventory.objects.get_or_create(location=location, + product=i.product)[0] + inventory.amount_ordered += i.amount + inventory.save() + i.ordered_at = timezone.now() + i.save() + + self.submitted_at = timezone.now() + self.save() + + def cancel(self): + """ + Cancels this Purchase Order + Declined Repairs etc + """ + location = self.created_by.get_location() + + for i in self.purchaseorderitem_set.all(): + inventory = Inventory.objects.get(location=location, product=i.product) + inventory.amount_ordered -= i.amount + inventory.save() + i.expected_ship_date = None + i.save() + + def add_product(self, product, amount, user): + """ + Adds a product to this Purchase Order + """ + poi = PurchaseOrderItem(amount=amount, purchase_order=self) + poi.created_by = user + # adding from a Service Order + if isinstance(product, AbstractOrderItem): + poi.code = product.product.code + poi.order_item = product + poi.price = product.price + poi.product_id = product.product.id + poi.title = product.product.title + # adding from Stock + if isinstance(product, Product): + poi.code = product.code + poi.title = product.title + poi.product_id = product.id + poi.price = product.price_purchase_stock + + poi.save() + + def delete(self, *args, **kwargs): + if self.submitted_at: + raise ValueError(_('Submitted orders cannot be deleted')) + return super(PurchaseOrder, self).delete(*args, **kwargs) + + class Meta: + ordering = ('-id',) + app_label = 'servo' + + +class PurchaseOrderItem(AbstractOrderItem): + "An item being purchased" + price = models.DecimalField( + max_digits=8, + decimal_places=2, + verbose_name=_("Purchase Price"), + help_text=_("Purchase price without taxes") + ) + + purchase_order = models.ForeignKey( + PurchaseOrder, + editable=False, + verbose_name=_("Purchase Order") + ) + + order_item = models.ForeignKey(ServiceOrderItem, null=True, editable=False) + reference = models.CharField(default='', blank=True, max_length=128) + ordered_at = models.DateTimeField(null=True, editable=False) + + expected_ship_date = models.DateField(null=True, editable=False) + received_at = models.DateTimeField( + null=True, + blank=True, + editable=False, + verbose_name=_("arrived") + ) + + received_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + editable=False, + related_name='+' + ) + + @classmethod + def from_soi(cls, po, soi, user): + """ + Creates a new POI from a Sales Order item + """ + poi = cls(purchase_order=po, order_item=soi) + poi.code = soi.code + poi.title = soi.title + poi.created_by = user + + poi.price = soi.get_purchase_price() + poi.product = soi.product + + return poi + + def get_incoming_url(self): + """ + Returns the correct URL to receive this item + """ + if self.received_at is None: + date = "0000-00-00" + else: + date = self.received_at.strftime("%Y-%m-%d") + + return "/sales/shipments/incoming/%s/%d/" % (date, self.pk) + + def receive(self, user): + if self.received_at is not None: + raise ValueError(_("Product has already been received")) + self.received_at = timezone.now() + self.received_by = user + self.save() + + def save(self, *args, **kwargs): + super(PurchaseOrderItem, self).save(*args, **kwargs) + # Sync SOI and POI serial numbers + if self.order_item: + if self.order_item.sn and not self.sn: + self.sn = self.order_item.sn + else: + self.order_item.sn = self.sn + + self.order_item.save() + + class Meta: + ordering = ('id',) + app_label = 'servo' + get_latest_by = 'id' + + +@receiver(post_save, sender=PurchaseOrderItem) +def trigger_product_received(sender, instance, created, **kwargs): + + if instance.received_at is None: + return + + product = instance.product + po = instance.purchase_order + location = po.created_by.get_location() + + inventory = Inventory.objects.get_or_create(location=location, product=product)[0] + + # Receiving an incoming item + if Configuration.track_inventory(): + try: + inventory.amount_ordered -= instance.amount + inventory.amount_stocked += instance.amount + inventory.save() + except Exception: + ref = po.reference or po.confirmation + ed = {'prod': product.code, 'ref': ref} + raise ValueError(_('Cannot receive item %(prod)s (%(ref)s)') % ed) + + sales_order = instance.purchase_order.sales_order + + if sales_order is None: + return + + # Trigger status change for parts receive + if sales_order.queue: + new_status = sales_order.queue.status_products_received + if new_status and sales_order.is_editable: + user = instance.received_by or instance.created_by + sales_order.set_status(new_status, user) + + +@receiver(post_save, sender=PurchaseOrder) +def trigger_purchase_order_created(sender, instance, created, **kwargs): + + sales_order = instance.sales_order + + if sales_order is None: + return + + if not sales_order.is_editable: + return + + if created: + msg = _("Purchase Order %d created") % instance.id + sales_order.notify("po_created", msg, instance.created_by) + + # Trigger status change for GSX repair submit (if defined) + if instance.submitted_at: + if sales_order.queue: + queue = sales_order.queue + if queue.status_products_ordered: + # Queue has a status for product_ordered - trigger it + new_status = queue.status_products_ordered + sales_order.set_status(new_status, instance.created_by) diff --git a/servo/models/queue.py b/servo/models/queue.py new file mode 100644 index 0000000..d3eb0f6 --- /dev/null +++ b/servo/models/queue.py @@ -0,0 +1,296 @@ +# -*- 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 datetime import timedelta +from django.conf import settings + +from django.db import models +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ +from django.contrib.sites.models import Site +from django.contrib.sites.managers import CurrentSiteManager +from django.core.urlresolvers import reverse + +from servo import defaults +from servo.models.common import Location + + +class Queue(models.Model): + + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + + title = models.CharField( + max_length=255, + default=_('New Queue'), + verbose_name=_('Title') + ) + + keywords = models.TextField( + default='', + blank=True, + help_text=_('Orders with devices matching these keywords will be automatically assigned to this queue') + ) + + locations = models.ManyToManyField( + Location, + verbose_name=_('locations'), + help_text=_("Pick the locations you want this queue to appear in.") + ) + + description = models.TextField( + blank=True, + verbose_name=_('description') + ) + + PRIO_LOW = 0 + PRIO_NORMAL = 1 + PRIO_HIGH = 2 + + PRIORITIES = ( + (PRIO_HIGH, _("High")), + (PRIO_NORMAL, _("Normal")), + (PRIO_LOW, _("Low")) + ) + + priority = models.IntegerField( + default=PRIO_NORMAL, + choices=PRIORITIES, + verbose_name=_("priority") + ) + + status_created = models.ForeignKey( + 'QueueStatus', + null=True, + blank=True, + related_name='+', + verbose_name=_(u'Order Created'), + help_text=_("Order has ben placed to a queue") + ) + + status_assigned = models.ForeignKey( + 'QueueStatus', + null=True, + blank=True, + related_name='+', + verbose_name=_(u'Order Assigned'), + help_text=_("Order has ben assigned to a user") + ) + + status_products_ordered = models.ForeignKey( + 'QueueStatus', + null=True, + blank=True, + related_name='+', + verbose_name=_("Products Ordered"), + help_text=_("Purchase Order for this Service Order has been submitted") + ) + status_products_received = models.ForeignKey( + 'QueueStatus', + null=True, + blank=True, + related_name='+', + verbose_name=_("Products Received"), + help_text=_("Products have been received") + ) + status_repair_completed = models.ForeignKey( + 'QueueStatus', + null=True, + blank=True, + related_name='+', + verbose_name=_("Repair Completed"), + help_text=_("GSX repair completed") + ) + + status_dispatched = models.ForeignKey( + 'QueueStatus', + null=True, + blank=True, + related_name='+', + verbose_name=_("Order Dispatched") + ) + + status_closed = models.ForeignKey( + 'QueueStatus', + null=True, + blank=True, + related_name='+', + verbose_name=_("Order Closed") + ) + + gsx_soldto = models.CharField( + blank=True, + default='', + max_length=10, + verbose_name=_("Sold-To"), + help_text=_("GSX queries of an order in this queue will be made using this Sold-To") + ) + + order_template = models.FileField( + null=True, + blank=True, + upload_to="templates", + verbose_name=_("order template"), + help_text=_("HTML template for Service Order/Work Confirmation") + ) + quote_template = models.FileField( + null=True, + blank=True, + upload_to="templates", + verbose_name=_("quote template"), + help_text=_("HTML template for cost estimate") + ) + receipt_template = models.FileField( + null=True, + blank=True, + upload_to="templates", + verbose_name=_("receipt template"), + help_text=_("HTML template for Sales Order Receipt") + ) + dispatch_template = models.FileField( + null=True, + blank=True, + upload_to="templates", + verbose_name=_("dispatch template"), + help_text=_("HTML template for dispatched order") + ) + + objects = CurrentSiteManager() + + def get_admin_url(self): + return reverse('admin-edit_queue', args=[self.pk]) + + def get_order_count(self, max_state=2): + count = self.order_set.filter(state__lt=max_state).count() + return count if count > 0 else '' + + def __unicode__(self): + return self.title + + class Meta: + ordering = ['title'] + app_label = "servo" + verbose_name = _("Queue") + verbose_name_plural = _("Queues") + unique_together = ('title', 'site',) + + +class Status(models.Model): + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + + FACTORS = ( + (60, _('Minutes')), + (3600, _('Hours')), + (86400, _('Days')), + (604800, _('Weeks')), + (2419200, _('Months')), + ) + + title = models.CharField( + max_length=255, + default=_(u'New Status'), + verbose_name=_(u'name') + ) + description = models.TextField( + null=True, + blank=True, + verbose_name=_(u'description') + ) + limit_green = models.IntegerField( + default=1, + verbose_name=_(u'green limit') + ) + limit_yellow = models.IntegerField( + default=15, + verbose_name=_(u'yellow limit') + ) + limit_factor = models.IntegerField( + choices=FACTORS, + default=FACTORS[0], + verbose_name=_(u'time unit') + ) + queue = models.ManyToManyField( + Queue, + editable=False, + through='QueueStatus' + ) + + def is_enabled(self, queue): + return self in queue.queuestatus_set.all() + + def get_admin_url(self): + return reverse('admin-edit_status', args=[self.pk]) + + def __unicode__(self): + return self.title + + class Meta: + app_label = 'servo' + ordering = ('title',) + verbose_name = _('Status') + verbose_name_plural = _('Statuses') + unique_together = ('title', 'site',) + + +class QueueStatus(models.Model): + """ + A status bound to a queue. + This allows us to set time limits for each status per indiviudal queue + """ + queue = models.ForeignKey(Queue) + status = models.ForeignKey(Status) + + limit_green = models.IntegerField(default=1, verbose_name=_(u'green limit')) + limit_yellow = models.IntegerField(default=15, verbose_name=_(u'yellow limit')) + limit_factor = models.IntegerField( + choices=Status().FACTORS, + verbose_name=_(u'time unit'), + default=Status().FACTORS[0][0] + ) + + def get_green_limit(self): + """ + Gets the green time limit for this QS + """ + return timezone.now() + timedelta(seconds=self.limit_green*self.limit_factor) + + def get_yellow_limit(self): + return timezone.now() + timedelta(seconds=self.limit_yellow*self.limit_factor) + + def __unicode__(self): + return self.status.title + + class Meta: + app_label = 'servo' + # A status should only be defined once per queue + unique_together = ('queue', 'status',) diff --git a/servo/models/repair.py b/servo/models/repair.py new file mode 100644 index 0000000..c45db2f --- /dev/null +++ b/servo/models/repair.py @@ -0,0 +1,641 @@ +# -*- 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 gsxws +import os.path + +from django.db import models +from django.conf import settings +from django.utils import timezone +from django.core.urlresolvers import reverse +from django.contrib.sites.models import Site +from django.utils.translation import ugettext_lazy as _ +from django.core.validators import MaxLengthValidator +from django.contrib.sites.managers import CurrentSiteManager + +from servo import defaults +from servo.models.common import GsxAccount +from servo.models import Queue, Order, Device, Product +from servo.models.order import ServiceOrderItem +from servo.models.parts import ServicePart +from servo.models.purchases import PurchaseOrder, PurchaseOrderItem + + +class Checklist(models.Model): + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + + title = models.CharField( + max_length=255, + unique=True, + verbose_name=_("title"), + default=_('New Checklist') + ) + queues = models.ManyToManyField( + Queue, + blank=True, + verbose_name=_("queue") + ) + + enabled = models.BooleanField(default=True, verbose_name=_("Enabled")) + objects = CurrentSiteManager() + + def get_admin_url(self): + return reverse('admin-edit_checklist', args=[self.pk]) + + def __unicode__(self): + return self.title + + class Meta: + app_label = "servo" + ordering = ("title",) + verbose_name = _('Checklist') + verbose_name_plural = _('Checklists') + + +class ChecklistItem(models.Model): + checklist = models.ForeignKey(Checklist) + title = models.CharField(max_length=255, verbose_name=_("Task")) + description = models.TextField( + blank=True, + default='', + verbose_name=_('Description') + ) + """ + reported = models.BooleanField( + default=True, + verbose_name=_("Reported"), + help_text=_('Report this result to the customer') + ) + """ + + def __unicode__(self): + return self.title + + class Meta: + app_label = "servo" + + +class ChecklistItemValue(models.Model): + order = models.ForeignKey(Order) + item = models.ForeignKey(ChecklistItem) + + checked_at = models.DateTimeField(auto_now_add=True) + checked_by = models.ForeignKey(settings.AUTH_USER_MODEL) + + class Meta: + app_label = "servo" + + +class Repair(models.Model): + """ + Proxies service order data between our internal + service orders and GSX repairs + """ + order = models.ForeignKey(Order, editable=False, on_delete=models.PROTECT) + device = models.ForeignKey(Device, editable=False, on_delete=models.PROTECT) + parts = models.ManyToManyField(ServiceOrderItem, through=ServicePart) + + created_at = models.DateTimeField(auto_now_add=True, editable=False) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + editable=False, + on_delete=models.PROTECT, + related_name="created_repairs" + ) + + tech_id = models.CharField( + default='', + blank=True, + max_length=15, + verbose_name=_(u'Technician') + ) + unit_received_at = models.DateTimeField( + default=timezone.now, + verbose_name=_(u'Unit Received') + ) + submitted_at = models.DateTimeField(null=True, editable=False) + completed_at = models.DateTimeField(null=True, editable=False) + completed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + editable=False, + on_delete=models.PROTECT, + related_name="completed_repairs" + ) + request_review = models.BooleanField( + default=False, + help_text=_("Repair should be reviewed by Apple before confirmation") + ) + confirmation = models.CharField(max_length=10, default='', editable=False) + reference = models.CharField( + blank=True, + default='', + max_length=16, + verbose_name=_("Reference") + ) + + symptom = models.TextField() + diagnosis = models.TextField() + notes = models.TextField( + blank=True, + default='', + validators=[MaxLengthValidator(800)], + help_text=_("Notes are mandatory when requesting review.") + ) + status = models.CharField(default='', editable=False, max_length=128) + attachment = models.FileField( + upload_to='repairs', + null=True, + blank=True, + help_text=_('Choose files to be sent with the repair creation request') + ) + repair_number = models.CharField(default='', max_length=12, editable=False) + mark_complete = models.BooleanField( + blank=True, + default=False, + verbose_name=_("mark complete"), + help_text=_("Requires replacement serial number") + ) + replacement_sn = models.CharField( + blank=True, + default='', + max_length=18, + verbose_name=_("New serial number"), + help_text=_("Serial Number of replacement part") + ) + # the account through which this repair was submitted + gsx_account = models.ForeignKey( + GsxAccount, + editable=False, + on_delete=models.PROTECT + ) + + repair_type = models.CharField( + max_length=2, + default="CA", + editable=False, + choices=gsxws.REPAIR_TYPES + ) + + component_data = models.TextField(default='', editable=False) + consumer_law = models.NullBooleanField( + default=None, + help_text=_('Unit is eligible for consumer law coverage') + ) + + @property + def has_cl_parts(self): + """ + Returns true if this repair contains Consumer Law parts + """ + return self.servicepart_set.filter(coverage_status='VW').exists() + + @property + def can_mark_complete(self): + """ + Returns true if this repair can be marked complete after submitting + """ + parts = self.servicepart_set.all() + if len(parts) > 1: return False + replacements = [p for p in parts if p.is_replacement()] + return len(replacements) == 1 + + @classmethod + def create_from_gsx(cls, order, confirmation): + """ + Creates a new Repair for order with confirmation number + """ + try: + repair = cls.objects.get(confirmation=confirmation) + msg = {'repair': repair.confirmation, 'order': repair.order} + raise ValueError(_('Repair %(repair)s already exists for order %(order)s') % msg) + except cls.DoesNotExist: + pass + + repair = cls(order=order) + repair.confirmation=confirmation + repair.save() + repair.update_details() + repair.update_status() + return repair + + def create_purchase_order(self): + # Create local purchase order + po = PurchaseOrder(supplier="Apple", created_by=self.created_by) + po.location = self.created_by.get_location() + po.reference = self.reference + po.sales_order = self.order + po.save() + return po + + def warranty_status(self): + """ + Gets warranty status for this device and these parts + """ + self.connect_gsx(self.created_by) + product = gsxws.Product(self.device.sn) + parts = [(p.code, p.comptia_code,) for p in self.order.get_parts()] + return product.warranty(parts, self.get_received_date()) + + def is_open(self): + return self.completed_at is None + + def get_products(self): + """ + Returns the Service Order Items in this Repair + """ + return [x.order_item for x in self.servicepart_set.all()] + + def get_number(self, user=None): + return self.confirmation or _("New GSX Repair") + + def set_parts(self, parts): + ServicePart.objects.filter(repair=self).delete() + for p in parts: + part = ServicePart.from_soi(self, p) + part.save() + + def add_part(self, order_item, user): + """ + Adds this Order Item as a part to this GSX repair + """ + self.connect_gsx(user) + gsx_rep = self.get_gsx_repair() + + part = ServicePart.from_soi(self, order_item) + order_line = part.get_repair_order_line() + + gsx_rep.update({'orderLines': [order_line]}) + part.order(user) + + return part + + def add_gsx_part(self, part): + """ + Adds a part that has been added manually in GSX web UI + """ + # part has been added to the order, but not the GSX repair + try: + oi = self.order.products.get(code=part.partNumber) + except ServiceOrderItem.DoesNotExist: + new_part = ServicePart(part_number=part.partNumber) + try: + p = Product.objects.get(code=part.partNumber) + except Product.DoesNotExist: + p = Product.from_gsx(new_part.lookup()) + p.save() + + oi = self.order.add_product(p, 1, self.created_by) + + oi.comptia_code = part.comptiaCode or '' + oi.comptia_modifier = part.comptiaModifier or '' + oi.save() + + sp = ServicePart.from_soi(self, oi) + sp.set_part_details(part) + + sp.order(self.created_by) + sp.save() + + def submit(self, customer_data): + """ + Creates a new GSX repair and all the documentation that goes along with it + """ + if len(self.parts.all()) < 1: + raise ValueError(_("Please add some parts to the repair")) + + if not self.order.queue: + raise ValueError(_("Order has not been assigned to a queue")) + + + repair_data = self.to_gsx() + + if self.repair_type == "CA": + gsx_repair = gsxws.CarryInRepair(**repair_data) + if self.repair_type == "ON": + gsx_repair = gsxws.IndirectOnsiteRepair(**repair_data) + + customer_data['regionCode'] = self.gsx_account.region + gsx_repair.customerAddress = gsxws.Customer(**customer_data) + + if self.component_data: + ccd = [] + cd = json.loads(self.component_data) + for k, v in cd.items(): + ccd.append(gsxws.ComponentCheck(component=k, serialNumber=v)) + + gsx_repair.componentCheckDetails = ccd + + parts = [p.get_repair_order_line() for p in self.servicepart_set.all()] + gsx_repair.orderLines = parts + + # Submit the GSX repair request + result = gsx_repair.create() + + po = self.create_purchase_order() + + for p in self.servicepart_set.all(): + p.purchase_order = po + p.created_by = self.created_by + p.save() + + poi = PurchaseOrderItem.from_soi(po, p.order_item, self.created_by) + poi.save() + + confirmation = result.confirmationNumber + self.confirmation = confirmation + self.submitted_at = timezone.now() + + po.confirmation = confirmation + po.submit(self.created_by) + + self.save() + + msg = _(u"GSX repair %s created") % confirmation + self.order.notify("gsx_repair_created", msg, self.created_by) + + if repair_data.get("markCompleteFlag") is True: + self.close(self.created_by) + + def get_gsx_repair(self): + return gsxws.CarryInRepair(self.confirmation) + + def get_unit_received(self): + """ + Returns (as a tuple) the GSX-compatible date and time of + when this unit was received + """ + import locale + langs = gsxws.get_format('en_XXX') + ts = self.unit_received_at + loc = locale.getlocale() + # reset locale to get correct AM/PM value + locale.setlocale(locale.LC_TIME, None) + result = ts.strftime(langs['df']), ts.strftime(langs['tf']) + locale.setlocale(locale.LC_TIME, loc) + return result + + def get_received_date(self): + return self.get_unit_received()[0] + + def to_gsx(self): + """ + Returns this Repair as a GSX-compatible dict + """ + data = {'serialNumber': self.device.sn} + data['notes'] = self.notes + data['symptom'] = self.symptom + data['poNumber'] = self.reference + data['diagnosis'] = self.diagnosis + data['shipTo'] = self.gsx_account.ship_to + # checkIfOutOfWarrantyCoverage + if self.tech_id: + data['diagnosedByTechId'] = self.tech_id + + ts = self.get_unit_received() + data['unitReceivedDate'] = ts[0] + data['unitReceivedTime'] = ts[1] + + if self.attachment: + data['fileData'] = self.attachment + data['fileName'] = os.path.basename(self.attachment.name) + + if self.mark_complete: + data['markCompleteFlag'] = self.mark_complete + data['replacementSerialNumber'] = self.replacement_sn + + data['requestReviewByApple'] = self.request_review + + if self.consumer_law is not None: + data['consumerLawEligible'] = self.consumer_law + + return data + + def has_serialized_parts(self): + """ + Checks if this Repair has any serialized parts + """ + count = self.parts.filter(servicepart__order_item__product__is_serialized=True).count() + return count > 0 + + def check_components(self): + """ + Runs GSX component check for this repair's parts + """ + l = gsxws.Lookup(serialNumber=self.device.sn) + l.repairStrategy = self.repair_type + l.shipTo = self.gsx_account.ship_to + parts = [] + + for i in self.servicepart_set.all(): + part = gsxws.ServicePart(i.part_number) + part.symptomCode = i.comptia_code + parts.append(part) + + try: + r = l.component_check(parts) + except gsxws.GsxError, e: + if e.code == "COMP.LKP.004": + return # Symptom Code not required for Replacement and Other category parts. + raise e + + if r.componentDetails is None: + return + + if len(self.component_data) < 1: + d = {} + for i in r.componentDetails: + f = i.componentCode + d[f] = i.componentDescription + + self.component_data = json.dumps(d) + + return self.component_data + + def connect_gsx(self, user=None): + """ + Initialize the GSX session with the right credentials. + User can also be different from the one who initially created the repair. + """ + account = user or self.created_by + self.gsx_account.connect(account) + + def set_status(self, new_status, user): + """ + Sets the current status of this repair to new_status + and notifies the corresponding Service Order + """ + if not new_status == self.status: + self.status = new_status + self.save() + self.order.notify("repair_status_changed", self.status, user) + + def update_status(self, user): + repair = self.get_gsx_repair() + status = repair.status().repairStatus + self.set_status(status, user) + + return self.status + + def get_details(self): + repair = self.get_gsx_repair() + details = repair.details() + + if isinstance(details.partsInfo, dict): + details.partsInfo = [details.partsInfo] + + self.update_details(details) + return details + + def get_return_label(self, part): + self.get_details() + part = self.servicepart_set.get(pk=part) + return part.get_return_label() + + def update_details(self, details): + """ + Updates what local info we have about this particular GSX repair + """ + part_list = list(self.servicepart_set.all().order_by('id')) + + for i, p in enumerate(details.partsInfo): + try: + part = part_list[i] + part.set_part_details(p) + part.save() + except IndexError: # part added in GSX web ui... + self.add_gsx_part(p) + except AttributeError: # some missing attribute in set_part_details() + pass + + def get_replacement_sn(self): + """ + Try to guess replacement part's SN + """ + oi = self.order.serviceorderitem_set.filter( + product__is_serialized=True, + product__part_type="REPLACEMENT" + ) + + try: + return oi[0].sn + except IndexError: + pass + + def complete(self, user): + self.completed_at = timezone.now() + self.completed_by = user + self.save() + + queue = self.order.queue + if queue.status_repair_completed: + status = queue.status_repair_completed + self.order.set_status(status, user) + + def close(self, user): + """ + Marks this GSX repair as complete + """ + self.connect_gsx(user) + repair = self.get_gsx_repair() + + try: + # Update part serial numbers + [part.update_sn() for part in self.servicepart_set.all()] + repair.mark_complete() + except gsxws.GsxError as e: + """ + Valid GSX errors are: + 'ACT.BIN.01': Repair # provided is not valid. Please enter a valid repair #. + 'RPR.LKP.01': No Repair found matching search criteria. + 'RPR.LKP.010': No Repair found matching the search criteria. + 'RPR.COM.030': Cannot mark repair as complete for Unit $1. Repair is not open. + 'RPR.COM.036': Repair for Unit $1 is already marked as complete. + 'RPR.COM.019': This repair cannot be updated. + 'RPR.LKP.16': This Repair Cannot be Updated.Repair is not Open. + 'RPR.COM.136': Repair $1 cannot be marked complete as the Warranty + Claims Certification Form status is either Declined or Hold. + 'ENT.UPL.022': 'Confirmation # $1 does not exist.' + """ + errorlist = ( + 'ACT.BIN.01', + 'RPR.LKP.01', + 'RPR.LKP.010', + 'RPR.COM.030', + 'RPR.COM.036', + 'RPR.COM.019', + 'RPR.LKP.16', + 'RPR.COM.136', + 'ENT.UPL.022', + ) + + if e.code not in errorlist: + raise e + + status = repair.status() + self.set_status(status.repairStatus, user) + + self.complete(user) + + def duplicate(self, user): + """ + Makes a copy of this GSX Repair + """ + new_rep = Repair(order=self.order, created_by=user, device=self.device) + new_rep.repair_type = self.repair_type + new_rep.tech_id = self.tech_id + new_rep.symptom = self.symptom + new_rep.diagnosis = self.diagnosis + new_rep.notes = self.notes + new_rep.reference = self.reference + new_rep.request_review = self.request_review + new_rep.mark_complete = self.mark_complete + new_rep.unit_received_at = self.unit_received_at + new_rep.attachment = self.attachment + new_rep.gsx_account = self.gsx_account + + new_rep.save() + new_rep.set_parts(self.order.get_parts()) + + return new_rep + + def get_absolute_url(self): + if self.submitted_at is None: + return reverse('repairs-edit_repair', args=[self.order.pk, self.pk]) + return reverse('repairs-view_repair', args=[self.order.pk, self.pk]) + + def __unicode__(self): + if self.pk is not None: + return _("Repair %d") % self.pk + + class Meta: + app_label = "servo" + get_latest_by = "created_at" diff --git a/servo/models/rules.py b/servo/models/rules.py new file mode 100644 index 0000000..db54ec6 --- /dev/null +++ b/servo/models/rules.py @@ -0,0 +1,177 @@ +# -*- 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.db import models +from django.core.cache import cache + +from django.dispatch import receiver +from django.db.models.signals import post_save + +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ + +from servo.models import Event, Queue + + +class ServoModel(models.Model): + class Meta: + abstract = True + app_label = "servo" + + +class Rule(ServoModel): + description = models.CharField(max_length=128, default=_('New Rule')) + MATCH_CHOICES = ( + ('ANY', _('Any')), + ('ALL', _('All')), + ) + match = models.CharField( + max_length=3, + default='ANY', + choices=MATCH_CHOICES + ) + + def as_dict(self): + d = {'description': self.description} + d['match'] = self.match + return d + + def serialize(self): + """ + Returns this rule as a JSON-string + """ + import json + d = self.as_dict() + d['conditions'] = [] + d['actions'] = [] + + for i in self.condition_set.all(): + d['conditions'].append(i.as_dict()) + + for i in self.action_set.all(): + d['actions'].append(i.as_dict()) + + return json.dumps(d) + + + def get_name(self): + return self.description + + def get_admin_url(self): + return reverse('rules-edit_rule', args=[self.pk]) + + def apply(self, event): + order = event.content_object + for a in self.action_set.all(): + a.apply(order, event) + + def __unicode__(self): + return self.description + + +class Condition(ServoModel): + rule = models.ForeignKey(Rule) + + EVENT_MAP = { + 'device_added': 'DEVICE', + } + + KEY_CHOICES = ( + ('QUEUE', _('Queue')), + ('STATUS', _('Status')), + ('DEVICE', _('Device name')), + ('CUSTOMER_NAME', _('Customer name')), + ) + + key = models.CharField(max_length=16, choices=KEY_CHOICES) + OPERATOR_CHOICES = ( + ('^%s$', _('Equals')), + ('%s', _('Contains')), + ('%d < %d', _('Less than')), + ('%d > %d', _('Greater than')), + ) + operator = models.CharField( + max_length=4, + default='^%s$', + choices=OPERATOR_CHOICES + ) + value = models.TextField(default='') + + def as_dict(self): + d = {'key': self.key} + d['operator'] = self.operator + d['value'] = self.value + return d + + def __unicode__(self): + return '%s %s %s' % (self.key, self.operator, self.value) + + +class Action(ServoModel): + rule = models.ForeignKey(Rule) + + KEY_CHOICES = ( + ('SEND_SMS', _('Send SMS')), + ('SEND_EMAIL', _('Send email')), + ('ADD_TAG', _('Add Tag')), + ('SET_PRIO', _('Set Priority')), + ('SET_QUEUE', _('Set Queue')), + ('SET_USER', _('Assign to')), + ) + + key = models.CharField( + max_length=32, + default='SEND_EMAIL', + choices=KEY_CHOICES + ) + value = models.TextField(default='') + + def as_dict(self): + d = {'key': self.key} + d['value'] = self.value + return d + + def apply(self, order, event): + if self.key == 'SET_QUEUE': + order.set_queue(self.value, event.triggered_by) + + if self.key == 'SET_USER': + order.set_user(self.value, event.triggered_by) + + def __unicode__(self): + return '%s %s' % (self.key, self.value) + + +@receiver(post_save, sender=Event) +def process_event(sender, instance, created, **kwargs): + try: + condition = Condition.EVENT_MAP[instance.action] + print condition + for r in Rule.objects.filter(condition__key=condition): + print 'APPLYING %s' % condition + r.apply(instance) + except KeyError: + return # no mapping for this event diff --git a/servo/models/shipments.py b/servo/models/shipments.py new file mode 100644 index 0000000..2330f0d --- /dev/null +++ b/servo/models/shipments.py @@ -0,0 +1,212 @@ +# -*- 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 models + +from django.conf import settings +from django.utils import timezone +from django.core.files import File +from django.utils.translation import ugettext_lazy as _ + +from servo.models import Location + + +class Shipment(models.Model): + """ + Bulk returns + """ + RETURN_DOA = 1 # Dead On Arrival + RETURN_GPR = 2 # Good Part Return + RETURN_CTS = 3 # Convert to stock + + location = models.ForeignKey(Location, editable=False) + + ship_to = models.CharField( + default='', + max_length=10, + editable=False, + ) + + return_id = models.CharField( + null=True, + unique=True, + max_length=10, + editable=False, + help_text="The return ID returned by GSX" + ) + + tracking_id = models.CharField( + blank=True, + default='', + max_length=30, + verbose_name=_('Tracking ID'), + help_text="Carrier's tracking ID" + ) + + tracking_url = models.URLField( + null=True, + editable=False, + help_text="The tracking URL returned by GSX" + ) + + packing_list = models.FileField( + null=True, + editable=False, + upload_to='returns', + help_text="The PDF returned by GSX" + ) + + carrier = models.CharField( + blank=True, + default='', + max_length=18, + choices=gsxws.CARRIERS, + verbose_name=_('carrier') + ) + + created_at = models.DateTimeField(auto_now=True, editable=False) + + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + editable=False, + on_delete=models.SET_NULL, + related_name="created_shipments" + ) + + dispatched_at = models.DateTimeField(null=True, editable=False) + + dispatched_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + editable=False, + related_name='dispatched_shipments' + ) + + width = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_('width') + ) + + height = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_('height') + ) + + length = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_('length') + ) + + weight = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_('weight') + ) + + @classmethod + def get_current(cls, user, location, ship_to): + try: + shipment = cls.objects.get(dispatched_at=None, + location=location, + ship_to=ship_to) + except cls.DoesNotExist: + shipment = cls.objects.create(created_by=user, + location=location, + ship_to=ship_to) + + return shipment + + def toggle_part(self, part): + part.registered_for_return = not part.registered_for_return + part.save() + + if part.registered_for_return: + self.servicepart_set.add(part) + else: + self.servicepart_set.remove(part) + + def verify(self): + """ + Verifies this shipment with GSX + """ + pass + + def register_bulk_return(self, user): + """ + Registers bulk return with GSX + """ + parts = [] # Array of outbound parts in GSX format + + gsx_act = self.location.gsx_accounts.get(ship_to=self.ship_to) + gsx_act.connect(user) + + for p in self.servicepart_set.all().order_by('box_number'): + parts.append(p.to_gsx()) + + ret = gsxws.Return(shipToCode=self.ship_to) + + ret.notes = "" + ret.width = self.width + ret.length = self.length + ret.height = self.height + ret.carrierCode = self.carrier + ret.trackingNumber = self.tracking_id + ret.estimatedTotalWeight = self.weight + + result = ret.register_parts(parts) + ret.bulkReturnOrder = parts + self.dispatched_by = user + self.dispatched_at = timezone.now() + self.return_id = result.bulkReturnId + self.tracking_url = result.trackingURL + + self.save() + + filename = "bulk_return_%s.pdf" % self.return_id + self.packing_list.save(filename, File(open(result.packingList))) + + def get_absolute_url(self): + return "/products/shipments/returns/%d/" % self.pk + + def save(self, *args, **kwargs): + if not self.pk: + self.location = self.created_by.location + + super(Shipment, self).save(*args, **kwargs) + + def __unicode__(self): + return u'Shipment #%s from %s' % (self.pk, self.location.title) + + class Meta: + app_label = "servo" + get_latest_by = 'id' + ordering = ('-dispatched_at',) -- cgit v1.2.3