Browse Source

initial v0.1

aguryev 3 years ago
commit
334eb052b2
73 changed files with 6243 additions and 0 deletions
  1. 28 0
      .gitignore
  2. 55 0
      Readme.md
  3. 15 0
      api/.dockerignore
  4. 193 0
      api/DataBaseORM.py
  5. 93 0
      api/Dockerfile
  6. 120 0
      api/Readme.md
  7. 1 0
      api/app.py
  8. 12 0
      api/app/__init__.py
  9. 119 0
      api/app/api.py
  10. 14 0
      api/app/models.py
  11. 39 0
      api/app/tasks.py
  12. 9 0
      api/app/uploads/globals.json
  13. 9 0
      api/app/uploads/globals_headless.json
  14. 18 0
      api/app/utils.py
  15. 31 0
      api/config.py
  16. 6 0
      api/requirements.txt
  17. 3 0
      api/runservice.sh
  18. BIN
      baangt_services.png
  19. 4 0
      files/.dockerignore
  20. 25 0
      files/Dockerfile
  21. 87 0
      files/Readme.md
  22. 1 0
      files/app.py
  23. 9 0
      files/app/__init__.py
  24. 12 0
      files/app/forms.py
  25. 73 0
      files/app/routes.py
  26. 11 0
      files/app/static/js/datafile.js
  27. BIN
      files/app/static/media/favicon.ico
  28. 52 0
      files/app/templates/base.html
  29. 54 0
      files/app/templates/upload.html
  30. 13 0
      files/config.py
  31. 2 0
      files/requirements.txt
  32. 4 0
      files/runservice.sh
  33. 21 0
      files/set_uploads.py
  34. 107 0
      run_services_in_docker.sh
  35. 4 0
      ui/.dockerignore
  36. 36 0
      ui/Dockerfile
  37. 46 0
      ui/Readme.md
  38. 1 0
      ui/app.py
  39. 17 0
      ui/app/__init__.py
  40. 37 0
      ui/app/filters.py
  41. 151 0
      ui/app/forms.py
  42. 531 0
      ui/app/models.py
  43. 85 0
      ui/app/static/css/styles.css
  44. 332 0
      ui/app/static/js/testrun.js
  45. BIN
      ui/app/static/media/favicon.ico
  46. 60 0
      ui/app/templates/base.html
  47. 17 0
      ui/app/templates/includes/flash.html
  48. 145 0
      ui/app/templates/testrun/create_item.html
  49. 151 0
      ui/app/templates/testrun/edit_item.html
  50. 36 0
      ui/app/templates/testrun/index.html
  51. 162 0
      ui/app/templates/testrun/item.html
  52. 470 0
      ui/app/templates/testrun/item_list.html
  53. 38 0
      ui/app/templates/testrun/login.html
  54. 43 0
      ui/app/templates/testrun/signup.html
  55. 120 0
      ui/app/templates/testrun/testrun_results.html
  56. 51 0
      ui/app/templates/testrun/testrun_status.html
  57. 1065 0
      ui/app/utils.py
  58. 605 0
      ui/app/views.py
  59. 36 0
      ui/config.py
  60. 41 0
      ui/db_defaults.py
  61. 48 0
      ui/defaults.json
  62. 263 0
      ui/docs/database.md
  63. BIN
      ui/examples/DropsTestExample.xlsx
  64. BIN
      ui/examples/DropsTestRunDefinition.xlsx
  65. BIN
      ui/examples/google_Images.xlsx
  66. 1 0
      ui/migrations/README
  67. 45 0
      ui/migrations/alembic.ini
  68. 96 0
      ui/migrations/env.py
  69. 24 0
      ui/migrations/script.py.mako
  70. 231 0
      ui/migrations/versions/154424b7dd12_.py
  71. 9 0
      ui/requirements.txt
  72. 5 0
      ui/runservice.sh
  73. 1 0
      ui/testrun.json

+ 28 - 0
.gitignore

@@ -0,0 +1,28 @@
+/.idea
+/examples
+venv
+logs
+Logs
+Screenshots
+baangt.ini
+__pycache__
+static/files
+browserDrivers
+browsermob-proxy
+0TestInput
+1Testresults
+1TestResults
+TestDownloads
+uploads/*.xlsx
+files/app/uploads/*
+ui/data
+ui/populate_db.py
+ui/app/static/files*
+api/jsons
+parent
+run_docker_mysql.sh
+run_docker_postgres.sh
+
+*.pyc
+*.db
+*.log

+ 55 - 0
Readme.md

@@ -0,0 +1,55 @@
+BAANGT Web-Services
+===================
+**BAANGT Web-Services** provide a simple way to define and run tests within *BAANGT* test suit.
+
+
+Components
+----------
+[**BAANGT UI Web-Service**](https://gogs.earthsquad.global/athos/baangt-service/src/master/ui) — Web UI providing functionality for definition of the *BAANGT* tests including import and export features
+
+[**BAANGT Execution API**](https://gogs.earthsquad.global/athos/baangt-service/src/master/api) — Service endpoints for running and retrieving results of the *BAANGT* tests
+
+[**BAANGT Data Files**](https://gogs.earthsquad.global/athos/baangt-service/src/master/files) — File storage service to handles *BAANGT* Data Files
+
+Design
+------
+![BAANGT Services](baangt_services.png "BAANGT Services")
+
+Run on Docker Containers
+------------------------
+**BAANGT Services** are available in docker containers. Just execute
+
+	./run_services_in_docker.sh
+
+Script `run_services_in_docker.sh` builds 3 *BAANGT* images:
+- `baangt-file:latest` (BAANGT Data Files)
+- `baangt-api:latest` (BAANGT Execution API)
+- `baangt-ui:latest` (BAANGT UI Web-Service)
+
+and runs 6 docker containers on the default ports:
+- `postgres` (BAANGT database on port 5430)
+- `redis` (Redis server on port 6380)
+- `rq-worker` (Redis Queue worker)
+- `baangt-file` (BAANGT Data Files on port 5050)
+- `baangt-api` (BAANGT Execution API on port 5000)
+- `baangt-ui` (BAANGT UI Web-Service on port 80)
+
+The script accepts the following optional arguments:
+
+| Argument    | Description
+|-------------|--------------
+| -ui {PORT}  | customize running port for `baangt-ui` container
+| -api {PORT} | customize running port for `baangt-api` container
+| -df {PORT}  | customize running port for `baangt-file` container
+| -rd {PORT}  | customize running port for `redis` container
+| -ps {PORT}  | customize running port for `postgres` container
+| -b          | skip building docker images
+| stop        | stop all six BAANGT containers
+
+For example, to use existing *BAANGT* images and run *BAANGT UI Web-Service* on port 8080 use:
+
+	./run_services_in_docker.sh -b -ui 8080
+
+To stop all *BAANGT* containers use:
+
+	./run_services_in_docker.sh stop

+ 15 - 0
api/.dockerignore

@@ -0,0 +1,15 @@
+uploads/*.xlsx
+venv
+logs
+browsermob-proxy
+browserDrivers
+0TestInput
+1Testresults
+1Testresults
+Logs
+Screenshots
+jsons
+__pycache__
+*.log
+*.db
+*.pyc

+ 193 - 0
api/DataBaseORM.py

@@ -0,0 +1,193 @@
+from sqlalchemy import Column, String, Integer, DateTime, Boolean, Table, ForeignKey, LargeBinary
+#from sqlalchemy.types import Binary, TypeDecorator
+from sqlalchemy.orm import relationship
+from sqlalchemy import create_engine
+from sqlalchemy.ext.declarative import declarative_base
+import os
+import uuid
+from baangt.base.PathManagement import ManagedPaths
+
+
+managedPaths = ManagedPaths()
+DATABASE_URL = os.getenv('DATABASE_URL') or 'sqlite:///'+str(managedPaths.derivePathForOSAndInstallationOption().joinpath('testrun.db'))
+
+engine = create_engine(DATABASE_URL)
+
+base = declarative_base()
+
+#
+# UUID as bytes
+#
+def uuidAsBytes():
+	return uuid.uuid4().bytes
+
+#
+# Testrun models
+#
+
+class TestrunLog(base):
+	#
+	# summary on Testrun results
+	#
+	__tablename__ = "testruns"
+	# columns
+	id = Column(LargeBinary, primary_key=True, default=uuidAsBytes)
+	testrunName = Column(String, nullable=False)
+	logfileName = Column(String, nullable=False)
+	startTime = Column(DateTime, nullable=False)
+	endTime = Column(DateTime, nullable=False)
+	dataFile = Column(String, nullable=True)
+	statusOk = Column(Integer, nullable=False)
+	statusFailed = Column(Integer, nullable=False)
+	statusPaused = Column(Integer, nullable=False)
+	# relationships
+	globalVars = relationship('GlobalAttribute')
+	testcase_sequences = relationship('TestCaseSequenceLog')
+
+	def __str__(self):
+		return str(uuid.UUID(bytes=self.id))
+
+	def to_json(self):
+		return {
+			'id': str(self),
+			'Name': self.testrunName,
+			'Summary': {
+				'TestRecords': sum((self.statusOk, self.statusPaused, self.statusFailed)),
+				'Successful': self.statusOk,
+				'Paused': self.statusPaused,
+				'Error': self.statusFailed,
+				'LogFile': self.logfileName,
+				'StartTime': self.startTime.strftime('%H:%M:%S'),
+				'EndTime': self.endTime.strftime('%H:%M:%S'),
+				'Duration': str(self.endTime - self.startTime),
+			},
+			'GlobalSettings': {gv.name: gv.value for gv in self.globalVars},
+			'TestSequences': [tsq.to_json() for tsq in self.testcase_sequences],
+		}
+
+
+
+class GlobalAttribute(base):
+	#
+	# global vars
+	#
+	__tablename__ = 'globals'
+	# columns
+	id = Column(Integer, primary_key=True)
+	name = Column(String, nullable=False)
+	value = Column(String, nullable=True)
+	testrun_id = Column(LargeBinary, ForeignKey('testruns.id'), nullable=False)
+	# relationships
+	testrun = relationship('TestrunLog', foreign_keys=[testrun_id])
+
+
+#
+# Test Case Sequence models
+#
+
+class TestCaseSequenceLog(base):
+	#
+	# TestCase Sequence
+	#
+
+	__tablename__ = 'testCaseSequences'
+	# columns
+	id = Column(LargeBinary, primary_key=True, default=uuidAsBytes)
+	testrun_id = Column(LargeBinary, ForeignKey('testruns.id'), nullable=False)
+	# relationships
+	testrun = relationship('TestrunLog', foreign_keys=[testrun_id])
+	testcases = relationship('TestCaseLog')
+
+	def __str__(self):
+		return str(uuid.UUID(bytes=self.id))
+
+	def to_json(self):
+		return {
+			'id': str(self),
+			'TestCases': [tc.to_json() for tc in self.testcases],
+		}
+
+
+#
+# Test Case models
+#
+
+class TestCaseLog(base):
+	#
+	# TestCase results
+	#
+	__tablename__ = 'testCases'
+	# columns
+	id = Column(LargeBinary, primary_key=True, default=uuidAsBytes)
+	testcase_sequence_id = Column(LargeBinary, ForeignKey('testCaseSequences.id'), nullable=False)
+	# relationships
+	testcase_sequence = relationship('TestCaseSequenceLog', foreign_keys=[testcase_sequence_id])
+	fields = relationship('TestCaseField')
+	networkInfo = relationship('TestCaseNetworkInfo')
+
+	def __str__(self):
+		return str(uuid.UUID(bytes=self.id))
+
+	def to_json(self):
+		return {
+			'id': str(self),
+			'Parameters': {pr.name: pr.value for pr in self.fields},
+			'NetworkInfo': [nw.to_json() for nw in self.networkInfo],
+		}
+
+
+class TestCaseField(base):
+	#
+	# field for a TestCase results
+	#
+	__tablename__ = 'testCaseFields'
+	# columns
+	id = Column(Integer, primary_key=True)
+	name = Column(String, nullable=False)
+	value = Column(String, nullable=True)
+	testcase_id = Column(LargeBinary, ForeignKey('testCases.id'), nullable=False)
+	# relationships
+	testcase = relationship('TestCaseLog', foreign_keys=[testcase_id])
+
+class TestCaseNetworkInfo(base):
+	#
+	# network info for a TestCase
+	#
+	__tablename__ = 'networkInfo'
+	# columns
+	id = Column(Integer, primary_key=True)
+	browserName = Column(String, nullable=True)
+	status = Column(Integer, nullable=True)
+	method = Column(String, nullable=True)
+	url = Column(String, nullable=True)
+	contentType = Column(String, nullable=True)
+	contentSize = Column(Integer, nullable=True)
+	headers = Column(String, nullable=True)
+	params = Column(String, nullable=True)
+	response = Column(String, nullable=True)
+	startDateTime = Column(DateTime, nullable=True)
+	duration = Column(Integer, nullable=True)
+	testcase_id = Column(LargeBinary, ForeignKey('testCases.id'), nullable=True)
+	# relationships
+	testcase = relationship('TestCaseLog', foreign_keys=[testcase_id])
+
+	def to_json(self):
+		return {
+			'BrowserName': self.browserName,
+			'Status': self.status,
+			'Method': self.method,
+			'URL': self.url,
+			'ContentType': self.contentType,
+			'ContentSize': self.contentSize,
+			'Headers': self.headers,
+			'Params': self.params,
+			'Response': '',#self.response,
+			'StartDateTime': str(self.startDateTime),
+			'Duration': self.duration,
+		}
+
+
+
+# create tables
+#if __name__ == '__main__':
+base.metadata.create_all(engine)

+ 93 - 0
api/Dockerfile

@@ -0,0 +1,93 @@
+FROM ubuntu:bionic
+
+LABEL Testrun API
+
+ENV PYTHONDONTWRITEBYTECODE 1
+ENV FLASK_APP "app.py"
+ENV FLASK_DEBUG True
+ENV LANG C.UTF-8
+ENV LC_ALL C.UTF-8
+
+RUN apt-get update && apt-get install -y \
+    python3 python3-pip \
+    fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 \
+    libnspr4 libnss3 lsb-release xdg-utils libxss1 libdbus-glib-1-2 libgbm1 \
+    curl unzip wget \
+    xvfb
+
+# install geckodriver
+
+RUN GECKODRIVER_VERSION=`curl https://github.com/mozilla/geckodriver/releases/latest | grep -Po 'v[0-9]+.[0-9]+.[0-9]+'` && \
+    wget https://github.com/mozilla/geckodriver/releases/download/$GECKODRIVER_VERSION/geckodriver-$GECKODRIVER_VERSION-linux64.tar.gz && \
+    tar -zxf geckodriver-$GECKODRIVER_VERSION-linux64.tar.gz -C /usr/local/bin && \
+    chmod +x /usr/local/bin/geckodriver && \
+    rm geckodriver-$GECKODRIVER_VERSION-linux64.tar.gz
+
+
+# install firefox and chrome
+
+RUN FIREFOX_SETUP=firefox-setup.tar.bz2 && \
+    apt-get purge firefox && \
+    wget -O $FIREFOX_SETUP "https://download.mozilla.org/?product=firefox-latest&os=linux64" && \
+    tar xjf $FIREFOX_SETUP -C /opt/ && \
+    ln -s /opt/firefox/firefox /usr/bin/firefox && \
+    rm $FIREFOX_SETUP
+
+RUN CHROME_SETUP=google-chrome.deb && \
+    wget -O $CHROME_SETUP "https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb" && \
+    dpkg -i $CHROME_SETUP && \
+    apt-get install -y -f && \
+    rm $CHROME_SETUP
+
+
+# install browsermob proxy
+RUN BROWSERMOB_SETUP=browsermob-proxy.zip && \
+    wget -O $BROWSERMOB_SETUP "https://github.com/lightbody/browsermob-proxy/releases/download/browsermob-proxy-2.1.4/browsermob-proxy-2.1.4-bin.zip" && \
+    unzip $BROWSERMOB_SETUP -d /opt/ && \
+    rm $BROWSERMOB_SETUP
+
+# install java: needed by browsermob
+RUN apt-get install -y openjdk-8-jre
+
+# install virtual env
+RUN apt update
+RUN apt-get install -y python3.6-venv
+RUN apt-get install -y libpq-dev
+
+# create working directory
+RUN mkdir /baangt
+WORKDIR /baangt
+
+COPY requirements.txt requirements.txt
+#RUN python3 -m venv venv
+#RUN venv/bin/pip install --upgrade pip
+#RUN venv/bin/pip install -r requirements.txt
+#RUN venv/bin/pip install gunicorn
+#RUN venv/bin/pip install pymysql cryptography psycopg2
+
+RUN pip3 install gunicorn
+RUN pip3 install pymysql cryptography psycopg2
+RUN pip3 install pyqt5==5.14
+RUN pip3 install -r requirements.txt
+
+# set up baangt environment
+RUN ln -s /opt/browsermob-proxy-2.1.4 /baangt/browsermob-proxy
+
+RUN mkdir /baangt/browserDrivers && \
+    ln -s /usr/local/bin/geckodriver /baangt/browserDrivers/geckodriver
+
+#COPY requirements.txt requirements.txt
+
+#RUN pip3 install -r requirements.txt
+
+ADD . /baangt
+
+RUN pip3 install dataclasses dataclasses_json
+COPY DataBaseORM.py /usr/local/lib/python3.6/dist-packages/baangt/base/DataBaseORM.py
+
+RUN chmod +x runservice.sh
+
+EXPOSE 5000
+ENTRYPOINT ["./runservice.sh"]
+
+#CMD flask run --host=0.0.0.0

+ 120 - 0
api/Readme.md

@@ -0,0 +1,120 @@
+BAANGT Execution API
+====================
+**BAANGT Execution API** provides service endpoints for executing *BAANGT* tests. The main features of the service are:
+- running the tests
+- storing test results to a database
+- retrieving the results of the finished tests
+
+Environmental Variables
+-----------------------
+You may use the following environmental variables to configure the **BAANGT Execution API**:
+
+| Variable | Default Value | Description
+|----------|---------------|-------------
+| SECRET_KEY | secret!key | The secret key of the app
+| DATABASE_URL | sqlite:///testrun.db | Database URL for *BAANGT* test results
+| BAANGT_DATAFILE_HOST | 127.0.0.1:5050 | The host that runs [*BAANGT Data Files*](https://gogs.earthsquad.global/athos/baangt-service/src/master/files)
+| REDIS_URL | redis:// | The Redis connection URL
+
+
+Redis Worker
+------------
+**BAANGT Execution API** implements *BAANGT* test runs as the background tasks via Redis. So, firstly you need a running Redis server. The service uses the Redis Queue (RQ) worker named `baangt-tasks` that based on the same code to run the *BAANGT* tests. At least one `baangt-tasks` RQ worker should be running to support *BAANGT* Execution API service. Be sure to install requirements and setup environmental vars before starting the RQ workers.
+
+```bash
+pip install -r requirements.txt
+export DATABASE_URL=[baangt_results_database_url]
+export REDIS_URL=[your_redis_url]
+rq worker baangt-tasks
+``` 
+
+Set-up
+------
+Before starting the **BAANGT Execution API** application you need just install the requirements from `requirements.txt`
+
+```bash
+pip install -r requirements.txt
+```
+
+Database
+--------
+**BAANGT Execution API** service interacts with *BAANGT* [Database](https://baangt.readthedocs.io/en/latest/SaveResults2Database.html)
+
+Run Service
+-----------
+Please use a Python WSGI Server to run the *BAANGT* Execution API application.  
+The **application name** is `app`  
+For example, to run the service with *gunicorn* use:
+
+```bash
+gunicorn -b :8000 --access-logfile access.log --error-logfile error.log app:app
+```
+
+
+API Reference
+-------------
+#### Base URL
+```url
+http://[HOST_NAME]/v0.1
+```
+
+#### Run Tests from XLSX
+	POST [BASE_URL]/run/xlsx/
+
+##### Requested files:
+	testrun: an XLSX file containing a Testrun definition
+
+##### Response:
+```json
+STATUS CODE: 202 Accepted
+JSON: 
+{
+  "id": "[TESTRUN_UUID]"
+}
+```
+------------------------
+
+#### Run Tests from JSON
+	POST [BASE_URL]/json
+
+##### Requested JSON:
+```json
+{
+  "TESTRUN_NAME": {"TESTRUN_DEFINITION_OBJECT"}
+}
+```
+##### Response:
+```json
+STATUS CODE: 202 Accepted
+JSON: 
+{
+  "id": "[TESTRUN_UUID]"
+}
+```
+-----------------------
+
+### Get Testrun results as JSON
+	GET [BASE_URL]/results/json/[UUID]
+UUID is fetched Testrun UUID as a string
+
+##### Response:
+Testrun is in progress:
+```json
+STATUS CODE: 202 Accepted
+JSON: 
+{
+  "id": "[TESTRUN_UUID]"
+}
+``` 
+Testrun is finished:
+```json
+STATUS CODE: 200 OK
+JSON: 
+{
+  "id": "[TESTRUN_UUID]",
+  "Name": "[TESTRUN_NAME]",
+  "Summary": {"[TESTRUN_SUMMARY_OBJECT]"},
+  "GlobalSettings": {"[TESTRUN_GLOBALS_OBJECT]"},
+  "TestSequences": {"[TEST_SEQUANCES_OBJECT]"}
+}
+```

+ 1 - 0
api/app.py

@@ -0,0 +1 @@
+from app import app

+ 12 - 0
api/app/__init__.py

@@ -0,0 +1,12 @@
+from flask import Flask
+from config import Config
+from redis import Redis
+import rq
+
+app = Flask(__name__)
+app.config.from_object(Config)
+
+app.redis = Redis.from_url(app.config['REDIS_URL'])
+app.task_queue = rq.Queue('baangt-tasks', connection=app.redis)
+
+from app import api, models

+ 119 - 0
api/app/api.py

@@ -0,0 +1,119 @@
+import os
+import requests
+from flask import jsonify, request, url_for, send_from_directory
+from werkzeug.utils import secure_filename
+from app import app, utils
+#from app.models import init_db, TestRunTask
+import uuid
+from rq.job import Job
+#from baangt.base.DataBaseORM import TestrunLog, DATABASE_URL
+from baangt.base.DataBaseORM import engine, TestrunLog
+from sqlalchemy.orm import sessionmaker
+
+#db_session = init_db()
+
+@app.route('/')
+def index():
+	return 'Welcome to baangt API service'
+
+@app.route(f'/{app.config["API_BASE"]}/run/<string:input_format>', methods=['POST'])
+def run_xlsx(input_format):
+	#
+	# runs Testrun defined in posted json or xlsx file 'testrun'
+	#
+
+	# validate input format
+	if not input_format in ['xlsx', 'json']:
+		app.logger.error(f'Test Run: unsupported input format requested: {input_format}')
+		return jsonify({'error': 'Invalid input format.'}), 400
+
+	# default json Testrun definition 
+	dictTestRun = None
+
+	# process JSON input
+	if input_format == 'json':
+		app.logger.info('Test Run: processing JSON input')
+		data = request.get_json()
+		if data is None:
+			app.logger.error('The request does not contain JSON TestRun')
+			return jsonify({'error': 'The request does not contain JSON TestRun.'}), 400
+		name = next(iter(data))
+		nameTestRun = utils.validateTestrunName(name)
+		dictTestRun = data[name]
+
+	# proces XLSX input
+	if input_format == 'xlsx':
+		app.logger.info('Test Run: processing XLSX input')
+		try:
+			xlsx = request.files.get('testrun')
+			# get filename and save file
+			filename = secure_filename(xlsx.filename)
+			nameTestRun = os.path.join(os.path.abspath(os.path.dirname(__file__)), app.config['UPLOAD_FOLDER'], filename)
+			xlsx.save(nameTestRun)
+		except Exception as e:
+			app.logger.error(f'Unable to process input: {e}')
+			return jsonify({'error': f'{e}'}), 400		
+
+	# set path to globals
+	path_to_globals = os.path.join(os.path.abspath(os.path.dirname(__file__)), app.config['UPLOAD_FOLDER'], 'globals.json')
+	# generate UUID
+	testRunUUID = uuid.uuid4()
+	# queue job
+	job = app.task_queue.enqueue(
+		'app.tasks.run_testrun',
+		testRunName=nameTestRun,
+		globalSettings=path_to_globals,
+		testRunDict=dictTestRun,
+		testRunId=testRunUUID,
+		job_id=str(testRunUUID),
+	)
+	app.logger.info(f'Test Run queued: {testRunUUID}')
+
+	return jsonify({'id': str(job.get_id())}), 202
+
+
+@app.route(f'/{app.config["API_BASE"]}/results/json/<string:uuid_str>')
+def get_testrun(uuid_str):
+	#
+	# returns results of the specified Testrun as JSON
+	#
+
+	app.logger.info(f'Testrun results requested: {uuid_str}')
+	jsonResponse = {'id': uuid_str}
+
+	# get TestrunLog from DB
+	session = sessionmaker(bind=engine)()
+	log = session.query(TestrunLog).get(uuid.UUID(uuid_str).bytes)
+
+	if log:
+		# Tetsrun completed and saved to DB
+		jsonResponse.update(log.to_json())
+		app.logger.info('Testrun results successfully fetched')
+		return jsonify(jsonResponse), 200
+
+	# get task status
+	try:
+		job = Job.fetch(uuid_str, connection=app.redis)
+		app.logger.info(f'Testrun job status: {job.get_status()}')
+		if job.get_status() == 'finished':
+			jsonResponse['error'] = 'Failed to save results.'
+			return jsonify(jsonResponse), 400
+		if job.get_status() == 'failed':
+			app.logger.error(f'Failed to finish Testrun: {job.exc_info}')
+			jsonResponse['error'] = f'Failed to finish Testrun.'
+			return jsonify(jsonResponse), 400	
+	except Exception as e:
+		app.json.error(f'e')
+		jsonResponse['error'] = 'Testrun does not exist.'
+		return jsonify(jsonResponse), 404
+
+	return jsonify(jsonResponse), 202
+	
+
+@app.route(f'/{app.config["UPLOAD_FOLDER"]}/<string:filename>')
+def uploads(filename):
+	#
+	# returns files with the results
+	#
+
+	return send_from_directory(app.config["UPLOAD_FOLDER"], filename)

+ 14 - 0
api/app/models.py

@@ -0,0 +1,14 @@
+from sqlalchemy import Column, LargeBinary, String
+from baangt.base.DataBaseORM import base, engine
+from sqlalchemy.orm import sessionmaker
+
+
+# declare models
+class TestRunTask(base):
+	__tablename__ = "testrunTasks"
+	id = Column(LargeBinary, primary_key=True)
+	testrun_id = Column(LargeBinary, nullable=False)
+
+def init_db():
+	base.metadata.create_all(engine)
+	return sessionmaker(bind=engine)()

+ 39 - 0
api/app/tasks.py

@@ -0,0 +1,39 @@
+from baangt.base.TestRun.TestRun import TestRun
+from . import app
+#from flask import current_app
+import requests
+import os
+
+app.app_context().push()
+
+def run_testrun(testRunName, globalSettings, testRunDict, testRunId):
+	#
+	# Testrun execution task
+	#
+
+	# get DataFiles
+	if testRunDict:
+		for key, value in testRunDict['GC.KWARGS_TESTRUNATTRIBUTES']['GC.STRUCTURE_TESTCASESEQUENCE'].items():
+			dataFileId = value[1]['GC.DATABASE_FILENAME']
+			url = '/'.join((
+				'http:/',
+				app.config.get('BAANGT_DATAFILE_HOST'),
+				app.config.get('BAANGT_DATAFILE_GET'),
+				dataFileId,
+			))
+		
+			r = requests.get(url, stream=True)
+			if r.status_code == 200:
+				with open(os.path.join(os.path.abspath(os.path.dirname(__file__)), app.config['UPLOAD_FOLDER'], dataFileId), 'wb') as f:
+					for chunck in r:
+						f.write(chunck)
+			else:
+				raise Exception(f'Data File ID {dataFileId} does not exist.')
+
+	TestRun(
+		testRunName=testRunName,
+		globalSettingsFileNameAndPath=globalSettings,
+		testRunDict=testRunDict,
+		uuid=testRunId,
+	)
+

+ 9 - 0
api/app/uploads/globals.json

@@ -0,0 +1,9 @@
+{
+    "exportFilesBasePath": "",
+    "TC.Lines": "",
+    "TC.dontCloseBrowser": "True",
+    "TC.slowExecution": "True",
+    "TC.NetworkInfo": "True",
+    "TC.BrowserAttributes": {"HEADLESS": "True"},
+    "TX.DEBUG": "True"
+}

+ 9 - 0
api/app/uploads/globals_headless.json

@@ -0,0 +1,9 @@
+{
+    "exportFilesBasePath": "",
+    "TC.Lines": "",
+    "TC.dontCloseBrowser": "True",
+    "TC.BrowserAttributes": {"HEADLESS": "True"},
+    "TC.slowExecution": "True",
+    "TC.NetworkInfo": "True",
+    "TX.DEBUG": "True"
+}

+ 18 - 0
api/app/utils.py

@@ -0,0 +1,18 @@
+from app import app
+import re
+#from baangt.base.DataBaseORM import TestrunLog
+#from app.api import db_session
+
+# check if file is allowed
+def isFileAllowed(filename):
+	return filename.split('.')[-1].upper() in app.config['ALLOWED_EXTENSIONS']
+
+def validateTestrunName(name):
+	#
+	# replaces 'XLSX' and 'JSON' strings in name for proper TestRun execution
+	#
+	validatedName = re.sub(r'[xX][lL][sS][xX]', '_x_l_s_x_', name)
+	validatedName = re.sub(r'[jJ][sS][oO][nN]', '_j_s_o_n_', validatedName)
+	return validatedName
+
+

+ 31 - 0
api/config.py

@@ -0,0 +1,31 @@
+import os
+
+
+#
+# configuration settings
+#
+
+class Config(object):
+	# flask secret key
+	SECRET_KEY = os.getenv('SECRET_KEY') or 'secret!key'
+
+	# json format
+	JSON_SORT_KEYS = False
+
+	# upload files
+	UPLOAD_FOLDER = 'uploads'
+	ALLOWED_EXTENSIONS = {'XLSX', 'JSON'}
+
+	#
+	# baangt DataFile service
+	#
+	BAANGT_DATAFILE_HOST = os.getenv('BAANGT_DATAFILE_HOST') or '127.0.0.1:5050'
+	BAANGT_DATAFILE_GET = 'get'
+
+	# REDIS
+	REDIS_URL = os.environ.get('REDIS_URL') or 'redis://'
+
+	# API URL
+	API_BASE = 'v0.1'
+
+	

+ 6 - 0
api/requirements.txt

@@ -0,0 +1,6 @@
+flask>=1.1.1
+rq>=1.3.0
+gevent>=1.4.0
+browsermob-proxy>=0.8.0
+faker>=4.0.3
+baangt>=2020.4.7rc4

+ 3 - 0
api/runservice.sh

@@ -0,0 +1,3 @@
+#!/bin/sh
+echo source venv/bin/activate
+exec gunicorn -b :5000 --access-logfile - --error-logfile - app:app

BIN
baangt_services.png


+ 4 - 0
files/.dockerignore

@@ -0,0 +1,4 @@
+app/uploads
+**/__pycache__
+**/*.pyc
+venv

+ 25 - 0
files/Dockerfile

@@ -0,0 +1,25 @@
+FROM python:3.8.2-alpine
+
+LABEL BAANGT File Service
+
+RUN adduser -D baangt
+
+WORKDIR /home/baangt
+
+COPY requirements.txt requirements.txt
+RUN python -m venv venv
+RUN venv/bin/pip install -r requirements.txt
+RUN venv/bin/pip install gunicorn
+
+ADD . /home/baangt
+
+RUN chmod +x runservice.sh
+RUN chown -R baangt:baangt ./
+
+ENV FLASK_APP "app.py"
+ENV FLASK_DEBUG True
+
+USER baangt
+
+EXPOSE 5000
+ENTRYPOINT ["./runservice.sh"]

+ 87 - 0
files/Readme.md

@@ -0,0 +1,87 @@
+BAANGT Data Files
+=================
+**BAANGT Data Files** is intended to store, renew, and retrieve *BAANGT* data files. The service could be achieved via HTTP requests. It provides the following end-points:
+- a *home* view with a form for uploading a *DataFile*
+- several [API](#api-reference) end-points  
+
+Environmental Variables
+-----------------------
+You may use the following environmental variables to configure the **BAANGT Data Files**:
+
+| Variable | Default Value | Description
+|----------|---------------|-------------
+| SECRET_KEY | secret!key | The secret key of the app
+| UPLOAD_FOLDER | uploads | Directory within the app to store the DataFiles 
+
+
+Set-up
+------
+Before starting the **BAANGT Data Files** application you need to:
+- install the required python packages from `requirements.txt`
+- define *UPLOAD_FOLDER*
+- run `set_uploads.py` to create a directory that will be a file storage for *BAANGT* DataFiles  
+
+```bash
+pip install -r requirements.txt
+export UPLOAD_FOLDER=[path_to_uploads_in_app]
+python set_uploads.py
+``` 
+
+Run Service
+-----------
+Please use a Python WSGI Server to run the *BAANGT* Data Files application.  
+The **application name** is `app`  
+For example, to run the service with *gunicorn* use:
+
+```bash
+gunicorn -b :8000 --access-logfile access.log --error-logfile error.log app:app
+```
+
+API Reference
+-------------
+#### Store a new Data File
+	POST http://[HOST]/save
+
+##### Requested files
+	dataFile: XLSX file comprising *BAANGT* test data
+
+##### Response
+```json
+STATUS CODE: 200 OK
+JSON: 
+{
+  "uuid": "FILE_UUID"
+}
+```
+---
+
+#### Renew a Data File
+	POST http://[HOST]/update/{uuid}
+
+##### URI parameters
+	uuid: UUID of the Data File to be renewed
+
+##### Requested files
+	dataFile: XLSX file comprising *BAANGT* test data
+
+##### Response
+```json
+STATUS CODE: 200 OK
+JSON: 
+{
+  "uuid": "FILE_UUID"
+}
+```
+---
+
+#### Retrieve a Data File
+	GET http://[HOST]/get/{uuid}
+
+##### URI parameters
+	uuid: UUID of the Data File to be retrieved
+
+##### Response
+```
+STATUS CODE: 200 OK
+RAW: requested Data File
+```

+ 1 - 0
files/app.py

@@ -0,0 +1 @@
+from app import app

+ 9 - 0
files/app/__init__.py

@@ -0,0 +1,9 @@
+from flask import Flask
+from config import Config
+
+
+app = Flask(__name__)
+app.config.from_object(Config)
+
+
+from app import routes

+ 12 - 0
files/app/forms.py

@@ -0,0 +1,12 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import FileField
+from wtforms.validators import ValidationError, DataRequired
+import app
+
+class UploadForm(FlaskForm):
+	file = FileField('Select Data File', validators=[DataRequired(message='Data File is mandatory')])
+
+	def validate_extension(self, file):
+		if '.' in file.filename and file.filename.split('.')[-1].upper() in app.config.get('ALLOWED_EXTENSIONS'):
+			return
+		raise ValidationError('Only XLSX files allowed')

+ 73 - 0
files/app/routes.py

@@ -0,0 +1,73 @@
+from flask import request, render_template, send_from_directory, jsonify
+from app import app, forms
+from uuid import uuid4
+import os
+
+
+@app.route('/', methods=['GET', 'POST'])
+def index():
+	#
+	# web view to upload data files
+	#
+	
+	form = forms.UploadForm()
+	
+	# get validated form
+	if form.validate_on_submit():
+		# save file
+		uuid = str(uuid4())
+		form.file.data.save(os.path.join(os.path.dirname(__file__), app.config['UPLOAD_FOLDER'], uuid))
+		app.logger.info(f'Uploaded file {uuid}')
+
+		return render_template('upload.html', form=form, uuid=uuid, filename=form.file.data.filename)
+
+	return render_template('upload.html', form=form)
+
+@app.route('/save', methods=['POST'])
+def save_file():
+	#
+	# save uploaded file and return corresponding UUID
+	#
+
+	# check for file in request
+	file = request.files.get('dataFile')
+
+	if file is None:
+		app.logger.error('Request does not contain dataFile')
+		return jsonify({'error': 'Request does not contain dataFile'}), 400
+
+	# save file
+	uuid = str(uuid4())
+	file.save(os.path.join(os.path.dirname(__file__), app.config['UPLOAD_FOLDER'], uuid))
+	app.logger.info(f'Uploaded file {uuid}')
+
+	return jsonify({'uuid': uuid}), 200
+
+@app.route('/update/<string:uuid>', methods=['POST'])
+def update_file(uuid):
+	#
+	# update UUID file by uploaded one and return corresponding UUID
+	#
+
+	# check for file in request
+	file = request.files.get('dataFile')
+
+	if file is None:
+		app.logger.error('Request does not contain dataFile')
+		return jsonify({'error': 'Request does not contain dataFile'}), 400
+
+	# update file
+	file.save(os.path.join(os.path.dirname(__file__), app.config['UPLOAD_FOLDER'], uuid))
+	app.logger.info(f'Updated file {uuid}')
+
+	return jsonify({'uuid': uuid}), 200
+
+
+@app.route('/get/<string:uuid>')
+def get_file(uuid):
+	#
+	# retrieve a file by UUID
+	#
+
+	app.logger.info(f'Requested file {uuid}')
+	return send_from_directory(app.config['UPLOAD_FOLDER'], uuid)

+ 11 - 0
files/app/static/js/datafile.js

@@ -0,0 +1,11 @@
+
+document.addEventListener('DOMContentLoaded', () => {
+	/*
+	show file name on file select
+	*/
+	document.querySelector('#file').onchange = (e) => {
+		const label = e.target.parentElement.querySelector('label');
+		label.innerText = e.target.files[0].name;
+	};
+});
+

BIN
files/app/static/media/favicon.ico


+ 52 - 0
files/app/templates/base.html

@@ -0,0 +1,52 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <!-- Required meta tags -->
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+
+    <!-- Bootstrap CSS -->
+    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
+
+    <!-- Local Styles -->
+    <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
+
+    <!-- favicon -->
+    <link rel="shortcut icon" href="{{ url_for('static', filename='media/favicon.ico') }}">
+
+    <title>
+    	{% block title %}
+    	{% endblock %}
+    </title>
+  </head>
+  
+  <body>
+    <nav class="navbar navbar-expand navbar-dark bg-dark flex-md-nowrap p-1 shadow">
+        <a class="navbar-brand ml-3 py-2" href="/">
+            <img src="{{ url_for('static', filename='media/favicon.ico') }}" width="40" height="40" class="rounded" alt="">
+            DataFile Service
+        </a>
+        <ul class="navbar-nav ml-auto mr-3">            
+            <li class="nav-item">
+                <a class="nav-link" href="#">Login</a>
+            </li>
+            <li class="nav-item">
+                <a class="nav-link" href="#">Sign up</a>
+            </li>
+        </ul>
+    </nav>
+      
+    {% block content %}
+    {% endblock %}
+
+
+    <!-- Local JS -->
+    <script src="{{ url_for('static', filename='js/datafile.js') }}"></script>
+
+    <!-- Optional JavaScript -->
+    <!-- jQuery first, then Popper.js, then Bootstrap JS -->
+    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script>
+    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
+    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>
+  </body>
+</html>

+ 54 - 0
files/app/templates/upload.html

@@ -0,0 +1,54 @@
+{% extends "base.html" %}
+
+{% block title %}
+    Upload Data File
+{% endblock %}
+
+{% block content %}
+
+<!-- flash messages -->
+{% with messages = get_flashed_messages(with_categories=true) %}
+    {% for category, msg in messages %}
+        <div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
+            {{ msg }}
+            <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+                <span aria-hidden="true">&times;</span>
+            </button>
+        </div>
+    {% endfor %}
+{% endwith %}
+
+<div class="container mt-5">
+    <!-- success message -->
+    {% if uuid %}
+    <div class="card text-white bg-success mb-3">
+        <div class="card-header">Success</div>
+        <div class="card-body">
+            <h5 class="card-title">File <span class="font-italic">{{ filename }}</span> uploaded</h5>
+            <p class="card-text">UUID: {{ uuid }}</p>
+            <a class="btn btn-primary" href={{ url_for('get_file', uuid=uuid) }}>Download</a>
+        </div>
+    </div>
+    {% endif %}
+
+    <!-- uoload form -->
+    <form method="post" enctype="multipart/form-data">
+        {{ form.hidden_tag() }}
+        <div class="card d-flex justify-content-center mt-5">
+            <div class="card-body">
+                <h5 class="card-title">Upload Data File</h5>
+                <div class="input-group mb-3">
+                    <div class="custom-file">
+                        {{ form.file(class="custom-file-input", accept=".xlsx") }}
+                        <label class="custom-file-label" for="{{ form.file.name }}">Choose file</label>
+                    </div>
+                    <div class="input-group-append">
+                        <button type="submit" class="btn btn-primary">Upload</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </form>
+</div>
+
+{% endblock %}

+ 13 - 0
files/config.py

@@ -0,0 +1,13 @@
+import os
+
+#
+# configuration settings
+#
+
+class Config(object):
+	# flask secret key
+	SECRET_KEY = os.getenv('SECRET_KEY') or 'secret!key'
+
+	# upload settings
+	UPLOAD_FOLDER = os.getenv('UPLOAD_FOLDER') or 'uploads'
+	ALLOWED_EXTENSIONS = {'XLSX'}

+ 2 - 0
files/requirements.txt

@@ -0,0 +1,2 @@
+flask>=1.1.1
+flask_wtf>=0.14.2

+ 4 - 0
files/runservice.sh

@@ -0,0 +1,4 @@
+#!/bin/sh
+source venv/bin/activate
+python set_uploads.py
+exec gunicorn -b :5000 --access-logfile - --error-logfile - app:app

+ 21 - 0
files/set_uploads.py

@@ -0,0 +1,21 @@
+##################################################
+#                                                #
+#   Sets upload directory for BAANGT DataFiles   #
+#                                                #
+##################################################
+
+from app import app
+import pathlib
+import os
+
+if __name__ == '__main__':
+	#
+	# create upload folder
+	#
+
+	# get path
+	path = os.path.join('app', app.config['UPLOAD_FOLDER'])
+
+	print(f'Creating DataFile folder:\n{path}')
+	pathlib.Path(path).mkdir(parents=True, exist_ok=True)
+	print('OK')

+ 107 - 0
run_services_in_docker.sh

@@ -0,0 +1,107 @@
+#!/bin/bash
+
+# default ports
+#       UI    API    DF     RD     PS
+ports=("80" "5000" "5050" "6380" "5430")
+# default flag for bulding docker images 
+b=true
+
+# parse arguments
+args=("$@")
+
+i=0
+while [ $i -lt $# ]
+do
+	case ${args[$i]} in
+		"-ui")
+			ports[0]=${args[$i+1]}
+			;;
+		"-api")
+			ports[1]=${args[$i+1]}
+			;;
+		"-df")
+			ports[2]=${args[$i+1]}
+			;;
+		"-rd")
+			ports[3]=${args[$i+1]}
+			;;
+		"-ps")
+			ports[4]=${args[$i+1]}
+			;;
+		"-b")
+			b=false
+			let i--
+			;;
+		"stop")
+			echo
+			echo Stopping containers...
+			docker stop baangt-ui baangt-api baangt-file rq-worker redis postgres
+			exit 1
+			;;
+	esac
+	let i=$i+2
+done
+
+# building docker images
+if [ $b = true ]
+then
+	# UI Service
+	echo
+	echo Building UI Service...
+	cd ui
+	docker build -t baangt-ui:latest .
+	cd ..
+	# File Servise
+	echo
+	echo Building DataFile Service...
+	cd files
+	docker build -t baangt-file:latest .
+	cd ..
+	# API Service
+	echo
+	echo Building Execution API Service...
+	cd api
+	docker build -t baangt-api:latest .
+	cd ..
+fi
+
+# running containers
+# Redis
+echo
+echo Starting Redis...
+docker run -d -p ${ports[3]}:6379 --name redis --rm redis:5-alpine
+
+# RQ Worker
+echo
+echo Starting Redis Worker...
+docker run -d --name rq-worker  \
+    -e BAANGT_DATAFILE_HOST=172.17.0.1:${ports[2]} --rm \
+    -e DATABASE_URL=postgresql://baangt:12345@172.17.0.1:${ports[4]}/baangt \
+    -e REDIS_URL=redis://172.17.0.1:${ports[3]}/0 --entrypoint rq \
+    baangt-api:latest worker -u redis://172.17.0.1:${ports[3]}/0 baangt-tasks 
+
+# PostgreSQL
+echo
+echo Starting PostgreSQL...
+docker run -d -p ${ports[4]}:5432 --name postgres -e POSTGRES_PASSWORD=12345 \
+    -e POSTGRES_USER=baangt -e POSTGRES_DB=baangt --rm postgres:10.12
+    
+# Data Files
+echo
+echo Starting Data File Service...
+docker run -d -p ${ports[2]}:5000 --name baangt-file --rm baangt-file:latest
+
+# UI Service
+echo
+echo Starting UI Service...
+docker run -d -p ${ports[0]}:5000 --name baangt-ui --rm \
+    -e BAANGT_DATAFILE_HOST=172.17.0.1:${ports[2]} \
+    -e BAANGT_API_HOST=172.17.0.1:${ports[1]} baangt-ui:latest
+
+# API Service
+echo
+echo Starting Execution API Service...
+docker run -d -p ${ports[1]}:5000 --name baangt-api --rm \
+    -e BAANGT_DATAFILE_HOST=172.17.0.1:${ports[2]}  \
+    -e DATABASE_URL=postgresql://baangt:12345@172.17.0.1:${ports[4]}/baangt \
+    -e REDIS_URL=redis://172.17.0.1:${ports[3]}/0 baangt-api:latest

+ 4 - 0
ui/.dockerignore

@@ -0,0 +1,4 @@
+**/__pycache__
+**/*.pyc
+**/*.db
+venv

+ 36 - 0
ui/Dockerfile

@@ -0,0 +1,36 @@
+FROM python:3.8.2-alpine
+
+LABEL BAANGT UI Service
+
+RUN adduser -D baangt
+
+RUN apk add --no-cache \
+        libressl-dev \
+        musl-dev \
+        libffi-dev \
+        gcc musl-dev 
+RUN apk add postgresql-dev=9.6.10-r0 --repository=http://dl-cdn.alpinelinux.org/alpine/v3.5/main
+
+WORKDIR /home/baangt
+
+COPY requirements.txt requirements.txt
+RUN python -m venv venv
+RUN venv/bin/pip install --upgrade pip
+RUN venv/bin/pip install -r requirements.txt
+RUN venv/bin/pip install gunicorn 
+RUN venv/bin/pip install pymysql cryptography psycopg2
+
+ADD . /home/baangt
+RUN mkdir /home/baangt/app/uploads
+RUN mkdir /home/baangt/app/static/files
+
+RUN chmod +x runservice.sh
+RUN chown -R baangt:baangt ./
+
+ENV FLASK_APP "app.py"
+ENV FLASK_DEBUG True
+
+USER baangt
+
+EXPOSE 5000
+ENTRYPOINT ["./runservice.sh"]

+ 46 - 0
ui/Readme.md

@@ -0,0 +1,46 @@
+BAANGT UI Web-Service
+=====================
+**BAANGT UI Web-Service** provides Web GUI for definition *BAANGT* tests. The main features of the service:
+- creating test definitions using Web GUI
+- importing test definitions from local files (XLSX, JSON formats are supported)
+- exporting test definitions to XLSX or JSON files
+- storing test definitions in a database (see details on the database in [**Database Description**](https://gogs.earthsquad.global/athos/baangt-service/src/master/ui/docs/database.md))
+- running the defined tests via **[BAANGT Execution API](https://gogs.earthsquad.global/athos/baangt-service/src/master/api)**
+
+Environmental Variables
+-----------------------
+You may use the following environmental variables to configure the **BAANGT UI Web-Service**:
+
+| Variable | Default Value | Description
+|----------|---------------|-------------
+| SECRET_KEY | secret!key | The secret key of the app
+| DATABASE_URL | sqlite:///testrun.db | [*Database*](https://gogs.earthsquad.global/athos/baangt-service/src/master/ui/docs/database.md) URL
+| BAANGT_API_HOST | 127.0.0.1:8000 | The host that runs [*BAANGT Execution API*](https://gogs.earthsquad.global/athos/baangt-service/src/master/api)
+| BAANGT_DATAFILE_HOST | 127.0.0.1:5050 | The host that runs [*BAANGT Data Files*](https://gogs.earthsquad.global/athos/baangt-service/src/master/files)
+
+Set-up
+------
+Before starting the **BAANGT UI Web-Service** application you need to define *DATABASE_URL* and run
+Before starting the **BAANGT Data Files** application you need to:
+- install the required python packages from `requirements.txt`
+- define *DATABASE_URL* var
+- run `flask db upgrade` to create required by the *BAANGT UI Web-Service* data tables 
+- run `python db_defaults.py` to populate *BAANGT UI Web-Service* database with the default supporting instances
+
+```bash
+pip install -r requirements.txt
+export DATABASE_URL=[your_database_url]
+flask db upgrade
+python db_defaults.py
+``` 
+ 
+
+Run Service
+-----------
+Please use a Python WSGI Server to run the *BAANGT* UI Web-Service application.  
+The **application name** is `app`  
+For example, to run the service with *gunicorn* use:
+
+```bash
+gunicorn -b :8000 --access-logfile access.log --error-logfile error.log app:app
+```

+ 1 - 0
ui/app.py

@@ -0,0 +1 @@
+from app import app

+ 17 - 0
ui/app/__init__.py

@@ -0,0 +1,17 @@
+import os
+from flask import Flask
+from flask_login import LoginManager
+from flask_sqlalchemy import SQLAlchemy
+from flask_migrate import Migrate
+from config import Config
+
+app = Flask(__name__)
+app.config.from_object(Config)
+
+
+db = SQLAlchemy(app)
+migrate = Migrate(app, db)
+login_manager = LoginManager(app)
+login_manager.login_view = 'login'
+
+from app import views, models, filters

+ 37 - 0
ui/app/filters.py

@@ -0,0 +1,37 @@
+from datetime import datetime
+from . import app
+
+@app.template_filter('name_by_type')
+def item_name(item_type, plural=True):
+	#
+	# get name of the item_type
+	#
+	
+	# categories
+	if item_type == 'main':
+		name = 'Main Item'
+
+	# main items 
+	elif item_type == 'testrun':
+		name = 'Testrun'
+	elif item_type == 'testcase_sequence':
+		name = 'Test Case Sequence'
+	elif item_type == 'testcase':
+		name = 'Test Case'
+	elif item_type == 'teststep_sequence':
+		name = 'Test Step Sequence'
+	elif item_type == 'teststep':
+		name = 'Test Step'
+	else:
+		# wrong item_type
+		return ''
+
+	# check for plurals
+	if plural:
+		name += 's'
+
+	return name
+
+@app.template_filter('time')
+def format_time(time):
+	return time.strftime('%Y-%m-%d %H:%M')

+ 151 - 0
ui/app/forms.py

@@ -0,0 +1,151 @@
+from flask_wtf import FlaskForm
+from wtforms.fields import StringField, PasswordField, SelectMultipleField, SelectField, FileField
+from wtforms.widgets import TextArea, ListWidget, CheckboxInput
+from wtforms.validators import ValidationError, DataRequired, EqualTo
+from app.models import User
+from app import utils
+
+
+#
+# authantication forms
+#
+
+class LoginForm(FlaskForm):
+	username = StringField('Username', validators=[DataRequired()])
+	password = PasswordField('Password', validators=[DataRequired()])
+
+	def validate_username(self, username):
+		if User.query.filter_by(username=username.data).count() == 0:
+			raise ValidationError('Wrong username')
+
+	def validate_password(self, password):
+		user = User.query.filter_by(username=self.username.data).first()
+		if user and not user.verify_password(password.data):
+			raise ValidationError('Wrong password')
+
+class SingupForm(FlaskForm):
+	username = StringField('Username', validators=[DataRequired()])
+	password = PasswordField('Password', validators=[DataRequired()])
+	password2 = PasswordField('Password again', validators=[DataRequired(), EqualTo('password')])
+
+	def validate_username(self, username):
+		if User.query.filter_by(username=username.data).count() > 0:
+			raise ValidationError('This username is in use. Please try another username.')
+
+#
+# testrun imprt forms
+#
+
+class TestrunImportForm(FlaskForm):
+	file = FileField('Testrun Definition', validators=[DataRequired()])
+	dataFile1 = FileField(f'Data File 1')
+	dataFile2 = FileField(f'Data File 2')
+	dataFile3 = FileField(f'Data File 3')
+	dataFile4 = FileField(f'Data File 4')
+	dataFile5 = FileField(f'Data File 5')
+
+	@property
+	def dataFiles(cls):
+		return [
+			cls.dataFile1,
+			cls.dataFile2,
+			cls.dataFile3,
+			cls.dataFile4,
+			cls.dataFile5,
+		]
+
+
+#
+# testrun item create forms
+#
+
+class TestrunCreateForm(FlaskForm):
+	name = StringField('Name', validators=[DataRequired()])
+	description = StringField('Description', validators=[DataRequired()], widget=TextArea())
+	testcase_sequences = SelectMultipleField('Test Case Sequences')
+
+	@classmethod
+	def new(cls):
+		# update choices
+		form = cls()
+		form.testcase_sequences.choices = utils.getTestCaseSequences()
+		return form
+
+
+class TestCaseSequenceCreateForm(FlaskForm):
+	name = StringField('Name', validators=[DataRequired()])
+	description = StringField('Description', validators=[DataRequired()], widget=TextArea())
+	classname = SelectField('Class Name')
+	datafiles = SelectMultipleField('Data Files')
+	#datafiles = FileField('Data Files')
+	testcases = SelectMultipleField('Test Cases')
+
+	@classmethod
+	def new(cls):
+		# update choices
+		form = cls()
+		form.classname.choices = utils.getClassNames()
+		form.datafiles.choices = utils.getDataFiles()
+		form.testcases.choices = utils.getTestCases()
+		return form
+
+
+class TestCaseCreateForm(FlaskForm):
+	name = StringField('Name', validators=[DataRequired()])
+	description = StringField('Description', validators=[DataRequired()], widget=TextArea())
+	classname = SelectField('Class Name')
+	browser_type = SelectField('Browser Type')
+	testcase_type = SelectField('Test Case Type')
+	testcase_stepsequences = SelectMultipleField('Step Sequences')
+
+	@classmethod
+	def new(cls):
+		# update choices
+		form = cls()
+		form.classname.choices = utils.getClassNames()
+		form.browser_type.choices = utils.getBrowserTypes()
+		form.testcase_type.choices = utils.getTestCaseTypes()
+		form.testcase_stepsequences.choices = utils.getTestStepSequences()
+		return form
+
+
+class TestStepSequenceCreateForm(FlaskForm):
+	name = StringField('Name', validators=[DataRequired()])
+	description = StringField('Description', validators=[DataRequired()], widget=TextArea())
+	classname = SelectField('Class Name')
+	teststeps = SelectMultipleField('Test Steps')
+
+	@classmethod
+	def new(cls):
+		# update choices
+		form = cls()
+		form.classname.choices = utils.getClassNames()
+		form.teststeps.choices = utils.getTestSteps()
+		return form
+
+
+class TestStepCreateForm(FlaskForm):
+	name = StringField('Name', validators=[DataRequired()])
+	description = StringField('Description', validators=[DataRequired()], widget=TextArea())
+	activity_type = SelectField('Activity Type')
+	locator_type = SelectField('Locator Type')
+	# model extension
+	locator = StringField('Locator')
+	value = StringField('Value')
+	value2 = StringField('Value 2')
+	comparison = SelectField('Comparison')
+	optional = SelectField('Optional', choices=(('1', 'False'), ('2', 'True')))
+	timeout = StringField('Timeout')
+	release = StringField('Release')
+	
+
+	@classmethod
+	def new(cls):
+		# update choices
+		form = cls()
+		form.activity_type.choices = utils.getActivityTypes()
+		form.locator_type.choices = utils.getLocatorTypes()
+		form.comparison.choices = utils.getComparisionChoices()
+		return form
+
+	

+ 531 - 0
ui/app/models.py

@@ -0,0 +1,531 @@
+from flask_login import UserMixin
+from werkzeug.security import generate_password_hash, check_password_hash
+from datetime import datetime
+from app import db, login_manager
+import uuid
+import re
+from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION
+
+#
+# Handling dialects
+#
+double_field = db.Float
+dialect = re.search(r'\((\w+)', str(db.get_engine()).lower())
+if dialect and dialect.group(1) == 'postgresql':
+	double_field = DOUBLE_PRECISION
+
+#
+# user Model
+# TODO: extend user Model
+#
+class User(UserMixin, db.Model):
+	__tablename__ = 'users'
+	id = db.Column(db.Integer, primary_key=True)
+	username = db.Column(db.String(64), unique=True, nullable=False)
+	password = db.Column(db.String(128), nullable=False)
+	created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+	lastlogin = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+
+	def __str__(self):
+		return self.username
+
+	def set_password(self, password):
+		self.password = generate_password_hash(password)
+
+	def verify_password(self, password):
+		return check_password_hash(self.password, password)
+
+@login_manager.user_loader
+def load_user(id):
+	return User.query.get(int(id))
+
+#
+# relation tables
+#
+
+testrun_casesequence = db.Table(
+	'testrun_casesequence',
+	db.Column('testrun_id', db.Integer, db.ForeignKey('testruns.id'), primary_key=True),
+	db.Column('testcase_sequence_id', db.Integer, db.ForeignKey('testcase_sequences.id'), primary_key=True)
+)
+
+testcase_sequence_datafile = db.Table(
+	'testcase_sequence_datafile',
+	db.Column('testcase_sequence_id', db.Integer, db.ForeignKey('testcase_sequences.id'), primary_key=True),
+	db.Column('datafile_id', db.Integer, db.ForeignKey('datafiles.id'), primary_key=True)
+)
+
+testcase_sequence_case = db.Table(
+	'testcase_sequence_case',
+	db.Column('testcase_sequence_id', db.Integer, db.ForeignKey('testcase_sequences.id'), primary_key=True),
+	db.Column('testcase_id', db.Integer, db.ForeignKey('testcases.id'), primary_key=True)
+)
+
+testcase_stepsequence = db.Table(
+	'testcase_stepsequence',
+	db.Column('testcase_id', db.Integer, db.ForeignKey('testcases.id'), primary_key=True),
+	db.Column('teststep_sequence_id', db.Integer, db.ForeignKey('teststep_sequences.id'), primary_key=True)
+)
+
+
+#
+# main entities
+#
+class Testrun(db.Model):
+	#
+	# Testrun object
+	#
+
+	__tablename__ = 'testruns'
+	
+	# fields
+	id = db.Column(db.Integer, primary_key=True)
+	name = db.Column(db.String(64), nullable=False)
+	description = db.Column(db.String(512), nullable=False)
+	created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+	creator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+	edited = db.Column(db.DateTime, nullable=True)
+	editor_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
+
+	# realshinships
+	creator = db.relationship('User', backref='created_testruns', lazy='immediate', foreign_keys=[creator_id])
+	editor = db.relationship('User', backref='edited_testruns', lazy='immediate', foreign_keys=[editor_id])
+
+	def __str__(self):
+		return self.name
+
+	def to_json(self):
+		#
+		# returns item as JSON
+		#
+
+		return {
+			self.name: {
+				'GC.KWARGS_TESTRUNATTRIBUTES': {
+					'GC.EXPORT_FORMAT': {
+						'GC.EXPORT_FORMAT': None,
+						'GC.EXP_FIELDLIST': [],
+					},
+					'GC.STRUCTURE_TESTCASESEQUENCE': {
+						i+1: self.testcase_sequences[i].to_json(i+1) for i in range(len(self.testcase_sequences))
+					},
+				}
+			}
+		}
+
+	def get_status(self):
+		#
+		# check for item completeness
+		# returns tuple: (status, message)
+		#
+
+		# check for TestCaseSequences
+		if len(self.testcase_sequences) == 0:
+			return False, f'Testrun {self} does not contain TestCaseSequences'
+
+		# iterate TestCaseSequences
+		for items in self.testcase_sequences:
+			status, message = items.get_status()
+			if not status:
+				return status, message
+
+		return True, 'OK'
+		
+
+class TestCaseSequence(db.Model):
+	#
+	# TestCse Sequence object
+	#
+
+	__tablename__ = 'testcase_sequences'
+
+	# fields
+	id = db.Column(db.Integer, primary_key=True)
+	name = db.Column(db.String(64), nullable=False)
+	description = db.Column(db.String(512), nullable=False)
+	created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+	creator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+	edited = db.Column(db.DateTime, nullable=True)
+	editor_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
+	classname_id = db.Column(db.Integer, db.ForeignKey('classnames.id'), nullable=False)
+	
+	# relashionships
+	creator = db.relationship('User', backref='created_testcase_sequances', lazy='immediate', foreign_keys=[creator_id])
+	editor = db.relationship('User', backref='edited_testcase_sequances', lazy='immediate', foreign_keys=[editor_id])
+	classname = db.relationship('ClassName', backref='testcase_sequences', lazy='immediate', foreign_keys=[classname_id])
+	testrun = db.relationship(
+		'Testrun',
+		secondary=testrun_casesequence,
+		lazy='subquery',
+		backref=db.backref('testcase_sequences', lazy=True),
+	)
+
+	def __str__(self):
+		return self.name
+
+	def to_json(self, TestCaseSequenceNumber=1):
+		#
+		# returns item as JSON
+		#
+
+		# get name of DataFile
+		if len(self.datafiles) > 0:
+			TestDataFileName = self.datafiles[0].uuid
+			TestDataSheetName = self.datafiles[0].sheet
+		else:
+			TestDataFileName = ''
+		return [
+			self.classname.name,
+			{
+				#'Number': TestCaseSequenceNumber,
+				'SequenceClass': self.classname.name,
+				'GC.DATABASE_FILENAME': TestDataFileName,
+				'GC.DATABASE_SHEETNAME': TestDataSheetName,
+				'GC.STRUCTURE_TESTCASE': {
+					i+1: self.testcases[i].to_json(
+						TestCaseSequenceNumber,
+						i+1,
+					) for i in range(len(self.testcases))
+				},
+			}
+		]
+
+	def get_status(self):
+		#
+		# check for item completeness
+		# returns tuple: (status, message)
+		#
+
+		# check for DataFiles
+		if len(self.datafiles) == 0:
+			return False, f'TestCaseSequence {self} does not contain DataFiles'
+		
+		# check for TestCases
+		if len(self.testcases) == 0:
+			return False, f'TestCaseSequence {self} does not contain TestCases'
+
+		# iterate TestCases
+		for items in self.testcases:
+			status, message = items.get_status()
+			if not status:
+				return status, message
+
+		return True, 'OK'
+
+
+class DataFile(db.Model):
+	#
+	# DataFile object
+	#
+
+	__tablename__ = 'datafiles'
+
+	# fields
+	id = db.Column(db.Integer, primary_key=True)
+	uuid = db.Column(db.String(64), nullable=False)
+	filename = db.Column(db.String(64), nullable=False)
+	sheet = db.Column(db.String(32), nullable=False, default='data')
+	created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+	creator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+	
+	# relationships
+	creator = db.relationship('User', backref='created_datafiles', lazy='immediate', foreign_keys=[creator_id])
+	testcase_sequence = db.relationship(
+		'TestCaseSequence',
+		secondary=testcase_sequence_datafile,
+		lazy='subquery',
+		backref=db.backref('datafiles', lazy=True),
+	)
+
+	def __str__(self):
+		return self.filename
+
+
+class TestCase(db.Model):
+	#
+	# testCase object
+	#
+
+	__tablename__ = 'testcases'
+	
+	# fields
+	id = db.Column(db.Integer, primary_key=True)
+	name = db.Column(db.String(64), nullable=False)
+	description = db.Column(db.String(512), nullable=False)
+	created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+	creator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+	edited = db.Column(db.DateTime, nullable=True)
+	editor_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
+	classname_id = db.Column(db.Integer, db.ForeignKey('classnames.id'), nullable=False)
+	browser_type_id = db.Column(db.Integer, db.ForeignKey('browser_types.id'), nullable=False)
+	testcase_type_id = db.Column(db.Integer, db.ForeignKey('testcase_types.id'), nullable=False)
+	
+	# realshionshipd
+	creator = db.relationship('User', backref='created_testcases', lazy='immediate', foreign_keys=[creator_id])
+	editor = db.relationship('User', backref='edited_testcases', lazy='immediate', foreign_keys=[editor_id])
+	classname = db.relationship('ClassName', backref='testcases', lazy='immediate', foreign_keys=[classname_id])
+	browser_type = db.relationship('BrowserType', backref='testcases', lazy='immediate', foreign_keys=[browser_type_id])
+	testcase_type = db.relationship('TestCaseType', backref='testcases', lazy='immediate', foreign_keys=[testcase_type_id])
+	testcase_sequence = db.relationship(
+		'TestCaseSequence',
+		secondary=testcase_sequence_case,
+		lazy='subquery',
+		backref=db.backref('testcases', lazy=True),
+	)
+
+	def __str__(self):
+		return self.name
+
+	def to_json(self, TestCaseSequenceNumber=1, TestCaseNumber=1):
+		#
+		# returns item as JSON
+		#
+
+		return [
+			self.classname.name,
+			{
+				'GC.KWARGS_TESTCASETYPE': self.testcase_type.name,
+				'GC.KWARGS_BROWSER': self.browser_type.name,
+				'GC.BROWSER_ATTRIBUTES': '',
+			},
+			{
+				'GC.STRUCTURE_TESTSTEP': {
+					i+1: self.teststep_sequences[i].to_json(
+						TestCaseSequenceNumber,
+						TestCaseNumber,
+						i+1,
+					) for i in range(len(self.teststep_sequences))
+				},
+			}
+		]
+
+	def get_status(self):
+		#
+		# check for item completeness
+		# returns tuple: (status, message)
+		#
+
+		# check for TestStepSequences
+		if len(self.teststep_sequences) == 0:
+			return False, f'TestCase {self} does not contain TestStepSequences'
+
+		# iterate TestStepSequence
+		for items in self.teststep_sequences:
+			status, message = items.get_status()
+			if not status:
+				return status, message
+
+		return True, 'OK'
+
+
+class TestStepSequence(db.Model):
+	#
+	# TestStep Sequence object
+	#
+
+	__tablename__ = 'teststep_sequences'
+
+	# fields
+	id = db.Column(db.Integer, primary_key=True)
+	name = db.Column(db.String(64), nullable=False)
+	description = db.Column(db.String(512), nullable=False)
+	created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+	creator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+	edited = db.Column(db.DateTime, nullable=True)
+	editor_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
+	classname_id = db.Column(db.Integer, db.ForeignKey('classnames.id'), nullable=False)
+	
+	# relashionship
+	creator = db.relationship('User', backref='created_teststep_sequences', lazy='immediate', foreign_keys=[creator_id])
+	editor = db.relationship('User', backref='edited_teststep_sequences', lazy='immediate', foreign_keys=[editor_id])
+	classname = db.relationship('ClassName', backref='teststep_sequences', lazy='immediate', foreign_keys=[classname_id])
+	testcase = db.relationship(
+		'TestCase',
+		secondary=testcase_stepsequence,
+		lazy='subquery',
+		backref=db.backref('teststep_sequences', lazy=True),
+	)
+
+	def __str__(self):
+		return self.name
+
+	def to_json(self, TestCaseSequenceNumber=1, TestCaseNumber=1, TestStepNumber=1):
+		#
+		# returns item as JSON
+		#
+
+		return [
+			{
+				'TestStepClass': self.classname.name,
+			},
+			{
+				'GC.STRUCTURE_TESTSTEPEXECUTION': {
+					i+1: self.teststeps[i].to_json(
+						TestCaseSequenceNumber,
+						TestCaseNumber,
+						TestStepNumber,
+						i+1,
+					) for i in range(len(self.teststeps))
+				}
+			},
+		]
+
+	def get_status(self):
+		#
+		# check for item completeness
+		# returns tuple: (status, message)
+		#
+
+		# check for TestSteps
+		if len(self.teststeps) == 0:
+			return False, f'TestStepSequence {self} does not contain TestSteps'
+
+		return True, 'OK'
+
+
+class TestStepExecution(db.Model):
+	#
+	# TestStep object
+	#
+	__tablename__ = 'teststep_executions'
+
+	# fields
+	id = db.Column(db.Integer, primary_key=True)
+	name = db.Column(db.String(64), nullable=False)
+	description = db.Column(db.String(512), nullable=False)
+	created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+	creator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+	edited = db.Column(db.DateTime, nullable=True)
+	locator = db.Column(db.String(512), nullable=True)
+	optional = db.Column(db.Boolean, nullable=False, default=False)
+	#timeout = db.Column(db.Float(precision='5,2'), nullable=True)
+	timeout = db.Column(double_field(precision='5,2'), nullable=True)
+	release = db.Column(db.String(64), nullable=True)
+	value = db.Column(db.String(1024), nullable=True)
+	value2 = db.Column(db.String(1024), nullable=True)
+	comparison = db.Column(db.String(16), nullable=True)
+	editor_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
+	activity_type_id = db.Column(db.Integer, db.ForeignKey('activity_types.id'), nullable=False)
+	locator_type_id = db.Column(db.Integer, db.ForeignKey('locator_types.id'), nullable=True)
+	teststep_sequence_id = db.Column(db.Integer, db.ForeignKey('teststep_sequences.id'), nullable=True)
+	
+	# relashionships
+	creator = db.relationship('User', backref='created_teststeps', lazy='immediate', foreign_keys=[creator_id])
+	editor = db.relationship('User', backref='edited_teststeps', lazy='immediate', foreign_keys=[editor_id])
+	activity_type = db.relationship('ActivityType', backref='teststeps', lazy='immediate', foreign_keys=[activity_type_id])
+	locator_type = db.relationship('LocatorType', backref='teststeps', lazy='immediate', foreign_keys=[locator_type_id])
+	teststep_sequence = db.relationship('TestStepSequence', backref='teststeps', lazy='immediate', foreign_keys=[teststep_sequence_id])
+	
+
+	def __str__(self):
+		return self.name
+
+	def to_json(self, TestCaseSequenceNumber=1, TestCaseNumber=1, TestStepNumber=1, TestStepExecutionNumber=1):
+		#
+		# returns item as JSON
+		#
+
+		# set LocatorType name
+		locator_type = ''
+		if self.locator_type:
+			locator_type = self.locator_type.name
+			
+		return {
+			'Activity': self.activity_type.name,
+			'LocatorType': locator_type,
+			'Locator': self.locator or '',
+			'Value': self.value or '',
+			'Comparison': self.comparison or '',
+			'Value2': self.value2 or '',
+			'Timeout': self.timeout or '',
+			'Optional': self.optional or '',
+			'Release': self.release or '',
+		}
+
+#
+# supporting entities
+#
+class GlobalTestStepExecution(db.Model):
+	__tablename__ = 'global_teststep_executions'
+	id = db.Column(db.Integer, primary_key=True)
+	name = db.Column(db.String(64), nullable=False)
+	description = db.Column(db.String(512), nullable=False)
+	created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+	creator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+	edited = db.Column(db.DateTime, nullable=True)
+	editor_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
+	activity_type_id = db.Column(db.Integer, db.ForeignKey('activity_types.id'), nullable=False)
+	locator_type_id = db.Column(db.Integer, db.ForeignKey('locator_types.id'), nullable=False)
+	teststep_sequence_id = db.Column(db.Integer, db.ForeignKey('teststep_sequences.id'), nullable=True)
+	creator = db.relationship('User', backref='created_global_teststeps', lazy='immediate', foreign_keys=[creator_id])
+	editor = db.relationship('User', backref='edited_global_teststeps', lazy='immediate', foreign_keys=[editor_id])
+	activity_type = db.relationship('ActivityType', backref='global_teststeps', lazy='immediate', foreign_keys=[activity_type_id])
+	locator_type = db.relationship('LocatorType', backref='global_teststeps', lazy='immediate', foreign_keys=[locator_type_id])
+	teststep_sequence = db.relationship('TestStepSequence', backref='global_teststeps', lazy='immediate', foreign_keys=[teststep_sequence_id])
+	
+	def __str__(self):
+		return self.name
+
+class ClassName(db.Model):
+	__tablename__ = 'classnames'
+	id = db.Column(db.Integer, primary_key=True)
+	name = db.Column(db.String(64), nullable=False)
+	description = db.Column(db.String(512), nullable=False)
+
+	def __str__(self):
+		return self.name
+
+class BrowserType(db.Model):
+	__tablename__ = 'browser_types'
+	id = db.Column(db.Integer, primary_key=True)
+	name = db.Column(db.String(64), nullable=False)
+	description = db.Column(db.String(512), nullable=False)
+
+	def __str__(self):
+		return self.name
+	
+class TestCaseType(db.Model):
+	__tablename__ = 'testcase_types'
+	id = db.Column(db.Integer, primary_key=True)
+	name = db.Column(db.String(64), nullable=False)
+	description = db.Column(db.String(512), nullable=False)
+
+	def __str__(self):
+		return self.name
+
+class ActivityType(db.Model):
+	__tablename__ = 'activity_types'
+	id = db.Column(db.Integer, primary_key=True)
+	name = db.Column(db.String(64), nullable=False)
+	description = db.Column(db.String(512), nullable=False)
+
+	def __str__(self):
+		return self.name
+
+class LocatorType(db.Model):
+	__tablename__ = 'locator_types'
+	id = db.Column(db.Integer, primary_key=True)
+	name = db.Column(db.String(64), nullable=False)
+	description = db.Column(db.String(512), nullable=False)
+
+	def __str__(self):
+		return self.name
+
+
+#
+# Testrun results
+#
+
+class TestRunCall(db.Model):
+	__tablename__ = 'testrun_calls'
+	#id = db.Column(db.Integer, primary_key=True)
+	id = db.Column(db.LargeBinary(16), primary_key=True)
+	#call_id = db.Column(db.LargeBinary(16), nullable=False)
+	created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+	creator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+	testrun_id = db.Column(db.Integer, db.ForeignKey('testruns.id'), nullable=False)
+	creator = db.relationship('User', backref='testruns_called', lazy='immediate', foreign_keys=[creator_id])
+	testrun = db.relationship('Testrun', backref='calls', lazy='immediate', foreign_keys=[testrun_id])
+
+	def __str__(self):
+		return str(uuid.UUID(bytes=self.id))

+ 85 - 0
ui/app/static/css/styles.css

@@ -0,0 +1,85 @@
+body {
+  min-width: 420px;
+}
+
+/* chips */
+.chip {
+  display: inline-block;
+  padding: 0 10px;
+  height: 30px;
+  font-size: 16px;
+  /*line-height: 50px;*/
+  border-radius: 15px;
+  background-color: #f1f1f1;
+}
+
+.closebtn {
+  padding-left: 10px;
+  color: #888;
+  font-weight: bold;
+  float: right;
+  font-size: 20px;
+  cursor: pointer;
+}
+
+/* input groups */
+.closebtn:hover {
+  color: #000;
+}
+
+.fixed-label {
+  width: 11rem;
+}
+
+.text-break {
+  word-wrap: break-word;
+}
+
+/* buttons */
+.btn-right {
+  width: 10rem;
+}
+
+/* dialog */
+.dialog {
+  justify-content: center !important;
+}
+
+
+/* widths */
+.w-20 {
+  width: 20%;
+}
+
+.w-40 {
+  width: 40%;
+}
+
+.w-60 {
+  width: 60%;
+}
+
+.w-80 {
+  width: 80%;
+}
+
+/* loading screen */
+#loading {
+  display: none;
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 100;
+  width: 100vw;
+  height: 150vh;
+  background-color: rgba(192, 192, 192, 0.8);
+}
+
+.spinner {
+  width: 3rem;
+  height: 3rem;
+}
+
+.h-80 {
+  height: 80vh;
+}

+ 332 - 0
ui/app/static/js/testrun.js

@@ -0,0 +1,332 @@
+
+    // get item
+    function load_item(item_type, item_id) {
+        const request = new XMLHttpRequest();
+        request.open('GET', `/${item_type}/${item_id}`);
+        request.onload = () => {
+            const response = request.responseText;
+            document.querySelector('main').innerHTML = response;
+        };
+        request.send();
+    }
+
+    // create new item
+    function new_item(item_type) {
+        const request = new XMLHttpRequest();
+        request.open('GET', `/${item_type}/new`);
+        request.onload = () => {
+            const response = request.responseText;
+            document.querySelector('main').innerHTML = response;
+        };
+        request.send();
+    }
+
+    // edit item
+    function edit_item(item_type, item_id) {
+        const request = new XMLHttpRequest();
+        request.open('GET', `/${item_type}/${item_id}/edit`);
+        request.onload = () => {
+            const response = request.responseText;
+            document.querySelector('main').innerHTML = response;
+        };
+        request.send();
+    }
+
+    // delete item
+    function set_delete_item(type_name, type, item_id) {
+        /* set data in delete modal */
+        document.querySelector('#deleteLabel').innerHTML = `Delete ${type_name} ID #${item_id}`;
+        const btns = document.querySelector('#deleteButtons');
+        btns.setAttribute('data-type', type);
+        btns.setAttribute('data-id', item_id);
+    }
+
+    function delete_item(e, cascade) {
+        /* delete item */
+        const request = new XMLHttpRequest();
+        if (cascade) {
+            // cascade delete: POST request
+            request.open('POST', `/${e.dataset['type']}/${e.dataset['id']}/delete`);
+        } else {
+            // single item delete: DELETE request
+            request.open('DELETE', `/${e.dataset['type']}/${e.dataset['id']}/delete`);
+        }
+        
+        request.onload = () => {
+            window.location.reload(true); 
+        }
+        request.send();
+    }
+
+    // push message
+    function push_message(msg, type) {
+        // create message
+        var alrt = document.createElement('div');
+        alrt.setAttribute('class', `alert alert-${type} alert-dismissible fade show`);
+        alrt.setAttribute('role', 'alert');
+        alrt.innerHTML = msg;
+        // create close button
+        var btn = document.createElement('button');
+        btn.setAttribute('type', 'button');
+        btn.setAttribute('class', 'close');
+        btn.setAttribute('data-dismiss', 'alert');
+        btn.setAttribute('aria-label', 'Close');
+        btn.innerHTML = `<span aria-hidden="true">&times;</span>`;
+        // add to main
+        alrt.appendChild(btn);
+        document.getElementById('flash_area').appendChild(alrt);
+    }
+
+    // add DataFile
+    function add_datafile() {
+        const message = document.getElementById('upload-message');
+        const modal = document.getElementById('uploadModal');
+        const files = document.getElementById('upload-file').files;
+        message.innerHTML = "";
+        if (files.length > 0) {
+            //console.log(files[0]);
+            const request = new XMLHttpRequest();
+            request.responseType = 'json';
+            request.open('POST', '/datafile/upload');
+            // get file
+            var datafile = new FormData();
+            datafile.append('datafile', files[0]);
+
+            request.onload = () => {
+                if (request.status === 200) {
+                    // add option to datalist
+                    var datalist_option = document.createElement('option');
+                    datalist_option.setAttribute('data-id', request.response['id']);
+                    datalist_option.innerText = request.response['name'];
+                    document.getElementById('datafilesOpt').appendChild(datalist_option);
+                    // add option to multi-select
+                    var select_option = document.createElement('option');
+                    select_option.innerText = request.response['name'];
+                    select_option.setAttribute('value', request.response['id']);
+                    document.getElementById('datafiles').appendChild(select_option);
+                    // message
+                    message.innerHTML = '<span class="text-success">DataFile successfully uploaded</span>'
+                    document.getElementById('upload-label').innerText = 'Choose a file';
+                } else {
+                    // push error message
+                    for (var key in request.response) {
+                        message.innerHTML = `<span class="text-danger">${key.toUpperCase()}: ${request.response[key]}</span>`
+                    }
+                }
+            }
+            request.send(datafile);
+        } else {
+            message.innerHTML = '<span class="text-success">Please select a DataFile</span>';
+        }
+    }
+
+    function add_datafile_new() {
+        const datafiles = document.getElementById('datafiles');
+        const files = document.getElementById('datafile-select').files;
+        const f = document.querySelector('form');
+        var datafile_list = new FormData();
+        if (files.length > 0) {
+            //datafiles.files.push.apply(files[0]);
+            datafile_list.append('file', files[0]);
+            //console.log(datafile_list.keys().length);
+            //console.log(datafile_list.keys().length);
+        } else {
+            push_message('No DataFile selected', 'warning');
+        }
+    }
+
+    function upload_datafile() {
+        //console.log('upload');
+        const datafiles = document.getElementById('datafiles');
+        //document.uploadForm.submit();
+    }
+
+    function create_chip(name, text, id) {
+        var chip_area = document.getElementById(name);
+        var new_chip = document.createElement('div');
+        var onclick_action;
+        new_chip.setAttribute('class', 'chip mr-1');
+        new_chip.setAttribute('data-id', id);
+        if (id >= 0) {        
+            onclick_action = `delete_chip(this.parentElement, '${name}')`;
+        } else {
+            onclick_action = 'this.parentElement.remove()';            
+        }
+
+        new_chip.innerHTML = `
+            <small>${text}</small>
+            <span class="closebtn" onclick="${onclick_action}">&times;</span>
+            `;
+        chip_area.appendChild(new_chip);
+    }
+
+    // select multiple with chips
+    function add_chip(e, name) {
+        // check if option exists
+        const text = e.value;
+        for (var i = 0; i < e.list.childElementCount; i++) {
+            //const opt_text = e.list.children[i].text.toUpperCase();
+            if (text === e.list.children[i].text) {
+                // create chip
+                //create_chip(`chips_${name}`, e.list.children[i].text, i);
+                
+                var chip_area = document.getElementById(`chips_${name}`);
+                var new_chip = document.createElement('div');
+                new_chip.setAttribute('class', 'chip mr-1');
+                new_chip.setAttribute('data-id', e.list.children[i].dataset['id']);
+                new_chip.innerHTML = `
+                    <small>${e.list.children[i].text}</small>
+                    <span class="closebtn" onclick="delete_chip(this.parentElement, '${name}')">&times;</span>
+                    `;
+                chip_area.appendChild(new_chip);
+                
+                // remove selected option from list
+                e.value = null;
+                e.list.children[i].disabled = true;
+                break;
+            }
+        }
+    }
+
+    function delete_chip(e, name) {
+        var list = document.getElementById(`${name}Opt`);
+        for (var i = 0; i < list.childElementCount; i++) {
+            if (e.dataset['id'] === list.children[i].dataset['id']) {
+                list.children[i].disabled = false;
+                e.parentElement.removeChild(e);
+                break;
+            }
+        }
+        
+    }
+
+    function get_chips() {
+        document.querySelectorAll('datalist').forEach(list => {
+            // create multyselect field
+            const name = list.id.substring(0, list.id.length - 3);
+            const selector = document.getElementById(name);
+            const chips_area = document.getElementById(`chips_${name}`);
+            //console.log(name);
+            chips_area.querySelectorAll('div').forEach(chip => {
+                //console.log(chip.dataset['id']);
+                //value = chip.dataset['id'];
+                for (var i = 0; i < selector.length; i++) {
+                    if (chip.dataset['id'] === selector.options[i].value) {
+                        selector.options[i].selected = true;
+                    }
+                }
+            });
+        });
+
+        return false;
+    }
+
+    function filter_options(e) {
+        const text = e.value.toUpperCase();
+        for (var i = 0; i < e.list.childElementCount; i++) {
+            const opt_text = e.list.children[i].text.toUpperCase();
+            if (!opt_text.includes(text)) {
+                e.list.children[i].style.display = opt_text ? 'list-item' : 'none';
+            }
+
+
+        }
+    }
+
+    function filter_items(e) {
+        const text = e.value.toUpperCase();
+        document.querySelectorAll('.testrun-item').forEach(item => {
+            var display = false;
+            item.querySelectorAll('.filtered').forEach(property => {
+                const value = property.innerHTML.toUpperCase();
+                if (value.includes(text)) {
+                    display = true;
+                }
+            });
+
+            item.style.display = display ? '' : 'none';
+
+        });
+    }
+
+
+    function get_file(e) {
+        // get filename
+        const filename = e.files[0].name;
+        const label = e.parentElement.querySelector('label');
+        label.innerText = filename;
+    }
+
+    function set_export_id(item_id) {
+        // set item_id in modal
+        const btn = document.querySelector('#exportButton')
+        btn.setAttribute('data-id', item_id);
+        btn.style.display = '';
+        // set modal body
+        document.querySelector('#exportRequest').style.display = '';
+        document.querySelector('#exportResponse').style.display = 'none';
+    }
+
+    function export_item(e) {
+        /*
+        exports a testrun
+        */
+        // get export format
+        const exportFormat = document.querySelector('input[name="formatRadio"]:checked').value;
+        const request = new XMLHttpRequest();
+        request.open('GET', `/testrun/${exportFormat}/${e.dataset['id']}`);
+        request.onload = () => {
+            // get response
+            const response = request.responseText;
+            // set export modal body 
+            const exportResponse = document.querySelector('#exportResponse');
+            exportResponse.innerHTML = response;
+            exportResponse.style.display = '';
+            document.querySelector('#exportRequest').style.display = 'none';
+            document.querySelector('#exportButton').style.display = 'none';
+        };
+        request.send();
+    }
+
+    function set_update_item(item_id) {
+        document.querySelector('#updateLabel').innerHTML = `Update Testrun ID #${item_id}`;
+        document.querySelector('#updateForm').setAttribute('action', `/testrun/${item_id}/import`);
+    }
+
+    function add_datafile_link(limit) {
+        const active = document.getElementById('activeDataFiles');
+        // display DataFile header
+        if (active.value == "0") {
+            document.getElementById('dataFileHeader').style.display = '';
+        }
+        // display DataFile field
+        document.getElementById(`dataDiv${++active.value}`).style.display = '';
+        // check if DataFile number limit achieved
+        if (active.value == limit) {
+            const link = document.getElementById('dataFileExpander');
+            link.setAttribute('class', 'text-muted');
+            link.setAttribute('onclick', '');
+        } 
+    }
+
+    function run_item(item_id) {
+        /*
+        run the testrun via API web-service
+        */
+        const request = new XMLHttpRequest();
+        request.open('GET', `/testrun/${item_id}/run`);
+        request.onload = () => {
+            // get response
+            const response = request.responseText;
+            //document.querySelector('#loading').style.display = 'none';
+            //alert(response);
+            document.documentElement.innerHTML = response;
+
+        }
+        scroll(0, 0);
+        document.querySelector('#titleLoading').innerHTML = `Running Testrun ID #${item_id}`;
+        document.querySelector('#loading').style.display = 'block';
+        
+        request.send();
+
+    }

BIN
ui/app/static/media/favicon.ico


+ 60 - 0
ui/app/templates/base.html

@@ -0,0 +1,60 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <!-- Required meta tags -->
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+
+    <!-- Bootstrap CSS -->
+    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
+
+    <!-- Local Styles -->
+    <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
+
+    <!-- favicon -->
+    <link rel="shortcut icon" href="{{ url_for('static', filename='media/favicon.ico') }}">
+
+    <title>
+    	{% block title %}
+    	{% endblock %}
+    </title>
+  </head>
+  
+  <body>
+    <nav class="navbar navbar-expand navbar-dark bg-dark flex-md-nowrap p-1 shadow">
+        <a class="navbar-brand ml-3 py-2" href="/">
+            <img src="{{ url_for('static', filename='media/favicon.ico') }}" width="40" height="40" class="rounded" alt="">
+            Testruns Definition
+        </a>
+        <ul class="navbar-nav ml-auto mr-3">            
+            <li class="nav-item">
+                {% if current_user.is_authenticated %}
+                    <span class="nav-link">{{ current_user }}</span>
+                {% else %}
+                    <a class="nav-link" href="{{ url_for('login')}}">Login</a>
+                {% endif %}
+            </li>
+            <li class="nav-item">
+                {% if current_user.is_authenticated %}
+                    <a class="nav-link" href="{{ url_for('logout')}}">Sign out</a>
+                {% else %}
+                    <a class="nav-link" href="{{ url_for('signup')}}">Sign up</a>
+                {% endif %}
+            </li>
+        </ul>
+    </nav>
+      
+    {% block content %}
+    {% endblock %}
+
+
+    <!-- Local JS -->
+    <script src="{{ url_for('static', filename='js/testrun.js') }}"></script>
+
+    <!-- Optional JavaScript -->
+    <!-- jQuery first, then Popper.js, then Bootstrap JS -->
+    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script>
+    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
+    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>
+  </body>
+</html>

+ 17 - 0
ui/app/templates/includes/flash.html

@@ -0,0 +1,17 @@
+
+<!-- flash messages -->
+
+<div id="flash_area">
+{% with messages = get_flashed_messages(with_categories=true) %}
+    {% for category, msg in messages %}
+        {% if category in ('success', 'warning', 'danger') %}
+        <div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
+            {{ msg }}
+            <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+                <span aria-hidden="true">&times;</span>
+            </button>
+        </div>
+        {% endif %}
+    {% endfor %}
+{% endwith %}
+</div>

+ 145 - 0
ui/app/templates/testrun/create_item.html

@@ -0,0 +1,145 @@
+{% extends "base.html" %}
+
+{% block title %}
+    Create {{ type|name_by_type(False) }}
+{% endblock %}
+
+{% block content %}
+
+<main>
+
+<!-- bread crumb -->
+<nav aria-label="breadcrumb">
+    <ol class="breadcrumb">
+        <li class="breadcrumb-item"><a href="/">Home</a></li>
+        <li class="breadcrumb-item"><a href="/{{ type }}">{{ type|name_by_type }}</a></li>
+        <li class="breadcrumb-item active" aria-current="page">New</li>
+    </ol>
+</nav>
+
+<!-- flash messages -->
+{% include "includes/flash.html" %}
+
+<div class="container mt-5">
+    <div class="border-bottom pt-3">
+        <h1 >Create new {{ type|name_by_type(False) }}</h1>
+    </div>
+
+    <form class="pr-5" method="post" onsubmit="get_chips()" action="/{{ type }}/new">
+        {{ form.hidden_tag() }}
+
+        {% for field in form %}
+            {% if field.name != "csrf_token" %}
+            
+            <!-- error message -->
+            {% for error in field.errors %}
+                <p class="text-danger">{{ error }}</p>
+            {% endfor %}
+
+            {% if field.name == 'value' %}
+                <div class="input-group mt-3">
+                    <div class="input-group-prepend w-60 ml-0">
+                        {{ form.value.label(class="input-group-text w-80") }}
+                        {{ form.comparison.label(class="input-group-text w-40") }}
+                    </div>
+                    <div class="input-group-append w-40">
+                        {{ form.value2.label(class="input-group-text w-100") }}
+                    </div>
+                </div>
+                <div class="input-group mb-3">
+                    <div class="input-group w-40">
+                        {{ form.value(class="form-control w-50") }}
+                    </div>
+                    <div class="input-group w-20">
+                        {{ form.comparison(class="form-control") }}
+                    </div>
+                    <div class="input-group w-40">
+                        {{ form.value2(class="form-control") }}                    
+                    </div>
+                </div>
+
+            {% elif field.name != 'comparison' and field.name != 'value2' %}
+            <div class="input-group my-3">
+                <div class="input-group-prepend">
+                    {{ field.label(class="input-group-text fixed-label") }}
+                </div>
+
+                {% if field.name in chips %}
+                <!-- choices for chips -->
+                    <input type="text" class="form-control" list="{{ field.name }}Opt" data-name="{{ field.name }}"
+                        onkeyup="filter_options(this)" oninput="add_chip(this, '{{ field.name }}')">
+                    <datalist id="{{ field.name }}Opt">
+                        {% for option in field.choices %}
+                            <option data-id="{{ option.0 }}">
+                                {{ option.1 }}
+                            </option>
+                        {% endfor %}
+                    </datalist>
+                
+                {% else %}
+                <!-- other fields -->
+                    {{ field(class="form-control") }}
+                {% endif %}
+                <!-- DataFile Upload -->
+                {% if field.name == 'datafiles' %}
+                    <div class="input-group-append">
+                        <button class="btn btn-outline-secondary" type="button" data-toggle="modal" data-target="#uploadModal">Upload</button>
+                    </div>
+                {% endif %}
+            </div>
+
+            <!-- chips area -->
+            {% if field.name in chips %}
+                <!-- hidden multi-select -->
+                <select class="d-none" id="{{ field.name }}" name="{{ field.name }}" multiple>
+                    {% for option in field.choices %}
+                        <option value="{{ option.0 }}">
+                            {{ option.1 }}
+                        </option>
+                    {% endfor %}
+                </select>
+                
+                <!-- chips -->
+                <div id="chips_{{ field.name }}">
+                </div>
+            {% endif %}
+            {% endif %}
+            {% endif %}
+
+        {% endfor %}
+
+        <p>
+            <button class="btn btn-primary px-5 my-3" type="submit">Create</button>
+        </p>
+    </form>
+
+    <!-- Upload DataFile Modal -->
+    <div class="modal fade" id="uploadModal" tabindex="-1" role="dialog" aria-labelledby="uploadModalLabel" aria-hidden="true">
+        <div class="modal-dialog" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="uploadModalLabel">Upload Data FIle</h5>
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                        <span aria-hidden="true">&times;</span>
+                    </button>
+                </div>
+                <div class="modal-body">
+                    <div id="upload-message" class="text-center mb-1"></div>
+                    <div class="custom-file">
+                        <input accept=".xlsx" class="custom-file-input" id="upload-file" onchange="get_file(this)" type="file">
+                        <label id="upload-label" class="custom-file-label" for="upload-file">Choose a file</label>
+                    </div>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+                    <button type="button" class="btn btn-primary" onclick="add_datafile()">Upload</button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+</div>
+
+</main>
+
+{% endblock %}

+ 151 - 0
ui/app/templates/testrun/edit_item.html

@@ -0,0 +1,151 @@
+{% extends "base.html" %}
+
+{% block title %}
+    {{ item.name }}
+{% endblock %}
+
+{% block content %}
+
+<main>
+
+<!-- bread crumb -->
+<nav aria-label="breadcrumb">
+    <ol class="breadcrumb">
+        <li class="breadcrumb-item"><a href="/">Home</a></li>
+        <li class="breadcrumb-item"><a href="/{{ type }}">{{ type|name_by_type }}</a></li>
+        <li class="breadcrumb-item active" aria-current="page">{{ item.name }}</li>
+    </ol>
+</nav>
+
+<!-- flash messages -->
+<div id="flash_area">
+{% include "includes/flash.html" %}
+
+<div class="container mt-5">
+    <div class="border-bottom pt-3">
+        <h1 >Edit '{{ item.name }}'</h1>
+    </div>
+
+    <form class="pr-5" method="post" onsubmit="get_chips()" action="/{{ type }}/{{ item.id }}/edit">
+        {{ form.hidden_tag() }}
+
+        {% for field in form %}
+            {% if field.name != "csrf_token" %}
+            
+            <!-- show errors -->
+            {% for error in field.errors %}
+                <p class="text-danger">{{ error }}</p>
+            {% endfor %}
+
+            {% if field.name == 'value' %}
+                <div class="input-group mt-3">
+                    <div class="input-group-prepend w-60 ml-0">
+                        {{ form.value.label(class="input-group-text w-80") }}
+                        {{ form.comparison.label(class="input-group-text w-40") }}
+                    </div>
+                    <div class="input-group-append w-40">
+                        {{ form.value2.label(class="input-group-text w-100") }}
+                    </div>
+                </div>
+                <div class="input-group mb-3">
+                    <div class="input-group w-40">
+                        {{ form.value(class="form-control w-50", value=form.value.data) }}
+                    </div>
+                    <div class="input-group w-20">
+                        {{ form.comparison(class="form-control", value=form.comparison.data) }}
+                    </div>
+                    <div class="input-group w-40">
+                        {{ form.value2(class="form-control", value=form.value2.data) }}                    
+                    </div>
+                </div>
+            {% elif field.name != 'comparison' and field.name != 'value2' %}
+            <div class="input-group my-3">
+                <div class="input-group-prepend">
+                    {{ field.label(class="input-group-text fixed-label") }}
+                </div>
+
+                {% if field.data is iterable and field.data is not string %}
+                    <!-- choices for chips -->
+                    <input type="text" class="form-control" list="{{ field.name }}Opt" data-name="{{ field.name }}"
+                        onkeyup="filter_options(this)" oninput="add_chip(this, '{{ field.name }}')">
+                    {% if field.name == 'datafiles' %}
+                    <!-- DataFile Upload -->
+                        <div class="input-group-append">
+                            <button class="btn btn-outline-secondary" type="button" data-toggle="modal" data-target="#uploadModal">Upload</button>
+                        </div>
+                    {% endif %}
+                    <datalist id="{{ field.name }}Opt">
+                        {% for option in field.choices %}
+                            <option data-id="{{ option.0 }}" {% if option.0 in field.data %}disabled{% endif %}>
+                                {{ option.1 }}
+                            </option>
+                        {% endfor %}
+                    </datalist>
+
+                {% else %}
+                    <!-- other fields -->
+                    {{ field(class="form-control", value=field.data) }}
+                {% endif %}
+            </div>
+
+            <!-- chips -->
+            {% if field.data is iterable and field.data is not string %}
+                <!-- multi-select -->
+                <select class="d-none" id="{{ field.name }}" name="{{ field.name }}" multiple>
+                    {% for option in field.choices %}
+                        <option value="{{ option.0 }}">
+                            {{ option.1 }}
+                        </option>
+                    {% endfor %}
+                </select>
+                <!-- chips -->
+                <div id="chips_{{ field.name }}">
+                    {% for option in field.choices if option.0 in field.data %}
+                        <div class="chip mr-1" data-id="{{ option.0 }}">
+                            <small>{{ option.1 }}</small>
+                            <span class="closebtn" onclick="delete_chip(this.parentElement, '{{ field.name }}')">&times;</span>
+                        </div>
+                    {% endfor %}
+                </div>
+
+            {% endif %}
+            {% endif %}
+            {% endif %}
+
+        {% endfor %}
+
+        <p>
+            <button class="btn btn-primary px-5 my-3" type="submit">Save</button>
+        </p>
+    </form>
+
+    <!-- Upload DataFile Modal -->
+    <div class="modal fade" id="uploadModal" tabindex="-1" role="dialog" aria-labelledby="uploadModalLabel" aria-hidden="true">
+        <div class="modal-dialog" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="uploadModalLabel">Upload Data FIle</h5>
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                        <span aria-hidden="true">&times;</span>
+                    </button>
+                </div>
+                <div class="modal-body">
+                    <div id="upload-message" class="text-center mb-1"></div>
+                    <div class="custom-file">
+                        <input accept=".xlsx" class="custom-file-input" id="upload-file" onchange="get_file(this)" type="file">
+                        <label id="upload-label" class="custom-file-label" for="upload-file">Choose a file</label>
+                    </div>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+                    <button type="button" class="btn btn-primary" onclick="add_datafile()">Upload</button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+</div>
+
+</main>
+
+{% endblock %}

+ 36 - 0
ui/app/templates/testrun/index.html

@@ -0,0 +1,36 @@
+{% extends "base.html" %}
+
+{% block title %}
+    Home
+{% endblock %}
+
+{% block content %}
+
+<!-- bread crumb -->
+<nav aria-label="breadcrumb">
+    <ol class="breadcrumb">
+        <li class="breadcrumb-item active" aria-current="page">Home</li>
+    </ol>
+</nav>
+
+<!-- flash messages -->
+{% include "includes/flash.html" %}
+
+<!-- item list -->
+<div class="container mt-5">
+    <div class="row">
+        <div class="list-group col-12">
+            {% for category, item_list in items.items() %}
+                <!-- header -->
+                <p class="list-group-item active h4">{{ category|name_by_type }}</p>
+                
+                <!-- items -->
+                {% for item in item_list %}
+                    <a class="list-group-item list-group-item-action h5" href="/{{ item }}">{{ item|name_by_type }}</a>
+                {% endfor %}
+            {% endfor %}
+        </div>
+    </div>
+</div>
+
+{% endblock %}

+ 162 - 0
ui/app/templates/testrun/item.html

@@ -0,0 +1,162 @@
+    <div class="border-bottom pt-3">
+        <h1 >{{ item }}</h1>
+        <p><small>
+                Created {{ item.created.strftime('%Y-%m-%d %H:%M') }} by {{ item.creator }}
+        </small></p>
+    
+        {% if item.edited %}
+        <p><small>
+            Last edidted {{ item.edited.strftime('%Y-%m-%d %H:%M') }} by {{ item.editor }}
+        </small></p>
+        {% endif %}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Description:</h4>
+        <p>{{ item.description }}</div>
+    <div>
+
+    {% if type == "testrun" %}
+    <div class="border-bottom pt-3">
+        <h4>Test Case Sequences:</h4>
+        {% if item.testcase_sequences %}
+            <ul>
+            {% for sequence in item.testcase_sequences %}
+                <li>{{ sequence }}</li>
+            {% endfor %}
+            </ul>
+        {% else %}
+            <p>No Test Case Sequence Defined</p>
+        {% endif %}
+    </div>
+    {% endif %}
+
+    {% if type == "testcase_sequence" or type == "testcase" or type == "teststep_sequences" %}
+    <div class="border-bottom pt-3">
+        <h4>Class Name:</h4>
+        {{ item.classname }}
+    </div>
+    {% endif %}
+
+    {% if type == "testcase_sequence" %}
+    <div class="border-bottom pt-3">
+        <h4>Data Files:</h4>
+        {% if item.datafiles %}
+            <ul>
+            {% for datafile in item.datafiles %}
+                <li>{{ datafile }}</li>
+            {% endfor %}
+            </ul>
+        {% else %}
+            <p>No Data File Defined</p>
+        {% endif %}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Test Cases:</h4>
+        {% if item.testcases %}
+            <ul>
+            {% for testcase in item.testcases %}
+                <li>{{ testcase }}</li>
+            {% endfor %}
+            </ul>
+        {% else %}
+            <p>No Test Case Defined</p>
+        {% endif %}
+    </div>
+    {% endif %}
+
+    {% if type == "testcase" %}
+    <div class="border-bottom pt-3">
+        <h4>Browser Type:</h4>
+        {{ item.browser_type }}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Test Case Type:</h4>
+        {{ item.testcase_type }}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Test Case Step Sequence:</h4>
+        {% if item.teststep_sequences %}
+            <ul>
+            {% for sequence in item.teststep_sequences %}
+                <li>{{ sequence }}</li>
+            {% endfor %}
+            </ul>
+        {% else %}
+            <p>No Test Case Step Sequence Defined</p>
+        {% endif %}
+    </div>
+    {% endif %}
+
+    {% if type == "teststep_sequence" %}
+    <div class="border-bottom pt-3">
+        <h4>Test Steps:</h4>
+        {% if item.teststeps %}
+            <ul>
+            {% for step in item.teststeps %}
+                <li>{{ step }}</li>
+            {% endfor %}
+            </ul>
+        {% else %}
+            <p>No Test Step Defined</p>
+        {% endif %}
+    </div>
+    {% endif %}
+
+    {% if type == "teststep" %}
+    <div class="border-bottom pt-3">
+        <h4>Activity Type:</h4>
+        {{ item.activity_type }}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Locator Type:</h4>
+        {{ item.locator_type }}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Locator:</h4>
+        {{ item.locator }}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Optional:</h4>
+        {{ item.optional }}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Timeout:</h4>
+        {{ item.timeout }}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Release:</h4>
+        {{ item.release }}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Value:</h4>
+        {{ item.value }}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Value 2:</h4>
+        {{ item.value2 }}
+    </div>
+
+    <div class="border-bottom pt-3">
+        <h4>Comparison:</h4>
+        {{ item.comparison }}
+    </div>
+    {% endif %}
+
+    <p>
+        <button class="btn btn-primary px-5 my-3 mr-3" onclick="edit_item('{{ type }}', '{{ item.id }}')">Edit</button>
+        <button class="btn btn-danger px-5 my-3" onclick="delete_item('{{ type }}', '{{ item.name}}', '{{ item.id }}')">Delete</button>
+    </p>
+
+
+

+ 470 - 0
ui/app/templates/testrun/item_list.html

@@ -0,0 +1,470 @@
+{% extends "base.html" %}
+
+{% block title %}
+    {{ type|name_by_type }}
+{% endblock %}
+
+{% block content %}
+
+<main onLoad="window.scroll(0, 150)">
+
+<!-- bread crumb -->
+<nav aria-label="breadcrumb">
+    <ol class="breadcrumb">
+        <li class="breadcrumb-item"><a href="/">Home</a></li>
+        <li class="breadcrumb-item active" aria-current="page">{{ type|name_by_type }}</li>
+    </ol>
+</nav>
+
+<!-- flash messages -->
+{% include "includes/flash.html" %}
+
+<!-- loading screen -->
+{% if type == 'testrun' %}
+<div id="loading">
+    <div class="d-flex justify-content-center align-items-center flex-column h-80">
+        <h1>Please wait</h1>
+        <h2 id="titleLoading">Loading...</h2>
+        <div class="spinner-border spinner mt-5" role="status">
+            <span class="sr-only">Loading...</span>
+        </div>
+    </div>
+</div>
+{% endif %}
+
+<!-- item list -->
+<div class="container mt-5">
+
+    <!-- item title -->
+    <div class="row">
+        <div class="col-md-9">
+            <h2>{{ type|name_by_type }}</h2>
+        </div>
+        <!-- create button -->
+        <div class="col-md-3">
+            <a class="btn btn-primary {% if type == 'testrun' %} btn-right {% endif %}" href="/{{ type }}/new" role="button">
+                <strong>&plus; </strong>create {{ type|name_by_type(False) }}
+            </a>
+        </div>
+    </div>
+
+    <!-- filter box -->
+    <div class="row mb-3">
+        <div class="col-md-9">
+            <input type="text" id="filter" placeholder="Search..." onkeyup="filter_items(this)">
+        </div>
+        <!-- Testrun Import button -->
+        {% if type == 'testrun' %}
+        <div class="col-md-3">
+            <button type="button" class="btn btn-success btn-right" data-toggle="modal" data-target="#importModal">
+                <strong>&plus; </strong>import {{ type|name_by_type(False) }}
+            </button>
+        </div>
+
+        <!-- Import Modal -->
+        <div class="modal fade" id="importModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
+            <div class="modal-dialog" role="document">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h5 class="modal-title" id="exampleModalLabel">Import {{ type|name_by_type(False) }}</h5>
+                        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                            <span aria-hidden="true">&times;</span>
+                        </button>
+                    </div>
+                    <form method="post" enctype="multipart/form-data" action="{{ url_for('import_testsun') }}">
+                        {{ form.hidden_tag() }}
+                        <div class="modal-body">
+                            <div class="custom-file mb-3">
+                                {{ form.file(class="custom-file-input", accept=".xlsx", onchange="get_file(this)") }}
+                                <label class="custom-file-label" for="{{ form.file.name }}">{{ form.file.label }}</label>
+                            </div>
+                            <input type=hidden id="activeDataFiles" value="0">
+                            <div id="dataFileHeader" style="display:none"><em >Data Files</em></div>
+                            {% for dataFileFiled in form.dataFiles %}
+                            <div id="dataDiv{{ loop.index }}" class="custom-file mb-1" style="display:none">
+                                {{ dataFileFiled(class="custom-file-input", accept=".xlsx", onchange="get_file(this)") }}
+                                <label class="custom-file-label" for="{{ dataFileFiled.name }}">{{ dataFileFiled.label }}</label>
+                            </div>
+                            {% endfor %}
+                            <a id="dataFileExpander" class="text-info mt-2" href="#" onclick="add_datafile_link({{ form.dataFiles|length }})">
+                                Add datafile
+                            </a>
+                        </div>
+                        <div class="modal-footer">
+                            <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+                            <button type="submit" class="btn btn-primary">Import</button>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+
+        {% endif %}
+    </div>
+
+    <!-- header -->
+    <div class="row border-top border-bottom py-3">
+        <div class="col-md-4"><strong>Name</strong></div>
+        <div class="col-md-5"><strong>Description</strong></div>
+
+        <!-- Test Step fields -->
+        {% if type == 'teststep' %}
+            <div class="col"><strong>Activity Type</strong></div>
+        {% else %}
+            <div class="col"><strong>Created</strong></div>
+        {% endif %}
+    </div>
+
+    <!-- item list -->
+    {% for item in items %}
+        <div class="row border-top py-3 testrun-item" id="item{{ item.id }}">
+            <div class="col-md-4 text-break">
+                <a class="filtered" data-toggle="collapse" href="#expand{{ item.id }}" aria-expanded="false">
+                    {{ item.name }}
+                </a>
+            </div>
+            <div class="filtered col-md-5 text-break text-break">{{ item.description }}</div>
+            {% if type == 'teststep' %}    
+                <div class="filtered col">{{ item.activity_type }}</div>
+            {% else %}
+                <div class="col">{{ item.created|time }}</div>
+            {% endif %}
+
+            <div class="col-12 collapse{% if request.args.get('show')|int == item.id %}.show{% endif %} mt-1" id="expand{{ item.id }}">
+                <!-- created data -->
+                <p><small>
+                    Created {{ item.created|time }} by {{ item.creator }}
+                </small></p>
+                <!-- edited data -->
+                {% if item.edited %}
+                    <p><small>
+                        Last edidted {{ item.edited|time }} by {{ item.editor }}
+                    </small></p>
+                {% endif %}
+
+                <ul class="list-group">
+
+                    <!-- description -->
+                    {% if not type == 'teststep' %}
+                        <li class="list-group-item h5 list-group-item-dark">Description</li>
+                        <li class="list-group-item">{{ item.description }}</li>
+                    {% endif %}
+
+                    <!-- Testrun fields -->
+                    {% if type == 'testrun' %}
+
+                        <li class="list-group-item h5 list-group-item-dark">{{ 'testcase_sequence'|name_by_type }}</li>
+                        {% if item.testcase_sequences %}
+                            {% for sequence in item.testcase_sequences %}
+                                <li class="list-group-item">
+                                    <a href="/testcase_sequence?show={{ sequence.id }}#item{{ sequence.id }}">{{ sequence }}</a>
+                                </li>
+                            {% endfor %}
+                        {% else %}
+                            <li class="list-group-item">No Test Case Sequence Defined</li>
+                        {% endif %}
+                        {% if item.calls %}
+                            <li class="list-group-item h5 list-group-item-dark">Calls</li>
+                            {% for call in item.calls %}
+                                <li class="list-group-item">
+                                    <div class="row py-2">
+                                        <div class="col-md-6">
+                                            <a href={{ url_for("get_results", call_id=call) }}>{{ call }}</a>
+                                        </div>
+                                        <div class="col-md-4">
+                                            {{ call.created|time }}
+                                        </div>
+                                        <div class="col-md-2">
+                                            {{ call.creator }}
+                                        </div>
+                                    </div>
+                                    
+                                </li>
+                            {% endfor %}
+                        {% endif %}
+
+                    {% endif %}
+
+                    <!-- Class Name -->
+                    {% if type == 'testcase_sequence' or type == 'testcase' or type == 'teststep_sequences' %}
+
+                    <li class="list-group-item h5 list-group-item-dark">Class Name</li>
+                    <li class="list-group-item">{{ item.classname }}</li>
+                    
+                    {% endif %}
+
+                    <!-- Testcase Sequence fields -->
+                    {% if type == 'testcase_sequence' %}
+
+                    <li class="list-group-item h5 list-group-item-dark">Data Files</li>
+                    {% if item.datafiles %}
+                        {% for datafile in item.datafiles %}
+                        <li class="list-group-item">{{ datafile }}</li>
+                        {% endfor %}
+                    {% else %}
+                        <li class="list-group-item">No Data File Defined</li>
+                    {% endif %}
+
+                    <li class="list-group-item h5 list-group-item-dark">{{ 'testcase'|name_by_type }}</li>
+                    {% if item.testcases %}
+                        {% for testcase in item.testcases %}
+                        <li class="list-group-item">
+                            <a href="/testcase?show={{ testcase.id }}#item{{ testcase.id }}">{{ testcase }}</a>
+                        </li>
+                        {% endfor %}
+                    {% else %}
+                        <li class="list-group-item">No Test Case Defined</li>
+                    {% endif %}
+
+                    {% endif %}
+
+                    <!-- Test Case fields -->
+                    {% if type == 'testcase' %}
+
+                    <li class="list-group-item h5 list-group-item-dark">Browser Type</li>
+                    <li class="list-group-item">{{ item.browser_type }}</li>
+
+                    <li class="list-group-item h5 list-group-item-dark">Test Case Type</li>
+                    <li class="list-group-item">{{ item.testcase_type }}</li>
+
+                    <li class="list-group-item h5 list-group-item-dark">{{ 'teststep_sequence'|name_by_type }}</li>
+                    {% if item.teststep_sequences %}
+                        {% for sequence in item.teststep_sequences %}
+                        <li class="list-group-item">
+                            <a href="/teststep_sequence?show={{ sequence.id }}#item{{ sequence.id }}">{{ sequence }}</a>
+                        </li>
+                        {% endfor %}
+                    {% else %}
+                        <li class="list-group-item">No Test Case Step Sequence Defined</li>
+                    {% endif %}
+
+                    {% endif %}
+
+                    <!-- Test Step Sequence fields -->
+                    {% if type == 'teststep_sequence' %}
+
+                    <li class="list-group-item h5 list-group-item-dark">{{ 'teststep'|name_by_type }}</li>
+                    {% if item.teststeps %}
+                        {% for step in item.teststeps %}
+                        <li class="list-group-item">
+                            <a href="/teststep?show={{ step.id }}#item{{ step.id }}">{{ step }}</a>
+                        </li>
+                        {% endfor %}
+                    {% else %}
+                        <li class="list-group-item">No Test Step Defined</li>
+                    {% endif %}
+
+                    {% endif %}
+
+                    <!-- Test Step fields -->
+                    {% if type == 'teststep' %}
+                    <li class="list-group-item h5 list-group-item-dark">Parameters</li>
+                    <li class="list-group-item">
+                        <!-- 
+                        <div class="row border-bottom py-2">
+                            <div class="col-md-3">
+                                <strong>Activity Type</strong>
+                            </div>
+                            <div class="col">
+                                {{ item.activity_type }}
+                            </div>
+                        </div>
+                        -->
+
+                        <!-- Valu/Comparison -->
+                        <div class="row py-2">
+                            <div class="col-md-2">
+                                <strong>Value</strong>
+                            </div>
+                            <div class="col-md-2">
+                                <strong>Comparison</strong>
+                            </div>
+                            <div class="col-md-2">
+                                <strong>Value 2</strong>
+                            </div>
+                        </div>
+
+                        <div class="row border-bottom py-2">
+                            <div class="col-md-2 text-break">
+                                {{ item.value }}
+                            </div>
+                            <div class="col-md-2">
+                                {{ item.comparison }}
+                            </div>
+                            <div class="col-md-2">
+                                {{ item.value2 }}
+                            </div>
+                        </div>
+
+                        <!-- others -->
+                        <div class="row border-bottom py-2">
+                            <div class="col-md-3">
+                                <strong>Locator Type</strong>
+                            </div>
+                            <div class="col-md-9">
+                                {{ item.locator_type }}
+                            </div>
+                        </div>
+
+                        <div class="row border-bottom py-2">
+                            <div class="col-md-3">
+                                <strong>Locator</strong>
+                            </div>
+                            <div class="col-md-9">
+                                {% if item.locator %}{{ item.locator }}{% endif %}
+                            </div>
+                        </div>
+
+
+                        <div class="row border-bottom py-2">
+                            <div class="col-md-3">
+                                <strong>Optional</strong>
+                            </div>
+                            <div class="col-md-9">
+                                {{ item.optional }}
+                            </div>
+                        </div>
+
+                        <div class="row border-bottom py-2">
+                            <div class="col-md-3">
+                                <strong>Timeout</strong>
+                            </div>
+                            <div class="col-md-9">
+                                {% if item.timeout %}{{ item.timeout }}{% endif %}
+                            </div>
+                        </div>
+
+                        <div class="row border-bottom py-2">
+                            <div class="col-md-3">
+                                <strong>Release</strong>
+                            </div>
+                            <div class="col-md-9">
+                                {% if item.release %}{{ item.release }}{% endif %}
+                            </div>
+                        </div>
+                    </li>
+
+                    {% endif %}
+
+                </ul>
+                
+                <!-- buttons -->
+                <p>
+                    <a class="btn btn-primary px-5 my-3 mr-3" href="/{{ type }}/{{ item.id }}/edit" role="button">Edit</a>
+                    <!-- Testrun buttons -->
+                    {% if type == 'testrun' %}
+                        <!-- Update button -->
+                        <button type="button" class="btn btn-primary px-5 my-3 mr-3" data-toggle="modal" data-target="#updateModal" onmousedown="set_update_item('{{ item.id }}')">
+                            Update
+                        </button>
+                        <!-- Export button -->
+                        <button type="button" class="btn btn-success px-5 my-3 mr-3" data-toggle="modal" data-target="#exportModal" onmousedown="set_export_id('{{ item.id }}')">
+                            Export
+                        </button>
+                        <!-- Run button -->
+                        <button type="button" class="btn btn-success px-5 my-3 mr-3" onmousedown="run_item('{{ item.id }}')">
+                            Run
+                        </button>
+                    {% endif %}
+                    <button class="btn btn-danger px-5 my-3" data-toggle="modal" data-target="#deleteModal" onmousedown="set_delete_item('{{ type|name_by_type }}','{{ type }}','{{ item.id }}')">
+                        Delete
+                    </button>
+                </p>
+               
+            </div>
+        </div>
+    {% endfor %}
+
+    <!-- Update Modal -->
+    {% if type == 'testrun' %}
+        <div class="modal fade" id="updateModal" tabindex="-1" role="dialog" aria-labelledby="updateModalLabel" aria-hidden="true">
+            <div class="modal-dialog" role="document">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h5 class="modal-title" id="updateLabel"></h5>
+                        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                            <span aria-hidden="true">&times;</span>
+                        </button>
+                    </div>
+                    <form method="post" enctype="multipart/form-data" id="updateForm">
+                        {{ form.hidden_tag() }}
+                        <div class="modal-body">
+                            <div class="custom-file">
+                                {{ form.file(class="custom-file-input", accept=".xlsx", onchange="get_file(this)") }}
+                                <label class="custom-file-label" for="{{ form.file.name }}">Choose a file</label>
+                            </div>
+                        </div>
+                        <div class="modal-footer">
+                            <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+                            <button type="submit" class="btn btn-primary">Update</button>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+    {% endif %}
+
+    <!-- Export  Modal -->
+    <div class="modal fade" id="exportModal" data-backdrop="static" tabindex="-1" role="dialog" aria-labelledby="exportLabel" aria-hidden="true">
+        <div class="modal-dialog" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="exportLabel">Export Testrun</h5>
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                        <span aria-hidden="true">&times;</span>
+                    </button>
+                </div>
+                <div class="modal-body" id="exportModalBody">
+                    <div id="exportResponse">
+                    </div>
+                    <div id="exportRequest">
+                        <div class="custom-control custom-radio">
+                            <input type="radio" id="xlsxRadio" name="formatRadio" value="XLSX" class="custom-control-input" checked>
+                            <label class="custom-control-label" for="xlsxRadio">XLSX</label>
+                        </div>
+                        <div class="custom-control custom-radio">
+                            <input type="radio" id="jsonRadio" name="formatRadio" value="JSON" class="custom-control-input">
+                            <label class="custom-control-label" for="jsonRadio">JSON</label>
+                        </div>
+                    </div>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+                    <button type="button" class="btn btn-primary" id="exportButton" onclick="export_item(this)">Export</button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Delete Modal -->
+    <div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteLabel" aria-hidden="true">
+        <div class="modal-dialog" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="deleteLabel"></h5>
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                        <span aria-hidden="true">&times;</span>
+                    </button>
+                </div>
+                <div class="modal-body">
+                    Delete all child elements?
+                </div>
+                <div class="modal-footer dialog" id="deleteButtons">
+                    <button type="button" class="btn btn-secondary btn-right" onclick="delete_item(this.parentElement, true)">
+                        Yes
+                    </button>
+                    <button type="button" class="btn btn-primary btn-right" onclick="delete_item(this.parentElement, false)">
+                        No
+                    </button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+
+
+</div>
+</main>
+
+
+{% endblock %}

+ 38 - 0
ui/app/templates/testrun/login.html

@@ -0,0 +1,38 @@
+{% extends "base.html" %}
+
+{% block title %}
+Login
+{% endblock %}
+
+{% block content %}
+<div class="d-flex flex-column text-center mt-5">
+	<h1 class="py-5">Please Login</h1>
+	
+	<!-- login form -->
+	<form method="post">
+		{{ form.hidden_tag() }}
+
+		<p>
+			{% for error in form.username.errors %}
+				<p class="text-danger">{{ error }}</p>
+			{% endfor %}
+			{{ form.username(placeholder="Username") }}
+		</p>
+		<p>
+			{% for error in form.password.errors %}
+				<p class="text-danger">{{ error }}</p>
+			{% endfor %}
+			{{ form.password(placeholder="Password") }}
+		</p>
+		<p>
+			<button class="btn btn-primary px-5" type="submit">Login</button>
+		</p>
+	</form>
+
+	<div class="mt-2">
+		Not registered yet? <a href="/signup">Sign up here</a>
+	</div>
+
+</div>
+
+{% endblock %}

+ 43 - 0
ui/app/templates/testrun/signup.html

@@ -0,0 +1,43 @@
+{% extends "base.html" %}
+
+{% block title %}
+Sign up
+{% endblock %}
+
+{% block content %}
+<div class="d-flex flex-column text-center mt-5">
+	<h1 class="py-5">Register a new user</h1>
+	
+	<!-- sign up form -->
+	<form method="post">
+		{{ form.hidden_tag() }}
+		<p>
+			{% for error in form.username.errors %}
+				<p class="text-danger">{{ error }}</p>
+			{% endfor %}
+			{{ form.username(placeholder="Username") }}
+		</p>
+		<p>
+			{% for error in form.password.errors %}
+				<p class="text-danger">{{ error }}</p>
+			{% endfor %}
+			{{ form.password(placeholder="Password") }}
+		</p>
+		<p>
+			{% for error in form.password2.errors %}
+				<p class="text-danger">{{ error }}</p>
+			{% endfor %}
+			{{ form.password2(placeholder="Password again") }}
+		</p>
+		<p>
+			<button class="btn btn-primary px-5" type="submit">Sign up</button>
+		</p>
+	</form>
+
+	<div class="mt-2">
+		Already registered? <a href="/login">Log in here</a>
+	</div>
+
+</div>
+
+{% endblock %}

+ 120 - 0
ui/app/templates/testrun/testrun_results.html

@@ -0,0 +1,120 @@
+{% extends "base.html" %}
+
+{% block title %}
+    Results
+{% endblock %}
+
+{% block content %}
+
+<main>
+
+<!-- bread crumb -->
+<nav aria-label="breadcrumb">
+    <ol class="breadcrumb">
+        <li class="breadcrumb-item"><a href="/">Home</a></li>
+        <li class="breadcrumb-item"><a href="/testrun">{{ 'testrun'|name_by_type }}</a></li>
+        <li class="breadcrumb-item active" aria-current="page">{{ results.id }}</li>
+    </ol>
+</nav>
+
+<!-- flash messages -->
+{% include "includes/flash.html" %}
+
+<div class="container mt-5">
+    <div class="border-bottom pt-3">
+        <h1>{{ results.Name }}</h1>
+    </div>
+
+    <!-- Summary -->
+    <div class="card border-dark mt-3">
+        <div class="card-header h4">Summary</div>
+        <div class="card-body text-dark">
+            <table class="table">
+                <tbody>
+                    <tr>
+                        <td>Testrecords</td>
+                        <td>{{ results.Summary.Testrecords }}</td>
+                    </tr>
+                    <tr class="table-success">
+                        <td>Successful</td>
+                        <td>{{ results.Summary.Successful }}</td>
+                    </tr>
+                    <tr>
+                        <td>Paused</td>
+                        <td>{{ results.Summary.Paused }}</td>
+                    </tr>
+                    <tr class="table-danger">
+                        <td>Error</td>
+                        <td>{{ results.Summary.Error }}</td>
+                    </tr>
+                    <tr>
+                        <td>Starttime</td>
+                        <td>{{ results.Summary.Starttime }}</td>
+                    </tr>
+                    <tr>
+                        <td>Endtime</td>
+                        <td>{{ results.Summary.Endtime }}</td>
+                    </tr>
+                    <tr>
+                        <td>Duration</td>
+                        <td><strong>{{ results.Summary.Duration }}</strong></td>
+                    </tr>
+                </tbody>
+            </table>
+        </div>
+    </div>
+
+    <!-- Globals -->
+    <div class="card border-dark mt-3">
+        <div class="card-header h4">Global Settings</div>
+        <div class="card-body text-dark">
+            <table class="table">
+                <tbody>
+                    {% for key, value in results.GlobalSettings.items() %}
+                        <tr>
+                            <td>{{ key }}</td>
+                            <td>{{ value }}</td>
+                        </tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+
+    <!-- Records -->
+    <h1 class="mt-3">Records</h1>
+    {% for test_seq in results.TestSequences %}
+        {% for tc in test_seq.TestCases %}
+            {% if tc.Parameters.get('TestCaseStatus') == 'OK' %}
+                {% set status = 'success' %}
+            {% elif tc.Parameters.get('TestCaseStatus') == 'Failed' %}
+                {% set status = 'danger' %}
+            {% else %}
+                {% set status = 'dark' %}
+            {% endif %}
+            <div class="card border-{{ status }} mt-3">
+                <div class="card-header h4">Record #{{ loop.index }}</div>
+                <div class="card-body">
+                    <table class="table text-{{ status }}">
+                        <tbody>
+                            {% for key, value in tc.Parameters.items() %}
+                                <tr>
+                                    <td>{{ key }}</td>
+                                    <td>{{ value }}</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+            </div>
+        {% endfor %}
+    {% endfor %}
+        
+
+
+
+</div>
+
+</main>
+
+{% endblock %}

+ 51 - 0
ui/app/templates/testrun/testrun_status.html

@@ -0,0 +1,51 @@
+{% extends "base.html" %}
+
+{% block title %}
+    Testrun execution
+{% endblock %}
+
+{% block content %}
+
+<main>
+
+<!-- bread crumb -->
+<nav aria-label="breadcrumb">
+    <ol class="breadcrumb">
+        <li class="breadcrumb-item"><a href="/">Home</a></li>
+        <li class="breadcrumb-item"><a href="/testrun">{{ 'testrun'|name_by_type }}</a></li>
+        <li class="breadcrumb-item active" aria-current="page">{{ results.id }}</li>
+    </ol>
+</nav>
+
+<!-- flash messages -->
+{% include "includes/flash.html" %}
+
+<div class="container mt-5">
+    <div class="border-bottom pt-3">
+        <h1>Testrun Execution Status</h1>
+    </div>
+
+    {% if status == 202 %}
+    <div class="card bg-light mt-3">
+        <div class="card-header">In progress</div>
+        <div class="card-body">
+            <h5 class="card-title">ID: {{ results.id }}</h5>
+            <p class="card-text">Testrun is executing. After the testrun is completed the results will be availabale at:</p>
+            <a href={{ url_for("get_results", call_id=results.id) }}>{{ request.host }}{{ url_for("get_results", call_id=results.id) }}</a>
+        </div>
+    </div>
+    {% else %}
+    <div class="card text-white bg-danger mt-3">
+        <div class="card-header">Error</div>
+        <div class="card-body">
+            <h5 class="card-title">ID: {{ results.id }}</h5>
+            <p class="card-text">{{ results.error }}</p>
+        </div>
+    </div>
+    {% endif %}
+
+</div>
+
+</main>
+
+{% endblock %}

File diff suppressed because it is too large
+ 1065 - 0
ui/app/utils.py


+ 605 - 0
ui/app/views.py

@@ -0,0 +1,605 @@
+
+from flask import render_template, redirect, flash, request, url_for, send_from_directory, jsonify
+from flask_login import login_required, current_user, login_user, logout_user
+from flask.logging import default_handler
+from app import app, db, models, forms, utils
+from datetime import datetime
+import requests
+import json
+import uuid
+import os
+
+# handle favicon requests
+@app.route('/favicon.ico') 
+def favicon(): 
+    return send_from_directory('static/media', 'favicon.ico', mimetype='image/vnd.microsoft.icon')
+
+@app.route('/')
+@login_required
+def index():
+	return render_template('testrun/index.html', items=utils.getItemCategories())
+
+@app.route('/<string:item_type>')
+@login_required
+def item_list(item_type):
+	# placeholder for import form
+	form = None
+	# get item list by type
+	if item_type == 'testrun':
+		items = models.Testrun.query.all()
+		# build form for importing a testrun
+		form = forms.TestrunImportForm()
+		
+	elif item_type == 'testcase_sequence':
+		items = models.TestCaseSequence.query.all()
+	elif item_type == 'testcase':
+		items = models.TestCase.query.all()
+	elif item_type == 'teststep_sequence':
+		items = models.TestStepSequence.query.all()
+	elif item_type == 'teststep':
+		items = models.TestStepExecution.query.all()
+	else:
+		app.logger.warning(f'Item type "{item_type}" does not exist. Requested by "{current_user}".')
+		flash(f'Item type "{item_type}" does not exist.', 'warning')
+		return redirect(url_for('index'))
+
+	return render_template('testrun/item_list.html', type=item_type, items=items, form=form)
+
+
+#
+# TODO:
+# check for outdating
+#
+@app.route('/<string:item_type>/<int:item_id>', methods=['GET', 'POST'])
+@login_required
+def get_item(item_type, item_id):
+	# get item by type and id
+	if item_type == 'testrun':
+		item = models.Testrun.query.get(item_id)
+	elif item_type == 'testcase_sequence':
+		item = models.TestCaseSequence.query.get(item_id)
+	elif item_type == 'testcase':
+		item = models.TestCase.query.get(item_id)
+	elif item_type == 'teststep_sequence':
+		item = models.TestStepSequence.query.get(item_id)
+	elif item_type == 'teststep':
+		item = models.TestStepExecution.query.get(item_id)
+	else:
+		app.logger.warning(f'Item type "{item_type}" does not exist. Requested by "{current_user}".')
+		flash(f'Item type "{item_type}" does not exist.', 'warning')
+		return redirect(url_for('index'))
+
+	return render_template('testrun/item.html', type=item_type, item=item)
+
+
+@app.route('/<string:item_type>/<int:item_id>/delete', methods=['POST', 'DELETE'])
+@login_required
+def delete_item(item_type, item_id):
+	#
+	# delete item
+	#
+
+	# cascade delete
+	if request.method == 'POST':
+		app.logger.info(f'Cascade deletion of {utils.getItemType(item_type)} ID {item_id} requested by "{current_user}".')
+		try:
+			utils.deleteCascade(item_type, item_id)
+			flash(f'Item {utils.getItemType(item_type)} ID #{item_id} and its children have been successfully deleted.', 'success')
+			app.logger.info(
+				f'Item {utils.getItemType(item_type)} ID #{item_id} and its children have been successfully deleted by "{current_user}".'
+			)
+			return jsonify({'success': 'OK'}), 200
+		except Exception as error:
+			app.logger.error(f'Failed to cascade delete {utils.getItemType(item_type)} ID {item_id} requested by "{current_user}": {error}')
+			return jsonify({'error': 'Bad Request'}), 400
+
+	# delete single item
+	elif request.method == 'DELETE':
+		# get item by type and id
+		app.logger.info(f'Deletion of {utils.getItemType(item_type)} ID {item_id} requested by "{current_user}".')
+		if item_type == 'testrun':
+			item = models.Testrun.query.get(item_id)
+		elif item_type == 'testcase_sequence':
+			item = models.TestCaseSequence.query.get(item_id)
+		elif item_type == 'testcase':
+			item = models.TestCase.query.get(item_id)
+		elif item_type == 'teststep_sequence':
+			item = models.TestStepSequence.query.get(item_id)
+		elif item_type == 'teststep':
+			item = models.TestStepExecution.query.get(item_id)
+		else:
+			app.logger.warning(f'Item type "{item_type}" does not exist. Requested by "{current_user}".')
+			flash(f'Item type "{item_type}" does not exist.', 'warning')
+			#return redirect(url_for('index'))
+			return jsonify({'error': 'Bad Request'}), 400
+
+		db.session.delete(item)
+		db.session.commit()
+		app.logger.info(f'Item {utils.getItemType(item_type)} ID {item_id} successfully deleted by {current_user}.')
+		flash(f'Item "{item.name}" has been successfully deleted.', 'success')
+		#return redirect(url_for('item_list', item_type=item_type))
+		return jsonify({'success': 'OK'}), 200
+
+	return jsonify({'error': 'Method Not Allowed'}), 405
+
+
+@app.route('/<string:item_type>/<int:item_id>/edit', methods=['GET', 'POST'])
+@login_required
+def edit_item(item_type, item_id):
+	#
+	# edit item
+	#
+	if item_type == 'testrun':
+		item = models.Testrun.query.get(item_id)
+		form = forms.TestrunCreateForm.new()
+		if request.method == 'GET':
+			form.testcase_sequences.data = [f'{x.id}' for x in item.testcase_sequences]
+	elif item_type == 'testcase_sequence':
+		item = models.TestCaseSequence.query.get(item_id)
+		form = forms.TestCaseSequenceCreateForm.new()
+		if request.method == 'GET':
+			form.classname.data = f'{item.classname.id}'
+			form.datafiles.data = [f'{x.id}' for x in item.datafiles]
+			form.testcases.data = [f'{x.id}' for x in item.testcases]
+	elif item_type == 'testcase':
+		item = models.TestCase.query.get(item_id)
+		form = forms.TestCaseCreateForm.new()
+		if request.method == 'GET':
+			form.classname.data = f'{item.classname.id}'
+			form.browser_type.data = f'{item.browser_type.id}'
+			form.testcase_type.data = f'{item.testcase_type.id}'
+			form.testcase_stepsequences.data = [f'{x.id}' for x in item.teststep_sequences]
+	elif item_type == 'teststep_sequence':
+		item = models.TestStepSequence.query.get(item_id)
+		form = forms.TestStepSequenceCreateForm.new()
+		if request.method == 'GET':
+			form.classname.data = f'{item.classname.id}'
+			form.teststeps.data =[f'{x.id}' for x in item.teststeps]
+	elif item_type == 'teststep':
+		item = models.TestStepExecution.query.get(item_id)
+		form = forms.TestStepCreateForm.new()
+		if request.method == 'GET':
+			form.activity_type.data = f'{item.activity_type.id}'
+			form.locator_type.data = f'{item.locator_type.id}'
+			# model extension
+			form.locator.data = item.locator or ''
+			if item.optional:
+				form.optional.data = '2'
+			else:
+				form.optional.data = '1'
+			if item.timeout:
+				form.timeout.data = f'{item.timeout}'
+			else:
+				form.timeout.data = ''
+			form.release.data = item.release or ''
+			form.value.data = item.value or ''
+			form.value2.data = item.value2 or ''
+			form.comparison.data = utils.getComparisonId(item.comparison)
+
+	else:
+		app.logger.warning(f'Item type "{item_type}" does not exist. Requested by "{current_user}".')
+		flash(f'Item type "{item_type}" does not exist.', 'warning')
+		return redirect(url_for('index'))
+
+	if request.method == 'GET':
+		form.name.data = item.name
+		form.description.data = item.description
+
+	if  form.validate_on_submit():
+		# update item data
+		item.name = form.name.data
+		item.description = form.description.data
+		# testrun
+		if item_type == 'testrun':
+			item.editor = current_user
+			item.edited = datetime.utcnow()
+			item.testcase_sequences=[models.TestCaseSequence.query.get(int(x)) for x in form.testcase_sequences.data]
+		# testcase sequence
+		elif item_type == 'testcase_sequence':
+			item.editor = current_user
+			item.edited = datetime.utcnow()
+			item.classname = models.ClassName.query.get(int(form.classname.data))
+			item.datafiles = [models.DataFile.query.get(int(x)) for x in form.datafiles.data]
+			item.testcases = [models.TestCase.query.get(int(x)) for x in form.testcases.data]
+		# testcase
+		elif item_type == 'testcase':
+			item.editor = current_user
+			item.edited = datetime.utcnow()
+			item.classname = models.ClassName.query.get(int(form.classname.data))
+			item.browser_type = models.BrowserType.query.get(int(form.browser_type.data))
+			item.testcase_type = models.TestCaseType.query.get(int(form.testcase_type.data))
+			item.teststep_sequences = [models.TestStepSequence.query.get(int(x)) for x in form.testcase_stepsequences.data]
+		# step sequence
+		elif item_type == 'teststep_sequence':
+			item.editor = current_user
+			item.edited = datetime.utcnow()
+			item.classname = models.ClassName.query.get(int(form.classname.data))
+			item.teststeps = [models.TestStepExecution.query.get(int(x)) for x in form.teststeps.data]
+		# test step
+		elif item_type == 'teststep':
+			item.editor = current_user
+			item.edited = datetime.utcnow()
+			item.activity_type = models.ActivityType.query.get(int(form.activity_type.data))
+			item.locator_type = models.LocatorType.query.get(int(form.locator_type.data))
+			# model extension
+			item.locator = form.locator.data
+			item.optional = [None, False, True][int(form.optional.data)]
+			try:
+				item.timeout = float(form.timeout.data)
+			except ValueError:
+				item.timeout = None
+			item.release = form.release.data or None
+			item.value = form.value.data or None
+			item.value2 = form.value2.data or None
+			if form.comparison.data == '0':
+				item.comparison = None
+			else:
+				item.comparison = utils.COMPARISONS[int(form.comparison.data)-1]
+			
+
+		# update item in db
+		db.session.commit()
+		app.logger.info(f'Edited {item_type} id {item_id} by {current_user}.')
+		flash(f'Item "{item.name}" successfully updated.', 'success')
+		return redirect(url_for('item_list', item_type=item_type))
+
+
+	return render_template('testrun/edit_item.html', type=item_type, item=item, form=form)
+
+
+@app.route('/<string:item_type>/new', methods=['GET', 'POST'])
+@login_required
+def new_item(item_type):
+	#
+	# create new item
+	#
+	if item_type == 'testrun':
+		form = forms.TestrunCreateForm.new()
+		chips = ['testcase_sequences']
+	elif item_type == 'testcase_sequence':
+		form = forms.TestCaseSequenceCreateForm.new()
+		chips = ['datafiles', 'testcases']
+	elif item_type == 'testcase':
+		form = forms.TestCaseCreateForm.new()
+		chips = ['testcase_stepsequences']
+	elif item_type == 'teststep_sequence':
+		form = forms.TestStepSequenceCreateForm.new()
+		chips = ['teststeps']
+	elif item_type == 'teststep':
+		form = forms.TestStepCreateForm.new()
+		chips = []
+	else:
+		app.logger.warning(f'Item type "{item_type}" does not exist. Requested by "{current_user}".')
+		flash(f'Item type "{item_type}" does not exist.', 'warning')
+		return redirect(url_for('index'))
+
+	if form.validate_on_submit():
+		# create new item
+		# testrun
+		if item_type == 'testrun':
+			item = models.Testrun(
+				name=form.name.data,
+				description=form.description.data,
+				creator=current_user,
+				testcase_sequences=[models.TestCaseSequence.query.get(int(x)) for x in form.testcase_sequences.data],
+			)
+		# testcase sequence
+		elif item_type == 'testcase_sequence':
+			item = models.TestCaseSequence(
+				name=form.name.data,
+				description=form.description.data,
+				creator=current_user,
+				classname=models.ClassName.query.get(int(form.classname.data)),
+				datafiles=[models.DataFile.query.get(int(x)) for x in form.datafiles.data],
+				testcases=[models.TestCase.query.get(int(x)) for x in form.testcases.data],
+			)
+		# testcase
+		elif item_type == 'testcase':
+			item = models.TestCase(
+				name=form.name.data,
+				description=form.description.data,
+				creator=current_user,
+				classname=models.ClassName.query.get(int(form.classname.data)),
+				browser_type=models.BrowserType.query.get(int(form.browser_type.data)),
+				testcase_type=models.TestCaseType.query.get(int(form.testcase_type.data)),
+				teststep_sequences=[models.TestStepSequence.query.get(int(x)) for x in form.testcase_stepsequences.data],
+			)
+		# step sequence
+		elif item_type == 'teststep_sequence':
+			item = models.TestStepSequence(
+				name=form.name.data,
+				description=form.description.data,
+				creator=current_user,
+				classname=models.ClassName.query.get(int(form.classname.data)),
+				teststeps=[models.TestStepExecution.query.get(int(x)) for x in form.teststeps.data],
+			)
+		# test step
+		elif item_type == 'teststep':
+			item = models.TestStepExecution(
+				name=form.name.data,
+				description=form.description.data,
+				creator=current_user,
+				activity_type=models.ActivityType.query.get(int(form.activity_type.data)),
+				locator_type=models.LocatorType.query.get(int(form.locator_type.data)),
+				# model extension
+				locator=form.locator.data,
+				optional=[None, False, True][int(form.optional.data)],
+			)
+			try:
+				item.timeout = float(form.timeout.data)
+			except ValueError:
+				item.timeout = None
+			item.release = form.release.data or None
+			item.value = form.value.data or None
+			item.value2 = form.value2.data or None
+			if form.comparison.data == '0':
+				item.comparison = None
+			else:
+				item.comparison = utils.COMPARISONS[int(form.comparison.data)-1]
+
+		# save item to db
+		db.session.add(item)
+		db.session.commit()
+		app.logger.info(f'Created {item_type} id {item.id} by {current_user}.')
+		flash(f'Item "{item.name}" successfully created.', 'success')
+		return redirect(url_for('item_list', item_type=item_type))
+
+	return render_template('testrun/create_item.html', type=item_type, chips=chips, form=form)
+	#return render_template('testrun/edit_item.html', type=item_type, item=None, form=form)
+
+
+@app.route('/testrun/<int:item_id>/run')
+@login_required
+def run_testrun(item_id):
+	#
+	# runs Testrun via API Web Service
+	#
+
+	# check for complete set of Testrun's layers
+	try:
+		utils.testrunIntegrity(item_id)
+	except ValueError as error:
+		app.logger.error(f'Failed to execute Testrun ID #{item_id} by {current_user}. {error}.')
+		flash(f'ERROR: Testrun ID #{item_id} cannot be executed. {error}', 'danger')
+		return redirect(url_for('item_list', item_type='testrun'))
+	 
+	# export to JSON
+	try:
+		json_testrun = utils.exportJSON(item_id, shouldBeSaved=False)
+	except Exception as error:
+		app.logger.error(f'Failed to export Testrun ID #{item_id} by {current_user}. {error}')
+		flash(f'ERROR: Failed to export Testrun ID #{item_id}. {error}', 'danger')
+		return redirect(url_for('item_list', item_type='testrun'))
+
+	# call API
+	url = '/'.join(('http:/', app.config.get('BAANGT_API_HOST'), app.config.get('BAANGT_API_EXECUTE')))
+	app.logger.info(f'Call execution of Testrun ID #{item_id} by {current_user}. API URL: {url}')
+	try:
+		r = requests.post(url, json=json_testrun)
+	except Exception as error:
+		app.logger.error(f'Failed to connect to {app.config.get("BAANGT_API_HOST")} by {current_user}. {error}')
+		flash(f'ERROR: Cannot establish connection: {app.config.get("BAANGT_API_HOST")}', 'danger')
+		return redirect(url_for('item_list', item_type='testrun'))
+
+	# API server error handler
+	if r.status_code == 500:
+		app.logger.error('ERROR: API Server interanal error')
+		flash('ERROR: API Server interanal error', 'danger')
+		return redirect(url_for('item_list', item_type='testrun'))
+
+	jsonResult = json.loads(r.text)
+	app.logger.info(f'API response: status code {r.status_code} JSON {jsonResult}')
+
+	if r.status_code == 202:
+		# store to db
+		tr_call = models.TestRunCall(
+			id=uuid.UUID(jsonResult['id']).bytes,
+			creator=current_user,
+			testrun_id=item_id,
+		)
+		db.session.add(tr_call)
+		db.session.commit()
+		app.logger.info(f'Created Testrun Call id {jsonResult["id"]} by {current_user}.')
+		return render_template('testrun/testrun_status.html', results=jsonResult, status=r.status_code) 
+	else:
+		flash(f'ERROR: {r.text}', 'danger')
+		return redirect(url_for('item_list', item_type='testrun'))
+
+@app.route('/results/<string:call_id>')
+@login_required
+def get_results(call_id):
+	#
+	# runs Testrun on web-srvice
+	#
+
+	# call API
+	url = '/'.join(('http:/', app.config.get('BAANGT_API_HOST'), app.config.get('BAANGT_API_STATUS'), call_id))
+	app.logger.info(f'Call results by {current_user}. API URL: {url}')
+	try:
+		r = requests.get(url)
+	except Exception as error:
+		app.logger.error(f'Failed to connect to {app.config.get("TESTRUN_SERVICE_HOST")} by {current_user}. {error}')
+		flash(f'ERROR: Cannot establish connection: {app.config.get("TESTRUN_SERVICE_HOST")}', 'danger')
+		return redirect(url_for('item_list', item_type='testrun'))
+
+	# API server error handler
+	if r.status_code == 500:
+		app.logger.error('ERROR: API Server interanal error')
+		flash('ERROR: API Server interanal error', 'danger')
+		return redirect(url_for('item_list', item_type='testrun'))
+
+	jsonResult = json.loads(r.text)
+	app.logger.info(f'API response: status code {r.status_code} JSON {jsonResult}')
+
+	if r.status_code == 200:
+		return render_template('testrun/testrun_results.html', results=jsonResult)
+
+	return render_template('testrun/testrun_status.html', results=jsonResult, status=r.status_code) 
+
+
+
+@app.route('/results')
+def test_view():
+	#
+	# TEST view
+	#
+
+	with open('data/response.json', 'r') as f:
+		j = json.load(f)
+
+	return render_template('testrun/testrun_results.html', results=j) 
+
+
+@app.route('/testrun/<string:export_format>/<int:item_id>', methods=['GET', 'POST'])
+@login_required
+def export_testrun(export_format, item_id):
+	#
+	# export Testrun object to <export_format>
+	# <export_format> is one of the following:
+	#   XLSX
+	#   JSON
+	#
+
+	if export_format == 'XLSX':
+		# export to XLSX
+		try:
+			result = utils.exportXLSX(item_id)
+		except Exception as e:
+			app.logger.error(f'Failed to export Testrun id {item_id} by {current_user}. {e}')
+			return f'ERROR: {e}'
+
+	elif export_format == 'JSON':
+		# export to JSON
+		try:
+			result = utils.exportJSON(item_id)
+		except Exception as e:
+			app.logger.error(f'Failed to export Testrun id {item_id} by {current_user}. {e}')
+			return f'ERROR: {e}'
+
+	else:
+		# invalid format
+		app.logger.error(f'Failed to export Testrun id {item_id} by {current_user}. Invalid format: {export_format}')
+		return f'ERROR: format {export_format} is not supported.'
+
+	# successful export
+	url = url_for('static', filename=f'files/{result}')
+	app.logger.info(f'Testrun ID #{item_id} exported to {export_format} by {current_user}. Target: static/files/{result}')
+
+	return f'Success: <a href="{url}">{result}</a>' 
+
+@app.route('/testrun/import', methods=['POST'])
+@login_required
+def import_testsun():
+	#
+	# imports testrun from file
+	#
+
+	# import only from XLSX is available now
+	form = forms.TestrunImportForm()
+
+	if form.validate_on_submit():
+		#utils.importXLSX(form.file.data)
+		try:
+			utils.importXLSX(form.file.data, datafiles=form.dataFiles)
+			app.logger.info(f'Testrun successfully imported from "{form.file.data.filename}" by {current_user}.')
+			flash(f'Testrun successfully imported from "{form.file.data.filename}"', 'success')
+		except Exception as error:
+			# discard changes
+			db.session.rollback()
+			app.logger.error(f'Failed to import Testrun from "{form.file.data.filename}" by {current_user}. {error}.')
+			flash(f'ERROR: Cannot import Testrun from "{form.file.data.filename}". {error}.', 'danger')
+	else:
+		flash(f'File is required for import', 'warning')
+
+	return redirect(url_for('item_list', item_type='testrun'))
+
+@app.route('/datafile/upload', methods=['POST'])
+@login_required
+def upload_datafile():
+	# get datafile
+	file = request.files.get('datafile')
+	if file is None:
+		return jsonify({'error': 'Request does not contain dataFile'}), 400
+
+	# upload data file
+	try:
+		datafile_id = utils.importDataFile(file)
+	except Exception as error:
+		app.logger.error(f'Failed to uplod DataFile "{file.filename}" by {current_user}. {error}.')
+		return jsonify({'error': f'Failed upload DataFile "{file.filename}". {error}.'}), 400
+	
+	return jsonify({'id': datafile_id, 'name': file.filename}), 200
+
+
+@app.route('/testrun/<int:item_id>/import', methods=['POST'])
+@login_required
+def update_testsun(item_id):
+	#
+	# imports testrun from file
+	#
+
+	# update only from XLSX is available now
+	form = forms.TestrunImportForm()
+
+	if form.validate_on_submit():
+		
+		# update items
+		try:
+			utils.importXLSX(form.file.data, item_id=item_id)
+			app.logger.info(f'Testrun ID #{item_id} successfully updated from "{form.file.data.filename}" by {current_user}.')
+			flash(f'Testrun ID #{item_id} has been successfully updated from "{form.file.data.filename}"', 'success')
+		except Exception as error:
+			# discard changes
+			db.session.rollback()
+			app.logger.error(f'Failed to update Testrun ID #{item_id} from "{form.file.data.filename}" by {current_user}. {error}.')
+			flash(f'ERROR: Cannot update Testrun ID #{item_id} from "{form.file.data.filename}". {error}.', 'danger')
+	else:
+		flash(f'File is required for import', 'warning')
+
+	return redirect(url_for('item_list', item_type='testrun'))
+	
+
+#
+# user authentication
+#
+
+@app.route('/signup', methods=['GET', 'POST'])
+def signup():
+	if current_user.is_authenticated:
+		return redirect(url_for('index'))
+
+	form = forms.SingupForm()
+	if form.validate_on_submit():
+		# create user
+		user = models.User(username=form.username.data)
+		user.set_password(form.password.data)
+		db.session.add(user)
+		db.session.commit()
+		# login
+		login_user(user, remember=True)
+		flash(f'User {user.username.capitalize()} successfully created!', 'succsess')
+		return redirect(url_for('index'))
+
+	return render_template('testrun/signup.html', form=form)
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+	if current_user.is_authenticated:
+		return redirect(url_for('index'))
+
+	form = forms.LoginForm()
+	if form.validate_on_submit():
+		user = models.User.query.filter_by(username=form.username.data).first()
+		if user and user.verify_password(form.password.data):
+			login_user(user, remember=True)
+			flash(f'Welcome {user.username.capitalize()}!', 'success')
+			return redirect(url_for('index'))
+
+	return render_template('testrun/login.html', form=form)
+
+@app.route('/logout')
+def logout():
+	logout_user()
+
+	return redirect(url_for('login'))
+	

+ 36 - 0
ui/config.py

@@ -0,0 +1,36 @@
+import os
+
+
+#
+# configuration settings
+#
+
+class Config(object):
+	#
+	# secret 
+	#
+	SECRET_KEY = os.getenv('SECRET_KEY') or 'secret!key'
+
+	#
+	# SQL Alchemy
+	#
+	SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL') or \
+		'sqlite:///' + os.path.join(os.path.abspath(os.path.dirname(__file__)), 'testrun.db')
+	SQLALCHEMY_TRACK_MODIFICATIONS = False
+
+	#
+	# baangt API web-service
+	#
+	BAANGT_API_HOST = os.getenv('BAANGT_API_HOST') or '127.0.0.1:8000'
+	BAANGT_API_EXECUTE = 'v0.1/run/json'
+	BAANGT_API_STATUS = 'v0.1/results/json'
+
+	#
+	# baangt DataFile service
+	#
+	BAANGT_DATAFILE_HOST = os.getenv('BAANGT_DATAFILE_HOST') or '127.0.0.1:5050'
+	BAANGT_DATAFILE_SAVE = 'save'
+	BAANGT_DATAFILE_UPDATE = 'update'
+
+
+	

+ 41 - 0
ui/db_defaults.py

@@ -0,0 +1,41 @@
+###########################################
+#                                         #
+#   Adds default instances to BAANGT DB   #
+#                                         #
+###########################################
+
+from app import db, models
+import json
+
+path_to_defaults = 'defaults.json'
+
+classes = {
+	"classnames": models.ClassName,
+	"testcases": models.TestCaseType,
+	"browsers": models.BrowserType,
+	"activities": models.ActivityType,
+	"locators": models.LocatorType,
+}
+
+def create_instances(cls, items):
+	#
+	# creates instances of class cls from dictionary items
+	#
+
+	for key, value in items.items():
+		instance = cls(name=key, description=value)
+		db.session.add(instance)
+
+
+if __name__ == '__main__':
+	# get defaults from json
+	with open(path_to_defaults, 'r') as f:
+		defaults = json.load(f)
+
+	# create defaults
+	for key, value in defaults.items():		
+		print(f'Creating {key}...')
+		create_instances(classes.get(key), value)
+		print('Done.')
+
+	db.session.commit()

+ 48 - 0
ui/defaults.json

@@ -0,0 +1,48 @@
+{
+	"classnames": {
+		"GC.CLASSES_TESTCASESEQUENCE": "Default BAANGT class for TestCaseSequence",
+		"GC.CLASSES_TESTCASE": "Default BAANGT class for TestCase",
+		"GC.CLASSES_TESTSTEPMASTER": "Default class for TestStepSequence"
+	},
+	"testcases": {
+		"Browser": "Browser",
+		"API-Rest": "API-Rest",
+		"API-SOAP": "API-SOAP",
+		"API-oDataV2": "API-oDataV2",
+		"API-oDataV4": "API-oDataV4"
+	},
+	"browsers": {
+		"FF": "Mozilla Firefox",
+		"Chrome": "Google Chrome",
+		"IE": "MS Internet Exploer",
+		"Safari": "Safari",
+		"Edge": "MS Edge"
+	},
+	"activities": {
+		"GOTOURL": "Go to an URL",
+		"SETTEXT": "Set text of an imput field",
+		"FORCETEXT": "Force text change of an input field",
+		"HANDLEIFRAME": "Handle a frame",
+		"CLICK": "Click on an Element",
+		"PAUSE": "Sleep for the specified time in seconds",
+		"COMMENT": "A Simple Comment",
+		"ADDRESS_CREATE": "Create an address",
+		"GOBACK": "Return to the previous page",
+		"SUBMIT": "Submit a form",
+		"APIURL": "Set base URL for API call",
+		"ENDPOINT": "Set end-point for API call",
+		"POST": "Use POST method for API call",
+		"GET": "Use GET method for API call",
+		"HEADER": "Set a header of the call",
+		"CLEAR": "Clear the value of an element",
+		"PDFCOMPARE": "PDF comparison",
+		"CHECKLINKS": "Checks link",
+		"SAVE": "Save data",
+		"ASSERT": "Assertion"
+	},
+	"locators": {
+		"xpath": "Locate elements by XPATH-expression",
+		"css": "Locate elements by CSS-path",
+		"id": "Locate elements by ID"
+	}
+}

+ 263 - 0
ui/docs/database.md

@@ -0,0 +1,263 @@
+Database Description
+====================
+
+**BAANGT UI Service** supports the following databases:
+- SQLite
+- PostreSQL
+
+The environmental variable DATABASE_URL shows **BAANGT UI Service** the location of the database.
+
+Authentication
+--------------
+
+### Table *users*
+
+Table holds the data on registered users.
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id     | INTEGER   | User's ID <br> PRIMARY KEY
+| username | VARCHAR(64) | A *username* associated with the user <br> UNIQUE <br> NOT NULL
+| password | VARCHAR(128) | The hashed password string associated with the user <br> NOT NULL
+| created | DATETIME | The time when the user was created <br> NOT NULL
+| lastlogin | DATETIME |  The time when the user last logged in <br> NOT NULL
+
+
+Main entities
+-------------
+
+#### Table `testruns`
+
+Table holds data on Test Run Definitions (TR).
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id     | INTEGER   | ID of the *TR* <br> PRIMARY KEY
+| name | VARCHAR(64) | A name associated with the *TR* <br> NOT NULL
+| description | VARCHAR(512) | A brief description of the *TR* <br> NOT NULL
+| created | DATETIME | The time when the *TR* was created <br> NOT NULL
+| creator_id | INTEGER | The *user* that created the *TR* <br> FOREIGN KEY to `users` <br> NOT NULL)
+| edited | DATETIME | The time when the *TR* was last edited
+| editor_id | INTEGER | The *user* that last edited the *TR* <br> FOREIGN KEY to `users`
+
+
+#### Table `testcase_sequences`
+
+Table holds data on Test Case Sequence Definitions (TCS).
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id | INTEGER | ID of the *TCS* <br> PRIMARY KEY
+| name | VARCHAR(64) | A name associated with the *TCS* <br> NOT NULL
+| description | VARCHAR(512) | A brief description of the *TCS* <br> NOT NULL
+| created | DATETIME  | The time when the *TCS* was created <br> NOT NULL
+| creator_id | INTEGER | The *user* that created the *TCS* <br> FOREIGN KEY to `users` <br> NOT NULL
+| edited | DATETIME | The time when the *TCS* was last edited
+| editor_id | INTEGER | The *user* that last edited the *TCS* <br> FOREIGN KEY to `users`
+| classname_id | INTEGER | The *classname* associated with the *TCS* <br> FOREIGN KEY to `classnames` <br> NOT NULL
+
+
+#### Table `datafiles`
+
+Table holds data on BAANGT Data Files. 
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id | INTEGER | ID of the *DataFile* <br> PRIMARY KEY
+| uuid | VARCHAR(64) | UUID string assocoated with the *DataFile* <br> NOT NULL
+| filename | VARCHAR(64) | The *DataFile* name <br> NOT NULL
+| sheet | VARCHAR(32) | The *sheet* name of the *DataFile* that contains the test data <br> NOT NULL
+| created | DATETIME  | The time when the *DataFile* instance was created <br> NOT NULL
+| creator_id | INTEGER | The *user* that created the *DataFile* instance <br> FOREIGN KEY to `users` <br> NOT NULL
+
+
+
+#### Table `testcases`
+
+Table holds data on Test Case Definitions (TC).
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id | INTEGER | ID of the *TC* <br> PRIMARY KEY
+| name | VARCHAR(64) | A name associated with the *TC* <br> NOT NULL
+| description | VARCHAR(512) | A brief description of the *TC* <br> NOT NULL
+| created | DATETIME | The time when the *TC* was created <br> NOT NULL
+| creator_id | INTEGER | The *user* that created the *TC* <br> FOREIGN KEY to `users` <br> NOT NULL
+| edited | DATETIME | The time when the *TC* was last edited
+| editor_id | INTEGER | The *user* that last edited the *TC* <br> FOREIGN KEY to `users`
+| classname_id | INTEGER | The *classname* associated with the *TC* <br> FOREIGN KEY to `classnames` <br> NOT NULL
+| browser_type_id | INTEGER | The *type of browser* that should be used to run the *TC* <br> FOREIGN KEY to `browser_types` <br> NOT NULL
+| testcase_type_id | INTEGER | The *type of test case* <br> FOREIGN KEY to `testcase_types` <br> NOT NULL
+
+
+
+#### Table `teststep_sequences`
+
+Table holds data on Test Step Sequence Definitions (TSS).
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id | INTEGER | ID of the *TSS* <br> PRIMARY KEY
+| name | VARCHAR(64) | A name associated with the *TSS* <br> NOT NULL
+| description | VARCHAR(512) | A brief description of the *TSS* <br> NOT NULL
+| created | DATETIME | The time when the *TSS* was created <br> NOT NULL
+| creator_id | INTEGER | The user that created the *TSS* <br> FOREIGN KEY to `users` <br> NOT NULL
+| edited | DATETIME | The time when the *TSS* was last edited
+| editor_id | INTEGER | The user that last edited the *TSS* <br> FOREIGN KEY to `users`
+| classname_id | INTEGER | The *classname* associated with the *TSS* <br> FOREIGN KEY to `classnames` <br> NOT NULL
+
+
+#### Table `teststep_executions`
+
+Table holds data on Definitions of Test Step Execution (TS).
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id | INTEGER | ID of the *TS* <br> PRIMARY KEY
+| name | VARCHAR(64) | A name associated with the *TS* <br> NOT NULL
+| description | VARCHAR(512) | A brief description of the *TS* <br> NOT NULL
+| created | DATETIME | The time when the *TS* was created <br> NOT NULL
+| creator_id | INTEGER | The user that created the *TS* <br> FOREIGN KEY to `users` <br> NOT NULL
+| edited | DATETIME | The time when the *TS* was last edited
+| editor_id | INTEGER | The user that last edited the *TS* <br> FOREIGN KEY to `users`
+| locator | VARCHAR(512) | A string representing a *BAANGT* locator
+| locator_type_id | INTEGER | The *locator type* used by the *TS* <br> FOREIGN KEY to `locator_types`
+| optional | BOOLEAN | | Shows if the execution of the current test case should be continued after *timeout exception* was was thrown <br> NOT NULL
+| timeout | DOUBLE(5,2) | Overrides the default value of the *BAANGT* timeout
+| release | VARCHAR(64) | A release string of the *TS* 
+| value | VARCHAR(1024) | A value associated with the *TS*'s activity
+| value2 | VARCHAR(1024) | The second value if the *TS* activity requires a comparison
+| comparison | VARCHAR(16) | A comparison string if required by the *TS*'s activity
+| activity_type_id | INTEGER | The *activity type* called by the *TS* <br> FOREIGN KEY to `activity_types` <br> NOT NULL
+| teststep_sequence_id | INTEGER | The *TSS* instance that contains the *TS* <br> FOREIGN KEY to `teststep_sequences`
+
+
+Relationships
+-------------
+
+#### Table `testrun_casesequence`
+
+Table holds many-to-many relationship between *TR* and *TCS*.
+
+| Column | Data Type | Constraints
+|--------|:---------:|-----------------
+| testrun_id | INTEGER | PRIMARY KEY <br> FOREIGN KEY to `testruns`
+| testcase_sequence_id | INTEGER | PRIMARY KEY <br> FOREIGN KEY to `testcase_sequences`
+
+
+#### Table `testcase_sequence_datafile`
+
+Table holds many-to-many relationship between *TCS* and *DataFiles*.
+
+| Column | Data Type | Constraints
+|--------|:---------:|-----------------
+| testcase_sequence_id | INTEGER | PRIMARY KEY <br> FOREIGN KEY to `testcase_sequences`
+| datafile_id | INTEGER | PRIMARY KEY <br> FOREIGN KEY to `datafiles`
+
+
+#### Table `testcase_sequence_case`
+
+Table holds many-to-many relationship between *TCS* and *TC*.
+
+| Column | Data Type | Constraints
+|--------|:---------:|-----------------
+| testcase_sequence_id | INTEGER | PRIMARY KEY <br> FOREIGN KEY to `testcase_sequences`
+| testcase_id | INTEGER | PRIMARY KEY <br> FOREIGN KEY to `testcases`
+
+
+#### Table `testcase_stepsequence`
+
+Table holds many-to-many relationship between *TC* and *TSS*.
+
+| Column | Data Type | Constraints
+|--------|:---------:|-----------------
+| testcase_id | INTEGER | PRIMARY KEY <br> FOREIGN KEY to `testcases`
+| teststep_sequence_id | INTEGER | PRIMARY KEY <br> FOREIGN KEY to `teststep_sequences`
+
+
+
+Supporting entities
+-------------------
+
+#### Table `global_teststep_executions`
+
+Table holds data on Definitions of Global Test Step Execution (GTS).
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id | INTEGER | ID of the *GTS* <br> PRIMARY KEY
+| name | VARCHAR(64) | A name associated with the *GTS* <br> NOT NULL
+| description | VARCHAR(512) | A brief description of the *GTS* <br> NOT NULL
+| created | DATETIME | The time when the *GTS* was created <br> NOT NULL
+| creator_id | INTEGER | The user that created the *GTS* <br> FOREIGN KEY to `users` <br> NOT NULL
+| edited | DATETIME | The time when the *GTS* was last edited
+| editor_id | INTEGER | The user that last edited the *GTS* <br> FOREIGN KEY to `users`
+| locator_type_id | INTEGER | The *locator type* used by the *GTS* <br> FOREIGN KEY to `locator_types`
+| activity_type_id | INTEGER | The *activity type* called by the *GTS* <br> FOREIGN KEY to `activity_types` <br> NOT NULL
+| teststep_sequence_id | INTEGER | The *TSS* instance that contains the *GTS* <br> FOREIGN KEY to `teststep_sequences`
+
+
+#### Table `classnames`
+
+Table holds data on the *BAANGT* Class Names.
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id | INTEGER | ID of the *ClassName*, <br> PRIMARY KEY
+| name | VARCHAR(64) | A name associated with the *ClassName* <br> NOT NULL
+| description | VARCHAR(512) | A brief description of the *ClassName* <br> NOT NULL
+
+#### Table `browser_types`
+
+Table holds data on the Browser Types.
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id | INTEGER | ID of the *BrowserType*, <br> PRIMARY KEY
+| name | VARCHAR(64) | A name associated with the *BrowserType* <br> NOT NULL
+| description | VARCHAR(512) | A brief description of the *BrowserType* <br> NOT NULL
+
+#### Table `testcase_types`
+
+Table holds data on the Types of Test Cases (TCT).
+
+| Column | Data Type | Description
+|--------|:---------:|----------------
+| id | INTEGER | ID of the *TCT*, <br> PRIMARY KEY
+| name | VARCHAR(64) | A name associated with the *TCT* <br> NOT NULL
+| description | VARCHAR(512) | A brief description of the *TCT* <br> NOT NULL
+
+#### Table `activity_types`
+
+Table holds data on the Activity Types of *BAANGT* tests.
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id | INTEGER | ID of the *ActivityType*, <br> PRIMARY KEY
+| name | VARCHAR(64) | A name associated with the *ActivityType* <br> NOT NULL
+| description | VARCHAR(512) | A brief description of the *ActivityType* <br> NOT NULL
+
+#### Table `locator_types`
+
+Table holds data on the Locator Types of *BAANGT* test activity.
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id | INTEGER | ID of the *LocatorType*, <br> PRIMARY KEY
+| name | VARCHAR(64) | A name associated with the *LocatorType* <br> NOT NULL
+| description | VARCHAR(512) | A brief description of the *LocatorType* <br> NOT NULL
+
+
+Results
+-------
+
+#### Table `testrun_calls`
+
+Table holds data on executed Testruns.
+
+| Column | Data Type | Description
+|--------|:---------:|-----------------
+| id | BLOB(16) | UUID  of the *Testrun execution* as a binary string <br> PRIMARY KEY
+| created | DATETIME | The time when the *Testrun execution* was called <br> NOT NULL
+| creator_id | INTEGER | The user that called the *Testrun execution* <br> FOREIGN KEY to `users` <br> NOT NULL
+| testrun_id | INTEGER | The *Testrun* that was executed <br> FOREIGN KEY to `testruns` <br> NOT NULL

BIN
ui/examples/DropsTestExample.xlsx


BIN
ui/examples/DropsTestRunDefinition.xlsx


BIN
ui/examples/google_Images.xlsx


+ 1 - 0
ui/migrations/README

@@ -0,0 +1 @@
+Generic single-database configuration.

+ 45 - 0
ui/migrations/alembic.ini

@@ -0,0 +1,45 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 96 - 0
ui/migrations/env.py

@@ -0,0 +1,96 @@
+from __future__ import with_statement
+
+import logging
+from logging.config import fileConfig
+
+from sqlalchemy import engine_from_config
+from sqlalchemy import pool
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+from flask import current_app
+config.set_main_option(
+    'sqlalchemy.url',
+    str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%'))
+target_metadata = current_app.extensions['migrate'].db.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline():
+    """Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url, target_metadata=target_metadata, literal_binds=True
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online():
+    """Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+
+    # this callback is used to prevent an auto-migration from being generated
+    # when there are no changes to the schema
+    # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
+    def process_revision_directives(context, revision, directives):
+        if getattr(config.cmd_opts, 'autogenerate', False):
+            script = directives[0]
+            if script.upgrade_ops.is_empty():
+                directives[:] = []
+                logger.info('No changes in schema detected.')
+
+    connectable = engine_from_config(
+        config.get_section(config.config_ini_section),
+        prefix='sqlalchemy.',
+        poolclass=pool.NullPool,
+    )
+
+    with connectable.connect() as connection:
+        context.configure(
+            connection=connection,
+            target_metadata=target_metadata,
+            process_revision_directives=process_revision_directives,
+            **current_app.extensions['migrate'].configure_args
+        )
+
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 24 - 0
ui/migrations/script.py.mako

@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+    ${downgrades if downgrades else "pass"}

+ 231 - 0
ui/migrations/versions/154424b7dd12_.py

@@ -0,0 +1,231 @@
+"""empty message
+
+Revision ID: 154424b7dd12
+Revises: 
+Create Date: 2020-05-17 16:16:20.655922
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '154424b7dd12'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('activity_types',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=64), nullable=False),
+    sa.Column('description', sa.String(length=512), nullable=False),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('browser_types',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=64), nullable=False),
+    sa.Column('description', sa.String(length=512), nullable=False),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('classnames',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=64), nullable=False),
+    sa.Column('description', sa.String(length=512), nullable=False),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('locator_types',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=64), nullable=False),
+    sa.Column('description', sa.String(length=512), nullable=False),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('testcase_types',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=64), nullable=False),
+    sa.Column('description', sa.String(length=512), nullable=False),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('users',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('username', sa.String(length=64), nullable=False),
+    sa.Column('password', sa.String(length=128), nullable=False),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('lastlogin', sa.DateTime(), nullable=False),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('username')
+    )
+    op.create_table('datafiles',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('uuid', sa.String(length=64), nullable=False),
+    sa.Column('filename', sa.String(length=64), nullable=False),
+    sa.Column('sheet', sa.String(length=32), nullable=False),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('creator_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('testcase_sequences',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=64), nullable=False),
+    sa.Column('description', sa.String(length=512), nullable=False),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('creator_id', sa.Integer(), nullable=False),
+    sa.Column('edited', sa.DateTime(), nullable=True),
+    sa.Column('editor_id', sa.Integer(), nullable=True),
+    sa.Column('classname_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['classname_id'], ['classnames.id'], ),
+    sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
+    sa.ForeignKeyConstraint(['editor_id'], ['users.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('testcases',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=64), nullable=False),
+    sa.Column('description', sa.String(length=512), nullable=False),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('creator_id', sa.Integer(), nullable=False),
+    sa.Column('edited', sa.DateTime(), nullable=True),
+    sa.Column('editor_id', sa.Integer(), nullable=True),
+    sa.Column('classname_id', sa.Integer(), nullable=False),
+    sa.Column('browser_type_id', sa.Integer(), nullable=False),
+    sa.Column('testcase_type_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['browser_type_id'], ['browser_types.id'], ),
+    sa.ForeignKeyConstraint(['classname_id'], ['classnames.id'], ),
+    sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
+    sa.ForeignKeyConstraint(['editor_id'], ['users.id'], ),
+    sa.ForeignKeyConstraint(['testcase_type_id'], ['testcase_types.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('testruns',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=64), nullable=False),
+    sa.Column('description', sa.String(length=512), nullable=False),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('creator_id', sa.Integer(), nullable=False),
+    sa.Column('edited', sa.DateTime(), nullable=True),
+    sa.Column('editor_id', sa.Integer(), nullable=True),
+    sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
+    sa.ForeignKeyConstraint(['editor_id'], ['users.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('teststep_sequences',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=64), nullable=False),
+    sa.Column('description', sa.String(length=512), nullable=False),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('creator_id', sa.Integer(), nullable=False),
+    sa.Column('edited', sa.DateTime(), nullable=True),
+    sa.Column('editor_id', sa.Integer(), nullable=True),
+    sa.Column('classname_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['classname_id'], ['classnames.id'], ),
+    sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
+    sa.ForeignKeyConstraint(['editor_id'], ['users.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('global_teststep_executions',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=64), nullable=False),
+    sa.Column('description', sa.String(length=512), nullable=False),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('creator_id', sa.Integer(), nullable=False),
+    sa.Column('edited', sa.DateTime(), nullable=True),
+    sa.Column('editor_id', sa.Integer(), nullable=True),
+    sa.Column('activity_type_id', sa.Integer(), nullable=False),
+    sa.Column('locator_type_id', sa.Integer(), nullable=False),
+    sa.Column('teststep_sequence_id', sa.Integer(), nullable=True),
+    sa.ForeignKeyConstraint(['activity_type_id'], ['activity_types.id'], ),
+    sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
+    sa.ForeignKeyConstraint(['editor_id'], ['users.id'], ),
+    sa.ForeignKeyConstraint(['locator_type_id'], ['locator_types.id'], ),
+    sa.ForeignKeyConstraint(['teststep_sequence_id'], ['teststep_sequences.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('testcase_sequence_case',
+    sa.Column('testcase_sequence_id', sa.Integer(), nullable=False),
+    sa.Column('testcase_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['testcase_id'], ['testcases.id'], ),
+    sa.ForeignKeyConstraint(['testcase_sequence_id'], ['testcase_sequences.id'], ),
+    sa.PrimaryKeyConstraint('testcase_sequence_id', 'testcase_id')
+    )
+    op.create_table('testcase_sequence_datafile',
+    sa.Column('testcase_sequence_id', sa.Integer(), nullable=False),
+    sa.Column('datafile_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['datafile_id'], ['datafiles.id'], ),
+    sa.ForeignKeyConstraint(['testcase_sequence_id'], ['testcase_sequences.id'], ),
+    sa.PrimaryKeyConstraint('testcase_sequence_id', 'datafile_id')
+    )
+    op.create_table('testcase_stepsequence',
+    sa.Column('testcase_id', sa.Integer(), nullable=False),
+    sa.Column('teststep_sequence_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['testcase_id'], ['testcases.id'], ),
+    sa.ForeignKeyConstraint(['teststep_sequence_id'], ['teststep_sequences.id'], ),
+    sa.PrimaryKeyConstraint('testcase_id', 'teststep_sequence_id')
+    )
+    op.create_table('testrun_calls',
+    sa.Column('id', sa.LargeBinary(length=16), nullable=False),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('creator_id', sa.Integer(), nullable=False),
+    sa.Column('testrun_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
+    sa.ForeignKeyConstraint(['testrun_id'], ['testruns.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('testrun_casesequence',
+    sa.Column('testrun_id', sa.Integer(), nullable=False),
+    sa.Column('testcase_sequence_id', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['testcase_sequence_id'], ['testcase_sequences.id'], ),
+    sa.ForeignKeyConstraint(['testrun_id'], ['testruns.id'], ),
+    sa.PrimaryKeyConstraint('testrun_id', 'testcase_sequence_id')
+    )
+    op.create_table('teststep_executions',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=64), nullable=False),
+    sa.Column('description', sa.String(length=512), nullable=False),
+    sa.Column('created', sa.DateTime(), nullable=False),
+    sa.Column('creator_id', sa.Integer(), nullable=False),
+    sa.Column('edited', sa.DateTime(), nullable=True),
+    sa.Column('locator', sa.String(length=512), nullable=True),
+    sa.Column('optional', sa.Boolean(), nullable=False),
+    sa.Column('timeout', sa.Float(precision='5,2'), nullable=True),
+    sa.Column('release', sa.String(length=64), nullable=True),
+    sa.Column('value', sa.String(length=1024), nullable=True),
+    sa.Column('value2', sa.String(length=1024), nullable=True),
+    sa.Column('comparison', sa.String(length=16), nullable=True),
+    sa.Column('editor_id', sa.Integer(), nullable=True),
+    sa.Column('activity_type_id', sa.Integer(), nullable=False),
+    sa.Column('locator_type_id', sa.Integer(), nullable=True),
+    sa.Column('teststep_sequence_id', sa.Integer(), nullable=True),
+    sa.ForeignKeyConstraint(['activity_type_id'], ['activity_types.id'], ),
+    sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
+    sa.ForeignKeyConstraint(['editor_id'], ['users.id'], ),
+    sa.ForeignKeyConstraint(['locator_type_id'], ['locator_types.id'], ),
+    sa.ForeignKeyConstraint(['teststep_sequence_id'], ['teststep_sequences.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('teststep_executions')
+    op.drop_table('testrun_casesequence')
+    op.drop_table('testrun_calls')
+    op.drop_table('testcase_stepsequence')
+    op.drop_table('testcase_sequence_datafile')
+    op.drop_table('testcase_sequence_case')
+    op.drop_table('global_teststep_executions')
+    op.drop_table('teststep_sequences')
+    op.drop_table('testruns')
+    op.drop_table('testcases')
+    op.drop_table('testcase_sequences')
+    op.drop_table('datafiles')
+    op.drop_table('users')
+    op.drop_table('testcase_types')
+    op.drop_table('locator_types')
+    op.drop_table('classnames')
+    op.drop_table('browser_types')
+    op.drop_table('activity_types')
+    # ### end Alembic commands ###

+ 9 - 0
ui/requirements.txt

@@ -0,0 +1,9 @@
+flask>=1.1.1
+sqlalchemy>=1.3.12
+flask_sqlalchemy>=2.4.1
+flask_login>=0.4.1
+flask_wtf>=0.14.2
+flask_migrate>=2.5.2
+xlsxwriter>=1.2.8
+xlrd>=1.2.0
+requests>=2.23.0

+ 5 - 0
ui/runservice.sh

@@ -0,0 +1,5 @@
+#!/bin/sh
+source venv/bin/activate
+flask db upgrade
+python db_defaults.py
+exec gunicorn -b :5000 --access-logfile - --error-logfile - app:app

File diff suppressed because it is too large
+ 1 - 0
ui/testrun.json