aboutsummaryrefslogtreecommitdiffstats
path: root/servo/models
diff options
context:
space:
mode:
authorFilipp Lepalaan <filipp@mac.com>2015-08-04 10:11:24 +0300
committerFilipp Lepalaan <filipp@mac.com>2015-08-04 10:11:24 +0300
commit63b0fc6269b38edf7234b9f151b80d81f614c0a3 (patch)
tree555de3068f33f8dddb4619349bbea7d9b7c822fd /servo/models
downloadServo-63b0fc6269b38edf7234b9f151b80d81f614c0a3.tar.gz
Servo-63b0fc6269b38edf7234b9f151b80d81f614c0a3.tar.bz2
Servo-63b0fc6269b38edf7234b9f151b80d81f614c0a3.zip
Initial commit
First public commit
Diffstat (limited to 'servo/models')
-rw-r--r--servo/models/__init__.py42
-rw-r--r--servo/models/account.py318
-rw-r--r--servo/models/calendar.py184
-rw-r--r--servo/models/common.py823
-rw-r--r--servo/models/customer.py314
-rw-r--r--servo/models/device.py523
-rw-r--r--servo/models/escalations.py125
-rw-r--r--servo/models/invoices.py226
-rw-r--r--servo/models/note.py617
-rw-r--r--servo/models/order.py1156
-rw-r--r--servo/models/parts.py407
-rw-r--r--servo/models/product.py654
-rw-r--r--servo/models/purchases.py353
-rw-r--r--servo/models/queue.py296
-rw-r--r--servo/models/repair.py641
-rw-r--r--servo/models/rules.py177
-rw-r--r--servo/models/shipments.py212
17 files changed, 7068 insertions, 0 deletions
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 <email> 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',)