diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..45531ed4
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,17 @@
+# Top-most EditorConfig file ?
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+# Indentiation
+[*.py]
+indent_style = space
+indent_size = 4
+
+[*.{css,js,json,html}]
+indent_style = space
+indent_size = 2
diff --git a/atmo/__init__.py b/atmo/__init__.py
index 5aa0232a..db957215 100644
--- a/atmo/__init__.py
+++ b/atmo/__init__.py
@@ -1 +1,4 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
default_app_config = 'atmo.apps.AtmoAppConfig'
diff --git a/atmo/apps.py b/atmo/apps.py
index 4a9ceb03..eb2138f4 100644
--- a/atmo/apps.py
+++ b/atmo/apps.py
@@ -1,8 +1,47 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from django.apps import AppConfig
from django.conf import settings
+from django.utils.module_loading import import_string
+import django_rq
import session_csrf
+DEFAULT_JOB_TIMEOUT = 15
+
+job_schedule = {
+ 'delete_clusters': {
+ 'cron_string': '* * * * *',
+ 'func': 'atmo.clusters.jobs.delete_clusters',
+ 'timeout': 5
+ },
+ 'update_clusters_info': {
+ 'cron_string': '* * * * *',
+ 'func': 'atmo.clusters.jobs.update_clusters_info',
+ 'timeout': 5
+ },
+ 'launch_jobs': {
+ 'cron_string': '* * * * *',
+ 'func': 'atmo.jobs.jobs.launch_jobs',
+ 'timeout': 5
+ },
+}
+
+
+def register_job_schedule():
+ scheduler = django_rq.get_scheduler()
+ for job_id, params in job_schedule.items():
+ scheduler.cron(
+ params['cron_string'],
+ id=job_id,
+ func=import_string(params['func']),
+ timeout=params.get('timeout', DEFAULT_JOB_TIMEOUT)
+ )
+ for job in scheduler.get_jobs():
+ if job.id not in job_schedule:
+ scheduler.cancel(job)
+
class AtmoAppConfig(AppConfig):
name = 'atmo'
@@ -21,8 +60,5 @@ def ready(self):
# Under some circumstances (e.g. when calling collectstatic)
# REDIS_URL is not available and we can skip the job schedule registration.
if settings.REDIS_URL:
- # This module contains references to some orm models, so it's
- # safer to import it here.
- from .schedule import register_job_schedule
# Register rq scheduled jobs
register_job_schedule()
diff --git a/atmo/clusters/__init__.py b/atmo/clusters/__init__.py
index e69de29b..564e6203 100644
--- a/atmo/clusters/__init__.py
+++ b/atmo/clusters/__init__.py
@@ -0,0 +1,3 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
diff --git a/atmo/clusters/forms.py b/atmo/clusters/forms.py
index fd744845..d6e617b1 100644
--- a/atmo/clusters/forms.py
+++ b/atmo/clusters/forms.py
@@ -1,107 +1,90 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from django import forms
from . import models
-from ..utils.fields import PublicKeyFileField
+from ..forms import CreatedByFormMixin, PublicKeyFileField
-class NewClusterForm(forms.ModelForm):
+class NewClusterForm(CreatedByFormMixin, forms.ModelForm):
+
identifier = forms.RegexField(
+ label="Cluster identifier",
required=True,
regex="^[\w-]{1,100}$",
widget=forms.TextInput(attrs={
'class': 'form-control',
- 'pattern': r'[\w-]+',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'A brief description of the cluster\'s purpose, '
- 'visible in the AWS management console.',
- 'data-validation-pattern-message': 'Valid cluster names are strings of alphanumeric '
- 'characters, \'_\', and \'-\'.',
- })
+ }),
+ help_text='A brief description of the cluster\'s purpose, '
+ 'visible in the AWS management console.',
)
size = forms.IntegerField(
+ label="Cluster size",
required=True,
min_value=1, max_value=20,
widget=forms.NumberInput(attrs={
'class': 'form-control',
'required': 'required',
- 'min': '1', 'max': '20',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'Number of workers to use in the cluster '
- '(1 is recommended for testing or development).',
- })
+ 'min': '1',
+ 'max': '20',
+ }),
+ help_text='Number of workers to use in the cluster '
+ '(1 is recommended for testing or development).'
)
public_key = PublicKeyFileField(
+ label="Public SSH key",
required=True,
- widget=forms.FileInput(attrs={'required': 'required'})
+ widget=forms.FileInput(attrs={'class': 'form-control', 'required': 'required'}),
+ help_text="""\
+Upload your SSH public key, not private key!
+This will generally be found in places like ~/.ssh/id_rsa.pub
.
+"""
+ )
+ emr_release = forms.ChoiceField(
+ choices=models.Cluster.EMR_RELEASES_CHOICES,
+ widget=forms.Select(
+ attrs={'class': 'form-control', 'required': 'required'}
+ ),
+ label='EMR release version',
+ initial=models.Cluster.EMR_RELEASES_CHOICES_DEFAULT,
)
- def __init__(self, user, *args, **kwargs):
- self.created_by = user
- super(NewClusterForm, self).__init__(*args, **kwargs)
-
- def save(self):
+ def save(self, commit=False):
# create the model without committing, since we haven't
# set the required created_by field yet
- new_cluster = super(NewClusterForm, self).save(commit=False)
+ cluster = super(NewClusterForm, self).save(commit=commit)
# set the field to the user that created the cluster
- new_cluster.created_by = self.created_by
+ cluster.created_by = self.created_by
# actually start the real cluster, and return the model object
- new_cluster.save()
- return new_cluster
+ cluster.save()
+ return cluster
class Meta:
model = models.Cluster
fields = ['identifier', 'size', 'public_key', 'emr_release']
-class EditClusterForm(forms.ModelForm):
- cluster = forms.ModelChoiceField(
- queryset=models.Cluster.objects.all(),
- required=True,
- widget=forms.HiddenInput(attrs={
- # fields with the `selected-cluster` class get their value automatically
- # set to the cluster ID of the selected cluster
- 'class': 'selected-cluster',
- })
- )
-
+class EditClusterForm(CreatedByFormMixin, forms.ModelForm):
identifier = forms.RegexField(
required=True,
- regex="^[\w-]{1,100}$",
+ regex=r'^[\w-]{1,100}$',
widget=forms.TextInput(attrs={
'class': 'form-control',
'pattern': r'[\w-]+',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'A brief description of the cluster\'s purpose, '
- 'visible in the AWS management console.',
- 'data-validation-pattern-message': 'Valid cluster names are strings of alphanumeric '
- 'characters, \'_\', and \'-\'.',
- })
+ }),
+ help_text='A brief description of the cluster\'s purpose, '
+ 'visible in the AWS management console.',
)
- def __init__(self, user, *args, **kwargs):
- self.created_by = user
- super(EditClusterForm, self).__init__(*args, **kwargs)
-
- def save(self):
- cleaned_data = super(EditClusterForm, self).clean()
- cluster = cleaned_data["cluster"]
- if self.created_by != cluster.created_by: # only allow editing clusters that one created
- raise ValueError("Disallowed attempt to edit another user's cluster")
- cluster.identifier = cleaned_data["identifier"]
+ def save(self, commit=True):
+ cluster = super(EditClusterForm, self).save(commit=False)
cluster.update_identifier()
- cluster.save()
+ if commit:
+ cluster.save()
+ self.save_m2m()
return cluster
class Meta:
@@ -109,28 +92,23 @@ class Meta:
fields = ['identifier']
-class DeleteClusterForm(forms.ModelForm):
- cluster = forms.ModelChoiceField(
- queryset=models.Cluster.objects.all(),
+class TerminateClusterForm(CreatedByFormMixin, forms.ModelForm):
+ confirmation = forms.RegexField(
required=True,
- widget=forms.HiddenInput(attrs={
- # fields with the `selected-cluster` class get their value automatically
- # set to the cluster ID of the selected cluster
- 'class': 'selected-cluster',
- })
+ label='Confirm termination with cluster identifier',
+ regex=r'^[\w-]{1,100}$',
+ widget=forms.TextInput(attrs={
+ 'class': 'form-control',
+ }),
)
- def __init__(self, user, *args, **kwargs):
- self.created_by = user
- super(DeleteClusterForm, self).__init__(*args, **kwargs)
-
- def save(self):
- cleaned_data = super(DeleteClusterForm, self).clean()
- cluster = cleaned_data["cluster"]
- if self.created_by != cluster.created_by: # only allow deleting clusters that one created
- raise ValueError("Disallowed attempt to delete another user's cluster")
- cluster.deactivate()
- return cluster
+ def clean_confirmation(self):
+ confirmation = self.cleaned_data.get('confirmation')
+ if confirmation != self.instance.identifier:
+ raise forms.ValidationError(
+ "Entered cluster identifier doesn't match"
+ )
+ return confirmation
class Meta:
model = models.Cluster
diff --git a/atmo/clusters/jobs.py b/atmo/clusters/jobs.py
index afb442a6..1c0fca84 100644
--- a/atmo/clusters/jobs.py
+++ b/atmo/clusters/jobs.py
@@ -5,8 +5,8 @@
from django.utils import timezone
import newrelic.agent
-from atmo.clusters.models import Cluster
-from atmo.utils import email
+from .models import Cluster
+from .. import email
@newrelic.agent.background_task(group='RQ')
diff --git a/atmo/clusters/management/commands/delete_clusters.py b/atmo/clusters/management/commands/delete_clusters.py
index 89b63b41..34229ca9 100644
--- a/atmo/clusters/management/commands/delete_clusters.py
+++ b/atmo/clusters/management/commands/delete_clusters.py
@@ -2,11 +2,13 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from django.core.management.base import BaseCommand
-from atmo.clusters.jobs import delete_clusters
+from ...jobs import delete_clusters
class Command(BaseCommand):
help = 'Go through expired clusters to deactivate or warn about ones that are expiring'
def handle(self, *args, **options):
+ self.stdout.write('Deleting expired clusters...', ending='')
delete_clusters()
+ self.stdout.write('done.')
diff --git a/atmo/clusters/management/commands/update_clusters_info.py b/atmo/clusters/management/commands/update_clusters_info.py
index 7a4e16fa..c3ac106b 100644
--- a/atmo/clusters/management/commands/update_clusters_info.py
+++ b/atmo/clusters/management/commands/update_clusters_info.py
@@ -2,11 +2,13 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from django.core.management.base import BaseCommand
-from atmo.clusters.jobs import update_clusters_info
+from ...jobs import update_clusters_info
class Command(BaseCommand):
help = 'Go through active clusters and update their status'
def handle(self, *args, **options):
+ self.stdout.write('Updating cluster info...', ending='')
update_clusters_info()
+ self.stdout.write('done.')
diff --git a/atmo/clusters/migrations/0001_initial.py b/atmo/clusters/migrations/0001_initial.py
index 23393c3b..e29a9ba8 100644
--- a/atmo/clusters/migrations/0001_initial.py
+++ b/atmo/clusters/migrations/0001_initial.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-07-27 15:50
from __future__ import unicode_literals
diff --git a/atmo/clusters/migrations/0002_cluster_emr_release.py b/atmo/clusters/migrations/0002_cluster_emr_release.py
index 56eb6135..95b21d7b 100644
--- a/atmo/clusters/migrations/0002_cluster_emr_release.py
+++ b/atmo/clusters/migrations/0002_cluster_emr_release.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-09-15 12:40
from __future__ import unicode_literals
diff --git a/atmo/clusters/migrations/0003_cluster_master_address.py b/atmo/clusters/migrations/0003_cluster_master_address.py
index 816e94a0..6625734f 100644
--- a/atmo/clusters/migrations/0003_cluster_master_address.py
+++ b/atmo/clusters/migrations/0003_cluster_master_address.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-09-27 22:29
from __future__ import unicode_literals
diff --git a/atmo/clusters/migrations/0004_auto_20161002_1841.py b/atmo/clusters/migrations/0004_auto_20161002_1841.py
index 070135a5..7f94084c 100644
--- a/atmo/clusters/migrations/0004_auto_20161002_1841.py
+++ b/atmo/clusters/migrations/0004_auto_20161002_1841.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-10-02 18:41
from __future__ import unicode_literals
diff --git a/atmo/clusters/migrations/__init__.py b/atmo/clusters/migrations/__init__.py
index e69de29b..564e6203 100644
--- a/atmo/clusters/migrations/__init__.py
+++ b/atmo/clusters/migrations/__init__.py
@@ -0,0 +1,3 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
diff --git a/atmo/clusters/models.py b/atmo/clusters/models.py
index 8f86e1f9..850bd31c 100644
--- a/atmo/clusters/models.py
+++ b/atmo/clusters/models.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from datetime import timedelta
from django.core.urlresolvers import reverse
@@ -5,15 +8,18 @@
from django.contrib.auth.models import User
from django.utils import timezone
-from ..utils import provisioning
-
-
-# Default release is the last item.
-EMR_RELEASES = ('5.0.0', '4.5.0')
+from .. import provisioning
class Cluster(models.Model):
FINAL_STATUS_LIST = ('COMPLETED', 'TERMINATED', 'FAILED')
+ # Default release is the first item, order should be from latest to oldest
+ EMR_RELEASES = (
+ '5.0.0',
+ '4.5.0',
+ )
+ EMR_RELEASES_CHOICES = list(zip(*(EMR_RELEASES,) * 2))
+ EMR_RELEASES_CHOICES_DEFAULT = EMR_RELEASES[0]
identifier = models.CharField(
max_length=100,
@@ -45,7 +51,7 @@ class Cluster(models.Model):
)
emr_release = models.CharField(
- max_length=50, choices=list(zip(*(EMR_RELEASES,) * 2)), default=EMR_RELEASES[-1],
+ max_length=50, choices=EMR_RELEASES_CHOICES, default=EMR_RELEASES_CHOICES_DEFAULT,
help_text=('Different EMR versions have different versions '
'of software like Hadoop, Spark, etc')
)
@@ -69,10 +75,6 @@ def __repr__(self):
def get_info(self):
return provisioning.cluster_info(self.jobflow_id)
- def is_expiring_soon(self):
- """Returns true if the cluster is expiring in the next hour."""
- return self.end_date <= timezone.now() + timedelta(hours=1)
-
def update_status(self):
"""Should be called to update latest cluster status in `self.most_recent_status`."""
info = self.get_info()
@@ -120,9 +122,18 @@ def deactivate(self):
def is_active(self):
return self.most_recent_status not in self.FINAL_STATUS_LIST
+ @property
+ def is_terminating(self):
+ return self.most_recent_status == 'TERMINATING'
+
@property
def is_ready(self):
return self.most_recent_status == 'WAITING'
+ @property
+ def is_expiring_soon(self):
+ """Returns true if the cluster is expiring in the next hour."""
+ return self.end_date <= timezone.now() + timedelta(hours=1)
+
def get_absolute_url(self):
return reverse('clusters-detail', kwargs={'id': self.id})
diff --git a/atmo/clusters/tests.py b/atmo/clusters/tests.py
index 2b1bc246..ca46eda2 100644
--- a/atmo/clusters/tests.py
+++ b/atmo/clusters/tests.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
import io
import mock
from datetime import timedelta
@@ -10,8 +13,8 @@
class TestCreateCluster(TestCase):
- @mock.patch('atmo.utils.provisioning.cluster_start', return_value=u'12345')
- @mock.patch('atmo.utils.provisioning.cluster_info', return_value={
+ @mock.patch('atmo.provisioning.cluster_start', return_value=u'12345')
+ @mock.patch('atmo.provisioning.cluster_info', return_value={
'start_time': timezone.now(),
'state': 'BOOTSTRAPPING',
'public_dns': 'master.public.dns.name',
@@ -22,12 +25,13 @@ def setUp(self, cluster_info, cluster_start):
self.client.force_login(self.test_user)
# request that a new cluster be created
- self.response = self.client.post(reverse('clusters-new'), {
- 'identifier': 'test-cluster',
- 'size': 5,
- 'public_key': io.BytesIO('ssh-rsa AAAAB3'),
- 'emr_release': models.EMR_RELEASES[-1]
- }, follow=True)
+ self.response = self.client.post(
+ reverse('clusters-new'), {
+ 'new-identifier': 'test-cluster',
+ 'new-size': 5,
+ 'new-public_key': io.BytesIO('ssh-rsa AAAAB3'),
+ 'new-emr_release': models.Cluster.EMR_RELEASES_CHOICES_DEFAULT
+ }, follow=True)
self.cluster_start = cluster_start
self.cluster = models.Cluster.objects.get(jobflow_id=u'12345')
@@ -43,7 +47,7 @@ def test_that_cluster_is_correctly_provisioned(self):
self.assertEqual(identifier, 'test-cluster')
self.assertEqual(size, 5)
self.assertEqual(public_key, 'ssh-rsa AAAAB3')
- self.assertEqual(emr_release, models.EMR_RELEASES[-1])
+ self.assertEqual(emr_release, models.Cluster.EMR_RELEASES_CHOICES_DEFAULT)
def test_that_the_model_was_created_correctly(self):
cluster = models.Cluster.objects.get(jobflow_id=u'12345')
@@ -55,23 +59,24 @@ def test_that_the_model_was_created_correctly(self):
self.start_date <= cluster.start_date <= self.start_date + timedelta(seconds=10)
)
self.assertEqual(cluster.created_by, self.test_user)
- self.assertEqual(cluster.emr_release, models.EMR_RELEASES[-1])
+ self.assertEqual(cluster.emr_release, models.Cluster.EMR_RELEASES_CHOICES_DEFAULT)
self.assertTrue(User.objects.filter(username='john.smith').exists())
- @mock.patch('atmo.utils.provisioning.cluster_start', return_value=u'67890')
+ @mock.patch('atmo.provisioning.cluster_start', return_value=u'67890')
@mock.patch(
- 'atmo.utils.provisioning.cluster_info', return_value={
+ 'atmo.provisioning.cluster_info', return_value={
'start_time': timezone.now(),
'state': 'BOOTSTRAPPING',
'public_dns': None,
})
def test_empty_public_dns(self, cluster_info, cluster_start):
- self.client.post(reverse('clusters-new'), {
- 'identifier': 'test-cluster',
- 'size': 5,
- 'public_key': io.BytesIO('ssh-rsa AAAAB3'),
- 'emr_release': models.EMR_RELEASES[-1]
- }, follow=True)
+ self.client.post(
+ reverse('clusters-new'), {
+ 'new-identifier': 'test-cluster',
+ 'new-size': 5,
+ 'new-public_key': io.BytesIO('ssh-rsa AAAAB3'),
+ 'new-emr_release': models.Cluster.EMR_RELEASES_CHOICES_DEFAULT
+ }, follow=True)
self.assertEqual(cluster_start.call_count, 1)
cluster = models.Cluster.objects.get(jobflow_id=u'67890')
self.assertEqual(cluster_info.call_count, 1)
@@ -79,13 +84,13 @@ def test_empty_public_dns(self, cluster_info, cluster_start):
class TestEditCluster(TestCase):
- @mock.patch('atmo.utils.provisioning.cluster_start', return_value=u'12345')
- @mock.patch('atmo.utils.provisioning.cluster_info', return_value={
+ @mock.patch('atmo.provisioning.cluster_start', return_value=u'12345')
+ @mock.patch('atmo.provisioning.cluster_info', return_value={
'start_time': timezone.now(),
'state': 'BOOTSTRAPPING',
'public_dns': 'master.public.dns.name',
})
- @mock.patch('atmo.utils.provisioning.cluster_rename', return_value=None)
+ @mock.patch('atmo.provisioning.cluster_rename', return_value=None)
def setUp(self, cluster_rename, cluster_info, cluster_start):
self.start_date = timezone.now()
@@ -101,10 +106,13 @@ def setUp(self, cluster_rename, cluster_info, cluster_start):
# request that the test cluster be edited
self.client.force_login(self.test_user)
- self.response = self.client.post(reverse('clusters-edit'), {
- 'cluster': cluster.id,
- 'identifier': 'new-cluster-name',
- }, follow=True)
+ self.response = self.client.post(
+ reverse('clusters-edit', kwargs={
+ 'id': cluster.id,
+ }), {
+ 'edit-cluster': cluster.id,
+ 'edit-identifier': 'new-cluster-name',
+ }, follow=True)
self.cluster_rename = cluster_rename
self.cluster = cluster
@@ -131,10 +139,10 @@ def test_that_the_model_was_edited_correctly(self):
self.assertEqual(cluster.created_by, self.test_user)
-class TestDeleteCluster(TestCase):
- @mock.patch('atmo.utils.provisioning.cluster_stop', return_value=None)
- @mock.patch('atmo.utils.provisioning.cluster_start', return_value=u'12345')
- @mock.patch('atmo.utils.provisioning.cluster_info', return_value={
+class TestTerminateCluster(TestCase):
+ @mock.patch('atmo.provisioning.cluster_stop', return_value=None)
+ @mock.patch('atmo.provisioning.cluster_start', return_value=u'12345')
+ @mock.patch('atmo.provisioning.cluster_info', return_value={
'start_time': timezone.now(),
'state': 'BOOTSTRAPPING',
'public_dns': 'master.public.dns.name',
@@ -153,9 +161,13 @@ def setUp(self, cluster_info, cluster_start, cluster_stop):
cluster.save()
# request that the test cluster be deleted
- self.response = self.client.post(reverse('clusters-delete'), {
- 'cluster': cluster.id,
- }, follow=True)
+ self.response = self.client.post(
+ reverse('clusters-terminate', kwargs={
+ 'id': cluster.id,
+ }), {
+ 'terminate-cluster': cluster.id,
+ 'terminate-confirmation': cluster.identifier,
+ }, follow=True)
self.cluster = cluster
self.cluster_stop = cluster_stop
self.cluster_info = cluster_info
diff --git a/atmo/clusters/urls.py b/atmo/clusters/urls.py
index 3e4fa149..6fb80450 100644
--- a/atmo/clusters/urls.py
+++ b/atmo/clusters/urls.py
@@ -1,9 +1,12 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^new/$', views.new_cluster, name='clusters-new'),
- url(r'^edit/$', views.edit_cluster, name='clusters-edit'),
- url(r'^delete/$', views.delete_cluster, name='clusters-delete'),
- url(r'^(?P[0-9]+)/$', views.detail_cluster, name='clusters-detail'),
+ url(r'^(?P\d+)/edit/$', views.edit_cluster, name='clusters-edit'),
+ url(r'^(?P\d+)/terminate/$', views.terminate_cluster, name='clusters-terminate'),
+ url(r'^(?P\d+)/$', views.detail_cluster, name='clusters-detail'),
]
diff --git a/atmo/clusters/views.py b/atmo/clusters/views.py
index 9f48ff95..840fb5a8 100644
--- a/atmo/clusters/views.py
+++ b/atmo/clusters/views.py
@@ -1,48 +1,112 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+from django import forms
from django.shortcuts import redirect, get_object_or_404, render
-from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
-from django.http import HttpResponseBadRequest
-from session_csrf import anonymous_csrf
-
-from . import forms
+from .forms import NewClusterForm, EditClusterForm, TerminateClusterForm
from .models import Cluster
@login_required
-@anonymous_csrf
-@require_POST
def new_cluster(request):
- form = forms.NewClusterForm(request.user, request.POST, request.FILES)
- if not form.is_valid():
- return HttpResponseBadRequest(form.errors.as_json(escape_html=True))
- cluster = form.save() # this will also magically spawn the cluster for us
- return redirect(cluster)
+ username = request.user.email.split("@")[0]
+ initial = {
+ "identifier": "{}-telemetry-analysis".format(username),
+ "size": 1,
+ }
+ if request.method == 'POST':
+ form = NewClusterForm(
+ request.user,
+ data=request.POST,
+ files=request.FILES,
+ initial=initial,
+ prefix='new',
+ )
+ if form.is_valid():
+ cluster = form.save() # this will also magically spawn the cluster for us
+ return redirect(cluster)
+ else:
+ form = NewClusterForm(
+ request.user,
+ initial=initial,
+ prefix='new',
+ )
+ context = {
+ 'form': form,
+ }
+ return render(request, 'atmo/cluster-new.html', context)
@login_required
-@anonymous_csrf
-@require_POST
-def edit_cluster(request):
- form = forms.EditClusterForm(request.user, request.POST)
- if not form.is_valid():
- return HttpResponseBadRequest(form.errors.as_json(escape_html=True))
- cluster = form.save() # this will also update the cluster for us
- return redirect(cluster)
+def edit_cluster(request, id):
+ cluster = get_object_or_404(Cluster, created_by=request.user, pk=id)
+ if not cluster.is_active:
+ return redirect(cluster)
+ if request.method == 'POST':
+ form = EditClusterForm(
+ request.user,
+ data=request.POST,
+ files=request.FILES,
+ instance=cluster,
+ prefix='edit',
+ )
+ if form.is_valid():
+ cluster = form.save() # this will also magically spawn the cluster for us
+ return redirect(cluster)
+ else:
+ form = EditClusterForm(
+ request.user,
+ instance=cluster,
+ prefix='edit',
+ )
+ context = {
+ 'form': form,
+ }
+ return render(request, 'atmo/cluster-edit.html', context)
@login_required
-@anonymous_csrf
-@require_POST
-def delete_cluster(request):
- form = forms.DeleteClusterForm(request.user, request.POST)
- if not form.is_valid():
- return HttpResponseBadRequest(form.errors.as_json(escape_html=True))
- cluster = form.save() # this will also terminate the cluster for us
- return redirect(cluster)
+def terminate_cluster(request, id):
+ cluster = get_object_or_404(Cluster, created_by=request.user, pk=id)
+ if not cluster.is_active:
+ return redirect(cluster)
+ if request.method == 'POST':
+ form = TerminateClusterForm(
+ request.user,
+ prefix='terminate',
+ data=request.POST,
+ instance=cluster,
+ )
+ if form.is_valid():
+ cluster.deactivate()
+ return redirect(cluster)
+ else:
+ form = TerminateClusterForm(
+ request.user,
+ prefix='terminate',
+ instance=cluster,
+ )
+ context = {
+ 'cluster': cluster,
+ 'form': form,
+ }
+ return render(request, 'atmo/cluster-terminate.html', context=context)
@login_required
def detail_cluster(request, id):
cluster = get_object_or_404(Cluster, created_by=request.user, pk=id)
- return render(request, 'atmo/detail-cluster.html', context={'cluster': cluster})
+ terminate_form = TerminateClusterForm(
+ request.user,
+ prefix='terminate',
+ instance=cluster,
+ )
+ # hiding the confirmation input on the detail page
+ terminate_form.fields['confirmation'].widget = forms.HiddenInput()
+ context = {
+ 'cluster': cluster,
+ 'terminate_form': terminate_form,
+ }
+ return render(request, 'atmo/cluster-detail.html', context=context)
diff --git a/atmo/context_processors.py b/atmo/context_processors.py
new file mode 100644
index 00000000..2de0e8bc
--- /dev/null
+++ b/atmo/context_processors.py
@@ -0,0 +1,11 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+from django.conf import settings as django_settings
+
+
+def settings(request):
+ """
+ Adds static-related context variables to the context.
+ """
+ return {'settings': django_settings}
diff --git a/atmo/utils/email.py b/atmo/email.py
similarity index 70%
rename from atmo/utils/email.py
rename to atmo/email.py
index 0d4cd1e7..b2723d12 100644
--- a/atmo/utils/email.py
+++ b/atmo/email.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from django.conf import settings
import boto3
diff --git a/atmo/forms.py b/atmo/forms.py
new file mode 100644
index 00000000..ffb08e98
--- /dev/null
+++ b/atmo/forms.py
@@ -0,0 +1,49 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+from django import forms
+from django.core.exceptions import ValidationError
+from django.template.defaultfilters import filesizeformat
+
+
+class PublicKeyFileField(forms.FileField):
+ """
+ Custom Django for file field that only accepts SSH public keys.
+
+ The cleaned data is the public key as a string.
+ """
+ def clean(self, data, initial=None):
+ uploaded_file = super(PublicKeyFileField, self).clean(data, initial)
+ if uploaded_file.size > 100000:
+ raise ValidationError(
+ 'File size must be at most 100kB, actual size is {}'.format(
+ filesizeformat(uploaded_file.size)
+ )
+ )
+ contents = uploaded_file.read()
+ if not contents.startswith('ssh-rsa AAAAB3'):
+ raise ValidationError(
+ 'Invalid public key (a public key should start with \'ssh-rsa AAAAB3\')'
+ )
+ return contents
+
+
+class CreatedByFormMixin(object):
+ """
+ Custom Django form mixin that takes a user object and if the provided
+ model form instance has a primary key checks if the given user
+ matches the "created_by" field.
+ """
+ def __init__(self, user, *args, **kwargs):
+ self.created_by = user
+ super(CreatedByFormMixin, self).__init__(*args, **kwargs)
+
+ def clean(self):
+ """
+ only allow deleting clusters that one created
+ """
+ super(CreatedByFormMixin, self).clean()
+ if self.instance.id and self.created_by != self.instance.created_by:
+ raise forms.ValidationError(
+ 'Access denied to the data of another user'
+ )
diff --git a/atmo/jobs/__init__.py b/atmo/jobs/__init__.py
index e69de29b..564e6203 100644
--- a/atmo/jobs/__init__.py
+++ b/atmo/jobs/__init__.py
@@ -0,0 +1,3 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
diff --git a/atmo/jobs/forms.py b/atmo/jobs/forms.py
index c69c3bec..e617545e 100644
--- a/atmo/jobs/forms.py
+++ b/atmo/jobs/forms.py
@@ -1,272 +1,146 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from django import forms
from . import models
+from ..forms import CreatedByFormMixin
-class NewSparkJobForm(forms.ModelForm):
+class BaseSparkJobForm(CreatedByFormMixin, forms.ModelForm):
identifier = forms.RegexField(
required=True,
- regex="^[\w-]{1,100}$",
+ label='Job identifier',
+ regex=r'^[\w-]{1,100}$',
widget=forms.TextInput(attrs={
'class': 'form-control',
- 'pattern': r'[\w-]+',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'A brief description of the scheduled Spark job\'s purpose, '
- 'visible in the AWS management console.',
- 'data-validation-pattern-message': 'Valid job names are strings of alphanumeric '
- 'characters, \'_\', and \'-\'.',
- })
- )
- notebook = forms.FileField(
- required=True,
- widget=forms.FileInput(attrs={'required': 'required'})
+ }),
+ help_text='A brief description of the scheduled Spark job\'s purpose, '
+ 'visible in the AWS management console.'
)
result_visibility = forms.ChoiceField(
- choices=[
- ('private', 'Private: results output to an S3 bucket, viewable with AWS credentials'),
- ('public', 'Public: results output to a public S3 bucket, viewable by anyone'),
- ],
+ choices=models.SparkJob.RESULT_VISIBILITY_CHOICES,
widget=forms.Select(
attrs={'class': 'form-control', 'required': 'required'}
- )
+ ),
+ label='Job result visibility'
)
size = forms.IntegerField(
required=True,
- min_value=1, max_value=20,
+ min_value=1,
+ max_value=20,
+ label='Job cluster size',
widget=forms.NumberInput(attrs={
'class': 'form-control',
'required': 'required',
- 'min': '1', 'max': '20',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'Number of workers to use when running the Spark job '
- '(1 is recommended for testing or development).',
- })
+ 'min': '1',
+ 'max': '20',
+ }),
+ help_text='Number of workers to use when running the Spark job '
+ '(1 is recommended for testing or development).'
)
interval_in_hours = forms.ChoiceField(
- choices=[
- (24, "Daily"),
- (24 * 7, "Weekly"),
- (24 * 30, "Monthly"),
- ],
+ choices=models.SparkJob.INTERVAL_CHOICES,
widget=forms.Select(
- attrs={'class': 'form-control', 'required': 'required'}
- )
+ attrs={
+ 'class': 'form-control',
+ 'required': 'required',
+ }
+ ),
+ label='Job interval',
+ help_text='Interval at which the Spark job should be run',
)
job_timeout = forms.IntegerField(
required=True,
- min_value=1, max_value=24,
+ min_value=1,
+ max_value=24,
+ label='Job timeout',
widget=forms.NumberInput(attrs={
'class': 'form-control',
'required': 'required',
- 'min': '1', 'max': '24',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'Number of hours that a single run of the job can run '
- 'for before timing out and being terminated.',
- })
+ 'min': '1',
+ 'max': '24',
+ }),
+ help_text='Number of hours that a single run of the job can run '
+ 'for before timing out and being terminated.'
)
start_date = forms.DateTimeField(
required=True,
- widget=forms.DateTimeInput(attrs={
- 'class': 'form-control datetimepicker',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'Date/time on which to enable the scheduled Spark job.',
- })
+ widget=forms.DateTimeInput(
+ attrs={
+ 'class': 'form-control datetimepicker',
+ }),
+ label='Job start date',
+ help_text='Date and time on which to enable the scheduled Spark job.',
)
end_date = forms.DateTimeField(
required=False,
widget=forms.DateTimeInput(attrs={
'class': 'form-control datetimepicker',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'Date/time on which to disable the scheduled Spark job '
- '- leave this blank if the job should not be disabled.',
- })
+ }),
+ label='Job end date (optional)',
+ help_text='Date and time on which to disable the scheduled Spark job '
+ '- leave this blank if the job should not be disabled.',
)
- def __init__(self, user, *args, **kwargs):
- self.created_by = user
- super(NewSparkJobForm, self).__init__(*args, **kwargs)
+ class Meta:
+ model = models.SparkJob
+ fields = []
+
+
+class NewSparkJobForm(BaseSparkJobForm):
+ notebook = forms.FileField(
+ required=True,
+ widget=forms.FileInput(attrs={'class': 'form-control', 'required': 'required'}),
+ label='Analysis Jupyter Notebook',
+ help_text='A Jupyter (formally IPython) Notebook has the file extension .ipynb'
+ )
def save(self):
# create the model without committing, since we haven't
# set the required created_by field yet
- new_spark_job = super(NewSparkJobForm, self).save(commit=False)
+ spark_job = super(NewSparkJobForm, self).save(commit=False)
# set the field to the user that created the scheduled Spark job
- new_spark_job.created_by = self.created_by
+ spark_job.created_by = self.created_by
# actually save the scheduled Spark job, and return the model object
- new_spark_job.save(self.cleaned_data["notebook"])
+ spark_job.save(self.cleaned_data['notebook'])
+ return spark_job
- class Meta:
- model = models.SparkJob
+ class Meta(BaseSparkJobForm.Meta):
fields = [
- 'identifier', 'result_visibility', 'size', 'interval_in_hours',
- 'job_timeout', 'start_date', 'end_date'
+ 'identifier', 'notebook', 'result_visibility', 'size',
+ 'interval_in_hours', 'job_timeout', 'start_date', 'end_date'
]
-class EditSparkJobForm(forms.ModelForm):
- job = forms.ModelChoiceField(
- queryset=models.SparkJob.objects.all(),
- required=True,
- widget=forms.HiddenInput(attrs={
- # fields with the `selected-spark-job` class get their value
- # automatically set to the job ID of the selected scheduled Spark job
- 'class': 'selected-spark-job',
- })
- )
+class EditSparkJobForm(BaseSparkJobForm):
- identifier = forms.RegexField(
- required=True,
- regex="^[\w-]{1,100}$",
- widget=forms.TextInput(attrs={
- 'class': 'form-control',
- 'pattern': r'[\w-]+',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'A brief description of the scheduled Spark job\'s purpose, '
- 'visible in the AWS management console.',
- 'data-validation-pattern-message': 'Valid job names are strings of alphanumeric '
- 'characters, \'_\', and \'-\'.',
- })
- )
- result_visibility = forms.ChoiceField(
- choices=[
- ('private', 'Private: results output to an S3 bucket, viewable with AWS credentials'),
- ('public', 'Public: results output to a public S3 bucket, viewable by anyone'),
- ],
- widget=forms.Select(
- attrs={'class': 'form-control', 'required': 'required'}
- )
- )
- size = forms.IntegerField(
- required=True,
- min_value=1, max_value=20,
- widget=forms.NumberInput(attrs={
- 'class': 'form-control',
- 'required': 'required',
- 'min': '1', 'max': '20',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'Number of workers to use when running the Spark job '
- '(1 is recommended for testing or development).',
- })
- )
- interval_in_hours = forms.ChoiceField(
- choices=[
- (24, "Daily"),
- (24 * 7, "Weekly"),
- (24 * 30, "Monthly"),
- ],
- widget=forms.Select(
- attrs={'class': 'form-control', 'required': 'required'}
- )
- )
- job_timeout = forms.IntegerField(
- required=True,
- min_value=1, max_value=24,
- widget=forms.NumberInput(attrs={
- 'class': 'form-control',
- 'required': 'required',
- 'min': '1', 'max': '24',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'Number of hours that a single run of the job can run '
- 'for before timing out and being terminated.',
- })
- )
- start_date = forms.DateTimeField(
- required=True,
- widget=forms.DateTimeInput(attrs={
- 'class': 'form-control datetimepicker',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'Date/time on which to enable the scheduled Spark job.',
- })
- )
- end_date = forms.DateTimeField(
- required=False,
- widget=forms.DateTimeInput(attrs={
- 'class': 'form-control datetimepicker',
- 'data-toggle': 'popover',
- 'data-trigger': 'focus',
- 'data-placement': 'top',
- 'data-container': 'body',
- 'data-content': 'Date/time on which to disable the scheduled Spark job '
- '- leave this blank if the job should not be disabled.',
- })
- )
-
- def __init__(self, user, *args, **kwargs):
- self.created_by = user
- super(EditSparkJobForm, self).__init__(*args, **kwargs)
-
- def save(self):
- cleaned_data = super(EditSparkJobForm, self).clean()
- job = cleaned_data["job"]
- if self.created_by != job.created_by: # only allow editing jobs that one creates
- raise ValueError("Disallowed attempt to edit another user's scheduled job")
- job.identifier = cleaned_data["identifier"]
- job.result_visibility = cleaned_data["result_visibility"]
- job.size = cleaned_data["size"]
- job.interval_in_hours = cleaned_data["interval_in_hours"]
- job.job_timeout = cleaned_data["job_timeout"]
- job.start_date = cleaned_data["start_date"]
- job.end_date = cleaned_data["end_date"]
- job.save()
-
- class Meta:
- model = models.SparkJob
+ class Meta(BaseSparkJobForm.Meta):
fields = [
'identifier', 'result_visibility', 'size', 'interval_in_hours',
'job_timeout', 'start_date', 'end_date'
]
-class DeleteSparkJobForm(forms.ModelForm):
- job = forms.ModelChoiceField(
- queryset=models.SparkJob.objects.all(),
+class DeleteSparkJobForm(CreatedByFormMixin, forms.ModelForm):
+ confirmation = forms.RegexField(
required=True,
- widget=forms.HiddenInput(attrs={
- # fields with the `selected-spark-job` class get their value
- # automatically set to the job ID of the selected scheduled Spark job
- 'class': 'selected-spark-job',
- })
+ label='Confirm termination with Spark job identifier',
+ regex=r'^[\w-]{1,100}$',
+ widget=forms.TextInput(attrs={
+ 'class': 'form-control',
+ }),
)
- def __init__(self, user, *args, **kwargs):
- self.created_by = user
- super(DeleteSparkJobForm, self).__init__(*args, **kwargs)
-
- def save(self):
- cleaned_data = super(DeleteSparkJobForm, self).clean()
- job = cleaned_data["job"]
- if self.created_by != job.created_by: # only allow deleting jobs that one creates
- raise ValueError("Disallowed attempt to delete another user's scheduled job")
- job.delete()
+ def clean_confirmation(self):
+ confirmation = self.cleaned_data.get('confirmation')
+ if confirmation != self.instance.identifier:
+ raise forms.ValidationError(
+ "Entered Spark job identifier doesn't match"
+ )
+ return confirmation
class Meta:
model = models.SparkJob
diff --git a/atmo/jobs/jobs.py b/atmo/jobs/jobs.py
index 03dc3ef2..d2a79d78 100644
--- a/atmo/jobs/jobs.py
+++ b/atmo/jobs/jobs.py
@@ -2,7 +2,7 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, you can obtain one at http://mozilla.org/MPL/2.0/.
import newrelic.agent
-from atmo.jobs.models import SparkJob
+from .models import SparkJob
@newrelic.agent.background_task(group='RQ')
diff --git a/atmo/jobs/management/commands/launch_jobs.py b/atmo/jobs/management/commands/launch_jobs.py
index c091d6aa..f46afc86 100644
--- a/atmo/jobs/management/commands/launch_jobs.py
+++ b/atmo/jobs/management/commands/launch_jobs.py
@@ -2,11 +2,13 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from django.core.management.base import BaseCommand
-from atmo.jobs.jobs import launch_jobs
+from ...jobs import launch_jobs
class Command(BaseCommand):
help = 'Launch scheduled jobs if necessary'
def handle(self, *args, **options):
+ self.stdout.write('Launching scheduled jobs...', ending='')
launch_jobs()
+ self.stdout.write('done.')
diff --git a/atmo/jobs/migrations/0001_initial.py b/atmo/jobs/migrations/0001_initial.py
index 1f387901..8572619d 100644
--- a/atmo/jobs/migrations/0001_initial.py
+++ b/atmo/jobs/migrations/0001_initial.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-07-27 15:50
from __future__ import unicode_literals
diff --git a/atmo/jobs/migrations/__init__.py b/atmo/jobs/migrations/__init__.py
index e69de29b..564e6203 100644
--- a/atmo/jobs/migrations/__init__.py
+++ b/atmo/jobs/migrations/__init__.py
@@ -0,0 +1,3 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
diff --git a/atmo/jobs/models.py b/atmo/jobs/models.py
index f7286b04..01e52f98 100644
--- a/atmo/jobs/models.py
+++ b/atmo/jobs/models.py
@@ -1,14 +1,31 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from datetime import timedelta
from django.core.urlresolvers import reverse
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
+from django.utils.functional import cached_property
-from ..utils import provisioning, scheduling
+from .. import provisioning, scheduling
class SparkJob(models.Model):
+ WEEKLY = 24 * 7
+ INTERVAL_CHOICES = [
+ (24, "Daily"),
+ (WEEKLY, "Weekly"),
+ (24 * 30, "Monthly"),
+ ]
+ INTERVAL_CHOICES_DEFAULT = INTERVAL_CHOICES[0][0]
+ RESULT_VISIBILITY_CHOICES = [
+ ('private', 'Private: results output to an S3 bucket, viewable with AWS credentials'),
+ ('public', 'Public: results output to a public S3 bucket, viewable by anyone'),
+ ]
+ RESULT_VISIBILITY_CHOICES_DEFAULT = RESULT_VISIBILITY_CHOICES[0][0]
+
identifier = models.CharField(
max_length=100,
help_text="Job name, used to non-uniqely identify individual jobs."
@@ -19,16 +36,20 @@ class SparkJob(models.Model):
)
result_visibility = models.CharField( # can currently be "public" or "private"
max_length=50,
- help_text="Whether notebook results are uploaded to a public or private bucket"
+ help_text="Whether notebook results are uploaded to a public or private bucket",
+ choices=RESULT_VISIBILITY_CHOICES,
+ default=RESULT_VISIBILITY_CHOICES_DEFAULT,
)
size = models.IntegerField(
help_text="Number of computers to use to run the job."
)
interval_in_hours = models.IntegerField(
- help_text="Interval at which the job should run, in hours."
+ help_text="Interval at which the job should run, in hours.",
+ choices=INTERVAL_CHOICES,
+ default=INTERVAL_CHOICES_DEFAULT,
)
job_timeout = models.IntegerField(
- help_text="Number of hours before the job times out."
+ help_text="Number of hours before the job times out.",
)
start_date = models.DateTimeField(
help_text="Date/time that the job should start being scheduled to run."
@@ -107,18 +128,31 @@ def run(self):
self.created_by.email,
self.identifier,
self.notebook_s3_key,
- self.result_visibility == "public",
+ self.is_public,
self.size,
self.job_timeout
)
self.update_status()
+ @property
+ def is_public(self):
+ return self.result_visibility == 'public'
+
+ @property
+ def notebook_name(self):
+ return self.notebook_s3_key.rsplit('/', 1)[-1]
+
+ @cached_property
+ def notebook_content(self):
+ if self.notebook_s3_key:
+ return scheduling.spark_job_get(self.notebook_s3_key)
+
def terminate(self):
"""Stop the currently running scheduled Spark job."""
if self.current_run_jobflow_id:
provisioning.cluster_stop(self.current_run_jobflow_id)
- def save(self, notebook_uploadedfile = None, *args, **kwargs):
+ def save(self, notebook_uploadedfile=None, *args, **kwargs):
if notebook_uploadedfile is not None: # notebook specified, replace current notebook
self.notebook_s3_key = scheduling.spark_job_add(
self.identifier,
@@ -129,7 +163,6 @@ def save(self, notebook_uploadedfile = None, *args, **kwargs):
def delete(self, *args, **kwargs):
self.terminate() # make sure to shut down the cluster if it's currently running
scheduling.spark_job_remove(self.notebook_s3_key)
-
super(SparkJob, self).delete(*args, **kwargs)
@classmethod
diff --git a/atmo/jobs/tests.py b/atmo/jobs/tests.py
index 896b857d..49360838 100644
--- a/atmo/jobs/tests.py
+++ b/atmo/jobs/tests.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
import io
import mock
from datetime import datetime, timedelta
@@ -10,13 +13,14 @@
class TestCreateSparkJob(TestCase):
- @mock.patch('atmo.utils.scheduling.spark_job_run', return_value=u'12345')
- def setUp(self, spark_job_run):
+ @mock.patch('atmo.scheduling.spark_job_run', return_value=u'12345')
+ @mock.patch('atmo.scheduling.spark_job_get', return_value=u'content')
+ def setUp(self, spark_job_get, spark_job_run):
self.test_user = User.objects.create_user('john.smith', 'john@smith.com', 'hunter2')
self.client.force_login(self.test_user)
# request that a new scheduled Spark job be created
- with mock.patch('atmo.utils.scheduling.spark_job_add') as mocked:
+ with mock.patch('atmo.scheduling.spark_job_add') as mocked:
def spark_job_add(identifier, notebook_uploadedfile):
self.saved_notebook_contents = notebook_uploadedfile.read()
return u's3://test/test-notebook.ipynb'
@@ -24,18 +28,20 @@ def spark_job_add(identifier, notebook_uploadedfile):
self.spark_job_add = mocked
self.response = self.client.post(reverse('jobs-new'), {
- 'identifier': 'test-spark-job',
- 'notebook': io.BytesIO('{}'),
- 'result_visibility': 'private',
- 'size': 5,
- 'interval_in_hours': 24,
- 'job_timeout': 12,
- 'start_date': '2016-04-05 13:25:47',
+ 'new-identifier': 'test-spark-job',
+ 'new-notebook': io.BytesIO('{}'),
+ 'new-result_visibility': 'private',
+ 'new-size': 5,
+ 'new-interval_in_hours': 24,
+ 'new-job_timeout': 12,
+ 'new-start_date': '2016-04-05 13:25:47',
}, follow=True)
+ self.spark_job = models.SparkJob.objects.get(identifier='test-spark-job')
def test_that_request_succeeded(self):
self.assertEqual(self.response.status_code, 200)
- self.assertEqual(self.response.redirect_chain[-1], ('/', 302))
+ self.assertEqual(self.response.redirect_chain[-1],
+ (self.spark_job.get_absolute_url(), 302))
def test_that_the_notebook_was_uploaded_correctly(self):
self.assertEqual(self.spark_job_add.call_count, 1)
@@ -60,8 +66,9 @@ def test_that_the_model_was_created_correctly(self):
class TestEditSparkJob(TestCase):
- @mock.patch('atmo.utils.scheduling.spark_job_run', return_value=u'12345')
- def setUp(self, spark_job_run):
+ @mock.patch('atmo.scheduling.spark_job_run', return_value=u'12345')
+ @mock.patch('atmo.scheduling.spark_job_get', return_value=u'content')
+ def setUp(self, spark_job_get, spark_job_run):
self.test_user = User.objects.create_user('john.smith', 'john@smith.com', 'hunter2')
self.client.force_login(self.test_user)
@@ -78,19 +85,24 @@ def setUp(self, spark_job_run):
spark_job.save()
# request that a new scheduled Spark job be created
- self.response = self.client.post(reverse('jobs-edit'), {
- 'job': spark_job.id,
- 'identifier': 'new-spark-job-name',
- 'result_visibility': 'public',
- 'size': 3,
- 'interval_in_hours': 24 * 7,
- 'job_timeout': 10,
- 'start_date': '2016-03-08 11:17:35',
- }, follow=True)
+ self.response = self.client.post(
+ reverse('jobs-edit', kwargs={
+ 'id': spark_job.id,
+ }), {
+ 'edit-job': spark_job.id,
+ 'edit-identifier': 'new-spark-job-name',
+ 'edit-result_visibility': 'public',
+ 'edit-size': 3,
+ 'edit-interval_in_hours': 24 * 7,
+ 'edit-job_timeout': 10,
+ 'edit-start_date': '2016-03-08 11:17:35',
+ }, follow=True)
+ self.spark_job = spark_job
def test_that_request_succeeded(self):
self.assertEqual(self.response.status_code, 200)
- self.assertEqual(self.response.redirect_chain[-1], ('/', 302))
+ self.assertEqual(self.response.redirect_chain[-1],
+ (self.spark_job.get_absolute_url(), 302))
def test_that_the_model_was_edited_correctly(self):
spark_job = models.SparkJob.objects.get(identifier=u'new-spark-job-name')
@@ -109,8 +121,9 @@ def test_that_the_model_was_edited_correctly(self):
class TestDeleteSparkJob(TestCase):
- @mock.patch('atmo.utils.scheduling.spark_job_remove', return_value=None)
- def setUp(self, spark_job_remove):
+ @mock.patch('atmo.scheduling.spark_job_remove', return_value=None)
+ @mock.patch('atmo.scheduling.spark_job_get', return_value=u'content')
+ def setUp(self, spark_job_get, spark_job_remove):
self.test_user = User.objects.create_user('john.smith', 'john@smith.com', 'hunter2')
self.client.force_login(self.test_user)
@@ -127,9 +140,13 @@ def setUp(self, spark_job_remove):
spark_job.save()
# request that the test job be deleted
- self.response = self.client.post(reverse('jobs-delete'), {
- 'job': spark_job.id,
- }, follow=True)
+ self.response = self.client.post(
+ reverse('jobs-delete', kwargs={
+ 'id': spark_job.id,
+ }), {
+ 'delete-job': spark_job.id,
+ 'delete-confirmation': spark_job.identifier,
+ }, follow=True)
self.spark_job_remove = spark_job_remove
diff --git a/atmo/jobs/urls.py b/atmo/jobs/urls.py
index c5c3e89b..72f70383 100644
--- a/atmo/jobs/urls.py
+++ b/atmo/jobs/urls.py
@@ -1,9 +1,12 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^new/', views.new_spark_job, name='jobs-new'),
- url(r'^edit/', views.edit_spark_job, name='jobs-edit'),
- url(r'^delete/', views.delete_spark_job, name='jobs-delete'),
- url(r'^(?P[0-9]+)/$', views.detail_spark_job, name='jobs-detail'),
+ url(r'^(?P\d+)/edit/', views.edit_spark_job, name='jobs-edit'),
+ url(r'^(?P\d+)/delete/', views.delete_spark_job, name='jobs-delete'),
+ url(r'^(?P\d+)/$', views.detail_spark_job, name='jobs-detail'),
]
diff --git a/atmo/jobs/views.py b/atmo/jobs/views.py
index 4a7bc94a..bf00a83a 100644
--- a/atmo/jobs/views.py
+++ b/atmo/jobs/views.py
@@ -1,52 +1,118 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
import logging
-from django.views.decorators.http import require_POST
+from django import forms
from django.contrib.auth.decorators import login_required
-from django.http import HttpResponseBadRequest
from django.shortcuts import redirect, get_object_or_404, render
+from django.utils import timezone
-from session_csrf import anonymous_csrf
-
+from .forms import NewSparkJobForm, EditSparkJobForm, DeleteSparkJobForm
from .models import SparkJob
-from . import forms
logger = logging.getLogger("django")
@login_required
-@anonymous_csrf
-@require_POST
def new_spark_job(request):
- form = forms.NewSparkJobForm(request.user, request.POST, request.FILES)
- if not form.is_valid():
- return HttpResponseBadRequest(form.errors.as_json(escape_html=True))
- form.save() # this will also magically create the job for us
- return redirect("/")
+ username = request.user.email.split("@")[0]
+ initial = {
+ "identifier": "{}-telemetry-scheduled-task".format(username),
+ "size": 1,
+ "interval_in_hours": SparkJob.WEEKLY,
+ "job_timeout": 24,
+ "start_date": timezone.now(),
+ }
+ if request.method == 'POST':
+ form = NewSparkJobForm(
+ request.user,
+ data=request.POST,
+ files=request.FILES,
+ initial=initial,
+ prefix='new',
+ )
+ if form.is_valid():
+ # this will also magically create the spark job for us
+ spark_job = form.save()
+ return redirect(spark_job)
+ else:
+ form = NewSparkJobForm(
+ request.user,
+ initial=initial,
+ prefix='new',
+ )
+ context = {
+ 'form': form,
+ }
+ return render(request, 'atmo/spark-job-new.html', context)
@login_required
-@anonymous_csrf
-@require_POST
-def edit_spark_job(request):
- form = forms.EditSparkJobForm(request.user, request.POST, request.FILES)
- if not form.is_valid():
- return HttpResponseBadRequest(form.errors.as_json(escape_html=True))
- form.save() # this will also update the job for us
- return redirect("/")
+def edit_spark_job(request, id):
+ spark_job = get_object_or_404(SparkJob, created_by=request.user, pk=id)
+ if request.method == 'POST':
+ form = EditSparkJobForm(
+ request.user,
+ data=request.POST,
+ files=request.FILES,
+ instance=spark_job,
+ prefix='edit',
+ )
+ if form.is_valid():
+ # this will also update the job for us
+ spark_job = form.save()
+ return redirect(spark_job)
+ else:
+ form = EditSparkJobForm(
+ request.user,
+ instance=spark_job,
+ prefix='edit',
+ )
+ context = {
+ 'form': form,
+ }
+ return render(request, 'atmo/spark-job-edit.html', context)
@login_required
-@anonymous_csrf
-@require_POST
-def delete_spark_job(request):
- form = forms.DeleteSparkJobForm(request.user, request.POST)
- if not form.is_valid():
- return HttpResponseBadRequest(form.errors.as_json(escape_html=True))
- form.save() # this will also delete the job for us
- return redirect("/")
+def delete_spark_job(request, id):
+ job = get_object_or_404(SparkJob, created_by=request.user, pk=id)
+ if request.method == 'POST':
+ form = DeleteSparkJobForm(
+ request.user,
+ prefix='delete',
+ data=request.POST,
+ instance=job,
+ )
+ if form.is_valid():
+ job.delete()
+ return redirect('dashboard')
+ else:
+ form = DeleteSparkJobForm(
+ request.user,
+ prefix='delete',
+ instance=job,
+ )
+ context = {
+ 'job': job,
+ 'form': form,
+ }
+ return render(request, 'atmo/spark-job-delete.html', context=context)
@login_required
def detail_spark_job(request, id):
job = get_object_or_404(SparkJob, created_by=request.user, pk=id)
- return render(request, 'atmo/detail-spark-job.html', context={'job': job})
+ delete_form = DeleteSparkJobForm(
+ request.user,
+ prefix='delete',
+ instance=job,
+ )
+ # hiding the confirmation input on the detail page
+ delete_form.fields['confirmation'].widget = forms.HiddenInput()
+ context = {
+ 'job': job,
+ 'delete_form': delete_form,
+ }
+ return render(request, 'atmo/spark-job-detail.html', context=context)
diff --git a/atmo/utils/middleware.py b/atmo/middleware.py
similarity index 70%
rename from atmo/utils/middleware.py
rename to atmo/middleware.py
index bf7be81a..2271a360 100644
--- a/atmo/utils/middleware.py
+++ b/atmo/middleware.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
import time
import os
import newrelic.agent
diff --git a/atmo/utils/provisioning.py b/atmo/provisioning.py
similarity index 90%
rename from atmo/utils/provisioning.py
rename to atmo/provisioning.py
index be1979ae..2976ec2b 100644
--- a/atmo/utils/provisioning.py
+++ b/atmo/provisioning.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from uuid import uuid4
from django.conf import settings
@@ -74,6 +77,8 @@ def cluster_start(user_email, identifier, size, public_key, emr_release):
{'Key': 'Owner', 'Value': user_email},
{'Key': 'Name', 'Value': identifier},
{'Key': 'Application', 'Value': settings.AWS_CONFIG['INSTANCE_APP_TAG']},
+ {'Key': 'App', 'Value': settings.AWS_CONFIG['ACCOUNTING_APP_TAG']},
+ {'Key': 'Type', 'Value': settings.AWS_CONFIG['ACCOUNTING_TYPE_TAG']},
],
VisibleToAllUsers=True
)
diff --git a/atmo/schedule.py b/atmo/schedule.py
deleted file mode 100644
index d40f47bf..00000000
--- a/atmo/schedule.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# This Source Code Form is subject to the terms of the Mozilla Public
-# License, v. 2.0. If a copy of the MPL was not distributed with this
-# file, you can obtain one at http://mozilla.org/MPL/2.0/.
-import django_rq
-from atmo.clusters.jobs import delete_clusters, update_clusters_info
-from atmo.jobs.jobs import launch_jobs
-
-
-default_job_timeout = 15
-
-job_schedule = {
- 'delete_clusters': {
- 'cron_string': '* * * * *',
- 'func': delete_clusters,
- 'timeout': 5
- },
- 'update_clusters_info': {
- 'cron_string': '* * * * *',
- 'func': update_clusters_info,
- 'timeout': 5
- },
- 'launch_jobs': {
- 'cron_string': '* * * * *',
- 'func': launch_jobs,
- 'timeout': 5
- },
-}
-
-
-def register_job_schedule():
- scheduler = django_rq.get_scheduler()
- for job_id, params in job_schedule.items():
- scheduler.cron(params['cron_string'], id=job_id,
- func=params['func'], timeout=params.get('timeout', default_job_timeout))
- for job in scheduler.get_jobs():
- if job.id not in job_schedule:
- scheduler.cancel(job)
diff --git a/atmo/utils/scheduling.py b/atmo/scheduling.py
similarity index 78%
rename from atmo/utils/scheduling.py
rename to atmo/scheduling.py
index 7c87895d..4c5e58ba 100644
--- a/atmo/utils/scheduling.py
+++ b/atmo/scheduling.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
from uuid import uuid4
from django.conf import settings
@@ -10,20 +13,30 @@
def spark_job_add(identifier, notebook_uploadedfile):
- # upload the notebook file to S3
+ """
+ Upload the notebook file to S3
+ """
key = 'jobs/{}/{}'.format(identifier, notebook_uploadedfile.name)
s3.put_object(
- Bucket = settings.AWS_CONFIG['CODE_BUCKET'],
- Key = key,
- Body = notebook_uploadedfile
+ Bucket=settings.AWS_CONFIG['CODE_BUCKET'],
+ Key=key,
+ Body=notebook_uploadedfile
)
return key
+def spark_job_get(notebook_s3_key):
+ obj = s3.get_object(
+ Bucket=settings.AWS_CONFIG['CODE_BUCKET'],
+ Key=notebook_s3_key,
+ )
+ return obj['Body'].read()
+
+
def spark_job_remove(notebook_s3_key):
s3.delete_object(
- Bucket = settings.AWS_CONFIG['CODE_BUCKET'],
- Key = notebook_s3_key,
+ Bucket=settings.AWS_CONFIG['CODE_BUCKET'],
+ Key=notebook_s3_key,
)
@@ -79,6 +92,8 @@ def spark_job_run(user_email, identifier, notebook_uri, result_is_public, size,
{'Key': 'Owner', 'Value': user_email},
{'Key': 'Name', 'Value': identifier},
{'Key': 'Application', 'Value': settings.AWS_CONFIG['INSTANCE_APP_TAG']},
+ {'Key': 'App', 'Value': settings.AWS_CONFIG['ACCOUNTING_APP_TAG']},
+ {'Key': 'Type', 'Value': settings.AWS_CONFIG['ACCOUNTING_TYPE_TAG']},
]
)
return cluster['JobFlowId']
diff --git a/atmo/settings.py b/atmo/settings.py
index 892411c3..883d7245 100644
--- a/atmo/settings.py
+++ b/atmo/settings.py
@@ -1,3 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
"""
Django settings for atmo project.
@@ -64,7 +67,7 @@
MIDDLEWARE_CLASSES = (
'django.middleware.security.SecurityMiddleware',
- 'atmo.utils.middleware.NewRelicPapertrailMiddleware',
+ 'atmo.middleware.NewRelicPapertrailMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
@@ -98,6 +101,10 @@
'INSTANCE_APP_TAG': 'telemetry-analysis-worker-instance',
'EMAIL_SOURCE': 'telemetry-alerts@mozilla.com',
+ # Tags for accounting purposes
+ 'ACCOUNTING_APP_TAG': 'telemetry-analysis',
+ 'ACCOUNTING_TYPE_TAG': 'worker',
+
# Buckets for storing S3 data
'CODE_BUCKET': 'telemetry-analysis-code-2',
'PUBLIC_DATA_BUCKET': 'telemetry-public-analysis-2',
@@ -177,9 +184,10 @@
# https://docs.djangoproject.com/en/1.9/topics/i18n/
LANGUAGE_CODE = config('LANGUAGE_CODE', default='en-us')
TIME_ZONE = config('TIME_ZONE', default='UTC')
-USE_I18N = config('USE_I18N', default=True, cast=bool)
-USE_L10N = config('USE_L10N', default=True, cast=bool)
+USE_I18N = False
+USE_L10N = False
USE_TZ = config('USE_TZ', default=True, cast=bool)
+DATETIME_FORMAT = 'Y-m-d H:i' # simplified ISO format since we assume UTC
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATIC_URL = '/static/'
@@ -228,7 +236,7 @@
'django.template.context_processors.request',
'django.contrib.messages.context_processors.messages',
'session_csrf.context_processor',
- 'atmo.utils.context_processors.settings',
+ 'atmo.context_processors.settings',
],
'loaders': [
'django.template.loaders.filesystem.Loader',
diff --git a/atmo/static/css/base.css b/atmo/static/css/base.css
index 58e31db0..e6039545 100644
--- a/atmo/static/css/base.css
+++ b/atmo/static/css/base.css
@@ -1,15 +1,31 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* don't let the top navigation bar cover any content */
+body {
+ padding-top: 30px;
+ padding-bottom: 90px;
+}
-.no-select {
- -webkit-touch-callout: none;
- -webkit-user-select: none;
- -khtml-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
+nav.navbar-dev {
+ background-color: #00539F;
+ -webkit-background-size: 40px 40px;
+ -moz-background-size: 40px 40px;
+ background-size: 40px 40px;
+ background-image:
+ linear-gradient(-45deg, rgba(255, 255, 255, .1) 25%, transparent 25%,
+ transparent 50%, rgba(255, 255, 255, .1) 50%, rgba(255, 255, 255, .1) 75%,
+ transparent 75%, transparent);
+}
+.navbar-dev a,
+.navbar-inverse .navbar-nav > li > a,
+.navbar-inverse .navbar-brand,
+.navbar-inverse .navbar-text,
+.navbar-inverse .navbar-text {
+ color: white;
}
-/* don't let the top navigation bar cover any content */
-body { padding: 70px; }
+.alert-dragons {
+ margin-top: 40px;
+ margin-bottom: 0;
+}
diff --git a/atmo/static/css/dashboard.css b/atmo/static/css/dashboard.css
deleted file mode 100644
index 00e0578f..00000000
--- a/atmo/static/css/dashboard.css
+++ /dev/null
@@ -1,11 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-.lead img {
- margin: 0.5em;
-}
-
-.editable-table .selected {
- background: rgb(217, 237, 247) !important;
-}
diff --git a/atmo/static/js/csrf.js b/atmo/static/js/csrf.js
new file mode 100644
index 00000000..e54b62be
--- /dev/null
+++ b/atmo/static/js/csrf.js
@@ -0,0 +1,12 @@
+(function() {
+ // Ensure that all AJAX requests sent with jQuery have CSRF tokens
+ var csrfToken = jQuery("input[name=csrfmiddlewaretoken]").val();
+ $.ajaxSetup({
+ beforeSend: function(xhr, settings) {
+ // non-CSRF-safe method that isn't cross domain
+ if (["GET", "HEAD", "OPTIONS", "TRACE"].indexOf(settings.Type) < 0 && !this.crossDomain) {
+ xhr.setRequestHeader("X-CSRFToken", csrfToken);
+ }
+ }
+ });
+})();
diff --git a/atmo/static/js/dashboard.js b/atmo/static/js/dashboard.js
deleted file mode 100644
index f2291d50..00000000
--- a/atmo/static/js/dashboard.js
+++ /dev/null
@@ -1,48 +0,0 @@
-$(function() {
- // set up form element popovers
- $('[data-toggle="popover"]').popover();
-
- // apply validation for form controls
- $('input, select, textarea').not('[type=submit]').jqBootstrapValidation();
-
- // apply datetimepicker initialization
- $('.datetimepicker').datetimepicker({
- sideBySide: true, // show the time picker and date picker at the same time
- useCurrent: false, // don't automatically set the date when opening the dialog
- widgetPositioning: {vertical: 'bottom'}, // make sure the picker shows up below the control
- format: 'YYYY-MM-DD h:mm',
- });
-
- $(".editable-table").each(function(i, e) { // select the first row of each editable table
- $(e).find("tr:has(td)").first().addClass("selected");
- updateSelectedIdClasses($(e));
- });
- $(".editable-table tr:has(td)").click(function() {
- // allow selecting individual rows
- var parentTable = $(this).parents("table").first(); // the table containing the clicked row
- parentTable.find("tr").removeClass("selected");
- $(this).addClass("selected"); // select the clicked row
- updateSelectedIdClasses(parentTable);
- });
-});
-
-// given a jQuery table object, update selected object IDs based on which table it is
-// for example, an input with the `selected-cluster` class should always contain the ID
-// of the selected row in the cluster table
-function updateSelectedIdClasses(editableTable) {
- // the first two columns of the tables should be row IDs, and row names
- var selectedId = editableTable.find("tr.selected td:first").text();
- var selectedName = editableTable.find("tr.selected td:nth-child(2)").text();
-
- // update objects as necessary
- switch (editableTable.attr("id")) {
- case "cluster-table":
- $(".selected-cluster").val(selectedId);
- $(".selected-cluster-name").text(selectedName);
- break;
- case "spark-job-table":
- $(".selected-spark-job").val(selectedId);
- $(".selected-spark-job-name").text(selectedName);
- break;
- }
-}
diff --git a/atmo/static/js/forms.js b/atmo/static/js/forms.js
new file mode 100644
index 00000000..8b18b72f
--- /dev/null
+++ b/atmo/static/js/forms.js
@@ -0,0 +1,41 @@
+$(function() {
+ // apply datetimepicker initialization
+ $('.datetimepicker').datetimepicker({
+ sideBySide: true, // show the time picker and date picker at the same time
+ useCurrent: false, // don't automatically set the date when opening the dialog
+ format: 'YYYY-MM-DD HH:mm',
+ stepping: 5,
+ toolbarPlacement: 'top',
+ showTodayButton: true,
+ showClear: true,
+ showClose: true
+ });
+
+ $('form').on('submit', function(event){
+ var $form = $(this);
+ var $submit = $form.find('button[type=submit]');
+ var $cancel = $form.find("a:contains('Cancel')");
+ var $reset = $form.find('button[type=reset]');
+ var submit_label = $submit.text();
+ var wait_label = 'Please wait…';
+
+ // disable submit button and change label
+ $submit.addClass('disabled').find('.submit-button').text(wait_label);
+
+ // hide cancel button
+ $cancel.addClass('hidden');
+
+ var reset_callback = function(event) {
+ // re-enable submit button
+ $submit.removeClass('disabled')
+ .find('.submit-button')
+ .text(submit_label);
+ // show cancel button again
+ $cancel.removeClass('hidden');
+ // hide reset button
+ $reset.addClass('hidden');
+ };
+ // show reset button to be able to reset form
+ $reset.removeClass('hidden').click(reset_callback);
+ });
+});
diff --git a/atmo/static/js/jobs.js b/atmo/static/js/jobs.js
new file mode 100644
index 00000000..263398c0
--- /dev/null
+++ b/atmo/static/js/jobs.js
@@ -0,0 +1,14 @@
+$(function() {
+ var root = this;
+ var $holder = $("#notebook-holder");
+ var content = $("#notebook-content").val();
+
+ if (content) {
+ var parsed = JSON.parse(content);
+ console.log("rendering");
+ var notebook = root.notebook = nb.parse(parsed);
+ $holder.empty()
+ $holder.append(notebook.render());
+ Prism.highlightAll();
+ }
+});
diff --git a/atmo/static/lib/ansi_up.js b/atmo/static/lib/ansi_up.js
new file mode 100644
index 00000000..74f541a4
--- /dev/null
+++ b/atmo/static/lib/ansi_up.js
@@ -0,0 +1,327 @@
+// ansi_up.js
+// version : 1.3.0
+// author : Dru Nelson
+// license : MIT
+// http://github.com/drudru/ansi_up
+
+(function (Date, undefined) {
+
+ var ansi_up,
+ VERSION = "1.3.0",
+
+ // check for nodeJS
+ hasModule = (typeof module !== 'undefined'),
+
+ // Normal and then Bright
+ ANSI_COLORS = [
+ [
+ { color: "0, 0, 0", 'class': "ansi-black" },
+ { color: "187, 0, 0", 'class': "ansi-red" },
+ { color: "0, 187, 0", 'class': "ansi-green" },
+ { color: "187, 187, 0", 'class': "ansi-yellow" },
+ { color: "0, 0, 187", 'class': "ansi-blue" },
+ { color: "187, 0, 187", 'class': "ansi-magenta" },
+ { color: "0, 187, 187", 'class': "ansi-cyan" },
+ { color: "255,255,255", 'class': "ansi-white" }
+ ],
+ [
+ { color: "85, 85, 85", 'class': "ansi-bright-black" },
+ { color: "255, 85, 85", 'class': "ansi-bright-red" },
+ { color: "0, 255, 0", 'class': "ansi-bright-green" },
+ { color: "255, 255, 85", 'class': "ansi-bright-yellow" },
+ { color: "85, 85, 255", 'class': "ansi-bright-blue" },
+ { color: "255, 85, 255", 'class': "ansi-bright-magenta" },
+ { color: "85, 255, 255", 'class': "ansi-bright-cyan" },
+ { color: "255, 255, 255", 'class': "ansi-bright-white" }
+ ]
+ ],
+
+ // 256 Colors Palette
+ PALETTE_COLORS;
+
+ function Ansi_Up() {
+ this.fg = this.bg = this.fg_truecolor = this.bg_truecolor = null;
+ this.bright = 0;
+ }
+
+ Ansi_Up.prototype.setup_palette = function() {
+ PALETTE_COLORS = [];
+ // Index 0..15 : System color
+ (function() {
+ var i, j;
+ for (i = 0; i < 2; ++i) {
+ for (j = 0; j < 8; ++j) {
+ PALETTE_COLORS.push(ANSI_COLORS[i][j]['color']);
+ }
+ }
+ })();
+
+ // Index 16..231 : RGB 6x6x6
+ // https://gist.github.com/jasonm23/2868981#file-xterm-256color-yaml
+ (function() {
+ var levels = [0, 95, 135, 175, 215, 255];
+ var format = function (r, g, b) { return levels[r] + ', ' + levels[g] + ', ' + levels[b] };
+ var r, g, b;
+ for (r = 0; r < 6; ++r) {
+ for (g = 0; g < 6; ++g) {
+ for (b = 0; b < 6; ++b) {
+ PALETTE_COLORS.push(format.call(this, r, g, b));
+ }
+ }
+ }
+ })();
+
+ // Index 232..255 : Grayscale
+ (function() {
+ var level = 8;
+ var format = function(level) { return level + ', ' + level + ', ' + level };
+ var i;
+ for (i = 0; i < 24; ++i, level += 10) {
+ PALETTE_COLORS.push(format.call(this, level));
+ }
+ })();
+ };
+
+ Ansi_Up.prototype.escape_for_html = function (txt) {
+ return txt.replace(/[&<>]/gm, function(str) {
+ if (str == "&") return "&";
+ if (str == "<") return "<";
+ if (str == ">") return ">";
+ });
+ };
+
+ Ansi_Up.prototype.linkify = function (txt) {
+ return txt.replace(/(https?:\/\/[^\s]+)/gm, function(str) {
+ return "" + str + "";
+ });
+ };
+
+ Ansi_Up.prototype.ansi_to_html = function (txt, options) {
+ return this.process(txt, options, true);
+ };
+
+ Ansi_Up.prototype.ansi_to_text = function (txt) {
+ var options = {};
+ return this.process(txt, options, false);
+ };
+
+ Ansi_Up.prototype.process = function (txt, options, markup) {
+ var self = this;
+ var raw_text_chunks = txt.split(/\033\[/);
+ var first_chunk = raw_text_chunks.shift(); // the first chunk is not the result of the split
+
+ var color_chunks = raw_text_chunks.map(function (chunk) {
+ return self.process_chunk(chunk, options, markup);
+ });
+
+ color_chunks.unshift(first_chunk);
+
+ return color_chunks.join('');
+ };
+
+ Ansi_Up.prototype.process_chunk = function (text, options, markup) {
+
+ // Are we using classes or styles?
+ options = typeof options == 'undefined' ? {} : options;
+ var use_classes = typeof options.use_classes != 'undefined' && options.use_classes;
+ var key = use_classes ? 'class' : 'color';
+
+ // Each 'chunk' is the text after the CSI (ESC + '[') and before the next CSI/EOF.
+ //
+ // This regex matches four groups within a chunk.
+ //
+ // The first and third groups match code type.
+ // We supported only SGR command. It has empty first group and 'm' in third.
+ //
+ // The second group matches all of the number+semicolon command sequences
+ // before the 'm' (or other trailing) character.
+ // These are the graphics or SGR commands.
+ //
+ // The last group is the text (including newlines) that is colored by
+ // the other group's commands.
+ var matches = text.match(/^([!\x3c-\x3f]*)([\d;]*)([\x20-\x2c]*[\x40-\x7e])([\s\S]*)/m);
+
+ if (!matches) return text;
+
+ var orig_txt = matches[4];
+ var nums = matches[2].split(';');
+
+ // We currently support only "SGR" (Select Graphic Rendition)
+ // Simply ignore if not a SGR command.
+ if (matches[1] !== '' || matches[3] !== 'm') {
+ return orig_txt;
+ }
+
+ if (!markup) {
+ return orig_txt;
+ }
+
+ var self = this;
+
+ while (nums.length > 0) {
+ var num_str = nums.shift();
+ var num = parseInt(num_str);
+
+ if (isNaN(num) || num === 0) {
+ self.fg = self.bg = null;
+ self.bright = 0;
+ } else if (num === 1) {
+ self.bright = 1;
+ } else if (num == 39) {
+ self.fg = null;
+ } else if (num == 49) {
+ self.bg = null;
+ } else if ((num >= 30) && (num < 38)) {
+ self.fg = ANSI_COLORS[self.bright][(num % 10)][key];
+ } else if ((num >= 90) && (num < 98)) {
+ self.fg = ANSI_COLORS[1][(num % 10)][key];
+ } else if ((num >= 40) && (num < 48)) {
+ self.bg = ANSI_COLORS[0][(num % 10)][key];
+ } else if ((num >= 100) && (num < 108)) {
+ self.bg = ANSI_COLORS[1][(num % 10)][key];
+ } else if (num === 38 || num === 48) { // extend color (38=fg, 48=bg)
+ (function() {
+ var is_foreground = (num === 38);
+ if (nums.length >= 1) {
+ var mode = nums.shift();
+ if (mode === '5' && nums.length >= 1) { // palette color
+ var palette_index = parseInt(nums.shift());
+ if (palette_index >= 0 && palette_index <= 255) {
+ if (!use_classes) {
+ if (!PALETTE_COLORS) {
+ self.setup_palette.call(self);
+ }
+ if (is_foreground) {
+ self.fg = PALETTE_COLORS[palette_index];
+ } else {
+ self.bg = PALETTE_COLORS[palette_index];
+ }
+ } else {
+ var klass = (palette_index >= 16)
+ ? ('ansi-palette-' + palette_index)
+ : ANSI_COLORS[palette_index > 7 ? 1 : 0][palette_index % 8]['class'];
+ if (is_foreground) {
+ self.fg = klass;
+ } else {
+ self.bg = klass;
+ }
+ }
+ }
+ } else if(mode === '2' && nums.length >= 3) { // true color
+ var r = parseInt(nums.shift());
+ var g = parseInt(nums.shift());
+ var b = parseInt(nums.shift());
+ if ((r >= 0 && r <= 255) && (g >= 0 && g <= 255) && (b >= 0 && b <= 255)) {
+ var color = r + ', ' + g + ', ' + b;
+ if (!use_classes) {
+ if (is_foreground) {
+ self.fg = color;
+ } else {
+ self.bg = color;
+ }
+ } else {
+ if (is_foreground) {
+ self.fg = 'ansi-truecolor';
+ self.fg_truecolor = color;
+ } else {
+ self.bg = 'ansi-truecolor';
+ self.bg_truecolor = color;
+ }
+ }
+ }
+ }
+ }
+ })();
+ }
+ }
+
+ if ((self.fg === null) && (self.bg === null)) {
+ return orig_txt;
+ } else {
+ var styles = [];
+ var classes = [];
+ var data = {};
+ var render_data = function (data) {
+ var fragments = [];
+ var key;
+ for (key in data) {
+ if (data.hasOwnProperty(key)) {
+ fragments.push('data-' + key + '="' + this.escape_for_html(data[key]) + '"');
+ }
+ }
+ return fragments.length > 0 ? ' ' + fragments.join(' ') : '';
+ };
+
+ if (self.fg) {
+ if (use_classes) {
+ classes.push(self.fg + "-fg");
+ if (self.fg_truecolor !== null) {
+ data['ansi-truecolor-fg'] = self.fg_truecolor;
+ self.fg_truecolor = null;
+ }
+ } else {
+ styles.push("color:rgb(" + self.fg + ")");
+ }
+ }
+ if (self.bg) {
+ if (use_classes) {
+ classes.push(self.bg + "-bg");
+ if (self.bg_truecolor !== null) {
+ data['ansi-truecolor-bg'] = self.bg_truecolor;
+ self.bg_truecolor = null;
+ }
+ } else {
+ styles.push("background-color:rgb(" + self.bg + ")");
+ }
+ }
+ if (use_classes) {
+ return '' + orig_txt + '';
+ } else {
+ return '' + orig_txt + '';
+ }
+ }
+ };
+
+ // Module exports
+ ansi_up = {
+
+ escape_for_html: function (txt) {
+ var a2h = new Ansi_Up();
+ return a2h.escape_for_html(txt);
+ },
+
+ linkify: function (txt) {
+ var a2h = new Ansi_Up();
+ return a2h.linkify(txt);
+ },
+
+ ansi_to_html: function (txt, options) {
+ var a2h = new Ansi_Up();
+ return a2h.ansi_to_html(txt, options);
+ },
+
+ ansi_to_text: function (txt) {
+ var a2h = new Ansi_Up();
+ return a2h.ansi_to_text(txt);
+ },
+
+ ansi_to_html_obj: function () {
+ return new Ansi_Up();
+ }
+ };
+
+ // CommonJS module is defined
+ if (hasModule) {
+ module.exports = ansi_up;
+ }
+ /*global ender:false */
+ if (typeof window !== 'undefined' && typeof ender === 'undefined') {
+ window.ansi_up = ansi_up;
+ }
+ /*global define:false */
+ if (typeof define === "function" && define.amd) {
+ define("ansi_up", [], function () {
+ return ansi_up;
+ });
+ }
+})(Date);
diff --git a/atmo/static/lib/es5-shim.min.js b/atmo/static/lib/es5-shim.min.js
new file mode 100644
index 00000000..14b24f15
--- /dev/null
+++ b/atmo/static/lib/es5-shim.min.js
@@ -0,0 +1,7 @@
+/*!
+ * https://github.com/es-shims/es5-shim
+ * @license es5-shim Copyright 2009-2015 by contributors, MIT License
+ * see https://github.com/es-shims/es5-shim/blob/v4.5.9/LICENSE
+ */
+(function(t,r){"use strict";if(typeof define==="function"&&define.amd){define(r)}else if(typeof exports==="object"){module.exports=r()}else{t.returnExports=r()}})(this,function(){var t=Array;var r=t.prototype;var e=Object;var n=e.prototype;var i=Function;var a=i.prototype;var o=String;var f=o.prototype;var u=Number;var l=u.prototype;var s=r.slice;var c=r.splice;var v=r.push;var h=r.unshift;var p=r.concat;var y=r.join;var d=a.call;var g=a.apply;var w=Math.max;var b=Math.min;var T=n.toString;var m=typeof Symbol==="function"&&typeof Symbol.toStringTag==="symbol";var D;var S=Function.prototype.toString,x=/^\s*class /,O=function isES6ClassFn(t){try{var r=S.call(t);var e=r.replace(/\/\/.*\n/g,"");var n=e.replace(/\/\*[.\s\S]*\*\//g,"");var i=n.replace(/\n/gm," ").replace(/ {2}/g," ");return x.test(i)}catch(a){return false}},j=function tryFunctionObject(t){try{if(O(t)){return false}S.call(t);return true}catch(r){return false}},E="[object Function]",I="[object GeneratorFunction]",D=function isCallable(t){if(!t){return false}if(typeof t!=="function"&&typeof t!=="object"){return false}if(m){return j(t)}if(O(t)){return false}var r=T.call(t);return r===E||r===I};var M;var U=RegExp.prototype.exec,F=function tryRegexExec(t){try{U.call(t);return true}catch(r){return false}},N="[object RegExp]";M=function isRegex(t){if(typeof t!=="object"){return false}return m?F(t):T.call(t)===N};var C;var k=String.prototype.valueOf,A=function tryStringObject(t){try{k.call(t);return true}catch(r){return false}},R="[object String]";C=function isString(t){if(typeof t==="string"){return true}if(typeof t!=="object"){return false}return m?A(t):T.call(t)===R};var P=e.defineProperty&&function(){try{var t={};e.defineProperty(t,"x",{enumerable:false,value:t});for(var r in t){return false}return t.x===t}catch(n){return false}}();var $=function(t){var r;if(P){r=function(t,r,n,i){if(!i&&r in t){return}e.defineProperty(t,r,{configurable:true,enumerable:false,writable:true,value:n})}}else{r=function(t,r,e,n){if(!n&&r in t){return}t[r]=e}}return function defineProperties(e,n,i){for(var a in n){if(t.call(n,a)){r(e,a,n[a],i)}}}}(n.hasOwnProperty);var J=function isPrimitive(t){var r=typeof t;return t===null||r!=="object"&&r!=="function"};var Y=u.isNaN||function isActualNaN(t){return t!==t};var Z={ToInteger:function ToInteger(t){var r=+t;if(Y(r)){r=0}else if(r!==0&&r!==1/0&&r!==-(1/0)){r=(r>0||-1)*Math.floor(Math.abs(r))}return r},ToPrimitive:function ToPrimitive(t){var r,e,n;if(J(t)){return t}e=t.valueOf;if(D(e)){r=e.call(t);if(J(r)){return r}}n=t.toString;if(D(n)){r=n.call(t);if(J(r)){return r}}throw new TypeError},ToObject:function(t){if(t==null){throw new TypeError("can't convert "+t+" to object")}return e(t)},ToUint32:function ToUint32(t){return t>>>0}};var z=function Empty(){};$(a,{bind:function bind(t){var r=this;if(!D(r)){throw new TypeError("Function.prototype.bind called on incompatible "+r)}var n=s.call(arguments,1);var a;var o=function(){if(this instanceof a){var i=g.call(r,this,p.call(n,s.call(arguments)));if(e(i)===i){return i}return this}else{return g.call(r,t,p.call(n,s.call(arguments)))}};var f=w(0,r.length-n.length);var u=[];for(var l=0;l1){a=arguments[1]}if(!D(t)){throw new TypeError("Array.prototype.forEach callback must be a function")}while(++n1){o=arguments[1]}if(!D(r)){throw new TypeError("Array.prototype.map callback must be a function")}for(var f=0;f1){o=arguments[1]}if(!D(t)){throw new TypeError("Array.prototype.filter callback must be a function")}for(var f=0;f1){i=arguments[1]}if(!D(t)){throw new TypeError("Array.prototype.every callback must be a function")}for(var a=0;a1){i=arguments[1]}if(!D(t)){throw new TypeError("Array.prototype.some callback must be a function")}for(var a=0;a=2){a=arguments[1]}else{do{if(i in e){a=e[i++];break}if(++i>=n){throw new TypeError("reduce of empty array with no initial value")}}while(true)}for(;i=2){i=arguments[1]}else{do{if(a in e){i=e[a--];break}if(--a<0){throw new TypeError("reduceRight of empty array with no initial value")}}while(true)}if(a<0){return i}do{if(a in e){i=t(i,e[a],a,r)}}while(a--);return i}},!at);var ot=r.indexOf&&[0,1].indexOf(1,2)!==-1;$(r,{indexOf:function indexOf(t){var r=et&&C(this)?X(this,""):Z.ToObject(this);var e=Z.ToUint32(r.length);if(e===0){return-1}var n=0;if(arguments.length>1){n=Z.ToInteger(arguments[1])}n=n>=0?n:w(0,e+n);for(;n1){n=b(n,Z.ToInteger(arguments[1]))}n=n>=0?n:e-Math.abs(n);for(;n>=0;n--){if(n in r&&t===r[n]){return n}}return-1}},ft);var ut=function(){var t=[1,2];var r=t.splice();return t.length===2&&_(r)&&r.length===0}();$(r,{splice:function splice(t,r){if(arguments.length===0){return[]}else{return c.apply(this,arguments)}}},!ut);var lt=function(){var t={};r.splice.call(t,0,0,1);return t.length===1}();$(r,{splice:function splice(t,r){if(arguments.length===0){return[]}var e=arguments;this.length=w(Z.ToInteger(this.length),0);if(arguments.length>0&&typeof r!=="number"){e=H(arguments);if(e.length<2){K(e,this.length-t)}else{e[1]=Z.ToInteger(r)}}return c.apply(this,e)}},!lt);var st=function(){var r=new t(1e5);r[8]="x";r.splice(1,1);return r.indexOf("x")===7}();var ct=function(){var t=256;var r=[];r[t]="a";r.splice(t+1,0,"b");return r[t]==="a"}();$(r,{splice:function splice(t,r){var e=Z.ToObject(this);var n=[];var i=Z.ToUint32(e.length);var a=Z.ToInteger(t);var f=a<0?w(i+a,0):b(a,i);var u=b(w(Z.ToInteger(r),0),i-f);var l=0;var s;while(ly){delete e[l-1];l-=1}}else if(v>u){l=i-u;while(l>f){s=o(l+u-1);h=o(l+v-1);if(G(e,s)){e[h]=e[s]}else{delete e[h]}l-=1}}l=f;for(var d=0;d=0&&!_(t)&&D(t.callee)};var Ct=Ft(arguments)?Ft:Nt;$(e,{keys:function keys(t){var r=D(t);var e=Ct(t);var n=t!==null&&typeof t==="object";var i=n&&C(t);if(!n&&!r&&!e){throw new TypeError("Object.keys called on a non-object")}var a=[];var f=St&&r;if(i&&xt||e){for(var u=0;u11){return t+1}return t},getMonth:function getMonth(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Bt(this);var r=Ht(this);if(t<0&&r>11){return 0}return r},getDate:function getDate(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Bt(this);var r=Ht(this);var e=Wt(this);if(t<0&&r>11){if(r===12){return e}var n=nr(0,t+1);return n-e+1}return e},getUTCFullYear:function getUTCFullYear(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Lt(this);if(t<0&&Xt(this)>11){return t+1}return t},getUTCMonth:function getUTCMonth(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Lt(this);var r=Xt(this);if(t<0&&r>11){return 0}return r},getUTCDate:function getUTCDate(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Lt(this);var r=Xt(this);var e=qt(this);if(t<0&&r>11){if(r===12){return e}var n=nr(0,t+1);return n-e+1}return e}},Pt);$(Date.prototype,{toUTCString:function toUTCString(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Kt(this);var r=qt(this);var e=Xt(this);var n=Lt(this);var i=Qt(this);var a=Vt(this);var o=_t(this);return rr[t]+", "+(r<10?"0"+r:r)+" "+er[e]+" "+n+" "+(i<10?"0"+i:i)+":"+(a<10?"0"+a:a)+":"+(o<10?"0"+o:o)+" GMT"}},Pt||Yt);$(Date.prototype,{toDateString:function toDateString(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=this.getDay();var r=this.getDate();var e=this.getMonth();var n=this.getFullYear();return rr[t]+" "+er[e]+" "+(r<10?"0"+r:r)+" "+n}},Pt||Zt);if(Pt||zt){Date.prototype.toString=function toString(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=this.getDay();var r=this.getDate();var e=this.getMonth();var n=this.getFullYear();var i=this.getHours();var a=this.getMinutes();var o=this.getSeconds();var f=this.getTimezoneOffset();var u=Math.floor(Math.abs(f)/60);var l=Math.floor(Math.abs(f)%60);return rr[t]+" "+er[e]+" "+(r<10?"0"+r:r)+" "+n+" "+(i<10?"0"+i:i)+":"+(a<10?"0"+a:a)+":"+(o<10?"0"+o:o)+" GMT"+(f>0?"-":"+")+(u<10?"0"+u:u)+(l<10?"0"+l:l)};if(P){e.defineProperty(Date.prototype,"toString",{configurable:true,enumerable:false,writable:true})}}var ir=-621987552e5;var ar="-000001";var or=Date.prototype.toISOString&&new Date(ir).toISOString().indexOf(ar)===-1;var fr=Date.prototype.toISOString&&new Date(-1).toISOString()!=="1969-12-31T23:59:59.999Z";var ur=d.bind(Date.prototype.getTime);$(Date.prototype,{toISOString:function toISOString(){if(!isFinite(this)||!isFinite(ur(this))){throw new RangeError("Date.prototype.toISOString called on non-finite value.")}var t=Lt(this);var r=Xt(this);t+=Math.floor(r/12);r=(r%12+12)%12;var e=[r+1,qt(this),Qt(this),Vt(this),_t(this)];t=(t<0?"-":t>9999?"+":"")+L("00000"+Math.abs(t),0<=t&&t<=9999?-4:-6);for(var n=0;n=7&&l>hr){var p=Math.floor(l/hr)*hr;var y=Math.floor(p/1e3);v+=y;h-=y*1e3}c=s===1&&o(e)===e?new t(r.parse(e)):s>=7?new t(e,n,i,a,f,v,h):s>=6?new t(e,n,i,a,f,v):s>=5?new t(e,n,i,a,f):s>=4?new t(e,n,i,a):s>=3?new t(e,n,i):s>=2?new t(e,n):s>=1?new t(e instanceof t?+e:e):new t}else{c=t.apply(this,arguments)}if(!J(c)){$(c,{constructor:r},true)}return c};var e=new RegExp("^"+"(\\d{4}|[+-]\\d{6})"+"(?:-(\\d{2})"+"(?:-(\\d{2})"+"(?:"+"T(\\d{2})"+":(\\d{2})"+"(?:"+":(\\d{2})"+"(?:(\\.\\d{1,}))?"+")?"+"("+"Z|"+"(?:"+"([-+])"+"(\\d{2})"+":(\\d{2})"+")"+")?)?)?)?"+"$");var n=[0,31,59,90,120,151,181,212,243,273,304,334,365];var i=function dayFromMonth(t,r){var e=r>1?1:0;return n[r]+Math.floor((t-1969+e)/4)-Math.floor((t-1901+e)/100)+Math.floor((t-1601+e)/400)+365*(t-1970)};var a=function toUTC(r){var e=0;var n=r;if(pr&&n>hr){var i=Math.floor(n/hr)*hr;var a=Math.floor(i/1e3);e+=a;n-=a*1e3}return u(new t(1970,0,1,0,0,e,n))};for(var f in t){if(G(t,f)){r[f]=t[f]}}$(r,{now:t.now,UTC:t.UTC},true);r.prototype=t.prototype;$(r.prototype,{constructor:r},true);var l=function parse(r){var n=e.exec(r);if(n){var o=u(n[1]),f=u(n[2]||1)-1,l=u(n[3]||1)-1,s=u(n[4]||0),c=u(n[5]||0),v=u(n[6]||0),h=Math.floor(u(n[7]||0)*1e3),p=Boolean(n[4]&&!n[8]),y=n[9]==="-"?1:-1,d=u(n[10]||0),g=u(n[11]||0),w;var b=c>0||v>0||h>0;if(s<(b?24:25)&&c<60&&v<60&&h<1e3&&f>-1&&f<12&&d<24&&g<60&&l>-1&&l=0){e+=dr.data[r];dr.data[r]=Math.floor(e/t);e=e%t*dr.base}},numToString:function numToString(){var t=dr.size;var r="";while(--t>=0){if(r!==""||t===0||dr.data[t]!==0){var e=o(dr.data[t]);if(r===""){r=e}else{r+=L("0000000",0,7-e.length)+e}}}return r},pow:function pow(t,r,e){return r===0?e:r%2===1?pow(t,r-1,e*t):pow(t*t,r/2,e)},log:function log(t){var r=0;var e=t;while(e>=4096){r+=12;e/=4096}while(e>=2){r+=1;e/=2}return r}};var gr=function toFixed(t){var r,e,n,i,a,f,l,s;r=u(t);r=Y(r)?0:Math.floor(r);if(r<0||r>20){throw new RangeError("Number.toFixed called with invalid number of decimals")}e=u(this);if(Y(e)){return"NaN"}if(e<=-1e21||e>=1e21){return o(e)}n="";if(e<0){n="-";e=-e}i="0";if(e>1e-21){a=dr.log(e*dr.pow(2,69,1))-69;f=a<0?e*dr.pow(2,-a,1):e/dr.pow(2,a,1);f*=4503599627370496;a=52-a;if(a>0){dr.multiply(0,f);l=r;while(l>=7){dr.multiply(1e7,0);l-=7}dr.multiply(dr.pow(10,l,1),0);l=a-1;while(l>=23){dr.divide(1<<23);l-=23}dr.divide(1<0){s=i.length;if(s<=r){i=n+L("0.0000000000000000000",0,r-s+2)+i}else{i=n+L(i,0,s-r)+"."+L(i,s-r)}}else{i=n+i}return i};$(l,{toFixed:gr},yr);var wr=function(){try{return 1..toPrecision(undefined)==="1"}catch(t){return true}}();var br=l.toPrecision;$(l,{toPrecision:function toPrecision(t){return typeof t==="undefined"?br.call(this):br.call(this,t)}},wr);if("ab".split(/(?:ab)*/).length!==2||".".split(/(.?)(.?)/).length!==4||"tesst".split(/(s)*/)[1]==="t"||"test".split(/(?:)/,-1).length!==4||"".split(/.?/).length||".".split(/()()/).length>1){(function(){var t=typeof/()??/.exec("")[1]==="undefined";var r=Math.pow(2,32)-1;f.split=function(e,n){var i=String(this);if(typeof e==="undefined"&&n===0){return[]}if(!M(e)){return X(this,e,n)}var a=[];var o=(e.ignoreCase?"i":"")+(e.multiline?"m":"")+(e.unicode?"u":"")+(e.sticky?"y":""),f=0,u,l,s,c;var h=new RegExp(e.source,o+"g");if(!t){u=new RegExp("^"+h.source+"$(?!\\s)",o)}var p=typeof n==="undefined"?r:Z.ToUint32(n);l=h.exec(i);while(l){s=l.index+l[0].length;if(s>f){K(a,L(i,f,l.index));if(!t&&l.length>1){l[0].replace(u,function(){for(var t=1;t1&&l.index=p){break}}if(h.lastIndex===l.index){h.lastIndex++}l=h.exec(i)}if(f===i.length){if(c||!h.test("")){K(a,"")}}else{K(a,L(i,f))}return a.length>p?H(a,0,p):a}})()}else if("0".split(void 0,0).length){f.split=function split(t,r){if(typeof t==="undefined"&&r===0){return[]}return X(this,t,r)}}var Tr=f.replace;var mr=function(){var t=[];"x".replace(/x(.)?/g,function(r,e){K(t,e)});return t.length===1&&typeof t[0]==="undefined"}();if(!mr){f.replace=function replace(t,r){var e=D(r);var n=M(t)&&/\)[*?]/.test(t.source);if(!e||!n){return Tr.call(this,t,r)}else{var i=function(e){var n=arguments.length;var i=t.lastIndex;t.lastIndex=0;var a=t.exec(e)||[];t.lastIndex=i;K(a,arguments[n-2],arguments[n-1]);return r.apply(this,a)};return Tr.call(this,t,i)}}}var Dr=f.substr;var Sr="".substr&&"0b".substr(-1)!=="b";$(f,{substr:function substr(t,r){var e=t;if(t<0){e=w(this.length+t,0)}return Dr.call(this,e,r)}},Sr);var xr=" \n\x0B\f\r \xa0\u1680\u180e\u2000\u2001\u2002\u2003"+"\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028"+"\u2029\ufeff";var Or="\u200b";var jr="["+xr+"]";var Er=new RegExp("^"+jr+jr+"*");var Ir=new RegExp(jr+jr+"*$");var Mr=f.trim&&(xr.trim()||!Or.trim());$(f,{trim:function trim(){if(typeof this==="undefined"||this===null){throw new TypeError("can't convert "+this+" to object")}return o(this).replace(Er,"").replace(Ir,"")}},Mr);var Ur=d.bind(String.prototype.trim);var Fr=f.lastIndexOf&&"abc\u3042\u3044".lastIndexOf("\u3042\u3044",2)!==-1;$(f,{lastIndexOf:function lastIndexOf(t){if(typeof this==="undefined"||this===null){throw new TypeError("can't convert "+this+" to object")}var r=o(this);var e=o(t);var n=arguments.length>1?u(arguments[1]):NaN;var i=Y(n)?Infinity:Z.ToInteger(n);var a=b(w(i,0),r.length);var f=e.length;var l=a+f;while(l>0){l=w(0,l-f);var s=q(L(r,l,a+f),e);if(s!==-1){return l+s}}return-1}},Fr);var Nr=f.lastIndexOf;$(f,{lastIndexOf:function lastIndexOf(t){return Nr.apply(this,arguments)}},f.lastIndexOf.length!==1);if(parseInt(xr+"08")!==8||parseInt(xr+"0x16")!==22){parseInt=function(t){var r=/^[\-+]?0[xX]/;return function parseInt(e,n){var i=Ur(String(e));var a=u(n)||(r.test(i)?16:10);return t(i,a)}}(parseInt)}if(1/parseFloat("-0")!==-Infinity){parseFloat=function(t){return function parseFloat(r){var e=Ur(String(r));var n=t(e);return n===0&&L(e,0,1)==="-"?-0:n}}(parseFloat)}if(String(new RangeError("test"))!=="RangeError: test"){var Cr=function toString(){if(typeof this==="undefined"||this===null){throw new TypeError("can't convert "+this+" to object")}var t=this.name;if(typeof t==="undefined"){t="Error"}else if(typeof t!=="string"){t=o(t)}var r=this.message;if(typeof r==="undefined"){r=""}else if(typeof r!=="string"){r=o(r)}if(!t){return r}if(!r){return t}return t+": "+r};Error.prototype.toString=Cr}if(P){var kr=function(t,r){if(Q(t,r)){var e=Object.getOwnPropertyDescriptor(t,r);if(e.configurable){e.enumerable=false;Object.defineProperty(t,r,e)}}};kr(Error.prototype,"message");if(Error.prototype.message!==""){Error.prototype.message=""}kr(Error.prototype,"name")}if(String(/a/gim)!=="/a/gim"){var Ar=function toString(){var t="/"+this.source+"/";if(this.global){t+="g"}if(this.ignoreCase){t+="i"}if(this.multiline){t+="m"}return t};RegExp.prototype.toString=Ar}});
+//# sourceMappingURL=es5-shim.map
diff --git a/atmo/static/lib/jqBootstrapValidation.min.js b/atmo/static/lib/jqBootstrapValidation.min.js
deleted file mode 100644
index c5d52362..00000000
--- a/atmo/static/lib/jqBootstrapValidation.min.js
+++ /dev/null
@@ -1,4 +0,0 @@
-/*! jqBootstrapValidation - v1.3.7 - 2013-05-07
-* http://reactiveraven.github.com/jqBootstrapValidation
-* Copyright (c) 2013 David Godfrey; Licensed MIT */
-(function(e){function s(e){return new RegExp("^"+e+"$")}function o(e,t){var n=Array.prototype.slice.call(arguments,2),r=e.split("."),i=r.pop();for(var s=0;s0});u.trigger("submit.validation"),i.trigger("validationLostFocus.validation"),s.each(function(t,n){var i=e(n);if(i.hasClass("warning")||i.hasClass("error"))i.removeClass("warning").addClass("error"),r++}),r?(o.options.preventSubmit&&(t.preventDefault(),t.stopImmediatePropagation()),n.addClass("error"),e.isFunction(o.options.submitError)&&o.options.submitError(n,t,u.jqBootstrapValidation("collectErrors",!0))):(n.removeClass("error"),e.isFunction(o.options.submitSuccess)&&o.options.submitSuccess(n,t))}),this.each(function(){var n=e(this),s=n.parents(".control-group").first(),u=s.find(".help-block").first(),a=n.parents("form").first(),f=[];!u.length&&o.options.autoAdd&&o.options.autoAdd.helpBlocks&&(u=e(''),s.find(".controls").append(u),t.push(u[0]));if(o.options.sniffHtml){var l;n.data("validationPatternPattern")&&n.attr("pattern",n.data("validationPatternPattern")),n.attr("pattern")!==undefined&&(l="Not in the expected format",n.data("validationPatternMessage")&&(l=n.data("validationPatternMessage")),n.data("validationPatternMessage",l),n.data("validationPatternRegex",n.attr("pattern")));if(n.attr("max")!==undefined||n.attr("aria-valuemax")!==undefined){var c=n.attr("max")!==undefined?n.attr("max"):n.attr("aria-valuemax");l="Too high: Maximum of '"+c+"'",n.data("validationMaxMessage")&&(l=n.data("validationMaxMessage")),n.data("validationMaxMessage",l),n.data("validationMaxMax",c)}if(n.attr("min")!==undefined||n.attr("aria-valuemin")!==undefined){var h=n.attr("min")!==undefined?n.attr("min"):n.attr("aria-valuemin");l="Too low: Minimum of '"+h+"'",n.data("validationMinMessage")&&(l=n.data("validationMinMessage")),n.data("validationMinMessage",l),n.data("validationMinMin",h)}n.attr("maxlength")!==undefined&&(l="Too long: Maximum of '"+n.attr("maxlength")+"' characters",n.data("validationMaxlengthMessage")&&(l=n.data("validationMaxlengthMessage")),n.data("validationMaxlengthMessage",l),n.data("validationMaxlengthMaxlength",n.attr("maxlength"))),n.attr("minlength")!==undefined&&(l="Too short: Minimum of '"+n.attr("minlength")+"' characters",n.data("validationMinlengthMessage")&&(l=n.data("validationMinlengthMessage")),n.data("validationMinlengthMessage",l),n.data("validationMinlengthMinlength",n.attr("minlength")));if(n.attr("required")!==undefined||n.attr("aria-required")!==undefined)l=o.builtInValidators.required.message,n.data("validationRequiredMessage")&&(l=n.data("validationRequiredMessage")),n.data("validationRequiredMessage",l);if(n.attr("type")!==undefined&&n.attr("type").toLowerCase()==="number"){l=o.validatorTypes.number.message,n.data("validationNumberMessage")&&(l=n.data("validationNumberMessage")),n.data("validationNumberMessage",l);var p=o.validatorTypes.number.step;n.data("validationNumberStep")&&(p=n.data("validationNumberStep")),n.data("validationNumberStep",p);var d=o.validatorTypes.number.decimal;n.data("validationNumberDecimal")&&(d=n.data("validationNumberDecimal")),n.data("validationNumberDecimal",d)}n.attr("type")!==undefined&&n.attr("type").toLowerCase()==="email"&&(l="Not a valid email address",n.data("validationEmailMessage")&&(l=n.data("validationEmailMessage")),n.data("validationEmailMessage",l)),n.attr("minchecked")!==undefined&&(l="Not enough options checked; Minimum of '"+n.attr("minchecked")+"' required",n.data("validationMincheckedMessage")&&(l=n.data("validationMincheckedMessage")),n.data("validationMincheckedMessage",l),n.data("validationMincheckedMinchecked",n.attr("minchecked"))),n.attr("maxchecked")!==undefined&&(l="Too many options checked; Maximum of '"+n.attr("maxchecked")+"' required",n.data("validationMaxcheckedMessage")&&(l=n.data("validationMaxcheckedMessage")),n.data("validationMaxcheckedMessage",l),n.data("validationMaxcheckedMaxchecked",n.attr("maxchecked")))}n.data("validation")!==undefined&&(f=n.data("validation").split(",")),e.each(n.data(),function(e,t){var n=e.replace(/([A-Z])/g,",$1").split(",");n[0]==="validation"&&n[1]&&f.push(n[1])});var v=f,m=[],g=function(e,t){f[e]=r(t)},y=function(t,i){if(n.data("validation"+i+"Shortcut")!==undefined)e.each(n.data("validation"+i+"Shortcut").split(","),function(e,t){m.push(t)});else if(o.builtInValidators[i.toLowerCase()]){var s=o.builtInValidators[i.toLowerCase()];s.type.toLowerCase()==="shortcut"&&e.each(s.shortcut.split(","),function(e,t){t=r(t),m.push(t),f.push(t)})}};do e.each(f,g),f=e.unique(f),m=[],e.each(v,y),v=m;while(v.length>0);var b={};e.each(f,function(t,i){var s=n.data("validation"+i+"Message"),u=!!s,a=!1;s||(s="'"+i+"' validation failed "),e.each(o.validatorTypes,function(t,o){b[t]===undefined&&(b[t]=[]);if(!a&&n.data("validation"+i+r(o.name))!==undefined){var f=o.init(n,i);u&&(f.message=s),b[t].push(e.extend(!0,{name:r(o.name),message:s},f)),a=!0}});if(!a&&o.builtInValidators[i.toLowerCase()]){var f=e.extend(!0,{},o.builtInValidators[i.toLowerCase()]);u&&(f.message=s);var l=f.type.toLowerCase();l==="shortcut"?a=!0:e.each(o.validatorTypes,function(t,s){b[t]===undefined&&(b[t]=[]),!a&&l===t.toLowerCase()&&(n.data("validation"+i+r(s.name),f[s.name.toLowerCase()]),b[l].push(e.extend(f,s.init(n,i))),a=!0)})}a||e.error("Cannot find validation info for '"+i+"'")}),u.data("original-contents",u.data("original-contents")?u.data("original-contents"):u.html()),u.data("original-role",u.data("original-role")?u.data("original-role"):u.attr("role")),s.data("original-classes",s.data("original-clases")?s.data("original-classes"):s.attr("class")),n.data("original-aria-invalid",n.data("original-aria-invalid")?n.data("original-aria-invalid"):n.attr("aria-invalid")),n.bind("validation.validation",function(t,r){var s=i(n),u=[];return e.each(b,function(t,i){(s||s.length||r&&r.includeEmpty||!!o.validatorTypes[t].includeEmpty||!!o.validatorTypes[t].blockSubmit&&r&&!!r.submitting)&&e.each(i,function(e,r){o.validatorTypes[t].validate(n,s,r)&&u.push(r.message)})}),u}),n.bind("getValidators.validation",function(){return b});var w=0;e.each(b,function(e,t){w+=t.length}),n.bind("getValidatorCount.validation",function(){return w}),n.bind("submit.validation",function(){return n.triggerHandler("change.validation",{submitting:!0})}),n.bind((o.options.bindEvents.length>0?o.options.bindEvents:["keyup","focus","blur","click","keydown","keypress","change"]).concat(["revalidate"]).join(".validation ")+".validation",function(t,r){var f=i(n),l=[];r&&!!r.submitting?s.data("jqbvIsSubmitting",!0):t.type!=="revalidate"&&s.data("jqbvIsSubmitting",!1);var c=!!s.data("jqbvIsSubmitting");s.find("input,textarea,select").each(function(t,i){var s=l.length;e.each(e(i).triggerHandler("validation.validation",r),function(e,t){l.push(t)});if(l.length>s)e(i).attr("aria-invalid","true");else{var o=n.data("original-aria-invalid");e(i).attr("aria-invalid",o!==undefined?o:!1)}}),a.find("input,select,textarea").not(n).not('[name="'+n.attr("name")+'"]').trigger("validationLostFocus.validation"),l=e.unique(l.sort()),l.length?(s.removeClass("success error warning").addClass(c?"error":"warning"),o.options.semanticallyStrict&&l.length===1?u.html(l[0]+(o.options.prependExistingHelpBlock?u.data("original-contents"):"")):u.html('"+(o.options.prependExistingHelpBlock?u.data("original-contents"):""))):(s.removeClass("warning error success"),f.length>0&&s.addClass("success"),u.html(u.data("original-contents"))),t.type==="blur"&&s.removeClass("success")}),n.bind("validationLostFocus.validation",function(){s.removeClass("success")})})},destroy:function(){return this.each(function(){var n=e(this),r=n.parents(".control-group").first(),i=r.find(".help-block").first(),s=n.parents("form").first();n.unbind(".validation"),s.unbind(".validationSubmit"),i.html(i.data("original-contents")),r.attr("class",r.data("original-classes")),n.attr("aria-invalid",n.data("original-aria-invalid")),i.attr("role",n.data("original-role")),e.inArray(i[0],t)>-1&&i.remove()})},collectErrors:function(t){var n={};return this.each(function(t,r){var i=e(r),s=i.attr("name"),o=i.triggerHandler("validation.validation",{includeEmpty:!0});n[s]=e.extend(!0,o,n[s])}),e.each(n,function(e,t){t.length===0&&delete n[e]}),n},hasErrors:function(){var t=[];return this.find("input,select,textarea").add(this).each(function(n,r){t=t.concat(e(r).triggerHandler("getValidators.validation")?e(r).triggerHandler("validation.validation",{submitting:!0}):[])}),t.length>0},override:function(t){n=e.extend(!0,n,t)}},validatorTypes:{callback:{name:"callback",init:function(e,t){var n={validatorName:t,callback:e.data("validation"+t+"Callback"),lastValue:e.val(),lastValid:!0,lastFinished:!0},r="Not valid";return e.data("validation"+t+"Message")&&(r=e.data("validation"+t+"Message")),n.message=r,n},validate:function(e,t,n){if(n.lastValue===t&&n.lastFinished)return!n.lastValid;if(n.lastFinished===!0){n.lastValue=t,n.lastValid=!0,n.lastFinished=!1;var r=n,i=e;o(n.callback,window,e,t,function(t){r.lastValue===t.value&&(r.lastValid=t.valid,t.message&&(r.message=t.message),r.lastFinished=!0,i.data("validation"+r.validatorName+"Message",r.message),setTimeout(function(){!e.is(":focus")&&e.parents("form").first().data("jqbvIsSubmitting")?i.trigger("blur.validation"):i.trigger("revalidate.validation")},1))})}return!1}},ajax:{name:"ajax",init:function(e,t){return{validatorName:t,url:e.data("validation"+t+"Ajax"),lastValue:e.val(),lastValid:!0,lastFinished:!0}},validate:function(t,n,r){return""+r.lastValue==""+n&&r.lastFinished===!0?r.lastValid===!1:(r.lastFinished===!0&&(r.lastValue=n,r.lastValid=!0,r.lastFinished=!1,e.ajax({url:r.url,data:"value="+encodeURIComponent(n)+"&field="+t.attr("name"),dataType:"json",success:function(e){""+r.lastValue==""+e.value&&(r.lastValid=!!e.valid,e.message&&(r.message=e.message),r.lastFinished=!0,t.data("validation"+r.validatorName+"Message",r.message),setTimeout(function(){t.trigger("revalidate.validation")},1))},failure:function(){r.lastValid=!0,r.message="ajax call failed",r.lastFinished=!0,t.data("validation"+r.validatorName+"Message",r.message),setTimeout(function(){t.trigger("revalidate.validation")},1)}})),!1)}},regex:{name:"regex",init:function(t,n){var r={},i=t.data("validation"+n+"Regex");r.regex=s(i),i===undefined&&e.error("Can't find regex for '"+n+"' validator on '"+t.attr("name")+"'");var o="Not in the expected format";return t.data("validation"+n+"Message")&&(o=t.data("validation"+n+"Message")),r.message=o,r.originalName=n,r},validate:function(e,t,n){return!n.regex.test(t)&&!n.negative||n.regex.test(t)&&n.negative}},email:{name:"email",init:function(e,t){var n={};n.regex=s("[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,4}");var r="Not a valid email address";return e.data("validation"+t+"Message")&&(r=e.data("validation"+t+"Message")),n.message=r,n.originalName=t,n},validate:function(e,t,n){return!n.regex.test(t)&&!n.negative||n.regex.test(t)&&n.negative}},required:{name:"required",init:function(e,t){var n="This is required";return e.data("validation"+t+"Message")&&(n=e.data("validation"+t+"Message")),{message:n,includeEmpty:!0}},validate:function(e,t,n){return!!(t.length===0&&!n.negative||t.length>0&&n.negative)},blockSubmit:!0},match:{name:"match",init:function(t,n){var r=t.data("validation"+n+"Match"),i=t.parents("form").first(),s=i.find('[name="'+r+'"]').first();s.bind("validation.validation",function(){t.trigger("revalidate.validation",{submitting:!0})});var o={};o.element=s,s.length===0&&e.error("Can't find field '"+r+"' to match '"+t.attr("name")+"' against in '"+n+"' validator");var u="Must match",a=null;return(a=i.find('label[for="'+r+'"]')).length?u+=" '"+a.text()+"'":(a=s.parents(".control-group").first().find("label")).length&&(u+=" '"+a.first().text()+"'"),t.data("validation"+n+"Message")&&(u=t.data("validation"+n+"Message")),o.message=u,o},validate:function(e,t,n){return t!==n.element.val()&&!n.negative||t===n.element.val()&&n.negative},blockSubmit:!0,includeEmpty:!0},max:{name:"max",init:function(e,t){var n={};return n.max=e.data("validation"+t+"Max"),n.message="Too high: Maximum of '"+n.max+"'",e.data("validation"+t+"Message")&&(n.message=e.data("validation"+t+"Message")),n},validate:function(e,t,n){return parseFloat(t,10)>parseFloat(n.max,10)&&!n.negative||parseFloat(t,10)<=parseFloat(n.max,10)&&n.negative}},min:{name:"min",init:function(e,t){var n={};return n.min=e.data("validation"+t+"Min"),n.message="Too low: Minimum of '"+n.min+"'",e.data("validation"+t+"Message")&&(n.message=e.data("validation"+t+"Message")),n},validate:function(e,t,n){return parseFloat(t)=parseFloat(n.min)&&n.negative}},maxlength:{name:"maxlength",init:function(e,t){var n={};return n.maxlength=e.data("validation"+t+"Maxlength"),n.message="Too long: Maximum of '"+n.maxlength+"' characters",e.data("validation"+t+"Message")&&(n.message=e.data("validation"+t+"Message")),n},validate:function(e,t,n){return t.length>n.maxlength&&!n.negative||t.length<=n.maxlength&&n.negative}},minlength:{name:"minlength",init:function(e,t){var n={};return n.minlength=e.data("validation"+t+"Minlength"),n.message="Too short: Minimum of '"+n.minlength+"' characters",e.data("validation"+t+"Message")&&(n.message=e.data("validation"+t+"Message")),n},validate:function(e,t,n){return t.length=n.minlength&&n.negative}},maxchecked:{name:"maxchecked",init:function(e,t){var n={},r=e.parents("form").first().find('[name="'+e.attr("name")+'"]');r.bind("change.validation click.validation",function(){e.trigger("revalidate.validation",{includeEmpty:!0})}),n.elements=r,n.maxchecked=e.data("validation"+t+"Maxchecked");var i="Too many: Max '"+n.maxchecked+"' checked";return e.data("validation"+t+"Message")&&(i=e.data("validation"+t+"Message")),n.message=i,n},validate:function(e,t,n){return n.elements.filter(":checked").length>n.maxchecked&&!n.negative||n.elements.filter(":checked").length<=n.maxchecked&&n.negative},blockSubmit:!0},minchecked:{name:"minchecked",init:function(e,t){var n={},r=e.parents("form").first().find('[name="'+e.attr("name")+'"]');r.bind("change.validation click.validation",function(){e.trigger("revalidate.validation",{includeEmpty:!0})}),n.elements=r,n.minchecked=e.data("validation"+t+"Minchecked");var i="Too few: Min '"+n.minchecked+"' checked";return e.data("validation"+t+"Message")&&(i=e.data("validation"+t+"Message")),n.message=i,n},validate:function(e,t,n){return n.elements.filter(":checked").length=n.minchecked&&n.negative},blockSubmit:!0,includeEmpty:!0},number:{name:"number",init:function(e,t){var n={};n.step=1,e.attr("step")&&(n.step=e.attr("step")),e.data("validation"+t+"Step")&&(n.step=e.data("validation"+t+"Step")),n.decimal=".",e.data("validation"+t+"Decimal")&&(n.decimal=e.data("validation"+t+"Decimal")),n.thousands="",e.data("validation"+t+"Thousands")&&(n.thousands=e.data("validation"+t+"Thousands")),n.regex=s("([+-]?\\d+(\\"+n.decimal+"\\d+)?)?"),n.message="Must be a number";var r=e.data("validation"+t+"Message");return r&&(n.message=r),n},validate:function(e,t,n){var r=t.replace(n.decimal,".").replace(n.thousands,""),i=parseFloat(r),s=parseFloat(n.step);while(s%1!==0)s=parseFloat(s.toPrecision(12))*10,i=parseFloat(i.toPrecision(12))*10;var o=n.regex.test(t),u=parseFloat(i)%parseFloat(s)===0,a=!isNaN(parseFloat(r))&&isFinite(r),f=!(o&&u&&a);return f},message:"Must be a number"}},builtInValidators:{email:{name:"Email",type:"email"},passwordagain:{name:"Passwordagain",type:"match",match:"password",message:"Does not match the given password"},positive:{name:"Positive",type:"shortcut",shortcut:"number,positivenumber"},negative:{name:"Negative",type:"shortcut",shortcut:"number,negativenumber"},integer:{name:"Integer",type:"regex",regex:"[+-]?\\d+",message:"No decimal places allowed"},positivenumber:{name:"Positivenumber",type:"min",min:0,message:"Must be a positive number"},negativenumber:{name:"Negativenumber",type:"max",max:0,message:"Must be a negative number"},required:{name:"Required",type:"required",message:"This is required"},checkone:{name:"Checkone",type:"minchecked",minchecked:1,message:"Check at least one option"},number:{name:"Number",type:"number",decimal:".",step:"1"},pattern:{name:"Pattern",type:"regex",message:"Not in expected format"}}},r=function(e){return e.toLowerCase().replace(/(^|\s)([a-z])/g,function(e,t,n){return t+n.toUpperCase()})},i=function(t){var n=null,r=t.attr("type");if(r==="checkbox"){n=t.is(":checked")?n:"";var i=t.parents("form").first()||t.parents(".control-group").first();i&&(n=i.find("input[name='"+t.attr("name")+"']:checked").map(function(t,n){return e(n).val()}).toArray().join(","))}else if(r==="radio"){n=e('input[name="'+t.attr("name")+'"]:checked').length>0?t.val():"";var s=t.parents("form").first()||t.parents(".control-group").first();s&&(n=s.find("input[name='"+t.attr("name")+"']:checked").map(function(t,n){return e(n).val()}).toArray().join(","))}else n=t.val();return n};e.fn.jqBootstrapValidation=function(t){return n.methods[t]?n.methods[t].apply(this,Array.prototype.slice.call(arguments,1)):typeof t=="object"||!t?n.methods.init.apply(this,arguments):(e.error("Method "+t+" does not exist on jQuery.jqBootstrapValidation"),null)},e.jqBootstrapValidation=function(t){e(":input").not("[type=image],[type=submit]").jqBootstrapValidation.apply(this,arguments)}})(jQuery);
\ No newline at end of file
diff --git a/atmo/static/lib/marked.min.js b/atmo/static/lib/marked.min.js
new file mode 100644
index 00000000..4d2eb081
--- /dev/null
+++ b/atmo/static/lib/marked.min.js
@@ -0,0 +1,6 @@
+/**
+ * marked - a markdown parser
+ * Copyright (c) 2011-2014, Christopher Jeffrey. (MIT Licensed)
+ * https://github.com/chjj/marked
+ */
+(function(){var block={newline:/^\n+/,code:/^( {4}[^\n]+\n*)+/,fences:noop,hr:/^( *[-*_]){3,} *(?:\n+|$)/,heading:/^ *(#{1,6}) *([^\n]+?) *#* *(?:\n+|$)/,nptable:noop,lheading:/^([^\n]+)\n *(=|-){2,} *(?:\n+|$)/,blockquote:/^( *>[^\n]+(\n(?!def)[^\n]+)*\n*)+/,list:/^( *)(bull) [\s\S]+?(?:hr|def|\n{2,}(?! )(?!\1bull )\n*|\s*$)/,html:/^ *(?:comment *(?:\n|\s*$)|closed *(?:\n{2,}|\s*$)|closing *(?:\n{2,}|\s*$))/,def:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +["(]([^\n]+)[")])? *(?:\n+|$)/,table:noop,paragraph:/^((?:[^\n]+\n?(?!hr|heading|lheading|blockquote|tag|def))+)\n*/,text:/^[^\n]+/};block.bullet=/(?:[*+-]|\d+\.)/;block.item=/^( *)(bull) [^\n]*(?:\n(?!\1bull )[^\n]*)*/;block.item=replace(block.item,"gm")(/bull/g,block.bullet)();block.list=replace(block.list)(/bull/g,block.bullet)("hr","\\n+(?=\\1?(?:[-*_] *){3,}(?:\\n+|$))")("def","\\n+(?="+block.def.source+")")();block.blockquote=replace(block.blockquote)("def",block.def)();block._tag="(?!(?:"+"a|em|strong|small|s|cite|q|dfn|abbr|data|time|code"+"|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo"+"|span|br|wbr|ins|del|img)\\b)\\w+(?!:/|[^\\w\\s@]*@)\\b";block.html=replace(block.html)("comment",//)("closed",/<(tag)[\s\S]+?<\/\1>/)("closing",/])*?>/)(/tag/g,block._tag)();block.paragraph=replace(block.paragraph)("hr",block.hr)("heading",block.heading)("lheading",block.lheading)("blockquote",block.blockquote)("tag","<"+block._tag)("def",block.def)();block.normal=merge({},block);block.gfm=merge({},block.normal,{fences:/^ *(`{3,}|~{3,})[ \.]*(\S+)? *\n([\s\S]*?)\s*\1 *(?:\n+|$)/,paragraph:/^/,heading:/^ *(#{1,6}) +([^\n]+?) *#* *(?:\n+|$)/});block.gfm.paragraph=replace(block.paragraph)("(?!","(?!"+block.gfm.fences.source.replace("\\1","\\2")+"|"+block.list.source.replace("\\1","\\3")+"|")();block.tables=merge({},block.gfm,{nptable:/^ *(\S.*\|.*)\n *([-:]+ *\|[-| :]*)\n((?:.*\|.*(?:\n|$))*)\n*/,table:/^ *\|(.+)\n *\|( *[-:]+[-| :]*)\n((?: *\|.*(?:\n|$))*)\n*/});function Lexer(options){this.tokens=[];this.tokens.links={};this.options=options||marked.defaults;this.rules=block.normal;if(this.options.gfm){if(this.options.tables){this.rules=block.tables}else{this.rules=block.gfm}}}Lexer.rules=block;Lexer.lex=function(src,options){var lexer=new Lexer(options);return lexer.lex(src)};Lexer.prototype.lex=function(src){src=src.replace(/\r\n|\r/g,"\n").replace(/\t/g," ").replace(/\u00a0/g," ").replace(/\u2424/g,"\n");return this.token(src,true)};Lexer.prototype.token=function(src,top,bq){var src=src.replace(/^ +$/gm,""),next,loose,cap,bull,b,item,space,i,l;while(src){if(cap=this.rules.newline.exec(src)){src=src.substring(cap[0].length);if(cap[0].length>1){this.tokens.push({type:"space"})}}if(cap=this.rules.code.exec(src)){src=src.substring(cap[0].length);cap=cap[0].replace(/^ {4}/gm,"");this.tokens.push({type:"code",text:!this.options.pedantic?cap.replace(/\n+$/,""):cap});continue}if(cap=this.rules.fences.exec(src)){src=src.substring(cap[0].length);this.tokens.push({type:"code",lang:cap[2],text:cap[3]||""});continue}if(cap=this.rules.heading.exec(src)){src=src.substring(cap[0].length);this.tokens.push({type:"heading",depth:cap[1].length,text:cap[2]});continue}if(top&&(cap=this.rules.nptable.exec(src))){src=src.substring(cap[0].length);item={type:"table",header:cap[1].replace(/^ *| *\| *$/g,"").split(/ *\| */),align:cap[2].replace(/^ *|\| *$/g,"").split(/ *\| */),cells:cap[3].replace(/\n$/,"").split("\n")};for(i=0;i ?/gm,"");this.token(cap,top,true);this.tokens.push({type:"blockquote_end"});continue}if(cap=this.rules.list.exec(src)){src=src.substring(cap[0].length);bull=cap[2];this.tokens.push({type:"list_start",ordered:bull.length>1});cap=cap[0].match(this.rules.item);next=false;l=cap.length;i=0;for(;i1&&b.length>1)){src=cap.slice(i+1).join("\n")+src;i=l-1}}loose=next||/\n\n(?!\s*$)/.test(item);if(i!==l-1){next=item.charAt(item.length-1)==="\n";if(!loose)loose=next}this.tokens.push({type:loose?"loose_item_start":"list_item_start"});this.token(item,false,bq);this.tokens.push({type:"list_item_end"})}this.tokens.push({type:"list_end"});continue}if(cap=this.rules.html.exec(src)){src=src.substring(cap[0].length);this.tokens.push({type:this.options.sanitize?"paragraph":"html",pre:!this.options.sanitizer&&(cap[1]==="pre"||cap[1]==="script"||cap[1]==="style"),text:cap[0]});continue}if(!bq&&top&&(cap=this.rules.def.exec(src))){src=src.substring(cap[0].length);this.tokens.links[cap[1].toLowerCase()]={href:cap[2],title:cap[3]};continue}if(top&&(cap=this.rules.table.exec(src))){src=src.substring(cap[0].length);item={type:"table",header:cap[1].replace(/^ *| *\| *$/g,"").split(/ *\| */),align:cap[2].replace(/^ *|\| *$/g,"").split(/ *\| */),cells:cap[3].replace(/(?: *\| *)?\n$/,"").split("\n")};for(i=0;i])/,autolink:/^<([^ >]+(@|:\/)[^ >]+)>/,url:noop,tag:/^|^<\/?\w+(?:"[^"]*"|'[^']*'|[^'">])*?>/,link:/^!?\[(inside)\]\(href\)/,reflink:/^!?\[(inside)\]\s*\[([^\]]*)\]/,nolink:/^!?\[((?:\[[^\]]*\]|[^\[\]])*)\]/,strong:/^__([\s\S]+?)__(?!_)|^\*\*([\s\S]+?)\*\*(?!\*)/,em:/^\b_((?:[^_]|__)+?)_\b|^\*((?:\*\*|[\s\S])+?)\*(?!\*)/,code:/^(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/,br:/^ {2,}\n(?!\s*$)/,del:noop,text:/^[\s\S]+?(?=[\\?(?:\s+['"]([\s\S]*?)['"])?\s*/;inline.link=replace(inline.link)("inside",inline._inside)("href",inline._href)();inline.reflink=replace(inline.reflink)("inside",inline._inside)();inline.normal=merge({},inline);inline.pedantic=merge({},inline.normal,{strong:/^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,em:/^_(?=\S)([\s\S]*?\S)_(?!_)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/});inline.gfm=merge({},inline.normal,{escape:replace(inline.escape)("])","~|])")(),url:/^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/,del:/^~~(?=\S)([\s\S]*?\S)~~/,text:replace(inline.text)("]|","~]|")("|","|https?://|")()});inline.breaks=merge({},inline.gfm,{br:replace(inline.br)("{2,}","*")(),text:replace(inline.gfm.text)("{2,}","*")()});function InlineLexer(links,options){this.options=options||marked.defaults;this.links=links;this.rules=inline.normal;this.renderer=this.options.renderer||new Renderer;this.renderer.options=this.options;if(!this.links){throw new Error("Tokens array requires a `links` property.")}if(this.options.gfm){if(this.options.breaks){this.rules=inline.breaks}else{this.rules=inline.gfm}}else if(this.options.pedantic){this.rules=inline.pedantic}}InlineLexer.rules=inline;InlineLexer.output=function(src,links,options){var inline=new InlineLexer(links,options);return inline.output(src)};InlineLexer.prototype.output=function(src){var out="",link,text,href,cap;while(src){if(cap=this.rules.escape.exec(src)){src=src.substring(cap[0].length);out+=cap[1];continue}if(cap=this.rules.autolink.exec(src)){src=src.substring(cap[0].length);if(cap[2]==="@"){text=cap[1].charAt(6)===":"?this.mangle(cap[1].substring(7)):this.mangle(cap[1]);href=this.mangle("mailto:")+text}else{text=escape(cap[1]);href=text}out+=this.renderer.link(href,null,text);continue}if(!this.inLink&&(cap=this.rules.url.exec(src))){src=src.substring(cap[0].length);text=escape(cap[1]);href=text;out+=this.renderer.link(href,null,text);continue}if(cap=this.rules.tag.exec(src)){if(!this.inLink&&/^/i.test(cap[0])){this.inLink=false}src=src.substring(cap[0].length);out+=this.options.sanitize?this.options.sanitizer?this.options.sanitizer(cap[0]):escape(cap[0]):cap[0];continue}if(cap=this.rules.link.exec(src)){src=src.substring(cap[0].length);this.inLink=true;out+=this.outputLink(cap,{href:cap[2],title:cap[3]});this.inLink=false;continue}if((cap=this.rules.reflink.exec(src))||(cap=this.rules.nolink.exec(src))){src=src.substring(cap[0].length);link=(cap[2]||cap[1]).replace(/\s+/g," ");link=this.links[link.toLowerCase()];if(!link||!link.href){out+=cap[0].charAt(0);src=cap[0].substring(1)+src;continue}this.inLink=true;out+=this.outputLink(cap,link);this.inLink=false;continue}if(cap=this.rules.strong.exec(src)){src=src.substring(cap[0].length);out+=this.renderer.strong(this.output(cap[2]||cap[1]));continue}if(cap=this.rules.em.exec(src)){src=src.substring(cap[0].length);out+=this.renderer.em(this.output(cap[2]||cap[1]));continue}if(cap=this.rules.code.exec(src)){src=src.substring(cap[0].length);out+=this.renderer.codespan(escape(cap[2],true));continue}if(cap=this.rules.br.exec(src)){src=src.substring(cap[0].length);out+=this.renderer.br();continue}if(cap=this.rules.del.exec(src)){src=src.substring(cap[0].length);out+=this.renderer.del(this.output(cap[1]));continue}if(cap=this.rules.text.exec(src)){src=src.substring(cap[0].length);out+=this.renderer.text(escape(this.smartypants(cap[0])));continue}if(src){throw new Error("Infinite loop on byte: "+src.charCodeAt(0))}}return out};InlineLexer.prototype.outputLink=function(cap,link){var href=escape(link.href),title=link.title?escape(link.title):null;return cap[0].charAt(0)!=="!"?this.renderer.link(href,title,this.output(cap[1])):this.renderer.image(href,title,escape(cap[1]))};InlineLexer.prototype.smartypants=function(text){if(!this.options.smartypants)return text;return text.replace(/---/g,"—").replace(/--/g,"–").replace(/(^|[-\u2014/(\[{"\s])'/g,"$1‘").replace(/'/g,"’").replace(/(^|[-\u2014/(\[{\u2018\s])"/g,"$1“").replace(/"/g,"”").replace(/\.{3}/g,"…")};InlineLexer.prototype.mangle=function(text){if(!this.options.mangle)return text;var out="",l=text.length,i=0,ch;for(;i.5){ch="x"+ch.toString(16)}out+=""+ch+";"}return out};function Renderer(options){this.options=options||{}}Renderer.prototype.code=function(code,lang,escaped){if(this.options.highlight){var out=this.options.highlight(code,lang);if(out!=null&&out!==code){escaped=true;code=out}}if(!lang){return""+(escaped?code:escape(code,true))+"\n
"}return''+(escaped?code:escape(code,true))+"\n
\n"};Renderer.prototype.blockquote=function(quote){return"\n"+quote+"
\n"};Renderer.prototype.html=function(html){return html};Renderer.prototype.heading=function(text,level,raw){return"\n"};Renderer.prototype.hr=function(){return this.options.xhtml?"
\n":"
\n"};Renderer.prototype.list=function(body,ordered){var type=ordered?"ol":"ul";return"<"+type+">\n"+body+""+type+">\n"};Renderer.prototype.listitem=function(text){return""+text+"\n"};Renderer.prototype.paragraph=function(text){return""+text+"
\n"};Renderer.prototype.table=function(header,body){return"\n"+"\n"+header+"\n"+"\n"+body+"\n"+"
\n"};Renderer.prototype.tablerow=function(content){return"\n"+content+"
\n"};Renderer.prototype.tablecell=function(content,flags){var type=flags.header?"th":"td";var tag=flags.align?"<"+type+' style="text-align:'+flags.align+'">':"<"+type+">";return tag+content+""+type+">\n"};Renderer.prototype.strong=function(text){return""+text+""};Renderer.prototype.em=function(text){return""+text+""};Renderer.prototype.codespan=function(text){return""+text+"
"};Renderer.prototype.br=function(){return this.options.xhtml?"
":"
"};Renderer.prototype.del=function(text){return""+text+""};Renderer.prototype.link=function(href,title,text){if(this.options.sanitize){try{var prot=decodeURIComponent(unescape(href)).replace(/[^\w:]/g,"").toLowerCase()}catch(e){return""}if(prot.indexOf("javascript:")===0||prot.indexOf("vbscript:")===0){return""}}var out='"+text+"";return out};Renderer.prototype.image=function(href,title,text){var out='":">";return out};Renderer.prototype.text=function(text){return text};function Parser(options){this.tokens=[];this.token=null;this.options=options||marked.defaults;this.options.renderer=this.options.renderer||new Renderer;this.renderer=this.options.renderer;this.renderer.options=this.options}Parser.parse=function(src,options,renderer){var parser=new Parser(options,renderer);return parser.parse(src)};Parser.prototype.parse=function(src){this.inline=new InlineLexer(src.links,this.options,this.renderer);this.tokens=src.reverse();var out="";while(this.next()){out+=this.tok()}return out};Parser.prototype.next=function(){return this.token=this.tokens.pop()};Parser.prototype.peek=function(){return this.tokens[this.tokens.length-1]||0};Parser.prototype.parseText=function(){var body=this.token.text;while(this.peek().type==="text"){body+="\n"+this.next().text}return this.inline.output(body)};Parser.prototype.tok=function(){switch(this.token.type){case"space":{return""}case"hr":{return this.renderer.hr()}case"heading":{return this.renderer.heading(this.inline.output(this.token.text),this.token.depth,this.token.text)}case"code":{return this.renderer.code(this.token.text,this.token.lang,this.token.escaped)}case"table":{var header="",body="",i,row,cell,flags,j;cell="";for(i=0;i/g,">").replace(/"/g,""").replace(/'/g,"'")}function unescape(html){return html.replace(/&([#\w]+);/g,function(_,n){n=n.toLowerCase();if(n==="colon")return":";if(n.charAt(0)==="#"){return n.charAt(1)==="x"?String.fromCharCode(parseInt(n.substring(2),16)):String.fromCharCode(+n.substring(1))}return""})}function replace(regex,opt){regex=regex.source;opt=opt||"";return function self(name,val){if(!name)return new RegExp(regex,opt);val=val.source||val;val=val.replace(/(^|[^\[])\^/g,"$1");regex=regex.replace(name,val);return self}}function noop(){}noop.exec=noop;function merge(obj){var i=1,target,key;for(;iAn error occured:
"+escape(e.message+"",true)+"
"}throw e}}marked.options=marked.setOptions=function(opt){merge(marked.defaults,opt);return marked};marked.defaults={gfm:true,tables:true,breaks:false,pedantic:false,sanitize:false,sanitizer:null,mangle:true,smartLists:false,silent:false,highlight:null,langPrefix:"lang-",smartypants:false,headerPrefix:"",renderer:new Renderer,xhtml:false};marked.Parser=Parser;marked.parser=Parser.parse;marked.Renderer=Renderer;marked.Lexer=Lexer;marked.lexer=Lexer.lex;marked.InlineLexer=InlineLexer;marked.inlineLexer=InlineLexer.output;marked.parse=marked;if(typeof module!=="undefined"&&typeof exports==="object"){module.exports=marked}else if(typeof define==="function"&&define.amd){define(function(){return marked})}else{this.marked=marked}}).call(function(){return this||(typeof window!=="undefined"?window:global)}());
diff --git a/atmo/static/lib/notebook.css b/atmo/static/lib/notebook.css
new file mode 100644
index 00000000..02f6b460
--- /dev/null
+++ b/atmo/static/lib/notebook.css
@@ -0,0 +1,80 @@
+/*Original from https://github.com/jsvine/nbpreview/blob/9da3f2dad5fa3cc1d38702fdc62df80c75baf664/css/vendor/notebook.css
+Copyright (c) 2015, Jeremy Singer-Vine*/
+.nb-notebook {
+ line-height: 1.5;
+}
+
+.nb-stdout, .nb-stderr {
+ white-space: pre-wrap;
+ margin: 1em 0;
+ padding: 0.1em 0.5em;
+}
+
+.nb-stderr {
+ background-color: #FAA;
+}
+
+.nb-cell + .nb-cell {
+ margin-top: 0.5em;
+}
+
+.nb-output table {
+ border: 1px solid #000;
+ border-collapse: collapse;
+}
+
+.nb-output th {
+ font-weight: bold;
+}
+
+.nb-output th, .nb-output td {
+ border: 1px solid #000;
+ padding: 0.25em;
+ text-align: left;
+ vertical-align: middle;
+ border-collapse: collapse;
+}
+
+.nb-cell {
+ position: relative;
+}
+
+.nb-raw-cell {
+ white-space: pre-wrap;
+ background-color: #f5f2f0;
+ font-family: Consolas, Monaco, 'Andale Mono', monospace;
+ padding: 1em;
+ margin: .5em 0;
+}
+
+.nb-output {
+ min-height: 1em;
+ width: 100%;
+ overflow-x: scroll;
+ border-right: 1px dotted #CCC;
+}
+
+.nb-output img {
+ max-width: 100%;
+}
+
+.nb-output:before, .nb-input:before {
+ position: absolute;
+ font-family: monospace;
+ color: #999;
+ left: -7.5em;
+ width: 7em;
+ text-align: right;
+}
+
+.nb-input:before {
+ content: "In [" attr(data-prompt-number) "]:";
+}
+.nb-output:before {
+ content: "Out [" attr(data-prompt-number) "]:";
+}
+
+// Fix pandas dataframe formatting
+div[style="max-height:1000px;max-width:1500px;overflow:auto;"] {
+ max-height: none !important;
+}
diff --git a/atmo/static/lib/notebook.min.js b/atmo/static/lib/notebook.min.js
new file mode 100644
index 00000000..0f38f2f7
--- /dev/null
+++ b/atmo/static/lib/notebook.min.js
@@ -0,0 +1 @@
+(function(){var root=this;var VERSION="0.2.5";var doc=root.document||require("jsdom").jsdom();var ident=function(x){return x};var makeElement=function(tag,classNames){var el=doc.createElement(tag);el.className=(classNames||[]).map(function(cn){return nb.prefix+cn}).join(" ");return el};var escapeHTML=function(raw){var replaced=raw.replace(//g,">");return replaced};var joinText=function(text){if(text.join){return text.map(joinText).join("")}else{return text}};var condRequire=function(module_name){return typeof require==="function"&&require(module_name)};var getMarkdown=function(){return root.marked||condRequire("marked")};var getAnsi=function(){var req=condRequire("ansi_up");var lib=root.ansi_up||req;return lib&&lib.ansi_to_html};var nb={prefix:"nb-",markdown:getMarkdown()||ident,ansi:getAnsi()||ident,VERSION:VERSION};nb.Input=function(raw,cell){this.raw=raw;this.cell=cell};nb.Input.prototype.render=function(){if(!this.raw.length){return makeElement("div")}var holder=makeElement("div",["input"]);var cell=this.cell;if(typeof cell.number==="number"){holder.setAttribute("data-prompt-number",this.cell.number)}var pre_el=makeElement("pre");var code_el=makeElement("code");var notebook=cell.worksheet.notebook;var m=notebook.metadata;var lang=this.cell.raw.language||m.language||m.language_info.name;code_el.setAttribute("data-language",lang);code_el.className="lang-"+lang;code_el.innerHTML=escapeHTML(joinText(this.raw));pre_el.appendChild(code_el);holder.appendChild(pre_el);this.el=holder;return holder};var imageCreator=function(format){return function(data){var el=makeElement("img",["image-output"]);el.src="data:image/"+format+";base64,"+joinText(data).replace(/\n/g,"");return el}};nb.display={};nb.display.text=function(text){var el=makeElement("pre",["text-output"]);el.innerHTML=escapeHTML(joinText(text));return el};nb.display["text/plain"]=nb.display.text;nb.display.html=function(html){var el=makeElement("div",["html-output"]);el.innerHTML=joinText(html);return el};nb.display["text/html"]=nb.display.html;nb.display.marked=function(md){return nb.display.html(nb.markdown(joinText(md)))};nb.display["text/markdown"]=nb.display.marked;nb.display.svg=function(svg){var el=makeElement("div",["svg-output"]);el.innerHTML=joinText(svg);return el};nb.display["text/svg+xml"]=nb.display.svg;nb.display.latex=function(latex){var el=makeElement("div",["latex-output"]);el.innerHTML=join(latex);return el};nb.display["text/latex"]=nb.display.latex;nb.display.javascript=function(js){var el=makeElement("script");script.innerHTML=js;return el};nb.display["application/javascript"]=nb.display.javascript;nb.display.png=imageCreator("png");nb.display["image/png"]=nb.display.png;nb.display.jpeg=imageCreator("jpeg");nb.display["image/jpeg"]=nb.display.jpeg;nb.display_priority=["png","image/png","jpeg","image/jpeg","svg","text/svg+xml","html","text/html","text/markdown","latex","text/latex","javascript","application/javascript","text","text/plain"];var render_display_data=function(){var o=this;var formats=nb.display_priority.filter(function(d){return o.raw.data?o.raw.data[d]:o.raw[d]});var format=formats[0];if(format){if(nb.display[format]){return nb.display[format](o.raw[format]||o.raw.data[format])}}return makeElement("div",["empty-output"])};var render_error=function(){var el=makeElement("pre",["pyerr"]);var raw=this.raw.traceback.join("\n");el.innerHTML=nb.ansi(escapeHTML(raw));return el};nb.Output=function(raw,cell){this.raw=raw;this.cell=cell;this.type=raw.output_type};nb.Output.prototype.renderers={display_data:render_display_data,execute_result:render_display_data,pyout:render_display_data,pyerr:render_error,error:render_error,stream:function(){var el=makeElement("pre",[this.raw.stream||this.raw.name]);var raw=joinText(this.raw.text);el.innerHTML=nb.ansi(escapeHTML(raw));return el}};nb.Output.prototype.render=function(){var outer=makeElement("div",["output"]);if(typeof this.cell.number==="number"){outer.setAttribute("data-prompt-number",this.cell.number)}var inner=this.renderers[this.type].call(this);outer.appendChild(inner);this.el=outer;return outer};nb.coalesceStreams=function(outputs){if(!outputs.length){return outputs}var last=outputs[0];var new_outputs=[last];outputs.slice(1).forEach(function(o){if(o.raw.output_type==="stream"&&last.raw.output_type==="stream"&&o.raw.stream===last.raw.stream){last.raw.text=last.raw.text.concat(o.raw.text)}else{new_outputs.push(o);last=o}});return new_outputs};nb.Cell=function(raw,worksheet){var cell=this;cell.raw=raw;cell.worksheet=worksheet;cell.type=raw.cell_type;if(cell.type==="code"){cell.number=raw.prompt_number>-1?raw.prompt_number:raw.execution_count;var source=raw.input||[raw.source];cell.input=new nb.Input(source,cell);var raw_outputs=(cell.raw.outputs||[]).map(function(o){return new nb.Output(o,cell)});cell.outputs=nb.coalesceStreams(raw_outputs)}};nb.Cell.prototype.renderers={markdown:function(){var el=makeElement("div",["cell","markdown-cell"]);el.innerHTML=nb.markdown(joinText(this.raw.source));return el},heading:function(){var el=makeElement("h"+this.raw.level,["cell","heading-cell"]);el.innerHTML=joinText(this.raw.source);return el},raw:function(){var el=makeElement("div",["cell","raw-cell"]);el.innerHTML=joinText(this.raw.source);return el},code:function(){var cell_el=makeElement("div",["cell","code-cell"]);cell_el.appendChild(this.input.render());var output_els=this.outputs.forEach(function(o){cell_el.appendChild(o.render())});return cell_el}};nb.Cell.prototype.render=function(){var el=this.renderers[this.type].call(this);this.el=el;return el};nb.Worksheet=function(raw,notebook){var worksheet=this;this.raw=raw;this.notebook=notebook;this.cells=raw.cells.map(function(c){return new nb.Cell(c,worksheet)});this.render=function(){var worksheet_el=makeElement("div",["worksheet"]);worksheet.cells.forEach(function(c){worksheet_el.appendChild(c.render())});this.el=worksheet_el;return worksheet_el}};nb.Notebook=function(raw,config){var notebook=this;this.raw=raw;this.config=config;var meta=this.metadata=raw.metadata;this.title=meta.title||meta.name;var _worksheets=raw.worksheets||[{cells:raw.cells}];this.worksheets=_worksheets.map(function(ws){return new nb.Worksheet(ws,notebook)});this.sheet=this.worksheets[0]};nb.Notebook.prototype.render=function(){var notebook_el=makeElement("div",["notebook"]);this.worksheets.forEach(function(w){notebook_el.appendChild(w.render())});this.el=notebook_el;return notebook_el};nb.parse=function(nbjson,config){return new nb.Notebook(nbjson,config)};if(typeof define==="function"&&define.amd){define(function(){return nb})}if(typeof exports!=="undefined"){if(typeof module!=="undefined"&&module.exports){exports=module.exports=nb}exports.nb=nb}else{root.nb=nb}}).call(this);
diff --git a/atmo/static/lib/prism.css b/atmo/static/lib/prism.css
new file mode 100644
index 00000000..fa49e6df
--- /dev/null
+++ b/atmo/static/lib/prism.css
@@ -0,0 +1,138 @@
+/* http://prismjs.com/download.html?themes=prism&languages=clike+javascript+ruby+go+java+python+scala */
+/**
+ * prism.js default theme for JavaScript, CSS and HTML
+ * Based on dabblet (http://dabblet.com)
+ * @author Lea Verou
+ */
+
+code[class*="language-"],
+pre[class*="language-"] {
+ color: black;
+ background: none;
+ text-shadow: 0 1px white;
+ font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
+ text-align: left;
+ white-space: pre;
+ word-spacing: normal;
+ word-break: normal;
+ word-wrap: normal;
+ line-height: 1.5;
+
+ -moz-tab-size: 4;
+ -o-tab-size: 4;
+ tab-size: 4;
+
+ -webkit-hyphens: none;
+ -moz-hyphens: none;
+ -ms-hyphens: none;
+ hyphens: none;
+}
+
+pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
+code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
+ text-shadow: none;
+ background: #b3d4fc;
+}
+
+pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
+code[class*="language-"]::selection, code[class*="language-"] ::selection {
+ text-shadow: none;
+ background: #b3d4fc;
+}
+
+@media print {
+ code[class*="language-"],
+ pre[class*="language-"] {
+ text-shadow: none;
+ }
+}
+
+/* Code blocks */
+pre[class*="language-"] {
+ padding: 1em;
+ margin: .5em 0;
+ overflow: auto;
+}
+
+:not(pre) > code[class*="language-"],
+pre[class*="language-"] {
+ background: #f5f2f0;
+}
+
+/* Inline code */
+:not(pre) > code[class*="language-"] {
+ padding: .1em;
+ border-radius: .3em;
+ white-space: normal;
+}
+
+.token.comment,
+.token.prolog,
+.token.doctype,
+.token.cdata {
+ color: slategray;
+}
+
+.token.punctuation {
+ color: #999;
+}
+
+.namespace {
+ opacity: .7;
+}
+
+.token.property,
+.token.tag,
+.token.boolean,
+.token.number,
+.token.constant,
+.token.symbol,
+.token.deleted {
+ color: #905;
+}
+
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted {
+ color: #690;
+}
+
+.token.operator,
+.token.entity,
+.token.url,
+.language-css .token.string,
+.style .token.string {
+ color: #a67f59;
+ background: hsla(0, 0%, 100%, .5);
+}
+
+.token.atrule,
+.token.attr-value,
+.token.keyword {
+ color: #07a;
+}
+
+.token.function {
+ color: #DD4A68;
+}
+
+.token.regex,
+.token.important,
+.token.variable {
+ color: #e90;
+}
+
+.token.important,
+.token.bold {
+ font-weight: bold;
+}
+.token.italic {
+ font-style: italic;
+}
+
+.token.entity {
+ cursor: help;
+}
diff --git a/atmo/static/lib/prism.min.js b/atmo/static/lib/prism.min.js
new file mode 100644
index 00000000..f696b0a6
--- /dev/null
+++ b/atmo/static/lib/prism.min.js
@@ -0,0 +1,9 @@
+/* http://prismjs.com/download.html?themes=prism-okaidia&languages=clike+javascript+ruby+go+java+python+scala */
+var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(){var e=/\blang(?:uage)?-(\w+)\b/i,t=0,n=_self.Prism={util:{encode:function(e){return e instanceof a?new a(e.type,n.util.encode(e.content),e.alias):"Array"===n.util.type(e)?e.map(n.util.encode):e.replace(/&/g,"&").replace(/e.length)break e;if(!(v instanceof a)){u.lastIndex=0;var b=u.exec(v),k=1;if(!b&&h&&m!=r.length-1){if(u.lastIndex=y,b=u.exec(e),!b)break;for(var w=b.index+(g?b[1].length:0),_=b.index+b[0].length,A=m,S=y,P=r.length;P>A&&_>S;++A)S+=(r[A].matchedStr||r[A]).length,w>=S&&(++m,y=S);if(r[m]instanceof a||r[A-1].greedy)continue;k=A-m,v=e.slice(y,S),b.index-=y}if(b){g&&(f=b[1].length);var w=b.index+f,b=b[0].slice(f),_=w+b.length,x=v.slice(0,w),O=v.slice(_),j=[m,k];x&&j.push(x);var N=new a(l,c?n.tokenize(b,c):b,d,b,h);j.push(N),O&&j.push(O),Array.prototype.splice.apply(r,j)}}}}}return r},hooks:{all:{},add:function(e,t){var a=n.hooks.all;a[e]=a[e]||[],a[e].push(t)},run:function(e,t){var a=n.hooks.all[e];if(a&&a.length)for(var r,i=0;r=a[i++];)r(t)}}},a=n.Token=function(e,t,n,a,r){this.type=e,this.content=t,this.alias=n,this.matchedStr=a||null,this.greedy=!!r};if(a.stringify=function(e,t,r){if("string"==typeof e)return e;if("Array"===n.util.type(e))return e.map(function(n){return a.stringify(n,t,e)}).join("");var i={type:e.type,content:a.stringify(e.content,t,r),tag:"span",classes:["token",e.type],attributes:{},language:t,parent:r};if("comment"==i.type&&(i.attributes.spellcheck="true"),e.alias){var l="Array"===n.util.type(e.alias)?e.alias:[e.alias];Array.prototype.push.apply(i.classes,l)}n.hooks.run("wrap",i);var o="";for(var s in i.attributes)o+=(o?" ":"")+s+'="'+(i.attributes[s]||"")+'"';return"<"+i.tag+' class="'+i.classes.join(" ")+'"'+(o?" "+o:"")+">"+i.content+""+i.tag+">"},!_self.document)return _self.addEventListener?(_self.addEventListener("message",function(e){var t=JSON.parse(e.data),a=t.language,r=t.code,i=t.immediateClose;_self.postMessage(n.highlight(r,n.languages[a],a)),i&&_self.close()},!1),_self.Prism):_self.Prism;var r=document.currentScript||[].slice.call(document.getElementsByTagName("script")).pop();return r&&(n.filename=r.src,document.addEventListener&&!r.hasAttribute("data-manual")&&("loading"!==document.readyState?window.requestAnimationFrame?window.requestAnimationFrame(n.highlightAll):window.setTimeout(n.highlightAll,16):document.addEventListener("DOMContentLoaded",n.highlightAll))),_self.Prism}();"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism);
+Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\w\W]*?\*\//,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0}],string:{pattern:/(["'])(\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/((?:\b(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[a-z0-9_\.\\]+/i,lookbehind:!0,inside:{punctuation:/(\.|\\)/}},keyword:/\b(if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,"boolean":/\b(true|false)\b/,"function":/[a-z0-9_]+(?=\()/i,number:/\b-?(?:0x[\da-f]+|\d*\.?\d+(?:e[+-]?\d+)?)\b/i,operator:/--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*|\/|~|\^|%/,punctuation:/[{}[\];(),.:]/};
+Prism.languages.javascript=Prism.languages.extend("clike",{keyword:/\b(as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/,number:/\b-?(0x[\dA-Fa-f]+|0b[01]+|0o[0-7]+|\d*\.?\d+([Ee][+-]?\d+)?|NaN|Infinity)\b/,"function":/[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*(?=\()/i,operator:/--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*\*?|\/|~|\^|%|\.{3}/}),Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:/(^|[^\/])\/(?!\/)(\[.+?]|\\.|[^\/\\\r\n])+\/[gimyu]{0,5}(?=\s*($|[\r\n,.;})]))/,lookbehind:!0,greedy:!0}}),Prism.languages.insertBefore("javascript","string",{"template-string":{pattern:/`(?:\\\\|\\?[^\\])*?`/,greedy:!0,inside:{interpolation:{pattern:/\$\{[^}]+\}/,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}}}),Prism.languages.markup&&Prism.languages.insertBefore("markup","tag",{script:{pattern:/(
-
@@ -14,31 +13,30 @@
-
+
+
- Telemetry Self-Serve Data Analysis - {% block head_title %}Welcome{% endblock %}
+ Telemetry Analysis Service - {% block head_title %}Welcome{% endblock %}
{% endblock %}
{% block head_extra %}
{% endblock %}
-