applications: add icon (#69652)
This commit is contained in:
parent
9a60b030d6
commit
55988f5867
|
@ -17,6 +17,24 @@
|
|||
from django import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from hobo.applications.models import Application
|
||||
|
||||
|
||||
class InstallForm(forms.Form):
|
||||
bundle = forms.FileField(label=_('Application'))
|
||||
|
||||
|
||||
class MetadataForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Application
|
||||
fields = ['name', 'slug', 'description', 'icon']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['icon'].widget.attrs = {'accept': 'image/jpeg,image/png'}
|
||||
|
||||
def clean_icon(self):
|
||||
value = self.cleaned_data.get('icon')
|
||||
if hasattr(value, 'content_type') and value.content_type not in ('image/jpeg', 'image/png'):
|
||||
raise forms.ValidationError(_('The icon must be in JPEG or PNG format.'))
|
||||
return value
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('applications', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='application',
|
||||
name='icon',
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
help_text='Icon file must be in JPEG or PNG format, and should be a square of at least 512×512 pixels.',
|
||||
null=True,
|
||||
upload_to='applications/icons/',
|
||||
verbose_name='Icon',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -27,7 +27,6 @@ from django.utils.timezone import now
|
|||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from hobo.environment.utils import get_installed_services
|
||||
from hobo.signature import sign_url
|
||||
|
||||
from .utils import Requests
|
||||
|
||||
|
@ -39,6 +38,15 @@ class Application(models.Model):
|
|||
|
||||
name = models.CharField(max_length=100, verbose_name=_('Name'))
|
||||
slug = models.SlugField(max_length=100)
|
||||
icon = models.FileField(
|
||||
verbose_name=_('Icon'),
|
||||
help_text=_(
|
||||
'Icon file must be in JPEG or PNG format, and should be a square of at least 512×512 pixels.'
|
||||
),
|
||||
upload_to='applications/icons/',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
description = models.TextField(verbose_name=_('Description'), blank=True)
|
||||
editable = models.BooleanField(default=True)
|
||||
elements = models.ManyToManyField('Element', blank=True, through='Relation')
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
{% block appbar %}<h2>{% trans "Metadata" %}</h2>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<div class="buttons">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "hobo/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load i18n thumbnail %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
|
@ -20,8 +20,15 @@
|
|||
<div id="applications">
|
||||
{% for application in object_list %}
|
||||
<div class="application application--{{ application.slug }}">
|
||||
<h3>{{ application.name }}</h3>
|
||||
<p>{{ application.description|default:"" }}</p>
|
||||
<h3>
|
||||
{% if application.icon %}
|
||||
{% thumbnail application.icon '64x64' crop='center' format='PNG' as im %}
|
||||
<img src="{{ im.url }}" alt="" />
|
||||
{% endthumbnail %}
|
||||
{% endif %}
|
||||
{{ application.name }}
|
||||
</h3>
|
||||
<p>{{ application.description|default:""|linebreaksbr }}</p>
|
||||
{% if application.editable %}
|
||||
<div class="buttons">
|
||||
<a class="button" href="{% url 'application-manifest' app_slug=application.slug %}"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "hobo/applications/home.html" %}
|
||||
{% load i18n %}
|
||||
{% load i18n thumbnail %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
|
@ -7,7 +7,14 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{{ app.name }}</h2>
|
||||
<h2 class="application-title">
|
||||
{% if app.icon %}
|
||||
{% thumbnail app.icon '64x64' crop='center' format='PNG' as im %}
|
||||
<img src="{{ im.url }}" alt="" />
|
||||
{% endthumbnail %}
|
||||
{% endif %}
|
||||
{{ app.name }}
|
||||
</h2>
|
||||
<span class="actions">
|
||||
<a rel="popup" href="{% url 'application-metadata' app_slug=app.slug %}">{% trans 'Metadata' %}</a>
|
||||
</span>
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
import urllib.parse
|
||||
|
||||
|
@ -28,7 +29,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
|||
|
||||
from hobo.environment.utils import get_installed_services
|
||||
|
||||
from .forms import InstallForm
|
||||
from .forms import InstallForm, MetadataForm
|
||||
from .models import Application, Element, Relation, Version
|
||||
from .utils import Requests
|
||||
|
||||
|
@ -114,7 +115,7 @@ class MetadataView(UpdateView):
|
|||
template_name = 'hobo/applications/edit-metadata.html'
|
||||
model = Application
|
||||
slug_url_kwarg = 'app_slug'
|
||||
fields = ['name', 'slug', 'description']
|
||||
form_class = MetadataForm
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('application-manifest', kwargs={'app_slug': self.object.slug})
|
||||
|
@ -214,6 +215,7 @@ def generate(request, app_slug):
|
|||
'application': app.name,
|
||||
'slug': app.slug,
|
||||
'description': app.description,
|
||||
'icon': os.path.basename(app.icon.name) if app.icon.name else None,
|
||||
'elements': [],
|
||||
}
|
||||
|
||||
|
@ -240,6 +242,13 @@ def generate(request, app_slug):
|
|||
tarinfo.mtime = version.last_update_timestamp.timestamp()
|
||||
tar.addfile(tarinfo, fileobj=manifest_fd)
|
||||
|
||||
if app.icon.name:
|
||||
icon_fd = app.icon.file
|
||||
tarinfo = tarfile.TarInfo(manifest_json['icon'])
|
||||
tarinfo.size = icon_fd.size
|
||||
tarinfo.mtime = version.last_update_timestamp.timestamp()
|
||||
tar.addfile(tarinfo, fileobj=icon_fd)
|
||||
|
||||
version.bundle.save('%s.tar' % app_slug, content=ContentFile(tar_io.getvalue()))
|
||||
version.save()
|
||||
|
||||
|
@ -275,6 +284,11 @@ class Install(FormView):
|
|||
else:
|
||||
app.relation_set.all().delete()
|
||||
app.save()
|
||||
icon = manifest.get('icon')
|
||||
if icon:
|
||||
app.icon.save(icon, tar.extractfile(icon), save=True)
|
||||
else:
|
||||
app.icon.delete()
|
||||
|
||||
for element_dict in manifest.get('elements'):
|
||||
element, created = Element.objects.get_or_create(
|
||||
|
|
|
@ -42,6 +42,7 @@ INSTALLED_APPS = (
|
|||
'rest_framework',
|
||||
'mellon',
|
||||
'gadjo',
|
||||
'sorl.thumbnail',
|
||||
'hobo.applications',
|
||||
'hobo.debug',
|
||||
'hobo.environment',
|
||||
|
@ -132,6 +133,13 @@ STATICFILES_FINDERS = list(global_settings.STATICFILES_FINDERS) + ['gadjo.finder
|
|||
|
||||
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'hobo', 'static'),)
|
||||
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
# from solr.thumbnail -- https://sorl-thumbnail.readthedocs.io/en/latest/reference/settings.html
|
||||
THUMBNAIL_PRESERVE_FORMAT = True
|
||||
THUMBNAIL_FORCE_OVERWRITE = False
|
||||
|
||||
LOCALE_PATHS = (os.path.join(BASE_DIR, 'hobo', 'locale'),)
|
||||
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
|
|
|
@ -327,9 +327,18 @@ a.button.button-paragraph:hover p {
|
|||
padding: 0 1em;
|
||||
h3 {
|
||||
margin-top: 1em;
|
||||
img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
.buttons {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h2.application-title {
|
||||
img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
|
2
setup.py
2
setup.py
|
@ -158,6 +158,8 @@ setup(
|
|||
'dnspython',
|
||||
'lxml',
|
||||
'num2words==0.5.9',
|
||||
'sorl-thumbnail',
|
||||
'Pillow',
|
||||
],
|
||||
zip_safe=False,
|
||||
cmdclass={
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import tarfile
|
||||
|
||||
import httmock
|
||||
|
@ -176,6 +178,7 @@ def test_create_application(app, admin_user, settings):
|
|||
resp = resp.click('Metadata')
|
||||
resp.form['description'] = 'Lorem ipsum'
|
||||
resp = resp.form.submit().follow()
|
||||
assert Application.objects.get(slug='test').icon.name == ''
|
||||
|
||||
# add forms
|
||||
assert '/add/forms/' in resp
|
||||
|
@ -198,6 +201,35 @@ def test_create_application(app, admin_user, settings):
|
|||
# uncompressed tar, primitive check of contents
|
||||
assert b'<formdef/>' in resp.content
|
||||
assert b'<carddef/>' in resp.content
|
||||
assert b'"icon": null' in resp.content
|
||||
|
||||
# add an icon
|
||||
resp = app.get('/applications/manifest/test/metadata/')
|
||||
resp.form['icon'] = Upload(
|
||||
'foo.png',
|
||||
base64.decodebytes(
|
||||
b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVQI12NoAAAAggCB3UNq9AAAAABJRU5ErkJggg=='
|
||||
),
|
||||
'image/png',
|
||||
)
|
||||
resp = resp.form.submit().follow()
|
||||
assert re.match(r'applications/icons/foo(_\w+)?.png', Application.objects.get(slug='test').icon.name)
|
||||
|
||||
# try an icon in an invalid format
|
||||
resp = app.get('/applications/manifest/test/metadata/')
|
||||
resp.form['icon'] = Upload('test.txt', b'hello', 'text/plain')
|
||||
resp = resp.form.submit()
|
||||
assert 'The icon must be in JPEG or PNG format' in resp
|
||||
assert re.match(r'applications/icons/foo(_\w+)?.png', Application.objects.get(slug='test').icon.name)
|
||||
|
||||
resp = app.get('/applications/manifest/test/')
|
||||
resp = resp.click('Generate application bundle').follow()
|
||||
resp = resp.click('Download')
|
||||
assert resp.content_type == 'application/x-tar'
|
||||
# uncompressed tar, primitive check of contents
|
||||
assert b'<formdef/>' in resp.content
|
||||
assert b'<carddef/>' in resp.content
|
||||
assert b'"icon": "foo' in resp.content
|
||||
|
||||
|
||||
def test_delete_application(app, admin_user, settings):
|
||||
|
@ -235,11 +267,21 @@ def test_delete_application(app, admin_user, settings):
|
|||
|
||||
@pytest.fixture
|
||||
def app_bundle():
|
||||
return get_bundle()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_bundle_with_icon():
|
||||
return get_bundle(with_icon=True)
|
||||
|
||||
|
||||
def get_bundle(with_icon=False):
|
||||
tar_io = io.BytesIO()
|
||||
with tarfile.open(mode='w', fileobj=tar_io) as tar:
|
||||
manifest_json = {
|
||||
'application': 'Test',
|
||||
'slug': 'test',
|
||||
'icon': 'foo.png' if with_icon else None,
|
||||
'description': '',
|
||||
'elements': [
|
||||
{'type': 'forms', 'slug': 'test', 'name': 'test', 'auto-dependency': False},
|
||||
|
@ -251,11 +293,18 @@ def app_bundle():
|
|||
tarinfo = tarfile.TarInfo('manifest.json')
|
||||
tarinfo.size = len(manifest_fd.getvalue())
|
||||
tar.addfile(tarinfo, fileobj=manifest_fd)
|
||||
if with_icon:
|
||||
icon_fd = io.BytesIO(
|
||||
b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVQI12NoAAAAggCB3UNq9AAAAABJRU5ErkJggg=='
|
||||
)
|
||||
tarinfo = tarfile.TarInfo('foo.png')
|
||||
tarinfo.size = len(icon_fd.getvalue())
|
||||
tar.addfile(tarinfo, fileobj=icon_fd)
|
||||
|
||||
return tar_io.getvalue()
|
||||
|
||||
|
||||
def test_deploy_application(app, admin_user, settings, app_bundle):
|
||||
def test_deploy_application(app, admin_user, settings, app_bundle, app_bundle_with_icon):
|
||||
Application.objects.all().delete()
|
||||
Wcs.objects.create(base_url='https://wcs.example.invalid', slug='foobar', title='Foobar')
|
||||
|
||||
|
@ -273,15 +322,21 @@ def test_deploy_application(app, admin_user, settings, app_bundle):
|
|||
login(app)
|
||||
|
||||
resp = app.get('/applications/')
|
||||
for i in range(2):
|
||||
for i in range(3):
|
||||
bundles = [app_bundle, app_bundle_with_icon, app_bundle]
|
||||
resp = resp.click('Install')
|
||||
resp.form['bundle'] = Upload('app.tar', app_bundle, 'application/x-tar')
|
||||
resp.form['bundle'] = Upload('app.tar', bundles[i], 'application/x-tar')
|
||||
with HTTMock(mocked_http):
|
||||
resp = resp.form.submit().follow()
|
||||
|
||||
assert Application.objects.count() == 1
|
||||
assert Application.objects.get(slug='test').name == 'Test'
|
||||
assert Application.objects.get(slug='test').elements.count() == 3
|
||||
app = Application.objects.get(slug='test')
|
||||
assert app.name == 'Test'
|
||||
if i == 1:
|
||||
assert re.match(r'applications/icons/foo(_\w+)?.png', app.icon.name)
|
||||
else:
|
||||
assert app.icon.name == ''
|
||||
assert app.elements.count() == 3
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
Loading…
Reference in New Issue