Rename pharos-dashboard and pharos-validator
authorTrevor Bramwell <tbramwell@linuxfoundation.org>
Fri, 22 Sep 2017 19:23:36 +0000 (12:23 -0700)
committerTrevor Bramwell <tbramwell@linuxfoundation.org>
Fri, 22 Sep 2017 19:23:36 +0000 (12:23 -0700)
As subdirectories of the pharos-tools repo, there is little need to keep
the pharos prefix.

Change-Id: Ica3d79411f409df638647300036c0664183c2725
Signed-off-by: Trevor Bramwell <tbramwell@linuxfoundation.org>
118 files changed:
.gitignore [new file with mode: 0644]
Makefile [new file with mode: 0644]
__init__.py [new file with mode: 0644]
booking_communication_agent.py [new file with mode: 0644]
config.env.sample [new file with mode: 0644]
config/nginx/pharos_dashboard.conf [new file with mode: 0644]
config/postgres/docker-entrypoint-initdb.d/pharos_dashboard.sh [new file with mode: 0755]
dashboard_api/__init__.py [new file with mode: 0644]
dashboard_api/api.py [new file with mode: 0644]
dashboard_notification/__init__.py [new file with mode: 0644]
dashboard_notification/notification.py [new file with mode: 0644]
docker-compose.yml [new file with mode: 0644]
rabbitmq/Dockerfile [new file with mode: 0644]
rabbitmq/init.sh [new file with mode: 0755]
readme.txt [new file with mode: 0644]
src/__init__.py [new file with mode: 0644]
src/account/__init__.py [new file with mode: 0644]
src/account/admin.py [new file with mode: 0644]
src/account/apps.py [new file with mode: 0644]
src/account/forms.py [new file with mode: 0644]
src/account/jira_util.py [new file with mode: 0644]
src/account/middleware.py [new file with mode: 0644]
src/account/migrations/0001_initial.py [new file with mode: 0644]
src/account/migrations/__init__.py [new file with mode: 0644]
src/account/models.py [new file with mode: 0644]
src/account/rsa.pem [new file with mode: 0644]
src/account/rsa.pub [new file with mode: 0644]
src/account/tasks.py [new file with mode: 0644]
src/account/tests/__init__.py [new file with mode: 0644]
src/account/tests/test_general.py [new file with mode: 0644]
src/account/urls.py [new file with mode: 0644]
src/account/views.py [new file with mode: 0644]
src/api/__init__.py [new file with mode: 0644]
src/api/migrations/__init__.py [new file with mode: 0644]
src/api/serializers.py [new file with mode: 0644]
src/api/urls.py [new file with mode: 0644]
src/api/views.py [new file with mode: 0644]
src/booking/__init__.py [new file with mode: 0644]
src/booking/admin.py [new file with mode: 0644]
src/booking/apps.py [new file with mode: 0644]
src/booking/forms.py [new file with mode: 0644]
src/booking/migrations/0001_initial.py [new file with mode: 0644]
src/booking/migrations/__init__.py [new file with mode: 0644]
src/booking/models.py [new file with mode: 0644]
src/booking/tests/__init__.py [new file with mode: 0644]
src/booking/tests/test_models.py [new file with mode: 0644]
src/booking/tests/test_views.py [new file with mode: 0644]
src/booking/urls.py [new file with mode: 0644]
src/booking/views.py [new file with mode: 0644]
src/dashboard/__init__.py [new file with mode: 0644]
src/dashboard/admin.py [new file with mode: 0644]
src/dashboard/apps.py [new file with mode: 0644]
src/dashboard/fixtures/dashboard.json [new file with mode: 0644]
src/dashboard/migrations/0001_initial.py [new file with mode: 0644]
src/dashboard/migrations/0002_auto_20170505_0815.py [new file with mode: 0644]
src/dashboard/migrations/__init__.py [new file with mode: 0644]
src/dashboard/models.py [new file with mode: 0644]
src/dashboard/tasks.py [new file with mode: 0644]
src/dashboard/templatetags/__init__.py [new file with mode: 0644]
src/dashboard/templatetags/jenkins_filters.py [new file with mode: 0644]
src/dashboard/templatetags/jira_filters.py [new file with mode: 0644]
src/dashboard/tests/__init__.py [new file with mode: 0644]
src/dashboard/tests/test_models.py [new file with mode: 0644]
src/dashboard/tests/test_views.py [new file with mode: 0644]
src/dashboard/urls.py [new file with mode: 0644]
src/dashboard/views.py [new file with mode: 0644]
src/jenkins/__init__.py [new file with mode: 0644]
src/jenkins/adapter.py [new file with mode: 0644]
src/jenkins/admin.py [new file with mode: 0644]
src/jenkins/apps.py [new file with mode: 0644]
src/jenkins/migrations/0001_initial.py [new file with mode: 0644]
src/jenkins/migrations/__init__.py [new file with mode: 0644]
src/jenkins/models.py [new file with mode: 0644]
src/jenkins/tasks.py [new file with mode: 0644]
src/jenkins/tests.py [new file with mode: 0644]
src/manage.py [new file with mode: 0644]
src/notification/__init__.py [new file with mode: 0644]
src/notification/admin.py [new file with mode: 0644]
src/notification/apps.py [new file with mode: 0644]
src/notification/migrations/0001_initial.py [new file with mode: 0644]
src/notification/migrations/__init__.py [new file with mode: 0644]
src/notification/models.py [new file with mode: 0644]
src/notification/signals.py [new file with mode: 0644]
src/notification/tasks.py [new file with mode: 0644]
src/notification/tests.py [new file with mode: 0644]
src/pharos_dashboard/__init__.py [new file with mode: 0644]
src/pharos_dashboard/celery.py [new file with mode: 0644]
src/pharos_dashboard/settings.py [new file with mode: 0644]
src/pharos_dashboard/urls.py [new file with mode: 0644]
src/pharos_dashboard/wsgi.py [new file with mode: 0644]
src/static/bower.json [new file with mode: 0644]
src/static/css/theme.css [new file with mode: 0644]
src/static/js/booking-calendar.js [new file with mode: 0644]
src/static/js/dataTables-sort.js [new file with mode: 0644]
src/static/js/datetimepicker-options.js [new file with mode: 0644]
src/static/js/flot-pie-chart.js [new file with mode: 0644]
src/static/js/fullcalendar-options.js [new file with mode: 0644]
src/templates/account/user_list.html [new file with mode: 0644]
src/templates/account/userprofile_update_form.html [new file with mode: 0644]
src/templates/base.html [new file with mode: 0644]
src/templates/booking/booking_calendar.html [new file with mode: 0644]
src/templates/booking/booking_detail.html [new file with mode: 0644]
src/templates/booking/booking_list.html [new file with mode: 0644]
src/templates/booking/booking_table.html [new file with mode: 0644]
src/templates/dashboard/ci_pods.html [new file with mode: 0644]
src/templates/dashboard/dev_pods.html [new file with mode: 0644]
src/templates/dashboard/jenkins_slaves.html [new file with mode: 0644]
src/templates/dashboard/resource.html [new file with mode: 0644]
src/templates/dashboard/resource_all.html [new file with mode: 0644]
src/templates/dashboard/resource_detail.html [new file with mode: 0644]
src/templates/dashboard/server_table.html [new file with mode: 0644]
src/templates/dashboard/table.html [new file with mode: 0644]
src/templates/layout.html [new file with mode: 0644]
src/templates/rest_framework/api.html [new file with mode: 0644]
web/Dockerfile [new file with mode: 0644]
web/requirements.txt [new file with mode: 0644]
worker/Dockerfile [new file with mode: 0644]
worker/requirements.txt [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..4154fdd
--- /dev/null
@@ -0,0 +1,46 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+
+# C extensions
+*.so
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+.tox/
+.coverage
+.cache
+nosetests.xml
+coverage.xml
+
+# Django:
+*.log
+*.pot
+
+# Celery
+celerybeat-schedule.db
+
+# KDE:
+.directory
+
+# Pycharm:
+.idea/
+
+# Virtualenv:
+venv/
+
+# Vim:
+*.swp
+
+# Bower Components:
+bower_components/
+
+# Production settings
+config.env
+
+# rsa key files
+rsa.pem
+rsa.pub
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..9070917
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,38 @@
+build:
+       docker-compose build
+
+up:
+       docker-compose up -d
+
+start:
+       docker-compose start
+
+stop:
+       docker-compose stop
+
+data:
+       docker volume create --name=pharos-data
+
+shell-nginx:
+       docker exec -ti ng01 bash
+
+shell-web:
+       docker exec -ti dg01 bash
+
+shell-db:
+       docker exec -ti ps01 bash
+
+log-nginx:
+       docker-compose logs nginx  
+
+log-web:
+       docker-compose logs web  
+
+log-ps:
+       docker-compose logs postgres
+
+log-rmq:
+       docker-compose logs rabbitmq
+
+log-worker:
+       docker-compose logs worker
diff --git a/__init__.py b/__init__.py
new file mode 100644 (file)
index 0000000..ce1acf3
--- /dev/null
@@ -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/booking_communication_agent.py b/booking_communication_agent.py
new file mode 100644 (file)
index 0000000..c52e98b
--- /dev/null
@@ -0,0 +1,42 @@
+from dashboard_notification.notification import Notification
+from dashboard_api.api import DashboardAPI
+
+CONFIG = {
+    'dashboard_ip': '127.0.0.1',
+    'dashboard_url': 'http://127.0.0.1',
+    'api_token': 'f33ff43c85ecb13f5d0632c05dbb0a7d85a5a8d1',
+    'user': 'opnfv',
+    'password': 'opnfvopnfv'
+}
+
+api = DashboardAPI(CONFIG['dashboard_url'], api_token=CONFIG['api_token'], verbose=True)
+
+
+def booking_start(message):
+    content = message.content
+    booking = api.get_booking(id=content['booking_id'])
+
+    # do something here...
+
+    # notify dashboard
+    api.post_resource_status(resource_id=booking['resource_id'], type='info', title='pod setup',
+                             content='details')
+
+
+def booking_end(message):
+    # do something here...
+
+    # notify dashboard
+    api.post_resource_status(resource_id=message.content['resource_id'], type='info',
+                             title='booking end', content='details')
+
+
+def main():
+    with Notification(CONFIG['dashboard_ip'], CONFIG['user'], CONFIG['password']) as notification:
+        notification.register(booking_start, 'Arm POD 2', 'booking_start')
+        notification.register(booking_end, 'Arm POD 2', 'booking_end')
+        notification.receive()  # wait for notifications
+
+
+if __name__ == "__main__":
+    main()
diff --git a/config.env.sample b/config.env.sample
new file mode 100644 (file)
index 0000000..060841c
--- /dev/null
@@ -0,0 +1,24 @@
+DASHBOARD_URL=http://labs.opnfv.org
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG=False
+
+DB_NAME=sample_name
+DB_USER=sample_user
+DB_PASS=sample_pass
+DB_SERVICE=postgres
+DB_PORT=5432
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY=http://www.miniwebtool.com/django-secret-key-generator/
+
+OAUTH_CONSUMER_KEY=sample_key
+OAUTH_CONSUMER_SECRET=sample_secret
+
+JIRA_URL=sample_url
+JIRA_USER_NAME=sample_jira_user
+JIRA_USER_PASSWORD=sample_jira_pass
+
+# Rabbitmq
+RABBITMQ_USER=opnfv
+RABBITMQ_PASSWORD=opnfvopnfv
diff --git a/config/nginx/pharos_dashboard.conf b/config/nginx/pharos_dashboard.conf
new file mode 100644 (file)
index 0000000..87b6f8e
--- /dev/null
@@ -0,0 +1,24 @@
+upstream web {
+       ip_hash;
+       server web:8000;
+}
+
+# portal
+server {       
+       listen 80;
+       server_name localhost;
+       charset     utf-8;
+
+       location /static {
+            alias /static;
+       }
+
+       location /media {
+            alias /media;
+       }
+
+       location / {
+            proxy_set_header Host $host;
+           proxy_pass http://web/;
+       }
+}
diff --git a/config/postgres/docker-entrypoint-initdb.d/pharos_dashboard.sh b/config/postgres/docker-entrypoint-initdb.d/pharos_dashboard.sh
new file mode 100755 (executable)
index 0000000..526228a
--- /dev/null
@@ -0,0 +1,14 @@
+##############################################################################
+# 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
+##############################################################################
+
+
+#!/bin/env bash
+
+psql -U postgres -c "CREATE USER $DB_USER PASSWORD '$DB_PASS'"
+psql -U postgres -c "CREATE DATABASE $DB_NAME OWNER $DB_USER"
diff --git a/dashboard_api/__init__.py b/dashboard_api/__init__.py
new file mode 100644 (file)
index 0000000..ce1acf3
--- /dev/null
@@ -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/dashboard_api/api.py b/dashboard_api/api.py
new file mode 100644 (file)
index 0000000..d40e0aa
--- /dev/null
@@ -0,0 +1,91 @@
+##############################################################################
+# 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 requests
+
+URLS = {
+    'resources': '/api/resources/',
+    'servers': '/api/servers/',
+    'bookings': '/api/bookings',
+    'resource_status': '/api/resource_status/',
+}
+
+class DashboardAPI(object):
+    def __init__(self, dashboard_url, api_token='', verbose=False):
+        self._api_token = api_token
+        self._verbose = verbose
+        self._resources_url = dashboard_url + URLS['resources']
+        self._servers_url = dashboard_url + URLS['servers']
+        self._bookings_url = dashboard_url + URLS['bookings']
+        self._resources_status_url = dashboard_url + URLS['resource_status']
+        self._logger = logging.getLogger(__name__)
+
+    def get_all_resources(self):
+        return self._get_json(self._resources_url)
+
+    def get_resource(self, id='', name='', url=''):
+        if url != '':
+            return self._get_json(url)[0]
+        url = self._resources_url + self._url_parameter(id=id, name=name)
+        return self._get_json(url)[0]
+
+    def get_all_bookings(self):
+        return self._get_json(self._bookings_url)
+
+    def get_resource_bookings(self, resource_id):
+        url = self._bookings_url + self._url_parameter(resource_id=resource_id)
+        return self._get_json(url)
+
+    def get_booking(self, id):
+        url = self._bookings_url + self._url_parameter(id=id)
+        return self._get_json(url)[0]
+
+    def post_resource_status(self, resource_id, type, title, content):
+        data = {
+            'resource': resource_id,
+            'type': type,
+            'title': title,
+            'content': content
+        }
+        return self._post_json(self._resources_status_url, data)
+
+    def get_url(self, url):
+        return self._get_json(url)
+
+    def _url_parameter(self, **kwargs):
+        res = ''
+        prefix = '?'
+        for key, val in kwargs.items():
+            res += prefix + key + '=' + str(val)
+            prefix = '&'
+        return res
+
+    def _get_json(self, url):
+        try:
+            response = requests.get(url)
+            if self._verbose:
+                print('Get JSON: ' + url)
+                print(response.status_code, response.content)
+            return response.json()
+        except requests.exceptions.RequestException as e:
+            self._logger.exception(e)
+        except ValueError as e:
+            self._logger.exception(e)
+
+    def _post_json(self, url, json):
+        if self._api_token == '':
+            raise Exception('Need api token to POST data.')
+        response = requests.post(url, json, headers={'Authorization': 'Token ' + self._api_token})
+        if self._verbose:
+            print('Post JSON: ' + url)
+            print(response.status_code, response.content)
+        return response.status_code
diff --git a/dashboard_notification/__init__.py b/dashboard_notification/__init__.py
new file mode 100644 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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/dashboard_notification/notification.py b/dashboard_notification/notification.py
new file mode 100644 (file)
index 0000000..6843c76
--- /dev/null
@@ -0,0 +1,120 @@
+##############################################################################
+# 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 jsonpickle
+import pika
+
+
+class Message(object):
+    def __init__(self, type, topic, content):
+        self.type = type
+        self.topic = topic
+        self.content = content
+
+
+class Notification(object):
+    """
+    This class can be used by the dashboard and the labs to exchange notifications about booking
+    events and pod status. It utilizes rabbitmq to communicate.
+
+    Notifications are associated to an event and to a topic.
+    Events are:
+    [ 'booking_start', 'booking_end']
+    The topic is usually a POD name, ie:
+    'Intel POD 2'
+    """
+
+    def __init__(self, dashboard_url, user=None, password=None, verbose=False):
+        self.rabbitmq_broker = dashboard_url
+        self.verbose = verbose
+        if user is None and password is None:
+            self._connection = pika.BlockingConnection(pika.ConnectionParameters(
+                host=self.rabbitmq_broker))
+        else:
+            self.credentials = pika.PlainCredentials(user, password)
+            self._connection = pika.BlockingConnection(pika.ConnectionParameters(
+                credentials=self.credentials,
+                host=self.rabbitmq_broker))
+        self._registry = {}
+        self._channel = self._connection.channel()
+        self._channel.exchange_declare(exchange='notifications', type='topic')
+        self._result = self._channel.queue_declare(exclusive=True, durable=True)
+        self._queue_name = self._result.method.queue
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self._connection.close()
+
+    def register(self, function, topic, type='all'):
+        """
+        Registers a function to be called for the specified event.
+        :param function: the function to register
+        :param event: the event type
+        :param regex: a regex to specify for wich topics the function will be called. Some
+        possible Expressions can be:
+        'Intel POD 2' : Intel POD 2
+        """
+
+        if topic not in self._registry:
+            self._registry[topic] = [(function, type)]
+        else:
+            self._registry[topic].append((function, type))
+
+    def receive(self):
+        """
+        Start receiving notifications. This is a blocking operation, if a notification is received,
+        the registered functions will be called.
+        """
+        if self.verbose:
+            print('Start receiving Notifications. Keys: ', self._registry.keys())
+        self._receive_message(self._registry.keys())
+
+    def send(self, message):
+        """
+        Send an event notification.
+        :param event: the event type
+        :param topic: the pod name
+        :param content: a JSON-serializable dictionary
+        """
+        self._send_message(message)
+
+    def _send_message(self, message):
+        routing_key = message.topic
+        message_json = jsonpickle.encode(message)
+        self._channel.basic_publish(exchange='notifications',
+                                    routing_key=routing_key,
+                                    body=message_json,
+                                    properties=pika.BasicProperties(
+                                        content_type='application/json',
+                                        delivery_mode=2,  # make message persistent
+                                    ))
+        if self.verbose:
+            print(" [x] Sent %r:%r" % (routing_key, message_json))
+
+    def _receive_message(self, binding_keys):
+        for key in binding_keys:
+            self._channel.queue_bind(exchange='notifications',
+                                     queue=self._queue_name,
+                                     routing_key=key)
+        self._channel.basic_consume(self._message_callback,
+                                    queue=self._queue_name)
+        self._channel.start_consuming()
+
+    def _message_callback(self, ch, method, properties, body):
+        if self.verbose:
+            print(" [x] Got %r:%r" % (method.routing_key, body))
+        if method.routing_key not in self._registry:
+            return
+        for func, type in self._registry[method.routing_key]:
+            message = jsonpickle.decode(body.decode())
+            if message.type == type:
+                func(message)
+        ch.basic_ack(delivery_tag=method.delivery_tag)
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644 (file)
index 0000000..44a263f
--- /dev/null
@@ -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
+##############################################################################
+
+
+version: '2'
+services:
+    nginx:
+        restart: always
+        image: nginx:latest
+        container_name: ng01
+        ports:
+            - "80:80"
+        volumes:
+            - ./config/nginx:/etc/nginx/conf.d
+            - /var/lib/pharos_dashboard/static:/static
+            - /var/lib/pharos_dashboard/media:/media
+        depends_on:
+            - web
+
+    web:
+        restart: always
+        build: ./web/
+        container_name: dg01
+        # yamllint disable rule:line-length
+        command: bash -c "python manage.py migrate && python manage.py collectstatic --no-input && gunicorn pharos_dashboard.wsgi -b 0.0.0.0:8000"
+        # yamllint enable rule:line-length
+        depends_on:
+            - postgres
+        links:
+            - postgres
+        env_file: config.env
+        volumes:
+            - ./:/pharos_dashboard
+            - /var/lib/pharos_dashboard/static:/static
+            - /var/lib/pharos_dashboard/media:/media
+        expose:
+            - "8000"
+
+    postgres:
+        restart: always
+        image: postgres:latest
+        container_name: ps01
+        env_file: config.env
+        volumes:
+            - ./config/postgres/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
+            - pharos-data:/var/lib/postgresql/data
+
+    rabbitmq:
+        restart: always
+        build: ./rabbitmq/
+        container_name: rm01
+        env_file: config.env
+        ports:
+            - "5672:5672"
+
+    worker:
+        restart: always
+        build: ./worker/
+        # yamllint disable rule:line-length
+        command: bash -c "celery -A pharos_dashboard worker -l info -B --schedule=~/celerybeat-schedule"
+        # yamllint enable rule:line-length
+        env_file: config.env
+        links:
+            - postgres
+            - rabbitmq
+        volumes:
+            - ./:/pharos_dashboard
+volumes:
+    pharos-data:
+        external: true
diff --git a/rabbitmq/Dockerfile b/rabbitmq/Dockerfile
new file mode 100644 (file)
index 0000000..71162a4
--- /dev/null
@@ -0,0 +1,4 @@
+FROM rabbitmq
+
+ADD init.sh /init.sh
+CMD ["/init.sh"]
\ No newline at end of file
diff --git a/rabbitmq/init.sh b/rabbitmq/init.sh
new file mode 100755 (executable)
index 0000000..9d04dd1
--- /dev/null
@@ -0,0 +1,10 @@
+#!/bin/sh
+
+# Create Rabbitmq user
+( sleep 10 ; \
+rabbitmqctl add_user $RABBITMQ_USER $RABBITMQ_PASSWORD 2>/dev/null ; \
+rabbitmqctl set_user_tags $RABBITMQ_USER administrator ; \
+rabbitmqctl set_permissions -p / $RABBITMQ_USER  ".*" ".*" ".*" ; \
+echo "*** User '$RABBITMQ_USER' with password '$RABBITMQ_PASSWORD' completed. ***") &
+
+rabbitmq-server $@
diff --git a/readme.txt b/readme.txt
new file mode 100644 (file)
index 0000000..2a25912
--- /dev/null
@@ -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
+##############################################################################
+
+
+The dashboard is deployed using docker-compose.
+Application / database files are saved in /var/lib/pharos_dashboard/.
+
+Deployment:
+
+- clone the repository
+- complete the config.env.sample file and save it as config.env
+- install docker, docker-compose and bower
+- run 'bower install' in ./src/static/ to fetch javascript dependencies
+- run 'make build' to build the containers
+- run 'make data'
+- run 'make up' to run the dashboard
+
+Updating:
+
+- make stop
+- git pull
+- run 'bower install' if javascript dependencies changed
+- make build
+- make start
+
+If there is migrations that need user input (like renaming a field), they need to be run manually!
+
+Logs / Shell access:
+
+- there is some shortcuts in the makefile
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644 (file)
index 0000000..ce1acf3
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..18b2e1a
--- /dev/null
@@ -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 (file)
index 0000000..9814648
--- /dev/null
@@ -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 (file)
index 0000000..7653e2b
--- /dev/null
@@ -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 (file)
index 0000000..fdb87f7
--- /dev/null
@@ -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 (file)
index 0000000..0f1dbd8
--- /dev/null
@@ -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 (file)
index 0000000..591f702
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..c2e9902
--- /dev/null
@@ -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 (file)
index 0000000..dbd4eed
--- /dev/null
@@ -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 (file)
index 0000000..cc50e45
--- /dev/null
@@ -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 (file)
index 0000000..bfb865d
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..e8f483b
--- /dev/null
@@ -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 (file)
index 0000000..3962a0c
--- /dev/null
@@ -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 (file)
index 0000000..17fbdc3
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..237ca02
--- /dev/null
@@ -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 (file)
index 0000000..a4a4b2f
--- /dev/null
@@ -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 (file)
index 0000000..84fa1b5
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..d883be1
--- /dev/null
@@ -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 (file)
index 0000000..99bf115
--- /dev/null
@@ -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 (file)
index 0000000..2dbfacb
--- /dev/null
@@ -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 (file)
index 0000000..6932dae
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..0b3fa3b
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..b4cd113
--- /dev/null
@@ -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 (file)
index 0000000..c1da013
--- /dev/null
@@ -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 (file)
index 0000000..9e01316
--- /dev/null
@@ -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 (file)
index 0000000..6fdca0e
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..0bfdef8
--- /dev/null
@@ -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 (file)
index 0000000..e0c4f44
--- /dev/null
@@ -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 (file)
index 0000000..f0ac3b2
--- /dev/null
@@ -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 (file)
index 0000000..aaf3945
--- /dev/null
@@ -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 (file)
index 0000000..4285b88
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..3de7db3
--- /dev/null
@@ -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 (file)
index 0000000..c5ef505
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..e7e1425
--- /dev/null
@@ -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 (file)
index 0000000..9a97c1d
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..3a3aeab
--- /dev/null
@@ -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 (file)
index 0000000..f5e17c2
--- /dev/null
@@ -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 (file)
index 0000000..609e5d6
--- /dev/null
@@ -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 (file)
index 0000000..62a9f83
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..edf502f
--- /dev/null
@@ -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 (file)
index 0000000..c499670
--- /dev/null
@@ -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 (file)
index 0000000..41faf60
--- /dev/null
@@ -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 (file)
index 0000000..b1c7889
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..8254ff3
--- /dev/null
@@ -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 (file)
index 0000000..ea986c1
--- /dev/null
@@ -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 (file)
index 0000000..3723cd3
--- /dev/null
@@ -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 (file)
index 0000000..80c496f
--- /dev/null
@@ -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 (file)
index 0000000..37dcbdd
--- /dev/null
@@ -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 (file)
index 0000000..bcaa1ab
--- /dev/null
@@ -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 (file)
index 0000000..2de22c4
--- /dev/null
@@ -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 (file)
index 0000000..8b8414e
--- /dev/null
@@ -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 (file)
index 0000000..b5914ce
--- /dev/null
@@ -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 (file)
index 0000000..89b3023
--- /dev/null
@@ -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 (file)
index 0000000..936c25b
--- /dev/null
@@ -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 (file)
index 0000000..7f73762
--- /dev/null
@@ -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 (file)
index 0000000..9df9aa6
--- /dev/null
@@ -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 (file)
index 0000000..f104c4d
--- /dev/null
@@ -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 (file)
index 0000000..f60f243
--- /dev/null
@@ -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 (file)
index 0000000..546b174
--- /dev/null
@@ -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 (file)
index 0000000..adcb5b8
--- /dev/null
@@ -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 (file)
index 0000000..3d43361
--- /dev/null
@@ -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 (file)
index 0000000..f473747
--- /dev/null
@@ -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 (file)
index 0000000..bd15637
--- /dev/null
@@ -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 (file)
index 0000000..f634293
--- /dev/null
@@ -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 (file)
index 0000000..3072d2f
--- /dev/null
@@ -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 (file)
index 0000000..d43f5fb
--- /dev/null
@@ -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 (file)
index 0000000..3b80b2a
--- /dev/null
@@ -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 (file)
index 0000000..22a1b95
--- /dev/null
@@ -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 (file)
index 0000000..68178eb
--- /dev/null
@@ -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 (file)
index 0000000..f4bb7b5
--- /dev/null
@@ -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 (file)
index 0000000..4d8530a
--- /dev/null
@@ -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 (file)
index 0000000..4644e85
--- /dev/null
@@ -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">&times;</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 (file)
index 0000000..4b016b2
--- /dev/null
@@ -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 (file)
index 0000000..ccdc46d
--- /dev/null
@@ -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 (file)
index 0000000..655b013
--- /dev/null
@@ -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 (file)
index 0000000..a20be95
--- /dev/null
@@ -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 (file)
index 0000000..a6f3b2e
--- /dev/null
@@ -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 (file)
index 0000000..fa361b1
--- /dev/null
@@ -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 (file)
index 0000000..c9e5735
--- /dev/null
@@ -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 (file)
index 0000000..a770d4e
--- /dev/null
@@ -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 (file)
index 0000000..740dd25
--- /dev/null
@@ -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 (file)
index 0000000..f01bd60
--- /dev/null
@@ -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 (file)
index 0000000..d59f0e3
--- /dev/null
@@ -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 (file)
index 0000000..9578e15
--- /dev/null
@@ -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 (file)
index 0000000..9c6c4f7
--- /dev/null
@@ -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
diff --git a/web/Dockerfile b/web/Dockerfile
new file mode 100644 (file)
index 0000000..228b0b0
--- /dev/null
@@ -0,0 +1,7 @@
+FROM python:3.5
+ENV PYTHONUNBUFFERED 1
+RUN mkdir /config
+ADD ./requirements.txt /config/
+RUN pip install -r /config/requirements.txt
+RUN mkdir -p /pharos_dashboard/src
+WORKDIR /pharos_dashboard/src
diff --git a/web/requirements.txt b/web/requirements.txt
new file mode 100644 (file)
index 0000000..f80f1c0
--- /dev/null
@@ -0,0 +1,17 @@
+celery==3.1.23
+cryptography==1.4
+Django==1.10
+django-bootstrap3==7.0.1
+django-crispy-forms==1.6.0
+django-filter==0.14.0
+django-registration==2.1.2
+djangorestframework==3.4.6
+gunicorn==19.6.0
+jira==1.0.7
+jsonpickle==0.9.3
+oauth2==1.9.0.post1
+oauthlib==1.1.2
+pika==0.10.0
+psycopg2==2.6.2
+PyJWT==1.4.2
+requests==2.11.0
diff --git a/worker/Dockerfile b/worker/Dockerfile
new file mode 100644 (file)
index 0000000..c1e8aff
--- /dev/null
@@ -0,0 +1,8 @@
+FROM python:3.5
+ENV PYTHONUNBUFFERED 1
+RUN mkdir /config
+ADD ./requirements.txt /config/
+RUN pip install -r /config/requirements.txt
+RUN useradd -ms /bin/bash celery
+USER celery
+WORKDIR /pharos_dashboard/src
diff --git a/worker/requirements.txt b/worker/requirements.txt
new file mode 100644 (file)
index 0000000..f80f1c0
--- /dev/null
@@ -0,0 +1,17 @@
+celery==3.1.23
+cryptography==1.4
+Django==1.10
+django-bootstrap3==7.0.1
+django-crispy-forms==1.6.0
+django-filter==0.14.0
+django-registration==2.1.2
+djangorestframework==3.4.6
+gunicorn==19.6.0
+jira==1.0.7
+jsonpickle==0.9.3
+oauth2==1.9.0.post1
+oauthlib==1.1.2
+pika==0.10.0
+psycopg2==2.6.2
+PyJWT==1.4.2
+requests==2.11.0