diff --git a/.travis.yml b/.travis.yml index b6bc97b..29690dc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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: diff --git a/README.md b/README.md index 8f11117..d041f44 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/requirements/base.txt b/requirements/base.txt index 2ab3ac9..1951ef3 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -13,3 +13,4 @@ six==1.9.0 requests==2.8.1 pytz==2015.7 raven==5.8.1 +pynarlivs==0.9.0 diff --git a/src/foobar/api.py b/src/foobar/api.py index d982cf8..d8f5a13 100644 --- a/src/foobar/api.py +++ b/src/foobar/api.py @@ -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) diff --git a/src/foobar/migrations/0015_auto_20160919_1224.py b/src/foobar/migrations/0015_auto_20160919_1224.py new file mode 100644 index 0000000..4251e1d --- /dev/null +++ b/src/foobar/migrations/0015_auto_20160919_1224.py @@ -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), + ), + ] diff --git a/src/foobar/migrations/0016_migrate_product_trx_references.py b/src/foobar/migrations/0016_migrate_product_trx_references.py new file mode 100644 index 0000000..c5d252a --- /dev/null +++ b/src/foobar/migrations/0016_migrate_product_trx_references.py @@ -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), + ] diff --git a/src/foobar/settings/base.py b/src/foobar/settings/base.py index 0f46d8f..3d1080c 100644 --- a/src/foobar/settings/base.py +++ b/src/foobar/settings/base.py @@ -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', diff --git a/src/foobar/settings/test.py b/src/foobar/settings/test.py index ecf74f0..4fef47c 100644 --- a/src/foobar/settings/test.py +++ b/src/foobar/settings/test.py @@ -6,3 +6,5 @@ 'NAME': ':memory:', }, } + +INSTALLED_APPS += ('shop.tests',) diff --git a/src/foobar/static/css/admin.css b/src/foobar/static/css/admin.css index 15eb10a..5e938d0 100644 --- a/src/foobar/static/css/admin.css +++ b/src/foobar/static/css/admin.css @@ -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; +} diff --git a/src/shop/admin.py b/src/shop/admin.py index 5bc29ff..212b5c2 100644 --- a/src/shop/admin.py +++ b/src/shop/admin.py @@ -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.+)/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) @@ -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 @@ -47,7 +247,6 @@ class ProductAdmin(admin.ModelAdmin): readonly_fields = ('qty', 'date_created', 'date_modified',) inlines = ( ProductTransactionCreatorInline, - ProductTransactionViewerInline, ) fieldsets = ( (None, { diff --git a/src/shop/api.py b/src/shop/api.py index 26a4e9a..5b8d4f9 100644 --- a/src/shop/api.py +++ b/src/shop/api.py @@ -1,5 +1,9 @@ +import logging from django.db import transaction -from . import models, enums +from django.contrib.contenttypes.models import ContentType +from . import models, enums, suppliers + +log = logging.getLogger(__name__) @transaction.atomic @@ -34,7 +38,11 @@ def get_product(id): def get_product_transactions_by_ref(reference): """Return item transactions with given reference.""" - return models.ProductTransaction.objects.filter(reference=reference) + ct = ContentType.objects.get_for_model(reference) + return models.ProductTransaction.objects.filter( + reference_ct=ct, + reference_id=reference.pk, + ) @transaction.atomic @@ -45,10 +53,14 @@ def create_product_transaction(product_id, trx_type, qty, reference=None): It automagically takes care of updating the quantity for the product. """ product_obj = models.Product.objects.get(id=product_id) + ct = None + if reference is not None: + ct = ContentType.objects.get_for_model(reference) trx_obj = product_obj.transactions.create( trx_type=trx_type, qty=qty, - reference=reference + reference_ct=ct, + reference_id=reference.pk if reference is not None else None ) return trx_obj @@ -71,3 +83,78 @@ def list_products(start=None, limit=None, **kwargs): def list_categories(): return models.ProductCategory.objects.all() + + +@transaction.atomic +def get_supplier_product(supplier_id, sku): + """Returns supplier product for given SKU. + + If the product does not exist in the local database, fetch it from the + supplier. + """ + try: + return models.SupplierProduct.objects.get( + supplier_id=supplier_id, + sku=sku + ) + except models.SupplierProduct.DoesNotExist: + pass + + # Product has not been found in the database. Let's fetch it from + # the supplier. + supplier_obj = models.Supplier.objects.get(id=supplier_id) + supplier_api = suppliers.get_supplier_api(supplier_obj.internal_name) + product_data = supplier_api.retrieve_product(sku) + if product_data is None: + log.warning('Product not found (sku: %s, supplier: %s', + sku, supplier_id) + return None + product_obj = models.SupplierProduct.objects.create( + supplier_id=supplier_id, + sku=sku, + price=product_data.price, + name=product_data.name + ) + return product_obj + + +def parse_report(supplier_internal_name, report_path): + """Parses a report file and returns parsed items.""" + supplier_api = suppliers.get_supplier_api(supplier_internal_name) + return supplier_api.parse_delivery_report(report_path) + + +@transaction.atomic +def populate_delivery(delivery_id): + """Populates the delivery with products based on the imported report.""" + delivery_obj = models.Delivery.objects.get(id=delivery_id) + supplier_obj = delivery_obj.supplier + items = parse_report(supplier_obj.internal_name, delivery_obj.report.path) + for item in items: + product_obj = get_supplier_product(supplier_obj.id, item.sku) + if product_obj is not None: + models.DeliveryItem.objects.create( + delivery=delivery_obj, + supplier_product_id=product_obj.id, + qty=item.qty * product_obj.qty_multiplier, + price=item.price / product_obj.qty_multiplier + ) + return delivery_obj + + +@transaction.atomic +def process_delivery(delivery_id): + """Adjusts the stock quantities based on the delivery data.""" + delivery_obj = models.Delivery.objects.get(id=delivery_id) + assert delivery_obj.valid, ('Some of the delivered items are not ' + 'associated with a product in the system.') + for item in delivery_obj.delivery_items.all(): + supplier_product = item.supplier_product + create_product_transaction( + product_id=supplier_product.product.id, + trx_type=enums.TrxType.INVENTORY, + qty=item.qty, + reference=item + ) + delivery_obj.locked = True + delivery_obj.save() diff --git a/src/shop/migrations/0006_auto_20160919_1224.py b/src/shop/migrations/0006_auto_20160919_1224.py new file mode 100644 index 0000000..929cd3f --- /dev/null +++ b/src/shop/migrations/0006_auto_20160919_1224.py @@ -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 = [ + ('shop', '0005_producttransaction_reference'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='code', + field=utils.models.ScannerField(max_length=13, unique=True), + ), + ] diff --git a/src/shop/migrations/0007_auto_20170212_0932.py b/src/shop/migrations/0007_auto_20170212_0932.py new file mode 100644 index 0000000..764bef4 --- /dev/null +++ b/src/shop/migrations/0007_auto_20170212_0932.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-12 09:32 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import djmoney.models.fields +import shop.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('shop', '0006_auto_20160919_1224'), + ] + + operations = [ + migrations.CreateModel( + name='Delivery', + fields=[ + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='date created')), + ('date_modified', models.DateTimeField(auto_now=True, null=True, verbose_name='date modified')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('report', models.FileField(storage=shop.models.OverwriteFileSystemStorage(), upload_to=shop.models.generate_delivery_report_filename)), + ], + options={ + 'verbose_name': 'Delivery', + 'verbose_name_plural': 'Deliveries', + }, + ), + migrations.CreateModel( + name='DeliveryItem', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('qty', models.PositiveIntegerField()), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[('SEK', 'SEK')], default='SEK', editable=False, max_length=3)), + ('price', djmoney.models.fields.MoneyField(blank=True, currency_choices=(('SEK', 'SEK'),), decimal_places=2, default=None, max_digits=10, null=True)), + ('delivery', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='delivery_items', to='shop.Delivery')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Supplier', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=32)), + ('internal_name', models.CharField(max_length=32)), + ], + options={ + 'verbose_name': 'supplier', + 'verbose_name_plural': 'suppliers', + }, + ), + migrations.CreateModel( + name='SupplierProduct', + fields=[ + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='date created')), + ('date_modified', models.DateTimeField(auto_now=True, null=True, verbose_name='date modified')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('sku', models.CharField(max_length=32, verbose_name='SKU')), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[('SEK', 'SEK')], default='SEK', editable=False, max_length=3)), + ('price', djmoney.models.fields.MoneyField(blank=True, currency_choices=(('SEK', 'SEK'),), decimal_places=2, default=None, max_digits=10, null=True)), + ('image', models.ImageField(blank=True, null=True, storage=shop.models.OverwriteFileSystemStorage(), upload_to=shop.models.generate_supplier_product_filename)), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_products', to='shop.Product')), + ('supplier', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='shop.Supplier')), + ], + options={ + 'verbose_name': 'supplier product', + 'verbose_name_plural': 'supplier products', + }, + ), + migrations.RenameField( + model_name='producttransaction', + old_name='reference', + new_name='reference_old', + ), + migrations.AddField( + model_name='producttransaction', + name='reference_ct', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='producttransaction', + name='reference_id', + field=models.UUIDField(blank=True, default=uuid.uuid4, null=True), + ), + migrations.AddField( + model_name='deliveryitem', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='delivery_item', to='shop.SupplierProduct'), + ), + migrations.AddField( + model_name='delivery', + name='items', + field=models.ManyToManyField(through='shop.DeliveryItem', to='shop.SupplierProduct'), + ), + migrations.AddField( + model_name='delivery', + name='supplier', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='shop.Supplier'), + ), + migrations.AlterUniqueTogether( + name='supplierproduct', + unique_together=set([('supplier', 'sku')]), + ), + ] diff --git a/src/shop/migrations/0008_remove_producttransaction_reference_old.py b/src/shop/migrations/0008_remove_producttransaction_reference_old.py new file mode 100644 index 0000000..7d4ea2e --- /dev/null +++ b/src/shop/migrations/0008_remove_producttransaction_reference_old.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-12 09:36 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0007_auto_20170212_0932'), + ] + + operations = [ + migrations.RemoveField( + model_name='producttransaction', + name='reference_old', + ), + ] diff --git a/src/shop/migrations/0009_populate_suppliers.py b/src/shop/migrations/0009_populate_suppliers.py new file mode 100644 index 0000000..f5f09ea --- /dev/null +++ b/src/shop/migrations/0009_populate_suppliers.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-11 20:08 +from __future__ import unicode_literals + +from django.db import migrations +from django.conf import settings + +from narlivs import Narlivs + + +def populate_suppliers(apps, schema_editor): + Supplier = apps.get_model("shop", "Supplier") + SupplierProduct = apps.get_model("shop", "SupplierProduct") + Product = apps.get_model("shop", "Product") + + products = Product.objects.all() + + if not products: + return + + narlivs = Narlivs(settings.NARLIVS_USERNAME, settings.NARLIVS_PASSWORD) + s = Supplier.objects.create(name='Närlivs', internal_name='narlivs') + + for p in products: + try: + data = narlivs.get_product(ean=p.code).data + except: + continue + SupplierProduct.objects.create( + supplier=s, + product_id=p.id, + name=p.name, + sku=data['sku'], + price=data['price'] + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0008_remove_producttransaction_reference_old'), + ] + + operations = [ + migrations.RunPython(populate_suppliers) + ] diff --git a/src/shop/migrations/0010_delivery_processed.py b/src/shop/migrations/0010_delivery_processed.py new file mode 100644 index 0000000..f888ad6 --- /dev/null +++ b/src/shop/migrations/0010_delivery_processed.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-12 10:35 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0009_populate_suppliers'), + ] + + operations = [ + migrations.AddField( + model_name='delivery', + name='processed', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/shop/migrations/0011_auto_20170212_1109.py b/src/shop/migrations/0011_auto_20170212_1109.py new file mode 100644 index 0000000..08f0b3b --- /dev/null +++ b/src/shop/migrations/0011_auto_20170212_1109.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-12 11:09 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0010_delivery_processed'), + ] + + operations = [ + migrations.RenameField( + model_name='delivery', + old_name='processed', + new_name='locked', + ), + ] diff --git a/src/shop/migrations/0012_auto_20170212_1247.py b/src/shop/migrations/0012_auto_20170212_1247.py new file mode 100644 index 0000000..2e33964 --- /dev/null +++ b/src/shop/migrations/0012_auto_20170212_1247.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-12 12:47 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0011_auto_20170212_1109'), + ] + + operations = [ + migrations.RenameField( + model_name='deliveryitem', + old_name='product', + new_name='supplier_product', + ), + ] diff --git a/src/shop/migrations/0013_deliveryitem_received.py b/src/shop/migrations/0013_deliveryitem_received.py new file mode 100644 index 0000000..0e57bbf --- /dev/null +++ b/src/shop/migrations/0013_deliveryitem_received.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-12 15:02 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0012_auto_20170212_1247'), + ] + + operations = [ + migrations.AddField( + model_name='deliveryitem', + name='received', + field=models.BooleanField(default=False, help_text='Has the product been received?'), + ), + ] diff --git a/src/shop/migrations/0014_auto_20170212_1539.py b/src/shop/migrations/0014_auto_20170212_1539.py new file mode 100644 index 0000000..532626f --- /dev/null +++ b/src/shop/migrations/0014_auto_20170212_1539.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-12 15:39 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0013_deliveryitem_received'), + ] + + operations = [ + migrations.AddField( + model_name='supplierproduct', + name='qty_multiplier', + field=models.PositiveIntegerField(default=1, help_text='The quantity in the report will be multiplied by this value.'), + ), + migrations.AlterField( + model_name='deliveryitem', + name='received', + field=models.BooleanField(default=False, help_text='Has the product been received?', verbose_name='☑️'), + ), + ] diff --git a/src/shop/models.py b/src/shop/models.py index fe63abc..cf004b4 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -1,9 +1,13 @@ import os +import uuid from django.db import models from django.conf import settings from django.utils.translation import ugettext_lazy as _ from django.core.files.storage import FileSystemStorage +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey from bananas.models import TimeStampedModel, UUIDModel +from moneyed import Money from enumfields import EnumIntegerField from djmoney.models.fields import MoneyField from utils.models import ScannerField @@ -24,6 +28,144 @@ def generate_product_filename(instance, filename): return 'product/{code}{ext}'.format(ext=ext, code=instance.code) +def generate_supplier_product_filename(instance, filename): + _, ext = os.path.splitext(filename) + return 'supplier/{supplier}/{sku}{ext}'.format( + supplier=instance.supplier.id, + sku=instance.sku, + ext=ext, + ) + + +def generate_delivery_report_filename(instance, filename): + _, ext = os.path.splitext(filename) + return 'report/{delivery_id}{ext}'.format( + delivery_id=instance.id, + ext=ext, + ) + + +class Supplier(UUIDModel): + name = models.CharField(max_length=32) + internal_name = models.CharField(max_length=32) + + class Meta: + verbose_name = _('supplier') + verbose_name_plural = _('suppliers') + + def __str__(self): + return '{0.name}'.format(self) + + +class SupplierProduct(UUIDModel, TimeStampedModel): + """Holds product information provided by the supplier.""" + supplier = models.ForeignKey('Supplier', related_name='products') + product = models.ForeignKey('Product', related_name='supplier_products', + null=True, blank=True) + name = models.CharField(max_length=64) + sku = models.CharField(max_length=32, verbose_name=_('SKU')) + price = MoneyField( + null=True, + blank=True, + max_digits=10, + decimal_places=2, + default_currency=settings.DEFAULT_CURRENCY, + currency_choices=settings.CURRENCY_CHOICES + ) + image = models.ImageField(blank=True, null=True, + upload_to=generate_supplier_product_filename, + storage=OverwriteFileSystemStorage()) + qty_multiplier = models.PositiveIntegerField( + verbose_name=_('Quantity multiplier'), + help_text=_('The quantity in the report will be multiplied by this ' + 'value.'), + default=1 + ) + + class Meta: + verbose_name = _('supplier product') + verbose_name_plural = _('supplier products') + unique_together = ('supplier', 'sku',) + + def __str__(self): + return self.name + + +class Delivery(UUIDModel, TimeStampedModel): + """Represents the set of products delivered to the shop by a supplier.""" + supplier = models.ForeignKey('Supplier', related_name='deliveries') + items = models.ManyToManyField('SupplierProduct', through='DeliveryItem') + report = models.FileField(upload_to=generate_delivery_report_filename, + storage=OverwriteFileSystemStorage()) + locked = models.BooleanField(default=False) + + class Meta: + verbose_name = _('Delivery') + verbose_name_plural = _('Deliveries') + + @property + def valid(self): + """Tells whether the delivery is valid for processing or not.""" + return self.associated and self.received + + @property + def associated(self): + """Tells if all the delivered items are associated with a product.""" + qs = self.items.all().select_related('product') + return all(item.product is not None for item in qs) + + @property + def received(self): + """Tells if all the delivered items are marked as received.""" + return all(item.received for item in self.delivery_items.all()) + + @property + def total_amount(self): + items = self.delivery_items.all() + if not items: + return None + currency = items[0].price_currency + zero_money = Money(0, currency) + return sum((item.total_price for item in items.all()), zero_money) + + def __str__(self): + fmt = 'Delivery from {0.supplier.name} ({0.date_created})' + return fmt.format(self) + + +class DeliveryItem(UUIDModel): + delivery = models.ForeignKey('Delivery', on_delete=models.CASCADE, + related_name='delivery_items') + supplier_product = models.ForeignKey('SupplierProduct', + on_delete=models.PROTECT, + related_name='delivery_item') + qty = models.PositiveIntegerField() + price = MoneyField( + null=True, + blank=True, + max_digits=10, + decimal_places=2, + default_currency=settings.DEFAULT_CURRENCY, + currency_choices=settings.CURRENCY_CHOICES + ) + received = models.BooleanField( + default=False, + help_text=_('Has the product been received?'), + verbose_name='☑️' + ) + + @property + def is_associated(self): + return self.supplier_product.product is not None + + @property + def total_price(self): + try: + return self.qty * self.price + except TypeError: + return None + + class ProductCategory(UUIDModel): """Groups together similar products.""" name = models.CharField(max_length=64) @@ -56,7 +198,6 @@ class Product(UUIDModel, TimeStampedModel): # cached quantity qty = models.IntegerField(default=0) - objects = querysets.ProductQuerySet.as_manager() class Meta: @@ -73,7 +214,10 @@ class ProductTransaction(UUIDModel, TimeStampedModel): trx_type = EnumIntegerField(enums.TrxType) trx_status = EnumIntegerField(enums.TrxStatus, default=enums.TrxStatus.FINALIZED) - reference = models.CharField(max_length=128, blank=True, null=True) + reference_ct = models.ForeignKey(ContentType, on_delete=models.CASCADE, + null=True, blank=True) + reference_id = models.UUIDField(default=uuid.uuid4, null=True, blank=True) + reference = GenericForeignKey('reference_ct', 'reference_id') objects = querysets.ProductTrxQuerySet.as_manager() class Meta: diff --git a/src/shop/suppliers/__init__.py b/src/shop/suppliers/__init__.py new file mode 100644 index 0000000..b5e87f9 --- /dev/null +++ b/src/shop/suppliers/__init__.py @@ -0,0 +1,7 @@ +from importlib import import_module + + +def get_supplier_api(internal_name): + """Provides supplier API for the supplier with given internal name.""" + name = '.{}'.format(internal_name) + return import_module(name, __name__).SupplierAPI() diff --git a/src/shop/suppliers/base.py b/src/shop/suppliers/base.py new file mode 100644 index 0000000..8abd709 --- /dev/null +++ b/src/shop/suppliers/base.py @@ -0,0 +1,34 @@ +from abc import ABCMeta, abstractmethod +from collections import namedtuple + + +DeliveryItem = namedtuple('DeliveryItem', ['sku', 'price', 'qty']) +SupplierProduct = namedtuple('SupplierProduct', ['name', 'price']) + + +class SupplierAPIException(Exception): + pass + + +class SupplierBase(metaclass=ABCMeta): + """Defines the interface of a supplier module.""" + + @abstractmethod + def parse_delivery_report(self, report_path): + """Parses a delivery report file and returns the delivered items. + + :param report_path: Path to the report file. + :type report_path: str + :rtype: List[DeliveryItem] -- The list of products imported from the + file. + :raises: SupplierAPIException + """ + + @abstractmethod + def retrieve_product(self, sku): + """Retrieve product data for given SKU. + + :param sku: SKU of the product to be retrieved. + :type sku: str + :rtype Union[SupplierProduct, None] + """ diff --git a/src/shop/suppliers/narlivs.py b/src/shop/suppliers/narlivs.py new file mode 100644 index 0000000..e5f1ada --- /dev/null +++ b/src/shop/suppliers/narlivs.py @@ -0,0 +1,121 @@ +import re +import decimal +import subprocess +import tempfile + +from django.conf import settings +from narlivs import Narlivs + +from .base import ( + DeliveryItem, + SupplierAPIException, + SupplierBase, + SupplierProduct +) + +ITEM_PATTERN = re.compile(r""" + \s*?\d+ # Row + \s+?(?P\d{9}) # SKU + \s+(?P.+?) # Description + \s+?\d+ # Delivered package quantity + \s+?(?P\w+) # Unit + \s+?(?P\d+) # Delivered unit quantity + \s+(?P[^ ]+)/ST # Purchase price + \s+[\d\.-]+ # Recommended price + \s+[\d\.-]+ # Marginal % + \s+(?P[\d\.]+) # Net price + \s+[\d\.]+$ # Reference +""", re.VERBOSE | re.MULTILINE) + +NET_VALUE_PATTERN = re.compile("nettobelopp:\s+([\d.]+?)\s+SEK") + +ITEM_TYPE_MAPPINGS = { + 'sku': str, + 'description': str, + 'unit': str, + 'qty': int, + 'price': decimal.Decimal, + 'net_price': decimal.Decimal, +} + + +def pdf_to_text(path): + """Converts a PDF file to text. + + Depends on the external pdftotext command line tool. + """ + with tempfile.NamedTemporaryFile(mode='r') as f: + code = subprocess.call(['pdftotext', '-layout', path, f.name]) + if code != 0: + return None + return f.read() + + +class SupplierAPI(SupplierBase): + """Supplier API implementation for Axfood Närlivs.""" + + def __init__(self): + self.narlivs = Narlivs( + username=settings.NARLIVS_USERNAME, + password=settings.NARLIVS_PASSWORD + ) + + def parse_delivery_report(self, report_path): + data = pdf_to_text(report_path) + + if not data: + raise SupplierAPIException('The report is probably not in PDF ' + 'format.') + + items = [m.groupdict() for m in ITEM_PATTERN.finditer(data)] + net = NET_VALUE_PATTERN.search(data) + + if not net or not items: + raise SupplierAPIException('The report could not be parsed.') + + net = net.group(1) + + # Cast the item values to proper types. + items = [{k: ITEM_TYPE_MAPPINGS[k](v) for k, v in item.items()} + for item in items] + + # Delivery report contain also the pant fee. The fee is represented + # as an item in the report. We want however to merge the fee with + # the corresponding item (always the item above the pant fee item). + consolidated_items = [] + + for item in items: + if item['description'].startswith('PANT '): + # For some drinks, the quantity in the report is incorrect + # and it is not equal to the quantity of the pant. The pant + # quantity seems however to be always correct, so we + # set it as the quantity of the drink. + if consolidated_items[-1]['qty'] != item['qty']: + consolidated_items[-1]['qty'] = item['qty'] + consolidated_items[-1]['price'] /= item['qty'] + consolidated_items[-1]['net_price'] += item['net_price'] + consolidated_items[-1]['price'] += item['price'] + else: + consolidated_items.append(item) + + # Make sure that we did not miss any item while parsing + item_sum1 = sum(item['net_price'] for item in consolidated_items) + item_sum2 = sum(item['price'] * item['qty'] + for item in consolidated_items) + net = decimal.Decimal(net) + assert item_sum1 == item_sum2 == net + + return [ + DeliveryItem( + sku=item['sku'], + price=item['price'], + qty=item['qty'] + ) for item in consolidated_items + ] + + def retrieve_product(self, sku): + data = self.narlivs.get_product(sku=sku).data + return SupplierProduct( + name=data['name'].title(), + price=data['price'] + ) diff --git a/src/shop/templates/admin/shop/delivery/change_form.html b/src/shop/templates/admin/shop/delivery/change_form.html new file mode 100644 index 0000000..2e12ee0 --- /dev/null +++ b/src/shop/templates/admin/shop/delivery/change_form.html @@ -0,0 +1,11 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block object-tools-items %} +{% if not original.locked %} +
  • + {% trans 'Confirm delivery' %} +
  • +{% endif %} +{{ block.super }} +{% endblock %} diff --git a/src/shop/tests/data/delivery_report.pdf b/src/shop/tests/data/delivery_report.pdf new file mode 100644 index 0000000..218809e Binary files /dev/null and b/src/shop/tests/data/delivery_report.pdf differ diff --git a/src/shop/tests/factories.py b/src/shop/tests/factories.py index 1c6250f..5d3af38 100644 --- a/src/shop/tests/factories.py +++ b/src/shop/tests/factories.py @@ -14,7 +14,7 @@ class Meta: name = factory.Sequence(lambda n: 'Product #{0}'.format(n)) code = factory.Sequence(lambda n: '1{0:012d}'.format(n)) - price = FuzzyMoney(0, 100000) + price = FuzzyMoney(10, 50) active = True @@ -32,3 +32,38 @@ class Meta: model = models.ProductCategory name = factory.Sequence(lambda n: 'Category #{0}'.format(n)) + + +class SupplierFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.Supplier + + +class SupplierProductFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.SupplierProduct + + supplier = factory.SubFactory(SupplierFactory) + product = factory.SubFactory(ProductFactory) + name = factory.Sequence(lambda n: 'Product #{0}'.format(n)) + sku = factory.Sequence(lambda n: '1{0:010d}'.format(n)) + price = FuzzyMoney(10, 50) + + +class DeliveryFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.Delivery + + supplier = factory.SubFactory(SupplierFactory) + report = 'dummy' + + +class DeliveryItemFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.DeliveryItem + + delivery = factory.SubFactory(SupplierFactory) + supplier_product = factory.SubFactory(SupplierProductFactory) + qty = factory.fuzzy.FuzzyInteger(1, 50) + price = FuzzyMoney(10, 50) + received = True diff --git a/src/shop/tests/models.py b/src/shop/tests/models.py new file mode 100644 index 0000000..c8b5b55 --- /dev/null +++ b/src/shop/tests/models.py @@ -0,0 +1,5 @@ +from bananas.models import UUIDModel, TimeStampedModel + + +class DummyModel(UUIDModel, TimeStampedModel): + pass diff --git a/src/shop/tests/test_api.py b/src/shop/tests/test_api.py index 334e728..a3e89eb 100644 --- a/src/shop/tests/test_api.py +++ b/src/shop/tests/test_api.py @@ -1,8 +1,33 @@ +from unittest import mock +from decimal import Decimal + +from moneyed import Money + from django.test import TestCase + +from ..suppliers.base import SupplierBase, DeliveryItem, SupplierProduct from .. import api, models, enums +from .models import DummyModel from . import factories +class DummySupplierAPI(SupplierBase): + def parse_delivery_report(self, report_path): + return [ + DeliveryItem( + sku='101176931', + qty=20, + price=Decimal('9.25') + ) + ] + + def retrieve_product(self, sku): + return SupplierProduct( + name='Billys Original', + price=Decimal('9.25') + ) + + class ShopAPITest(TestCase): def test_create_product(self): product_obj = api.create_product(code='1234567812345', name='Banana') @@ -38,23 +63,22 @@ def test_create_product_transaction(self): self.assertEqual(product_obj.qty, -1) def test_get_product_transactions_by_ref(self): + dummy_obj = DummyModel.objects.create() product_obj = factories.ProductFactory.create() api.create_product_transaction( product_id=product_obj.id, trx_type=enums.TrxType.INVENTORY, qty=1, - reference='1337' + reference=dummy_obj ) api.create_product_transaction( product_id=product_obj.id, trx_type=enums.TrxType.INVENTORY, qty=1, - reference='1337' + reference=dummy_obj ) - trx_objs = api.get_product_transactions_by_ref('1337') + trx_objs = api.get_product_transactions_by_ref(dummy_obj) self.assertEqual(len(trx_objs), 2) - trx_objs = api.get_product_transactions_by_ref('7331') - self.assertEqual(len(trx_objs), 0) def test_cancel_product_transaction(self): product_obj = factories.ProductFactory.create() @@ -88,3 +112,85 @@ def test_list_products(self): self.assertEqual(len(api.list_products(active=False)), 0) objs = api.list_products(name__startswith='Billys') self.assertEqual(len(objs), 1) + + @mock.patch('shop.suppliers.get_supplier_api') + def test_get_supplier_product_existing(self, mock_get_supplier_api): + mock_get_supplier_api.return_value = DummySupplierAPI() + supplier_obj = factories.SupplierFactory.create() + factories.SupplierProductFactory.create( + supplier=supplier_obj, + name='Billys', + sku='101176931', + price=1337 + ) + product_obj = api.get_supplier_product(supplier_obj.id, '101176931') + self.assertIsNotNone(product_obj) + self.assertEqual(product_obj.sku, '101176931') + self.assertEqual(product_obj.price.amount, 1337) + + @mock.patch('shop.suppliers.get_supplier_api') + def test_get_supplier_product_non_existing(self, mock_get_supplier_api): + mock_get_supplier_api.return_value = DummySupplierAPI() + supplier_obj = factories.SupplierFactory.create() + product_obj = api.get_supplier_product(supplier_obj.id, '101176931') + self.assertIsNotNone(product_obj) + self.assertEqual(product_obj.sku, '101176931') + self.assertEqual(product_obj.price.amount, Decimal('9.25')) + + @mock.patch('shop.suppliers.get_supplier_api') + def test_populate_delivery(self, mock_get_supplier_api): + mock_get_supplier_api.return_value = DummySupplierAPI() + supplier_obj = factories.SupplierFactory.create() + factories.SupplierProductFactory.create( + supplier=supplier_obj, + name='Billys', + sku='101176931', + price='9', + qty_multiplier=2 + ) + delivery_obj = factories.DeliveryFactory( + supplier=supplier_obj, + report='dummy' + ) + delivery_obj = api.populate_delivery(delivery_obj.id) + self.assertIsNotNone(delivery_obj) + delivery_obj.refresh_from_db() + delivery_items = delivery_obj.delivery_items.all() + self.assertEqual(len(delivery_items), 1) + # The quantity and the price should be recalculated + # according to the multiplier. + self.assertEqual(delivery_items[0].qty, 40) + self.assertEqual(delivery_items[0].price.amount, Decimal('4.62')) + + def test_process_delivery(self): + delivery_obj = factories.DeliveryFactory() + item_obj1 = factories.DeliveryItemFactory( + delivery=delivery_obj, + qty=10, + price=Money(50, 'SEK'), + received=True + + ) + self.assertTrue(item_obj1.is_associated) + product_obj1 = item_obj1.supplier_product.product + pre_qty1 = product_obj1.qty + item_obj2 = factories.DeliveryItemFactory( + delivery=delivery_obj, + qty=5, + price=Money(10, 'SEK'), + received=False + ) + self.assertTrue(item_obj2.is_associated) + product_obj2 = item_obj2.supplier_product.product + pre_qty2 = product_obj2.qty + self.assertFalse(delivery_obj.valid) + item_obj2.received = True + item_obj2.save() + self.assertTrue(delivery_obj.valid) + api.process_delivery(delivery_obj.id) + trxs_qs = models.ProductTransaction.objects + self.assertEqual(trxs_qs.count(), 2) + product_obj1.refresh_from_db() + product_obj2.refresh_from_db() + self.assertEqual(product_obj1.qty - pre_qty1, 10) + self.assertEqual(product_obj2.qty - pre_qty2, 5) diff --git a/src/shop/tests/test_narlivs.py b/src/shop/tests/test_narlivs.py new file mode 100644 index 0000000..6174f82 --- /dev/null +++ b/src/shop/tests/test_narlivs.py @@ -0,0 +1,21 @@ +from os.path import join, dirname, abspath +from django.test import TestCase +from ..suppliers import get_supplier_api, narlivs + +TESTDATA_DIR = join(dirname(abspath(__file__)), 'data') + + +class NarlivsTest(TestCase): + def setUp(self): + self.api = get_supplier_api('narlivs') + self.report_path = join(TESTDATA_DIR, 'delivery_report.pdf') + + def test_pdf_to_text(self): + text = narlivs.pdf_to_text(self.report_path) + self.assertTrue('001337' in text) + + def test_receive_delivery(self): + items = self.api.parse_delivery_report(self.report_path) + self.assertEqual(len(items), 32) + for item in items: + self.assertEqual(len(item.sku), 9) diff --git a/src/shop/tests/test_suppliers.py b/src/shop/tests/test_suppliers.py new file mode 100644 index 0000000..d874ce4 --- /dev/null +++ b/src/shop/tests/test_suppliers.py @@ -0,0 +1,8 @@ +from django.test import TestCase +from .. import suppliers + + +class SupplierTest(TestCase): + def test_get_supplier_api(self): + api = suppliers.get_supplier_api('narlivs') + self.assertIsNotNone(api)