Skip to content

Commit

Permalink
Merge pull request #15 from uppsaladatavetare/feature/suppliers
Browse files Browse the repository at this point in the history
Automatised inventory system (#4)
  • Loading branch information
kjagiello authored Feb 12, 2017
2 parents d9bbc7c + 20a2810 commit 76c5caf
Show file tree
Hide file tree
Showing 31 changed files with 1,168 additions and 15 deletions.
3 changes: 3 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
language: python

sudo: required

python:
- "3.4"

install:
- pip install -r requirements/test.txt
- sudo apt-get install poppler-utils

env:
matrix:
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

This repository contains the backend for the FooBar kiosk and inventory system.

## Requirements

- Python 3.4+
- Djanggo 1.9+
- [pdftotext](https://linux.die.net/man/1/pdftotext) for delivery report parsing

## Setup

$ git clone git@github.com:uppsaladatavetare/foobar-api.git
Expand Down
1 change: 1 addition & 0 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ six==1.9.0
requests==2.8.1
pytz==2015.7
raven==5.8.1
pynarlivs==0.9.0
2 changes: 1 addition & 1 deletion src/foobar/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def cancel_purchase(purchase_id, force=False):
purchase_obj.save()
# Cancel related shop item transactions
for item_trx_obj in purchase_obj.items.all():
trx_objs = shop_api.get_product_transactions_by_ref(item_trx_obj.id)
trx_objs = shop_api.get_product_transactions_by_ref(item_trx_obj)
# Only one transaction with given reference should exist
assert len(trx_objs) == 1
shop_api.cancel_product_transaction(trx_objs[0].id)
Expand Down
21 changes: 21 additions & 0 deletions src/foobar/migrations/0015_auto_20160919_1224.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2016-09-19 12:24
from __future__ import unicode_literals

from django.db import migrations
import utils.models


class Migration(migrations.Migration):

dependencies = [
('foobar', '0014_card_date_used'),
]

operations = [
migrations.AlterField(
model_name='card',
name='number',
field=utils.models.ScannerField(max_length=20, unique=True),
),
]
32 changes: 32 additions & 0 deletions src/foobar/migrations/0016_migrate_product_trx_references.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-11 21:54
from __future__ import unicode_literals

from django.db import migrations
from django.contrib.contenttypes.models import ContentType


def migrate_references(apps, schema_editor):
ProductTransaction = apps.get_model("shop", "ProductTransaction")
PurchaseItem = apps.get_model("foobar", "PurchaseItem")
ContentType = apps.get_model("contenttypes", "ContentType")
trxs = ProductTransaction.objects.all()
for trx in trxs:
if trx.reference_old:
item_obj = PurchaseItem.objects.get(id=trx.reference_old)
trx.reference_ct = ContentType.objects.get_for_model(item_obj)
trx.reference_id = item_obj.id
trx.save()


class Migration(migrations.Migration):

dependencies = [
('foobar', '0015_auto_20160919_1224'),
('shop', '0007_auto_20170212_0932'),
('contenttypes', '__first__'),
]

operations = [
migrations.RunPython(migrate_references),
]
3 changes: 3 additions & 0 deletions src/foobar/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@
THUNDERPUSH_APIKEY = os.getenv('THUNDERPUSH_APIKEY', 'foobar')
THUNDERPUSH_PROTO = os.getenv('THUNDERPUSH_PROTO', 'http')

NARLIVS_USERNAME = os.getenv('NARLIVS_USERNAME', '')
NARLIVS_PASSWORD = os.getenv('NARLIVS_PASSWORD', '')

TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
Expand Down
2 changes: 2 additions & 0 deletions src/foobar/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@
'NAME': ':memory:',
},
}

INSTALLED_APPS += ('shop.tests',)
5 changes: 5 additions & 0 deletions src/foobar/static/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,8 @@ div.breadcrumbs a:focus, div.breadcrumbs a:hover {
input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, input[type=url]:focus, input[type=number]:focus, textarea:focus, select:focus, .vTextField:focus {
border-color: #f5c8c8;
}

.object-tools a[disabled] {
background-color: #bbb !important;
cursor: default;
}
205 changes: 202 additions & 3 deletions src/shop/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,204 @@
import tempfile
from django import forms
from django.contrib import admin
from django.conf.urls import url
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from . import models
from .suppliers.base import SupplierAPIException
from . import models, api


class ProductStateFilter(admin.SimpleListFilter):
title = _('Product state')
parameter_name = 'state'

def lookups(self, request, model_admin):
return (
('unassociated', _('Unassociated products')),
)

def queryset(self, request, queryset):
if self.value() == 'unassociated':
return queryset.filter(product=None)
return queryset


@admin.register(models.SupplierProduct)
class SupplierProductAdmin(admin.ModelAdmin):
list_display = ('name', 'supplier', 'sku', 'product',)
list_filter = (ProductStateFilter, 'supplier',)
search_fields = ('name', 'sku',)
raw_id_fields = ('product',)

class Media:
css = {
'all': (
'css/hide_admin_original.css',
'css/scan_card.css',
'css/ladda-themeless.min.css',
)
}
js = (
'js/spin.min.js',
'js/ladda.min.js',
'js/sock.js',
'js/thunderpush.js',
'js/scan-card.js',
)


class DeliveryItemInline(admin.TabularInline):
fields = ('received', 'supplier_product', 'category', 'is_associated',
'qty', 'price', 'total_price',)
readonly_fields = ('total_price', 'is_associated', 'category',)
ordering = ('-supplier_product__product__category',
'supplier_product__product__name', 'received',)
verbose_name = _('Delivery item')
model = models.Delivery.items.through
raw_id_fields = ('supplier_product',)
extra = 0

def category(self, obj):
return obj.supplier_product.product.category.name

def is_associated(self, obj):
# Ugly hack for forcing django admin to display the value as a boolean
return obj.is_associated
is_associated.boolean = True

def get_readonly_fields(self, request, obj=None):
if obj and obj.locked:
return self.readonly_fields + ('supplier_product', 'qty', 'price',
'received',)
return self.readonly_fields

def has_delete_permission(self, request, obj=None):
return not obj.locked if obj is not None else True

def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('supplier_product',
'supplier_product__product',
'supplier_product__product__category',)


class DeliveryForm(forms.ModelForm):
"""Implements custom validation for the Delivery admin."""

def clean(self):
supplier = self.cleaned_data.get('supplier')
report = self.cleaned_data.get('report')
if report is not None and supplier is not None:
try:
# `report` contain most likely an in-memory file.
# Save it to a temporary file, then try to parse it.
with tempfile.NamedTemporaryFile() as f:
f.write(report.read())
items = api.parse_report(supplier.internal_name, f.name)
except SupplierAPIException as e:
raise forms.ValidationError(
_('Report parse error: %s') % str(e)
)
if not items:
raise forms.ValidationError(
_('No products could be imported from the report file.')
)
return self.cleaned_data


@admin.register(models.Delivery)
class DeliveryAdmin(admin.ModelAdmin):
list_display = ('id', 'supplier', 'total_amount', 'locked',
'date_created',)
list_filter = ('supplier', 'locked',)
inlines = (DeliveryItemInline,)
readonly_fields = ('total_amount', 'date_created', 'valid',
'error_message', 'locked',)
ordering = ('-date_created',)
actions = None
form = DeliveryForm
fieldsets = (
(None, {
'fields': (
'supplier',
'report',
'date_created',
)
}),
(_('Additional info'), {
'fields': (
'total_amount',
'locked',
('valid', 'error_message',)
)
}),

)

class Media:
css = {
'all': (
'css/hide_admin_original.css',
)
}

def valid(self, obj):
return obj.valid
valid.boolean = True

def error_message(self, obj):
if not obj.associated:
return _('Some of the products need to be associated.')
if not obj.received:
return _('Some of the products need to be marked as received.')
return '---'

def get_readonly_fields(self, request, obj=None):
if obj:
return self.readonly_fields + ('supplier', 'report',)
return self.readonly_fields

def save_model(self, request, obj, form, change):
super(DeliveryAdmin, self).save_model(request, obj, form, change)
if not change:
api.populate_delivery(obj.id)

def process_delivery(self, request, obj_id):
from django.shortcuts import get_object_or_404
from django.contrib import messages
from django.http import HttpResponseRedirect, Http404
obj = get_object_or_404(models.Delivery, id=obj_id)
if obj.locked:
raise Http404()
elif not obj.valid:
self.message_user(request, self.error_message(obj), messages.ERROR)
else:
api.process_delivery(obj.id)
msg = _('The inventory has been updated accordingly.')
self.message_user(request, msg)
url = reverse(
'admin:shop_delivery_change',
args=[obj_id],
current_app=self.admin_site.name,
)
return HttpResponseRedirect(url)

def get_urls(self):
urls = super().get_urls()
custom_urls = [
url(
r'^(?P<obj_id>.+)/process/$',
self.admin_site.admin_view(self.process_delivery),
name='delivery-process',
),
]
return custom_urls + urls

def has_delete_permission(self, request, obj=None):
# Disable deleting of processed deliveries
if obj:
return not obj.locked
return super().has_delete_permission(request, obj)


@admin.register(models.ProductCategory)
Expand All @@ -15,7 +213,9 @@ class ProductTransactionViewerInline(admin.TabularInline):
ordering = ('-date_created',)
verbose_name = _('View transaction')
verbose_name_plural = _('View transactions')
max_num = 25
max_num = 1
extra = 1
min_num = 1

def has_add_permission(self, request, obj=None):
return False
Expand Down Expand Up @@ -47,7 +247,6 @@ class ProductAdmin(admin.ModelAdmin):
readonly_fields = ('qty', 'date_created', 'date_modified',)
inlines = (
ProductTransactionCreatorInline,
ProductTransactionViewerInline,
)
fieldsets = (
(None, {
Expand Down
Loading

0 comments on commit 76c5caf

Please sign in to comment.