# hobo - portal to configure and deploy applications # Copyright (C) 2015-2022 Entr'ouvert # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import io import json import tarfile import urllib.parse from django.conf import settings from django.core.files.base import ContentFile from django.http import HttpResponse, HttpResponseRedirect from django.urls import reverse, reverse_lazy from django.views.generic import FormView, ListView, TemplateView from django.views.generic.edit import CreateView, DeleteView, UpdateView from hobo.environment.utils import get_installed_services from .forms import InstallForm from .models import Application, Element, Relation, Version from .utils import Requests requests = Requests() class HomeView(ListView): template_name = 'hobo/applications/home.html' model = Application def get_queryset(self): return super().get_queryset().order_by('name') home = HomeView.as_view() class InitView(CreateView): template_name = 'hobo/applications/create.html' model = Application fields = ['name'] def get_success_url(self): return reverse('application-manifest', kwargs={'app_slug': self.object.slug}) init = InitView.as_view() def get_object_types(): object_types = [] for service_id, services in getattr(settings, 'KNOWN_SERVICES', {}).items(): if service_id not in Application.SUPPORTED_MODULES: continue service_objects = {x.get_base_url_path(): x for x in get_installed_services(types=[service_id])} for service in services.values(): if service_objects[service['url']].secondary: continue url = urllib.parse.urljoin(service['url'], 'api/export-import/') response = requests.get(url) if not response.ok: continue for object_type in response.json()['data']: object_type['service'] = service object_types.append(object_type) return object_types class ManifestView(TemplateView): template_name = 'hobo/applications/manifest.html' def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['app'] = Application.objects.get(slug=self.kwargs['app_slug']) context['relations'] = context['app'].relation_set.all().select_related('element') context['versions'] = context['app'].version_set.exists() context['types_by_service'] = {} type_labels = {} for object_type in get_object_types(): type_labels[object_type['id']] = object_type['singular'] if object_type.get('minor'): continue service = object_type['service']['title'] if service not in context['types_by_service']: context['types_by_service'][service] = [] context['types_by_service'][service].append(object_type) for relation in context['relations']: relation.element.type_label = type_labels.get(relation.element.type) return context manifest = ManifestView.as_view() class MetadataView(UpdateView): template_name = 'hobo/applications/edit-metadata.html' model = Application slug_url_kwarg = 'app_slug' fields = ['name', 'slug', 'description'] def get_success_url(self): return reverse('application-manifest', kwargs={'app_slug': self.object.slug}) metadata = MetadataView.as_view() class AppAddElementView(TemplateView): template_name = 'hobo/applications/add-element.html' def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['app'] = Application.objects.get(slug=self.kwargs['app_slug']) for object_type in get_object_types(): if object_type.get('id') == self.kwargs['type']: context['type'] = object_type url = object_type['urls']['list'] response = requests.get(url) context['elements'] = response.json()['data'] break return context def post(self, request, app_slug, type): context = self.get_context_data() app = context['app'] element_infos = {x['id']: x for x in context['elements']} for element_slug in request.POST.getlist('elements'): element, created = Element.objects.get_or_create( type=type, slug=element_slug, defaults={'name': element_infos[element_slug]['text']} ) element.name = element_infos[element_slug]['text'] element.cache = element_infos[element_slug] element.save() relation, created = Relation.objects.get_or_create(application=app, element=element) relation.auto_dependency = False relation.save() return HttpResponseRedirect(reverse('application-manifest', kwargs={'app_slug': app_slug})) add_element = AppAddElementView.as_view() class AppDeleteElementView(DeleteView): model = Relation template_name = 'hobo/applications/element_confirm_delete.html' def get_success_url(self): return reverse('application-manifest', kwargs={'app_slug': self.kwargs['app_slug']}) delete_element = AppDeleteElementView.as_view() def scandeps(request, app_slug): app = Application.objects.get(slug=app_slug) app.relation_set.filter(auto_dependency=True).delete() relations = app.relation_set.select_related('element') elements = {(x.element.type, x.element.slug): x.element for x in relations} finished = False while not finished: finished = True for (type, slug), element in list(elements.items()): dependencies_url = element.cache['urls'].get('dependencies') element.done = True if not dependencies_url: continue response = requests.get(dependencies_url) for dependency in response.json()['data']: if (dependency['type'], dependency['id']) in elements: continue finished = False element, created = Element.objects.get_or_create( type=dependency['type'], slug=dependency['id'], defaults={'name': dependency['text']} ) element.name = dependency['text'] element.cache = dependency element.save() relation, created = Relation.objects.get_or_create(application=app, element=element) if created: relation.auto_dependency = True relation.save() elements[(element.type, element.slug)] = element return HttpResponseRedirect(reverse('application-manifest', kwargs={'app_slug': app_slug})) def generate(request, app_slug): app = Application.objects.get(slug=app_slug) version = Version(application=app) version.save() tar_io = io.BytesIO() with tarfile.open(mode='w', fileobj=tar_io) as tar: manifest_json = { 'application': app.name, 'slug': app.slug, 'description': app.description, 'elements': [], } for relation in app.relation_set.all().select_related('element'): element = relation.element manifest_json['elements'].append( { 'type': element.type, 'slug': element.slug, 'name': element.name, 'auto-dependency': relation.auto_dependency, } ) response = requests.get(element.cache['urls']['export']) tarinfo = tarfile.TarInfo('%s/%s' % (element.type, element.slug)) tarinfo.mtime = version.last_update_timestamp.timestamp() tarinfo.size = int(response.headers['content-length']) tar.addfile(tarinfo, fileobj=io.BytesIO(response.content)) manifest_fd = io.BytesIO(json.dumps(manifest_json, indent=2).encode()) tarinfo = tarfile.TarInfo('manifest.json') tarinfo.size = len(manifest_fd.getvalue()) tarinfo.mtime = version.last_update_timestamp.timestamp() tar.addfile(tarinfo, fileobj=manifest_fd) version.bundle.save('%s.tar' % app_slug, content=ContentFile(tar_io.getvalue())) version.save() return HttpResponseRedirect(reverse('application-manifest', kwargs={'app_slug': app_slug})) def download(request, app_slug): app = Application.objects.get(slug=app_slug) version = app.version_set.order_by('-last_update_timestamp')[0] response = HttpResponse(version.bundle, content_type='application/x-tar') response['Content-Disposition'] = 'attachment; filename="%s"' % '%s.tar' % app_slug return response class Install(FormView): form_class = InstallForm template_name = 'hobo/applications/install.html' success_url = reverse_lazy('applications-home') def form_valid(self, form): tar_io = io.BytesIO(self.request.FILES['bundle'].read()) tar = tarfile.open(fileobj=tar_io) manifest = json.loads(tar.extractfile('manifest.json').read().decode()) app, created = Application.objects.get_or_create( slug=manifest.get('slug'), defaults={'name': manifest.get('application')} ) app.name = manifest.get('application') app.description = manifest.get('description') if created: # mark as non-editable only newly deployed applications, this allows # overwriting a local application and keep on developing it. app.editable = False else: app.relation_set.all().delete() app.save() for element_dict in manifest.get('elements'): element, created = Element.objects.get_or_create( type=element_dict['type'], slug=element_dict['slug'], defaults={'name': element_dict['name']} ) element.name = element_dict['name'] element.save() relation = Relation( application=app, element=element, auto_dependency=element_dict['auto-dependency'], ) relation.save() version = Version(application=app) version.bundle.save('%s.tar' % app.slug, content=ContentFile(tar_io.getvalue())) version.save() version.deploy() return super().form_valid(form) install = Install.as_view() class AppDeleteView(DeleteView): model = Application template_name = 'hobo/applications/app_confirm_delete.html' def get_success_url(self): return reverse('applications-home') delete = AppDeleteView.as_view()