summaryrefslogtreecommitdiff
path: root/opendc-web
diff options
context:
space:
mode:
authorFabian Mastenbroek <mail.fabianm@gmail.com>2021-05-15 13:09:06 +0200
committerFabian Mastenbroek <mail.fabianm@gmail.com>2021-05-18 15:46:40 +0200
commit2281d3265423d01e60f8cc088de5a5730bb8a910 (patch)
tree8dc81338cfd30845717f1b9025176d26c82fe930 /opendc-web
parent05d2318538eba71ac0555dc5ec146499d9cb0592 (diff)
api: Migrate to Flask Restful
This change updates the API to use Flask Restful instead of our own in-house REST library. This change reduces the maintenance effort and allows us to drastically simplify the API implementation needed for the OpenDC v2 API.
Diffstat (limited to 'opendc-web')
-rw-r--r--opendc-web/opendc-web-api/.pylintrc3
-rw-r--r--opendc-web/opendc-web-api/Dockerfile12
-rwxr-xr-xopendc-web/opendc-web-api/app.py179
-rw-r--r--opendc-web/opendc-web-api/conftest.py19
-rw-r--r--opendc-web/opendc-web-api/opendc/api/portfolios.py135
-rw-r--r--opendc-web/opendc-web-api/opendc/api/prefabs.py120
-rw-r--r--opendc-web/opendc-web-api/opendc/api/projects.py195
-rw-r--r--opendc-web/opendc-web-api/opendc/api/scenarios.py81
-rw-r--r--opendc-web/opendc-web-api/opendc/api/schedulers.py46
-rw-r--r--opendc-web/opendc-web-api/opendc/api/topologies.py93
-rw-r--r--opendc-web/opendc-web-api/opendc/api/traces.py51
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/paths.json19
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py67
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py49
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py125
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py149
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py19
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/test_endpoint.py71
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py22
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py50
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/test_endpoint.py145
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/test_endpoint.py24
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py36
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py60
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py35
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py85
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/test_endpoint.py119
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py31
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py52
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/test_endpoint.py32
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/scenarios/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py59
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py115
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/schedulers/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/schedulers/endpoint.py19
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/schedulers/test_endpoint.py2
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/topologies/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py58
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/test_endpoint.py113
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/endpoint.py10
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/test_endpoint.py6
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/endpoint.py14
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/test_endpoint.py15
-rw-r--r--opendc-web/opendc-web-api/opendc/auth.py (renamed from opendc-web/opendc-web-api/opendc/util/auth.py)161
-rw-r--r--opendc-web/opendc-web-api/opendc/database.py (renamed from opendc-web/opendc-web-api/opendc/util/database.py)41
-rw-r--r--opendc-web/opendc-web-api/opendc/exts.py60
-rw-r--r--opendc-web/opendc-web-api/opendc/models/model.py19
-rw-r--r--opendc-web/opendc-web-api/opendc/models/portfolio.py21
-rw-r--r--opendc-web/opendc-web-api/opendc/models/prefab.py23
-rw-r--r--opendc-web/opendc-web-api/opendc/models/project.py27
-rw-r--r--opendc-web/opendc-web-api/opendc/models/scenario.py46
-rw-r--r--opendc-web/opendc-web-api/opendc/models/topology.py76
-rw-r--r--opendc-web/opendc-web-api/opendc/util.py32
-rw-r--r--opendc-web/opendc-web-api/opendc/util/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/util/exceptions.py64
-rw-r--r--opendc-web/opendc-web-api/opendc/util/json.py12
-rw-r--r--opendc-web/opendc-web-api/opendc/util/parameter_checker.py85
-rw-r--r--opendc-web/opendc-web-api/opendc/util/path_parser.py36
-rw-r--r--opendc-web/opendc-web-api/opendc/util/rest.py109
-rw-r--r--opendc-web/opendc-web-api/requirements.txt2
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_portfolios.py324
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_prefabs.py252
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_projects.py167
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_scenarios.py135
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_schedulers.py22
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_topologies.py140
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_traces.py40
-rw-r--r--opendc-web/opendc-web-ui/src/shapes.js2
83 files changed, 2279 insertions, 2152 deletions
diff --git a/opendc-web/opendc-web-api/.pylintrc b/opendc-web/opendc-web-api/.pylintrc
index 7fe24187..4dbb0b50 100644
--- a/opendc-web/opendc-web-api/.pylintrc
+++ b/opendc-web/opendc-web-api/.pylintrc
@@ -65,7 +65,8 @@ disable=duplicate-code,
invalid-name,
bare-except,
too-few-public-methods,
- fixme
+ fixme,
+ no-self-use
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
diff --git a/opendc-web/opendc-web-api/Dockerfile b/opendc-web/opendc-web-api/Dockerfile
index 49702c90..a2f2d787 100644
--- a/opendc-web/opendc-web-api/Dockerfile
+++ b/opendc-web/opendc-web-api/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.8
+FROM python:3.8-slim
MAINTAINER OpenDC Maintainers <opendc@atlarge-research.com>
# Ensure the STDOUT is not buffered by Python so that our logs become visible
@@ -9,9 +9,15 @@ ENV PYTHONUNBUFFERED 1
COPY ./ /opendc
# Fetch web server dependencies
-RUN pip install -r /opendc/requirements.txt
+RUN pip install -r /opendc/requirements.txt && pip install pyuwsgi
+
+# Create opendc user
+RUN groupadd --gid 1000 opendc \
+ && useradd --uid 1000 --gid opendc --shell /bin/bash --create-home opendc
+RUN chown -R opendc:opendc /opendc
+USER opendc
# Set working directory
WORKDIR /opendc
-CMD ["python3", "main.py"]
+CMD uwsgi -M --socket 0.0.0.0:80 --protocol=http --wsgi-file app.py --enable-threads --processes 2 --lazy-app
diff --git a/opendc-web/opendc-web-api/app.py b/opendc-web/opendc-web-api/app.py
index ee4b3d32..5041457f 100755
--- a/opendc-web/opendc-web-api/app.py
+++ b/opendc-web/opendc-web-api/app.py
@@ -1,25 +1,35 @@
#!/usr/bin/env python3
-import json
import os
-import sys
-import traceback
from dotenv import load_dotenv
-from flask import Flask, request, jsonify
+from flask import Flask, jsonify
from flask_compress import Compress
from flask_cors import CORS
+from flask_restful import Api
+from marshmallow import ValidationError
-from opendc.util import rest, path_parser, database
-from opendc.util.auth import AuthError, AuthManager, AsymmetricJwtAlgorithm
-from opendc.util.exceptions import AuthorizationTokenError, RequestInitializationError
-from opendc.util.json import JSONEncoder
+from opendc.api.portfolios import Portfolio, PortfolioScenarios
+from opendc.api.prefabs import Prefab, PrefabList
+from opendc.api.projects import ProjectList, Project, ProjectTopologies, ProjectPortfolios
+from opendc.api.scenarios import Scenario
+from opendc.api.schedulers import SchedulerList
+from opendc.api.topologies import Topology
+from opendc.api.traces import TraceList, Trace
+from opendc.auth import AuthError
+from opendc.util import JSONEncoder
+
+# Load environmental variables from dotenv file
load_dotenv()
-TEST_MODE = "OPENDC_FLASK_TESTING" in os.environ
-# Setup Sentry if DSN is specified
-if 'SENTRY_DSN' in os.environ:
+def setup_sentry():
+ """
+ Setup the Sentry integration for Flask if a DSN is supplied via the environmental variables.
+ """
+ if 'SENTRY_DSN' not in os.environ:
+ return
+
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration
@@ -28,119 +38,64 @@ if 'SENTRY_DSN' in os.environ:
traces_sample_rate=0.1
)
-# Set up database if not testing
-if not TEST_MODE:
- database.DB.initialize_database(
- user=os.environ['OPENDC_DB_USERNAME'],
- password=os.environ['OPENDC_DB_PASSWORD'],
- database=os.environ['OPENDC_DB'],
- host=os.environ.get('OPENDC_DB_HOST', 'localhost'))
-
-# Set up the core app
-app = Flask("opendc")
-app.testing = TEST_MODE
-app.config['SECRET_KEY'] = os.environ['OPENDC_FLASK_SECRET']
-app.json_encoder = JSONEncoder
-
-# Set up CORS support
-CORS(app)
-
-compress = Compress()
-compress.init_app(app)
-
-auth = AuthManager(AsymmetricJwtAlgorithm(jwks_url=f"https://{os.environ['AUTH0_DOMAIN']}/.well-known/jwks.json"),
- issuer=f"https://{os.environ['AUTH0_DOMAIN']}/", audience=os.environ['AUTH0_AUDIENCE'])
-
-API_VERSIONS = {'v2'}
-
-
-@app.errorhandler(AuthError)
-def handle_auth_error(ex):
- response = jsonify(ex.error)
- response.status_code = ex.status_code
- return response
-
-
-@app.route('/<string:version>/<path:endpoint_path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
-@auth.require
-def api_call(version, endpoint_path):
- """Call an API endpoint directly over HTTP."""
-
- # Check whether given version is valid
- if version not in API_VERSIONS:
- return jsonify(error='API version not found'), 404
-
- # Get path and parameters
- (path, path_parameters) = path_parser.parse(version, endpoint_path)
-
- query_parameters = request.args.to_dict()
- for param in query_parameters:
- try:
- query_parameters[param] = int(query_parameters[param])
- except:
- pass
-
- try:
- body_parameters = json.loads(request.get_data())
- except:
- body_parameters = {}
-
- # Create and call request
- (req, response) = _process_message({
- 'id': 0,
- 'method': request.method,
- 'parameters': {
- 'body': body_parameters,
- 'path': path_parameters,
- 'query': query_parameters
- },
- 'path': path,
- 'token': request.headers.get('auth-token')
- })
- print(
- f'HTTP:\t{req.method} to `/{req.path}` resulted in {response.status["code"]}: {response.status["description"]}')
- sys.stdout.flush()
+def setup_api(app):
+ """
+ Setup the API interface.
+ """
+ api = Api(app)
+ # Map to ('string', 'ObjectId') passing type and format
+ api.add_resource(ProjectList, '/projects/')
+ api.add_resource(Project, '/projects/<string:project_id>')
+ api.add_resource(ProjectTopologies, '/projects/<string:project_id>/topologies')
+ api.add_resource(ProjectPortfolios, '/projects/<string:project_id>/portfolios')
+ api.add_resource(Topology, '/topologies/<string:topology_id>')
+ api.add_resource(PrefabList, '/prefabs/')
+ api.add_resource(Prefab, '/prefabs/<string:prefab_id>')
+ api.add_resource(Portfolio, '/portfolios/<string:portfolio_id>')
+ api.add_resource(PortfolioScenarios, '/portfolios/<string:portfolio_id>/scenarios')
+ api.add_resource(Scenario, '/scenarios/<string:scenario_id>')
+ api.add_resource(TraceList, '/traces/')
+ api.add_resource(Trace, '/traces/<string:trace_id>')
+ api.add_resource(SchedulerList, '/schedulers/')
- flask_response = jsonify(json.loads(response.to_JSON()))
- flask_response.status_code = response.status['code']
- return flask_response
+ @app.errorhandler(AuthError)
+ def handle_auth_error(ex):
+ response = jsonify(ex.error)
+ response.status_code = ex.status_code
+ return response
+ @app.errorhandler(ValidationError)
+ def handle_validation_error(ex):
+ return {'message': 'Input validation failed', 'errors': ex.messages}, 400
-def _process_message(message):
- """Process a request message and return the response."""
+ return api
- try:
- req = rest.Request(message)
- res = req.process()
- return req, res
+def create_app(testing=False):
+ app = Flask(__name__)
+ app.config['TESTING'] = testing
+ app.config['SECRET_KEY'] = os.environ['OPENDC_FLASK_SECRET']
+ app.config['RESTFUL_JSON'] = {'cls': JSONEncoder}
+ app.json_encoder = JSONEncoder
- except AuthorizationTokenError:
- res = rest.Response(401, 'Authorization error')
- res.id = message['id']
+ # Setup Sentry if DSN is specified
+ setup_sentry()
- except RequestInitializationError as e:
- res = rest.Response(400, str(e))
- res.id = message['id']
+ # Set up CORS support
+ CORS(app)
- if not 'method' in message:
- message['method'] = 'UNSPECIFIED'
- if not 'path' in message:
- message['path'] = 'UNSPECIFIED'
+ # Setup compression
+ compress = Compress()
+ compress.init_app(app)
- except Exception:
- res = rest.Response(500, 'Internal server error')
- if 'id' in message:
- res.id = message['id']
- traceback.print_exc()
+ # Setup API
+ setup_api(app)
- req = rest.Request()
- req.method = message['method']
- req.path = message['path']
+ return app
- return req, res
+application = create_app(testing="OPENDC_FLASK_TESTING" in os.environ)
if __name__ == '__main__':
- app.run()
+ application.run()
diff --git a/opendc-web/opendc-web-api/conftest.py b/opendc-web/opendc-web-api/conftest.py
index c502c078..430262f1 100644
--- a/opendc-web/opendc-web-api/conftest.py
+++ b/opendc-web/opendc-web-api/conftest.py
@@ -4,10 +4,11 @@ Configuration file for all unit tests.
from functools import wraps
import pytest
-from flask import _request_ctx_stack
+from flask import _request_ctx_stack, g
+from opendc.database import Database
-def decorator(self, f):
+def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
_request_ctx_stack.top.current_user = {'sub': 'test'}
@@ -20,12 +21,14 @@ def client():
"""Returns a Flask API client to interact with."""
# Disable authorization for test API endpoints
- from opendc.util.auth import AuthManager
- AuthManager.require = decorator
+ from opendc import exts
+ exts.requires_auth = decorator
- from app import app
+ from app import create_app
- app.config['TESTING'] = True
+ app = create_app(testing=True)
- with app.test_client() as client:
- yield client
+ with app.app_context():
+ g.db = Database()
+ with app.test_client() as client:
+ yield client
diff --git a/opendc-web/opendc-web-api/opendc/api/portfolios.py b/opendc-web/opendc-web-api/opendc/api/portfolios.py
new file mode 100644
index 00000000..b07e9da5
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/portfolios.py
@@ -0,0 +1,135 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from flask import request
+from flask_restful import Resource
+from marshmallow import Schema, fields
+
+from opendc.exts import requires_auth, current_user
+from opendc.models.portfolio import Portfolio as PortfolioModel, PortfolioSchema
+from opendc.models.project import Project
+from opendc.models.scenario import ScenarioSchema, Scenario
+from opendc.models.topology import Topology
+
+
+class Portfolio(Resource):
+ """
+ Resource representing a portfolio.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, portfolio_id):
+ """
+ Get a portfolio by identifier.
+ """
+ portfolio = PortfolioModel.from_id(portfolio_id)
+
+ portfolio.check_exists()
+ portfolio.check_user_access(current_user['sub'], False)
+
+ data = portfolio.obj
+ return {'data': data}
+
+ def put(self, portfolio_id):
+ """
+ Replace the portfolio.
+ """
+ schema = Portfolio.PutSchema()
+ result = schema.load(request.json)
+
+ portfolio = PortfolioModel.from_id(portfolio_id)
+ portfolio.check_exists()
+ portfolio.check_user_access(current_user['sub'], True)
+
+ portfolio.set_property('name', result['portfolio']['name'])
+ portfolio.set_property('targets.enabledMetrics', result['portfolio']['targets']['enabledMetrics'])
+ portfolio.set_property('targets.repeatsPerScenario', result['portfolio']['targets']['repeatsPerScenario'])
+
+ portfolio.update()
+ data = portfolio.obj
+ return {'data': data}
+
+ def delete(self, portfolio_id):
+ """
+ Delete a portfolio.
+ """
+ portfolio = PortfolioModel.from_id(portfolio_id)
+
+ portfolio.check_exists()
+ portfolio.check_user_access(current_user['sub'], True)
+
+ portfolio_id = portfolio.get_id()
+
+ project = Project.from_id(portfolio.obj['projectId'])
+ project.check_exists()
+ if portfolio_id in project.obj['portfolioIds']:
+ project.obj['portfolioIds'].remove(portfolio_id)
+ project.update()
+
+ old_object = portfolio.delete()
+ return {'data': old_object}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a portfolio.
+ """
+ portfolio = fields.Nested(PortfolioSchema, required=True)
+
+
+class PortfolioScenarios(Resource):
+ """
+ Resource representing the scenarios of a portfolio.
+ """
+ method_decorators = [requires_auth]
+
+ def post(self, portfolio_id):
+ """
+ Add a new scenario to this portfolio
+ """
+ schema = PortfolioScenarios.PostSchema()
+ result = schema.load(request.json)
+
+ portfolio = PortfolioModel.from_id(portfolio_id)
+
+ portfolio.check_exists()
+ portfolio.check_user_access(current_user['sub'], True)
+
+ scenario = Scenario(result['scenario'])
+
+ topology = Topology.from_id(scenario.obj['topology']['topologyId'])
+ topology.check_exists()
+ topology.check_user_access(current_user['sub'], True)
+
+ scenario.set_property('portfolioId', portfolio.get_id())
+ scenario.set_property('simulation', {'state': 'QUEUED'})
+ scenario.set_property('topology.topologyId', topology.get_id())
+
+ scenario.insert()
+
+ portfolio.obj['scenarioIds'].append(scenario.get_id())
+ portfolio.update()
+ data = scenario.obj
+ return {'data': data}
+
+ class PostSchema(Schema):
+ """
+ Schema for the POST operation on a portfolio's scenarios.
+ """
+ scenario = fields.Nested(ScenarioSchema, required=True)
diff --git a/opendc-web/opendc-web-api/opendc/api/prefabs.py b/opendc-web/opendc-web-api/opendc/api/prefabs.py
new file mode 100644
index 00000000..7bb17e7d
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/prefabs.py
@@ -0,0 +1,120 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from datetime import datetime
+from flask import request
+from flask_restful import Resource
+from marshmallow import Schema, fields
+
+from opendc.models.prefab import Prefab as PrefabModel, PrefabSchema
+from opendc.database import Database
+from opendc.exts import current_user, requires_auth, db
+
+
+class PrefabList(Resource):
+ """
+ Resource for the list of prefabs available to the user.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self):
+ """
+ Get the available prefabs for a user.
+ """
+ user_id = current_user['sub']
+
+ own_prefabs = db.fetch_all({'authorId': user_id}, PrefabModel.collection_name)
+ public_prefabs = db.fetch_all({'visibility': 'public'}, PrefabModel.collection_name)
+
+ authorizations = {"authorizations": []}
+ authorizations["authorizations"].append(own_prefabs)
+ authorizations["authorizations"].append(public_prefabs)
+ return {'data': authorizations}
+
+ def post(self):
+ """
+ Create a new prefab.
+ """
+ schema = PrefabList.PostSchema()
+ result = schema.load(request.json)
+
+ prefab = PrefabModel(result['prefab'])
+ prefab.set_property('datetimeCreated', Database.datetime_to_string(datetime.now()))
+ prefab.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+
+ user_id = current_user['sub']
+ prefab.set_property('authorId', user_id)
+
+ prefab.insert()
+ return {'data': prefab.obj}
+
+ class PostSchema(Schema):
+ """
+ Schema for the POST operation on the prefab list.
+ """
+ prefab = fields.Nested(PrefabSchema, required=True)
+
+
+class Prefab(Resource):
+ """
+ Resource representing a single prefab.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, prefab_id):
+ """Get this Prefab."""
+ prefab = PrefabModel.from_id(prefab_id)
+ prefab.check_exists()
+ prefab.check_user_access(current_user['sub'])
+ return {'data': prefab.obj}
+
+ def put(self, prefab_id):
+ """Update a prefab's name and/or contents."""
+
+ schema = Prefab.PutSchema()
+ result = schema.load(request.json)
+
+ prefab = PrefabModel.from_id(prefab_id)
+ prefab.check_exists()
+ prefab.check_user_access(current_user['sub'])
+
+ prefab.set_property('name', result['prefab']['name'])
+ prefab.set_property('rack', result['prefab']['rack'])
+ prefab.set_property('datetime_last_edited', Database.datetime_to_string(datetime.now()))
+ prefab.update()
+
+ return {'data': prefab.obj}
+
+ def delete(self, prefab_id):
+ """Delete this Prefab."""
+ prefab = PrefabModel.from_id(prefab_id)
+
+ prefab.check_exists()
+ prefab.check_user_access(current_user['sub'])
+
+ old_object = prefab.delete()
+
+ return {'data': old_object}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a prefab.
+ """
+ prefab = fields.Nested(PrefabSchema, required=True)
diff --git a/opendc-web/opendc-web-api/opendc/api/projects.py b/opendc-web/opendc-web-api/opendc/api/projects.py
new file mode 100644
index 00000000..8c44b680
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/projects.py
@@ -0,0 +1,195 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from datetime import datetime
+from flask import request
+from flask_restful import Resource
+from marshmallow import Schema, fields
+
+from opendc.models.portfolio import Portfolio, PortfolioSchema
+from opendc.models.topology import Topology, TopologySchema
+from opendc.models.project import Project as ProjectModel, ProjectSchema
+from opendc.exts import current_user, requires_auth
+from opendc.database import Database
+
+
+class ProjectList(Resource):
+ """
+ Resource representing the list of projects available to a user.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self):
+ """Get the authorized projects of the user"""
+ user_id = current_user['sub']
+ projects = ProjectModel.get_for_user(user_id)
+ return {'data': projects}
+
+ def post(self):
+ """Create a new project, and return that new project."""
+ user_id = current_user['sub']
+
+ schema = Project.PutSchema()
+ result = schema.load(request.json)
+
+ topology = Topology({'name': 'Default topology', 'rooms': []})
+ topology.insert()
+
+ project = ProjectModel(result['project'])
+ project.set_property('datetimeCreated', Database.datetime_to_string(datetime.now()))
+ project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+ project.set_property('topologyIds', [topology.get_id()])
+ project.set_property('portfolioIds', [])
+ project.set_property('authorizations', [{'userId': user_id, 'level': 'OWN'}])
+ project.insert()
+
+ topology.set_property('projectId', project.get_id())
+ topology.update()
+
+ return {'data': project.obj}
+
+
+class Project(Resource):
+ """
+ Resource representing a single project.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, project_id):
+ """Get this Project."""
+ project = ProjectModel.from_id(project_id)
+
+ project.check_exists()
+ project.check_user_access(current_user['sub'], False)
+
+ return {'data': project.obj}
+
+ def put(self, project_id):
+ """Update a project's name."""
+ schema = Project.PutSchema()
+ result = schema.load(request.json)
+
+ project = ProjectModel.from_id(project_id)
+
+ project.check_exists()
+ project.check_user_access(current_user['sub'], True)
+
+ project.set_property('name', result['project']['name'])
+ project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+ project.update()
+
+ return {'data': project.obj}
+
+ def delete(self, project_id):
+ """Delete this Project."""
+ project = ProjectModel.from_id(project_id)
+
+ project.check_exists()
+ project.check_user_access(current_user['sub'], True)
+
+ for topology_id in project.obj['topologyIds']:
+ topology = Topology.from_id(topology_id)
+ topology.delete()
+
+ for portfolio_id in project.obj['portfolioIds']:
+ portfolio = Portfolio.from_id(portfolio_id)
+ portfolio.delete()
+
+ old_object = project.delete()
+
+ return {'data': old_object}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a project.
+ """
+ project = fields.Nested(ProjectSchema, required=True)
+
+
+class ProjectTopologies(Resource):
+ """
+ Resource representing the topologies of a project.
+ """
+ method_decorators = [requires_auth]
+
+ def post(self, project_id):
+ """Add a new Topology to the specified project and return it"""
+ schema = ProjectTopologies.PutSchema()
+ result = schema.load(request.json)
+
+ project = ProjectModel.from_id(project_id)
+
+ project.check_exists()
+ project.check_user_access(current_user['sub'], True)
+
+ topology = Topology({
+ 'projectId': project.get_id(),
+ 'name': result['topology']['name'],
+ 'rooms': result['topology']['rooms'],
+ })
+
+ topology.insert()
+
+ project.obj['topologyIds'].append(topology.get_id())
+ project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+ project.update()
+
+ return {'data': topology.obj}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a project topology.
+ """
+ topology = fields.Nested(TopologySchema, required=True)
+
+
+class ProjectPortfolios(Resource):
+ """
+ Resource representing the portfolios of a project.
+ """
+ method_decorators = [requires_auth]
+
+ def post(self, project_id):
+ """Add a new Portfolio for this Project."""
+ schema = ProjectPortfolios.PutSchema()
+ result = schema.load(request.json)
+
+ project = ProjectModel.from_id(project_id)
+
+ project.check_exists()
+ project.check_user_access(current_user['sub'], True)
+
+ portfolio = Portfolio(result['portfolio'])
+
+ portfolio.set_property('projectId', project.get_id())
+ portfolio.set_property('scenarioIds', [])
+
+ portfolio.insert()
+
+ project.obj['portfolioIds'].append(portfolio.get_id())
+ project.update()
+
+ return {'data': portfolio.obj}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a project portfolio.
+ """
+ portfolio = fields.Nested(PortfolioSchema, required=True)
diff --git a/opendc-web/opendc-web-api/opendc/api/scenarios.py b/opendc-web/opendc-web-api/opendc/api/scenarios.py
new file mode 100644
index 00000000..b566950a
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/scenarios.py
@@ -0,0 +1,81 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from flask import request
+from flask_restful import Resource
+from marshmallow import Schema, fields
+
+from opendc.models.scenario import Scenario as ScenarioModel, ScenarioSchema
+from opendc.models.portfolio import Portfolio
+from opendc.exts import current_user, requires_auth
+
+
+class Scenario(Resource):
+ """
+ A Scenario resource.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, scenario_id):
+ """Get scenario by identifier."""
+ scenario = ScenarioModel.from_id(scenario_id)
+ scenario.check_exists()
+ scenario.check_user_access(current_user['sub'], False)
+ data = scenario.obj
+ return {'data': data}
+
+ def put(self, scenario_id):
+ """Update this Scenarios name."""
+ schema = Scenario.PutSchema()
+ result = schema.load(request.json)
+
+ scenario = ScenarioModel.from_id(scenario_id)
+
+ scenario.check_exists()
+ scenario.check_user_access(current_user['sub'], True)
+
+ scenario.set_property('name', result['scenario']['name'])
+
+ scenario.update()
+ data = scenario.obj
+ return {'data': data}
+
+ def delete(self, scenario_id):
+ """Delete this Scenario."""
+ scenario = ScenarioModel.from_id(scenario_id)
+ scenario.check_exists()
+ scenario.check_user_access(current_user['sub'], True)
+
+ scenario_id = scenario.get_id()
+
+ portfolio = Portfolio.from_id(scenario.obj['portfolioId'])
+ portfolio.check_exists()
+ if scenario_id in portfolio.obj['scenarioIds']:
+ portfolio.obj['scenarioIds'].remove(scenario_id)
+ portfolio.update()
+
+ old_object = scenario.delete()
+ return {'data': old_object}
+
+ class PutSchema(Schema):
+ """
+ Schema for the put operation.
+ """
+ scenario = fields.Nested(ScenarioSchema, required=True)
diff --git a/opendc-web/opendc-web-api/opendc/api/schedulers.py b/opendc-web/opendc-web-api/opendc/api/schedulers.py
new file mode 100644
index 00000000..b00d8c31
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/schedulers.py
@@ -0,0 +1,46 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+
+from flask_restful import Resource
+from opendc.exts import requires_auth
+
+SCHEDULERS = [
+ 'mem',
+ 'mem-inv',
+ 'core-mem',
+ 'core-mem-inv',
+ 'active-servers',
+ 'active-servers-inv',
+ 'provisioned-cores',
+ 'provisioned-cores-inv',
+ 'random'
+]
+
+
+class SchedulerList(Resource):
+ """
+ Resource for the list of schedulers to pick from.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self):
+ """Get all available Traces."""
+ return {'data': [{'name': name} for name in SCHEDULERS]}
diff --git a/opendc-web/opendc-web-api/opendc/api/topologies.py b/opendc-web/opendc-web-api/opendc/api/topologies.py
new file mode 100644
index 00000000..eedf049d
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/topologies.py
@@ -0,0 +1,93 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from datetime import datetime
+
+from flask import request
+from flask_restful import Resource
+from marshmallow import Schema, fields
+
+from opendc.database import Database
+from opendc.models.project import Project
+from opendc.models.topology import Topology as TopologyModel, TopologySchema
+from opendc.exts import current_user, requires_auth
+
+
+class Topology(Resource):
+ """
+ Resource representing a single topology.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, topology_id):
+ """
+ Get a single topology.
+ """
+ topology = TopologyModel.from_id(topology_id)
+ topology.check_exists()
+ topology.check_user_access(current_user['sub'], False)
+ data = topology.obj
+ return {'data': data}
+
+ def put(self, topology_id):
+ """
+ Replace the topology.
+ """
+ topology = TopologyModel.from_id(topology_id)
+
+ schema = Topology.PutSchema()
+ result = schema.load(request.json)
+
+ topology.check_exists()
+ topology.check_user_access(current_user['sub'], True)
+
+ topology.set_property('name', result['topology']['name'])
+ topology.set_property('rooms', result['topology']['rooms'])
+ topology.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+
+ topology.update()
+ data = topology.obj
+ return {'data': data}
+
+ def delete(self, topology_id):
+ """
+ Delete a topology.
+ """
+ topology = TopologyModel.from_id(topology_id)
+
+ topology.check_exists()
+ topology.check_user_access(current_user['sub'], True)
+
+ topology_id = topology.get_id()
+
+ project = Project.from_id(topology.obj['projectId'])
+ project.check_exists()
+ if topology_id in project.obj['topologyIds']:
+ project.obj['topologyIds'].remove(topology_id)
+ project.update()
+
+ old_object = topology.delete()
+ return {'data': old_object}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a topology.
+ """
+ topology = fields.Nested(TopologySchema, required=True)
diff --git a/opendc-web/opendc-web-api/opendc/api/traces.py b/opendc-web/opendc-web-api/opendc/api/traces.py
new file mode 100644
index 00000000..f685f00c
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/traces.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from flask_restful import Resource
+
+from opendc.exts import requires_auth
+from opendc.models.trace import Trace as TraceModel
+
+
+class TraceList(Resource):
+ """
+ Resource for the list of traces to pick from.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self):
+ """Get all available Traces."""
+ traces = TraceModel.get_all()
+ data = traces.obj
+ return {'data': data}
+
+
+class Trace(Resource):
+ """
+ Resource representing a single trace.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, trace_id):
+ """Get trace information by identifier."""
+ trace = TraceModel.from_id(trace_id)
+ trace.check_exists()
+ data = trace.obj
+ return {'data': data}
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/paths.json b/opendc-web/opendc-web-api/opendc/api/v2/paths.json
deleted file mode 100644
index 652be5bc..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/paths.json
+++ /dev/null
@@ -1,19 +0,0 @@
-[
- "/users",
- "/users/{userId}",
- "/projects",
- "/projects/{projectId}",
- "/projects/{projectId}/authorizations",
- "/projects/{projectId}/topologies",
- "/projects/{projectId}/portfolios",
- "/topologies/{topologyId}",
- "/portfolios/{portfolioId}",
- "/portfolios/{portfolioId}/scenarios",
- "/scenarios/{scenarioId}",
- "/schedulers",
- "/traces",
- "/traces/{traceId}",
- "/prefabs",
- "/prefabs/{prefabId}",
- "/prefabs/authorizations"
-]
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py
deleted file mode 100644
index c856f4ce..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py
+++ /dev/null
@@ -1,67 +0,0 @@
-from opendc.models.portfolio import Portfolio
-from opendc.models.project import Project
-from opendc.util.rest import Response
-
-
-def GET(request):
- """Get this Portfolio."""
-
- request.check_required_parameters(path={'portfolioId': 'string'})
-
- portfolio = Portfolio.from_id(request.params_path['portfolioId'])
-
- portfolio.check_exists()
- portfolio.check_user_access(request.current_user['sub'], False)
-
- return Response(200, 'Successfully retrieved portfolio.', portfolio.obj)
-
-
-def PUT(request):
- """Update this Portfolio."""
-
- request.check_required_parameters(path={'portfolioId': 'string'}, body={'portfolio': {
- 'name': 'string',
- 'targets': {
- 'enabledMetrics': 'list',
- 'repeatsPerScenario': 'int',
- },
- }})
-
- portfolio = Portfolio.from_id(request.params_path['portfolioId'])
-
- portfolio.check_exists()
- portfolio.check_user_access(request.current_user['sub'], True)
-
- portfolio.set_property('name',
- request.params_body['portfolio']['name'])
- portfolio.set_property('targets.enabledMetrics',
- request.params_body['portfolio']['targets']['enabledMetrics'])
- portfolio.set_property('targets.repeatsPerScenario',
- request.params_body['portfolio']['targets']['repeatsPerScenario'])
-
- portfolio.update()
-
- return Response(200, 'Successfully updated portfolio.', portfolio.obj)
-
-
-def DELETE(request):
- """Delete this Portfolio."""
-
- request.check_required_parameters(path={'portfolioId': 'string'})
-
- portfolio = Portfolio.from_id(request.params_path['portfolioId'])
-
- portfolio.check_exists()
- portfolio.check_user_access(request.current_user['sub'], True)
-
- portfolio_id = portfolio.get_id()
-
- project = Project.from_id(portfolio.obj['projectId'])
- project.check_exists()
- if portfolio_id in project.obj['portfolioIds']:
- project.obj['portfolioIds'].remove(portfolio_id)
- project.update()
-
- old_object = portfolio.delete()
-
- return Response(200, 'Successfully deleted portfolio.', old_object)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py
deleted file mode 100644
index b12afce3..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from opendc.models.portfolio import Portfolio
-from opendc.models.scenario import Scenario
-from opendc.models.topology import Topology
-from opendc.util.rest import Response
-
-
-def POST(request):
- """Add a new Scenario for this Portfolio."""
-
- request.check_required_parameters(path={'portfolioId': 'string'},
- body={
- 'scenario': {
- 'name': 'string',
- 'trace': {
- 'traceId': 'string',
- 'loadSamplingFraction': 'float',
- },
- 'topology': {
- 'topologyId': 'string',
- },
- 'operational': {
- 'failuresEnabled': 'bool',
- 'performanceInterferenceEnabled': 'bool',
- 'schedulerName': 'string',
- },
- }
- })
-
- portfolio = Portfolio.from_id(request.params_path['portfolioId'])
-
- portfolio.check_exists()
- portfolio.check_user_access(request.current_user['sub'], True)
-
- scenario = Scenario(request.params_body['scenario'])
-
- topology = Topology.from_id(scenario.obj['topology']['topologyId'])
- topology.check_exists()
- topology.check_user_access(request.current_user['sub'], True)
-
- scenario.set_property('portfolioId', portfolio.get_id())
- scenario.set_property('simulation', {'state': 'QUEUED'})
- scenario.set_property('topology.topologyId', topology.get_id())
-
- scenario.insert()
-
- portfolio.obj['scenarioIds'].append(scenario.get_id())
- portfolio.update()
-
- return Response(200, 'Successfully added Scenario.', scenario.obj)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py
deleted file mode 100644
index ff1666c0..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py
+++ /dev/null
@@ -1,125 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_add_scenario_missing_parameter(client):
- assert '400' in client.post('/v2/portfolios/1/scenarios').status
-
-
-def test_add_scenario_non_existing_portfolio(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.post(f'/v2/portfolios/{test_id}/scenarios',
- json={
- 'scenario': {
- 'name': 'test',
- 'trace': {
- 'traceId': test_id,
- 'loadSamplingFraction': 1.0,
- },
- 'topology': {
- 'topologyId': test_id,
- },
- 'operational': {
- 'failuresEnabled': True,
- 'performanceInterferenceEnabled': False,
- 'schedulerName': 'DEFAULT',
- },
- }
- }).status
-
-
-def test_add_scenario_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'portfolioId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'VIEW'
- }]
- })
- assert '403' in client.post(f'/v2/portfolios/{test_id}/scenarios',
- json={
- 'scenario': {
- 'name': 'test',
- 'trace': {
- 'traceId': test_id,
- 'loadSamplingFraction': 1.0,
- },
- 'topology': {
- 'topologyId': test_id,
- },
- 'operational': {
- 'failuresEnabled': True,
- 'performanceInterferenceEnabled': False,
- 'schedulerName': 'DEFAULT',
- },
- }
- }).status
-
-
-def test_add_scenario(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'portfolioId': test_id,
- 'portfolioIds': [test_id],
- 'scenarioIds': [test_id],
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'EDIT'
- }],
- 'simulation': {
- 'state': 'QUEUED',
- },
- })
- mocker.patch.object(DB,
- 'insert',
- return_value={
- '_id': test_id,
- 'name': 'test',
- 'trace': {
- 'traceId': test_id,
- 'loadSamplingFraction': 1.0,
- },
- 'topology': {
- 'topologyId': test_id,
- },
- 'operational': {
- 'failuresEnabled': True,
- 'performanceInterferenceEnabled': False,
- 'schedulerName': 'DEFAULT',
- },
- 'portfolioId': test_id,
- 'simulationState': {
- 'state': 'QUEUED',
- },
- })
- mocker.patch.object(DB, 'update', return_value=None)
- res = client.post(
- f'/v2/portfolios/{test_id}/scenarios',
- json={
- 'scenario': {
- 'name': 'test',
- 'trace': {
- 'traceId': test_id,
- 'loadSamplingFraction': 1.0,
- },
- 'topology': {
- 'topologyId': test_id,
- },
- 'operational': {
- 'failuresEnabled': True,
- 'performanceInterferenceEnabled': False,
- 'schedulerName': 'DEFAULT',
- },
- }
- })
- assert 'portfolioId' in res.json['content']
- assert 'simulation' in res.json['content']
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py
deleted file mode 100644
index 1a44c63d..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py
+++ /dev/null
@@ -1,149 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_portfolio_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/v2/portfolios/{test_id}').status
-
-
-def test_get_portfolio_no_authorizations(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []})
- res = client.get(f'/v2/portfolios/{test_id}')
- assert '403' in res.status
-
-
-def test_get_portfolio_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- 'projectId': test_id,
- '_id': test_id,
- 'authorizations': []
- })
- res = client.get(f'/v2/portfolios/{test_id}')
- assert '403' in res.status
-
-
-def test_get_portfolio(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- 'projectId': test_id,
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'EDIT'
- }]
- })
- res = client.get(f'/v2/portfolios/{test_id}')
- assert '200' in res.status
-
-
-def test_update_portfolio_missing_parameter(client):
- assert '400' in client.put(f'/v2/portfolios/{test_id}').status
-
-
-def test_update_portfolio_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/v2/portfolios/{test_id}', json={
- 'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }
- }).status
-
-
-def test_update_portfolio_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'VIEW'
- }]
- })
- mocker.patch.object(DB, 'update', return_value={})
- assert '403' in client.put(f'/v2/portfolios/{test_id}', json={
- 'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }
- }).status
-
-
-def test_update_portfolio(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'OWN'
- }],
- 'targets': {
- 'enabledMetrics': [],
- 'repeatsPerScenario': 1
- }
- })
- mocker.patch.object(DB, 'update', return_value={})
-
- res = client.put(f'/v2/portfolios/{test_id}', json={'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }})
- assert '200' in res.status
-
-
-def test_delete_project_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.delete(f'/v2/portfolios/{test_id}').status
-
-
-def test_delete_project_different_user(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'googleId': 'other_test',
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'VIEW'
- }]
- })
- mocker.patch.object(DB, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/v2/portfolios/{test_id}').status
-
-
-def test_delete_project(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'googleId': 'test',
- 'portfolioIds': [test_id],
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'delete_one', return_value={})
- mocker.patch.object(DB, 'update', return_value=None)
- res = client.delete(f'/v2/portfolios/{test_id}')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py
deleted file mode 100644
index 5a8d367f..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from opendc.models.prefab import Prefab
-from opendc.util.database import DB
-from opendc.util.rest import Response
-
-
-def GET(request):
- """Return all prefabs the user is authorized to access"""
-
- user_id = request.current_user['sub']
-
- own_prefabs = DB.fetch_all({'authorId': user_id}, Prefab.collection_name)
- public_prefabs = DB.fetch_all({'visibility': 'public'}, Prefab.collection_name)
-
- authorizations = {"authorizations": []}
-
- authorizations["authorizations"].append(own_prefabs)
- authorizations["authorizations"].append(public_prefabs)
-
- return Response(200, 'Successfully fetched authorizations.', authorizations)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/test_endpoint.py
deleted file mode 100644
index 6d36d428..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/test_endpoint.py
+++ /dev/null
@@ -1,71 +0,0 @@
-from opendc.util.database import DB
-from unittest.mock import Mock
-
-test_id = 24 * '1'
-
-
-def test_get_authorizations(client, mocker):
- DB.fetch_all = Mock()
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id})
- DB.fetch_all.side_effect = [
- [{
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'private'
- },
- {
- '_id': '2' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'private'
- },
- {
- '_id': '3' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'public'
- },
- {
- '_id': '4' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'public'
- }],
- [{
- '_id': '5' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- },
- {
- '_id': '6' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- },
- {
- '_id': '7' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- },
- {
- '_id': '8' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- }]
- ]
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id})
- res = client.get('/v2/prefabs/authorizations')
- assert '200' in res.status
-
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py
deleted file mode 100644
index 4a30f7eb..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from datetime import datetime
-
-from opendc.models.prefab import Prefab
-from opendc.util.database import Database
-from opendc.util.rest import Response
-
-
-def POST(request):
- """Create a new prefab, and return that new prefab."""
-
- request.check_required_parameters(body={'prefab': {'name': 'string'}})
-
- prefab = Prefab(request.params_body['prefab'])
- prefab.set_property('datetimeCreated', Database.datetime_to_string(datetime.now()))
- prefab.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
-
- user_id = request.current_user['sub']
- prefab.set_property('authorId', user_id)
-
- prefab.insert()
-
- return Response(200, 'Successfully created prefab.', prefab.obj)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py
deleted file mode 100644
index f1cf1fcd..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from datetime import datetime
-
-from opendc.models.prefab import Prefab
-from opendc.util.database import Database
-from opendc.util.rest import Response
-
-
-def GET(request):
- """Get this Prefab."""
-
- request.check_required_parameters(path={'prefabId': 'string'})
-
- prefab = Prefab.from_id(request.params_path['prefabId'])
- prefab.check_exists()
- prefab.check_user_access(request.current_user['sub'])
-
- return Response(200, 'Successfully retrieved prefab', prefab.obj)
-
-
-def PUT(request):
- """Update a prefab's name and/or contents."""
-
- request.check_required_parameters(body={'prefab': {'name': 'name'}}, path={'prefabId': 'string'})
-
- prefab = Prefab.from_id(request.params_path['prefabId'])
-
- prefab.check_exists()
- prefab.check_user_access(request.current_user['sub'])
-
- prefab.set_property('name', request.params_body['prefab']['name'])
- prefab.set_property('rack', request.params_body['prefab']['rack'])
- prefab.set_property('datetime_last_edited', Database.datetime_to_string(datetime.now()))
- prefab.update()
-
- return Response(200, 'Successfully updated prefab.', prefab.obj)
-
-
-def DELETE(request):
- """Delete this Prefab."""
-
- request.check_required_parameters(path={'prefabId': 'string'})
-
- prefab = Prefab.from_id(request.params_path['prefabId'])
-
- prefab.check_exists()
- prefab.check_user_access(request.current_user['sub'])
-
- old_object = prefab.delete()
-
- return Response(200, 'Successfully deleted prefab.', old_object)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/test_endpoint.py
deleted file mode 100644
index bc3b1a32..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/test_endpoint.py
+++ /dev/null
@@ -1,145 +0,0 @@
-from opendc.util.database import DB
-from unittest.mock import Mock
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_prefab_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/v2/prefabs/{test_id}').status
-
-
-def test_get_private_prefab_not_authorized(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- res = client.get(f'/v2/prefabs/{test_id}')
- assert '403' in res.status
-
-
-def test_get_private_prefab(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': 'test',
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- res = client.get(f'/v2/prefabs/{test_id}')
- assert '200' in res.status
-
-
-def test_get_public_prefab(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'public',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- res = client.get(f'/v2/prefabs/{test_id}')
- assert '200' in res.status
-
-
-def test_update_prefab_missing_parameter(client):
- assert '400' in client.put(f'/v2/prefabs/{test_id}').status
-
-
-def test_update_prefab_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/v2/prefabs/{test_id}', json={'prefab': {'name': 'S'}}).status
-
-
-def test_update_prefab_not_authorized(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(DB, 'update', return_value={})
- assert '403' in client.put(f'/v2/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}}).status
-
-
-def test_update_prefab(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': 'test',
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(DB, 'update', return_value={})
- res = client.put(f'/v2/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}})
- assert '200' in res.status
-
-
-def test_delete_prefab_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.delete(f'/v2/prefabs/{test_id}').status
-
-
-def test_delete_prefab_different_user(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(DB, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/v2/prefabs/{test_id}').status
-
-
-def test_delete_prefab(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': 'test',
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(DB, 'delete_one', return_value={'prefab': {'name': 'name'}})
- res = client.delete(f'/v2/prefabs/{test_id}')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/test_endpoint.py
deleted file mode 100644
index 39a78c21..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/test_endpoint.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_add_prefab_missing_parameter(client):
- assert '400' in client.post('/v2/prefabs').status
-
-
-def test_add_prefab(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'authorizations': []})
- mocker.patch.object(DB,
- 'insert',
- return_value={
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id
- })
- res = client.post('/v2/prefabs', json={'prefab': {'name': 'test prefab'}})
- assert 'datetimeCreated' in res.json['content']
- assert 'datetimeLastEdited' in res.json['content']
- assert 'authorId' in res.json['content']
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py
deleted file mode 100644
index b381d689..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from datetime import datetime
-
-from opendc.models.project import Project
-from opendc.models.topology import Topology
-from opendc.util.database import Database
-from opendc.util.rest import Response
-
-
-def GET(request):
- """Get the authorized projects of the user"""
- user_id = request.current_user['sub']
- projects = Project.get_for_user(user_id)
- return Response(200, 'Successfully retrieved projects', projects)
-
-
-def POST(request):
- """Create a new project, and return that new project."""
-
- request.check_required_parameters(body={'project': {'name': 'string'}})
- user_id = request.current_user['sub']
-
- topology = Topology({'name': 'Default topology', 'rooms': []})
- topology.insert()
-
- project = Project(request.params_body['project'])
- project.set_property('datetimeCreated', Database.datetime_to_string(datetime.now()))
- project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
- project.set_property('topologyIds', [topology.get_id()])
- project.set_property('portfolioIds', [])
- project.set_property('authorizations', [{'userId': user_id, 'authorizationLevel': 'OWN'}])
- project.insert()
-
- topology.set_property('projectId', project.get_id())
- topology.update()
-
- return Response(200, 'Successfully created project.', project.obj)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py
deleted file mode 100644
index fa53ce6b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py
+++ /dev/null
@@ -1,60 +0,0 @@
-from datetime import datetime
-
-from opendc.models.portfolio import Portfolio
-from opendc.models.project import Project
-from opendc.models.topology import Topology
-from opendc.util.database import Database
-from opendc.util.rest import Response
-
-
-def GET(request):
- """Get this Project."""
-
- request.check_required_parameters(path={'projectId': 'string'})
-
- project = Project.from_id(request.params_path['projectId'])
-
- project.check_exists()
- project.check_user_access(request.current_user['sub'], False)
-
- return Response(200, 'Successfully retrieved project', project.obj)
-
-
-def PUT(request):
- """Update a project's name."""
-
- request.check_required_parameters(body={'project': {'name': 'name'}}, path={'projectId': 'string'})
-
- project = Project.from_id(request.params_path['projectId'])
-
- project.check_exists()
- project.check_user_access(request.current_user['sub'], True)
-
- project.set_property('name', request.params_body['project']['name'])
- project.set_property('datetime_last_edited', Database.datetime_to_string(datetime.now()))
- project.update()
-
- return Response(200, 'Successfully updated project.', project.obj)
-
-
-def DELETE(request):
- """Delete this Project."""
-
- request.check_required_parameters(path={'projectId': 'string'})
-
- project = Project.from_id(request.params_path['projectId'])
-
- project.check_exists()
- project.check_user_access(request.current_user['sub'], True)
-
- for topology_id in project.obj['topologyIds']:
- topology = Topology.from_id(topology_id)
- topology.delete()
-
- for portfolio_id in project.obj['portfolioIds']:
- portfolio = Portfolio.from_id(portfolio_id)
- portfolio.delete()
-
- old_object = project.delete()
-
- return Response(200, 'Successfully deleted project.', old_object)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py
deleted file mode 100644
index 18b4d007..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from opendc.models.portfolio import Portfolio
-from opendc.models.project import Project
-from opendc.util.rest import Response
-
-
-def POST(request):
- """Add a new Portfolio for this Project."""
-
- request.check_required_parameters(path={'projectId': 'string'},
- body={
- 'portfolio': {
- 'name': 'string',
- 'targets': {
- 'enabledMetrics': 'list',
- 'repeatsPerScenario': 'int',
- },
- }
- })
-
- project = Project.from_id(request.params_path['projectId'])
-
- project.check_exists()
- project.check_user_access(request.current_user['sub'], True)
-
- portfolio = Portfolio(request.params_body['portfolio'])
-
- portfolio.set_property('projectId', project.get_id())
- portfolio.set_property('scenarioIds', [])
-
- portfolio.insert()
-
- project.obj['portfolioIds'].append(portfolio.get_id())
- project.update()
-
- return Response(200, 'Successfully added Portfolio.', portfolio.obj)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py
deleted file mode 100644
index 7ddfe0ce..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py
+++ /dev/null
@@ -1,85 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_add_portfolio_missing_parameter(client):
- assert '400' in client.post(f'/v2/projects/{test_id}/portfolios').status
-
-
-def test_add_portfolio_non_existing_project(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.post(f'/v2/projects/{test_id}/portfolios',
- json={
- 'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }
- }).status
-
-
-def test_add_portfolio_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'VIEW'
- }]
- })
- assert '403' in client.post(f'/v2/projects/{test_id}/portfolios',
- json={
- 'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }
- }).status
-
-
-def test_add_portfolio(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'portfolioIds': [test_id],
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'EDIT'
- }]
- })
- mocker.patch.object(DB,
- 'insert',
- return_value={
- '_id': test_id,
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- },
- 'projectId': test_id,
- 'scenarioIds': [],
- })
- mocker.patch.object(DB, 'update', return_value=None)
- res = client.post(
- f'/v2/projects/{test_id}/portfolios',
- json={
- 'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }
- })
- assert 'projectId' in res.json['content']
- assert 'scenarioIds' in res.json['content']
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/test_endpoint.py
deleted file mode 100644
index 03e6758b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/test_endpoint.py
+++ /dev/null
@@ -1,119 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_project_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/v2/projects/{test_id}').status
-
-
-def test_get_project_no_authorizations(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'authorizations': []})
- res = client.get(f'/v2/projects/{test_id}')
- assert '403' in res.status
-
-
-def test_get_project_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': []
- })
- res = client.get(f'/v2/projects/{test_id}')
- assert '403' in res.status
-
-
-def test_get_project(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'EDIT'
- }]
- })
- res = client.get(f'/v2/projects/{test_id}')
- assert '200' in res.status
-
-
-def test_update_project_missing_parameter(client):
- assert '400' in client.put(f'/v2/projects/{test_id}').status
-
-
-def test_update_project_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/v2/projects/{test_id}', json={'project': {'name': 'S'}}).status
-
-
-def test_update_project_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'VIEW'
- }]
- })
- mocker.patch.object(DB, 'update', return_value={})
- assert '403' in client.put(f'/v2/projects/{test_id}', json={'project': {'name': 'S'}}).status
-
-
-def test_update_project(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'update', return_value={})
-
- res = client.put(f'/v2/projects/{test_id}', json={'project': {'name': 'S'}})
- assert '200' in res.status
-
-
-def test_delete_project_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.delete(f'/v2/projects/{test_id}').status
-
-
-def test_delete_project_different_user(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'googleId': 'other_test',
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'VIEW'
- }],
- 'topologyIds': []
- })
- mocker.patch.object(DB, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/v2/projects/{test_id}').status
-
-
-def test_delete_project(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'googleId': 'test',
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'OWN'
- }],
- 'topologyIds': [],
- 'portfolioIds': [],
- })
- mocker.patch.object(DB, 'update', return_value=None)
- mocker.patch.object(DB, 'delete_one', return_value={'googleId': 'test'})
- res = client.delete(f'/v2/projects/{test_id}')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py
deleted file mode 100644
index 47f2a207..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from datetime import datetime
-
-from opendc.models.project import Project
-from opendc.models.topology import Topology
-from opendc.util.rest import Response
-from opendc.util.database import Database
-
-
-def POST(request):
- """Add a new Topology to the specified project and return it"""
-
- request.check_required_parameters(path={'projectId': 'string'}, body={'topology': {'name': 'string'}})
-
- project = Project.from_id(request.params_path['projectId'])
-
- project.check_exists()
- project.check_user_access(request.current_user['sub'], True)
-
- topology = Topology({
- 'projectId': project.get_id(),
- 'name': request.params_body['topology']['name'],
- 'rooms': request.params_body['topology']['rooms'],
- })
-
- topology.insert()
-
- project.obj['topologyIds'].append(topology.get_id())
- project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
- project.update()
-
- return Response(200, 'Successfully inserted topology.', topology.obj)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py
deleted file mode 100644
index 2e872415..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py
+++ /dev/null
@@ -1,52 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_add_topology_missing_parameter(client):
- assert '400' in client.post(f'/v2/projects/{test_id}/topologies').status
-
-
-def test_add_topology(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'OWN'
- }],
- 'topologyIds': []
- })
- mocker.patch.object(DB,
- 'insert',
- return_value={
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'topologyIds': []
- })
- mocker.patch.object(DB, 'update', return_value={})
- res = client.post(f'/v2/projects/{test_id}/topologies', json={'topology': {'name': 'test project', 'rooms': []}})
- assert 'rooms' in res.json['content']
- assert '200' in res.status
-
-
-def test_add_topology_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'VIEW'
- }]
- })
- assert '403' in client.post(f'/v2/projects/{test_id}/topologies',
- json={
- 'topology': {
- 'name': 'test_topology',
- 'rooms': {}
- }
- }).status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/test_endpoint.py
deleted file mode 100644
index db768f28..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/test_endpoint.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_get_user_projects(client, mocker):
- mocker.patch.object(DB, 'fetch_all', return_value={'_id': test_id, 'authorizations': [{'userId': 'test',
- 'authorizationLevel': 'OWN'}]})
- res = client.get('/v2/projects')
- assert '200' in res.status
-
-
-def test_add_project_missing_parameter(client):
- assert '400' in client.post('/v2/projects').status
-
-
-def test_add_project(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'authorizations': []})
- mocker.patch.object(DB,
- 'insert',
- return_value={
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'topologyIds': []
- })
- mocker.patch.object(DB, 'update', return_value={})
- res = client.post('/v2/projects', json={'project': {'name': 'test project'}})
- assert 'datetimeCreated' in res.json['content']
- assert 'datetimeLastEdited' in res.json['content']
- assert 'topologyIds' in res.json['content']
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py
deleted file mode 100644
index 7399f98c..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py
+++ /dev/null
@@ -1,59 +0,0 @@
-from opendc.models.scenario import Scenario
-from opendc.models.portfolio import Portfolio
-from opendc.util.rest import Response
-
-
-def GET(request):
- """Get this Scenario."""
-
- request.check_required_parameters(path={'scenarioId': 'string'})
-
- scenario = Scenario.from_id(request.params_path['scenarioId'])
-
- scenario.check_exists()
- scenario.check_user_access(request.current_user['sub'], False)
-
- return Response(200, 'Successfully retrieved scenario.', scenario.obj)
-
-
-def PUT(request):
- """Update this Scenarios name."""
-
- request.check_required_parameters(path={'scenarioId': 'string'}, body={'scenario': {
- 'name': 'string',
- }})
-
- scenario = Scenario.from_id(request.params_path['scenarioId'])
-
- scenario.check_exists()
- scenario.check_user_access(request.current_user['sub'], True)
-
- scenario.set_property('name',
- request.params_body['scenario']['name'])
-
- scenario.update()
-
- return Response(200, 'Successfully updated scenario.', scenario.obj)
-
-
-def DELETE(request):
- """Delete this Scenario."""
-
- request.check_required_parameters(path={'scenarioId': 'string'})
-
- scenario = Scenario.from_id(request.params_path['scenarioId'])
-
- scenario.check_exists()
- scenario.check_user_access(request.current_user['sub'], True)
-
- scenario_id = scenario.get_id()
-
- portfolio = Portfolio.from_id(scenario.obj['portfolioId'])
- portfolio.check_exists()
- if scenario_id in portfolio.obj['scenarioIds']:
- portfolio.obj['scenarioIds'].remove(scenario_id)
- portfolio.update()
-
- old_object = scenario.delete()
-
- return Response(200, 'Successfully deleted scenario.', old_object)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py
deleted file mode 100644
index 24b38671..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py
+++ /dev/null
@@ -1,115 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_scenario_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/v2/scenarios/{test_id}').status
-
-
-def test_get_scenario_no_authorizations(client, mocker):
- m = mocker.MagicMock()
- m.side_effect = ({'portfolioId': test_id}, {'projectId': test_id}, {'authorizations': []})
- mocker.patch.object(DB, 'fetch_one', m)
- res = client.get(f'/v2/scenarios/{test_id}')
- assert '403' in res.status
-
-
-def test_get_scenario(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- side_effect=[
- {'portfolioId': test_id},
- {'projectId': test_id},
- {'authorizations':
- [{'userId': 'test', 'authorizationLevel': 'OWN'}]
- }])
- res = client.get(f'/v2/scenarios/{test_id}')
- assert '200' in res.status
-
-
-def test_update_scenario_missing_parameter(client):
- assert '400' in client.put(f'/v2/scenarios/{test_id}').status
-
-
-def test_update_scenario_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/v2/scenarios/{test_id}', json={
- 'scenario': {
- 'name': 'test',
- }
- }).status
-
-
-def test_update_scenario_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- side_effect=[
- {'portfolioId': test_id},
- {'projectId': test_id},
- {'authorizations':
- [{'userId': 'test', 'authorizationLevel': 'VIEW'}]
- }])
- mocker.patch.object(DB, 'update', return_value={})
- assert '403' in client.put(f'/v2/scenarios/{test_id}', json={
- 'scenario': {
- 'name': 'test',
- }
- }).status
-
-
-def test_update_scenario(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- side_effect=[
- {'_id': test_id, 'portfolioId': test_id},
- {'projectId': test_id},
- {'authorizations':
- [{'userId': 'test', 'authorizationLevel': 'OWN'}]
- }])
- mocker.patch.object(DB, 'update', return_value={})
-
- res = client.put(f'/v2/scenarios/{test_id}', json={'scenario': {
- 'name': 'test',
- }})
- assert '200' in res.status
-
-
-def test_delete_project_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.delete(f'/v2/scenarios/{test_id}').status
-
-
-def test_delete_project_different_user(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- side_effect=[
- {'_id': test_id, 'portfolioId': test_id},
- {'projectId': test_id},
- {'authorizations':
- [{'userId': 'test', 'authorizationLevel': 'VIEW'}]
- }])
- mocker.patch.object(DB, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/v2/scenarios/{test_id}').status
-
-
-def test_delete_project(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'portfolioId': test_id,
- 'googleId': 'test',
- 'scenarioIds': [test_id],
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'delete_one', return_value={})
- mocker.patch.object(DB, 'update', return_value=None)
- res = client.delete(f'/v2/scenarios/{test_id}')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/schedulers/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/schedulers/endpoint.py
deleted file mode 100644
index f33159bf..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/endpoint.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from opendc.util.rest import Response
-
-SCHEDULERS = [
- 'mem',
- 'mem-inv',
- 'core-mem',
- 'core-mem-inv',
- 'active-servers',
- 'active-servers-inv',
- 'provisioned-cores',
- 'provisioned-cores-inv',
- 'random'
-]
-
-
-def GET(_):
- """Get all available Schedulers."""
-
- return Response(200, 'Successfully retrieved Schedulers.', [{'name': name} for name in SCHEDULERS])
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/schedulers/test_endpoint.py
deleted file mode 100644
index 4950ca4c..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/test_endpoint.py
+++ /dev/null
@@ -1,2 +0,0 @@
-def test_get_schedulers(client):
- assert '200' in client.get('/v2/schedulers').status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/topologies/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/topologies/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/topologies/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py
deleted file mode 100644
index 80618190..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py
+++ /dev/null
@@ -1,58 +0,0 @@
-from datetime import datetime
-
-from opendc.util.database import Database
-from opendc.models.project import Project
-from opendc.models.topology import Topology
-from opendc.util.rest import Response
-
-
-def GET(request):
- """Get this Topology."""
-
- request.check_required_parameters(path={'topologyId': 'string'})
-
- topology = Topology.from_id(request.params_path['topologyId'])
-
- topology.check_exists()
- topology.check_user_access(request.current_user['sub'], False)
-
- return Response(200, 'Successfully retrieved topology.', topology.obj)
-
-
-def PUT(request):
- """Update this topology"""
- request.check_required_parameters(path={'topologyId': 'string'}, body={'topology': {'name': 'string', 'rooms': {}}})
- topology = Topology.from_id(request.params_path['topologyId'])
-
- topology.check_exists()
- topology.check_user_access(request.current_user['sub'], True)
-
- topology.set_property('name', request.params_body['topology']['name'])
- topology.set_property('rooms', request.params_body['topology']['rooms'])
- topology.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
-
- topology.update()
-
- return Response(200, 'Successfully updated topology.', topology.obj)
-
-
-def DELETE(request):
- """Delete this topology"""
- request.check_required_parameters(path={'topologyId': 'string'})
-
- topology = Topology.from_id(request.params_path['topologyId'])
-
- topology.check_exists()
- topology.check_user_access(request.current_user['sub'], True)
-
- topology_id = topology.get_id()
-
- project = Project.from_id(topology.obj['projectId'])
- project.check_exists()
- if topology_id in project.obj['topologyIds']:
- project.obj['topologyIds'].remove(topology_id)
- project.update()
-
- old_object = topology.delete()
-
- return Response(200, 'Successfully deleted topology.', old_object)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/test_endpoint.py
deleted file mode 100644
index 96d2e08e..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/test_endpoint.py
+++ /dev/null
@@ -1,113 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_topology(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'EDIT'
- }]
- })
- res = client.get(f'/v2/topologies/{test_id}')
- assert '200' in res.status
-
-
-def test_get_topology_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get('/v2/topologies/1').status
-
-
-def test_get_topology_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': []
- })
- res = client.get(f'/v2/topologies/{test_id}')
- assert '403' in res.status
-
-
-def test_get_topology_no_authorizations(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []})
- res = client.get(f'/v2/topologies/{test_id}')
- assert '403' in res.status
-
-
-def test_update_topology_missing_parameter(client):
- assert '400' in client.put(f'/v2/topologies/{test_id}').status
-
-
-def test_update_topology_non_existent(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/v2/topologies/{test_id}', json={'topology': {'name': 'test_topology', 'rooms': {}}}).status
-
-
-def test_update_topology_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': []
- })
- mocker.patch.object(DB, 'update', return_value={})
- assert '403' in client.put(f'/v2/topologies/{test_id}', json={
- 'topology': {
- 'name': 'updated_topology',
- 'rooms': {}
- }
- }).status
-
-
-def test_update_topology(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'update', return_value={})
-
- assert '200' in client.put(f'/v2/topologies/{test_id}', json={
- 'topology': {
- 'name': 'updated_topology',
- 'rooms': {}
- }
- }).status
-
-
-def test_delete_topology(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'googleId': 'test',
- 'topologyIds': [test_id],
- 'authorizations': [{
- 'userId': 'test',
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'delete_one', return_value={})
- mocker.patch.object(DB, 'update', return_value=None)
- res = client.delete(f'/v2/topologies/{test_id}')
- assert '200' in res.status
-
-
-def test_delete_nonexistent_topology(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.delete(f'/v2/topologies/{test_id}').status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/traces/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/traces/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/endpoint.py
deleted file mode 100644
index ee699e02..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/endpoint.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from opendc.models.trace import Trace
-from opendc.util.rest import Response
-
-
-def GET(_):
- """Get all available Traces."""
-
- traces = Trace.get_all()
-
- return Response(200, 'Successfully retrieved Traces', traces.obj)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/traces/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/test_endpoint.py
deleted file mode 100644
index 36846bd9..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/test_endpoint.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from opendc.util.database import DB
-
-
-def test_get_traces(client, mocker):
- mocker.patch.object(DB, 'fetch_all', return_value=[])
- assert '200' in client.get('/v2/traces').status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/endpoint.py
deleted file mode 100644
index 670f88d1..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/endpoint.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from opendc.models.trace import Trace
-from opendc.util.rest import Response
-
-
-def GET(request):
- """Get this Trace."""
-
- request.check_required_parameters(path={'traceId': 'string'})
-
- trace = Trace.from_id(request.params_path['traceId'])
-
- trace.check_exists()
-
- return Response(200, 'Successfully retrieved trace.', trace.obj)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/test_endpoint.py
deleted file mode 100644
index 0c51538b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/test_endpoint.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_get_trace_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/v2/traces/{test_id}').status
-
-
-def test_get_trace(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'name': 'test trace'})
- res = client.get(f'/v2/traces/{test_id}')
- assert 'name' in res.json['content']
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/util/auth.py b/opendc-web/opendc-web-api/opendc/auth.py
index 810b582a..1870f01c 100644
--- a/opendc-web/opendc-web-api/opendc/util/auth.py
+++ b/opendc-web/opendc-web-api/opendc/auth.py
@@ -17,118 +17,107 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
+
import json
import time
-from functools import wraps
import urllib3
-from flask import request, _request_ctx_stack
+from flask import request
from jose import jwt, JWTError
-from werkzeug.local import LocalProxy
-current_user = LocalProxy(lambda: getattr(_request_ctx_stack.top, 'current_user', None))
+
+def get_token():
+ """
+ Obtain the Access Token from the Authorization Header
+ """
+ auth = request.headers.get("Authorization", None)
+ if not auth:
+ raise AuthError({
+ "code": "authorization_header_missing",
+ "description": "Authorization header is expected"
+ }, 401)
+
+ parts = auth.split()
+
+ if parts[0].lower() != "bearer":
+ raise AuthError({
+ "code": "invalid_header",
+ "description": "Authorization header must start with"
+ " Bearer"
+ }, 401)
+ if len(parts) == 1:
+ raise AuthError({"code": "invalid_header", "description": "Token not found"}, 401)
+ if len(parts) > 2:
+ raise AuthError({"code": "invalid_header", "description": "Authorization header must be" " Bearer token"}, 401)
+
+ token = parts[1]
+ return token
class AuthError(Exception):
"""
This error is thrown when the request failed to authorize.
"""
-
def __init__(self, error, status_code):
Exception.__init__(self, error)
self.error = error
self.status_code = status_code
-class AuthManager:
+class AuthContext:
"""
This class handles the authorization of requests.
"""
-
def __init__(self, alg, issuer, audience):
self._alg = alg
self._issuer = issuer
self._audience = audience
- def require(self, f):
- """Determines if the Access Token is valid
+ def validate(self, token):
"""
-
- @wraps(f)
- def decorated(*args, **kwargs):
- token = _get_token()
- try:
- header = jwt.get_unverified_header(token)
- except JWTError as e:
- raise AuthError({"code": "invalid_token",
- "description": str(e)}, 401)
-
- alg = header.get('alg', None)
- if alg != self._alg.algorithm:
- raise AuthError({"code": "invalid_header",
- "description": f"Signature algorithm of {alg} is not supported. Expected the ID token "
- f"to be signed with {self._alg.algorithm}"}, 401)
-
- kid = header.get('kid', None)
- try:
- secret_or_certificate = self._alg.get_key(key_id=kid)
- except TokenValidationError as e:
- raise AuthError({"code": "invalid_header",
- "description": str(e)}, 401)
- try:
- payload = jwt.decode(token,
- key=secret_or_certificate,
- algorithms=[self._alg.algorithm],
- audience=self._audience,
- issuer=self._issuer)
- _request_ctx_stack.top.current_user = payload
- return f(*args, **kwargs)
- except jwt.ExpiredSignatureError:
- raise AuthError({"code": "token_expired",
- "description": "token is expired"}, 401)
- except jwt.JWTClaimsError:
- raise AuthError({"code": "invalid_claims",
- "description":
- "incorrect claims,"
- "please check the audience and issuer"}, 401)
- except Exception as e:
- print(e)
- raise AuthError({"code": "invalid_header",
- "description":
- "Unable to parse authentication"
- " token."}, 401)
-
- return decorated
-
-
-def _get_token():
- """
- Obtain the Access Token from the Authorization Header
- """
- auth = request.headers.get("Authorization", None)
- if not auth:
- raise AuthError({"code": "authorization_header_missing",
- "description":
- "Authorization header is expected"}, 401)
-
- parts = auth.split()
-
- if parts[0].lower() != "bearer":
- raise AuthError({"code": "invalid_header",
- "description":
- "Authorization header must start with"
- " Bearer"}, 401)
- if len(parts) == 1:
- raise AuthError({"code": "invalid_header",
- "description": "Token not found"}, 401)
- if len(parts) > 2:
- raise AuthError({"code": "invalid_header",
- "description":
- "Authorization header must be"
- " Bearer token"}, 401)
-
- token = parts[1]
- return token
+ Validate the specified JWT token.
+ :param token: The authorization token specified by the user.
+ :return: The token payload on success, otherwise `AuthError`.
+ """
+ try:
+ header = jwt.get_unverified_header(token)
+ except JWTError as e:
+ raise AuthError({"code": "invalid_token", "message": str(e)}, 401)
+
+ alg = header.get('alg', None)
+ if alg != self._alg.algorithm:
+ raise AuthError(
+ {
+ "code":
+ "invalid_header",
+ "message":
+ f"Signature algorithm of {alg} is not supported. Expected the ID token "
+ f"to be signed with {self._alg.algorithm}"
+ }, 401)
+
+ kid = header.get('kid', None)
+ try:
+ secret_or_certificate = self._alg.get_key(key_id=kid)
+ except TokenValidationError as e:
+ raise AuthError({"code": "invalid_header", "message": str(e)}, 401)
+ try:
+ payload = jwt.decode(token,
+ key=secret_or_certificate,
+ algorithms=[self._alg.algorithm],
+ audience=self._audience,
+ issuer=self._issuer)
+ return payload
+ except jwt.ExpiredSignatureError:
+ raise AuthError({"code": "token_expired", "message": "Token is expired"}, 401)
+ except jwt.JWTClaimsError:
+ raise AuthError(
+ {
+ "code": "invalid_claims",
+ "message": "Incorrect claims, please check the audience and issuer"
+ }, 401)
+ except Exception as e:
+ print(e)
+ raise AuthError({"code": "invalid_header", "message": "Unable to parse authentication token."}, 401)
class SymmetricJwtAlgorithm:
@@ -137,7 +126,6 @@ class SymmetricJwtAlgorithm:
shared_secret (str): The shared secret used to decode the token.
algorithm (str, optional): The expected signing algorithm. Defaults to "HS256".
"""
-
def __init__(self, shared_secret, algorithm="HS256"):
self.algorithm = algorithm
self._shared_secret = shared_secret
@@ -158,7 +146,6 @@ class AsymmetricJwtAlgorithm:
jwks_url (str): The url where the JWK set is located.
algorithm (str, optional): The expected signing algorithm. Defaults to "RS256".
"""
-
def __init__(self, jwks_url, algorithm="RS256"):
self.algorithm = algorithm
self._fetcher = JwksFetcher(jwks_url)
diff --git a/opendc-web/opendc-web-api/opendc/util/database.py b/opendc-web/opendc-web-api/opendc/database.py
index dd26533d..f9a33b66 100644
--- a/opendc-web/opendc-web-api/opendc/util/database.py
+++ b/opendc-web/opendc-web-api/opendc/database.py
@@ -1,3 +1,23 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
import urllib.parse
from datetime import datetime
@@ -9,19 +29,27 @@ CONNECTION_POOL = None
class Database:
"""Object holding functionality for database access."""
- def __init__(self):
- self.opendc_db = None
-
- def initialize_database(self, user, password, database, host):
+ def __init__(self, db=None):
"""Initializes the database connection."""
+ self.opendc_db = db
+ @classmethod
+ def from_credentials(cls, user, password, database, host):
+ """
+ Construct a database instance from the specified credentials.
+ :param user: The username to connect with.
+ :param password: The password to connect with.
+ :param database: The database name to connect to.
+ :param host: The host to connect to.
+ :return: The database instance.
+ """
user = urllib.parse.quote_plus(user)
password = urllib.parse.quote_plus(password)
database = urllib.parse.quote_plus(database)
host = urllib.parse.quote_plus(host)
client = MongoClient('mongodb://%s:%s@%s/default_db?authSource=%s' % (user, password, host, database))
- self.opendc_db = client.opendc
+ return cls(client.opendc)
def fetch_one(self, query, collection):
"""Uses existing mongo connection to return a single (the first) document in a collection matching the given
@@ -72,6 +100,3 @@ class Database:
def string_to_datetime(string_to_convert):
"""Return a datetime corresponding to the given string representation."""
return datetime.strptime(string_to_convert, DATETIME_STRING_FORMAT)
-
-
-DB = Database()
diff --git a/opendc-web/opendc-web-api/opendc/exts.py b/opendc-web/opendc-web-api/opendc/exts.py
new file mode 100644
index 00000000..f088a29c
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/exts.py
@@ -0,0 +1,60 @@
+import os
+from functools import wraps
+
+from flask import g, _request_ctx_stack
+from werkzeug.local import LocalProxy
+
+from opendc.database import Database
+from opendc.auth import AuthContext, AsymmetricJwtAlgorithm, get_token
+
+
+def get_db():
+ """
+ Return the configured database instance for the application.
+ """
+ _db = getattr(g, 'db', None)
+ if _db is None:
+ _db = Database.from_credentials(user=os.environ['OPENDC_DB_USERNAME'],
+ password=os.environ['OPENDC_DB_PASSWORD'],
+ database=os.environ['OPENDC_DB'],
+ host=os.environ.get('OPENDC_DB_HOST', 'localhost'))
+ g.db = _db
+ return _db
+
+
+db = LocalProxy(get_db)
+
+
+def get_auth_context():
+ """
+ Return the configured auth context for the application.
+ """
+ _auth_context = getattr(g, 'auth_context', None)
+ if _auth_context is None:
+ _auth_context = AuthContext(
+ alg=AsymmetricJwtAlgorithm(jwks_url=f"https://{os.environ['AUTH0_DOMAIN']}/.well-known/jwks.json"),
+ issuer=f"https://{os.environ['AUTH0_DOMAIN']}/",
+ audience=os.environ['AUTH0_AUDIENCE']
+ )
+ g.auth_context = _auth_context
+ return _auth_context
+
+
+auth_context = LocalProxy(get_auth_context)
+
+
+def requires_auth(f):
+ """Decorator to determine if the Access Token is valid.
+ """
+
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ token = get_token()
+ payload = auth_context.validate(token)
+ _request_ctx_stack.top.current_user = payload
+ return f(*args, **kwargs)
+
+ return decorated
+
+
+current_user = LocalProxy(lambda: getattr(_request_ctx_stack.top, 'current_user', None))
diff --git a/opendc-web/opendc-web-api/opendc/models/model.py b/opendc-web/opendc-web-api/opendc/models/model.py
index f9dfc9ad..28299453 100644
--- a/opendc-web/opendc-web-api/opendc/models/model.py
+++ b/opendc-web/opendc-web-api/opendc/models/model.py
@@ -1,8 +1,7 @@
from bson.objectid import ObjectId
+from werkzeug.exceptions import NotFound
-from opendc.util.database import DB
-from opendc.util.exceptions import ClientError
-from opendc.util.rest import Response
+from opendc.exts import db
class Model:
@@ -15,15 +14,13 @@ class Model:
"""Fetches the document with given ID from the collection."""
if isinstance(_id, str) and len(_id) == 24:
_id = ObjectId(_id)
- elif not isinstance(_id, ObjectId):
- return cls(None)
- return cls(DB.fetch_one({'_id': _id}, cls.collection_name))
+ return cls(db.fetch_one({'_id': _id}, cls.collection_name))
@classmethod
def get_all(cls):
"""Fetches all documents from the collection."""
- return cls(DB.fetch_all({}, cls.collection_name))
+ return cls(db.fetch_all({}, cls.collection_name))
def __init__(self, obj):
self.obj = obj
@@ -35,7 +32,7 @@ class Model:
def check_exists(self):
"""Raises an error if the enclosed object does not exist."""
if self.obj is None:
- raise ClientError(Response(404, 'Not found.'))
+ raise NotFound('Entity not found.')
def set_property(self, key, value):
"""Sets the given property on the enclosed object, with support for simple nested access."""
@@ -48,11 +45,11 @@ class Model:
def insert(self):
"""Inserts the enclosed object and generates a UUID for it."""
self.obj['_id'] = ObjectId()
- DB.insert(self.obj, self.collection_name)
+ db.insert(self.obj, self.collection_name)
def update(self):
"""Updates the enclosed object and updates the internal reference to the newly inserted object."""
- DB.update(self.get_id(), self.obj, self.collection_name)
+ db.update(self.get_id(), self.obj, self.collection_name)
def delete(self):
"""Deletes the enclosed object in the database, if it existed."""
@@ -60,5 +57,5 @@ class Model:
return None
old_object = self.obj.copy()
- DB.delete_one({'_id': self.get_id()}, self.collection_name)
+ db.delete_one({'_id': self.get_id()}, self.collection_name)
return old_object
diff --git a/opendc-web/opendc-web-api/opendc/models/portfolio.py b/opendc-web/opendc-web-api/opendc/models/portfolio.py
index 8e3f2a52..aff1d3f0 100644
--- a/opendc-web/opendc-web-api/opendc/models/portfolio.py
+++ b/opendc-web/opendc-web-api/opendc/models/portfolio.py
@@ -1,7 +1,28 @@
+from marshmallow import Schema, fields
+
from opendc.models.project import Project
from opendc.models.model import Model
+class TargetSchema(Schema):
+ """
+ Schema representing a target.
+ """
+ enabledMetrics = fields.List(fields.String())
+ repeatsPerScenario = fields.Integer(required=True)
+
+
+class PortfolioSchema(Schema):
+ """
+ Schema representing a portfolio.
+ """
+ _id = fields.String()
+ projectId = fields.String()
+ name = fields.String(required=True)
+ scenarioIds = fields.List(fields.String())
+ targets = fields.Nested(TargetSchema)
+
+
class Portfolio(Model):
"""Model representing a Portfolio."""
diff --git a/opendc-web/opendc-web-api/opendc/models/prefab.py b/opendc-web/opendc-web-api/opendc/models/prefab.py
index 05356358..d83ef4cb 100644
--- a/opendc-web/opendc-web-api/opendc/models/prefab.py
+++ b/opendc-web/opendc-web-api/opendc/models/prefab.py
@@ -1,17 +1,30 @@
+from marshmallow import Schema, fields
+from werkzeug.exceptions import Forbidden
+
+from opendc.models.topology import ObjectSchema
from opendc.models.model import Model
-from opendc.util.exceptions import ClientError
-from opendc.util.rest import Response
+
+
+class PrefabSchema(Schema):
+ """
+ Schema for a Prefab.
+ """
+ _id = fields.String()
+ name = fields.String(required=True)
+ datetimeCreated = fields.DateTime()
+ datetimeLastEdited = fields.DateTime()
+ rack = fields.Nested(ObjectSchema)
class Prefab(Model):
- """Model representing a Project."""
+ """Model representing a Prefab."""
collection_name = 'prefabs'
def check_user_access(self, user_id):
"""Raises an error if the user with given [user_id] has insufficient access to view this prefab.
- :param user_id: The Google ID of the user.
+ :param user_id: The user ID of the user.
"""
if self.obj['authorId'] != user_id and self.obj['visibility'] == "private":
- raise ClientError(Response(403, "Forbidden from retrieving prefab."))
+ raise Forbidden("Forbidden from retrieving prefab.")
diff --git a/opendc-web/opendc-web-api/opendc/models/project.py b/opendc-web/opendc-web-api/opendc/models/project.py
index 2b3fd5f4..ee84c73e 100644
--- a/opendc-web/opendc-web-api/opendc/models/project.py
+++ b/opendc-web/opendc-web-api/opendc/models/project.py
@@ -1,7 +1,20 @@
+from marshmallow import Schema, fields
+from werkzeug.exceptions import Forbidden
+
from opendc.models.model import Model
-from opendc.util.database import DB
-from opendc.util.exceptions import ClientError
-from opendc.util.rest import Response
+from opendc.exts import db
+
+
+class ProjectSchema(Schema):
+ """
+ Schema representing a Project.
+ """
+ _id = fields.String()
+ name = fields.String(required=True)
+ datetimeCreated = fields.DateTime()
+ datetimeLastEdited = fields.DateTime()
+ topologyIds = fields.List(fields.String())
+ portfolioIds = fields.List(fields.String())
class Project(Model):
@@ -16,13 +29,11 @@ class Project(Model):
:param edit_access: True when edit access should be checked, otherwise view access.
"""
for authorization in self.obj['authorizations']:
- if user_id == authorization['userId'] and authorization['authorizationLevel'] != 'VIEW' or not edit_access:
+ if user_id == authorization['userId'] and authorization['level'] != 'VIEW' or not edit_access:
return
- raise ClientError(Response(403, "Forbidden from retrieving project."))
+ raise Forbidden("Forbidden from retrieving project.")
@classmethod
def get_for_user(cls, user_id):
"""Get all projects for the specified user id."""
- return DB.fetch_all({'authorizations': {
- 'userId': user_id
- }}, Project.collection_name)
+ return db.fetch_all({'authorizations.userId': user_id}, Project.collection_name)
diff --git a/opendc-web/opendc-web-api/opendc/models/scenario.py b/opendc-web/opendc-web-api/opendc/models/scenario.py
index 3dfde012..2911b1ae 100644
--- a/opendc-web/opendc-web-api/opendc/models/scenario.py
+++ b/opendc-web/opendc-web-api/opendc/models/scenario.py
@@ -1,7 +1,52 @@
+from marshmallow import Schema, fields
from opendc.models.model import Model
from opendc.models.portfolio import Portfolio
+class SimulationSchema(Schema):
+ """
+ Simulation details.
+ """
+ state = fields.String()
+
+
+class TraceSchema(Schema):
+ """
+ Schema for specifying the trace of a scenario.
+ """
+ traceId = fields.String()
+ loadSamplingFraction = fields.Float()
+
+
+class TopologySchema(Schema):
+ """
+ Schema for topology specification for a scenario.
+ """
+ topologyId = fields.String()
+
+
+class OperationalSchema(Schema):
+ """
+ Schema for the operational phenomena for a scenario.
+ """
+ failuresEnabled = fields.Boolean()
+ performanceInterferenceEnabled = fields.Boolean()
+ schedulerName = fields.String()
+
+
+class ScenarioSchema(Schema):
+ """
+ Schema representing a scenario.
+ """
+ _id = fields.String()
+ portfolioId = fields.String()
+ name = fields.String(required=True)
+ simulation = fields.Nested(SimulationSchema)
+ trace = fields.Nested(TraceSchema)
+ topology = fields.Nested(TopologySchema)
+ operational = fields.Nested(OperationalSchema)
+
+
class Scenario(Model):
"""Model representing a Scenario."""
@@ -16,5 +61,4 @@ class Scenario(Model):
:param edit_access: True when edit access should be checked, otherwise view access.
"""
portfolio = Portfolio.from_id(self.obj['portfolioId'])
- print(portfolio.obj)
portfolio.check_user_access(user_id, edit_access)
diff --git a/opendc-web/opendc-web-api/opendc/models/topology.py b/opendc-web/opendc-web-api/opendc/models/topology.py
index 3ebec16d..c6354ae6 100644
--- a/opendc-web/opendc-web-api/opendc/models/topology.py
+++ b/opendc-web/opendc-web-api/opendc/models/topology.py
@@ -1,7 +1,83 @@
+from marshmallow import Schema, fields
+
from opendc.models.project import Project
from opendc.models.model import Model
+class MemorySchema(Schema):
+ """
+ Schema representing a memory unit.
+ """
+ _id = fields.String()
+ name = fields.String()
+ speedMbPerS = fields.Integer()
+ sizeMb = fields.Integer()
+ energyConsumptionW = fields.Integer()
+
+
+class PuSchema(Schema):
+ """
+ Schema representing a processing unit.
+ """
+ _id = fields.String()
+ name = fields.String()
+ clockRateMhz = fields.Integer()
+ numberOfCores = fields.Integer()
+ energyConsumptionW = fields.Integer()
+
+
+class MachineSchema(Schema):
+ """
+ Schema representing a machine.
+ """
+ _id = fields.String()
+ position = fields.Integer()
+ cpus = fields.List(fields.Nested(PuSchema))
+ gpus = fields.List(fields.Nested(PuSchema))
+ memories = fields.List(fields.Nested(MemorySchema))
+ storages = fields.List(fields.Nested(MemorySchema))
+
+
+class ObjectSchema(Schema):
+ """
+ Schema representing a room object.
+ """
+ _id = fields.String()
+ name = fields.String()
+ capacity = fields.Integer()
+ powerCapacityW = fields.Integer()
+ machines = fields.List(fields.Nested(MachineSchema))
+
+
+class TileSchema(Schema):
+ """
+ Schema representing a room tile.
+ """
+ _id = fields.String()
+ positionX = fields.Integer()
+ positionY = fields.Integer()
+ rack = fields.Nested(ObjectSchema)
+
+
+class RoomSchema(Schema):
+ """
+ Schema representing a room.
+ """
+ _id = fields.String()
+ name = fields.String(required=True)
+ tiles = fields.List(fields.Nested(TileSchema), required=True)
+
+
+class TopologySchema(Schema):
+ """
+ Schema representing a datacenter topology.
+ """
+ _id = fields.String()
+ projectId = fields.String()
+ name = fields.String(required=True)
+ rooms = fields.List(fields.Nested(RoomSchema), required=True)
+
+
class Topology(Model):
"""Model representing a Project."""
diff --git a/opendc-web/opendc-web-api/opendc/util.py b/opendc-web/opendc-web-api/opendc/util.py
new file mode 100644
index 00000000..e7dc07a4
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/util.py
@@ -0,0 +1,32 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import flask
+from bson.objectid import ObjectId
+
+
+class JSONEncoder(flask.json.JSONEncoder):
+ """
+ A customized JSON encoder to handle unsupported types.
+ """
+ def default(self, o):
+ if isinstance(o, ObjectId):
+ return str(o)
+ return flask.json.JSONEncoder.default(self, o)
diff --git a/opendc-web/opendc-web-api/opendc/util/__init__.py b/opendc-web/opendc-web-api/opendc/util/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/util/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/util/exceptions.py b/opendc-web/opendc-web-api/opendc/util/exceptions.py
deleted file mode 100644
index 7724a407..00000000
--- a/opendc-web/opendc-web-api/opendc/util/exceptions.py
+++ /dev/null
@@ -1,64 +0,0 @@
-class RequestInitializationError(Exception):
- """Raised when a Request cannot successfully be initialized"""
-
-
-class UnimplementedEndpointError(RequestInitializationError):
- """Raised when a Request path does not point to a module."""
-
-
-class MissingRequestParameterError(RequestInitializationError):
- """Raised when a Request does not contain one or more required parameters."""
-
-
-class UnsupportedMethodError(RequestInitializationError):
- """Raised when a Request does not use a supported REST method.
-
- The method must be in all-caps, supported by REST, and implemented by the module.
- """
-
-
-class AuthorizationTokenError(RequestInitializationError):
- """Raised when an authorization token is not correctly verified."""
-
-
-class ForeignKeyError(Exception):
- """Raised when a foreign key constraint is not met."""
-
-
-class RowNotFoundError(Exception):
- """Raised when a database row is not found."""
- def __init__(self, table_name):
- super(RowNotFoundError, self).__init__('Row in `{}` table not found.'.format(table_name))
-
- self.table_name = table_name
-
-
-class ParameterError(Exception):
- """Raised when a parameter is either missing or incorrectly typed."""
-
-
-class IncorrectParameterError(ParameterError):
- """Raised when a parameter is of the wrong type."""
- def __init__(self, parameter_name, parameter_location):
- super(IncorrectParameterError,
- self).__init__('Incorrectly typed `{}` {} parameter.'.format(parameter_name, parameter_location))
-
- self.parameter_name = parameter_name
- self.parameter_location = parameter_location
-
-
-class MissingParameterError(ParameterError):
- """Raised when a parameter is missing."""
- def __init__(self, parameter_name, parameter_location):
- super(MissingParameterError,
- self).__init__('Missing required `{}` {} parameter.'.format(parameter_name, parameter_location))
-
- self.parameter_name = parameter_name
- self.parameter_location = parameter_location
-
-
-class ClientError(Exception):
- """Raised when a 4xx response is to be returned."""
- def __init__(self, response):
- super(ClientError, self).__init__(str(response))
- self.response = response
diff --git a/opendc-web/opendc-web-api/opendc/util/json.py b/opendc-web/opendc-web-api/opendc/util/json.py
deleted file mode 100644
index 2ef4f965..00000000
--- a/opendc-web/opendc-web-api/opendc/util/json.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import flask
-from bson.objectid import ObjectId
-
-
-class JSONEncoder(flask.json.JSONEncoder):
- """
- A customized JSON encoder to handle unsupported types.
- """
- def default(self, o):
- if isinstance(o, ObjectId):
- return str(o)
- return flask.json.JSONEncoder.default(self, o)
diff --git a/opendc-web/opendc-web-api/opendc/util/parameter_checker.py b/opendc-web/opendc-web-api/opendc/util/parameter_checker.py
deleted file mode 100644
index 14dd1dc0..00000000
--- a/opendc-web/opendc-web-api/opendc/util/parameter_checker.py
+++ /dev/null
@@ -1,85 +0,0 @@
-from opendc.util import exceptions
-from opendc.util.database import Database
-
-
-def _missing_parameter(params_required, params_actual, parent=''):
- """Recursively search for the first missing parameter."""
-
- for param_name in params_required:
-
- if param_name not in params_actual:
- return '{}.{}'.format(parent, param_name)
-
- param_required = params_required.get(param_name)
- param_actual = params_actual.get(param_name)
-
- if isinstance(param_required, dict):
-
- param_missing = _missing_parameter(param_required, param_actual, param_name)
-
- if param_missing is not None:
- return '{}.{}'.format(parent, param_missing)
-
- return None
-
-
-def _incorrect_parameter(params_required, params_actual, parent=''):
- """Recursively make sure each parameter is of the correct type."""
-
- for param_name in params_required:
-
- param_required = params_required.get(param_name)
- param_actual = params_actual.get(param_name)
-
- if isinstance(param_required, dict):
-
- param_incorrect = _incorrect_parameter(param_required, param_actual, param_name)
-
- if param_incorrect is not None:
- return '{}.{}'.format(parent, param_incorrect)
-
- else:
-
- if param_required == 'datetime':
- try:
- Database.string_to_datetime(param_actual)
- except:
- return '{}.{}'.format(parent, param_name)
-
- type_pairs = [
- ('int', (int,)),
- ('float', (float, int)),
- ('bool', (bool,)),
- ('string', (str, int)),
- ('list', (list,)),
- ]
-
- for str_type, actual_types in type_pairs:
- if param_required == str_type and all(not isinstance(param_actual, t)
- for t in actual_types):
- return '{}.{}'.format(parent, param_name)
-
- return None
-
-
-def _format_parameter(parameter):
- """Format the output of a parameter check."""
-
- parts = parameter.split('.')
- inner = ['["{}"]'.format(x) for x in parts[2:]]
- return parts[1] + ''.join(inner)
-
-
-def check(request, **kwargs):
- """Check if all required parameters are there."""
-
- for location, params_required in kwargs.items():
- params_actual = getattr(request, 'params_{}'.format(location))
-
- missing_parameter = _missing_parameter(params_required, params_actual)
- if missing_parameter is not None:
- raise exceptions.MissingParameterError(_format_parameter(missing_parameter), location)
-
- incorrect_parameter = _incorrect_parameter(params_required, params_actual)
- if incorrect_parameter is not None:
- raise exceptions.IncorrectParameterError(_format_parameter(incorrect_parameter), location)
diff --git a/opendc-web/opendc-web-api/opendc/util/path_parser.py b/opendc-web/opendc-web-api/opendc/util/path_parser.py
deleted file mode 100644
index c8452f20..00000000
--- a/opendc-web/opendc-web-api/opendc/util/path_parser.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import json
-import os
-
-
-def parse(version, endpoint_path):
- """Map an HTTP endpoint path to an API path"""
-
- # Get possible paths
- with open(os.path.join(os.path.dirname(__file__), '..', 'api', '{}', 'paths.json').format(version)) as paths_file:
- paths = json.load(paths_file)
-
- # Find API path that matches endpoint_path
- endpoint_path_parts = endpoint_path.strip('/').split('/')
- paths_parts = [x.strip('/').split('/') for x in paths if len(x.strip('/').split('/')) == len(endpoint_path_parts)]
- path = None
-
- for path_parts in paths_parts:
- found = True
- for (endpoint_part, part) in zip(endpoint_path_parts, path_parts):
- if not part.startswith('{') and endpoint_part != part:
- found = False
- break
- if found:
- path = path_parts
-
- if path is None:
- return None
-
- # Extract path parameters
- parameters = {}
-
- for (name, value) in zip(path, endpoint_path_parts):
- if name.startswith('{'):
- parameters[name.strip('{}')] = value
-
- return '{}/{}'.format(version, '/'.join(path)), parameters
diff --git a/opendc-web/opendc-web-api/opendc/util/rest.py b/opendc-web/opendc-web-api/opendc/util/rest.py
deleted file mode 100644
index 63d063b3..00000000
--- a/opendc-web/opendc-web-api/opendc/util/rest.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import importlib
-import json
-
-from opendc.util import exceptions, parameter_checker
-from opendc.util.exceptions import ClientError
-from opendc.util.auth import current_user
-
-
-class Request:
- """WebSocket message to REST request mapping."""
- def __init__(self, message=None):
- """"Initialize a Request from a socket message."""
-
- # Get the Request parameters from the message
-
- if message is None:
- return
-
- try:
- self.message = message
-
- self.id = message['id']
-
- self.path = message['path']
- self.method = message['method']
-
- self.params_body = message['parameters']['body']
- self.params_path = message['parameters']['path']
- self.params_query = message['parameters']['query']
-
- self.token = message['token']
-
- except KeyError as exception:
- raise exceptions.MissingRequestParameterError(exception)
-
- # Parse the path and import the appropriate module
-
- try:
- self.path = message['path'].strip('/')
-
- module_base = 'opendc.api.{}.endpoint'
- module_path = self.path.replace('{', '').replace('}', '').replace('/', '.')
-
- self.module = importlib.import_module(module_base.format(module_path))
- except ImportError as e:
- print(e)
- raise exceptions.UnimplementedEndpointError('Unimplemented endpoint: {}.'.format(self.path))
-
- # Check the method
-
- if self.method not in ['POST', 'GET', 'PUT', 'PATCH', 'DELETE']:
- raise exceptions.UnsupportedMethodError('Non-rest method: {}'.format(self.method))
-
- if not hasattr(self.module, self.method):
- raise exceptions.UnsupportedMethodError('Unimplemented method at endpoint {}: {}'.format(
- self.path, self.method))
-
- self.current_user = current_user
-
- def check_required_parameters(self, **kwargs):
- """Raise an error if a parameter is missing or of the wrong type."""
-
- try:
- parameter_checker.check(self, **kwargs)
- except exceptions.ParameterError as e:
- raise ClientError(Response(400, str(e)))
-
- def process(self):
- """Process the Request and return a Response."""
-
- method = getattr(self.module, self.method)
-
- try:
- response = method(self)
- except ClientError as e:
- e.response.id = self.id
- return e.response
-
- response.id = self.id
-
- return response
-
- def to_JSON(self):
- """Return a JSON representation of this Request"""
-
- self.message['id'] = 0
- self.message['token'] = None
-
- return json.dumps(self.message)
-
-
-class Response:
- """Response to websocket mapping"""
- def __init__(self, status_code, status_description, content=None):
- """Initialize a new Response."""
-
- self.id = 0
- self.status = {'code': status_code, 'description': status_description}
- self.content = content
-
- def to_JSON(self):
- """"Return a JSON representation of this Response"""
-
- data = {'id': self.id, 'status': self.status}
-
- if self.content is not None:
- data['content'] = self.content
-
- return json.dumps(data, default=str)
diff --git a/opendc-web/opendc-web-api/requirements.txt b/opendc-web/opendc-web-api/requirements.txt
index a518da47..375ed40c 100644
--- a/opendc-web/opendc-web-api/requirements.txt
+++ b/opendc-web/opendc-web-api/requirements.txt
@@ -9,6 +9,7 @@ Flask==1.1.2
Flask-Compress==1.5.0
Flask-Cors==3.0.9
Flask-SocketIO==4.3.1
+Flask-Restful==0.3.8
greenlet==0.4.17
httplib2==0.19.0
isort==4.3.21
@@ -16,6 +17,7 @@ itsdangerous==1.1.0
Jinja2==2.11.3
lazy-object-proxy==1.4.3
MarkupSafe==1.1.1
+marshmallow==3.12.1
mccabe==0.6.1
monotonic==1.5
more-itertools==8.6.0
diff --git a/opendc-web/opendc-web-api/tests/api/test_portfolios.py b/opendc-web/opendc-web-api/tests/api/test_portfolios.py
new file mode 100644
index 00000000..da7991f6
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_portfolios.py
@@ -0,0 +1,324 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from opendc.exts import db
+
+test_id = 24 * '1'
+test_id_2 = 24 * '2'
+
+
+def test_get_portfolio_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/portfolios/{test_id}').status
+
+
+def test_get_portfolio_no_authorizations(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []})
+ res = client.get(f'/portfolios/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_portfolio_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ 'projectId': test_id,
+ '_id': test_id,
+ 'authorizations': []
+ })
+ res = client.get(f'/portfolios/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_portfolio(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ 'projectId': test_id,
+ '_id': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'EDIT'
+ }]
+ })
+ res = client.get(f'/portfolios/{test_id}')
+ assert '200' in res.status
+
+
+def test_update_portfolio_missing_parameter(client):
+ assert '400' in client.put(f'/portfolios/{test_id}').status
+
+
+def test_update_portfolio_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.put(f'/portfolios/{test_id}', json={
+ 'portfolio': {
+ 'name': 'test',
+ 'targets': {
+ 'enabledMetrics': ['test'],
+ 'repeatsPerScenario': 2
+ }
+ }
+ }).status
+
+
+def test_update_portfolio_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }]
+ })
+ mocker.patch.object(db, 'update', return_value={})
+ assert '403' in client.put(f'/portfolios/{test_id}', json={
+ 'portfolio': {
+ 'name': 'test',
+ 'targets': {
+ 'enabledMetrics': ['test'],
+ 'repeatsPerScenario': 2
+ }
+ }
+ }).status
+
+
+def test_update_portfolio(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }],
+ 'targets': {
+ 'enabledMetrics': [],
+ 'repeatsPerScenario': 1
+ }
+ })
+ mocker.patch.object(db, 'update', return_value={})
+
+ res = client.put(f'/portfolios/{test_id}', json={'portfolio': {
+ 'name': 'test',
+ 'targets': {
+ 'enabledMetrics': ['test'],
+ 'repeatsPerScenario': 2
+ }
+ }})
+ assert '200' in res.status
+
+
+def test_delete_project_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.delete(f'/portfolios/{test_id}').status
+
+
+def test_delete_project_different_user(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'googleId': 'other_test',
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }]
+ })
+ mocker.patch.object(db, 'delete_one', return_value=None)
+ assert '403' in client.delete(f'/portfolios/{test_id}').status
+
+
+def test_delete_project(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'googleId': 'test',
+ 'portfolioIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ mocker.patch.object(db, 'delete_one', return_value={})
+ mocker.patch.object(db, 'update', return_value=None)
+ res = client.delete(f'/portfolios/{test_id}')
+ assert '200' in res.status
+
+
+def test_add_topology_missing_parameter(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'googleId': 'test',
+ 'portfolioIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ assert '400' in client.post(f'/projects/{test_id}/topologies').status
+
+
+def test_add_topology(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }],
+ 'topologyIds': []
+ })
+ mocker.patch.object(db,
+ 'insert',
+ return_value={
+ '_id': test_id,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'topologyIds': []
+ })
+ mocker.patch.object(db, 'update', return_value={})
+ res = client.post(f'/projects/{test_id}/topologies', json={'topology': {'name': 'test project', 'rooms': []}})
+ assert 'rooms' in res.json['data']
+ assert '200' in res.status
+
+
+def test_add_topology_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }]
+ })
+ assert '403' in client.post(f'/projects/{test_id}/topologies',
+ json={
+ 'topology': {
+ 'name': 'test_topology',
+ 'rooms': []
+ }
+ }).status
+
+
+def test_add_portfolio_missing_parameter(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'googleId': 'test',
+ 'portfolioIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ assert '400' in client.post(f'/projects/{test_id}/portfolios').status
+
+
+def test_add_portfolio_non_existing_project(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.post(f'/projects/{test_id}/portfolios',
+ json={
+ 'portfolio': {
+ 'name': 'test',
+ 'targets': {
+ 'enabledMetrics': ['test'],
+ 'repeatsPerScenario': 2
+ }
+ }
+ }).status
+
+
+def test_add_portfolio_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }]
+ })
+ assert '403' in client.post(f'/projects/{test_id}/portfolios',
+ json={
+ 'portfolio': {
+ 'name': 'test',
+ 'targets': {
+ 'enabledMetrics': ['test'],
+ 'repeatsPerScenario': 2
+ }
+ }
+ }).status
+
+
+def test_add_portfolio(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'portfolioIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'EDIT'
+ }]
+ })
+ mocker.patch.object(db,
+ 'insert',
+ return_value={
+ '_id': test_id,
+ 'name': 'test',
+ 'targets': {
+ 'enabledMetrics': ['test'],
+ 'repeatsPerScenario': 2
+ },
+ 'projectId': test_id,
+ 'scenarioIds': [],
+ })
+ mocker.patch.object(db, 'update', return_value=None)
+ res = client.post(
+ f'/projects/{test_id}/portfolios',
+ json={
+ 'portfolio': {
+ 'name': 'test',
+ 'targets': {
+ 'enabledMetrics': ['test'],
+ 'repeatsPerScenario': 2
+ }
+ }
+ })
+ assert 'projectId' in res.json['data']
+ assert 'scenarioIds' in res.json['data']
+ assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_prefabs.py b/opendc-web/opendc-web-api/tests/api/test_prefabs.py
new file mode 100644
index 00000000..ea3d92d6
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_prefabs.py
@@ -0,0 +1,252 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from unittest.mock import Mock
+from opendc.exts import db
+
+test_id = 24 * '1'
+test_id_2 = 24 * '2'
+
+
+def test_add_prefab_missing_parameter(client):
+ assert '400' in client.post('/prefabs/').status
+
+
+def test_add_prefab(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id, 'authorizations': []})
+ mocker.patch.object(db,
+ 'insert',
+ return_value={
+ '_id': test_id,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': test_id
+ })
+ res = client.post('/prefabs/', json={'prefab': {'name': 'test prefab'}})
+ assert 'datetimeCreated' in res.json['data']
+ assert 'datetimeLastEdited' in res.json['data']
+ assert 'authorId' in res.json['data']
+ assert '200' in res.status
+
+
+def test_get_prefabs(client, mocker):
+ db.fetch_all = Mock()
+ mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id})
+ db.fetch_all.side_effect = [
+ [{
+ '_id': test_id,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': test_id,
+ 'visibility' : 'private'
+ },
+ {
+ '_id': '2' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': test_id,
+ 'visibility' : 'private'
+ },
+ {
+ '_id': '3' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': test_id,
+ 'visibility' : 'public'
+ },
+ {
+ '_id': '4' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': test_id,
+ 'visibility' : 'public'
+ }],
+ [{
+ '_id': '5' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': '2' * 24,
+ 'visibility' : 'public'
+ },
+ {
+ '_id': '6' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': '2' * 24,
+ 'visibility' : 'public'
+ },
+ {
+ '_id': '7' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': '2' * 24,
+ 'visibility' : 'public'
+ },
+ {
+ '_id': '8' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': '2' * 24,
+ 'visibility' : 'public'
+ }]
+ ]
+ mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id})
+ res = client.get('/prefabs/')
+ assert '200' in res.status
+
+
+def test_get_prefab_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/prefabs/{test_id}').status
+
+
+def test_get_private_prefab_not_authorized(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': test_id_2,
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ res = client.get(f'/prefabs/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_private_prefab(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': 'test',
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ res = client.get(f'/prefabs/{test_id}')
+ assert '200' in res.status
+
+
+def test_get_public_prefab(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': test_id_2,
+ 'visibility': 'public',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ res = client.get(f'/prefabs/{test_id}')
+ assert '200' in res.status
+
+
+def test_update_prefab_missing_parameter(client):
+ assert '400' in client.put(f'/prefabs/{test_id}').status
+
+
+def test_update_prefab_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'S'}}).status
+
+
+def test_update_prefab_not_authorized(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': test_id_2,
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ mocker.patch.object(db, 'update', return_value={})
+ assert '403' in client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}}).status
+
+
+def test_update_prefab(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': 'test',
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ mocker.patch.object(db, 'update', return_value={})
+ res = client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}})
+ assert '200' in res.status
+
+
+def test_delete_prefab_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.delete(f'/prefabs/{test_id}').status
+
+
+def test_delete_prefab_different_user(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': test_id_2,
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ mocker.patch.object(db, 'delete_one', return_value=None)
+ assert '403' in client.delete(f'/prefabs/{test_id}').status
+
+
+def test_delete_prefab(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': 'test',
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ mocker.patch.object(db, 'delete_one', return_value={'prefab': {'name': 'name'}})
+ res = client.delete(f'/prefabs/{test_id}')
+ assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_projects.py b/opendc-web/opendc-web-api/tests/api/test_projects.py
new file mode 100644
index 00000000..c4c82e0d
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_projects.py
@@ -0,0 +1,167 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from opendc.exts import db
+
+test_id = 24 * '1'
+
+
+def test_get_user_projects(client, mocker):
+ mocker.patch.object(db, 'fetch_all', return_value={'_id': test_id, 'authorizations': [{'userId': 'test',
+ 'level': 'OWN'}]})
+ res = client.get('/projects/')
+ assert '200' in res.status
+
+
+def test_add_project_missing_parameter(client):
+ assert '400' in client.post('/projects/').status
+
+
+def test_add_project(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id, 'authorizations': []})
+ mocker.patch.object(db,
+ 'insert',
+ return_value={
+ '_id': test_id,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'topologyIds': []
+ })
+ mocker.patch.object(db, 'update', return_value={})
+ res = client.post('/projects/', json={'project': {'name': 'test project'}})
+ assert 'datetimeCreated' in res.json['data']
+ assert 'datetimeLastEdited' in res.json['data']
+ assert 'topologyIds' in res.json['data']
+ assert '200' in res.status
+
+
+def test_get_project_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/projects/{test_id}').status
+
+
+def test_get_project_no_authorizations(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'authorizations': []})
+ res = client.get(f'/projects/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_project_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'authorizations': []
+ })
+ res = client.get(f'/projects/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_project(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'EDIT'
+ }]
+ })
+ res = client.get(f'/projects/{test_id}')
+ assert '200' in res.status
+
+
+def test_update_project_missing_parameter(client):
+ assert '400' in client.put(f'/projects/{test_id}').status
+
+
+def test_update_project_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.put(f'/projects/{test_id}', json={'project': {'name': 'S'}}).status
+
+
+def test_update_project_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }]
+ })
+ mocker.patch.object(db, 'update', return_value={})
+ assert '403' in client.put(f'/projects/{test_id}', json={'project': {'name': 'S'}}).status
+
+
+def test_update_project(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ mocker.patch.object(db, 'update', return_value={})
+
+ res = client.put(f'/projects/{test_id}', json={'project': {'name': 'S'}})
+ assert '200' in res.status
+
+
+def test_delete_project_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.delete(f'/projects/{test_id}').status
+
+
+def test_delete_project_different_user(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'googleId': 'other_test',
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }],
+ 'topologyIds': []
+ })
+ mocker.patch.object(db, 'delete_one', return_value=None)
+ assert '403' in client.delete(f'/projects/{test_id}').status
+
+
+def test_delete_project(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'googleId': 'test',
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }],
+ 'topologyIds': [],
+ 'portfolioIds': [],
+ })
+ mocker.patch.object(db, 'update', return_value=None)
+ mocker.patch.object(db, 'delete_one', return_value={'googleId': 'test'})
+ res = client.delete(f'/projects/{test_id}')
+ assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_scenarios.py b/opendc-web/opendc-web-api/tests/api/test_scenarios.py
new file mode 100644
index 00000000..bdd5c4a3
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_scenarios.py
@@ -0,0 +1,135 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from opendc.exts import db
+
+test_id = 24 * '1'
+test_id_2 = 24 * '2'
+
+
+def test_get_scenario_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/scenarios/{test_id}').status
+
+
+def test_get_scenario_no_authorizations(client, mocker):
+ m = mocker.MagicMock()
+ m.side_effect = ({'portfolioId': test_id}, {'projectId': test_id}, {'authorizations': []})
+ mocker.patch.object(db, 'fetch_one', m)
+ res = client.get(f'/scenarios/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_scenario(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ side_effect=[
+ {'portfolioId': test_id},
+ {'projectId': test_id},
+ {'authorizations':
+ [{'userId': 'test', 'level': 'OWN'}]
+ }])
+ res = client.get(f'/scenarios/{test_id}')
+ assert '200' in res.status
+
+
+def test_update_scenario_missing_parameter(client):
+ assert '400' in client.put(f'/scenarios/{test_id}').status
+
+
+def test_update_scenario_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.put(f'/scenarios/{test_id}', json={
+ 'scenario': {
+ 'name': 'test',
+ }
+ }).status
+
+
+def test_update_scenario_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ side_effect=[
+ {'portfolioId': test_id},
+ {'projectId': test_id},
+ {'authorizations':
+ [{'userId': 'test', 'level': 'VIEW'}]
+ }])
+ mocker.patch.object(db, 'update', return_value={})
+ assert '403' in client.put(f'/scenarios/{test_id}', json={
+ 'scenario': {
+ 'name': 'test',
+ }
+ }).status
+
+
+def test_update_scenario(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ side_effect=[
+ {'_id': test_id, 'portfolioId': test_id},
+ {'projectId': test_id},
+ {'authorizations':
+ [{'userId': 'test', 'level': 'OWN'}]
+ }])
+ mocker.patch.object(db, 'update', return_value={})
+
+ res = client.put(f'/scenarios/{test_id}', json={'scenario': {
+ 'name': 'test',
+ }})
+ assert '200' in res.status
+
+
+def test_delete_project_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.delete(f'/scenarios/{test_id}').status
+
+
+def test_delete_project_different_user(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ side_effect=[
+ {'_id': test_id, 'portfolioId': test_id},
+ {'projectId': test_id},
+ {'authorizations':
+ [{'userId': 'test', 'level': 'VIEW'}]
+ }])
+ mocker.patch.object(db, 'delete_one', return_value=None)
+ assert '403' in client.delete(f'/scenarios/{test_id}').status
+
+
+def test_delete_project(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'portfolioId': test_id,
+ 'googleId': 'test',
+ 'scenarioIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ mocker.patch.object(db, 'delete_one', return_value={})
+ mocker.patch.object(db, 'update', return_value=None)
+ res = client.delete(f'/scenarios/{test_id}')
+ assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_schedulers.py b/opendc-web/opendc-web-api/tests/api/test_schedulers.py
new file mode 100644
index 00000000..5d9e6995
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_schedulers.py
@@ -0,0 +1,22 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+def test_get_schedulers(client):
+ assert '200' in client.get('/schedulers/').status
diff --git a/opendc-web/opendc-web-api/tests/api/test_topologies.py b/opendc-web/opendc-web-api/tests/api/test_topologies.py
new file mode 100644
index 00000000..6e7c54ef
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_topologies.py
@@ -0,0 +1,140 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from opendc.exts import db
+
+test_id = 24 * '1'
+test_id_2 = 24 * '2'
+
+
+def test_get_topology(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'EDIT'
+ }]
+ })
+ res = client.get(f'/topologies/{test_id}')
+ assert '200' in res.status
+
+
+def test_get_topology_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get('/topologies/1').status
+
+
+def test_get_topology_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': []
+ })
+ res = client.get(f'/topologies/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_topology_no_authorizations(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []})
+ res = client.get(f'/topologies/{test_id}')
+ assert '403' in res.status
+
+
+def test_update_topology_missing_parameter(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': []
+ })
+ assert '400' in client.put(f'/topologies/{test_id}').status
+
+
+def test_update_topology_non_existent(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.put(f'/topologies/{test_id}', json={'topology': {'name': 'test_topology', 'rooms': []}}).status
+
+
+def test_update_topology_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': []
+ })
+ mocker.patch.object(db, 'update', return_value={})
+ assert '403' in client.put(f'/topologies/{test_id}', json={
+ 'topology': {
+ 'name': 'updated_topology',
+ 'rooms': []
+ }
+ }).status
+
+
+def test_update_topology(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ mocker.patch.object(db, 'update', return_value={})
+
+ assert '200' in client.put(f'/topologies/{test_id}', json={
+ 'topology': {
+ 'name': 'updated_topology',
+ 'rooms': []
+ }
+ }).status
+
+
+def test_delete_topology(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'googleId': 'test',
+ 'topologyIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ mocker.patch.object(db, 'delete_one', return_value={})
+ mocker.patch.object(db, 'update', return_value=None)
+ res = client.delete(f'/topologies/{test_id}')
+ assert '200' in res.status
+
+
+def test_delete_nonexistent_topology(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.delete(f'/topologies/{test_id}').status
diff --git a/opendc-web/opendc-web-api/tests/api/test_traces.py b/opendc-web/opendc-web-api/tests/api/test_traces.py
new file mode 100644
index 00000000..0b252c2f
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_traces.py
@@ -0,0 +1,40 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from opendc.exts import db
+
+test_id = 24 * '1'
+
+
+def test_get_traces(client, mocker):
+ mocker.patch.object(db, 'fetch_all', return_value=[])
+ assert '200' in client.get('/traces/').status
+
+
+def test_get_trace_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/traces/{test_id}').status
+
+
+def test_get_trace(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'name': 'test trace'})
+ res = client.get(f'/traces/{test_id}')
+ assert 'name' in res.json['data']
+ assert '200' in res.status
diff --git a/opendc-web/opendc-web-ui/src/shapes.js b/opendc-web/opendc-web-ui/src/shapes.js
index 621c7d25..9aeb99d8 100644
--- a/opendc-web/opendc-web-ui/src/shapes.js
+++ b/opendc-web/opendc-web-ui/src/shapes.js
@@ -23,7 +23,7 @@ export const Authorization = PropTypes.shape({
user: User,
projectId: PropTypes.string.isRequired,
project: Project,
- authorizationLevel: PropTypes.string.isRequired,
+ level: PropTypes.string.isRequired,
})
export const ProcessingUnit = PropTypes.shape({