From 75a0b2cfd442c5499bb520eee6131263763e8687 Mon Sep 17 00:00:00 2001 From: Nikolay Zamotaev Date: Tue, 12 Feb 2019 18:14:39 +0300 Subject: Category icon upload implementation Task-number: AUTOSUITE-759 Change-Id: I1030d4b127b41cccfee545e1d4412e61e67f8fc1 Reviewed-by: Egor Nemtsev --- appstore/settings.py | 8 ++++ requirements.txt | 2 + store/admin.py | 94 +++++++++++++++----------------------- store/api.py | 27 +++++++---- store/migrations/0001_initial.py | 7 ++- store/models.py | 85 ++++++++++++---------------------- store/static/img/category_All.png | Bin 0 -> 620 bytes 7 files changed, 101 insertions(+), 122 deletions(-) create mode 100644 store/static/img/category_All.png diff --git a/appstore/settings.py b/appstore/settings.py index e3edabc..8e67698 100644 --- a/appstore/settings.py +++ b/appstore/settings.py @@ -96,6 +96,7 @@ INSTALLED_APPS = ( 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'ordered_model', 'store', ) @@ -153,3 +154,10 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') # trailing slash if there is a path component (optional in other cases). # Examples: "http://media.lawrence.com", "http://example.com/media/" MEDIA_URL = '' + +# Icon size (icons are resized to this size on upload) +ICON_SIZE_X = 36 +ICON_SIZE_Y = 32 +# If the icon should be transformed to monochrome, with alpha channel, when uploaded or not +ICON_DECOLOR = True + diff --git a/requirements.txt b/requirements.txt index 79be93a..41de88c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ pkg-resources==0.0.0 PyYAML django==1.11 django-common +django-ordered-model==2.1 pyOpenSSL M2Crypto Enum34 @@ -9,4 +10,5 @@ ipaddress cffi paramiko cryptography +pillow python-magic==0.4.15 diff --git a/store/admin.py b/store/admin.py index f2adb23..61d5ff0 100644 --- a/store/admin.py +++ b/store/admin.py @@ -33,34 +33,51 @@ import os from django import forms -from django.conf import settings -from django.conf.urls import include, url from django.contrib import admin -from django.core.exceptions import PermissionDenied -from django.shortcuts import redirect, get_object_or_404 from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy +from ordered_model.admin import OrderedModelAdmin +from django.core.files.uploadedfile import InMemoryUploadedFile from store.models import * from utilities import parseAndValidatePackageMetadata, writeTempIcon, makeTagList +import StringIO class CategoryAdminForm(forms.ModelForm): class Meta: exclude = ["id", "rank"] def save(self, commit=False): - m = super(CategoryAdminForm, self).save(commit) - try: - test = Category.objects.all().order_by('-rank')[:1].values('rank')[0]['rank'] + 1 - except: - test = 0 - m.rank = test + m = super(CategoryAdminForm, self).save(commit=False) return m -class CategoryAdmin(admin.ModelAdmin): + def clean(self): + cleaned_data = super(CategoryAdminForm, self).clean() + #Icon fixing (resize, turn monochrome, add alpha channel) + if cleaned_data['icon']: + if settings.ICON_DECOLOR: + # make image monochrome + alpha channel, this is done to compensate for + # how icons are treated in neptune3-ui + im = Image.open(cleaned_data['icon']) + grey, alpha = im.convert('LA').split() + grey = ImageChops.invert(grey) + im.putalpha(grey) + im = im.convert('LA') + else: + # No conversion, icons are uploaded as-is, only scaling is used. + im = Image.open(cleared_data['icon']) + size = (settings.ICON_SIZE_X,settings.ICON_SIZE_Y,) + im.thumbnail(size, Image.ANTIALIAS) + imagefile = StringIO.StringIO() + im.save(imagefile, format='png') + imagefile.seek(0) + cleaned_data['icon'] = InMemoryUploadedFile(imagefile, 'icon', "icon.png", 'image/png', imagefile.len, None) + return cleaned_data + +class CategoryAdmin(OrderedModelAdmin): form = CategoryAdminForm - list_display = ('name', 'move') - ordering = ('rank',) + list_display = ('name', 'icon_image', 'move_up_down_links') + ordering = ('order',) def save_model(self, request, obj, form, change): obj.save() @@ -70,53 +87,14 @@ class CategoryAdmin(admin.ModelAdmin): return obj.name name.short_description = ugettext_lazy('Item caption') - def move(self, obj): - """ - Returns html with links to move_up and move_down views. - """ - button = u' %s' - prefix = settings.STATIC_URL - - link = '%d/move_up/' % obj.pk - html = button % (link, prefix, 'up', _('up')) + " | " - link = '%d/move_down/' % obj.pk - html += button % (link, prefix, 'down', _('down')) + def icon_image(self, obj): + prefix = settings.URL_PREFIX + image_request = prefix + "/category/icon?id=%s" % (obj.id) + html = u'' % (settings.ICON_SIZE_X, settings.ICON_SIZE_Y, image_request) return html - move.allow_tags = True - move.short_description = ugettext_lazy('Move') - - def get_urls(self): - admin_view = self.admin_site.admin_view - urls = [ - url(r'^(?P\d+)/move_up/$', admin_view(self.move_up)), - url(r'^(?P\d+)/move_down/$', admin_view(self.move_down)), - ] - return urls + super(CategoryAdmin, self).get_urls() - - def move_up(self, request, item_pk): - """ - Decrease rank (change ordering) of the menu item with - id=``item_pk``. - """ - if self.has_change_permission(request): - item = get_object_or_404(Category, pk=item_pk) - item.decrease_rank() - else: - raise PermissionDenied - return redirect('admin:store_category_changelist') - - def move_down(self, request, item_pk): - """ - Increase rank (change ordering) of the menu item with - id=``item_pk``. - """ - if self.has_change_permission(request): - item = get_object_or_404(Category, pk=item_pk) - item.increase_rank() - else: - raise PermissionDenied - return redirect('admin:store_category_changelist') + icon_image.allow_tags = True + icon_image.short_description = ugettext_lazy('Category icon') class AppAdminForm(forms.ModelForm): diff --git a/store/api.py b/store/api.py index 63c3121..1c26944 100644 --- a/store/api.py +++ b/store/api.py @@ -50,7 +50,6 @@ from tags import SoftwareTagList def hello(request): status = 'ok' - if settings.APPSTORE_MAINTENANCE: status = 'maintenance' elif getRequestDictionary(request).get("platform", "") != str(settings.APPSTORE_PLATFORM_ID): @@ -308,7 +307,7 @@ def appDownload(request, path): def categoryList(request): # this is not valid JSON, since we are returning a list! allmeta = [{'id': -1, 'name': 'All'}, ] #All metacategory - categoryobject = Category.objects.all().order_by('rank').values('id', 'name') + categoryobject = Category.objects.all().order_by('order').values('id', 'name') categoryobject=allmeta + list(categoryobject) return JsonResponse(categoryobject, safe = False) @@ -316,14 +315,26 @@ def categoryList(request): def categoryIcon(request): response = HttpResponse(content_type = 'image/png') categoryId = getRequestDictionary(request)['id'] - - # there are no category icons (yet), so we just return the icon of the first app in this category try: - app = App.objects.filter(category__exact = categoryId).order_by('-dateModified')[0] #FIXME - the category icon is unimplemented - with open(iconPath(app.appid,app.architecture), 'rb') as iconPng: - response.write(iconPng.read()) + if categoryId != '-1': + category = Category.objects.filter(id__exact = categoryId)[0] + filename = iconPath() + "category_" + str(category.id) + ".png" + else: + from django.contrib.staticfiles import finders + filename = finders.find('img/category_All.png') + with open(filename, 'rb') as icon: + response.write(icon.read()) + response['Content-Length'] = icon.tell() + except: - emptyPng = '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x01\x03\x00\x00\x00%\xdbV\xca\x00\x00\x00\x03PLTE\x00\x00\x00\xa7z=\xda\x00\x00\x00\x01tRNS\x00@\xe6\xd8f\x00\x00\x00\nIDAT\x08\xd7c`\x00\x00\x00\x02\x00\x01\xe2!\xbc3\x00\x00\x00\x00IEND\xaeB`\x82' + # In case there was error in searching for category, + # return this image: + # +-----+ + # | | + # |Error| + # | | + # +-----+ + emptyPng = "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00 \x00\x00\x00 \x01\x03\x00\x00\x00I\xb4\xe8\xb7\x00\x00\x00\x06PLTE\x00\x00\x00\x00\x00\x00\xa5\x67\xb9\xcf\x00\x00\x00\x01tRNS\x00@\xe6\xd8f\x00\x00\x00\x33IDAT\x08\xd7\x63\xf8\x0f\x04\x0c\x0d\x0c\x0c\x8c\x44\x13\x7f\x40\xc4\x01\x10\x71\xb0\xf4\x5c\x2c\xc3\xcf\x36\xc1\x44\x86\x83\x2c\x82\x8e\x48\xc4\x5f\x16\x3e\x47\xd2\x0c\xc5\x46\x80\x9c\x06\x00\xa4\xe5\x1d\xb4\x8e\xae\xe8\x43\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82" response.write(emptyPng) return response diff --git a/store/migrations/0001_initial.py b/store/migrations/0001_initial.py index bde513e..dca1599 100644 --- a/store/migrations/0001_initial.py +++ b/store/migrations/0001_initial.py @@ -69,9 +69,14 @@ class Migration(migrations.Migration): name='Category', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.PositiveIntegerField(db_index=True, editable=False)), ('name', models.CharField(max_length=200)), - ('rank', models.SmallIntegerField(db_index=True, unique=True)), + ('icon', models.ImageField(storage=store.models.OverwriteStorage(), upload_to=store.models.category_file_name)), ], + options={ + 'ordering': ('order',), + 'abstract': False, + }, ), migrations.CreateModel( name='Vendor', diff --git a/store/models.py b/store/models.py index eb3ede9..08bebc3 100644 --- a/store/models.py +++ b/store/models.py @@ -31,69 +31,50 @@ ############################################################################# import os +from PIL import Image, ImageChops from django.db import models +from ordered_model.models import OrderedModel from django.conf import settings from django.contrib.auth.models import User from django.core.files.storage import FileSystemStorage +from django.db.models.fields.files import ImageFieldFile from utilities import packagePath, writeTempIcon, makeTagList +def category_file_name(instance, filename): + # filename parameter is unused. See django documentation for details: + # https://docs.djangoproject.com/en/1.11/ref/models/fields/#django.db.models.FileField.upload_to + return settings.MEDIA_ROOT + "icons/category_" + str(instance.id) + ".png" -class Category(models.Model): +class OverwriteStorage(FileSystemStorage): + def get_available_name(self, name, max_length=None): + if self.exists(name): + os.remove(os.path.join(settings.MEDIA_ROOT, name)) + return name + +class Category(OrderedModel): name = models.CharField(max_length = 200) - rank = models.SmallIntegerField(unique = True, db_index = True) + icon = models.ImageField(upload_to = category_file_name, storage = OverwriteStorage()) + + class Meta(OrderedModel.Meta): + ordering = ('order',) def __unicode__(self): return self.name - def is_first(self): - """ - Returns ``True`` if item is the first one in the menu. - """ - return Category.objects.filter(rank__lt = self.rank).count() == 0 - - def is_last(self): - """ - Returns ``True`` if item is the last one in the menu. - """ - return Category.objects.filter(rank__gt = self.rank).count() == 0 - - def increase_rank(self): - """ - Changes position of this item with the next item in the - menu. Does nothing if this item is the last one. - """ - try: - next_item = Category.objects.filter(rank__gt = self.rank)[0] - except IndexError: - pass - else: - self.swap_ranks(next_item) - - def decrease_rank(self): - """ - Changes position of this item with the previous item in the - menu. Does nothing if this item is the first one. - """ - try: - list = Category.objects.filter(rank__lt = self.rank).reverse() - prev_item = list[len(list) - 1] - except IndexError: - pass - else: - self.swap_ranks(prev_item) - - def swap_ranks(self, other): - """ - Swap positions with ``other`` menu item. - """ - maxrank = 5000 - prev_rank, self.rank = self.rank, maxrank - self.save() - self.rank, other.rank = other.rank, prev_rank - other.save() - self.save() + def save(self, *args, **kwargs): + if self.id is None: + # This is a django hack. When category icon is saved and then later accessed, + # category_id is used as a unique icon identifier. When category is first created, + # but not saved yet, category_id is None. So this hack first saves category without icon + # and then saves the icon separately. This is done to prevent creation of category_None.png + # file, when the icon is saved. + saved_icon = self.icon + self.icon = None + super(Category, self).save(*args, **kwargs) + self.icon = saved_icon + super(Category, self).save(*args, **kwargs) class Vendor(models.Model): user = models.ForeignKey(User, primary_key = True) @@ -104,12 +85,6 @@ class Vendor(models.Model): return self.name -class OverwriteStorage(FileSystemStorage): - def get_available_name(self, name): - if self.exists(name): - os.remove(os.path.join(settings.MEDIA_ROOT, name)) - return name - def content_file_name(instance, filename): return packagePath(instance.appid, instance.architecture) diff --git a/store/static/img/category_All.png b/store/static/img/category_All.png new file mode 100644 index 0000000..2f44f41 Binary files /dev/null and b/store/static/img/category_All.png differ -- cgit v1.2.3