applications: add icon (#69652)
gitea-wip/hobo/pipeline/head There was a failure building this commit Details
gitea/hobo/pipeline/head Something is wrong with the build of this commit Details

This commit is contained in:
Lauréline Guérin 2022-10-18 17:38:31 +02:00
parent 9a60b030d6
commit 55988f5867
No known key found for this signature in database
GPG Key ID: 1FAB9B9B4F93D473
12 changed files with 166 additions and 14 deletions

View File

@ -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

View File

@ -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',
),
),
]

View File

@ -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')

View File

@ -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">

View File

@ -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 %}"

View File

@ -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>

View File

@ -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(

View File

@ -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 = (

View File

@ -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;
}
}

View File

@ -158,6 +158,8 @@ setup(
'dnspython',
'lxml',
'num2words==0.5.9',
'sorl-thumbnail',
'Pillow',
],
zip_safe=False,
cmdclass={

View File

@ -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

View File

@ -63,6 +63,8 @@ deps:
enum34<=1.1.6
psycopg2<2.9
psycopg2-binary<2.9
sorl-thumbnail
Pillow
black: pre-commit
commands =
./getlasso3.sh