views.py 50.5 KB
Newer Older
BERJON Matthieu's avatar
BERJON Matthieu committed
1
2
3
4
5
6
7
8
9
10
11
# -*- coding: utf-8 -*-
"""Main view module

This module handles most of the front-end for the Allgo system. You'll find
all the logic (controller) in an MVC pattern.

Attributes:
    log: module level variable to save information as a log data.

"""
# Python standard libraries
12
import glob
BERJON Matthieu's avatar
BERJON Matthieu committed
13
import io
14
15
16
import json
import logging
import os
17
import re
18
import shutil
19
import tempfile
20
import zipfile
21

BERJON Matthieu's avatar
BERJON Matthieu committed
22
# Third party imports
23
24
25
import iso8601
import natsort
import requests
26
from django.conf import settings
27
from django.contrib import messages
28
from django.contrib.auth.forms import PasswordChangeForm
29
from django.contrib.auth.mixins import LoginRequiredMixin
BERJON Matthieu's avatar
BERJON Matthieu committed
30
from django.contrib.auth.models import User
31
from django.contrib.messages.views import SuccessMessageMixin
32
from django.core.exceptions import ObjectDoesNotExist
CAMPION Sebastien's avatar
CAMPION Sebastien committed
33
from django.core.urlresolvers import reverse
34
from django.db import transaction
BERJON Matthieu's avatar
BERJON Matthieu committed
35
from django.db.models import Count
36
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect, FileResponse, Http404
BERJON Matthieu's avatar
BERJON Matthieu committed
37
from django.shortcuts import render, get_object_or_404, redirect
38
from django.urls import reverse, reverse_lazy
39
from django.utils.crypto import get_random_string
40
from django.utils.text import slugify
41
from django.views.decorators.csrf import csrf_exempt
BERJON Matthieu's avatar
BERJON Matthieu committed
42
from django.views.generic import (
CAMPION Sebastien's avatar
pep8    
CAMPION Sebastien committed
43
44
45
    CreateView,
    DeleteView,
    DetailView,
46
    FormView,
CAMPION Sebastien's avatar
pep8    
CAMPION Sebastien committed
47
48
49
50
51
    ListView,
    RedirectView,
    TemplateView,
    UpdateView,
    View,
BERJON Matthieu's avatar
BERJON Matthieu committed
52
)
53
from django.views.generic.detail import SingleObjectMixin
BERJON Matthieu's avatar
BERJON Matthieu committed
54
from taggit.models import Tag
55
from allauth.account.models import EmailAddress
BERJON Matthieu's avatar
BERJON Matthieu committed
56

BERJON Matthieu's avatar
BERJON Matthieu committed
57
from .forms import (
CAMPION Sebastien's avatar
cosmit    
CAMPION Sebastien committed
58
59
60
61
62
63
    UserForm,
    HomeSignupForm,
    UserWebappForm,
    JobForm,
    SSHForm,
    RunnerForm,
64
65
    WebappForm,
    WebappSandboxForm,
66
    WebappImportForm,
CAMPION Sebastien's avatar
cosmit    
CAMPION Sebastien committed
67
)
CAMPION Sebastien's avatar
pep8    
CAMPION Sebastien committed
68
# Local imports
BAIRE Anthony's avatar
BAIRE Anthony committed
69
import config
70
from .helpers import get_base_url, get_ssh_data, upload_data, notify_controller, lookup_job_file
71
from .mixins import IsProviderMixin
CAMPION Sebastien's avatar
pep8    
CAMPION Sebastien committed
72
73
from .models import (
    AllgoUser,
74
    DockerOs,
CAMPION Sebastien's avatar
pep8    
CAMPION Sebastien committed
75
    Job,
76
    JobQueue,
CAMPION Sebastien's avatar
pep8    
CAMPION Sebastien committed
77
78
79
    Quota,
    Runner,
    Webapp,
80
    WebappParameter,
CAMPION Sebastien's avatar
pep8    
CAMPION Sebastien committed
81
82
    WebappVersion,
)
BAIRE Anthony's avatar
BAIRE Anthony committed
83
from .signals import job_post_save
84
from .templatetags.converters import status_icon
BERJON Matthieu's avatar
BERJON Matthieu committed
85

BERJON Matthieu's avatar
BERJON Matthieu committed
86
# Start logger
87
log = logging.getLogger('allgo')
CAMPION Sebastien's avatar
CAMPION Sebastien committed
88

CAMPION Sebastien's avatar
CAMPION Sebastien committed
89

BERJON Matthieu's avatar
BERJON Matthieu committed
90
91
92
93
94
95
96
97
98
99
class IndexDetail(TemplateView):
    """Home view

    Generate the home as a standard `TemplateView` by calling a specific
    template. Most of the data are handled in the template itself, only few
    contexte data are provided for specific use.

    Attributes:
        template_name:  filename of the template used.

100
    """
BERJON Matthieu's avatar
BERJON Matthieu committed
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
    template_name = 'home.html'

    def get_context_data(self, **kwargs):
        """ Generate specific data to pass on in the context of the template.

        Returns:
            user_nb (int): number of users recorded in the database.
            webapp_nb (int): number of webapps recorded in the database.
            job_nb (int): number of jobs recorded in the database.
            signup_form: form specific for signin-up directly on the home page.
        """
        context = super(IndexDetail, self).get_context_data(**kwargs)
        users = User.objects.all().count()
        webapps = Webapp.objects.all().count()
        jobs = Job.objects.all().count()

        context['user_nb'] = users
        context['webapp_nb'] = webapps
        context['job_nb'] = jobs
        context['signup_form'] = HomeSignupForm()

        return context
BERJON Matthieu's avatar
BERJON Matthieu committed
123
124


BAIRE Anthony's avatar
BAIRE Anthony committed
125
126
127
128
129
130
131
132
133
# Legacy views
class LegacyWebappDetail(SingleObjectMixin, RedirectView):
    model = Webapp
    permanent = True
    slug_field = "docker_name"
    def get_redirect_url(self, **kwargs):
        return reverse("main:webapp_detail",
                args=(self.get_object().docker_name,))

134
135
# WEBAPPS
# -----------------------------------------------------------------------------
136
class WebappList(ListView):
BERJON Matthieu's avatar
BERJON Matthieu committed
137
    """ Display a paginated list of available webapps.
138

BERJON Matthieu's avatar
BERJON Matthieu committed
139
140
    The webapps are filtered from the most recent to the oldest and no private
    apps are displayed.
141

BERJON Matthieu's avatar
BERJON Matthieu committed
142
143
144
145
146
147
148
    Attributes:
        model:  Webapp model is used.
        context_object_name:    the name used in the template to display each
                                variable.
        paginate_by: the number of occurences per page
        template_name: name of the template loaded with this view.
        queryset: a specific queryset designed to filter the data.
149

BERJON Matthieu's avatar
BERJON Matthieu committed
150
151
152
    Todo:
        - the number of occurences per page could be loaded from the config
        file.
153
154

    """
BERJON Matthieu's avatar
BERJON Matthieu committed
155
    model = Webapp
BERJON Matthieu's avatar
BERJON Matthieu committed
156
    context_object_name = 'webapps'
BERJON Matthieu's avatar
BERJON Matthieu committed
157
158
    paginate_by = 10
    template_name = 'webapp_list.html'
159
    queryset = Webapp.objects.filter(private=0).order_by('-created_at')
160

BERJON Matthieu's avatar
BERJON Matthieu committed
161

BERJON Matthieu's avatar
BERJON Matthieu committed
162
class UserWebappList(ListView):
BERJON Matthieu's avatar
BERJON Matthieu committed
163
164
165
166
167
168
169
170
171
172
173
174
175
    """List of user's webapp

    Returns all the webapps owned by a specific user. Only the user can its
    apps.

    Attributes:
        model: database model
        context_object_name: variable name used in the template to display the
                            data.
        paginate_by: number of occurences by page.
        template_name: template filename.
                            
    """
BERJON Matthieu's avatar
BERJON Matthieu committed
176
177
178
179
180
181
182
183
184
185
186
187
188
    model = Webapp
    context_object_name = 'webapps'
    paginate_by = 10
    template_name = 'webapp_list.html'

    def get_queryset(self):
        """Filter apps for a given user"""
        user = User.objects.get(username=self.kwargs['username'])
        queryset = Webapp.objects.filter(user=user)
        return queryset


class WebappUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
BERJON Matthieu's avatar
BERJON Matthieu committed
189
190
191
192
193
194
195
196
    """Form to update the webapp data

    Attributes:
        form_class: form object.
        template_name: template filename.
        success_message: message when the form is properly submitted.

    """
BERJON Matthieu's avatar
BERJON Matthieu committed
197
    form_class = UserWebappForm
198
    template_name = 'webapp_update.html'
BERJON Matthieu's avatar
BERJON Matthieu committed
199
    success_message = 'Your app has been successfully updated.'
200
    error_message = 'The email doesn\'t belong to any registered user. Please enter a valid owner email address.'
BERJON Matthieu's avatar
BERJON Matthieu committed
201
202

    def get_success_url(self):
BERJON Matthieu's avatar
BERJON Matthieu committed
203
        """If successful redirect to the same page"""
BERJON Matthieu's avatar
BERJON Matthieu committed
204
205
206
        return reverse('main:webapp_update', args=(self.object.docker_name,))

    def get_object(self):
BERJON Matthieu's avatar
BERJON Matthieu committed
207
        """Returns the object according to its docker name or a 404 error"""
BERJON Matthieu's avatar
BERJON Matthieu committed
208
209
210
211
        data = self.kwargs.get('docker_name', None)
        queryset = get_object_or_404(Webapp, docker_name=data, user_id=self.request.user.id)
        return queryset

212
213
214
215
216
217
    def get_form(self):
        form = super().get_form()
        if not self.request.user.is_superuser:
            form.fields['memory_limit_mb'].widget.attrs['readonly'] = True
        return form

BERJON Matthieu's avatar
BERJON Matthieu committed
218
    def form_valid(self, form):
BERJON Matthieu's avatar
BERJON Matthieu committed
219
        """Save data coming from the form in the database """
BERJON Matthieu's avatar
BERJON Matthieu committed
220
221
222
223
        obj = form.save(commit=False)
        try:
            user = User.objects.get(username=form.cleaned_data['owner'])
            obj.user_id = user.id
224
            obj.memory_limit = form.get_memory_limit(self.request)
BERJON Matthieu's avatar
BERJON Matthieu committed
225
            form.save()
BERJON Matthieu's avatar
BERJON Matthieu committed
226
227
            # Add the tag to the database (specific because it's a many to 
            # many relationship)
228
            form.save_m2m()
BERJON Matthieu's avatar
BERJON Matthieu committed
229
            if user != self.request.user:
230
                messages.success(self.request, self.success_message)
BERJON Matthieu's avatar
BERJON Matthieu committed
231
232
233
234
                return redirect('main:user_webapp_list', self.request.user.username)
            else:
                return super(WebappUpdate, self).form_valid(form)
        except User.DoesNotExist:
235
            messages.error(self.request, self.error_message)
BERJON Matthieu's avatar
BERJON Matthieu committed
236
237
238
            return super(WebappUpdate, self).form_invalid(form)


239
class WebappCreate(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, CreateView):
BERJON Matthieu's avatar
BERJON Matthieu committed
240
241
242
243
244
245
246
247
248
249
    """Create a new webapp

    Attributes:
        model: model to use in this class.
        form_class: form object passed to the template.
        success_message: successfull message sent to the template
        template_name: template filename.
        group_required: groups that user must belong to.

    """
250
251
252
    model = Webapp
    form_class = WebappForm
    success_message = 'Webapp created successfully.'
253
    template_name = 'webapp_add.html'
254
    #  group_required = ['inria', ]
255
256

    def get_success_url(self):
BERJON Matthieu's avatar
BERJON Matthieu committed
257
        """If successful redirect to the webapp list page"""
258
        return reverse('main:webapp_sandbox_panel', args=(self.webapp.docker_name,))
259

260
261
262
263
264
265
    def get_form(self):
        form = super().get_form()
        if not self.request.user.is_superuser:
            form.fields['memory_limit_mb'].widget.attrs['readonly'] = True
        return form

266
    def form_valid(self, form):
BERJON Matthieu's avatar
BERJON Matthieu committed
267
        """Save data coming from the form in the database """
268
269
270
271
        obj = form.save(commit=False)
        obj.user_id = self.request.user.id
        if not form.cleaned_data['contact']:
            obj.contact = self.request.user.email
272
        obj.sandbox_state = Webapp.IDLE
273
274
275
        # Ensure that all specials characters are removed, spaces are replaced
        # by hyphens and everything is lower-cased
        obj.docker_name = slugify(form.cleaned_data['name'])
276
        obj.memory_limit = form.get_memory_limit(self.request)
277
278
279
280
281
282
283
284
285
286

        # validate the Webapp record before saving
        # (this is a safety measure, do not remove)
        # FIXME: currently this raises an exception if the slugify-generated
        #        docker_name does not comply with the model constraints
        #        (for example: 'root' and 'sshd' are reserved names)
        #        To solve this, i think we should let the user choose the
        #        docker_name
        obj.full_clean()

287
288
289
290
        obj.save()

        # set up the docker container for the app
        Quota.objects.create(user=self.request.user, webapp=obj)
291
292
293
294
        # pass on the webapp data to get_successful_url to redirect with the
        # correct arguments (for instance the docker_name)
        self.webapp = obj
        return super().form_valid(form)
BERJON Matthieu's avatar
BERJON Matthieu committed
295

296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
def get_rails_webapp_metadata(*, webapp_id=None, docker_name=None):
    """Download the metadata of a webapp from the legacy rails server
    
    Must provide either `webapp_id` or `docker_name`, but not both
    """
    assert bool(webapp_id) != bool(docker_name)

    if docker_name:
        url = "%s/app/%s/export" % (config.env.ALLGO_IMPORT_URL, docker_name)
    else:
        url = "%s/webapp/%d/export" % (config.env.ALLGO_IMPORT_URL, webapp_id)
    try:
        # use a 1s timeout to avoid blocking the django thread if the rails
        # server is not responding
        rep = requests.get(url, timeout=1)
        if rep.status_code == 404:
            raise Http404()
        rep.raise_for_status()
        js = rep.json()
        if (webapp_id not in (None, js["id"])
                or docker_name not in ("", js["docker_name"])):
            raise Exception("rails returned a webapp with inconsistent id or docker_name")
        return js
    except Exception as e:
        log.error("webapp import error: failed to get %s (%s)", url, e)
        raise

class WebappImport(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, FormView):
    """Import a new webapp

    This only creates the Webapp entry (along with the tags and webapp
    parameters), versions are imported separately.

    Once the webapp is imported, the Webapp entry is created with
    imported=True which enables the WebappVersionImport view (for import the
    actual versions, including the docker images).

    A webapp can be imported only if the e-mail of the current user strictly
    matches the owner e-mail of the imported app. If not, then the app has to
    be imported by a superuser, who can then transfer its ownership to the
    requesting user.

    An imported webapp will keep the same id and docker_name (to preserve the
    published urls). The import fails the id or docker_name is already used by
    another webapp.

    """
    model = Webapp
    form_class = WebappImportForm
    success_message = 'Webapp imported successfully.'
    template_name = 'webapp_import.html'

    def get_success_url(self):
        return reverse('main:webapp_version_import', args=(self.object.docker_name,))

    def get_context_data(self, **kwargs):
        ctx=super().get_context_data()
        ctx["import_url"] = config.env.ALLGO_IMPORT_URL+"/apps"
        return ctx

    def form_valid(self, form):
        def error(msg = "500 Internal Server Error"):
            messages.error(self.request, "Import failed : " + msg)
            return self.form_invalid(form)

        # parse the form parameters an prepare the import url
        webapp_id = form.cleaned_data["webapp_id"]
        docker_name = form.cleaned_data["docker_name"]
        if bool(webapp_id) == bool(docker_name):
            return error("You must provide either a name or an id")
            
        # get the metadata from the rails server an store them in var 'js'
        try:
            js = get_rails_webapp_metadata(webapp_id=webapp_id,
                    docker_name=docker_name)
        except Http404:
            return error("application not found")
        except Exception as e:
            return error()

        webapp_id   = js["id"]
        docker_name = js["docker_name"]

        # ensure this app does not already exist locally
        if Webapp.objects.filter(docker_name=docker_name).exists():
            return error("webapp named %r already exists" % docker_name)
        if Webapp.objects.filter(id=webapp_id).exists():
            return error("webapp id %r already exists" % webapp_id)
        
        current_user = self.request.user
        if not current_user.is_superuser:
            # ensure this app has the same owner
            if current_user.email != js["user"]:
                return error("""this webapp belongs to another user (if you think
                it really belongs to you, then you should contact the
                administrators)""")

            # ensure the user email is verified
            #TODO support gitlab accounts
            if not EmailAddress.objects.filter(user=current_user,
                    email=current_user.email, verified=True).exists():
                return error("your e-mail address is not yet verified")

        # We can import the webapp !

        webapp = Webapp(user=current_user, imported=True,
                docker_os=DockerOs.objects.first())
        # TODO: import logo+readme (but they are not yet implemented in django)
        for field_name in ("id", "docker_name", "name", "description",
                "contact", "default_quota", "entrypoint", "private",
                "memory_limit"):
            setattr(webapp, field_name, js[field_name])

        # try to use job queue with same name or fallback to the default queue
        webapp.job_queue = JobQueue.objects.filter(name=js["default_job_queue"]
                ).first() or JobQueue.objects.filter(is_default=True).first()

        webapp.save()

        # import the tags and parameters
        webapp.tags.add(*js["tags"])
        for param in js["parameters"]:
            if (param["value"], param["name"], param["detail"]) != (
                    None, None, None):
                WebappParameter.objects.create(webapp=webapp,
                        name=param["name"], value=param["value"],
                        detail=param["detail"])

        self.object = webapp
        return super().form_valid(form)

class WebappVersionImport(LoginRequiredMixin, DetailView):
    """Import version

    This view is enabled only for webapps created with imported=True

    The GET view lists the current status of remote version along with the
    local version (if any). If the remote version can be imported, it displays
    a checkbox to allow requesting its import.

    The POST view creates the WebappVersion entries with state=IMPORT and
    notifies the controller (which performs the actual import). The import is
    considered done as soon as the entry reaches the COMMITTED state.
    """
    template_name = 'webapp_version_import.html'

    def get_object(self):
        """Returns the object according to its docker name or a 404 error"""
        data = self.kwargs.get('docker_name', None)
        queryset = get_object_or_404(Webapp, docker_name=data, user_id=self.request.user.id)
        return queryset

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        webapp = self.object

        if not webapp.imported:
            raise Http404()

        # url of this webapp on the legacy server
        ctx["import_url"] = "%s/app/%s" % (
                config.env.ALLGO_IMPORT_URL, webapp.docker_name)

        # get the webapp metadata from the legacy server
        remote_versions = get_rails_webapp_metadata(
                docker_name=webapp.docker_name)["versions"]

        # dict of local webapp versions (indexed by the number) 
        # (if multiple entries exist with the same number (this happens if a
        # commit/push/import is in progress), we keep the one with the highest
        # id)
        local_versions = {}
        for v in WebappVersion.objects.filter(webapp=webapp).exclude(
                state__in=(WebappVersion.ERROR,
                    WebappVersion.REPLACED)).order_by("id"):
            local_versions[v.number] = v

        # list of versions to be displayed on the page
        versions = {}
        for remote in remote_versions:
            number = remote["number"]
            assert number not in versions, "rails must not export duplicated versions"
            local_version = local_versions.get(number)
            in_progress = getattr(local_version, "state", None) == WebappVersion.IMPORT
            versions[number] = {
                    "number":    number,
                    "remote_ts": iso8601.parse_date(remote["updated_at"]),
                    "local_ts":         "-" if in_progress else getattr(local_version, "updated_at", ""),
                    "local_imported":   None if in_progress else getattr(local_version, "imported", None),
                    "in_progress":      in_progress,
                    }
        ctx["versions"] = natsort.versorted(versions.values(),
                key=lambda v: v["number"], reverse=True)

        return ctx

    def post(self, request, *, docker_name):
        webapp = self.get_object()
        if not webapp.imported:
            raise Http404()

        remote_versions = get_rails_webapp_metadata(
                docker_name=webapp.docker_name)["versions"]

        for remote in remote_versions:
            number = remote["number"]
            if request.POST.get("version_"+number):
                log.info("import version %s", number)
                version = WebappVersion.objects.update_or_create({
                    "imported": True,
                    "published":         remote["published"],
                    "description":       remote["changelog"],
                    "docker_image_size": remote["docker_image_size"],
                    },
                    webapp=webapp,
                    number=number,
                    state=WebappVersion.IMPORT)[0]
                version.created_at = remote["created_at"]
                version.updated_at = remote["updated_at"]
                version.save()

        transaction.on_commit(lambda: notify_controller(webapp))

        return HttpResponseRedirect(request.path_info)

BERJON Matthieu's avatar
BERJON Matthieu committed
521

522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
class WebappJson(LoginRequiredMixin, DetailView):
    """json variant of the application details
    
    (used by the /aio/apps/<DOCKER_NAME>/events endpoint)
    """

    def get_object(self):
        """Returns the object according to its docker name or a 404 error"""
        data = self.kwargs.get('docker_name', None)
        queryset = get_object_or_404(Webapp, docker_name=data)
        return queryset

    def render_to_response(self, context, **kwargs):
        webapp = context["webapp"]
        return JsonResponse({
            "id":               webapp.id,
            "sandbox_state":    webapp.get_sandbox_state_display(),
            })

541
class WebappSandboxPanel(LoginRequiredMixin, TemplateView):
BERJON Matthieu's avatar
BERJON Matthieu committed
542
543
544
545
546
547
548
549
    """Create a new sandbox for a given application

    Attributes:
        form_class: form object to pass on the template.
        model: model to use in this class
        template_name: template filename

    """
550
    template_name = 'webapp_sandbox_panel.html'
551
552

    def get_object(self):
BERJON Matthieu's avatar
BERJON Matthieu committed
553
        """Returns the object according to its docker name or a 404 error"""
554
        data = self.kwargs.get('docker_name', None)
555
        queryset = get_object_or_404(Webapp, docker_name=data, user_id=self.request.user.id)
556
557
558
        return queryset

    def get_context_data(self, **kwargs):
BERJON Matthieu's avatar
BERJON Matthieu committed
559
560
561
562
563
564
565
        """Recover data to pass on to the template context
        
        In order to give the user a feedback regarding the way to push its image
        to the registry, we need to pass both the webapp `docker_name` and the
        `registry` URL.

        """
566
        context = super().get_context_data(**kwargs)
567
        context['webapp'] = self.get_object()
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582

        context["ssh_command"] = "ssh%s %s@%s" % (
                (" -p %s" % config.env.ALLGO_SSH_PORT
                    if config.env.ALLGO_SSH_PORT != "22" else ""),
                (kwargs["docker_name"]),
                (config.env.ALLGO_SSH_HOST))

        # candidate docker os (start from scratch)
        context['docker_os_list'] = DockerOs.objects.all()

        # candidate versions (start from an existing version)
        versions = {}
        for state in (WebappVersion.READY, WebappVersion.COMMITTED):
            versions.update((v.number, v) for v in WebappVersion.objects.filter(
                webapp=context["webapp"], state=state))
583
584
        context['versions'] = natsort.versorted(versions.values(), key=lambda v: v.number)
        context['versions'].reverse()
585
586
        return context

587
588
589
    def post(self, request, *, docker_name):
        log.info("POST %r", request.POST)

590
        webapp = self.get_object()
591
592
        action = request.POST["action"]

BAIRE Anthony's avatar
BAIRE Anthony committed
593
594
595
596
597
        def stop_sandbox():
            webapp.sandbox_state = Webapp.STOPPING
            webapp.sandbox_version_id = None
            webapp.save()

598
599
        log.info("action %r", request.POST["action"])
        if action == "start":
BAIRE Anthony's avatar
BAIRE Anthony committed
600
601
602
603
604
            if webapp.sandbox_state != Webapp.IDLE:
                messages.error(request,
                        "unable to start sandbox %r because it not idle"
                        % webapp.name)
            else:
605
606
607
608
609
610
611
612
613
                if "webapp_version_id" in request.POST:
                    # start from an existing version
                    webapp.sandbox_version_id = int(request.POST["webapp_version_id"])
                else:
                    # start from scratch
                    webapp.docker_os_id = request.POST["docker_os_id"]
                    webapp.sandbox_version = None
                webapp.sandbox_state = Webapp.STARTING
                webapp.save()
BAIRE Anthony's avatar
BAIRE Anthony committed
614
                messages.success(request, "starting sandbox %r" % webapp.name)
615
616

        elif action == "commit":
BAIRE Anthony's avatar
BAIRE Anthony committed
617
618
619
620
621
            if webapp.sandbox_state != Webapp.RUNNING:
                messages.error(request,
                        "unable to commit sandbox %r because it is not running"
                        % webapp.name)
            else:
622
623
624
625
626
                # query previous active versions of this webapp
                previous = WebappVersion.objects.filter(webapp=webapp,
                            state__in = (WebappVersion.READY, WebappVersion.COMMITTED))
                extra = {}

627
628
                if request.POST["version-action"] == "replace-version":
                    number = request.POST["version-select"]
629
630
                    # keep the previous 'created_at' timestamp when replacing an image
                    extra["created_at"] = getattr(previous.filter(number=number).first(), "created_at")
631
632
                else:
                    number = request.POST["version-new"]
633
634
635

                    # ensure that this version number does not already exist
                    if previous.filter(number=number).exists():
636
637
638
                        messages.error(request, "unable to commit because version %r already exists"
                                " (if you want to overwrite this version, then use"
                                "  'replace version' instead)" % number)
BAIRE Anthony's avatar
BAIRE Anthony committed
639
                        return HttpResponseRedirect(request.path_info)
640

641
642
                WebappVersion.objects.create(
                        webapp=webapp,
643
                        number=number,
644
645
                        state=WebappVersion.SANDBOX,
                        published=True,
646
                        description=request.POST["description"],
647
                        **extra)
BAIRE Anthony's avatar
BAIRE Anthony committed
648
                stop_sandbox()
649

BAIRE Anthony's avatar
BAIRE Anthony committed
650
651
                messages.success(request, "committing sandbox %r version %r"
                        % (webapp.name, number))
652

653
654
        elif action == "rollback":
            if webapp.sandbox_state == Webapp.RUNNING:
BAIRE Anthony's avatar
BAIRE Anthony committed
655
                stop_sandbox()
BAIRE Anthony's avatar
BAIRE Anthony committed
656
657
658
659
                messages.success(request, "rolling back sandbox %r" % webapp.name)
            else:
                messages.error(request, "unable to roll back, sandbox %r is not running"
                        % webapp.name)
660
661
662

        elif action == "abort":
            if webapp.sandbox_state == Webapp.START_ERROR:
BAIRE Anthony's avatar
BAIRE Anthony committed
663
                stop_sandbox()
BAIRE Anthony's avatar
BAIRE Anthony committed
664
                messages.success(request, "reset sandbox %r" % webapp.name)
665
666
667
668
669

        elif action == "retry":
            if webapp.sandbox_state == Webapp.START_ERROR:
                webapp.sandbox_state = Webapp.STARTING
                webapp.save()
BAIRE Anthony's avatar
BAIRE Anthony committed
670
                messages.success(request, "starting sandbox %r" % webapp.name)
671
            elif webapp.sandbox_state == Webapp.STOP_ERROR:
BAIRE Anthony's avatar
BAIRE Anthony committed
672
                stop_sandbox()
BAIRE Anthony's avatar
BAIRE Anthony committed
673
                messages.success(request, "stopping sandbox %r" % webapp.name)
674

BAIRE Anthony's avatar
BAIRE Anthony committed
675
676
        log.debug("new sandbox state: %r -> %r",
                webapp.docker_name, webapp.sandbox_state)
677

BAIRE Anthony's avatar
BAIRE Anthony committed
678
679
680
681
682
        # NOTE: we return a 302 redirect to the same page (instead of rendering
        # it directly) to force the browser to make a separate GET request.
        # This prevent reexecuting the POST request if the user refreshes the
        # page.
        return HttpResponseRedirect(request.path_info)
683

684
685
686
687
688
689
690
691
692
693
694
695
# TAGS
# -----------------------------------------------------------------------------

class TagList(ListView):
    """List all available tag along with their number of occurences

    Attributes:
        model: database model
        context_object_name: variable name used in the template to display the
                            data.
        template_name: template filename.
    """
BERJON Matthieu's avatar
BERJON Matthieu committed
696
    model = Tag
697
698
699
700
701
    context_object_name = 'tags'
    template_name = 'tag_list.html'

    def get_queryset(self):
        """Return all available tags
BERJON Matthieu's avatar
BERJON Matthieu committed
702

703
704
        Each tag return as well the number of webapps attached to it
        """
BERJON Matthieu's avatar
BERJON Matthieu committed
705
        tags = Tag.objects.annotate(num_tag=Count('taggit_taggeditem_items'))
706
707
708
709
710
711
712
713
714
715
716
717
718
        return tags


class TagWebappList(ListView):
    """List all available webapps for a given tag

    Attributes:
        model: database model
        context_object_name: variable name used in the template to display the
                            data.
        paginated_by: number of occurences per page.
        template_name: template filename.
    """
BERJON Matthieu's avatar
BERJON Matthieu committed
719
    model = Webapp
720
721
722
723
724
    context_object_name = 'webapps'
    paginated_by = 10
    template_name = 'tag_webapp_list.html'

    def get_queryset(self):
BERJON Matthieu's avatar
BERJON Matthieu committed
725
        return Webapp.objects.filter(tags__slug=self.kwargs['slug'])
726

727
728
729
    def get_context_data(self, **kwargs):
        return super().get_context_data(tag=self.kwargs["slug"], **kwargs)

730

731
732
733
# PROFILE
# -----------------------------------------------------------------------------
class UserUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
BERJON Matthieu's avatar
BERJON Matthieu committed
734
735
736
737
738
739
740
    """Update the user profile

    Attributes:
        form_class: form object to pass on the template.
        template_name: template filename
        success_message: successfull message sent to the template
    """
741
    form_class = UserForm
742
    template_name = "user_update.html"
743
744
745
    success_message = 'Profile updated successfully.'

    def get_success_url(self):
BERJON Matthieu's avatar
BERJON Matthieu committed
746
        """If successful redirect to the user page"""
747
748
749
        return reverse('main:user_detail')

    def get_object(self):
BERJON Matthieu's avatar
BERJON Matthieu committed
750
        """Only get the User record for the user making the request"""
751
752
753
        return User.objects.get(username=self.request.user.username)

    def get_context_data(self, **kwargs):
BERJON Matthieu's avatar
BERJON Matthieu committed
754
755
756
757
758
759
760
        """Recover data to pass on to the template

        In order to display specific data, I process the SSH key to get its
        fingerprint and comment. Both the SSH key, fingerprint, comment and
        token to context template.

        """
761
762
763
764
765
        queryset = AllgoUser.objects.get(user_id=self.request.user.id)
        key = queryset.sshkey
        token = queryset.token
        if key:
            fingerprint, comment = get_ssh_data(key)
BERJON Matthieu's avatar
BERJON Matthieu committed
766
            kwargs['sshkey'] = True
767
768
769
770
771
772
773
774
            kwargs['ssh_comment'] = comment
            kwargs['ssh_fingerprint'] = fingerprint
        if token:
            kwargs['token'] = token
        return super(UserUpdate, self).get_context_data(**kwargs)


class UserToken(LoginRequiredMixin, RedirectView):
BERJON Matthieu's avatar
BERJON Matthieu committed
775
    """Regenerate the user token"""
776

777
778
    success_message = 'Token generated successfully.'

779
    def dispatch(self, request, *args, **kwargs):
BERJON Matthieu's avatar
BERJON Matthieu committed
780
        """Generate the token and save it into the database"""
781
782
783
784
785
786
        queryset = AllgoUser.objects.get(user_id=self.request.user.id)
        queryset.token = get_random_string(length=32)
        queryset.save()
        return super(UserToken, self).dispatch(request, *args, **kwargs)

    def get_redirect_url(self, *args, **kwargs):
BERJON Matthieu's avatar
BERJON Matthieu committed
787
        """Redirect the user to the user page and display a successful message"""
788
        messages.success(self.request, self.success_message)
789
790
        return reverse('main:user_detail')

BERJON Matthieu's avatar
BERJON Matthieu committed
791
792

class UserSSHAdd(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
BERJON Matthieu's avatar
BERJON Matthieu committed
793
794
795
796
797
798
799
    """Save a SSH key to the database.
    
    Attributes:
        form_class: form object to pass on the template.
        template_name: template filename
        success_message: successfull message sent to the template
    """
BERJON Matthieu's avatar
BERJON Matthieu committed
800
    form_class = SSHForm
801
    template_name = 'user_ssh_add.html'
BERJON Matthieu's avatar
BERJON Matthieu committed
802
803
804
    success_message = 'SSH key added successfully.'

    def get_success_url(self):
BERJON Matthieu's avatar
BERJON Matthieu committed
805
        """If successful redirect to the user page"""
BERJON Matthieu's avatar
BERJON Matthieu committed
806
807
808
        return reverse('main:user_detail')

    def get_object(self):
BERJON Matthieu's avatar
BERJON Matthieu committed
809
        """Only get the User record for the user making the request"""
BERJON Matthieu's avatar
BERJON Matthieu committed
810
        return AllgoUser.objects.get(user_id=self.request.user.id)
811
812
813


class UserSSHDelete(LoginRequiredMixin, RedirectView):
BERJON Matthieu's avatar
BERJON Matthieu committed
814
    """Delete the user SSH key"""
815

816
817
    success_message = 'The SSH key has been successfully deleted.'

818
    def dispatch(self, request, *args, **kwargs):
BERJON Matthieu's avatar
BERJON Matthieu committed
819
        """Generate an empty SSH key and save it into the database"""
820
821
822
823
824
825
        queryset = AllgoUser.objects.get(user_id=request.user.id)
        queryset.sshkey = ''
        queryset.save()
        return super(UserSSHDelete, self).dispatch(request, *args, **kwargs)

    def get_redirect_url(self, *args, **kwargs):
BERJON Matthieu's avatar
BERJON Matthieu committed
826
        """If successful redirect to the user page"""
827
        messages.success(self.request, self.success_message)
828
829
830
        return reverse('main:user_detail')


831
class UserPasswordUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
BERJON Matthieu's avatar
BERJON Matthieu committed
832
    """Update the user's password.
833
834
835

    We reuse the Django password form system in order to keep something robust
    even if it dedicates a specific view for it.
BERJON Matthieu's avatar
BERJON Matthieu committed
836
837
838
839
840
841

    Attributes:
        success_url: URL or handle where the user will be redirected.
        form_class: form object to pass on the template.
        template_name: template filename
        success_message: successfull message sent to the template
842
843
844
    """
    success_url = reverse_lazy('main:user_detail')
    form_class = PasswordChangeForm
845
    template_name = "user_password_update.html"
846
847
848
    success_message = 'Password updated successfully.'

    def get_object(self, queryset=None):
BERJON Matthieu's avatar
BERJON Matthieu committed
849
850
851
852
853
        """Return the user data
        
        Todo:
            - Not sure the relevance of getting this information to the template
        """
854
855
856
        return self.request.user

    def get_form_kwargs(self):
BERJON Matthieu's avatar
BERJON Matthieu committed
857
        """Return the arguments related to the user"""
858
859
860
861
862
        kwargs = super(UserPasswordUpdate, self).get_form_kwargs()
        kwargs['user'] = kwargs.pop('instance')
        return kwargs

    def dispatch(self, request, *args, **kwargs):
BERJON Matthieu's avatar
BERJON Matthieu committed
863
864
865
866
867
868
        """
        Todo:
            - I'm not sure why I wrote that and why it is useful in the present
              case. It needs to be investigated.

        """
869
870
        return super(UserPasswordUpdate, self) \
            .dispatch(request, *args, **kwargs)
BERJON Matthieu's avatar
BERJON Matthieu committed
871

BERJON Matthieu's avatar
BERJON Matthieu committed
872
873
874
875
# JOBS
# -----------------------------------------------------------------------------

class JobList(LoginRequiredMixin, ListView):
BERJON Matthieu's avatar
BERJON Matthieu committed
876
877
878
879
880
881
882
883
884
885
886
887
    """Display the list of jobs for a given identified user

    Attributes:
        model:  model used in the view.
        context_object_name:    the name used in the template to display each
                                variable.
        paginate_by: the number of occurences per page
        template_name: name of the template loaded with this view.
        redirect_field_name: None

    Todo:
        - Check the relevance of `redirect_field_name` and delete it if necessary
BERJON Matthieu's avatar
BERJON Matthieu committed
888
889
890
891
892
893
894
895
896
    """
    model = Job
    context_object_name = 'job_list'
    template_name = 'job_list.html'
    paginate_by = 10
    redirect_field_name = 'redirect_to'

    def get_queryset(self):
        """Filter jobs for a given user"""
897
898
        queryset = Job.objects.filter(user_id=self.request.user.id
                ).exclude(state__in=(Job.DELETED, Job.ARCHIVED)).order_by('-id')
BERJON Matthieu's avatar
BERJON Matthieu committed
899
        return queryset
BERJON Matthieu's avatar
BERJON Matthieu committed
900

901

902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
class JobDetail(LoginRequiredMixin, DetailView):
    """Get a job detail for a specific user

    Attributes:
        model:  model used in the view.
        context_object_name:    the name used in the template to display each
                                variable.
        template_name: name of the template loaded with this view.

    """
    model = Job
    template_name = 'job_detail.html'
    context_object_name = 'job'

    def get_context_data(self, **kwargs):
        """Recover the logs and files related to this job"""
BERJON Matthieu's avatar
BERJON Matthieu committed
918
919
        job = Job.objects.get(pk=self.object.pk)

920
921
922
        if job.state == Job.DONE:
            # job is done
            # -> read the `allgo.log` file
923
            log_file = os.path.join(job.data_dir, 'allgo.log')
924
            try:
925
                with open(log_file, 'r', errors="replace") as log_data:
BERJON Matthieu's avatar
BERJON Matthieu committed
926
                    logs = log_data.read()
927
928
929
930
931
932
933
            except OSError as e:
                logs = '(logs not available)'
                log.error("Log file not available for job #%d (%s)", job.id, e)
        else:
            # job is pending
            # -> logs will be streamed (ajax request)
            logs = ""
BERJON Matthieu's avatar
BERJON Matthieu committed
934
935
        kwargs['logs'] = logs

936
937
938
        # Hide the logs panel if the job is not yet started
        kwargs["logs_hidden"] = "hidden" if job.state in (Job.NEW, Job.WAITING) else ""

BERJON Matthieu's avatar
BERJON Matthieu committed
939
940
        # Get the files and some metadata such as the webapp version
        webapp = Webapp.objects.get(docker_name=self.object.webapp.docker_name)
941
942
943
944
945
946

        # List all job files
        # NOTE: calling lookup_job_file is a security feature
        kwargs['files'] = [x for x in os.listdir(job.data_dir)
                if lookup_job_file(job.id, x)]

BERJON Matthieu's avatar
BERJON Matthieu committed
947
        return super().get_context_data(**kwargs)
948

949
950
    def render_to_response(self, context, **kwargs):
        if self.request.META.get("HTTP_ACCEPT") == "application/json":
951
952
            # json variant of the job details
            # (used by the /aio/jobs/<ID>/events endpoint)
953
954
955
956
            job = context["job"]
            return JsonResponse({
                "id":           job.id,
                "state":        job.get_state_display(),
957
                "result":       job.get_result_display(),
958
959
960
961
962
                "rendered_status": status_icon(job),
                "exec_time":    job.exec_time,
                })
        else:
            return super().render_to_response(context, **kwargs)
963

BERJON Matthieu's avatar
BERJON Matthieu committed
964
965
966
class JobCreate(SuccessMessageMixin, CreateView):
    """ Display the data related a specific web and create a job instance
        into the database
BERJON Matthieu's avatar
BERJON Matthieu committed
967
968
969
970
971
972
973
974
975

    Attributes:
        model:  model used in the view.
        form_class: form object to pass on the template.
        success_url: URL or handle where the user will be redirected.
        success_message: successfull message sent to the template
        template_name: name of the template loaded with this view.

    """
976
977
978
    model = Job
    form_class = JobForm
    success_message = 'Job created successfully.'
BERJON Matthieu's avatar
BERJON Matthieu committed
979
    template_name = 'webapp_detail.html'
980

981
982
983
    def get_success_url(self):
        return reverse('main:job_detail', args=(self.job_id,))

984
    def form_valid(self, form):
BERJON Matthieu's avatar
BERJON Matthieu committed
985
        """Save data coming from the form in the database """
986
987
        webapp = Webapp.objects.get(docker_name=self.kwargs['docker_name'])

BERJON Matthieu's avatar
BERJON Matthieu committed
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
        # If the user isn't identified, we send back an error message and
        # and redirect the user.
        if self.request.user.is_anonymous():
            messages.add_message(self.request, messages.ERROR, 'You must be identified to create a job.')
            log.warning("Someone tried to run a job without being identified.")
            return redirect('main:webapp_detail', webapp.docker_name)
        else:
            obj = form.save(commit=False)
            obj.queue_id = form.cleaned_data.get('queue_id').id
            obj.state = Job.NEW
            obj.result = 0
            obj.user_id = self.request.user.id
            obj.webapp_id = webapp.id
            obj.version = form.cleaned_data.get('version')
            obj.save()
1003

BERJON Matthieu's avatar
BERJON Matthieu committed
1004
            # Upload files if there are any
1005
            upload_data(self.request.FILES.getlist('files'), obj)
BERJON Matthieu's avatar
BERJON Matthieu committed
1006

BERJON Matthieu's avatar
BERJON Matthieu committed
1007
1008
1009
            # start the job
            obj.state = Job.WAITING
            obj.save()
1010
            self.job_id = obj.id
1011

BERJON Matthieu's avatar
BERJON Matthieu committed
1012
            return super().form_valid(form)
1013
1014

    def get_context_data(self, **kwargs):
BERJON Matthieu's avatar
BERJON Matthieu committed
1015
        """Pass on the docker name to the template"""
1016
        webapp = Webapp.objects.get(docker_name=self.kwargs['docker_name'])
BERJON Matthieu's avatar
BERJON Matthieu committed
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
        kwargs['webapp'] = webapp

        # Check if a readme is declared in the database
        if webapp.readme:
            readme_file = os.path.join(
                settings.MEDIA_ROOT,
                self.object.docker_name,
                'Readme')
            if os.path.exists(readme_file):
                with open(readme_file, 'r') as md_data:
                    kwargs['readme'] = md_data.read()
            else:
                log.warning("No README available for app %s", self.model.name)
        else:
            readme_file = None
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048

        # select the list of versions to be displayed
        versions = natsort.versorted(set(v for v, in WebappVersion.objects
                .filter(webapp=webapp, state__in=(  WebappVersion.SANDBOX,
                                                    WebappVersion.COMMITTED,
                                                    WebappVersion.READY))
                .values_list("number")))

        # also list 'sandbox' if the sandbox is running and if the current user
        # is allowed to use the sandbox
        if webapp.sandbox_state == Webapp.RUNNING and (
            webapp.is_pushable_by(self.request.user)):
            versions.append("sandbox")
        
        versions.reverse()
        kwargs['versions'] = versions

1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
        # build the sample command lines for using the REST API
        base_url = get_base_url(self.request)
        user = self.request.user
        auth = "Authorization: Token token=" + (
                user.allgouser.token if user.is_authenticated else "<your private_token>")

        kwargs["job_create_cmd"] = ["curl", "-H", auth,
                "-X", "POST", base_url + reverse("api:jobs"),
                None, "-F", "job[webapp_id]=" + str(webapp.id),
                None, "-F", "job[param]=",
1059
                None, "-F", "job[queue]=" + webapp.job_queue.name,
1060
1061
1062
1063
1064
1065
1066
                None, "-F", "files[0]=@test.txt",
                None, "-F", "files[1]=@test2.csv",
                None, "-F", "job[file_url]=<my_file_url>",
                None, "-F", "job[dataset]=<my_dataset_name>",
                ]

        kwargs["job_result_cmd"] = ["curl", "-H", auth,
1067
                base_url + reverse("api:job", args=(42,)).replace("42", "<job_id>")]
1068

BERJON Matthieu's avatar
BERJON Matthieu committed
1069
        return super().get_context_data(**kwargs)
1070

1071
1072
    def get_form_kwargs(self):
        """Return webapp data"""
BERJON Matthieu's avatar
BERJON Matthieu committed
1073
        kwargs = super().get_form_kwargs()
1074
1075
1076
1077
        queryset = Webapp.objects.get(docker_name=self.kwargs['docker_name'])
        kwargs['webapp'] = queryset
        return kwargs

BAIRE Anthony's avatar
BAIRE Anthony committed
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
class JobAbort(LoginRequiredMixin, View):
    def post(self, request, *, pk):
        job_id = int(pk)
        # switch state to ABORTING if the job is running (this is done
        # atomically to avoid messing up with the controller)
        if Job.objects.filter(id=job_id, state=Job.RUNNING
                ).update(state=Job.ABORTING):
            job_post_save(Job.objects.get(id=job_id))
            messages.success(request, "aborting job %s" % job_id)
        else:
            messages.error(request, "unable to abort job %s because is not running" % job_id)
        return redirect('main:job_detail', job_id)

        

1093

1094
class JobDelete(LoginRequiredMixin, DeleteView):
BERJON Matthieu's avatar
BERJON Matthieu committed
1095
1096
1097
1098
1099
1100
1101
1102
    """Delete a job from the database

    Attributes:
        model:  model used in the view.
        success_url: URL or handle where the user will be redirected.
        success_message: successfull message sent to the template
        template_name: name of the template loaded with this view.

1103
1104
1105
1106
1107
1108
1109
1110
    Note:
        The `success_message` can't be used alone with the
        `SuccessMessageMixin` because it's hooked to `form_valid` method and 
        can't work with a `DeleteView`.

    See also:
        https://code.djangoproject.com/ticket/21926

BERJON Matthieu's avatar
BERJON Matthieu committed
1111
1112
1113
1114
1115
1116
    """
    model = Job
    success_message = 'Job successfully deleted.'
    success_url = reverse_lazy('main:job_list')
    template_name = 'job_delete.html'

1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
    @classmethod
    def as_view(cls, **kw):
        # manage db transactions manually
        return transaction.non_atomic_requests(super().as_view(**kw))

    def delete(self, request, *args, pk, **kwargs):
        # NOTE: if job is in WAITING state, then any state update must be done
        # atomically so as not to mess up with che controller
        if not (Job.objects.filter(id=pk, state=Job.DONE
                    ).update(state=Job.ARCHIVED)
            or  Job.objects.filter(id=pk, state__in=(Job.NEW, Job.WAITING)
                    ).update(state=Job.DELETED)
            or  Job.objects.filter(id=pk, state__in=(Job.DELETED, Job.ARCHIVED)).exists()
            ):

1132
            messages.error(self.request, "cannot delete a running job")
1133
            return redirect('main:job_detail', pk)
1134

1135
        transaction.commit()
1136

1137
        self.object = job = self.get_object()
1138
        notify_controller(job) # so that the DELETED/ARCHIVED state is propagated into the redis db
1139
1140
1141

        # delete the data dir if present
        # FIXME: if this fail then we have dangling files staying in the way
1142
        job_dir = job.data_dir
1143
1144
1145
        if os.path.exists(job_dir):
            shutil.rmtree(job_dir)

1146
1147
1148
1149
1150
        if job.state == Job.DELETED:
            job.delete()

        messages.success(self.request, self.success_message)
        return redirect(self.get_success_url())
1151

1152

BERJON Matthieu's avatar
BERJON Matthieu committed
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162

class JobFileDownload(LoginRequiredMixin, View):
    """Download a given file"""

    def get(self, request, *args, **kwargs):
        """Return a file for a given job and filename
        """
        # get file
        job_id = self.kwargs['pk']
        filename = self.kwargs['filename']
1163
        return redirect("/datastore/%s/%s" % (job_id, filename))
BERJON Matthieu's avatar
BERJON Matthieu committed
1164
1165


1166
class JobFileDownloadAll(LoginRequiredMixin, SingleObjectMixin, View):
BERJON Matthieu's avatar
BERJON Matthieu committed
1167
1168
    """Archive and download all files of a given job
    """
1169
    model = Job
BERJON Matthieu's avatar
BERJON Matthieu committed
1170
1171
1172
1173
1174
1175
1176

    def get(self, request, *args, **kwargs):
        """get all the file for a given job

        The method gets the job ID, recover each file related to this job,
        archive into a ZIP file and return it.

1177
1178
1179
        The ZIP file is stored as an anonymous file in /tmp/ then streamed with
        FileResponse. This is better that keepingthe whole file in memory
        because it may be large (and linux has a quite efficient page cache).
BERJON Matthieu's avatar
BERJON Matthieu committed
1180
        """
1181
        job = self.get_object()
1182
        tmp_file = tempfile.TemporaryFile()
BERJON Matthieu's avatar
BERJON Matthieu committed
1183

1184
1185
        zip_subdir = str(job.id)
        zip_filename = 'job_%s.zip' % zip_subdir
BERJON Matthieu's avatar
BERJON Matthieu committed
1186

1187
        zip_file = zipfile.ZipFile(tmp_file, 'w')
BERJON Matthieu's avatar
BERJON Matthieu committed
1188

1189
1190
1191
1192
1193
1194
        for filename in os.listdir(job.data_dir):
            # NOTE: calling lookup_job_file is a security feature
            real_path = lookup_job_file(job.id, filename)
            if real_path:
                zip_path = os.path.join(zip_subdir, filename)
                zip_file.write(real_path, zip_path)
BERJON Matthieu's avatar
BERJON Matthieu committed
1195

1196
        zip_file.close()
BERJON Matthieu's avatar
BERJON Matthieu committed
1197

1198
1199
        tmp_file.seek(0)
        response = FileResponse(tmp_file, content_type='application/x-zip-compressed')
1200
1201
        response["Content-Disposition"] = "attachment; filename={0}".format(zip_filename)
        return response
1202
1203


BERJON Matthieu's avatar
BERJON Matthieu committed
1204
1205
1206
# RUNNERS
# -----------------------------------------------------------------------------
class RunnerList(LoginRequiredMixin, ListView):
BERJON Matthieu's avatar
BERJON Matthieu committed
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
    """List all runners of a given user

    Attributes:
        model:  model used in the view.
        context_object_name:    the name used in the template to display each
                                variable.
        paginate_by: the number of occurences per page
        template_name: name of the template loaded with this view.

    """
BERJON Matthieu's avatar
BERJON Matthieu committed
1217
1218
1219
1220
1221
    model = Runner
    context_object_name = 'runner_list'
    paginate_by = 10
    template_name = 'runner_list.html'

BERJON Matthieu's avatar
BERJON Matthieu committed
1222
1223
1224
1225
1226
1227
1228
    def get_queryset(self):
        """Returns all runners of a given user
        
        Returns all runners for a given user from the most recent to the
        oldest one.
        """
        queryset = Runner.objects.filter(user=self.request.user).order_by('-created_at')
BERJON Matthieu's avatar
BERJON Matthieu committed
1229
        return queryset
BERJON Matthieu's avatar
BERJON Matthieu committed
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
    
    def get_context_data(self, **kwargs):
        """Return the number of webapps of a given user
        
        This method returns the number of webapps of a given user and pass it
        onto the template in order to display or not the link to add a runner
        or not. There are no reasons of adding a runner if the user doesn't
        manage any applications.
        """
        webapp_count = Webapp.objects.filter(user=self.request.user).count()
        kwargs['webapp_count'] = webapp_count
        return super().get_context_data(**kwargs)
1242
1243


1244
class RunnerCreate(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, CreateView):
BERJON Matthieu's avatar
BERJON Matthieu committed
1245
1246
1247
1248
1249
1250
1251
1252
1253
    """Create a runner and save it into the database

    Attributes:
        model:  model used in the view.
        form_class: form object to pass on the template.
        success_message: successfull message sent to the template
        template_name: name of the template loaded with this view.

    """
1254
1255
1256
    model = Runner
    form_class = RunnerForm
    success_message = 'Runner saved successfully.'
1257
    error_message = 'You don\'t have sufficient privileges to create an open bar runner.'
1258
    success_url = reverse_lazy('main:runner_list')
1259
    template_name = 'runner_add_update.html'
1260

1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
    def form_valid(self, form):
        """ Validate some fields before saving them."""
        obj = form.save(commit=False)

        # If the open-bar argument is true and the user is a superuser.
        # We setup the field as True
        if form.cleaned_data.get('open_bar') and self.request.user.is_superuser:
            obj.open_bar = True

        # If the open-bar argument is true but the user isn't
        # We send an error message and force the field to False
        if form.cleaned_data.get('open_bar') and not self.request.user.is_superuser:
            obj.open_bar = False
1274
            messages.error(self.request, self.error_message)