diff options
Diffstat (limited to 'src')
99 files changed, 4570 insertions, 0 deletions
diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..ce1acf3 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,8 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +##############################################################################
\ No newline at end of file diff --git a/src/account/__init__.py b/src/account/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/account/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/account/admin.py b/src/account/admin.py new file mode 100644 index 0000000..18b2e1a --- /dev/null +++ b/src/account/admin.py @@ -0,0 +1,15 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.contrib import admin + +from account.models import UserProfile + +admin.site.register(UserProfile)
\ No newline at end of file diff --git a/src/account/apps.py b/src/account/apps.py new file mode 100644 index 0000000..9814648 --- /dev/null +++ b/src/account/apps.py @@ -0,0 +1,15 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'account' diff --git a/src/account/forms.py b/src/account/forms.py new file mode 100644 index 0000000..7653e2b --- /dev/null +++ b/src/account/forms.py @@ -0,0 +1,22 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +import django.forms as forms +import pytz as pytz + +from account.models import UserProfile + + +class AccountSettingsForm(forms.ModelForm): + class Meta: + model = UserProfile + fields = ['company', 'ssh_public_key', 'pgp_public_key', 'timezone'] + + timezone = forms.ChoiceField(choices=[(x, x) for x in pytz.common_timezones], initial='UTC') diff --git a/src/account/jira_util.py b/src/account/jira_util.py new file mode 100644 index 0000000..fdb87f7 --- /dev/null +++ b/src/account/jira_util.py @@ -0,0 +1,65 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +import base64 +import os + +import oauth2 as oauth +from django.conf import settings +from jira import JIRA +from tlslite.utils import keyfactory + + +class SignatureMethod_RSA_SHA1(oauth.SignatureMethod): + name = 'RSA-SHA1' + + def signing_base(self, request, consumer, token): + if not hasattr(request, 'normalized_url') or request.normalized_url is None: + raise ValueError("Base URL for request is not set.") + + sig = ( + oauth.escape(request.method), + oauth.escape(request.normalized_url), + oauth.escape(request.get_normalized_parameters()), + ) + + key = '%s&' % oauth.escape(consumer.secret) + if token: + key += oauth.escape(token.secret) + raw = '&'.join(sig) + return key, raw + + def sign(self, request, consumer, token): + """Builds the base signature string.""" + key, raw = self.signing_base(request, consumer, token) + + module_dir = os.path.dirname(__file__) # get current directory + with open(module_dir + '/rsa.pem', 'r') as f: + data = f.read() + privateKeyString = data.strip() + privatekey = keyfactory.parsePrivateKey(privateKeyString) + raw = str.encode(raw) + signature = privatekey.hashAndSign(raw) + return base64.b64encode(signature) + + +def get_jira(user): + module_dir = os.path.dirname(__file__) # get current directory + with open(module_dir + '/rsa.pem', 'r') as f: + key_cert = f.read() + + oauth_dict = { + 'access_token': user.userprofile.oauth_token, + 'access_token_secret': user.userprofile.oauth_secret, + 'consumer_key': settings.OAUTH_CONSUMER_KEY, + 'key_cert': key_cert + } + + return JIRA(server=settings.JIRA_URL, oauth=oauth_dict)
\ No newline at end of file diff --git a/src/account/middleware.py b/src/account/middleware.py new file mode 100644 index 0000000..0f1dbd8 --- /dev/null +++ b/src/account/middleware.py @@ -0,0 +1,32 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.utils import timezone +from django.utils.deprecation import MiddlewareMixin + +from account.models import UserProfile + + +class TimezoneMiddleware(MiddlewareMixin): + """ + Activate the timezone from request.user.userprofile if user is authenticated, + deactivate the timezone otherwise and use default (UTC) + """ + def process_request(self, request): + if request.user.is_authenticated: + try: + tz = request.user.userprofile.timezone + timezone.activate(tz) + except UserProfile.DoesNotExist: + UserProfile.objects.create(user=request.user) + tz = request.user.userprofile.timezone + timezone.activate(tz) + else: + timezone.deactivate() diff --git a/src/account/migrations/0001_initial.py b/src/account/migrations/0001_initial.py new file mode 100644 index 0000000..591f702 --- /dev/null +++ b/src/account/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-11-03 13:33 +from __future__ import unicode_literals + +import account.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timezone', models.CharField(default='UTC', max_length=100)), + ('ssh_public_key', models.FileField(blank=True, null=True, upload_to=account.models.upload_to)), + ('pgp_public_key', models.FileField(blank=True, null=True, upload_to=account.models.upload_to)), + ('company', models.CharField(max_length=200)), + ('oauth_token', models.CharField(max_length=1024)), + ('oauth_secret', models.CharField(max_length=1024)), + ('jira_url', models.CharField(default='', max_length=100)), + ('full_name', models.CharField(default='', max_length=100)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'user_profile', + }, + ), + ] diff --git a/src/account/migrations/__init__.py b/src/account/migrations/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/account/migrations/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/account/models.py b/src/account/models.py new file mode 100644 index 0000000..c2e9902 --- /dev/null +++ b/src/account/models.py @@ -0,0 +1,35 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.contrib.auth.models import User +from django.db import models + + +def upload_to(object, filename): + return object.user.username + '/' + filename + +class UserProfile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + timezone = models.CharField(max_length=100, blank=False, default='UTC') + ssh_public_key = models.FileField(upload_to=upload_to, null=True, blank=True) + pgp_public_key = models.FileField(upload_to=upload_to, null=True, blank=True) + company = models.CharField(max_length=200, blank=False) + + oauth_token = models.CharField(max_length=1024, blank=False) + oauth_secret = models.CharField(max_length=1024, blank=False) + + jira_url = models.CharField(max_length=100, default='') + full_name = models.CharField(max_length=100, default='') + + class Meta: + db_table = 'user_profile' + + def __str__(self): + return self.user.username diff --git a/src/account/rsa.pem b/src/account/rsa.pem new file mode 100644 index 0000000..dbd4eed --- /dev/null +++ b/src/account/rsa.pem @@ -0,0 +1,17 @@ +-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALRiMLAh9iimur8V +A7qVvdqxevEuUkW4K+2KdMXmnQbG9Aa7k7eBjK1S+0LYmVjPKlJGNXHDGuy5Fw/d +7rjVJ0BLB+ubPK8iA/Tw3hLQgXMRRGRXXCn8ikfuQfjUS1uZSatdLB81mydBETlJ +hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtleUzavkbrPjy0T5FMou8H +X9u2AC2ry8vD/l7cqedtwMPp9k7TubgNFo+NGvKsl2ynyprOZR1xjQ7WgrgVB+mm +uScOM/5HVceFuGRDhYTCObE+y1kxRloNYXnx3ei1zbeYLPCHdhxRYW7T0qcynNmw +rn05/KO2RLjgQNalsQJBANeA3Q4Nugqy4QBUCEC09SqylT2K9FrrItqL2QKc9v0Z +zO2uwllCbg0dwpVuYPYXYvikNHHg+aCWF+VXsb9rpPsCQQDWR9TT4ORdzoj+Nccn +qkMsDmzt0EfNaAOwHOmVJ2RVBspPcxt5iN4HI7HNeG6U5YsFBb+/GZbgfBT3kpNG +WPTpAkBI+gFhjfJvRw38n3g/+UeAkwMI2TJQS4n8+hid0uus3/zOjDySH3XHCUno +cn1xOJAyZODBo47E+67R4jV1/gzbAkEAklJaspRPXP877NssM5nAZMU0/O/NGCZ+ +3jPgDUno6WbJn5cqm8MqWhW1xGkImgRk+fkDBquiq4gPiT898jusgQJAd5Zrr6Q8 +AO/0isr/3aa6O6NLQxISLKcPDk2NOccAfS/xOtfOz4sJYM3+Bs4Io9+dZGSDCA54 +Lw03eHTNQghS0A== +-----END PRIVATE KEY----- + diff --git a/src/account/rsa.pub b/src/account/rsa.pub new file mode 100644 index 0000000..cc50e45 --- /dev/null +++ b/src/account/rsa.pub @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC0YjCwIfYoprq/FQO6lb3asXrx +LlJFuCvtinTF5p0GxvQGu5O3gYytUvtC2JlYzypSRjVxwxrsuRcP3e641SdASwfr +mzyvIgP08N4S0IFzEURkV1wp/IpH7kH41EtbmUmrXSwfNZsnQRE5SYSOhh+LcK2w +yQkdgcMv11l4KoBkcwIDAQAB +-----END PUBLIC KEY----- diff --git a/src/account/tasks.py b/src/account/tasks.py new file mode 100644 index 0000000..bfb865d --- /dev/null +++ b/src/account/tasks.py @@ -0,0 +1,34 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from celery import shared_task +from django.contrib.auth.models import User +from jira import JIRAError + +from account.jira_util import get_jira + + +@shared_task +def sync_jira_accounts(): + users = User.objects.all() + for user in users: + jira = get_jira(user) + try: + user_dict = jira.myself() + except JIRAError: + # User can be anonymous (local django admin account) + continue + user.email = user_dict['emailAddress'] + user.userprofile.url = user_dict['self'] + user.userprofile.full_name = user_dict['displayName'] + print(user_dict) + + user.userprofile.save() + user.save()
\ No newline at end of file diff --git a/src/account/tests/__init__.py b/src/account/tests/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/account/tests/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/account/tests/test_general.py b/src/account/tests/test_general.py new file mode 100644 index 0000000..e8f483b --- /dev/null +++ b/src/account/tests/test_general.py @@ -0,0 +1,60 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.contrib.auth.models import User +from django.test import Client +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from account.models import UserProfile + + +class AccountMiddlewareTestCase(TestCase): + def setUp(self): + self.client = Client() + self.user1 = User.objects.create(username='user1') + self.user1.set_password('user1') + self.user1profile = UserProfile.objects.create(user=self.user1) + self.user1.save() + + def test_timezone_middleware(self): + """ + The timezone should be UTC for anonymous users, for authenticated users it should be set + to user.userprofile.timezone + """ + #default + self.assertEqual(timezone.get_current_timezone_name(), 'UTC') + + url = reverse('account:settings') + # anonymous request + self.client.get(url) + self.assertEqual(timezone.get_current_timezone_name(), 'UTC') + + # authenticated user with UTC timezone (userprofile default) + self.client.login(username='user1', password='user1') + self.client.get(url) + self.assertEqual(timezone.get_current_timezone_name(), 'UTC') + + # authenticated user with custom timezone (userprofile default) + self.user1profile.timezone = 'Etc/Greenwich' + self.user1profile.save() + self.client.get(url) + self.assertEqual(timezone.get_current_timezone_name(), 'Etc/Greenwich') + + # if there is no profile for a user, it should be created + user2 = User.objects.create(username='user2') + user2.set_password('user2') + user2.save() + self.client.login(username='user2', password='user2') + self.client.get(url) + self.assertTrue(user2.userprofile) + + diff --git a/src/account/urls.py b/src/account/urls.py new file mode 100644 index 0000000..3962a0c --- /dev/null +++ b/src/account/urls.py @@ -0,0 +1,36 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +"""pharos_dashboard URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url + +from account.views import * + +urlpatterns = [ + url(r'^settings/', AccountSettingsView.as_view(), name='settings'), + url(r'^authenticated/$', JiraAuthenticatedView.as_view(), name='authenticated'), + url(r'^login/$', JiraLoginView.as_view(), name='login'), + url(r'^logout/$', JiraLogoutView.as_view(), name='logout'), + url(r'^users/$', UserListView.as_view(), name='users'), +] diff --git a/src/account/views.py b/src/account/views.py new file mode 100644 index 0000000..17fbdc3 --- /dev/null +++ b/src/account/views.py @@ -0,0 +1,153 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +import os +import urllib + +import oauth2 as oauth +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import logout, authenticate, login +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.models import User +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.views.generic import RedirectView, TemplateView, UpdateView +from jira import JIRA +from rest_framework.authtoken.models import Token + +from account.forms import AccountSettingsForm +from account.jira_util import SignatureMethod_RSA_SHA1 +from account.models import UserProfile + + +@method_decorator(login_required, name='dispatch') +class AccountSettingsView(UpdateView): + model = UserProfile + form_class = AccountSettingsForm + template_name_suffix = '_update_form' + + def get_success_url(self): + messages.add_message(self.request, messages.INFO, + 'Settings saved') + return '/' + + def get_object(self, queryset=None): + return self.request.user.userprofile + + def get_context_data(self, **kwargs): + token, created = Token.objects.get_or_create(user=self.request.user) + context = super(AccountSettingsView, self).get_context_data(**kwargs) + context.update({'title': "Settings", 'token': token}) + return context + + +class JiraLoginView(RedirectView): + def get_redirect_url(self, *args, **kwargs): + consumer = oauth.Consumer(settings.OAUTH_CONSUMER_KEY, settings.OAUTH_CONSUMER_SECRET) + client = oauth.Client(consumer) + client.set_signature_method(SignatureMethod_RSA_SHA1()) + + # Step 1. Get a request token from Jira. + try: + resp, content = client.request(settings.OAUTH_REQUEST_TOKEN_URL, "POST") + except Exception as e: + messages.add_message(self.request, messages.ERROR, + 'Error: Connection to Jira failed. Please contact an Administrator') + return '/' + if resp['status'] != '200': + messages.add_message(self.request, messages.ERROR, + 'Error: Connection to Jira failed. Please contact an Administrator') + return '/' + + # Step 2. Store the request token in a session for later use. + self.request.session['request_token'] = dict(urllib.parse.parse_qsl(content.decode())) + # Step 3. Redirect the user to the authentication URL. + url = settings.OAUTH_AUTHORIZE_URL + '?oauth_token=' + \ + self.request.session['request_token']['oauth_token'] + \ + '&oauth_callback=' + settings.OAUTH_CALLBACK_URL + return url + + +class JiraLogoutView(LoginRequiredMixin, RedirectView): + def get_redirect_url(self, *args, **kwargs): + logout(self.request) + return '/' + + +class JiraAuthenticatedView(RedirectView): + def get_redirect_url(self, *args, **kwargs): + # Step 1. Use the request token in the session to build a new client. + consumer = oauth.Consumer(settings.OAUTH_CONSUMER_KEY, settings.OAUTH_CONSUMER_SECRET) + token = oauth.Token(self.request.session['request_token']['oauth_token'], + self.request.session['request_token']['oauth_token_secret']) + client = oauth.Client(consumer, token) + client.set_signature_method(SignatureMethod_RSA_SHA1()) + + # Step 2. Request the authorized access token from Jira. + try: + resp, content = client.request(settings.OAUTH_ACCESS_TOKEN_URL, "POST") + except Exception as e: + messages.add_message(self.request, messages.ERROR, + 'Error: Connection to Jira failed. Please contact an Administrator') + return '/' + if resp['status'] != '200': + messages.add_message(self.request, messages.ERROR, + 'Error: Connection to Jira failed. Please contact an Administrator') + return '/' + + access_token = dict(urllib.parse.parse_qsl(content.decode())) + + module_dir = os.path.dirname(__file__) # get current directory + with open(module_dir + '/rsa.pem', 'r') as f: + key_cert = f.read() + + oauth_dict = { + 'access_token': access_token['oauth_token'], + 'access_token_secret': access_token['oauth_token_secret'], + 'consumer_key': settings.OAUTH_CONSUMER_KEY, + 'key_cert': key_cert + } + + jira = JIRA(server=settings.JIRA_URL, oauth=oauth_dict) + username = jira.current_user() + url = '/' + # Step 3. Lookup the user or create them if they don't exist. + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + # Save our permanent token and secret for later. + user = User.objects.create_user(username=username, + password=access_token['oauth_token_secret']) + profile = UserProfile() + profile.user = user + profile.save() + url = reverse('account:settings') + user.userprofile.oauth_token = access_token['oauth_token'] + user.userprofile.oauth_secret = access_token['oauth_token_secret'] + user.userprofile.save() + user.set_password(access_token['oauth_token_secret']) + user.save() + user = authenticate(username=username, password=access_token['oauth_token_secret']) + login(self.request, user) + # redirect user to settings page to complete profile + return url + + +@method_decorator(login_required, name='dispatch') +class UserListView(TemplateView): + template_name = "account/user_list.html" + + def get_context_data(self, **kwargs): + users = User.objects.all() + context = super(UserListView, self).get_context_data(**kwargs) + context.update({'title': "Dashboard Users", 'users': users}) + return context diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/api/migrations/__init__.py b/src/api/migrations/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/api/migrations/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/api/serializers.py b/src/api/serializers.py new file mode 100644 index 0000000..237ca02 --- /dev/null +++ b/src/api/serializers.py @@ -0,0 +1,39 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from rest_framework import serializers + +from booking.models import Booking +from dashboard.models import Server, Resource, ResourceStatus + +class BookingSerializer(serializers.ModelSerializer): + installer_name = serializers.CharField(source='installer.name') + scenario_name = serializers.CharField(source='scenario.name') + + class Meta: + model = Booking + fields = ('id', 'resource_id', 'start', 'end', 'installer_name', 'scenario_name', 'purpose') + + +class ServerSerializer(serializers.ModelSerializer): + class Meta: + model = Server + fields = ('id', 'resource_id', 'name', 'model', 'cpu', 'ram', 'storage') + + +class ResourceSerializer(serializers.ModelSerializer): + class Meta: + model = Resource + fields = ('id', 'name', 'description', 'url', 'server_set') + +class ResourceStatusSerializer(serializers.ModelSerializer): + class Meta: + model = ResourceStatus + fields = ('id', 'resource', 'timestamp','type', 'title', 'content') diff --git a/src/api/urls.py b/src/api/urls.py new file mode 100644 index 0000000..a4a4b2f --- /dev/null +++ b/src/api/urls.py @@ -0,0 +1,40 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +"""pharos_dashboard URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url, include +from rest_framework import routers + +from api.views import * + +router = routers.DefaultRouter() +router.register(r'resources', ResourceViewSet) +router.register(r'servers', ServerViewSet) +router.register(r'bookings', BookingViewSet) +router.register(r'resource_status', ResourceStatusViewSet) + +urlpatterns = [ + url(r'^', include(router.urls)), + url(r'^token$', GenerateTokenView.as_view(), name='generate_token'), +]
\ No newline at end of file diff --git a/src/api/views.py b/src/api/views.py new file mode 100644 index 0000000..84fa1b5 --- /dev/null +++ b/src/api/views.py @@ -0,0 +1,53 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect +from django.utils.decorators import method_decorator +from django.views import View +from rest_framework import viewsets +from rest_framework.authtoken.models import Token + +from api.serializers import * +from booking.models import Booking +from dashboard.models import Resource, Server, ResourceStatus + + +class BookingViewSet(viewsets.ModelViewSet): + queryset = Booking.objects.all() + serializer_class = BookingSerializer + filter_fields = ('resource', 'id') + + +class ServerViewSet(viewsets.ModelViewSet): + queryset = Server.objects.all() + serializer_class = ServerSerializer + filter_fields = ('resource', 'name') + + +class ResourceViewSet(viewsets.ModelViewSet): + queryset = Resource.objects.all() + serializer_class = ResourceSerializer + filter_fields = ('name', 'id') + +class ResourceStatusViewSet(viewsets.ModelViewSet): + queryset = ResourceStatus.objects.all() + serializer_class = ResourceStatusSerializer + + +@method_decorator(login_required, name='dispatch') +class GenerateTokenView(View): + def get(self, request, *args, **kwargs): + user = self.request.user + token, created = Token.objects.get_or_create(user=user) + if not created: + token.delete() + Token.objects.create(user=user) + return redirect('account:settings') diff --git a/src/booking/__init__.py b/src/booking/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/booking/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/booking/admin.py b/src/booking/admin.py new file mode 100644 index 0000000..d883be1 --- /dev/null +++ b/src/booking/admin.py @@ -0,0 +1,17 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.contrib import admin + +from booking.models import * + +admin.site.register(Booking) +admin.site.register(Installer) +admin.site.register(Scenario)
\ No newline at end of file diff --git a/src/booking/apps.py b/src/booking/apps.py new file mode 100644 index 0000000..99bf115 --- /dev/null +++ b/src/booking/apps.py @@ -0,0 +1,15 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.apps import AppConfig + + +class BookingConfig(AppConfig): + name = 'booking' diff --git a/src/booking/forms.py b/src/booking/forms.py new file mode 100644 index 0000000..2dbfacb --- /dev/null +++ b/src/booking/forms.py @@ -0,0 +1,23 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +import django.forms as forms + +from booking.models import Installer, Scenario + + +class BookingForm(forms.Form): + fields = ['start', 'end', 'purpose', 'installer', 'scenario'] + + start = forms.DateTimeField() + end = forms.DateTimeField() + purpose = forms.CharField(max_length=300) + installer = forms.ModelChoiceField(queryset=Installer.objects.all(), required=False) + scenario = forms.ModelChoiceField(queryset=Scenario.objects.all(), required=False)
\ No newline at end of file diff --git a/src/booking/migrations/0001_initial.py b/src/booking/migrations/0001_initial.py new file mode 100644 index 0000000..6932dae --- /dev/null +++ b/src/booking/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-11-03 13:33 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dashboard', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Booking', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('start', models.DateTimeField()), + ('end', models.DateTimeField()), + ('jira_issue_id', models.IntegerField(null=True)), + ('jira_issue_status', models.CharField(max_length=50)), + ('purpose', models.CharField(max_length=300)), + ], + options={ + 'db_table': 'booking', + }, + ), + migrations.CreateModel( + name='Installer', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=30)), + ], + ), + migrations.CreateModel( + name='Scenario', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=300)), + ], + ), + migrations.AddField( + model_name='booking', + name='installer', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='booking.Installer'), + ), + migrations.AddField( + model_name='booking', + name='resource', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dashboard.Resource'), + ), + migrations.AddField( + model_name='booking', + name='scenario', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='booking.Scenario'), + ), + migrations.AddField( + model_name='booking', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/booking/migrations/__init__.py b/src/booking/migrations/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/booking/migrations/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/booking/models.py b/src/booking/models.py new file mode 100644 index 0000000..0b3fa3b --- /dev/null +++ b/src/booking/models.py @@ -0,0 +1,77 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.conf import settings +from django.contrib.auth.models import User +from django.db import models +from jira import JIRA +from jira import JIRAError + +from dashboard.models import Resource + + +class Installer(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=30) + + def __str__(self): + return self.name + +class Scenario(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=300) + + def __str__(self): + return self.name + + +class Booking(models.Model): + id = models.AutoField(primary_key=True) + user = models.ForeignKey(User, models.CASCADE) # delete if user is deleted + resource = models.ForeignKey(Resource, models.PROTECT) + start = models.DateTimeField() + end = models.DateTimeField() + jira_issue_id = models.IntegerField(null=True) + jira_issue_status = models.CharField(max_length=50) + + installer = models.ForeignKey(Installer, models.DO_NOTHING, null=True) + scenario = models.ForeignKey(Scenario, models.DO_NOTHING, null=True) + purpose = models.CharField(max_length=300, blank=False) + + class Meta: + db_table = 'booking' + + def get_jira_issue(self): + try: + jira = JIRA(server=settings.JIRA_URL, + basic_auth=(settings.JIRA_USER_NAME, settings.JIRA_USER_PASSWORD)) + issue = jira.issue(self.jira_issue_id) + return issue + except JIRAError: + return None + + def save(self, *args, **kwargs): + """ + Save the booking if self.user is authorized and there is no overlapping booking. + Raise PermissionError if the user is not authorized + Raise ValueError if there is an overlapping booking + """ + if self.start >= self.end: + raise ValueError('Start date is after end date') + # conflicts end after booking starts, and start before booking ends + conflicting_dates = Booking.objects.filter(resource=self.resource).exclude(id=self.id) + conflicting_dates = conflicting_dates.filter(end__gt=self.start) + conflicting_dates = conflicting_dates.filter(start__lt=self.end) + if conflicting_dates.count() > 0: + raise ValueError('This booking overlaps with another booking') + return super(Booking, self).save(*args, **kwargs) + + def __str__(self): + return str(self.resource) + ' from ' + str(self.start) + ' until ' + str(self.end) diff --git a/src/booking/tests/__init__.py b/src/booking/tests/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/booking/tests/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/booking/tests/test_models.py b/src/booking/tests/test_models.py new file mode 100644 index 0000000..b4cd113 --- /dev/null +++ b/src/booking/tests/test_models.py @@ -0,0 +1,94 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from datetime import timedelta + +from django.contrib.auth.models import Permission +from django.test import TestCase +from django.utils import timezone + +from booking.models import * +from dashboard.models import Resource +from jenkins.models import JenkinsSlave + + +class BookingModelTestCase(TestCase): + def setUp(self): + self.slave = JenkinsSlave.objects.create(name='test', url='test') + self.owner = User.objects.create(username='owner') + + self.res1 = Resource.objects.create(name='res1', slave=self.slave, description='x', + url='x',owner=self.owner) + self.res2 = Resource.objects.create(name='res2', slave=self.slave, description='x', + url='x',owner=self.owner) + + self.user1 = User.objects.create(username='user1') + + self.add_booking_perm = Permission.objects.get(codename='add_booking') + self.user1.user_permissions.add(self.add_booking_perm) + + self.user1 = User.objects.get(pk=self.user1.id) + + self.installer = Installer.objects.create(name='TestInstaller') + self.scenario = Scenario.objects.create(name='TestScenario') + + def test_start_end(self): + """ + if the start of a booking is greater or equal then the end, saving should raise a + ValueException + """ + start = timezone.now() + end = start - timedelta(weeks=1) + self.assertRaises(ValueError, Booking.objects.create, start=start, end=end, + resource=self.res1, user=self.user1) + end = start + self.assertRaises(ValueError, Booking.objects.create, start=start, end=end, + resource=self.res1, user=self.user1) + + def test_conflicts(self): + """ + saving an overlapping booking on the same resource should raise a ValueException + saving for different resources should succeed + """ + start = timezone.now() + end = start + timedelta(weeks=1) + self.assertTrue( + Booking.objects.create(start=start, end=end, user=self.user1, resource=self.res1)) + + self.assertRaises(ValueError, Booking.objects.create, start=start, + end=end, resource=self.res1, user=self.user1) + self.assertRaises(ValueError, Booking.objects.create, start=start + timedelta(days=1), + end=end - timedelta(days=1), resource=self.res1, user=self.user1) + + self.assertRaises(ValueError, Booking.objects.create, start=start - timedelta(days=1), + end=end, resource=self.res1, user=self.user1) + self.assertRaises(ValueError, Booking.objects.create, start=start - timedelta(days=1), + end=end - timedelta(days=1), resource=self.res1, user=self.user1) + + self.assertRaises(ValueError, Booking.objects.create, start=start, + end=end + timedelta(days=1), resource=self.res1, user=self.user1) + self.assertRaises(ValueError, Booking.objects.create, start=start + timedelta(days=1), + end=end + timedelta(days=1), resource=self.res1, user=self.user1) + + self.assertTrue(Booking.objects.create(start=start - timedelta(days=1), end=start, + user=self.user1, resource=self.res1)) + self.assertTrue(Booking.objects.create(start=end, end=end + timedelta(days=1), + user=self.user1, resource=self.res1)) + + self.assertTrue( + Booking.objects.create(start=start - timedelta(days=2), end=start - timedelta(days=1), + user=self.user1, resource=self.res1)) + self.assertTrue( + Booking.objects.create(start=end + timedelta(days=1), end=end + timedelta(days=2), + user=self.user1, resource=self.res1)) + self.assertTrue( + Booking.objects.create(start=start, end=end, + user=self.user1, resource=self.res2, scenario=self.scenario, + installer=self.installer))
\ No newline at end of file diff --git a/src/booking/tests/test_views.py b/src/booking/tests/test_views.py new file mode 100644 index 0000000..c1da013 --- /dev/null +++ b/src/booking/tests/test_views.py @@ -0,0 +1,106 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from datetime import timedelta + +from django.test import Client +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from django.utils.encoding import force_text +from registration.forms import User + +from account.models import UserProfile +from booking.models import Booking +from dashboard.models import Resource +from jenkins.models import JenkinsSlave + + +class BookingViewTestCase(TestCase): + def setUp(self): + self.client = Client() + self.slave = JenkinsSlave.objects.create(name='test', url='test') + self.owner = User.objects.create(username='owner') + self.res1 = Resource.objects.create(name='res1', slave=self.slave, description='x', + url='x',owner=self.owner) + self.user1 = User.objects.create(username='user1') + self.user1.set_password('user1') + self.user1profile = UserProfile.objects.create(user=self.user1) + self.user1.save() + + self.user1 = User.objects.get(pk=self.user1.id) + + + def test_resource_bookings_json(self): + url = reverse('booking:bookings_json', kwargs={'resource_id': 0}) + self.assertEqual(self.client.get(url).status_code, 404) + + url = reverse('booking:bookings_json', kwargs={'resource_id': self.res1.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(force_text(response.content), {"bookings": []}) + booking1 = Booking.objects.create(start=timezone.now(), + end=timezone.now() + timedelta(weeks=1), user=self.user1, + resource=self.res1) + response = self.client.get(url) + json = response.json() + self.assertEqual(response.status_code, 200) + self.assertIn('bookings', json) + self.assertEqual(len(json['bookings']), 1) + self.assertIn('start', json['bookings'][0]) + self.assertIn('end', json['bookings'][0]) + self.assertIn('id', json['bookings'][0]) + self.assertIn('purpose', json['bookings'][0]) + + def test_booking_form_view(self): + url = reverse('booking:create', kwargs={'resource_id': 0}) + self.assertEqual(self.client.get(url).status_code, 404) + + # authenticated user + url = reverse('booking:create', kwargs={'resource_id': self.res1.id}) + self.client.login(username='user1',password='user1') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed('booking/booking_calendar.html') + self.assertTemplateUsed('booking/booking_form.html') + self.assertIn('resource', response.context) + + + def test_booking_view(self): + start = timezone.now() + end = start + timedelta(weeks=1) + booking = Booking.objects.create(start=start, end=end, user=self.user1, resource=self.res1) + + url = reverse('booking:detail', kwargs={'booking_id':0}) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + url = reverse('booking:detail', kwargs={'booking_id':booking.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed('booking/booking_detail.html') + self.assertIn('booking', response.context) + + def test_booking_list_view(self): + start = timezone.now() - timedelta(weeks=2) + end = start + timedelta(weeks=1) + Booking.objects.create(start=start, end=end, user=self.user1, resource=self.res1) + + url = reverse('booking:list') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed('booking/booking_list.html') + self.assertTrue(len(response.context['bookings']) == 0) + + start = timezone.now() + end = start + timedelta(weeks=1) + Booking.objects.create(start=start, end=end, user=self.user1, resource=self.res1) + response = self.client.get(url) + self.assertTrue(len(response.context['bookings']) == 1)
\ No newline at end of file diff --git a/src/booking/urls.py b/src/booking/urls.py new file mode 100644 index 0000000..9e01316 --- /dev/null +++ b/src/booking/urls.py @@ -0,0 +1,39 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +"""pharos_dashboard URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url + +from booking.views import * + +urlpatterns = [ + url(r'^(?P<resource_id>[0-9]+)/$', BookingFormView.as_view(), name='create'), + url(r'^(?P<resource_id>[0-9]+)/bookings_json/$', ResourceBookingsJSON.as_view(), + name='bookings_json'), + + url(r'^detail/$', BookingView.as_view(), name='detail_prefix'), + url(r'^detail/(?P<booking_id>[0-9]+)/$', BookingView.as_view(), name='detail'), + + url(r'^list/$', BookingListView.as_view(), name='list') +] diff --git a/src/booking/views.py b/src/booking/views.py new file mode 100644 index 0000000..6fdca0e --- /dev/null +++ b/src/booking/views.py @@ -0,0 +1,122 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import JsonResponse +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.utils import timezone +from django.views import View +from django.views.generic import FormView +from django.views.generic import TemplateView +from jira import JIRAError + +from account.jira_util import get_jira +from booking.forms import BookingForm +from booking.models import Booking +from dashboard.models import Resource + + +def create_jira_ticket(user, booking): + jira = get_jira(user) + issue_dict = { + 'project': 'PHAROS', + 'summary': str(booking.resource) + ': Access Request', + 'description': booking.purpose, + 'issuetype': {'name': 'Task'}, + 'components': [{'name': 'POD Access Request'}], + 'assignee': {'name': booking.resource.owner.username} + } + issue = jira.create_issue(fields=issue_dict) + jira.add_attachment(issue, user.userprofile.pgp_public_key) + jira.add_attachment(issue, user.userprofile.ssh_public_key) + booking.jira_issue_id = issue.id + booking.save() + + +class BookingFormView(FormView): + template_name = "booking/booking_calendar.html" + form_class = BookingForm + + def dispatch(self, request, *args, **kwargs): + self.resource = get_object_or_404(Resource, id=self.kwargs['resource_id']) + return super(BookingFormView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + title = 'Booking: ' + self.resource.name + context = super(BookingFormView, self).get_context_data(**kwargs) + context.update({'title': title, 'resource': self.resource}) + return context + + def get_success_url(self): + return reverse('booking:create', kwargs=self.kwargs) + + def form_valid(self, form): + if not self.request.user.is_authenticated: + messages.add_message(self.request, messages.ERROR, + 'You need to be logged in to book a Pod.') + return super(BookingFormView, self).form_invalid(form) + + user = self.request.user + booking = Booking(start=form.cleaned_data['start'], + end=form.cleaned_data['end'], + purpose=form.cleaned_data['purpose'], + installer=form.cleaned_data['installer'], + scenario=form.cleaned_data['scenario'], + resource=self.resource, user=user) + try: + booking.save() + except ValueError as err: + messages.add_message(self.request, messages.ERROR, err) + return super(BookingFormView, self).form_invalid(form) + try: + if settings.CREATE_JIRA_TICKET: + create_jira_ticket(user, booking) + except JIRAError: + messages.add_message(self.request, messages.ERROR, 'Failed to create Jira Ticket. ' + 'Please check your Jira ' + 'permissions.') + booking.delete() + return super(BookingFormView, self).form_invalid(form) + messages.add_message(self.request, messages.SUCCESS, 'Booking saved') + return super(BookingFormView, self).form_valid(form) + + +class BookingView(TemplateView): + template_name = "booking/booking_detail.html" + + def get_context_data(self, **kwargs): + booking = get_object_or_404(Booking, id=self.kwargs['booking_id']) + title = 'Booking Details' + context = super(BookingView, self).get_context_data(**kwargs) + context.update({'title': title, 'booking': booking}) + return context + + +class BookingListView(TemplateView): + template_name = "booking/booking_list.html" + + def get_context_data(self, **kwargs): + bookings = Booking.objects.filter(end__gte=timezone.now()) + title = 'Search Booking' + context = super(BookingListView, self).get_context_data(**kwargs) + context.update({'title': title, 'bookings': bookings}) + return context + + +class ResourceBookingsJSON(View): + def get(self, request, *args, **kwargs): + resource = get_object_or_404(Resource, id=self.kwargs['resource_id']) + bookings = resource.booking_set.get_queryset().values('id', 'start', 'end', 'purpose', + 'jira_issue_status', + 'installer__name', 'scenario__name') + return JsonResponse({'bookings': list(bookings)}) diff --git a/src/dashboard/__init__.py b/src/dashboard/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/dashboard/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/dashboard/admin.py b/src/dashboard/admin.py new file mode 100644 index 0000000..0bfdef8 --- /dev/null +++ b/src/dashboard/admin.py @@ -0,0 +1,20 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.contrib import admin + +from dashboard.models import * + +admin.site.site_header = "Pharos Dashboard Administration" +admin.site.site_title = "Pharos Dashboard" + +admin.site.register(Resource) +admin.site.register(Server) +admin.site.register(ResourceStatus) diff --git a/src/dashboard/apps.py b/src/dashboard/apps.py new file mode 100644 index 0000000..e0c4f44 --- /dev/null +++ b/src/dashboard/apps.py @@ -0,0 +1,15 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.apps import AppConfig + + +class DashboardConfig(AppConfig): + name = 'dashboard' diff --git a/src/dashboard/fixtures/dashboard.json b/src/dashboard/fixtures/dashboard.json new file mode 100644 index 0000000..f0ac3b2 --- /dev/null +++ b/src/dashboard/fixtures/dashboard.json @@ -0,0 +1,164 @@ +[ +{ + "model": "dashboard.resource", + "pk": 1, + "fields": { + "name": "Linux Foundation POD 1", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Lf+Lab" + } +}, +{ + "model": "dashboard.resource", + "pk": 2, + "fields": { + "name": "Linux Foundation POD 2", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Lf+Lab" + } +}, +{ + "model": "dashboard.resource", + "pk": 3, + "fields": { + "name": "Ericsson POD 2", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Ericsson+Hosting+and+Request+Process" + } +}, +{ + "model": "dashboard.resource", + "pk": 4, + "fields": { + "name": "Intel POD 2", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Intel+Pod2" + } +}, +{ + "model": "dashboard.resource", + "pk": 5, + "fields": { + "name": "Intel POD 5", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Intel+Pod5" + } +}, +{ + "model": "dashboard.resource", + "pk": 6, + "fields": { + "name": "Intel POD 6", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Intel+Pod6" + } +}, +{ + "model": "dashboard.resource", + "pk": 7, + "fields": { + "name": "Intel POD 8", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Intel+Pod8" + } +}, +{ + "model": "dashboard.resource", + "pk": 8, + "fields": { + "name": "Huawei POD 1", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Huawei+Hosting" + } +}, +{ + "model": "dashboard.resource", + "pk": 9, + "fields": { + "name": "Intel POD 3", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Intel+Pod3" + } +}, +{ + "model": "dashboard.resource", + "pk": 10, + "fields": { + "name": "Dell POD 1", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Dell+Hosting" + } +}, +{ + "model": "dashboard.resource", + "pk": 11, + "fields": { + "name": "Dell POD 2", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Dell+Hosting" + } +}, +{ + "model": "dashboard.resource", + "pk": 12, + "fields": { + "name": "Orange POD 2", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Opnfv-orange-pod2" + } +}, +{ + "model": "dashboard.resource", + "pk": 13, + "fields": { + "name": "Arm POD 1", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Enea-pharos-lab" + } +}, +{ + "model": "dashboard.resource", + "pk": 14, + "fields": { + "name": "Ericsson POD 1", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Ericsson+Hosting+and+Request+Process" + } +}, +{ + "model": "dashboard.resource", + "pk": 15, + "fields": { + "name": "Huawei POD 2", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Huawei+Hosting" + } +}, +{ + "model": "dashboard.resource", + "pk": 16, + "fields": { + "name": "Huawei POD 3", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Huawei+Hosting" + } +}, +{ + "model": "dashboard.resource", + "pk": 17, + "fields": { + "name": "Huawei POD 4", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Huawei+Hosting" + } +}, +{ + "model": "dashboard.resource", + "pk": 18, + "fields": { + "name": "Intel POD 9", + "description": "Some description", + "url": "https://wiki.opnfv.org/display/pharos/Intel+Pod9" + } +} +] diff --git a/src/dashboard/migrations/0001_initial.py b/src/dashboard/migrations/0001_initial.py new file mode 100644 index 0000000..aaf3945 --- /dev/null +++ b/src/dashboard/migrations/0001_initial.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-11-03 13:33 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('jenkins', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Resource', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=300, null=True)), + ('url', models.CharField(blank=True, max_length=100, null=True)), + ('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_lab_owner', to=settings.AUTH_USER_MODEL)), + ('slave', models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='jenkins.JenkinsSlave')), + ('vpn_users', models.ManyToManyField(blank=True, related_name='user_vpn_users', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'resource', + }, + ), + migrations.CreateModel( + name='ResourceStatus', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('type', models.CharField(max_length=20)), + ('title', models.CharField(max_length=50)), + ('content', models.CharField(max_length=5000)), + ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dashboard.Resource')), + ], + options={ + 'db_table': 'resource_status', + }, + ), + migrations.CreateModel( + name='Server', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(blank=True, max_length=100)), + ('model', models.CharField(blank=True, max_length=100)), + ('cpu', models.CharField(blank=True, max_length=100)), + ('ram', models.CharField(blank=True, max_length=100)), + ('storage', models.CharField(blank=True, max_length=100)), + ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dashboard.Resource')), + ], + options={ + 'db_table': 'server', + }, + ), + ] diff --git a/src/dashboard/migrations/0002_auto_20170505_0815.py b/src/dashboard/migrations/0002_auto_20170505_0815.py new file mode 100644 index 0000000..4285b88 --- /dev/null +++ b/src/dashboard/migrations/0002_auto_20170505_0815.py @@ -0,0 +1,42 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2017-05-05 08:15 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='resource', + name='dev_pod', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='resource', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_lab_owner', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='resource', + name='slave', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='jenkins.JenkinsSlave'), + ), + ] diff --git a/src/dashboard/migrations/__init__.py b/src/dashboard/migrations/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/dashboard/migrations/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/dashboard/models.py b/src/dashboard/models.py new file mode 100644 index 0000000..3de7db3 --- /dev/null +++ b/src/dashboard/models.py @@ -0,0 +1,95 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from datetime import timedelta + +from django.contrib.auth.models import User +from django.db import models +from django.utils import timezone + +from jenkins.models import JenkinsSlave + + +class Resource(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=100, unique=True) + description = models.CharField(max_length=300, blank=True, null=True) + url = models.CharField(max_length=100, blank=True, null=True) + owner = models.ForeignKey(User, related_name='user_lab_owner', null=True, blank=True) + vpn_users = models.ManyToManyField(User, related_name='user_vpn_users', blank=True) + slave = models.ForeignKey(JenkinsSlave, on_delete=models.DO_NOTHING, null=True, blank=True) + dev_pod = models.BooleanField(default=False) + + def get_booking_utilization(self, weeks): + """ + Return a dictionary containing the count of booked and free seconds for a resource in the + range [now,now + weeks] if weeks is positive, + or [now-weeks, now] if weeks is negative + """ + + length = timedelta(weeks=abs(weeks)) + now = timezone.now() + + start = now + end = now + length + if weeks < 0: + start = now - length + end = now + + bookings = self.booking_set.filter(start__lt=start + length, end__gt=start) + + booked_seconds = 0 + for booking in bookings: + booking_start = booking.start + booking_end = booking.end + if booking_start < start: + booking_start = start + if booking_end > end: + booking_end = start + length + total = booking_end - booking_start + booked_seconds += total.total_seconds() + + return {'booked_seconds': booked_seconds, + 'available_seconds': length.total_seconds() - booked_seconds} + + class Meta: + db_table = 'resource' + + def __str__(self): + return self.name + +class Server(models.Model): + id = models.AutoField(primary_key=True) + resource = models.ForeignKey(Resource, on_delete=models.CASCADE) + name = models.CharField(max_length=100, blank=True) + model = models.CharField(max_length=100, blank=True) + cpu = models.CharField(max_length=100, blank=True) + ram = models.CharField(max_length=100, blank=True) + storage = models.CharField(max_length=100, blank=True) + + class Meta: + db_table = 'server' + + def __str__(self): + return self.name + +class ResourceStatus(models.Model): + id = models.AutoField(primary_key=True) + resource = models.ForeignKey(Resource, on_delete=models.CASCADE) + timestamp = models.DateTimeField(auto_now_add=True) + type = models.CharField(max_length=20) + title = models.CharField(max_length=50) + content = models.CharField(max_length=5000) + + class Meta: + db_table = 'resource_status' + + def __str__(self): + return self.resource.name + ': ' + self.title + ' ' + str(self.timestamp) diff --git a/src/dashboard/tasks.py b/src/dashboard/tasks.py new file mode 100644 index 0000000..c5ef505 --- /dev/null +++ b/src/dashboard/tasks.py @@ -0,0 +1,24 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from datetime import timedelta + +from celery import shared_task +from django.utils import timezone + +from jenkins.models import JenkinsStatistic +from notification.models import BookingNotification + + +@shared_task +def database_cleanup(): + now = timezone.now() + JenkinsStatistic.objects.filter(timestamp__lt=now - timedelta(weeks=4)).delete() + BookingNotification.objects.filter(submit_time__lt=now - timedelta(weeks=4)).delete()
\ No newline at end of file diff --git a/src/dashboard/templatetags/__init__.py b/src/dashboard/templatetags/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/dashboard/templatetags/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/dashboard/templatetags/jenkins_filters.py b/src/dashboard/templatetags/jenkins_filters.py new file mode 100644 index 0000000..e7e1425 --- /dev/null +++ b/src/dashboard/templatetags/jenkins_filters.py @@ -0,0 +1,38 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.template.defaultfilters import register + + +@register.filter +def jenkins_job_color(job_result): + if job_result == 'SUCCESS': + return '#5cb85c' + if job_result == 'FAILURE': + return '#d9534f' + if job_result == 'UNSTABLE': + return '#EDD62B' + return '#646F73' # job is still building + + +@register.filter +def jenkins_status_color(slave_status): + if slave_status == 'offline': + return '#d9534f' + if slave_status == 'online': + return '#5cb85c' + if slave_status == 'online / idle': + return '#5bc0de' + + +@register.filter +def jenkins_job_blink(job_result): + if job_result == '': # job is still building + return 'class=blink_me' diff --git a/src/dashboard/templatetags/jira_filters.py b/src/dashboard/templatetags/jira_filters.py new file mode 100644 index 0000000..9a97c1d --- /dev/null +++ b/src/dashboard/templatetags/jira_filters.py @@ -0,0 +1,17 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.conf import settings +from django.template.defaultfilters import register + + +@register.filter +def jira_issue_url(issue): + return settings.JIRA_URL + '/browse/' + str(issue) diff --git a/src/dashboard/tests/__init__.py b/src/dashboard/tests/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/dashboard/tests/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/dashboard/tests/test_models.py b/src/dashboard/tests/test_models.py new file mode 100644 index 0000000..3a3aeab --- /dev/null +++ b/src/dashboard/tests/test_models.py @@ -0,0 +1,69 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from datetime import timedelta +from math import ceil, floor + +from django.test import TestCase +from django.utils import timezone + +from booking.models import * +from dashboard.models import Resource +from jenkins.models import JenkinsSlave + + +class ResourceModelTestCase(TestCase): + def setUp(self): + self.slave = JenkinsSlave.objects.create(name='test', url='test') + self.owner = User.objects.create(username='owner') + + self.res1 = Resource.objects.create(name='res1', slave=self.slave, description='x', + url='x', owner=self.owner) + + def test_booking_utilization(self): + utilization = self.res1.get_booking_utilization(1) + self.assertTrue(utilization['booked_seconds'] == 0) + self.assertTrue(utilization['available_seconds'] == timedelta(weeks=1).total_seconds()) + + start = timezone.now() + timedelta(days=1) + end = start + timedelta(days=1) + booking = Booking.objects.create(start=start, end=end, purpose='test', resource=self.res1, + user=self.owner) + + utilization = self.res1.get_booking_utilization(1) + booked_seconds = timedelta(days=1).total_seconds() + self.assertEqual(utilization['booked_seconds'], booked_seconds) + + utilization = self.res1.get_booking_utilization(-1) + self.assertEqual(utilization['booked_seconds'], 0) + + booking.delete() + start = timezone.now() - timedelta(days=1) + end = start + timedelta(days=2) + booking = Booking.objects.create(start=start, end=end, purpose='test', resource=self.res1, + user=self.owner) + booked_seconds = self.res1.get_booking_utilization(1)['booked_seconds'] + # use ceil because a fraction of the booked time has already passed now + booked_seconds = ceil(booked_seconds) + self.assertEqual(booked_seconds, timedelta(days=1).total_seconds()) + + booking.delete() + start = timezone.now() + timedelta(days=6) + end = start + timedelta(days=2) + booking = Booking.objects.create(start=start, end=end, purpose='test', resource=self.res1, + user=self.owner) + booked_seconds = self.res1.get_booking_utilization(1)['booked_seconds'] + booked_seconds = floor(booked_seconds) + self.assertEqual(booked_seconds, timedelta(days=1).total_seconds()) + + + + + diff --git a/src/dashboard/tests/test_views.py b/src/dashboard/tests/test_views.py new file mode 100644 index 0000000..f5e17c2 --- /dev/null +++ b/src/dashboard/tests/test_views.py @@ -0,0 +1,75 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.test import TestCase +from django.urls import reverse + +from dashboard.models import Resource +from jenkins.models import JenkinsSlave + + +class DashboardViewTestCase(TestCase): + def setUp(self): + self.slave_active = JenkinsSlave.objects.create(name='slave_active', url='x', active=True) + self.slave_inactive = JenkinsSlave.objects.create(name='slave_inactive', url='x', + active=False) + self.res_active = Resource.objects.create(name='res_active', slave=self.slave_active, + description='x', url='x') + self.res_inactive = Resource.objects.create(name='res_inactive', slave=self.slave_inactive, + description='x', url='x') + + def test_booking_utilization_json(self): + url = reverse('dashboard:booking_utilization', kwargs={'resource_id': 0, 'weeks': 0}) + self.assertEqual(self.client.get(url).status_code, 404) + + url = reverse('dashboard:booking_utilization', kwargs={'resource_id': self.res_active.id, + 'weeks': 0}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'data') + + def test_jenkins_utilization_json(self): + url = reverse('dashboard:jenkins_utilization', kwargs={'resource_id': 0, 'weeks': 0}) + self.assertEqual(self.client.get(url).status_code, 404) + + url = reverse('dashboard:jenkins_utilization', kwargs={'resource_id': self.res_active.id, + 'weeks': 0}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'data') + + def test_jenkins_slaves_view(self): + url = reverse('dashboard:jenkins_slaves') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn(self.slave_active, response.context['slaves']) + self.assertNotIn(self.slave_inactive, response.context['slaves']) + + def test_ci_pods_view(self): + url = reverse('dashboard:ci_pods') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context['ci_pods']), 0) + + self.slave_active.ci_slave = True + self.slave_inactive.ci_slave = True + self.slave_active.save() + self.slave_inactive.save() + + response = self.client.get(url) + self.assertIn(self.res_active, response.context['ci_pods']) + self.assertNotIn(self.res_inactive, response.context['ci_pods']) + + def test_dev_pods_view(self): + url = reverse('dashboard:dev_pods') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context['dev_pods']), 0) + diff --git a/src/dashboard/urls.py b/src/dashboard/urls.py new file mode 100644 index 0000000..609e5d6 --- /dev/null +++ b/src/dashboard/urls.py @@ -0,0 +1,41 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +"""pharos_dashboard URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url + +from dashboard.views import * + +urlpatterns = [ + url(r'^ci_pods/$', CIPodsView.as_view(), name='ci_pods'), + url(r'^dev_pods/$', DevelopmentPodsView.as_view(), name='dev_pods'), + url(r'^jenkins_slaves/$', JenkinsSlavesView.as_view(), name='jenkins_slaves'), + url(r'^resource/all/$', LabOwnerView.as_view(), name='resources'), + url(r'^resource/(?P<resource_id>[0-9]+)/$', ResourceView.as_view(), name='resource'), + url(r'^resource/(?P<resource_id>[0-9]+)/booking_utilization/(?P<weeks>-?\d+)/$', + BookingUtilizationJSON.as_view(), name='booking_utilization'), + url(r'^resource/(?P<resource_id>[0-9]+)/jenkins_utilization/(?P<weeks>-?\d+)/$', + JenkinsUtilizationJSON.as_view(), name='jenkins_utilization'), + url(r'^$', DevelopmentPodsView.as_view(), name="index"), +] diff --git a/src/dashboard/views.py b/src/dashboard/views.py new file mode 100644 index 0000000..62a9f83 --- /dev/null +++ b/src/dashboard/views.py @@ -0,0 +1,141 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from datetime import timedelta + +from django.http import JsonResponse +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.views import View +from django.views.generic import TemplateView + +from booking.models import Booking +from dashboard.models import Resource +from jenkins.models import JenkinsSlave + + +class JenkinsSlavesView(TemplateView): + template_name = "dashboard/jenkins_slaves.html" + + def get_context_data(self, **kwargs): + slaves = JenkinsSlave.objects.filter(active=True) + context = super(JenkinsSlavesView, self).get_context_data(**kwargs) + context.update({'title': "Jenkins Slaves", 'slaves': slaves}) + return context + + +class CIPodsView(TemplateView): + template_name = "dashboard/ci_pods.html" + + def get_context_data(self, **kwargs): + ci_pods = Resource.objects.filter(slave__ci_slave=True, slave__active=True) + context = super(CIPodsView, self).get_context_data(**kwargs) + context.update({'title': "CI Pods", 'ci_pods': ci_pods}) + return context + + +class DevelopmentPodsView(TemplateView): + template_name = "dashboard/dev_pods.html" + + def get_context_data(self, **kwargs): + resources = Resource.objects.filter(dev_pod=True) + + bookings = Booking.objects.filter(start__lte=timezone.now()) + bookings = bookings.filter(end__gt=timezone.now()) + + dev_pods = [] + for resource in resources: + booking_utilization = resource.get_booking_utilization(weeks=4) + total = booking_utilization['booked_seconds'] + booking_utilization['available_seconds'] + try: + utilization_percentage = "%d%%" % (float(booking_utilization['booked_seconds']) / + total * 100) + except (ValueError, ZeroDivisionError): + return "" + + dev_pod = (resource, None, utilization_percentage) + for booking in bookings: + if booking.resource == resource: + dev_pod = (resource, booking, utilization_percentage) + dev_pods.append(dev_pod) + + context = super(DevelopmentPodsView, self).get_context_data(**kwargs) + context.update({'title': "Development Pods", 'dev_pods': dev_pods}) + return context + + +class ResourceView(TemplateView): + template_name = "dashboard/resource.html" + + def get_context_data(self, **kwargs): + resource = get_object_or_404(Resource, id=self.kwargs['resource_id']) + bookings = Booking.objects.filter(resource=resource, end__gt=timezone.now()) + context = super(ResourceView, self).get_context_data(**kwargs) + context.update({'title': str(resource), 'resource': resource, 'bookings': bookings}) + return context + + +class LabOwnerView(TemplateView): + template_name = "dashboard/resource_all.html" + + def get_context_data(self, **kwargs): + resources = Resource.objects.filter(slave__dev_pod=True, slave__active=True) + pods = [] + for resource in resources: + utilization = resource.slave.get_utilization(timedelta(days=7)) + bookings = Booking.objects.filter(resource=resource, end__gt=timezone.now()) + pods.append((resource, utilization, bookings)) + context = super(LabOwnerView, self).get_context_data(**kwargs) + context.update({'title': "Overview", 'pods': pods}) + return context + + +class BookingUtilizationJSON(View): + def get(self, request, *args, **kwargs): + resource = get_object_or_404(Resource, id=kwargs['resource_id']) + utilization = resource.get_booking_utilization(int(kwargs['weeks'])) + utilization = [ + { + 'label': 'Booked', + 'data': utilization['booked_seconds'], + 'color': '#d9534f' + }, + { + 'label': 'Available', + 'data': utilization['available_seconds'], + 'color': '#5cb85c' + }, + ] + return JsonResponse({'data': utilization}) + + +class JenkinsUtilizationJSON(View): + def get(self, request, *args, **kwargs): + resource = get_object_or_404(Resource, id=kwargs['resource_id']) + weeks = int(kwargs['weeks']) + utilization = resource.slave.get_utilization(timedelta(weeks=weeks)) + utilization = [ + { + 'label': 'Offline', + 'data': utilization['offline'], + 'color': '#d9534f' + }, + { + 'label': 'Online', + 'data': utilization['online'], + 'color': '#5cb85c' + }, + { + 'label': 'Idle', + 'data': utilization['idle'], + 'color': '#5bc0de' + }, + ] + return JsonResponse({'data': utilization}) diff --git a/src/jenkins/__init__.py b/src/jenkins/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/jenkins/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/jenkins/adapter.py b/src/jenkins/adapter.py new file mode 100644 index 0000000..edf502f --- /dev/null +++ b/src/jenkins/adapter.py @@ -0,0 +1,134 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +import logging +import re + +import requests +from django.core.cache import cache + +logger = logging.getLogger(__name__) + +# TODO: implement caching decorator, cache get_* functions +def get_json(url): + if cache.get(url) is None: + try: + response = requests.get(url) + json = response.json() + cache.set(url, json, 180) # cache result for 180 seconds + return json + except requests.exceptions.RequestException as e: + logger.exception(e) + except ValueError as e: + logger.exception(e) + else: + return cache.get(url) + + +def get_all_slaves(): + url = "https://build.opnfv.org/ci/computer/api/json?tree=computer[displayName,offline,idle]" + json = get_json(url) + if json is not None: + return json['computer'] # return list of dictionaries + return [] + + +def get_slave(slavename): + slaves = get_all_slaves() + for slave in slaves: + if slave['displayName'] == slavename: + return slave + return {} + + +def get_ci_slaves(): + url = "https://build.opnfv.org/ci/label/ci-pod/api/json?tree=nodes[nodeName,offline,idle]" + json = get_json(url) + if json is not None: + return json['nodes'] + return [] + + +def get_all_jobs(): + url = "https://build.opnfv.org/ci/api/json?tree=jobs[displayName,url,lastBuild[fullDisplayName,building,builtOn,timestamp,result]]" + json = get_json(url) + if json is not None: + return json['jobs'] # return list of dictionaries + return [] + + +def get_jenkins_job(slavename): + jobs = get_all_jobs() + max_time = 0 + last_job = None + for job in jobs: + if job['lastBuild'] is not None: + if job['lastBuild']['builtOn'] == slavename: + if job['lastBuild']['building'] is True: + return job # return active build + if job['lastBuild']['timestamp'] > max_time: + last_job = job + max_time = job['lastBuild']['timestamp'] + return last_job + + +def is_ci_slave(slavename): + ci_slaves = get_ci_slaves() + for ci_slave in ci_slaves: + if ci_slave['nodeName'] == slavename: + return True + return False + + +def is_dev_pod(slavename): + if is_ci_slave(slavename): + return False + if slavename.find('pod') != -1: + return True + return False + + +def parse_job(job): + result = parse_job_string(job['lastBuild']['fullDisplayName']) + result['building'] = job['lastBuild']['building'] + result['result'] = '' + if not job['lastBuild']['building']: + result['result'] = job['lastBuild']['result'] + result['url'] = job['url'] + return result + + +def parse_job_string(full_displayname): + job = {} + job['scenario'] = '' + job['installer'] = '' + job['branch'] = '' + tokens = re.split(r'[ -]', full_displayname) + for i in range(len(tokens)): + if tokens[i] == 'os': + job['scenario'] = '-'.join(tokens[i: i + 4]) + elif tokens[i] in ['fuel', 'joid', 'apex', 'compass']: + job['installer'] = tokens[i] + elif tokens[i] in ['master', 'arno', 'brahmaputra', 'colorado']: + job['branch'] = tokens[i] + tokens = full_displayname.split(' ') + job['name'] = tokens[0] + return job + +def get_slave_url(slave): + return 'https://build.opnfv.org/ci/computer/' + slave['displayName'] + + +def get_slave_status(slave): + if not slave['offline'] and slave['idle']: + return 'online / idle' + if not slave['offline']: + return 'online' + return 'offline' diff --git a/src/jenkins/admin.py b/src/jenkins/admin.py new file mode 100644 index 0000000..c499670 --- /dev/null +++ b/src/jenkins/admin.py @@ -0,0 +1,17 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.conf import settings +from django.contrib import admin + +from jenkins.models import JenkinsSlave + +if settings.DEBUG: + admin.site.register(JenkinsSlave)
\ No newline at end of file diff --git a/src/jenkins/apps.py b/src/jenkins/apps.py new file mode 100644 index 0000000..41faf60 --- /dev/null +++ b/src/jenkins/apps.py @@ -0,0 +1,15 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.apps import AppConfig + + +class JenkinsConfig(AppConfig): + name = 'jenkins' diff --git a/src/jenkins/migrations/0001_initial.py b/src/jenkins/migrations/0001_initial.py new file mode 100644 index 0000000..b1c7889 --- /dev/null +++ b/src/jenkins/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-11-03 13:33 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='JenkinsSlave', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('status', models.CharField(default='offline', max_length=30)), + ('url', models.CharField(max_length=1024)), + ('ci_slave', models.BooleanField(default=False)), + ('dev_pod', models.BooleanField(default=False)), + ('building', models.BooleanField(default=False)), + ('last_job_name', models.CharField(default='', max_length=1024)), + ('last_job_url', models.CharField(default='', max_length=1024)), + ('last_job_scenario', models.CharField(default='', max_length=50)), + ('last_job_branch', models.CharField(default='', max_length=50)), + ('last_job_installer', models.CharField(default='', max_length=50)), + ('last_job_result', models.CharField(default='', max_length=30)), + ('active', models.BooleanField(default=False)), + ], + options={ + 'db_table': 'jenkins_slave', + }, + ), + migrations.CreateModel( + name='JenkinsStatistic', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('offline', models.BooleanField(default=False)), + ('idle', models.BooleanField(default=False)), + ('online', models.BooleanField(default=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('slave', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='jenkins.JenkinsSlave')), + ], + options={ + 'db_table': 'jenkins_statistic', + }, + ), + ] diff --git a/src/jenkins/migrations/__init__.py b/src/jenkins/migrations/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/jenkins/migrations/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/jenkins/models.py b/src/jenkins/models.py new file mode 100644 index 0000000..8254ff3 --- /dev/null +++ b/src/jenkins/models.py @@ -0,0 +1,62 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.db import models +from django.utils import timezone + + +class JenkinsSlave(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=100, unique=True) + status = models.CharField(max_length=30, default='offline') + url = models.CharField(max_length=1024) + ci_slave = models.BooleanField(default=False) + dev_pod = models.BooleanField(default=False) + + building = models.BooleanField(default=False) + + last_job_name = models.CharField(max_length=1024, default='') + last_job_url = models.CharField(max_length=1024, default='') + last_job_scenario = models.CharField(max_length=50, default='') + last_job_branch = models.CharField(max_length=50, default='') + last_job_installer = models.CharField(max_length=50, default='') + last_job_result = models.CharField(max_length=30, default='') + + active = models.BooleanField(default=False) + + def get_utilization(self, timedelta): + """ + Return a dictionary containing the count of idle, online and offline measurements in the time from + now-timedelta to now + """ + utilization = {'idle': 0, 'online': 0, 'offline': 0} + statistics = self.jenkinsstatistic_set.filter(timestamp__gte=timezone.now() - timedelta) + utilization['idle'] = statistics.filter(idle=True).count() + utilization['online'] = statistics.filter(online=True).count() + utilization['offline'] = statistics.filter(offline=True).count() + return utilization + + class Meta: + db_table = 'jenkins_slave' + + def __str__(self): + return self.name + + +class JenkinsStatistic(models.Model): + id = models.AutoField(primary_key=True) + slave = models.ForeignKey(JenkinsSlave, on_delete=models.CASCADE) + offline = models.BooleanField(default=False) + idle = models.BooleanField(default=False) + online = models.BooleanField(default=False) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'jenkins_statistic' diff --git a/src/jenkins/tasks.py b/src/jenkins/tasks.py new file mode 100644 index 0000000..ea986c1 --- /dev/null +++ b/src/jenkins/tasks.py @@ -0,0 +1,64 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from celery import shared_task + +from dashboard.models import Resource +from jenkins.models import JenkinsSlave, JenkinsStatistic +from .adapter import * + + +@shared_task +def sync_jenkins(): + update_jenkins_slaves() + + +def update_jenkins_slaves(): + JenkinsSlave.objects.all().update(active=False) + + jenkins_slaves = get_all_slaves() + for slave in jenkins_slaves: + jenkins_slave, created = JenkinsSlave.objects.get_or_create(name=slave['displayName'], + url=get_slave_url(slave)) + jenkins_slave.active = True + jenkins_slave.ci_slave = is_ci_slave(slave['displayName']) + jenkins_slave.dev_pod = is_dev_pod(slave['displayName']) + jenkins_slave.status = get_slave_status(slave) + + # if this is a new slave and a pod, check if there is a resource for it, create one if not + if created and 'pod' in slave['displayName']: + # parse resource name from slave name + # naming example: orange-pod1, resource name: Orange POD 1 + tokens = slave['displayName'].split('-') + name = tokens[0].capitalize() + ' POD '# company name + name += tokens[1][3:] # remove 'pod' + resource, created = Resource.objects.get_or_create(name=name) + resource.slave = jenkins_slave + resource.save() + + last_job = get_jenkins_job(jenkins_slave.name) + if last_job is not None: + last_job = parse_job(last_job) + jenkins_slave.last_job_name = last_job['name'] + jenkins_slave.last_job_url = last_job['url'] + jenkins_slave.last_job_scenario = last_job['scenario'] + jenkins_slave.last_job_branch = last_job['branch'] + jenkins_slave.last_job_installer = last_job['installer'] + jenkins_slave.last_job_result = last_job['result'] + jenkins_slave.save() + + jenkins_statistic = JenkinsStatistic(slave=jenkins_slave) + if jenkins_slave.status == 'online' or jenkins_slave.status == 'building': + jenkins_statistic.online = True + if jenkins_slave.status == 'offline': + jenkins_statistic.offline = True + if jenkins_slave.status == 'online / idle': + jenkins_statistic.idle = True + jenkins_statistic.save() diff --git a/src/jenkins/tests.py b/src/jenkins/tests.py new file mode 100644 index 0000000..3723cd3 --- /dev/null +++ b/src/jenkins/tests.py @@ -0,0 +1,129 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from datetime import timedelta +from unittest import TestCase + +import jenkins.adapter as jenkins +from jenkins.models import * + + +# Tests that the data we get with the jenkinsadapter contains all the +# data we need. These test will fail if; +# - there is no internet connection +# - the opnfv jenkins url has changed +# - the jenkins api has changed +# - jenkins is not set up / there is no data +class JenkinsAdapterTestCase(TestCase): + def test_get_all_slaves(self): + slaves = jenkins.get_all_slaves() + self.assertTrue(len(slaves) > 0) + for slave in slaves: + self.assertTrue('displayName' in slave) + self.assertTrue('idle' in slave) + self.assertTrue('offline' in slave) + + def test_get_slave(self): + slaves = jenkins.get_all_slaves() + self.assertEqual(slaves[0], jenkins.get_slave(slaves[0]['displayName'])) + self.assertEqual({}, jenkins.get_slave('098f6bcd4621d373cade4e832627b4f6')) + + def test_get_ci_slaves(self): + slaves = jenkins.get_ci_slaves() + self.assertTrue(len(slaves) > 0) + for slave in slaves: + self.assertTrue('nodeName' in slave) + + def test_get_jenkins_job(self): + slaves = jenkins.get_ci_slaves() + job = None + for slave in slaves: + job = jenkins.get_jenkins_job(slave['nodeName']) + if job is not None: + break + # We need to test at least one job + self.assertNotEqual(job, None) + + def test_get_all_jobs(self): + jobs = jenkins.get_all_jobs() + lastBuild = False + self.assertTrue(len(jobs) > 0) + for job in jobs: + self.assertTrue('displayName' in job) + self.assertTrue('url' in job) + self.assertTrue('lastBuild' in job) + if job['lastBuild'] is not None: + lastBuild = True + self.assertTrue('building' in job['lastBuild']) + self.assertTrue('fullDisplayName' in job['lastBuild']) + self.assertTrue('result' in job['lastBuild']) + self.assertTrue('timestamp' in job['lastBuild']) + self.assertTrue('builtOn' in job['lastBuild']) + self.assertTrue(lastBuild) + + def test_parse_job(self): + job = { + "displayName": "apex-deploy-baremetal-os-nosdn-fdio-noha-colorado", + "url": "https://build.opnfv.org/ci/job/apex-deploy-baremetal-os-nosdn-fdio-noha-colorado/", + "lastBuild": { + "building": False, + "fullDisplayName": "apex-deploy-baremetal-os-nosdn-fdio-noha-colorado #37", + "result": "SUCCESS", + "timestamp": 1476283629917, + "builtOn": "lf-pod1" + } + } + + job = jenkins.parse_job(job) + self.assertEqual(job['scenario'], 'os-nosdn-fdio-noha') + self.assertEqual(job['installer'], 'apex') + self.assertEqual(job['branch'], 'colorado') + self.assertEqual(job['result'], 'SUCCESS') + self.assertEqual(job['building'], False) + self.assertEqual(job['url'], + "https://build.opnfv.org/ci/job/apex-deploy-baremetal-os-nosdn-fdio-noha-colorado/") + self.assertEqual(job['name'], + 'apex-deploy-baremetal-os-nosdn-fdio-noha-colorado') + + def test_get_slave_status(self): + slave = { + 'offline': True, + 'idle': False + } + self.assertEqual(jenkins.get_slave_status(slave), 'offline') + slave = { + 'offline': False, + 'idle': False + } + self.assertEqual(jenkins.get_slave_status(slave), 'online') + slave = { + 'offline': False, + 'idle': True + } + self.assertEqual(jenkins.get_slave_status(slave), 'online / idle') + + +class JenkinsModelTestCase(TestCase): + def test_get_utilization(self): + jenkins_slave = JenkinsSlave.objects.create(name='test', status='offline', url='') + utilization = jenkins_slave.get_utilization(timedelta(weeks=1)) + self.assertEqual(utilization['idle'], 0) + self.assertEqual(utilization['offline'], 0) + self.assertEqual(utilization['online'], 0) + + for i in range(10): + JenkinsStatistic.objects.create(slave=jenkins_slave, + offline=True, idle=True, + online=True) + + utilization = jenkins_slave.get_utilization(timedelta(weeks=1)) + self.assertEqual(utilization['idle'], 10) + self.assertEqual(utilization['offline'], 10) + self.assertEqual(utilization['online'], 10) diff --git a/src/manage.py b/src/manage.py new file mode 100644 index 0000000..80c496f --- /dev/null +++ b/src/manage.py @@ -0,0 +1,32 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pharos_dashboard.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/src/notification/__init__.py b/src/notification/__init__.py new file mode 100644 index 0000000..37dcbdd --- /dev/null +++ b/src/notification/__init__.py @@ -0,0 +1,11 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +default_app_config = 'notification.apps.NotificationConfig'
\ No newline at end of file diff --git a/src/notification/admin.py b/src/notification/admin.py new file mode 100644 index 0000000..bcaa1ab --- /dev/null +++ b/src/notification/admin.py @@ -0,0 +1,17 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.conf import settings +from django.contrib import admin + +from notification.models import BookingNotification + +if settings.DEBUG: + admin.site.register(BookingNotification)
\ No newline at end of file diff --git a/src/notification/apps.py b/src/notification/apps.py new file mode 100644 index 0000000..2de22c4 --- /dev/null +++ b/src/notification/apps.py @@ -0,0 +1,18 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.apps import AppConfig + + +class NotificationConfig(AppConfig): + name = 'notification' + + def ready(self): + import notification.signals #noqa
\ No newline at end of file diff --git a/src/notification/migrations/0001_initial.py b/src/notification/migrations/0001_initial.py new file mode 100644 index 0000000..8b8414e --- /dev/null +++ b/src/notification/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-11-03 13:33 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('booking', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='BookingNotification', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('type', models.CharField(max_length=100)), + ('submit_time', models.DateTimeField()), + ('submitted', models.BooleanField(default=False)), + ('booking', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='booking.Booking')), + ], + ), + ] diff --git a/src/notification/migrations/__init__.py b/src/notification/migrations/__init__.py new file mode 100644 index 0000000..b5914ce --- /dev/null +++ b/src/notification/migrations/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + diff --git a/src/notification/models.py b/src/notification/models.py new file mode 100644 index 0000000..89b3023 --- /dev/null +++ b/src/notification/models.py @@ -0,0 +1,33 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.db import models + +class BookingNotification(models.Model): + id = models.AutoField(primary_key=True) + type = models.CharField(max_length=100) + booking = models.ForeignKey('booking.Booking', on_delete=models.CASCADE) + submit_time = models.DateTimeField() + submitted = models.BooleanField(default=False) + + def get_content(self): + return { + 'resource_id': self.booking.resource.id, + 'booking_id': self.booking.id, + 'user': self.booking.user.username, + 'user_id': self.booking.user.id, + } + + def save(self, *args, **kwargs): + notifications = self.booking.bookingnotification_set.filter(type=self.type).exclude( + id=self.id) + #if notifications.count() > 0: + # raise ValueError('Doubled Notification') + return super(BookingNotification, self).save(*args, **kwargs) diff --git a/src/notification/signals.py b/src/notification/signals.py new file mode 100644 index 0000000..936c25b --- /dev/null +++ b/src/notification/signals.py @@ -0,0 +1,25 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from booking.models import Booking +from notification.models import BookingNotification + + +@receiver(post_save, sender=Booking) +def booking_notification_handler(sender, instance, **kwargs): + BookingNotification.objects.update_or_create( + booking=instance, type='booking_start', defaults={'submit_time': instance.start} + ) + BookingNotification.objects.update_or_create( + booking=instance, type='booking_end', defaults={'submit_time': instance.end} + )
\ No newline at end of file diff --git a/src/notification/tasks.py b/src/notification/tasks.py new file mode 100644 index 0000000..7f73762 --- /dev/null +++ b/src/notification/tasks.py @@ -0,0 +1,49 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +import os +import sys +from datetime import timedelta + +from celery import shared_task +from django.conf import settings +from django.utils import timezone + +from notification.models import BookingNotification + +# this adds the top level directory to the python path, this is needed so that we can access the +# notification library +sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..')) + +from dashboard_notification.notification import Notification, Message + + +@shared_task +def send_booking_notifications(): + with Notification(dashboard_url=settings.RABBITMQ_URL, user=settings.RABBITMQ_USER, password=settings.RABBITMQ_PASSWORD) as messaging: + now = timezone.now() + notifications = BookingNotification.objects.filter(submitted=False, + submit_time__gt=now - timedelta(minutes=1), + submit_time__lt=now + timedelta(minutes=5)) + for notification in notifications: + message = Message(type=notification.type, topic=notification.booking.resource.name, + content=notification.get_content()) + messaging.send(message) + notification.submitted = True + notification.save() + +@shared_task +def notification_debug(): + with Notification(dashboard_url=settings.RABBITMQ_URL) as messaging: + notifications = BookingNotification.objects.all() + for notification in notifications: + message = Message(type=notification.type, topic=notification.booking.resource.name, + content=notification.get_content()) + messaging.send(message) diff --git a/src/notification/tests.py b/src/notification/tests.py new file mode 100644 index 0000000..9df9aa6 --- /dev/null +++ b/src/notification/tests.py @@ -0,0 +1,41 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +from datetime import timedelta +from unittest import TestCase + +from django.contrib.auth.models import User +from django.utils import timezone + +from booking.models import Booking +from dashboard.models import Resource +from jenkins.models import JenkinsSlave +from notification.models import * + + +class JenkinsModelTestCase(TestCase): + def setUp(self): + self.slave = JenkinsSlave.objects.create(name='test1', url='test') + self.res1 = Resource.objects.create(name='res1', slave=self.slave, description='x', + url='x') + self.user1 = User.objects.create(username='user1') + + start = timezone.now() + end = start + timedelta(days=1) + self.booking = Booking.objects.create(start=start, end=end, purpose='test', + resource=self.res1, user=self.user1) + + def test_booking_notification(self): + BookingNotification.objects.create(type='test', booking=self.booking, + submit_time=timezone.now()) + + self.assertRaises(ValueError, BookingNotification.objects.create, type='test', + booking=self.booking, + submit_time=timezone.now()) diff --git a/src/pharos_dashboard/__init__.py b/src/pharos_dashboard/__init__.py new file mode 100644 index 0000000..f104c4d --- /dev/null +++ b/src/pharos_dashboard/__init__.py @@ -0,0 +1,13 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app # noqa diff --git a/src/pharos_dashboard/celery.py b/src/pharos_dashboard/celery.py new file mode 100644 index 0000000..f60f243 --- /dev/null +++ b/src/pharos_dashboard/celery.py @@ -0,0 +1,30 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +import os + +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pharos_dashboard.settings') + +from django.conf import settings # noqa + +app = Celery('pharos_dashboard') + +# Using a string here means the worker will not have to +# pickle the object when using Windows. +app.config_from_object('django.conf:settings') +app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) + + +@app.task(bind=True) +def debug_task(self): + print('Request: {0!r}'.format(self.request))
\ No newline at end of file diff --git a/src/pharos_dashboard/settings.py b/src/pharos_dashboard/settings.py new file mode 100644 index 0000000..546b174 --- /dev/null +++ b/src/pharos_dashboard/settings.py @@ -0,0 +1,184 @@ +import os +from datetime import timedelta + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +# Application definition + +INSTALLED_APPS = [ + 'dashboard', + 'booking', + 'account', + 'jenkins', + 'notification', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.humanize', + 'bootstrap3', + 'crispy_forms', + 'rest_framework', + 'rest_framework.authtoken', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'account.middleware.TimezoneMiddleware', +] + +ROOT_URLCONF = 'pharos_dashboard.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates')] + , + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'pharos_dashboard.wsgi.application' + +# Password validation +# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/1.10/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.10/howto/static-files/ +MEDIA_URL = '/media/' +STATIC_URL = '/static/' + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.10/howto/static-files/ +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "static"), +] + +LOGIN_REDIRECT_URL = '/' + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ['SECRET_KEY'] + +BOOTSTRAP3 = { + 'set_placeholder': False, +} + +ALLOWED_HOSTS = ['*'] + +# Database +# https://docs.djangoproject.com/en/1.10/ref/settings/#databases +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ['DB_NAME'], + 'USER': os.environ['DB_USER'], + 'PASSWORD': os.environ['DB_PASS'], + 'HOST': os.environ['DB_SERVICE'], + 'PORT': os.environ['DB_PORT'] + } +} + + +# Rest API Settings +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + ], + 'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + ) +} + +MEDIA_ROOT = '/media' +STATIC_ROOT = '/static' + +# Jira Settings +CREATE_JIRA_TICKET = False + +JIRA_URL = os.environ['JIRA_URL'] + +JIRA_USER_NAME = os.environ['JIRA_USER_NAME'] +JIRA_USER_PASSWORD = os.environ['JIRA_USER_PASSWORD'] + +OAUTH_CONSUMER_KEY = os.environ['OAUTH_CONSUMER_KEY'] +OAUTH_CONSUMER_SECRET = os.environ['OAUTH_CONSUMER_SECRET'] + +OAUTH_REQUEST_TOKEN_URL = JIRA_URL + '/plugins/servlet/oauth/request-token' +OAUTH_ACCESS_TOKEN_URL = JIRA_URL + '/plugins/servlet/oauth/access-token' +OAUTH_AUTHORIZE_URL = JIRA_URL + '/plugins/servlet/oauth/authorize' + +OAUTH_CALLBACK_URL = os.environ['DASHBOARD_URL'] + '/accounts/authenticated' + +# Celery Settings +CELERY_TIMEZONE = 'UTC' + +RABBITMQ_URL = 'rabbitmq' +RABBITMQ_USER = os.environ['RABBITMQ_USER'] +RABBITMQ_PASSWORD = os.environ['RABBITMQ_PASSWORD'] + +BROKER_URL = 'amqp://' + RABBITMQ_USER + ':' + RABBITMQ_PASSWORD + '@rabbitmq:5672//' + +CELERYBEAT_SCHEDULE = { + 'sync-jenkins': { + 'task': 'jenkins.tasks.sync_jenkins', + 'schedule': timedelta(minutes=5) + }, + 'send-booking-notifications': { + 'task': 'notification.tasks.send_booking_notifications', + 'schedule': timedelta(minutes=5) + }, + 'clean-database': { + 'task': 'dashboard.tasks.database_cleanup', + 'schedule': timedelta(hours=24) + }, +} diff --git a/src/pharos_dashboard/urls.py b/src/pharos_dashboard/urls.py new file mode 100644 index 0000000..adcb5b8 --- /dev/null +++ b/src/pharos_dashboard/urls.py @@ -0,0 +1,44 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +"""pharos_dashboard URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf import settings +from django.conf.urls import url, include +from django.conf.urls.static import static +from django.contrib import admin + + +urlpatterns = [ + url(r'^', include('dashboard.urls', namespace='dashboard')), + url(r'^booking/', include('booking.urls', namespace='booking')), + url(r'^accounts/', include('account.urls', namespace='account')), + + url(r'^admin/', admin.site.urls), + url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), + + url(r'^api/', include('api.urls')) +] + +if settings.DEBUG is True: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
\ No newline at end of file diff --git a/src/pharos_dashboard/wsgi.py b/src/pharos_dashboard/wsgi.py new file mode 100644 index 0000000..3d43361 --- /dev/null +++ b/src/pharos_dashboard/wsgi.py @@ -0,0 +1,26 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +""" +WSGI config for pharos_dashboard project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pharos_dashboard.settings") + +application = get_wsgi_application() diff --git a/src/static/bower.json b/src/static/bower.json new file mode 100644 index 0000000..f473747 --- /dev/null +++ b/src/static/bower.json @@ -0,0 +1,24 @@ +{ + "name": "pharos-dashboard-dependencies", + "authors": [ + "maxbr <maxbr@mi.fu-berlin.de>" + ], + "description": "This package contains all the Js/CSS dependencies needed to run the Pharos Dashboard.", + "main": "", + "license": "Apache2", + "homepage": "", + "private": true, + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ], + "dependencies": { + "eonasdan-bootstrap-datetimepicker": "^4.17.37", + "fullcalendar": "^2.9.0", + "jquery-migrate": "^3.0.0", + "startbootstrap-sb-admin-2-blackrockdigital": "^3.3.7" + } +} diff --git a/src/static/css/theme.css b/src/static/css/theme.css new file mode 100644 index 0000000..bd15637 --- /dev/null +++ b/src/static/css/theme.css @@ -0,0 +1,13 @@ +.blink_me { + animation: blinker 1.5s linear infinite; +} + +@keyframes blinker { + 20% { + opacity: 0.4; + } +} + +.modal p { + word-wrap: break-word; +}
\ No newline at end of file diff --git a/src/static/js/booking-calendar.js b/src/static/js/booking-calendar.js new file mode 100644 index 0000000..f634293 --- /dev/null +++ b/src/static/js/booking-calendar.js @@ -0,0 +1,58 @@ +/***************************************************************************** + * Copyright (c) 2016 Max Breitenfeldt and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Apache License, Version 2.0 + * which accompanies this distribution, and is available at + * http://www.apache.org/licenses/LICENSE-2.0 + *****************************************************************************/ + + +function parseCalendarEvents(bookings) { + var events = []; + for (var i = 0; i < bookings.length; i++) { + // convert ISO 8601 timestring to moment, needed for timezone handling + start = moment(bookings[i]['start']); + end = moment(bookings[i]['end']); + + installer = bookings[i]['installer__name']; + if (installer === null) { + installer = ''; + } + + scenario = bookings[i]['scenario__name']; + if (scenario === null) { + scenario = ''; + } + title = bookings[i]['purpose'] + ' ' + installer + ' ' + scenario; + + event = { + id: bookings[i]['id'], + title: title, + start: start, + end: end, + }; + events.push(event); + } + return events; +} + +function loadEvents(url) { + $.ajax({ + url: url, + type: 'get', + success: function (data) { + $('#calendar').fullCalendar('addEventSource', parseCalendarEvents(data['bookings'])); + }, + failure: function (data) { + alert('Error loading booking data'); + } + }); +} + +$(document).ready(function () { + $('#calendar').fullCalendar(calendarOptions); + loadEvents(bookings_url); + $('#starttimepicker').datetimepicker(timepickerOptions); + $('#endtimepicker').datetimepicker(timepickerOptions); +}); diff --git a/src/static/js/dataTables-sort.js b/src/static/js/dataTables-sort.js new file mode 100644 index 0000000..3072d2f --- /dev/null +++ b/src/static/js/dataTables-sort.js @@ -0,0 +1,36 @@ +/***************************************************************************** +* Copyright (c) 2016 Max Breitenfeldt and others. +* +* All rights reserved. This program and the accompanying materials +* are made available under the terms of the Apache License, Version 2.0 +* which accompanies this distribution, and is available at +* http://www.apache.org/licenses/LICENSE-2.0 +*****************************************************************************/ + + +/** + * This is a sort function for dataTables to sort tables by the status column. + * The order should be: online < online/idle < offline + */ +jQuery.extend(jQuery.fn.dataTableExt.oSort, { + "status-pre": function (a) { + switch (a) { + case 'online': + return 1; + case 'online / idle': + return 2; + case 'offline': + return 3; + default: + return a; + } + }, + + "status-asc": function (a, b) { + return ((a < b) ? -1 : ((a > b) ? 1 : 0)); + }, + + "status-desc": function (a, b) { + return ((a < b) ? 1 : ((a > b) ? -1 : 0)); + } +});
\ No newline at end of file diff --git a/src/static/js/datetimepicker-options.js b/src/static/js/datetimepicker-options.js new file mode 100644 index 0000000..d43f5fb --- /dev/null +++ b/src/static/js/datetimepicker-options.js @@ -0,0 +1,13 @@ +/***************************************************************************** +* Copyright (c) 2016 Max Breitenfeldt and others. +* +* All rights reserved. This program and the accompanying materials +* are made available under the terms of the Apache License, Version 2.0 +* which accompanies this distribution, and is available at +* http://www.apache.org/licenses/LICENSE-2.0 +*****************************************************************************/ + + +var timepickerOptions = { + format: 'MM/DD/YYYY HH:00' +};
\ No newline at end of file diff --git a/src/static/js/flot-pie-chart.js b/src/static/js/flot-pie-chart.js new file mode 100644 index 0000000..3b80b2a --- /dev/null +++ b/src/static/js/flot-pie-chart.js @@ -0,0 +1,30 @@ +/***************************************************************************** +* Copyright (c) 2016 Max Breitenfeldt and others. +* +* All rights reserved. This program and the accompanying materials +* are made available under the terms of the Apache License, Version 2.0 +* which accompanies this distribution, and is available at +* http://www.apache.org/licenses/LICENSE-2.0 +*****************************************************************************/ + + +function loadChartData(chart_id, url) { + $.ajax({ + url: url, + type: 'get', + success: function (data) { + var data = data['data']; + var plotObj = $.plot($("#" + chart_id), data, { + series: { + pie: { + show: true + } + } + }); + }, + failure: function (data) { + alert('Error loading data'); + } + }); + +}
\ No newline at end of file diff --git a/src/static/js/fullcalendar-options.js b/src/static/js/fullcalendar-options.js new file mode 100644 index 0000000..22a1b95 --- /dev/null +++ b/src/static/js/fullcalendar-options.js @@ -0,0 +1,101 @@ +/***************************************************************************** +* Copyright (c) 2016 Max Breitenfeldt and others. +* +* All rights reserved. This program and the accompanying materials +* are made available under the terms of the Apache License, Version 2.0 +* which accompanies this distribution, and is available at +* http://www.apache.org/licenses/LICENSE-2.0 +*****************************************************************************/ + + +var tmpevent; + +function sendEventToForm(event) { + $('#starttimepicker').data("DateTimePicker").date(event.start); + $('#endtimepicker').data("DateTimePicker").date(event.end); +} + +var calendarOptions = { + height: 600, + header: { + left: 'prev,next today', + center: 'title', + right: 'agendaWeek,month' + }, + timezone: user_timezone, // set in booking_calendar.html + defaultView: 'month', + slotDuration: '00:60:00', + slotLabelFormat: "HH:mm", + firstDay: 1, + allDaySlot: false, + selectOverlap: false, + eventOverlap: false, + selectable: true, + editable: false, + eventLimit: true, // allow "more" link when too many events + timeFormat: 'H(:mm)', // uppercase H for 24-hour clock + unselectAuto: true, + nowIndicator: true, + + // selectHelper is only working in the agendaWeek view, this is a workaround: + // if an event is selected, the existing selection is removed and a temporary event is added + // to the calendar + select: function (start, end) { + if (tmpevent != undefined) { + $('#calendar').fullCalendar('removeEvents', tmpevent.id); + $('#calendar').fullCalendar('rerenderEvents'); + tmpevent = undefined; + } + // the times need to be converted here to make them show up in the agendaWeek view if they + // are created in the month view. If they are not converted, the tmpevent will only show + // up in the (deactivated) allDaySlot + start = moment(start); + end = moment(end); + + tmpevent = { + id: '537818f62bc63518ece15338fb86c8be', + title: 'New Booking', + start: start, + end: end, + editable: true + }; + + $('#calendar').fullCalendar('renderEvent', tmpevent, true); + sendEventToForm(tmpevent); + }, + + eventClick: function (event) { + if (tmpevent != undefined) { + if (event.id != tmpevent.id) { + $('#calendar').fullCalendar('removeEvents', tmpevent.id); + $('#calendar').fullCalendar('rerenderEvents'); + tmpevent = undefined; + } + } + + // tmpevent is deleted if a real event is clicked, load event details + if (tmpevent == undefined) { + var booking_detail_url = booking_detail_prefix + event.id; + + $.ajax({ + url: booking_detail_url, + type: 'get', + success: function (data) { + $('#booking_detail_content').html(data); + }, + failure: function (data) { + alert('Error loading booking details'); + } + }); + $('#booking_detail_modal').modal('show'); + } + }, + + eventDrop: function (event) { + sendEventToForm(event); + }, + + eventResize: function (event) { + sendEventToForm(event); + } +};
\ No newline at end of file diff --git a/src/templates/account/user_list.html b/src/templates/account/user_list.html new file mode 100644 index 0000000..68178eb --- /dev/null +++ b/src/templates/account/user_list.html @@ -0,0 +1,55 @@ +{% extends "dashboard/table.html" %} +{% load staticfiles %} + +{% block table %} + <thead> + <tr> + <th>Username</th> + <th>Full Name</th> + <th>Email</th> + <th>Company</th> + <th>SSH Key</th> + <th>GPG Key</th> + </tr> + </thead> + <tbody> + {% for user in users %} + <tr> + <td> + {{ user.username }} + </td> + <td> + {{ user.userprofile.full_name }} + </td> + <td> + {{ user.email }} + </td> + <td> + {{ user.userprofile.company }} + </td> + <td> + {% if user.userprofile.ssh_public_key %} + <a href={{ user.userprofile.ssh_public_key.url }}>SSH</a> + {% endif %} + </td> + <td> + {% if user.userprofile.pgp_public_key %} + <a href={{ user.userprofile.pgp_public_key.url }}>GPG</a> + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> +{% endblock table %} + + +{% block tablejs %} + <script type="text/javascript"> + $(document).ready(function () { + $('#table').DataTable({ + scrollX: true, + "order": [[0, "asc"]] + }); + }); + </script> +{% endblock tablejs %} diff --git a/src/templates/account/userprofile_update_form.html b/src/templates/account/userprofile_update_form.html new file mode 100644 index 0000000..f4bb7b5 --- /dev/null +++ b/src/templates/account/userprofile_update_form.html @@ -0,0 +1,38 @@ +{% extends "layout.html" %} +{% load bootstrap3 %} + +{% block basecontent %} + <div class="container"> + <div class="row"> + <div class="col-md-4 col-md-offset-4"> + {% bootstrap_messages %} + <div class="login-panel panel panel-default"> + <div class="panel-heading"> + <h3 class="panel-title"> + {{ title }} + </h3> + </div> + <div class="panel-body"> + <form enctype="multipart/form-data" method="post"> + {% csrf_token %} + {% bootstrap_form form %} + <p><b>API Token</b> + <a href="{% url 'generate_token' %}" class="btn btn-default"> + Generate + </a> + </p> + <p style="word-wrap: break-word;">{{ token.key }}</p> + + <p></p> + {% buttons %} + <button type="submit" class="btn btn btn-success"> + Save Profile + </button> + {% endbuttons %} + </form> + </div> + </div> + </div> + </div> + </div> +{% endblock basecontent %} diff --git a/src/templates/base.html b/src/templates/base.html new file mode 100644 index 0000000..4d8530a --- /dev/null +++ b/src/templates/base.html @@ -0,0 +1,111 @@ +{% extends "layout.html" %} +{% load bootstrap3 %} + +{% block basecontent %} + <div id="wrapper"> + <!-- Navigation --> + <nav class="navbar navbar-default navbar-static-top" role="navigation" + style="margin-bottom: 0"> + <div class="navbar-header"> + <button type="button" class="navbar-toggle" data-toggle="collapse" + data-target=".navbar-collapse"> + <span class="sr-only">Toggle navigation</span> + <span class="icon-bar"></span> + <span class="icon-bar"></span> + <span class="icon-bar"></span> + </button> + <a href="https://www.opnfv.org/" class="navbar-left"><img + src="http://artifacts.opnfv.org/apex/review/14099/installation-instructions/_static/opnfv-logo.png"></a> + <a class="navbar-brand" href={% url 'dashboard:index' %}>Pharos Dashboard</a> + </div> + <!-- /.navbar-header --> + + <ul class="nav navbar-top-links navbar-right"> + <li class="dropdown"> + <a class="dropdown-toggle" data-toggle="dropdown" href="#"> + <i class="fa fa-user fa-fw"></i> <i class="fa fa-caret-down"></i> + </a> + <ul class="dropdown-menu dropdown-user"> + {% if user.is_authenticated %} + <li><a href="{% url 'account:settings' %}"><i + class="fa fa-gear fa-fw"></i> + Settings</a> + </li> + <li class="divider"></li> + <li><a href="{% url 'account:logout' %}?next={{ request.path }}"><i + class="fa fa-sign-out fa-fw"></i> + Logout</a> + </li> + {% else %} + <li><a href="{% url 'account:login' %}"><i + class="fa fa-sign-in fa-fw"></i> + Login with Jira</a> + <li> + {% endif %} + </ul> + <!-- /.dropdown-user --> + </li> + <!-- /.dropdown --> + </ul> + <!-- /.navbar-top-links --> + + <div class="navbar-default sidebar" role="navigation"> + <div class="sidebar-nav navbar-collapse"> + <ul class="nav" id="side-menu"> + <li> + <a href="{% url 'dashboard:ci_pods' %}"><i + class="fa fa-fw"></i>CI-Pods</a> + </li> + <li> + <a href="{% url 'dashboard:dev_pods' %}"><i + class="fa fa-fw"></i>Development + Pods</a> + </li> + <li> + <a href="{% url 'dashboard:jenkins_slaves' %}"><i + class="fa fa-fw"></i>Jenkins + Slaves</a> + </li> + <li> + {% if user.is_authenticated %} + <a href="{% url 'account:users' %}"><i + class="fa fa-fw"></i>User List + </a> + {% endif %} + </li> + <li> + <a href="{% url 'booking:list' %}"><i + class="fa fa-fw"></i>Booking List + </a> + </li> + <li> + <a href="{% url 'api-root' %}"><i + class="fa fa-fw"></i>API + </a> + </li> + </ul> + </div> + <!-- /.sidebar-collapse --> + </div> + <!-- /.navbar-static-side --> + </nav> + + <!-- Page Content --> + <div id="page-wrapper"> + <div class="row"> + <div class="col-lg-12"> + <h1 class="page-header">{{ title }}</h1> + </div> + <!-- /.col-lg-12 --> + </div> + + {% bootstrap_messages %} + + {% block content %} + + {% endblock content %} + </div> + <!-- /#page-wrapper --> + </div> + <!-- /#wrapper --> +{% endblock basecontent %} diff --git a/src/templates/booking/booking_calendar.html b/src/templates/booking/booking_calendar.html new file mode 100644 index 0000000..4644e85 --- /dev/null +++ b/src/templates/booking/booking_calendar.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% load staticfiles %} + +{% load bootstrap3 %} + +{% block extrahead %} + <link href="{% static "bower_components/fullcalendar/dist/fullcalendar.css" %}" + rel='stylesheet'/> + <link href="{% static "bower_components/eonasdan-bootstrap-datetimepicker/build/css/bootstrap-datetimepicker.min.css" %}" + rel='stylesheet'/> +{% endblock extrahead %} + +{% block content %} + <div class="col-lg-8"> + <div class="container-fluid"> + <div class="panel panel-default"> + <div class="panel-heading"> + <i class="fa fa-calendar fa-fw"></i>Calendar + </div> + <div class="panel-body"> + <div id='calendar'> + </div> + </div> + <!-- /.panel-body --> + </div> + <!-- /.panel --> + </div> + </div> + + <div class="col-lg-4"> + <div class="panel panel-default"> + <div class="panel-heading"> + <i class="fa fa-edit fa-fw"></i>Booking + </div> + <div class="panel-body"> + {% if user.is_authenticated %} + <div id="booking_form_div"> + {% bootstrap_form_errors form type='non_fields' %} + <form method="post" action="" class="form" id="bookingform"> + {% csrf_token %} + + <div class='input-group' id='starttimepicker'> + {% bootstrap_field form.start addon_after='<span class="glyphicon glyphicon-calendar"></span>' %} + </div> + <div class='input-group' id='endtimepicker'> + {% bootstrap_field form.end addon_after='<span class="glyphicon glyphicon-calendar"></span>' %} + </div> + {% bootstrap_field form.purpose %} + {% bootstrap_field form.installer %} + {% bootstrap_field form.scenario %} + {% buttons %} + <button type="submit" class="btn btn btn-success"> + Book + </button> + {% endbuttons %} + </form> + </div> + {% else %} + <p>Please + <a href="{% url 'account:login' %}"> + login with Jira</a> + to book this Pod</p> + {% endif %} + </div> + </div> + </div> + + <div id="booking_detail_modal" class="modal fade" role="dialog"> + <div class="modal-dialog"> + + <!-- Modal content--> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal">×</button> + <h4 class="modal-title">Booking Detail</h4> + </div> + <div class="modal-body" id="booking_detail_content"> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-default" data-dismiss="modal">Close + </button> + </div> + </div> + + </div> + </div> +{% endblock content %} + +{% block extrajs %} + <script type="text/javascript"> + var bookings_url = "{% url 'booking:bookings_json' resource_id=resource.id %}"; + var booking_detail_prefix = "{% url 'booking:detail_prefix' %}"; + var user_timezone = "{{ request.user.userprofile.timezone }}" + </script> + + <script src={% static "bower_components/moment/moment.js" %}></script> + <script src={% static "bower_components/fullcalendar/dist/fullcalendar.js" %}></script> + <script type="text/javascript" + src={% static "bower_components/eonasdan-bootstrap-datetimepicker/build/js/bootstrap-datetimepicker.min.js" %}></script> + <script src={% static "js/fullcalendar-options.js" %}></script> + <script src={% static "js/datetimepicker-options.js" %}></script> + <script src={% static "js/booking-calendar.js" %}></script> +{% endblock extrajs %}
\ No newline at end of file diff --git a/src/templates/booking/booking_detail.html b/src/templates/booking/booking_detail.html new file mode 100644 index 0000000..4b016b2 --- /dev/null +++ b/src/templates/booking/booking_detail.html @@ -0,0 +1,26 @@ +{% load jira_filters %} + +<p> + <b>Resource: </b> + <a href="{{ booking.resource.url }}"> + {{ booking.resource.name }} + </a> +</p> +<p> + <b>User: </b> {{ booking.user.username }} +</p> +<p> + <b>Start: </b> {{ booking.start }} +</p> +<p> + <b>End: </b> {{ booking.end }} +</p> +<p> + <b>Purpose: </b> {{ booking.purpose }} +</p> +<p> + <b>Installer: </b> {{ booking.installer }} +</p> +<p> + <b>Scenario: </b> {{ booking.scenario }} +</p>
\ No newline at end of file diff --git a/src/templates/booking/booking_list.html b/src/templates/booking/booking_list.html new file mode 100644 index 0000000..ccdc46d --- /dev/null +++ b/src/templates/booking/booking_list.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% load staticfiles %} + +{% block extrahead %} + <!-- DataTables CSS --> + <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + rel="stylesheet"> + + <!-- DataTables Responsive CSS --> + <link href="{% static "bower_components/datatables-responsive/css/dataTables.responsive.css" %}" + rel="stylesheet"> +{% endblock extrahead %} + +{% block content %} + <div class="row"> + <div class="panel-body"> + <div class="dataTables_wrapper"> + <table class="table table-striped table-bordered table-hover" id="table" + cellspacing="0" + width="100%"> + {% include "booking/booking_table.html" %} + </table> + </div> + <!-- /.table-responsive --> + <!-- /.panel-body --> + <!-- /.panel --> + </div> + <!-- /.col-lg-12 --> + </div> +{% endblock content %} + +{% block extrajs %} + <!-- DataTables JavaScript --> + <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + rel="stylesheet"> + + + <script src={% static "bower_components/datatables/media/js/jquery.dataTables.min.js" %}></script> + <script src={% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.min.js" %}></script> + + <script type="text/javascript"> + $(document).ready(function () { + $('#table').DataTable({ + scrollX: true, + }); + }); + </script> +{% endblock extrajs %} diff --git a/src/templates/booking/booking_table.html b/src/templates/booking/booking_table.html new file mode 100644 index 0000000..655b013 --- /dev/null +++ b/src/templates/booking/booking_table.html @@ -0,0 +1,37 @@ +{% load jira_filters %} + + +<thead> +<tr> + <th>User</th> + <th>Purpose</th> + <th>Start</th> + <th>End</th> + <th>Installer</th> + <th>Scenario</th> +</tr> +</thead> +<tbody> +{% for booking in bookings %} + <tr> + <td> + {{ booking.user.username }} + </td> + <td> + {{ booking.purpose }} + </td> + <td> + {{ booking.start }} + </td> + <td> + {{ booking.end }} + </td> + <td> + {{ booking.installer }} + </td> + <td> + {{ booking.scenario }} + </td> + </tr> +{% endfor %} +</tbody>
\ No newline at end of file diff --git a/src/templates/dashboard/ci_pods.html b/src/templates/dashboard/ci_pods.html new file mode 100644 index 0000000..a20be95 --- /dev/null +++ b/src/templates/dashboard/ci_pods.html @@ -0,0 +1,61 @@ +{% extends "dashboard/table.html" %} +{% load staticfiles %} +{% load jenkins_filters %} + +{% block table %} + <thead> + <tr> + <th>Name</th> + <th>Slave Name</th> + <th>Status</th> + <th>Installer</th> + <th>Scenario</th> + <th>Branch</th> + <th>Job</th> + </tr> + </thead> + <tbody> + {% for pod in ci_pods %} + <tr> + <td> + <a target='_blank' href={{ pod.url }}>{{ pod.name }}</a> + </td> + <td> + <a target='_blank' href={{ pod.slave.url }}>{{ pod.slave.name }}</a> + </td> + <td style="background-color:{{ pod.slave.status | jenkins_status_color }}"> + {{ pod.slave.status }} + </td> + <td {{ pod.slave.last_job_result | jenkins_job_blink }}> + {{ pod.slave.last_job_installer }} + </td> + <td {{ pod.slave.last_job_result | jenkins_job_blink }}> + {{ pod.slave.last_job_scenario }} + </td> + <td {{ pod.slave.last_job_result | jenkins_job_blink }}> + {{ pod.slave.last_job_branch }} + </td> + <td><a {{ pod.slave.last_job_result | jenkins_job_blink }} + style="color:{{ pod.slave.last_job_result | jenkins_job_color }}" + target='_blank' + href={{ pod.slave.last_job_url }}>{{ pod.slave.last_job_name }}</a> + </td> + </tr> + {% endfor %} + </tbody> +{% endblock table %} + + +{% block tablejs %} + <script type="text/javascript"> + $(document).ready(function () { + $('#table').DataTable({ + scrollX: true, + columnDefs: [ + {type: 'status', targets: 2} + ], + "order": [[2, "asc"]] + }); + }); + </script> +{% endblock tablejs %} diff --git a/src/templates/dashboard/dev_pods.html b/src/templates/dashboard/dev_pods.html new file mode 100644 index 0000000..a6f3b2e --- /dev/null +++ b/src/templates/dashboard/dev_pods.html @@ -0,0 +1,70 @@ +{% extends "dashboard/table.html" %} +{% load staticfiles %} +{% load jenkins_filters %} + +{% block table %} + <thead> + <tr> + <th>Name</th> + <th>Slave Name</th> + <th>Booked by</th> + <th>Booked until</th> + <th>Purpose</th> + <th>Utilization</th> + <th>Status</th> + <th></th> + <th></th> + </tr> + </thead> + <tbody> + {% for pod, booking, utilization in dev_pods %} + <tr> + <td> + <a href={% url 'dashboard:resource' resource_id=pod.id %}>{{ pod.name }}</a> + </td> + <td> + <a target='_blank' href={{ pod.slave.url }}>{{ pod.slave.name }}</a> + </td> + <td> + {{ booking.user.username }} + </td> + <td> + {{ booking.end }} + </td> + <td> + {{ booking.purpose }} + </td> + <td> + {{ utilization }} + </td> + <td style="background-color:{{ pod.slave.status | jenkins_status_color }}"> + {{ pod.slave.status }} + </td> + <td> + <a href="{% url 'booking:create' resource_id=pod.id %}" class="btn btn-primary"> + Book + </a> + </td> + <td> + <a href="{% url 'dashboard:resource' resource_id=pod.id %}" class="btn btn-primary"> + Info + </a> + </td> + </tr> + {% endfor %} + </tbody> +{% endblock table %} + +{% block tablejs %} + <script type="text/javascript"> + $(document).ready(function () { + $('#table').DataTable({ + scrollX: true, + columnDefs: [ + {type: 'status', targets: 6} + ], + "order": [[6, "asc"]] + }); + }); + </script> +{% endblock tablejs %} diff --git a/src/templates/dashboard/jenkins_slaves.html b/src/templates/dashboard/jenkins_slaves.html new file mode 100644 index 0000000..fa361b1 --- /dev/null +++ b/src/templates/dashboard/jenkins_slaves.html @@ -0,0 +1,46 @@ +{% extends "dashboard/table.html" %} +{% load staticfiles %} + +{% load jenkins_filters %} + +{% block table %} + <thead> + <tr> + <th>Slave name</th> + <th>Status</th> + <th>Job</th> + </tr> + </thead> + <tbody> + {% for slave in slaves %} + <tr> + <td><a target='_blank' + href={{ slave.url }}>{{ slave.name }}</a> + </td> + <td style="background-color:{{ slave.status | jenkins_status_color }}"> + {{ slave.status }} + </td> + <td><a {{ slave.last_job_result | jenkins_job_blink }} + style="color:{{ slave.last_job_result | jenkins_job_color }}" + target="_blank" href={{ slave.last_job_url }}> + {{ slave.last_job_name }}</a> + </td> + </tr> + {% endfor %} + </tbody> +{% endblock table %} + + +{% block tablejs %} + <script type="text/javascript"> + $(document).ready(function () { + $('#table').DataTable({ + scrollX: true, + columnDefs: [ + {type: 'status', targets: 1} + ], + "order": [[1, "asc"]] + }); + }); + </script> +{% endblock tablejs %} diff --git a/src/templates/dashboard/resource.html b/src/templates/dashboard/resource.html new file mode 100644 index 0000000..c9e5735 --- /dev/null +++ b/src/templates/dashboard/resource.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% load staticfiles %} + +{% block extrahead %} + <!-- Morris Charts CSS --> + <link href="{% static "bower_components/morrisjs/morris.css" %}" rel="stylesheet"> + + <!-- DataTables CSS --> + <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + rel="stylesheet"> + + <!-- DataTables Responsive CSS --> + <link href="{% static "bower_components/datatables-responsive/css/dataTables.responsive.css" %}" + rel="stylesheet"> +{% endblock extrahead %} + + +{% block content %} + {% include "dashboard/resource_detail.html" %} +{% endblock content %} + + +{% block extrajs %} + <!-- DataTables JavaScript --> + <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + rel="stylesheet"> + + <script src={% static "bower_components/datatables/media/js/jquery.dataTables.min.js" %}></script> + <script src={% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.min.js" %}></script> + + + + <!-- Flot Charts JavaScript --> + <script src="{% static "bower_components/flot/excanvas.min.js" %}"></script> + <script src="{% static "bower_components/flot/jquery.flot.js" %}"></script> + <script src="{% static "bower_components/flot/jquery.flot.pie.js" %}"></script> + <script src="{% static "bower_components/flot/jquery.flot.resize.js" %}"></script> + <script src="{% static "bower_components/flot/jquery.flot.time.js" %}"></script> + <script src="{% static "bower_components/flot.tooltip/js/jquery.flot.tooltip.min.js" %}"></script> + + <script src="{% static "js/flot-pie-chart.js" %}"></script> + + <script type="text/javascript"> + $(document).ready(function () { + $('#{{ resource.id }}_server_table').DataTable({}); + $('#{{ resource.id }}_bookings_table').DataTable({}); + $('#{{ resource.id }}_vpn_user_table').DataTable({}); + + var chart_id = "{{ resource.id }}_booking_utilization"; + var utilization_url = "{% url 'dashboard:booking_utilization' resource_id=resource.id weeks=4 %}"; + loadChartData(chart_id, utilization_url); + + var chart_id = "{{ resource.id }}_jenkins_utilization"; + var utilization_url = "{% url 'dashboard:jenkins_utilization' resource_id=resource.id weeks=1 %}"; + loadChartData(chart_id, utilization_url); + }); + </script> +{% endblock extrajs %}
\ No newline at end of file diff --git a/src/templates/dashboard/resource_all.html b/src/templates/dashboard/resource_all.html new file mode 100644 index 0000000..a770d4e --- /dev/null +++ b/src/templates/dashboard/resource_all.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% load staticfiles %} + +{% block extrahead %} + <!-- Morris Charts CSS --> + <link href="{% static "bower_components/morrisjs/morris.css" %}" rel="stylesheet"> + + <!-- DataTables CSS --> + <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + rel="stylesheet"> + + <!-- DataTables Responsive CSS --> + <link href="{% static "bower_components/datatables-responsive/css/dataTables.responsive.css" %}" + rel="stylesheet"> +{% endblock extrahead %} + + +{% block content %} + {% for resource, utilization, bookings in pods %} + <div class="row"> + <div class="col-lg-12"> + <div class="panel panel-default"> + <div class="panel-heading"> + {{ resource.name }} + </div> + <div class="panel-body"> + {% include "dashboard/resource_detail.html" %} + </div> + </div> + </div> + </div> + {% endfor %} +{% endblock content %} + + +{% block extrajs %} + <!-- DataTables JavaScript --> + <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + rel="stylesheet"> + + <script src={% static "bower_components/datatables/media/js/jquery.dataTables.min.js" %}></script> + <script src={% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.min.js" %}></script> + + + + <!-- Flot Charts JavaScript --> + <script src="{% static "bower_components/flot/excanvas.min.js" %}"></script> + <script src="{% static "bower_components/flot/jquery.flot.js" %}"></script> + <script src="{% static "bower_components/flot/jquery.flot.pie.js" %}"></script> + <script src="{% static "bower_components/flot/jquery.flot.resize.js" %}"></script> + <script src="{% static "bower_components/flot/jquery.flot.time.js" %}"></script> + <script src="{% static "bower_components/flot.tooltip/js/jquery.flot.tooltip.min.js" %}"></script> + <script src="{% static "js/flot-pie-chart.js" %}"></script>< + + <script type="text/javascript"> + $(document).ready(function () { + {% for resource, utilization, bookings in pods %} + + $('#{{ resource.id }}_server_table').DataTable({}); + $('#{{ resource.id }}_bookings_table').DataTable({}); + $('#{{ resource.id }}_vpn_user_table').DataTable({}); + + var chart_id = "{{ resource.id }}_booking_utilization"; + var utilization_url = "{% url 'dashboard:booking_utilization' resource_id=resource.id weeks=4 %}"; + loadChartData(chart_id, utilization_url); + + var chart_id = "{{ resource.id }}_jenkins_utilization"; + var utilization_url = "{% url 'dashboard:jenkins_utilization' resource_id=resource.id weeks=1 %}"; + loadChartData(chart_id, utilization_url); + {% endfor %} + }); + </script> +{% endblock extrajs %}
\ No newline at end of file diff --git a/src/templates/dashboard/resource_detail.html b/src/templates/dashboard/resource_detail.html new file mode 100644 index 0000000..740dd25 --- /dev/null +++ b/src/templates/dashboard/resource_detail.html @@ -0,0 +1,205 @@ +{% load jenkins_filters %} + +<div class="row"> + <div class="col-lg-3"> + <div class="panel panel-default"> + <div class="panel-heading"> + Jenkins Utilization + <div class="pull-right"> + <div class="form-group"> + <select onchange="loadChartData('{{ resource.id }}_jenkins_utilization', this.value);"> + <option value="{% url 'dashboard:jenkins_utilization' resource_id=resource.id weeks=1 %}"> + Last Week + </option> + <option value="{% url 'dashboard:jenkins_utilization' resource_id=resource.id weeks=4 %}"> + Last Month + </option> + </select> + </div> + </div> + </div> + <div class="panel-body"> + <div class="flot-chart"> + <div class="flot-chart-content" + id="{{ resource.id }}_jenkins_utilization"></div> + </div> + </div> + </div> + </div> + + <div class="col-lg-9"> + <div class="panel panel-default"> + <div class="panel-heading"> + Status + </div> + <div class="panel-body"> + <div class="list-group pre-scrollable"> + {% for status in resource.resourcestatus_set.all %} + <a href="#" class="list-group-item"> + <i class="fa fa-info fa-fw"></i> {{ status.title }} + <span class="pull-right text-muted small"> + <em>{{ status.timestamp }}</em> + </span> + </a> + {% endfor %} + </div> + </div> + </div> + </div> + <div class="col-lg-9"> + <div class="panel panel-default"> + <div class="panel-heading"> + Servers + </div> + <div class="panel-body"> + <div class="dataTables_wrapper"> + <table class="table table-striped table-bordered table-hover" + id="{{ resource.id }}_server_table" cellspacing="0" + width="100%"> + {% include "dashboard/server_table.html" %} + </table> + </div> + </div> + </div> + </div> +</div> +<div class="row"> + <div class="col-lg-3"> + <div class="panel panel-default"> + <div class="panel-heading"> + Booking Utilization + <div class="pull-right"> + <div class="form-group"> + <select onchange="loadChartData('{{ resource.id }}_booking_utilization', this.value);"> + <option value="{% url 'dashboard:booking_utilization' resource_id=resource.id weeks=-4 %}"> + Last Month + </option> + <option value="{% url 'dashboard:booking_utilization' resource_id=resource.id weeks=-1 %}"> + Last Week + </option> + <option value="{% url 'dashboard:booking_utilization' resource_id=resource.id weeks=1 %}"> + Next Week + </option> + <option selected="selected" + value="{% url 'dashboard:booking_utilization' resource_id=resource.id weeks=4 %}"> + Next Month + </option> + </select> + </div> + </div> + </div> + <div class="panel-body"> + <div class="flot-chart"> + <div class="flot-chart-content" + id="{{ resource.id }}_booking_utilization"></div> + </div> + </div> + </div> + </div> + <div class="col-lg-9"> + <div class="panel panel-default"> + <div class="panel-heading"> + Bookings + </div> + <div class="panel-body"> + <div class="dataTables_wrapper"> + <table class="table table-striped table-bordered table-hover" + id="{{ resource.id }}_bookings_table" cellspacing="0" + width="100%"> + {% include "booking/booking_table.html" %} + </table> + </div> + </div> + </div> + </div> +</div> +<div class="row"> + <div class="col-lg-3"> + <div class="panel panel-default"> + <div class="panel-heading"> + Contact + </div> + <div class="panel-body"> + <p> + <b>Lab Owner: </b> + {{ resource.owner.username }} + </p> + <p> + <b>Email: </b> + {{ resource.owner.email }} + </p> + <p> + <a href="{% url 'booking:create' resource_id=resource.id %}" class="btn + btn-primary"> + Booking + </a> + <a href="{{ resource.url }}" class="btn + btn-primary"> + OPNFV Wiki + </a> + </p> + </div> + </div> + </div> + <div class="col-lg-3"> + <div class="panel panel-default"> + <div class="panel-heading"> + Jenkins Status + </div> + <div class="panel-body"> + <p> + <b>Slave Name: </b> + <a target='_blank' + href={{ resource.slave.url }}>{{ resource.slave.name }}</a> + </p> + <p> + <b>Status: </b> + {{ resource.slave.status }} + </p> + <p> + <b>Last Job: </b> + <a href="{{ resource.slave.last_job_url }}"> + {{ resource.slave.last_job_name }} + </a> + </p> + </div> + </div> + </div> + <div class="col-lg-6"> + <div class="panel panel-default"> + <div class="panel-heading"> + VPN Users + </div> + <div class="panel-body"> + <div class="dataTables_wrapper"> + <table class="table table-striped table-bordered table-hover" + id="{{ resource.id }}_vpn_user_table" cellspacing="0" + width="100%"> + <thead> + <tr> + <th>User</th> + <th>Email</th> + <th>Company</th> + </tr> + </thead> + <tbody> + {% for user in resource.vpn_users.all %} + <tr> + <td> + {{ user.username }} + </td> + <td> + {{ user.email }} + </td> + <td> + {{ user.userprofile.company }} + </td> + </tr> + {% endfor %} + </table> + </tbody> + </div> + </div> + </div> + </div> +</div> diff --git a/src/templates/dashboard/server_table.html b/src/templates/dashboard/server_table.html new file mode 100644 index 0000000..f01bd60 --- /dev/null +++ b/src/templates/dashboard/server_table.html @@ -0,0 +1,30 @@ +<thead> +<tr> + <th>Server</th> + <th>Model</th> + <th>CPU</th> + <th>RAM</th> + <th>Storage</th> +</tr> +</thead> +<tbody> +{% for server in resource.server_set.all %} + <tr> + <td> + {{ server.name }} + </td> + <td> + {{ server.model }} + </td> + <td> + {{ server.cpu }} + </td> + <td> + {{ server.ram }} + </td> + <td> + {{ server.storage }} + </td> + </tr> +{% endfor %} +</tbody>
\ No newline at end of file diff --git a/src/templates/dashboard/table.html b/src/templates/dashboard/table.html new file mode 100644 index 0000000..d59f0e3 --- /dev/null +++ b/src/templates/dashboard/table.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% load staticfiles %} + +{% block extrahead %} + <!-- DataTables CSS --> + <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + rel="stylesheet"> + + <!-- DataTables Responsive CSS --> + <link href="{% static "bower_components/datatables-responsive/css/dataTables.responsive.css" %}" rel="stylesheet"> +{% endblock extrahead %} + +{% block content %} + <div class="row"> + <div class="col-lg-12"> + <div class="dataTables_wrapper"> + <table class="table table-striped table-bordered table-hover" id="table" cellspacing="0" + width="100%"> + + {% block table %} + {% endblock table %} + + </table> + </div> + <!-- /.table-responsive --> + <!-- /.panel-body --> + <!-- /.panel --> + </div> + <!-- /.col-lg-12 --> + </div> +{% endblock content %} + +{% block extrajs %} + <!-- DataTables JavaScript --> + + <script src={% static "bower_components/datatables/media/js/jquery.dataTables.min.js" %}></script> + <script src={% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.min.js" %}></script> + + <script src={% static "js/dataTables-sort.js" %}></script> + + {% block tablejs %} + {% endblock tablejs %} +{% endblock extrajs %} diff --git a/src/templates/layout.html b/src/templates/layout.html new file mode 100644 index 0000000..9578e15 --- /dev/null +++ b/src/templates/layout.html @@ -0,0 +1,73 @@ +{% load staticfiles %} +<!DOCTYPE html> +<html lang="en"> + +<head> + + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="description" content=""> + <meta name="author" content=""> + + <title>OPNFV Pharos {{ title }}</title> + + <!-- Bootstrap Core CSS --> + <link href="{% static "bower_components/bootstrap/dist/css/bootstrap.min.css" %}" + rel="stylesheet"> + + <!-- MetisMenu CSS --> + <link href="{% static "bower_components/metisMenu/dist/metisMenu.min.css" %}" rel="stylesheet"> + + <!-- Custom CSS --> + <link href="{% static "bower_components/startbootstrap-sb-admin-2-blackrockdigital/dist/css/sb-admin-2.min.css" %}" + rel="stylesheet"> + <link href="{% static "css/theme.css" %}" rel="stylesheet"> + + <!-- Custom Fonts --> + <link href="{% static "bower_components/font-awesome/css/font-awesome.min.css" %}" + rel="stylesheet" type="text/css"> + + <!-- Favicon --> + <link rel="shortcut icon" href="{% static 'favicon.ico' %}"> + + {% block extrahead %} + {% endblock extrahead %} + + <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries --> + <!-- WARNING: Respond.js doesn't work if you view the page via file:// --> + <!--[if lt IE 9]> + <script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script> + <script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script> + <![endif]--> + +</head> + +{% block extrastyle %} +{% endblock extrastyle %} + +<body> +{% block basecontent %} +{% endblock basecontent %} + + +<script src="https://code.jquery.com/jquery-2.2.4.min.js" + integrity="sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=" crossorigin="anonymous"></script> +{#<!-- jQuery -->#} +{#<script src="{% static "bower_components/jquery/dist/jquery.min.js" %}"></script>#} +{#<script src="{% static "bower_components/jquery-migrate/jquery-migrate.min.js" %}"></script>#} + +{#<script src="https://code.jquery.com/jquery-2.2.0.min.js"></script>#} +<!-- Bootstrap Core JavaScript --> +<script src="{% static "bower_components/bootstrap/dist/js/bootstrap.min.js" %}"></script> + +<!-- Metis Menu Plugin JavaScript --> +<script src="{% static "bower_components/metisMenu/dist/metisMenu.min.js" %}"></script> + +<!-- Custom Theme JavaScript --> +<script src="{% static "bower_components/startbootstrap-sb-admin-2-blackrockdigital/dist/js/sb-admin-2.min.js" %}"></script> + +{% block extrajs %} +{% endblock extrajs %} +</body> +</html> diff --git a/src/templates/rest_framework/api.html b/src/templates/rest_framework/api.html new file mode 100644 index 0000000..9c6c4f7 --- /dev/null +++ b/src/templates/rest_framework/api.html @@ -0,0 +1,9 @@ +{% extends "rest_framework/base.html" %} + +{% block title %}Pharos Dashboard API{% endblock %} + +{% block branding %} + <a class='navbar-brand' rel="nofollow" href=#> + Pharos Dashboard API + </a> +{% endblock %}
\ No newline at end of file |